diff --git a/docs/user/cli-reference/03-env.md b/docs/user/cli-reference/03-env.md index 408102d8..fe62ee83 100644 --- a/docs/user/cli-reference/03-env.md +++ b/docs/user/cli-reference/03-env.md @@ -84,17 +84,33 @@ devbase env get AWS_PROFILE 環境変数を削除します。 ``` -devbase env delete KEY +devbase env delete KEY [-p] +``` + +| オプション | 説明 | +|-----------|------| +| `-p` | プロジェクト設定から削除(デフォルトはグローバル)。`projects/` 配下で実行してください | + +```bash +# グローバルから削除 +devbase env delete OLD_API_KEY + +# カレントプロジェクトの設定から削除 +devbase env delete GCP_ACTIVE_PROFILE -p ``` ## `devbase env edit` -デフォルトエディタで `.env` ファイルを開きます。 +デフォルトエディタで設定を開きます。設定が暗号化されている場合は、復号した内容を一時ファイルで編集し、保存時に再暗号化します。 ``` -devbase env edit +devbase env edit [-p] ``` +| オプション | 説明 | +|-----------|------| +| `-p` | カレントプロジェクトの設定を開く(デフォルトはグローバル)。`projects/` 配下で実行してください | + ## `devbase env project` プロジェクト固有の環境変数を対話式で設定します。 @@ -103,6 +119,39 @@ devbase env edit devbase env project ``` +## `devbase env keygen` + +設定の暗号化に使う devbase 専用の age 鍵を生成します。鍵ファイルは `0600`、置き場のディレクトリは `0700` で作成されます。 + +``` +devbase env keygen [--force] [-y|--yes] +``` + +| オプション | 説明 | +|-----------|------| +| `--force` | 既存の鍵を作り直す。**旧鍵でしか復号できない機密は失われます** | +| `-y`, `--yes` | `--force` 時の確認プロンプトを省略(CI 等での自動実行用) | + +鍵の場所は次のとおりで、コマンドラインからは指定できません(生成先と復号時の探索先を必ず一致させるため)。別の場所に置きたい場合は `DEVBASE_AGE_KEY_FILE` を設定してから実行します。 + +| 指定 | 鍵ファイルのパス | +|-----|-----------------| +| 既定 | `~/.config/devbase/age/keys.txt`(`XDG_CONFIG_HOME` があればその配下) | +| `DEVBASE_AGE_KEY_FILE` | 指定したパスをそのまま使用 | + +```bash +# 既定の場所に生成する(既に鍵があれば公開鍵を表示するだけで何もしない) +devbase env keygen + +# 置き場を変えて生成する +DEVBASE_AGE_KEY_FILE=~/keys/devbase-age.txt devbase env keygen + +# 既存の鍵を捨てて作り直す(確認プロンプトあり) +devbase env keygen --force +``` + +> **鍵のバックアップは必須です。** この鍵を失うと、暗号化した機密は誰にも復号できません(devbase 側にも復旧手段はありません)。生成後に表示される鍵ファイルを、パスワード管理ツールなど端末とは別の場所へ必ず複製してください。鍵は全ワークスペース共通のため、`--force` で作り直すと他のワークスペースで暗号化した機密も復号できなくなります。 + ## `devbase env export` 複数プロジェクトの `.env` 群を暗号化したまま 1 つのバンドルにまとめて書き出します。 diff --git a/docs/user/cli-reference/README.md b/docs/user/cli-reference/README.md index b81e0ad2..50343e49 100644 --- a/docs/user/cli-reference/README.md +++ b/docs/user/cli-reference/README.md @@ -6,7 +6,7 @@ devbase の全コマンドの構文、オプション、使用例をまとめた |---------|------| | [トップレベルコマンド](01-toplevel.md) | `init` / `status` / `bin/rc` | | [project グループ](02-project.md) | コンテナのライフサイクル管理・一覧(`up` / `down` / `login` / `ps` / `logs` / `scale` / `build` / `rebuild` / `list`)と非推奨の `container` グループ | -| [env グループ](03-env.md) | 環境変数の管理(`init` / `sync` / `list` / `set` / `get` / `delete` / `edit` / `project` / `export` / `import`) | +| [env グループ](03-env.md) | 環境変数の管理(`init` / `sync` / `list` / `set` / `get` / `delete` / `edit` / `project` / `keygen` / `export` / `import`) | | [plugin グループ](04-plugin.md) | プラグインの管理(`list` / `install` / `uninstall` / `update` / `info` / `sync` / `migrate` / `repo *`) | | [snapshot グループ](05-snapshot.md) | スナップショットの管理(`create` / `list` / `restore` / `copy` / `delete` / `rotate`) | diff --git a/etc/_devbase b/etc/_devbase index 74464f9f..d6ae39e5 100644 --- a/etc/_devbase +++ b/etc/_devbase @@ -245,9 +245,18 @@ _devbase() { '1:assignment:' \ '--project[Set in project .env]' '-p[Set in project .env]' ;; - get|delete) + get) _arguments '1:key:' ;; + delete) + _arguments \ + '1:key:' \ + '--project[Delete from project .env]' '-p[Delete from project .env]' + ;; + edit) + _arguments \ + '--project[Edit project .env]' '-p[Edit project .env]' + ;; export) _arguments \ '1:dest:_files' \ diff --git a/etc/devbase-completion.bash b/etc/devbase-completion.bash index f3f9c180..e204ac3a 100644 --- a/etc/devbase-completion.bash +++ b/etc/devbase-completion.bash @@ -162,7 +162,7 @@ _devbase_completions() { COMPREPLY=($(compgen -W "--global -g --project -p --reveal -r --keys -k" -- "$cur")) fi ;; - set) + set|delete|edit) if [[ "$cur" == -* ]]; then COMPREPLY=($(compgen -W "--project -p" -- "$cur")) fi diff --git a/issues/plan35.md b/issues/plan35.md index 83da6dd2..c58c42cb 100644 --- a/issues/plan35.md +++ b/issues/plan35.md @@ -268,8 +268,8 @@ release branch: `release/PLAN35` / base branch: `main` | PR # | branch 名 | 概要 | 対応する段階 | 依存 | 並行可否 | |---|---|---|---|---|---| | 1 | `feature/PLAN35-secret-store` | 秘密ストアの抽象層 + age 実装 + 鍵生成 (`devbase env keygen`) と受信者管理 | 段階 1 | なし | ○ | -| 2 | `feature/PLAN35-env-commands` | 設定操作コマンド群 (`init`/`sync`/`set`/`get`/`delete`/`edit`/`list`) の保存先切替、`encrypt`/`decrypt` 移行コマンド | 段階 2・5(移行) | PR1 | × | -| 3 | `feature/PLAN35-runtime` | 起動ラッパーの機密読み込み廃止、`env exec` によるホスト側処理への注入、変数名のみを列挙する構成生成、プロジェクト構成の移行 | 段階 3・4 | PR2 | × | +| 2 | `feature/PLAN35-env-commands` | 設定操作コマンド群 (`init`/`sync`/`set`/`get`/`delete`/`edit`/`list`) の保存先切替 | 段階 2 | PR1 | × | +| 3 | `feature/PLAN35-runtime` | 起動ラッパーの機密読み込み廃止、`env exec` によるホスト側処理への注入、変数名のみを列挙する構成生成、`encrypt`/`decrypt` による移行 | 段階 3・4・5(移行) | PR2 | × | | 4 | `feature/PLAN35-ops-docs` | `rekey` / `doctor`、除外設定の修正、取り込みバックアップの暗号化、ドキュメント | 段階 5(残り) | PR3 | × | 段階 6 (SOPS を差し替え先として実装) は本 release のスコープ外とし、運用要件が出た時点で別 plan に切り出す。 @@ -278,6 +278,8 @@ release branch: `release/PLAN35` / base branch: `main` 4 本すべてが `lib/devbase/env/` の同一層を触るため、worktree による並行開発の利得よりコンフリクト解消のコストが上回る。PR1 の抽象層が確定しないと PR2 の保存先切替は書けず、PR2 の読み込み経路が確定しないと PR3 の注入経路は書けない。したがって「PR n を release へ merge → PR n+1 を release から切る」の直列で進める。 +移行コマンド (`encrypt` / `decrypt`) は当初 PR2 に置く想定だったが、PR3 へ移した。共通の機密を暗号化した時点で平文ファイルは消えるため、コンテナ構成がそのファイルを参照したままだと起動が失敗する。移行手段と、それを受け止める起動経路の変更は同じ PR に入っていないと、その PR だけを取り込んだ状態が壊れる。 + ### 11.2 ホスト側で機密を必要とする処理の洗い出し(段階 3 の必須事前作業) `bin/devbase` が `set -a; source $DEVBASE_ROOT/.env` で読み込んだ値に依存するホスト側処理は、実測で以下の 2 系統のみだった。 diff --git a/lib/devbase/cli.py b/lib/devbase/cli.py index c36b81be..17a37d3b 100644 --- a/lib/devbase/cli.py +++ b/lib/devbase/cli.py @@ -275,10 +275,17 @@ def _add_env_parser(subparsers): env_get = env_sub.add_parser('get', help='Get a variable') env_get.add_argument('key', help='Variable name') + # delete / edit の --project は set と対。設定が暗号化されると利用者がエディタで + # 直接開いて消せなくなるため、プロジェクト設定を CLI から掃除する経路を残す。 env_delete = env_sub.add_parser('delete', help='Delete a variable') env_delete.add_argument('key', help='Variable name') + env_delete.add_argument('--project', '-p', action='store_true', + help='Delete from project .env') + + env_edit = env_sub.add_parser('edit', help='Open .env in editor') + env_edit.add_argument('--project', '-p', action='store_true', + help='Edit project .env') - env_sub.add_parser('edit', help='Open .env in editor') env_sub.add_parser('project', help='Setup project-specific variables') # 生成先を選ぶオプションは置かない。復号側は $DEVBASE_AGE_KEY_FILE か既定パスしか diff --git a/lib/devbase/commands/env.py b/lib/devbase/commands/env.py index 40e12dca..e66fcffd 100644 --- a/lib/devbase/commands/env.py +++ b/lib/devbase/commands/env.py @@ -3,6 +3,7 @@ import os import subprocess from pathlib import Path +from typing import Optional import yaml @@ -15,6 +16,108 @@ logger = get_logger(__name__) +# --------------------------------------------------------------------------- +# 保存先の解決 +# --------------------------------------------------------------------------- +# +# 設定の実体は秘密ストア (devbase.env.secret_store) が持ち、平文か暗号化かは +# ファイルの存在から自動判定される。以下のヘルパは「どの参照を扱うか」だけを決め、 +# 各コマンドは保存形式を意識せず EnvFile 互換の操作で読み書きする。 + +def _secret_store(devbase_root: Path): + from devbase.env.secret_store import SecretStore + + return SecretStore(devbase_root) + + +def _global_env(devbase_root: Path): + """共通設定のビューを返す""" + from devbase.env.secret_store import SecretRef + from devbase.env.secret_view import SecretEnvFile + + return SecretEnvFile(_secret_store(devbase_root), SecretRef.for_global()) + + +def _current_project_name(devbase_root: Path, cwd: Optional[Path] = None) -> Optional[str]: + """CWD が ``projects/`` 配下ならプロジェクト名を返す。 + + ``projects//sub/dir`` のような下位ディレクトリから実行された場合も + ```` を返す。保存先はプロジェクトの直下に固定したい (コンテナ構成が + 参照するのはそこであり、実行時の CWD ではない) ため、末尾ではなく先頭の + パス要素を採用する。 + + 判定は論理パス → 物理パスの順に 2 段で行う。両方が要るのは: + + - ``.resolve()`` だけだと、プラグイン経由で ``projects/`` が + シンボリックリンクになっているプロジェクト配下で実行したときに + リンク先の実体を指してしまい、``projects/`` の外と判定される。 + - 論理パスだけだと、リンク先の実体パスで入ったときに ``projects/`` 配下と + 判定できない。 + + ``PWD`` 由来のパスはシェルがシンボリックリンクを保った論理パスなので、 + まず ``resolve()`` せずそのまま突き合わせる。 + + 2 段で使う正規化が違うのは、それぞれ守りたい性質が違うため: + + - 論理パス側は ``os.path.abspath`` (= ``normpath``) で ``..`` を **文字列として** + 畳む。シンボリックリンクを解いてしまうと上記の症状が戻るので解かない。一方 + ``..`` を畳まないと ``projects/web/../../outside`` のような + ``projects/`` の外を指すパスが ``relative_to`` を通ってしまい、プロジェクト外 + からの ``--project`` が ``web`` の設定を書き換える。``..`` を textual に畳む + のはシェルの ``cd`` / ``PWD`` の意味論そのものなので、論理パス扱いと矛盾しない。 + - 物理パス側は ``.resolve()`` でリンクも ``..`` も実体まで解く。こちらは + 「実体パスで入られた場合」を拾うためのフォールバックなので、リンクを + 保つ理由が無い。 + """ + current = Path(cwd) if cwd is not None else Path(os.environ.get('PWD', os.getcwd())) + projects_dir = Path(devbase_root) / 'projects' + + def to_logical(path: Path) -> Path: + """シンボリックリンクは解かず、絶対パス化と ``..`` の畳み込みだけ行う""" + return Path(os.path.abspath(path)) + + for to_path in (to_logical, Path.resolve): + try: + relative = to_path(current).relative_to(to_path(projects_dir)) + except (ValueError, OSError): + continue + parts = relative.parts + if parts: + return parts[0] + return None + + +def _project_env(devbase_root: Path, cwd: Optional[Path] = None): + """CWD のプロジェクト設定のビューを返す (projects/ 配下でなければ ``None``)""" + from devbase.env.secret_store import SecretRef + from devbase.env.secret_view import SecretEnvFile + + name = _current_project_name(devbase_root, cwd) + if name is None: + return None + return SecretEnvFile(_secret_store(devbase_root), SecretRef.for_project(name)) + + +def _target_env(devbase_root: Path, project: bool): + """``--project`` の有無から操作対象の設定ビューを返す (解決できなければ ``None``)。 + + ``projects/`` 配下でない場所での ``--project`` は、どのプロジェクトの + 設定を指しているのか決められない。従来は CWD に ``.env`` を作っていたが、 + コンテナが読む先とは限らないため明示的に断る。 + + set / delete / edit の 3 つが同じ判断とエラー文言を持つ必要があるので、 + ここへ集約して振る舞いがずれないようにする。 + """ + if not project: + return _global_env(devbase_root) + + env_file = _project_env(devbase_root) + if env_file is None: + logger.error( + "--project は $DEVBASE_ROOT/projects/ 配下で実行してください") + return env_file + + def cmd_env(devbase_root: Path, args) -> int: """envサブコマンドの振り分け""" subcmd = getattr(args, 'subcommand', None) @@ -30,8 +133,10 @@ def cmd_env(devbase_root: Path, args) -> int: 'set': lambda: cmd_env_set(devbase_root, getattr(args, 'assignment', ''), project=getattr(args, 'project', False)), 'get': lambda: cmd_env_get(devbase_root, getattr(args, 'key', '')), - 'delete': lambda: cmd_env_delete(devbase_root, getattr(args, 'key', '')), - 'edit': lambda: cmd_env_edit(devbase_root), + 'delete': lambda: cmd_env_delete(devbase_root, getattr(args, 'key', ''), + project=getattr(args, 'project', False)), + 'edit': lambda: cmd_env_edit(devbase_root, + project=getattr(args, 'project', False)), 'project': lambda: cmd_env_project(devbase_root), 'export': lambda: cmd_env_export(devbase_root, args), 'import': lambda: cmd_env_import(devbase_root, args), @@ -50,8 +155,7 @@ def cmd_env(devbase_root: Path, args) -> int: def cmd_env_init(devbase_root: Path, reset: bool = False) -> int: """全体環境の初期セットアップ(対話式)""" - env_path = devbase_root / '.env' - env_file = EnvFile(env_path) + env_file = _global_env(devbase_root) env_file.load() if env_file.count() > 0 and not reset: @@ -60,11 +164,9 @@ def cmd_env_init(devbase_root: Path, reset: bool = False) -> int: print(" やり直し: devbase env init --reset") return 0 - if reset and env_path.exists(): + if reset and env_file.file_exists(): env_file.backup() logger.info("既存の設定をバックアップしました") - env_file = EnvFile(env_path) - env_file.load() for key in list(env_file.get_all().keys()): env_file.delete(key) @@ -83,14 +185,13 @@ def cmd_env_init(devbase_root: Path, reset: bool = False) -> int: _update_source_metadata(devbase_root, env_file) - logger.info("セットアップ完了: %s (%d変数)", env_path, env_file.count()) + logger.info("セットアップ完了: %s (%d変数)", env_file.path, env_file.count()) return 0 def cmd_env_sync(devbase_root: Path) -> int: """ソースファイルから認証情報を再同期する""" - env_path = devbase_root / '.env' - env_file = EnvFile(env_path) + env_file = _global_env(devbase_root) env_file.load() sources = SourcesManager(devbase_root) @@ -221,37 +322,31 @@ def cmd_env_list(devbase_root: Path, global_only: bool = False, keys_only: bool = False) -> int: """設定済み変数の一覧表示""" if not project_only: - env_path = devbase_root / '.env' - env_file = EnvFile(env_path) - env_file.load() + env_file = _global_env(devbase_root) all_vars = env_file.get_all() - print(f"\n=== グローバル ({env_path}) ===") + print(f"\n=== グローバル ({env_file.path}{_mode_suffix(env_file)}) ===") _print_env_vars(all_vars, keys_only, reveal) print(f"\nグローバル: {len(all_vars)}変数") if not global_only: - current_dir = Path(os.environ.get('PWD', os.getcwd())) - projects_dir = devbase_root / 'projects' + proj_env = _project_env(devbase_root) + if proj_env is not None and proj_env.file_exists(): + proj_vars = proj_env.get_all() - try: - current_dir.relative_to(projects_dir) - except ValueError: - pass - else: - project_env_path = current_dir / '.env' - if project_env_path.exists(): - proj_env = EnvFile(project_env_path) - proj_env.load() - proj_vars = proj_env.get_all() - - print(f"\n=== プロジェクト: {current_dir.name} ({project_env_path}) ===") - _print_env_vars(proj_vars, keys_only, reveal) - print(f"\nプロジェクト: {len(proj_vars)}変数") + print(f"\n=== プロジェクト: {proj_env.ref.name} " + f"({proj_env.path}{_mode_suffix(proj_env)}) ===") + _print_env_vars(proj_vars, keys_only, reveal) + print(f"\nプロジェクト: {len(proj_vars)}変数") return 0 +def _mode_suffix(env_file) -> str: + """一覧表示で保存形式を示す接尾辞。平文のときは何も足さない。""" + return ' [暗号化]' if env_file.is_encrypted() else '' + + def _format_value(key: str, value: str, reveal: bool) -> str: """表示用に値をフォーマットする""" sensitive_patterns = ('KEY', 'SECRET', 'TOKEN', 'PASSWORD', 'CREDENTIALS', 'BASE64') @@ -276,36 +371,26 @@ def cmd_env_set(devbase_root: Path, assignment: str, project: bool = False) -> i logger.error("キー名が空です") return 1 - if project: - env_path = Path(os.environ.get('PWD', os.getcwd())) / '.env' - else: - env_path = devbase_root / '.env' + env_file = _target_env(devbase_root, project) + if env_file is None: + return 1 - env_file = EnvFile(env_path) - env_file.load() env_file.set(key, value) env_file.save() - logger.info("%s を設定しました", key) + logger.info("%s を設定しました (%s)", key, env_file.path) return 0 def cmd_env_get(devbase_root: Path, key: str) -> int: """変数の値を取得する""" - env_path = devbase_root / '.env' - env_file = EnvFile(env_path) - env_file.load() - - value = env_file.get(key) + value = _global_env(devbase_root).get(key) if value is not None: print(value) return 0 - current_dir = Path(os.environ.get('PWD', os.getcwd())) - project_env_path = current_dir / '.env' - if project_env_path.exists() and project_env_path != env_path: - proj_env = EnvFile(project_env_path) - proj_env.load() + proj_env = _project_env(devbase_root) + if proj_env is not None and proj_env.file_exists(): value = proj_env.get(key) if value is not None: print(value) @@ -315,44 +400,112 @@ def cmd_env_get(devbase_root: Path, key: str) -> int: return 1 -def cmd_env_delete(devbase_root: Path, key: str) -> int: - """変数を削除する""" - env_path = devbase_root / '.env' - env_file = EnvFile(env_path) - env_file.load() +def cmd_env_delete(devbase_root: Path, key: str, project: bool = False) -> int: + """変数を削除する + + ``--project`` を受けるのは、暗号化された設定は利用者がエディタで直接開いて + 不要なキーを消せないため。CLI からプロジェクト設定を掃除する手段が要る。 + """ + env_file = _target_env(devbase_root, project) + if env_file is None: + return 1 if env_file.delete(key): env_file.save() - logger.info("%s を削除しました", key) + logger.info("%s を削除しました (%s)", key, env_file.path) return 0 logger.error("変数 '%s' は存在しません", key) return 1 -def cmd_env_edit(devbase_root: Path) -> int: - """エディタで.envを開く""" - env_path = devbase_root / '.env' +def cmd_env_edit(devbase_root: Path, project: bool = False) -> int: + """エディタで.envを開く + + ``--project`` を受けるのは delete と同じ理由。暗号化されていれば + ``_edit_encrypted`` 経由で復号 → 編集 → 再暗号化する。 + """ + env_file = _target_env(devbase_root, project) + if env_file is None: + return 1 + editor = os.environ.get('EDITOR', 'vi') - return subprocess.call([editor, str(env_path)]) + if not env_file.is_encrypted(): + return subprocess.call([editor, str(env_file.path)]) -def cmd_env_project(devbase_root: Path) -> int: - """プロジェクト固有変数の設定(対話式)""" - current_dir = Path(os.environ.get('PWD', os.getcwd())) - projects_dir = devbase_root / 'projects' + return _edit_encrypted(env_file, editor) + + +def _edit_encrypted(env_file, editor: str) -> int: + """暗号化された設定を、平文を残さずにエディタで編集する。 + + エディタは平文のファイルしか開けないため、復号結果を一時ファイルへ書いて + 編集させ、保存後に暗号化し直してから消す。一時ファイルは自分専用の + ``0700`` ディレクトリに ``0600`` で作り、正常終了でも異常終了でも + ``finally`` で必ず削除する。 + + ここだけは平文が一瞬ディスクに載る。エディタの外部プロセスに値を渡す方法が + 他に無いためで、恒久的な平文ファイルを作らないという方針の例外として扱う + (plan35 §7 の「守れないもの」に対応する)。 + """ + import shutil + import tempfile + + from devbase.env import io_common as _io_common + from devbase.errors import DevbaseError try: - current_dir.relative_to(projects_dir) - except ValueError: - logger.error("projects/ 配下で実行してください") + data = env_file.get_all() + except DevbaseError as e: + logger.error("%s", e) return 1 - project_name = current_dir.name + workdir = Path(tempfile.mkdtemp(prefix='devbase-env-')) + tmp_path = workdir / '.env' + try: + _io_common.write_secure_bytes(tmp_path, EnvFile.dump_bytes(data)) + before = tmp_path.read_bytes() + + rc = subprocess.call([editor, str(tmp_path)]) + if rc != 0: + logger.error("エディタが異常終了したため保存しません (exit=%d)", rc) + return rc + + after = tmp_path.read_bytes() + if after == before: + logger.info("変更はありません") + return 0 + + try: + edited = EnvFile.parse_bytes(after) + except UnicodeDecodeError as e: + logger.error("編集結果を UTF-8 として読めませんでした: %s", e) + return 1 + + for key in list(env_file.get_all()): + env_file.delete(key) + for key, value in edited.items(): + env_file.set(key, value) + env_file.save() + logger.info("保存しました: %s (%d変数)", env_file.path, env_file.count()) + return 0 + except DevbaseError as e: + logger.error("%s", e) + return 1 + finally: + shutil.rmtree(workdir, ignore_errors=True) + + +def cmd_env_project(devbase_root: Path) -> int: + """プロジェクト固有変数の設定(対話式)""" + env_file = _project_env(devbase_root) + if env_file is None: + logger.error("projects/ 配下で実行してください") + return 1 - env_yml_path = current_dir / 'env.yml' - env_path = current_dir / '.env' - env_file = EnvFile(env_path) + project_name = env_file.ref.name + env_yml_path = Path(devbase_root) / 'projects' / project_name / 'env.yml' env_file.load() print(f"\n=== {project_name} プロジェクト環境変数 ===") @@ -409,7 +562,7 @@ def cmd_env_project(devbase_root: Path) -> int: pass env_file.save() - logger.info("保存完了: %s (%d変数)", env_path, env_file.count()) + logger.info("保存完了: %s (%d変数)", env_file.path, env_file.count()) return 0 diff --git a/lib/devbase/env/agekeys.py b/lib/devbase/env/agekeys.py index 0728bcf7..9f19dda1 100644 --- a/lib/devbase/env/agekeys.py +++ b/lib/devbase/env/agekeys.py @@ -11,7 +11,6 @@ from __future__ import annotations import os -import stat from datetime import datetime, timezone from pathlib import Path from typing import List, Optional, Tuple @@ -72,60 +71,15 @@ def recipients_file(devbase_root: Path) -> Path: # --------------------------------------------------------------------------- def _ensure_private_dir(path: Path) -> None: - """ディレクトリを用意し、**自分が新規作成した階層だけ** ``0700`` にする。 - - 既存ディレクトリまで chmod すると、``DEVBASE_AGE_KEY_FILE=/tmp/devbase-key`` - のように共有ディレクトリを鍵の置き場に指定されたとき、その共有ディレクトリ - ごと他ユーザーやサービスから読めなくしてしまう。devbase が作っていない - ディレクトリの権限はその所有者の管轄なので触らず、緩い場合は警告に留める。 - - ``mkdir(parents=True)`` で一括作成してから chmod すると、作成から chmod まで - の間だけ umask 依存の緩い権限 (例 0755) が見えてしまう。その隙に開いた fd は - 後から chmod しても閉じないため、**作成前に** 未存在の階層を控えておき、親→子 - の順に ``mkdir(mode=0o700)`` で 1 階層ずつ作る。こうすれば最初から 0700 で、 - 緩い権限が一瞬も露出しない。 - - ``mode`` は umask でビットが削られることはあっても広がることはなく、``0o700`` - には group / other ビットが無いので umask の影響を受けない。「umask で緩く - なるのでは」と後追いの chmod を足す必要は無い。 - """ - path = Path(path) - missing: List[Path] = [] - probe = path - while not probe.exists(): - missing.append(probe) - parent = probe.parent - if parent == probe: # ルートまで到達 (通常は起こらない) - break - probe = parent - - if not missing: - _warn_if_world_accessible(path) - return - - # missing は子→親の順に積んであるので、逆順 (親→子) に作る - for target in reversed(missing): - try: - target.mkdir(mode=0o700) - except FileExistsError: - # 並行して他プロセスが先に作った場合。既存ディレクトリは - # 所有者の管轄として権限を触らない方針に合わせ、chmod しない。 - continue + """鍵 / 受信者リストの置き場を ``0700`` で用意する。 - -def _warn_if_world_accessible(path: Path) -> None: - """既存ディレクトリの権限が緩ければ警告する (権限は変更しない)""" - try: - mode = stat.S_IMODE(path.stat().st_mode) - except OSError: - return - if mode & 0o077: - logger.warning( - "%s は他ユーザーからアクセスできます (mode %04o)。" - "devbase が作成したディレクトリではないため権限は変更しません。" - "機密を置く場所なら chmod 700 を検討してください", - path, mode, - ) + 実装は ``io_common.ensure_private_dir`` にある。機密ファイルの書き出し + (``write_secure_bytes``) と同じ規則でディレクトリを掘る必要があり、実装を + 2 箇所に持つと片方だけ緩む。ここでは「置き場を利用者が明示的に選べる経路」 + なので、既存ディレクトリが緩いときの警告を有効にして呼ぶ + (``DEVBASE_AGE_KEY_FILE`` に共有ディレクトリを指された場合に気づけるように)。 + """ + _io_common.ensure_private_dir(path, warn_if_permissive=True) def _key_exists_error(path: Path) -> AgeKeyError: diff --git a/lib/devbase/env/io_common.py b/lib/devbase/env/io_common.py index b6e16a35..ec078608 100644 --- a/lib/devbase/env/io_common.py +++ b/lib/devbase/env/io_common.py @@ -3,12 +3,17 @@ io_export / io_import の両方で必要になる「ファイル不在を許容する passphrase 読み取り」 「省略時の既定 age 鍵 fallback」「0600 でセキュアにバイト列を書き出す」処理を 1 箇所に集約する。 + +書き出し先ディレクトリを掘る ``ensure_private_dir`` もここに置く。鍵ファイル +(``agekeys``) と機密の保存先 (``secrets/`` など) で同じ規則を使う必要があり、 +実装が二重にあると片方だけ緩むため。 """ from __future__ import annotations import getpass import os +import stat import sys import tempfile from pathlib import Path @@ -88,6 +93,69 @@ def resolve_identity_specs(specs: Sequence[str]) -> List[str]: return found +def ensure_private_dir(path: Path, *, warn_if_permissive: bool = False) -> None: + """ディレクトリを用意し、**自分が新規作成した階層だけ** ``0700`` にする。 + + 既存ディレクトリまで chmod すると、``DEVBASE_AGE_KEY_FILE=/tmp/devbase-key`` + のように共有ディレクトリを鍵の置き場に指定されたとき、その共有ディレクトリ + ごと他ユーザーやサービスから読めなくしてしまう。devbase が作っていない + ディレクトリの権限はその所有者の管轄なので触らず、緩い場合は警告に留める。 + + ``mkdir(parents=True)`` で一括作成してから chmod すると、作成から chmod まで + の間だけ umask 依存の緩い権限 (例 0755) が見えてしまう。その隙に開いた fd は + 後から chmod しても閉じないため、**作成前に** 未存在の階層を控えておき、親→子 + の順に ``mkdir(mode=0o700)`` で 1 階層ずつ作る。こうすれば最初から 0700 で、 + 緩い権限が一瞬も露出しない。 + + ``mode`` は umask でビットが削られることはあっても広がることはなく、``0o700`` + には group / other ビットが無いので umask の影響を受けない。「umask で緩く + なるのでは」と後追いの chmod を足す必要は無い。 + + ``warn_if_permissive`` は既定で無効。鍵ファイルのように置き場を利用者が + 明示的に選ぶ経路では警告する価値があるが、``write_secure_bytes`` は + ``$DEVBASE_ROOT`` 直下や export 先の CWD のような「緩くて当たり前」の + ディレクトリにも書くため、常に鳴らすと本当の警告が埋もれる。 + """ + path = Path(path) + missing: List[Path] = [] + probe = path + while not probe.exists(): + missing.append(probe) + parent = probe.parent + if parent == probe: # ルートまで到達 (通常は起こらない) + break + probe = parent + + if not missing: + if warn_if_permissive: + warn_if_world_accessible(path) + return + + # missing は子→親の順に積んであるので、逆順 (親→子) に作る + for target in reversed(missing): + try: + target.mkdir(mode=0o700) + except FileExistsError: + # 並行して他プロセスが先に作った場合。既存ディレクトリは + # 所有者の管轄として権限を触らない方針に合わせ、chmod しない。 + continue + + +def warn_if_world_accessible(path: Path) -> None: + """既存ディレクトリの権限が緩ければ警告する (権限は変更しない)""" + try: + mode = stat.S_IMODE(path.stat().st_mode) + except OSError: + return + if mode & 0o077: + logger.warning( + "%s は他ユーザーからアクセスできます (mode %04o)。" + "devbase が作成したディレクトリではないため権限は変更しません。" + "機密を置く場所なら chmod 700 を検討してください", + path, mode, + ) + + def write_secure_bytes(path: Path, data: bytes, *, mode: int = 0o600) -> None: """``path`` に ``data`` を書き出す (新規・既存どちらも ``mode`` を強制)。 @@ -99,8 +167,13 @@ def write_secure_bytes(path: Path, data: bytes, *, mode: int = 0o600) -> None: - mode 引数が無視される環境 (Windows 等) のため後追いでも ``chmod`` を試みる ``chmod`` が失敗するプラットフォームでは例外を握りつぶす (主に Windows)。 + + 親ディレクトリを新規に掘る場合は ``ensure_private_dir`` に任せて ``0700`` に + する。``mkdir`` の既定は umask 依存で、``secrets/`` のような機密の置き場が + 0755 で生まれうるため (ファイルが 0600 でも、ディレクトリが読めると + ファイル名の一覧から何を保存しているかは漏れる)。 """ - path.parent.mkdir(parents=True, exist_ok=True) + ensure_private_dir(path.parent) if path.exists(): try: os.chmod(path, mode) @@ -156,8 +229,11 @@ def write_secure_bytes_atomic(path: Path, data: bytes, *, mode: int = 0o600) -> という順序にして、途中のどこで失敗しても旧内容がそのまま残るようにする。 失敗時は一時ファイルを掃除してから例外を送出する。 + + 親ディレクトリの扱いは ``write_secure_bytes`` と同じく ``ensure_private_dir`` + に任せる (新規に掘る階層だけ ``0700``、既存の権限は変えない)。 """ - path.parent.mkdir(parents=True, exist_ok=True) + ensure_private_dir(path.parent) # mkstemp は 0600 で作成するため、作成時点から権限が広がらない。 fd, tmp_name = tempfile.mkstemp( prefix=f'.{path.name}.', suffix='.tmp', dir=str(path.parent)) diff --git a/lib/devbase/env/secret_view.py b/lib/devbase/env/secret_view.py new file mode 100644 index 00000000..4560deae --- /dev/null +++ b/lib/devbase/env/secret_view.py @@ -0,0 +1,124 @@ +"""秘密ストア上の 1 参照を ``EnvFile`` と同じ操作性で扱うビュー + +設定の収集処理 (``collectors/``) や ``devbase env`` の各コマンドは、``EnvFile`` の +``get`` / ``set`` / ``save`` という素朴な API に対して書かれている。保存先が平文か +暗号化かでこれらを書き分けると、収集処理まで暗号化を意識することになる。 + +そこで ``SecretStore`` の 1 参照を ``EnvFile`` と同じ形に見せるビューを挟み、 +呼び出し側は保存先を知らないまま従来どおり書けるようにする。 +""" + +from __future__ import annotations + +from pathlib import Path +from typing import Dict, Optional + +from devbase.env.secret_store import SecretRef, SecretStore +from devbase.log import get_logger + +logger = get_logger(__name__) + + +class SecretEnvFile: + """``SecretStore`` の 1 参照を ``EnvFile`` 互換の操作で読み書きする""" + + def __init__(self, store: SecretStore, ref: SecretRef): + self._store = store + self._ref = ref + self._data: Dict[str, str] = {} + self._loaded = False + + # -- 読み書き ----------------------------------------------------------- + + def load(self) -> Dict[str, str]: + self._data = self._store.load(self._ref) + self._loaded = True + return self._data + + def save(self) -> None: + """現在の内容を保存する (保存形式は既存のものを維持する)""" + if not self._loaded: + # 一度も読んでいない状態で保存すると、既存の値を空で上書きしてしまう + self.load() + self._store.save(self._ref, self._data) + + def _ensure_loaded(self) -> None: + if not self._loaded: + self.load() + + # -- EnvFile 互換の操作 -------------------------------------------------- + + def get(self, key: str, default: Optional[str] = None) -> Optional[str]: + self._ensure_loaded() + return self._data.get(key, default) + + def set(self, key: str, value: str) -> None: + self._ensure_loaded() + self._data[key] = value + + def exists(self, key: str) -> bool: + self._ensure_loaded() + return key in self._data + + def get_all(self) -> Dict[str, str]: + self._ensure_loaded() + return self._data.copy() + + def delete(self, key: str) -> bool: + self._ensure_loaded() + if key in self._data: + del self._data[key] + return True + return False + + def count(self) -> int: + self._ensure_loaded() + return len(self._data) + + # -- 保存先の情報 -------------------------------------------------------- + + @property + def ref(self) -> SecretRef: + return self._ref + + @property + def path(self) -> Path: + """実際の保存先パス (暗号化なら ``.age`` ファイル)""" + return self._store.path(self._ref) + + @property + def file_path(self) -> Path: + """``EnvFile.file_path`` 互換のエイリアス""" + return self.path + + def mode(self) -> str: + """``'age'`` / ``'plaintext'`` / ``'absent'``""" + return self._store.mode(self._ref) + + def is_encrypted(self) -> bool: + return self._store.is_encrypted(self._ref) + + def file_exists(self) -> bool: + return self._store.exists(self._ref) + + def backup(self) -> Optional[Path]: + """保存先ファイルを ``.backup`` 付きで複製する。 + + 暗号化されている場合は暗号文のまま複製されるため、複製が新たな平文の + 滞留を生むことはない。 + """ + import shutil + + if not self.file_exists(): + return None + source = self.path + backup_path = Path(str(source) + '.backup') + try: + shutil.copy2(source, backup_path) + except OSError as e: + logger.warning("バックアップを作成できませんでした (%s): %s", backup_path, e) + return None + return backup_path + + def __repr__(self) -> str: + return f"SecretEnvFile({self._ref!r} -> {self.path})" diff --git a/tests/commands/test_env_store_switch.py b/tests/commands/test_env_store_switch.py new file mode 100644 index 00000000..4816dc9e --- /dev/null +++ b/tests/commands/test_env_store_switch.py @@ -0,0 +1,493 @@ +"""devbase env の各コマンドが秘密ストア経由で読み書きすることの検証 + +保存先が平文か暗号化かに関わらず、同じ操作で同じ結果になることを確かめる。 +""" + +from __future__ import annotations + +import pyrage +import pytest + +from devbase.commands import env as env_cmd +from devbase.env.secret_store import SecretRef, SecretStore + + +GLOBAL = SecretRef.for_global() + + +@pytest.fixture +def devbase_root(tmp_path, monkeypatch): + """projects/ を持つ空の DEVBASE_ROOT。鍵は tmp 配下に閉じ込める。""" + from devbase.env import agekeys + + (tmp_path / 'projects' / 'web').mkdir(parents=True) + monkeypatch.setenv(agekeys.KEY_FILE_ENV, str(tmp_path / 'age' / 'keys.txt')) + monkeypatch.setenv('PWD', str(tmp_path)) + monkeypatch.chdir(tmp_path) + return tmp_path + + +@pytest.fixture +def with_key(devbase_root): + """暗号化に使える devbase 専用鍵を用意する""" + from devbase.env import agekeys + + _, public = agekeys.generate_key_file() + return public + + +def encrypt_global(root, data): + """グローバル設定を暗号化状態で作る""" + SecretStore(root).age.save(GLOBAL, data) + + +def plain_global(root, data): + SecretStore(root).plaintext.save(GLOBAL, data) + + +# --------------------------------------------------------------------------- +# プロジェクト名の解決 +# --------------------------------------------------------------------------- + +def test_project_name_resolves_from_the_project_dir(devbase_root): + name = env_cmd._current_project_name(devbase_root, devbase_root / 'projects' / 'web') + assert name == 'web' + + +def test_project_name_resolves_from_a_subdirectory(devbase_root): + sub = devbase_root / 'projects' / 'web' / 'src' / 'deep' + sub.mkdir(parents=True) + assert env_cmd._current_project_name(devbase_root, sub) == 'web' + + +def test_project_name_is_none_outside_projects(devbase_root): + assert env_cmd._current_project_name(devbase_root, devbase_root) is None + + +@pytest.fixture +def linked_project(devbase_root, tmp_path): + """``projects/linked`` を tmp 配下の実体へのシンボリックリンクとして作る。 + + プラグイン経由のプロジェクト (projects/ -> plugins/...) の再現。 + """ + target = tmp_path / 'link-target' + (target / 'sub').mkdir(parents=True) + (devbase_root / 'projects' / 'linked').symlink_to(target) + return target + + +def test_project_name_resolves_inside_a_symlinked_project(devbase_root, linked_project): + """リンク経由の論理パスで入ったらプロジェクト名が取れる""" + link = devbase_root / 'projects' / 'linked' + assert env_cmd._current_project_name(devbase_root, link) == 'linked' + + +def test_project_name_resolves_in_a_symlinked_project_subdirectory(devbase_root, + linked_project): + sub = devbase_root / 'projects' / 'linked' / 'sub' + assert env_cmd._current_project_name(devbase_root, sub) == 'linked' + + +def test_project_name_is_none_from_the_symlink_target_path(devbase_root, linked_project): + """リンク先の実体パスから実行した場合は ``None``。 + + 実体は ``projects/`` の外にあり、どのリンク名から辿られたのかを一意に + 決められない (複数のリンクが同じ実体を指しうる) ため、推測せず断る。 + """ + assert env_cmd._current_project_name(devbase_root, linked_project) is None + assert env_cmd._current_project_name(devbase_root, linked_project / 'sub') is None + + +def test_project_name_resolves_through_a_dot_dot_path(devbase_root): + """``..`` を含むパスも正規化して拾える""" + sub = devbase_root / 'projects' / 'web' / 'src' + sub.mkdir(parents=True) + assert env_cmd._current_project_name(devbase_root, sub / '..' / 'src') == 'web' + + +def test_project_name_resolves_when_dot_dot_stays_inside_the_project(devbase_root): + """``projects/web/sub/..`` のようにプロジェクト内へ戻る ``..`` は web のまま""" + sub = devbase_root / 'projects' / 'web' / 'sub' + sub.mkdir(parents=True) + assert env_cmd._current_project_name(devbase_root, sub / '..') == 'web' + + +def test_project_name_is_none_when_dot_dot_escapes_projects(devbase_root): + """``..`` で ``projects/`` の外へ抜けるパスは ``None``。 + + ``..`` を畳まずに突き合わせると ``projects/web/../../outside`` が + 「``projects/web`` 配下」と誤判定され、プロジェクト外で実行した + ``--project`` が web の設定を書き換えてしまう。 + """ + outside = devbase_root / 'outside' + outside.mkdir() + escaped = devbase_root / 'projects' / 'web' / '..' / '..' / 'outside' + + assert env_cmd._current_project_name(devbase_root, escaped) is None + + +def test_project_name_is_none_when_dot_dot_escapes_from_a_deeper_path(devbase_root): + """途中の階層が実在しても ``..`` の畳み込み結果で判定する""" + (devbase_root / 'projects' / 'web' / 'src').mkdir(parents=True) + escaped = devbase_root / 'projects' / 'web' / 'src' / '..' / '..' / '..' + + assert env_cmd._current_project_name(devbase_root, escaped) is None + + +def test_project_name_handles_dot_dot_inside_a_symlinked_project(devbase_root, + linked_project): + """``..`` の畳み込みはシンボリックリンク対応を壊さない。 + + 論理パス側で ``..`` を解決しても (``resolve()`` ではなく文字列として畳む)、 + リンク名 ``linked`` は保たれる。 + """ + sub = devbase_root / 'projects' / 'linked' / 'sub' + assert env_cmd._current_project_name(devbase_root, sub / '..') == 'linked' + + +# --------------------------------------------------------------------------- +# set / get / delete +# --------------------------------------------------------------------------- + +def test_set_creates_plaintext_when_nothing_exists(devbase_root): + assert env_cmd.cmd_env_set(devbase_root, 'FOO=bar') == 0 + assert (devbase_root / '.env').exists() + assert SecretStore(devbase_root).load(GLOBAL) == {'FOO': 'bar'} + + +def test_set_writes_into_the_encrypted_store(devbase_root, with_key): + encrypt_global(devbase_root, {'FOO': 'old'}) + + assert env_cmd.cmd_env_set(devbase_root, 'FOO=new') == 0 + + assert not (devbase_root / '.env').exists() # 平文が生まれていない + store = SecretStore(devbase_root) + assert store.is_encrypted(GLOBAL) + assert store.load(GLOBAL) == {'FOO': 'new'} + + +def test_set_does_not_lose_other_keys_in_the_encrypted_store(devbase_root, with_key): + encrypt_global(devbase_root, {'KEEP': '1'}) + + env_cmd.cmd_env_set(devbase_root, 'ADDED=2') + + assert SecretStore(devbase_root).load(GLOBAL) == {'KEEP': '1', 'ADDED': '2'} + + +def test_get_reads_from_the_encrypted_store(devbase_root, with_key, capsys): + encrypt_global(devbase_root, {'TOKEN': 'secret-value'}) + + assert env_cmd.cmd_env_get(devbase_root, 'TOKEN') == 0 + assert capsys.readouterr().out.strip() == 'secret-value' + + +def test_get_falls_back_to_the_project_secrets(devbase_root, with_key, monkeypatch, capsys): + project_dir = devbase_root / 'projects' / 'web' + monkeypatch.setenv('PWD', str(project_dir)) + SecretStore(devbase_root).age.save(SecretRef.for_project('web'), {'DB': 'pw'}) + + assert env_cmd.cmd_env_get(devbase_root, 'DB') == 0 + assert capsys.readouterr().out.strip() == 'pw' + + +def test_get_reports_a_missing_key(devbase_root): + assert env_cmd.cmd_env_get(devbase_root, 'NOPE') == 1 + + +def test_delete_updates_the_encrypted_store(devbase_root, with_key): + encrypt_global(devbase_root, {'A': '1', 'B': '2'}) + + assert env_cmd.cmd_env_delete(devbase_root, 'A') == 0 + + assert SecretStore(devbase_root).load(GLOBAL) == {'B': '2'} + assert not (devbase_root / '.env').exists() + + +def test_delete_reports_a_missing_key(devbase_root, with_key): + encrypt_global(devbase_root, {'A': '1'}) + assert env_cmd.cmd_env_delete(devbase_root, 'B') == 1 + + +def test_delete_project_updates_the_encrypted_project_store(devbase_root, with_key, + monkeypatch): + """暗号化されたプロジェクト設定からも CLI でキーを消せる""" + monkeypatch.setenv('PWD', str(devbase_root / 'projects' / 'web')) + store = SecretStore(devbase_root) + store.age.save(SecretRef.for_project('web'), {'A': '1', 'B': '2'}) + encrypt_global(devbase_root, {'A': 'global'}) + + assert env_cmd.cmd_env_delete(devbase_root, 'A', project=True) == 0 + + assert store.load(SecretRef.for_project('web')) == {'B': '2'} + # グローバル側の同名キーは巻き添えにしない + assert store.load(GLOBAL) == {'A': 'global'} + assert not (devbase_root / 'projects' / 'web' / '.env').exists() + + +def test_delete_project_requires_a_project_dir(devbase_root, with_key): + encrypt_global(devbase_root, {'A': '1'}) + + assert env_cmd.cmd_env_delete(devbase_root, 'A', project=True) == 1 + + # グローバルへフォールバックしていない + assert SecretStore(devbase_root).load(GLOBAL) == {'A': '1'} + + +def test_delete_project_reports_a_missing_key(devbase_root, with_key, monkeypatch): + monkeypatch.setenv('PWD', str(devbase_root / 'projects' / 'web')) + SecretStore(devbase_root).age.save(SecretRef.for_project('web'), {'A': '1'}) + + assert env_cmd.cmd_env_delete(devbase_root, 'B', project=True) == 1 + + +def test_set_project_requires_a_project_dir(devbase_root): + assert env_cmd.cmd_env_set(devbase_root, 'FOO=bar', project=True) == 1 + assert not (devbase_root / '.env').exists() + + +def test_set_project_writes_under_the_project(devbase_root, monkeypatch): + project_dir = devbase_root / 'projects' / 'web' + monkeypatch.setenv('PWD', str(project_dir / 'src')) + (project_dir / 'src').mkdir() + + assert env_cmd.cmd_env_set(devbase_root, 'FOO=bar', project=True) == 0 + + # 下位ディレクトリで実行してもプロジェクト直下に書かれる + assert (project_dir / '.env').exists() + assert not (project_dir / 'src' / '.env').exists() + + +# --------------------------------------------------------------------------- +# list +# --------------------------------------------------------------------------- + +def test_list_marks_the_encrypted_store(devbase_root, with_key, capsys): + encrypt_global(devbase_root, {'TOKEN': 'x'}) + + env_cmd.cmd_env_list(devbase_root, global_only=True) + + out = capsys.readouterr().out + assert '[暗号化]' in out + assert 'global.env.age' in out + + +def test_list_does_not_mark_plaintext(devbase_root, capsys): + plain_global(devbase_root, {'FOO': 'bar'}) + + env_cmd.cmd_env_list(devbase_root, global_only=True) + + assert '[暗号化]' not in capsys.readouterr().out + + +def test_list_shows_project_secrets(devbase_root, with_key, monkeypatch, capsys): + monkeypatch.setenv('PWD', str(devbase_root / 'projects' / 'web')) + SecretStore(devbase_root).age.save(SecretRef.for_project('web'), {'DB': 'pw'}) + + env_cmd.cmd_env_list(devbase_root, project_only=True, keys_only=True) + + out = capsys.readouterr().out + assert 'web' in out and 'DB' in out + + +# --------------------------------------------------------------------------- +# init +# --------------------------------------------------------------------------- + +def test_init_treats_an_encrypted_store_as_already_set_up(devbase_root, with_key, capsys): + encrypt_global(devbase_root, {'FOO': 'bar'}) + + assert env_cmd.cmd_env_init(devbase_root) == 0 + assert '既にセットアップ済み' in capsys.readouterr().out + + +# --------------------------------------------------------------------------- +# edit +# --------------------------------------------------------------------------- + +def fake_editor(monkeypatch, mutate): + """EDITOR 起動を差し替えて、渡された一時ファイルを mutate させる""" + calls = [] + + def _call(argv): + path = argv[-1] + calls.append(path) + return mutate(path) + + monkeypatch.setattr(env_cmd.subprocess, 'call', _call) + return calls + + +def test_edit_opens_the_plaintext_file_directly(devbase_root, monkeypatch): + plain_global(devbase_root, {'FOO': 'bar'}) + calls = fake_editor(monkeypatch, lambda path: 0) + + assert env_cmd.cmd_env_edit(devbase_root) == 0 + assert calls == [str(devbase_root / '.env')] + + +def test_edit_reencrypts_the_result(devbase_root, with_key, monkeypatch): + from pathlib import Path + + encrypt_global(devbase_root, {'FOO': 'bar'}) + + def mutate(path): + Path(path).write_text('FOO=changed\nNEW=added\n') + return 0 + + fake_editor(monkeypatch, mutate) + + assert env_cmd.cmd_env_edit(devbase_root) == 0 + + store = SecretStore(devbase_root) + assert store.is_encrypted(GLOBAL) + assert store.load(GLOBAL) == {'FOO': 'changed', 'NEW': 'added'} + assert not (devbase_root / '.env').exists() + + +def test_edit_removes_the_temporary_plaintext(devbase_root, with_key, monkeypatch): + from pathlib import Path + + encrypt_global(devbase_root, {'FOO': 'bar'}) + seen = {} + + def mutate(path): + seen['path'] = Path(path) + assert seen['path'].exists() + assert 'bar' in seen['path'].read_text() # 復号結果が渡っている + return 0 + + fake_editor(monkeypatch, mutate) + env_cmd.cmd_env_edit(devbase_root) + + assert not seen['path'].exists() + assert not seen['path'].parent.exists() + + +def test_edit_keeps_the_stored_value_when_the_editor_fails(devbase_root, with_key, + monkeypatch): + from pathlib import Path + + encrypt_global(devbase_root, {'FOO': 'bar'}) + + def mutate(path): + Path(path).write_text('FOO=should-not-be-saved\n') + return 1 + + fake_editor(monkeypatch, mutate) + + assert env_cmd.cmd_env_edit(devbase_root) == 1 + assert SecretStore(devbase_root).load(GLOBAL) == {'FOO': 'bar'} + + +def test_edit_without_changes_leaves_the_ciphertext_alone(devbase_root, with_key, + monkeypatch, caplog): + encrypt_global(devbase_root, {'FOO': 'bar'}) + before = SecretStore(devbase_root).age.path(GLOBAL).read_bytes() + + fake_editor(monkeypatch, lambda path: 0) + + with caplog.at_level('INFO', logger='devbase'): + assert env_cmd.cmd_env_edit(devbase_root) == 0 + assert '変更はありません' in caplog.text + # 同じ内容でも再暗号化すると nonce が変わり差分が出るため、書き直していない + # ことをバイト列の同一性で確かめる + assert SecretStore(devbase_root).age.path(GLOBAL).read_bytes() == before + + +def test_edit_rejects_a_non_utf8_result(devbase_root, with_key, monkeypatch): + from pathlib import Path + + encrypt_global(devbase_root, {'FOO': 'bar'}) + + def mutate(path): + Path(path).write_bytes(b'\xff\xfe\x00broken') + return 0 + + fake_editor(monkeypatch, mutate) + + assert env_cmd.cmd_env_edit(devbase_root) == 1 + assert SecretStore(devbase_root).load(GLOBAL) == {'FOO': 'bar'} + + +def test_edit_project_reencrypts_the_project_store(devbase_root, with_key, monkeypatch): + """暗号化されたプロジェクト設定も復号 → 編集 → 再暗号化できる""" + from pathlib import Path + + monkeypatch.setenv('PWD', str(devbase_root / 'projects' / 'web')) + store = SecretStore(devbase_root) + project = SecretRef.for_project('web') + store.age.save(project, {'FOO': 'bar'}) + encrypt_global(devbase_root, {'GLOBAL_KEY': 'kept'}) + + def mutate(path): + assert 'bar' in Path(path).read_text() # 復号結果が渡っている + Path(path).write_text('FOO=changed\n') + return 0 + + fake_editor(monkeypatch, mutate) + + assert env_cmd.cmd_env_edit(devbase_root, project=True) == 0 + + assert store.is_encrypted(project) + assert store.load(project) == {'FOO': 'changed'} + assert not (devbase_root / 'projects' / 'web' / '.env').exists() + # グローバル側は触っていない + assert store.load(GLOBAL) == {'GLOBAL_KEY': 'kept'} + + +def test_edit_project_opens_the_plaintext_file_directly(devbase_root, monkeypatch): + project_dir = devbase_root / 'projects' / 'web' + monkeypatch.setenv('PWD', str(project_dir)) + SecretStore(devbase_root).plaintext.save(SecretRef.for_project('web'), {'FOO': 'bar'}) + calls = fake_editor(monkeypatch, lambda path: 0) + + assert env_cmd.cmd_env_edit(devbase_root, project=True) == 0 + assert calls == [str(project_dir / '.env')] + + +def test_edit_project_requires_a_project_dir(devbase_root, with_key, monkeypatch): + encrypt_global(devbase_root, {'FOO': 'bar'}) + calls = fake_editor(monkeypatch, lambda path: 0) + + assert env_cmd.cmd_env_edit(devbase_root, project=True) == 1 + + # エディタも起動していない (グローバルへフォールバックしていない) + assert calls == [] + + +# --------------------------------------------------------------------------- +# 両形式が同時に存在する場合 +# --------------------------------------------------------------------------- + +def test_commands_stop_when_both_formats_exist(devbase_root, with_key): + from devbase.env.secret_store import SecretStoreError + + plain_global(devbase_root, {'FOO': 'plain'}) + encrypt_global(devbase_root, {'FOO': 'encrypted'}) + + with pytest.raises(SecretStoreError, match='両方に存在'): + env_cmd.cmd_env_get(devbase_root, 'FOO') + + +# --------------------------------------------------------------------------- +# 明示的な受信者での運用 +# --------------------------------------------------------------------------- + +def test_set_encrypts_for_every_registered_recipient(devbase_root, with_key): + """受信者リストがあれば、set はそこに並ぶ全員宛に暗号化する""" + from devbase.env import agekeys + + other = pyrage.x25519.Identity.generate() + agekeys.add_recipient(devbase_root, with_key) # 自分 + agekeys.add_recipient(devbase_root, str(other.to_public())) # 同僚 + encrypt_global(devbase_root, {'FOO': 'bar'}) + + assert env_cmd.cmd_env_set(devbase_root, 'FOO=updated') == 0 + + # 自分の鍵でも + assert SecretStore(devbase_root).load(GLOBAL) == {'FOO': 'updated'} + # 同僚の鍵でも読める + other_key = devbase_root / 'other.key' + other_key.write_text(str(other)) + reader = SecretStore(devbase_root, identities=[str(other_key)]) + assert reader.load(GLOBAL) == {'FOO': 'updated'} diff --git a/tests/env/test_io_common.py b/tests/env/test_io_common.py index e47bdccb..33176c94 100644 --- a/tests/env/test_io_common.py +++ b/tests/env/test_io_common.py @@ -169,3 +169,100 @@ def boom(src, dst): assert path.read_bytes() == b"old" # 書きかけの一時ファイルも残さない assert [p.name for p in tmp_path.iterdir()] == [path.name] + + +# --------------------------------------------------------------------------- +# 親ディレクトリの権限 (ensure_private_dir) +# --------------------------------------------------------------------------- + +@pytest.fixture +def no_umask(): + """umask 0 (= 権限が一切削られない状態) で書き込ませる。 + + ``mkdir`` の既定モードは umask 依存なので、umask で偶然絞られている環境では + 「0700 を明示している」ことを確認できない。 + """ + import os + + old = os.umask(0) + try: + yield + finally: + os.umask(old) + + +@pytest.mark.parametrize("write", ["write_secure_bytes", + "write_secure_bytes_atomic"]) +def test_write_secure_bytes_creates_parent_dirs_with_0700(tmp_path, no_umask, + write): + """新規に掘る親ディレクトリは umask 0 でも 0700。 + + ファイルが 0600 でも、置き場 (``secrets/`` 等) が 0755 だと保存している + ファイル名の一覧が他ユーザーから見えてしまう。 + """ + import stat + + path = tmp_path / "secrets" / "deep" / "secret.bin" + getattr(io_common, write)(path, b"payload") + + assert path.read_bytes() == b"payload" + assert stat.S_IMODE((tmp_path / "secrets").stat().st_mode) == 0o700 + assert stat.S_IMODE(path.parent.stat().st_mode) == 0o700 + + +@pytest.mark.parametrize("write", ["write_secure_bytes", + "write_secure_bytes_atomic"]) +def test_write_secure_bytes_does_not_chmod_an_existing_dir(tmp_path, write): + """既存ディレクトリの権限は変えない。 + + export 先の CWD のように devbase が作っていないディレクトリを 0700 へ + 落とすと、他ユーザーやサービスのアクセスを壊す。 + """ + import os + import stat + + shared = tmp_path / "shared" + shared.mkdir() + os.chmod(shared, 0o755) + + getattr(io_common, write)(shared / "secret.bin", b"payload") + + assert stat.S_IMODE(shared.stat().st_mode) == 0o755 + # ディレクトリを緩いままにする代わり、ファイル自体は 0600 で守る + assert stat.S_IMODE((shared / "secret.bin").stat().st_mode) == 0o600 + + +def test_ensure_private_dir_is_quiet_about_an_existing_dir_by_default(tmp_path, + caplog): + """既定では緩い既存ディレクトリを警告しない。 + + ``$DEVBASE_ROOT`` 直下や CWD のような「緩くて当たり前」の場所へ毎回書くため、 + 常時警告すると本当の警告 (鍵の置き場が緩い等) が埋もれる。 + """ + import os + + shared = tmp_path / "shared" + shared.mkdir() + os.chmod(shared, 0o777) + + with caplog.at_level("WARNING"): + io_common.write_secure_bytes(shared / "secret.bin", b"payload") + + assert not [r for r in caplog.records if "shared" in r.getMessage()] + + +def test_ensure_private_dir_warns_when_asked(tmp_path, caplog): + """``warn_if_permissive=True`` なら緩い既存ディレクトリを警告する (agekeys 経路)""" + import os + + shared = tmp_path / "shared" + shared.mkdir() + os.chmod(shared, 0o777) + + with caplog.at_level("WARNING"): + io_common.ensure_private_dir(shared, warn_if_permissive=True) + + assert any("shared" in r.getMessage() for r in caplog.records) + # 警告するだけで権限は変えない + import stat + assert stat.S_IMODE(shared.stat().st_mode) == 0o777