diff --git a/.gitignore b/.gitignore index 8da54a50..4b3b8268 100644 --- a/.gitignore +++ b/.gitignore @@ -1,10 +1,13 @@ __pycache__/ .venv/ .env -.env.bak -.env.backup +# 日時付きの控え (.env.bak-20260807172231 等) は完全一致では弾けない。 +# 実際に未追跡のまま検出された経緯があるためワイルドカードで除外する。 +.env.bak* +.env.backup* .gemini/ -.docker-compose.scale.yml +# up 中の退避 (.docker-compose.scale.yml.prev) も含めて除外する。 +.docker-compose.scale.yml* plugins.yml repos/ plugins/*/ @@ -14,6 +17,9 @@ projects/* .env.sources.yml .cache/ +# 暗号化した機密の保存先 (PLAN35)。暗号文とはいえリポジトリには載せない。 +secrets/ + # クロスレビュー (ndf:cross-review) の作業生成物 .cross_review/ gh-payload.json diff --git a/bin/devbase b/bin/devbase index 8b2db6e2..272a391c 100755 --- a/bin/devbase +++ b/bin/devbase @@ -36,12 +36,18 @@ env_var_keys() { # Environment setup export DOCKER_GID=$( [ "$(uname)" = "Darwin" ] && echo "0" || grep docker /etc/group | cut -d: -f3 ) export COMPOSE_PROJECT_NAME=$(basename "$PWD") -# devbase root の .env (AWS / BigQuery 等の devbase ツール用変数) を読み込む。 +# devbase root の非機密設定 (env) を読み込む。 +# +# 機密 (認証情報) はここでは読まない。暗号化された機密は Python 本体が必要に +# なった時点で復号し、値を必要とする処理へ環境変数として渡す (plan35 §4.4)。 +# シェルから読める場所に機密を置かないことが暗号化の前提なので、ここで +# `${DEVBASE_ROOT}/.env` を source すると意味が無くなる。 +# # project ディレクトリで実行された場合に Laravel ランタイム用 .env を bash で # source すると、CRLF 改行や `|` / `&` 等の特殊文字を含む値で syntax error に # なる。compose は同階層の .env を自動で読むため wrapper 側で project .env を # source する必要は無い。 -[ -f "${DEVBASE_ROOT}/.env" ] && set -a && source "${DEVBASE_ROOT}/.env" && set +a +[ -f "${DEVBASE_ROOT}/env" ] && set -a && source "${DEVBASE_ROOT}/env" && set +a # 呼び出し元 (初期 CWD) の env で定義された変数キーを記録しておく。 # project 切替 (maybe_cd_project) 時に「呼び出し元プロジェクトにしか無い変数」を @@ -61,6 +67,15 @@ export DEVBASE_ROOT # Function definitions # =================================================================== +# Docker Compose の変数展開が機密を必要とする場合があるため、compose の呼び出しは +# Python 経由で機密を注入して実行する (plan35 §4.4 / §11.2)。復号結果は子プロセスの +# 環境変数としてだけ渡り、ファイルには書き出されない。 +compose_with_secrets() { + ensure_uv + PYTHONPATH="${DEVBASE_ROOT}/lib:$PYTHONPATH" \ + uv run --project "$DEVBASE_ROOT" python -m devbase.cli env exec -- "$@" +} + cmd_build() { echo "=== Building devbase images ===" @@ -154,7 +169,7 @@ cmd_build() { echo "" echo "[2/2] Building project image without cache..." - if docker compose build "${DEV_SERVICE_NAME:-dev}" --no-cache "$@"; then + if compose_with_secrets docker compose build "${DEV_SERVICE_NAME:-dev}" --no-cache "$@"; then echo "" echo "✓ All images built successfully" else @@ -176,7 +191,7 @@ cmd_build() { echo "" echo "[2/2] Building project image..." - if docker compose build "${DEV_SERVICE_NAME:-dev}" "$@"; then + if compose_with_secrets docker compose build "${DEV_SERVICE_NAME:-dev}" "$@"; then echo "" echo "✓ All images built successfully" else @@ -209,7 +224,7 @@ cmd_build() { echo "" echo "[2/2] Building project image..." - if docker compose build "${DEV_SERVICE_NAME:-dev}" "$@"; then + if compose_with_secrets docker compose build "${DEV_SERVICE_NAME:-dev}" "$@"; then echo "" echo "✓ All images built successfully" else diff --git a/containers/lfm/compose.yml b/containers/lfm/compose.yml index b9c199b4..745c2d44 100644 --- a/containers/lfm/compose.yml +++ b/containers/lfm/compose.yml @@ -5,4 +5,3 @@ services: build: context: . dockerfile: Dockerfile -c diff --git a/docs/README.md b/docs/README.md index eae57239..b108925c 100644 --- a/docs/README.md +++ b/docs/README.md @@ -46,6 +46,7 @@ graph TD | [CLI リファレンス](user/cli-reference/README.md) | 全コマンドの構文・オプション・使用例 | | [プラグインレジストリ](user/plugin-registries.md) | 公開・社内レジストリの一覧と追加方法 | | [環境変数ガイド](user/environment-variables.md) | 3レベル構造、コレクター、ソース同期 | +| [環境変数の暗号化](user/env-encryption.md) | 認証情報を暗号化して保存する / 鍵とチーム共有 / 平文へ戻す | | [コンテナ操作ガイド](user/container-operations.md) | ライフサイクル、並行開発、ボリューム構造 | | [スナップショットガイド](user/snapshot-guide.md) | 増分バックアップ、世代管理、復元手順 | | [トラブルシューティング](user/troubleshooting.md) | カテゴリ別の問題と解決策 | @@ -101,6 +102,7 @@ docs/ │ │ └── 05-snapshot.md ← snapshot グループ │ ├── plugin-registries.md ← プラグインレジストリ │ ├── environment-variables.md ← 環境変数ガイド +│ ├── env-encryption.md ← 環境変数の暗号化 │ ├── container-operations.md ← コンテナ操作ガイド │ ├── snapshot-guide.md ← スナップショットガイド │ └── troubleshooting.md ← トラブルシューティング @@ -124,6 +126,7 @@ docs/ | devbase を初めてインストールする | [はじめに](user/getting-started.md#セットアップ手順) | | コマンドの使い方を調べる | [CLI リファレンス](user/cli-reference/README.md) | | 環境変数を設定する | [環境変数ガイド](user/environment-variables.md#環境変数の操作) | +| 認証情報を暗号化して保存する | [環境変数の暗号化](user/env-encryption.md) | | 複数コンテナで並行開発する | [コンテナ操作ガイド](user/container-operations.md#並行開発) | | データをバックアップ・復元する | [スナップショットガイド](user/snapshot-guide.md) | | エラーが発生した | [トラブルシューティング](user/troubleshooting.md) | diff --git a/docs/user/cli-reference/03-env.md b/docs/user/cli-reference/03-env.md index 408102d8..878f22f3 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,166 @@ 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 encrypt` + +平文で保存されている設定を、暗号化ストア (`$DEVBASE_ROOT/secrets/`) へ移します。事前に `devbase env keygen` で鍵を作っておく必要があります。 + +``` +devbase env encrypt [--project NAME]... [--dry-run] [-y|--yes] +``` + +| オプション | 説明 | +|-----------|------| +| `--project NAME` | 対象を指定プロジェクトだけに絞る(繰り返し指定可)。指定すると共通設定は対象外になります | +| `--dry-run` | 変更内容と構成ファイルの差分を表示するだけで、何も書き換えません | +| `-y`, `--yes` | 確認プロンプトを省略 | + +実行すると次の 3 つが行われます。 + +1. 平文の設定を暗号化して `secrets/` 配下へ保存する +2. **暗号化した内容を読み戻して元と一致することを確認**してから、元の平文を `backups/env-encrypt/<日時>/` へ退避する +3. 各プロジェクトの `compose.yml` から機密ファイルの参照をコメントアウトする(元の行はコメントとして残るため、`decrypt` で復元できます) + +```bash +# 何が変わるかを先に確認する +devbase env encrypt --dry-run + +# 共通設定とすべてのプロジェクトを暗号化する +devbase env encrypt + +# 特定プロジェクトだけを暗号化する +devbase env encrypt --project web +``` + +> 退避した平文は**自動では消しません**。内容を確認したうえで、案内された `backups/env-encrypt/<日時>/` を削除してください。削除するまでは端末上に平文の認証情報が残ったままです。 + +> 退避先は毎回新しく作られます。同じ秒に再実行して名前が衝突した場合は `<日時>-2`, `<日時>-3` … と別のディレクトリになり、**過去の退避物を上書きすることはありません**。 + +## `devbase env decrypt` + +暗号化された設定を平文へ戻します。`encrypt` と対になる退避コマンドです。 + +``` +devbase env decrypt [--project NAME]... [--dry-run] [-y|--yes] +``` + +オプションは `encrypt` と同じです。`compose.yml` のコメントアウトも元に戻るため、暗号化前の状態へそのまま復帰します。機密ファイルは `KEY=VALUE` の一覧へ畳まず原文のバイト列のまま暗号化しているので、コメント・空行・`export KEY=...` 表記・値のクォートもそのまま戻ります。 + +> 原文が保たれるのは**値を書き換えるまで**です。暗号化した状態で `devbase env set` などを実行すると、内容は `KEY=VALUE` を昇順に並べた書式へ正規化され、コメントは残りません(平文だけを使っていた頃と同じ挙動です)。 + +```bash +devbase env decrypt --dry-run +devbase env decrypt +``` + +## `devbase env exec` + +復号した機密を環境変数として渡した状態で、任意のコマンドを実行します。値はその子プロセスの環境変数としてのみ渡り、ファイルには書き出されません。 + +``` +devbase env exec -- CMD [ARGS...] +``` + +起動ラッパーは共通の機密ファイルを読み込まないため、ホスト側で機密を必要とする処理(Docker Compose の変数展開など)はこのコマンドを通します。devbase 自身の `devbase build` も内部でこれを使っています。 + +```bash +# コンテナに渡る値を確認する +devbase env exec -- printenv ANTHROPIC_API_KEY + +# 機密を必要とする compose 操作を手で実行する +devbase env exec -- docker compose config +``` + +> `devbase env exec -- printenv` のように値を表示するコマンドは、画面共有や端末ログに認証情報がそのまま残ります。実行する場面に注意してください。 + +## `devbase env rekey` + +誰が機密を復号できるかを変更し、暗号化済みの機密をまとめて暗号化し直します。 + +``` +devbase env rekey [--add-recipient KEY]... [--remove-recipient KEY]... [--dry-run] [-y|--yes] +``` + +| オプション | 説明 | +|-----------|------| +| `--add-recipient KEY` | 受信者を追加(繰り返し指定可)。`age1...` / `ssh-ed25519 ...` / `@PATH` | +| `--remove-recipient KEY` | 受信者を削除(繰り返し指定可) | +| `--dry-run` | 変更内容を表示するだけで、何も書き換えません | +| `-y`, `--yes` | 確認プロンプトを省略 | + +受信者は `$DEVBASE_ROOT/secrets/recipients.txt` に記録されます。リストがまだ無い状態で追加すると、自分の公開鍵も一緒に登録されます(登録しないと自分が受信者から外れ、自分の機密を復号できなくなるため)。 + +```bash +# 同僚を追加する +devbase env rekey --add-recipient age1xxxxxxxx... + +# 抜けた人を外す +devbase env rekey --remove-recipient age1xxxxxxxx... +``` + +> 自分の公開鍵を受信者から外すと、再暗号化後にその端末では機密を復号できなくなります。実行前に警告が表示されます。 + +受信者リストの更新と全機密の再暗号化は、途中で失敗しても中途半端な状態を残さない 1 つのまとまりとして適用されます。書き込みに失敗した場合は受信者リストも各暗号文も実行前の内容へ戻るため、旧受信者宛と新受信者宛の暗号文が混在することはありません。 + +## `devbase env doctor` + +端末上に残る平文と、除外設定の穴を点検します。問題が見つかると非ゼロで終了するため、定期実行にも使えます。 + +``` +devbase env doctor +``` + +確認する内容: + +| 観点 | 内容 | +|-----|------| +| 鍵 | 鍵ファイルの有無と権限、置き場のディレクトリ権限 | +| 保存先の衝突 | 暗号化ファイルと平文が同時に存在していないか | +| 退避された平文 | `backups/env-encrypt/` / `backups/env-import/` に平文が残っていないか | +| 控えファイル | `.env.bak-<日時>` のような平文の控えが残っていないか | +| 除外設定 | `.env` / `secrets/` 配下 / `.env.bak-<日時>` / `projects//.env` が実際に Git から除外されるか | + +除外設定の点検は `.gitignore` を読んで解釈するのではなく、代表的なパスを `git check-ignore` に渡して **Git 自身に判定させます**(`.gitignore` の解釈は Git の実装が正であり、独自に真似ると書き方によって食い違うため)。ルート指定(`/.env`)でも任意階層(`**/.env`)でも、Git が実際に除外できていれば報告されません。逆に次のように **Git は除外しない** 書き方は、除外されないパスを挙げて報告します。 + +- `.env # 機密` — Git は行頭の `#` だけをコメントとして扱うため、これは `.env # 機密` というパターンになります +- `.env` の後に `!.env` — 後段の再包含で除外が取り消されます +- `secrets/*.age` — 配下の平文(`secrets/leftover.env` など)が漏れます + +`DEVBASE_ROOT` が Git リポジトリでない場合や `git` が使えない場合は、「除外設定を確認できませんでした」と報告します(誤って「問題なし」とは言いません)。 + ## `devbase env export` 複数プロジェクトの `.env` 群を暗号化したまま 1 つのバンドルにまとめて書き出します。 diff --git a/docs/user/cli-reference/README.md b/docs/user/cli-reference/README.md index b81e0ad2..8ea5852e 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` / `encrypt` / `decrypt` / `exec` / `rekey` / `doctor` / `export` / `import`) | | [plugin グループ](04-plugin.md) | プラグインの管理(`list` / `install` / `uninstall` / `update` / `info` / `sync` / `migrate` / `repo *`) | | [snapshot グループ](05-snapshot.md) | スナップショットの管理(`create` / `list` / `restore` / `copy` / `delete` / `rotate`) | @@ -26,7 +26,9 @@ graph TD D --> D3["login [index]"] D --> D4["build [image] / rebuild [name]"] D --> D2["list [--no-interactive]"] - E --> E1[init / sync / list / set / get / delete / edit / project / export / import] + E --> E1[init / sync / list / set / get / delete / edit / project] + E --> E2[keygen / encrypt / decrypt / exec / rekey / doctor] + E --> E3[export / import] F --> F1[list / install / uninstall / update / info / sync / migrate] F --> F2[repo add / repo remove / repo list / repo refresh] G --> G1[create / list / restore / copy / delete / rotate] diff --git a/docs/user/env-encryption.md b/docs/user/env-encryption.md new file mode 100644 index 00000000..38e3806d --- /dev/null +++ b/docs/user/env-encryption.md @@ -0,0 +1,144 @@ +# 環境変数の暗号化 + +devbase が扱う認証情報(クラウドのアクセスキー、コード管理サービスの個人アクセストークン、各種 AI サービスの API キーなど)を、保存時に暗号化して持つためのガイドです。 + +暗号化しない運用も引き続き可能です。移行は明示的なコマンドで行い、いつでも平文へ戻せます。 + +## 何が変わるのか + +| | 暗号化しない場合(既定) | 暗号化した場合 | +|---|---|---| +| 共通の機密 | `$DEVBASE_ROOT/.env` | `$DEVBASE_ROOT/secrets/global.env.age` | +| プロジェクトの機密 | `projects//.env` | `secrets/projects/.env.age` | +| コンテナへの渡り方 | 構成ファイルが平文を直接読む | devbase が復号し、変数名だけを列挙した構成で渡す | +| 日々の操作 | `devbase env set` / `get` / `edit` … | **変わらない** | + +保存先はファイルの存在から自動で判定されます。暗号化ファイルがあればそれを、無ければ平文を使います。 + +## 使いはじめる + +### 1. 鍵を作る + +```bash +devbase env keygen +``` + +鍵は `~/.config/devbase/age/keys.txt` に `0600` で作られます。場所を変えたい場合は `DEVBASE_AGE_KEY_FILE` を設定してから実行してください。 + +> **鍵のバックアップは必須です。** この鍵を失うと、暗号化した機密は誰にも復号できません(devbase 側にも復旧手段はありません)。パスワード管理ツールなど、端末とは別の場所へ必ず複製してください。 + +### 2. 何が変わるか確認する + +```bash +devbase env encrypt --dry-run +``` + +暗号化される設定の一覧と、各プロジェクトの `compose.yml` に加わる変更の差分が表示されます。 + +### 3. 暗号化する + +```bash +devbase env encrypt +``` + +次の順で処理されます。 + +1. 平文の設定を暗号化して `secrets/` 配下へ保存する +2. **暗号化した内容を読み戻して元と一致することを確認**する +3. 元の平文を `backups/env-encrypt/<日時>/` へ退避する +4. 各プロジェクトの `compose.yml` から機密ファイルの参照をコメントアウトする + +途中で失敗した場合は、それまでの変更をすべて巻き戻して中止します。「一部だけ暗号化され、構成は存在しないファイルを参照したまま」という状態にはなりません。 + +### 4. 退避された平文を消す + +`encrypt` は元の平文を**自動では削除しません**。内容を確認してから、案内されたディレクトリを削除してください。 + +```bash +rm -rf $DEVBASE_ROOT/backups/env-encrypt/<日時> +``` + +消し忘れは `devbase env doctor` が指摘し続けます。 + +## 日々の操作 + +暗号化しても操作は変わりません。 + +```bash +devbase env list # 暗号化された保存先には [暗号化] と表示される +devbase env set KEY=VALUE +devbase env get KEY +devbase env delete KEY --project +devbase env edit # 復号 → 編集 → 再暗号化 +``` + +`devbase env edit` は、復号結果を自分専用の一時ディレクトリへ `0600` で書き、編集後に暗号化し直してから必ず削除します。エディタへ値を渡す手段が他に無いため、**この操作の間だけ平文が一瞬ディスクに載ります**。 + +## 点検する + +```bash +devbase env doctor +``` + +以下を確認し、問題があれば非ゼロで終了します。 + +- 鍵ファイルとその置き場の権限 +- 暗号化ファイルと平文が同時に存在していないか +- 移行時・取り込み時に退避された平文が残っていないか +- 日時付きの控えファイル(`.env.bak-20260807172231` など)が残っていないか +- `.env` / `secrets/` 配下 / `.env.bak-<日時>` / `projects//.env` が実際に Git から除外されるか(`git check-ignore` で Git 自身に判定させます。`DEVBASE_ROOT` が Git リポジトリでなければ「確認できませんでした」と報告します) + +## チームで共有する + +各メンバーの公開鍵を受信者として登録すると、秘密鍵を渡さずに同じファイルを共同で使えます。 + +```bash +# 同僚の公開鍵を追加して、既存の機密をまとめて暗号化し直す +devbase env rekey --add-recipient age1xxxxxxxx... + +# 抜けた人を外す +devbase env rekey --remove-recipient age1xxxxxxxx... + +# 何が変わるか先に見る +devbase env rekey --add-recipient age1xxxxxxxx... --dry-run +``` + +受信者は `$DEVBASE_ROOT/secrets/recipients.txt` に記録されます。中身は公開鍵だけですが、第三者が自分の鍵を追記できると以後の暗号化がその相手にも復号可能になるため、`0600` で保護されます。 + +自分の公開鍵を受信者から外そうとすると警告が出ます。外したまま再暗号化すると、その端末では機密を復号できなくなります。 + +## 持ち運ぶ + +`devbase env export` / `import` はそのまま使えます。書き出しは暗号化された機密を復号してバンドルへ入れ、バンドル自体を age で暗号化します。取り込み先が暗号化されていれば、**取り込み結果も暗号化されたまま**保存されます(平文の `.env` は作られません)。 + +```bash +devbase env export bundle.dbenv --recipient age1xxxxxxxx... +devbase env import bundle.dbenv --identity ~/.config/devbase/age/keys.txt +``` + +## 平文へ戻す + +```bash +devbase env decrypt +``` + +`compose.yml` のコメントアウトも元に戻り、暗号化前の状態へそのまま復帰します。コメントや空行、`export KEY=value` 表記も失われません(`devbase env set` などで値を書き換えた場合は、平文だけで運用していたときと同じく整形されます)。 + +## 守れること / 守れないこと + +**守れること**: 端末のディスク上に残る保存ファイル、バックアップ、クラウド同期フォルダ、ファイル転送中、リポジトリへの誤コミット、画面共有時の誤表示。 + +**守れないこと**: + +- **実行時の平文化**: 最終的にコンテナへは環境変数として平文で渡ります +- **コンテナ環境の可視性**: コンテナの詳細情報を参照できれば、注入済みの環境変数は読めます。devbase は開発コンテナに Docker の制御ソケットを渡す構成を既定に含むため、コンテナ内から他コンテナの環境変数も参照できます +- **構成の展開結果**: `docker compose config` は変数名だけの列挙を実際の値へ解決して表示します +- **利用者権限を得た攻撃者**: 既定の鍵保管では鍵も同時に読めます + +既定の保護は「鍵が錠前の隣にある」状態です。端末上で利用者権限を得た攻撃者は防げません。これは実運用上の妥協として明示しています。 + +## 関連 + +- [CLI リファレンス: env グループ](cli-reference/03-env.md) +- [環境変数の一覧](environment-variables.md) +- [バンドルの書き出しと取り込み](env-export-import.md) diff --git a/etc/_devbase b/etc/_devbase index ebb590bc..84f1a8cf 100644 --- a/etc/_devbase +++ b/etc/_devbase @@ -105,6 +105,12 @@ _devbase() { 'project:Setup project-specific variables' 'export:Export .env files as an encrypted bundle (age)' 'import:Import .env bundle (age decrypt + merge)' + 'keygen:Generate the devbase age key used by the secret store' + 'exec:Run a command with the decrypted secrets in its environment' + 'encrypt:Move plaintext settings into the encrypted store' + 'decrypt:Move encrypted settings back to plaintext' + 'rekey:Change who can decrypt the secrets and re-encrypt them' + 'doctor:Check for leftover plaintext secrets and ignore-rule gaps' ) plugin_subcommands=( @@ -244,9 +250,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' \ @@ -278,6 +293,27 @@ _devbase() { '--backup-dir[Override backup directory]:dir:_files -/' \ '--keep-last[Keep only the last N backup directories]:n:' ;; + exec) + _arguments '*:command:_command_names -e' + ;; + encrypt|decrypt) + _arguments \ + '*--project[Limit to the specified project (repeatable)]:name:' \ + '--dry-run[Show what would change without writing]' \ + '--yes[Skip the confirmation prompt]' '-y[Skip the confirmation prompt]' + ;; + rekey) + _arguments \ + '*--add-recipient[Public key to add (repeatable)]:key:' \ + '*--remove-recipient[Public key to remove (repeatable)]:key:' \ + '--dry-run[Show what would change without writing]' \ + '--yes[Skip the confirmation prompt]' '-y[Skip the confirmation prompt]' + ;; + keygen) + _arguments \ + '--force[Overwrite an existing key]' \ + '--yes[Skip the confirmation prompt for --force]' '-y[Skip the confirmation prompt for --force]' + ;; *) _describe -t env-commands 'env command' env_subcommands ;; diff --git a/etc/devbase-completion.bash b/etc/devbase-completion.bash index 5bb07fc4..3b7a192c 100644 --- a/etc/devbase-completion.bash +++ b/etc/devbase-completion.bash @@ -35,7 +35,7 @@ _devbase_completions() { # project / container は同じサブコマンド群 (container は非推奨だが補完は維持)。 local project_subcommands="up down ps login logs scale build rebuild list" local container_subcommands="up down ps login logs scale build rebuild" - local env_subcommands="init sync list set get delete edit project export import" + local env_subcommands="init sync list set get delete edit project export import keygen exec encrypt decrypt rekey doctor" local plugin_subcommands="list install uninstall update info sync repo" local repo_subcommands="add remove list refresh" local snapshot_subcommands="create list restore copy delete rotate" @@ -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/REPORT35.md b/issues/REPORT35.md new file mode 100644 index 00000000..037b451c --- /dev/null +++ b/issues/REPORT35.md @@ -0,0 +1,248 @@ +## 結論 + +devbase の標準バックエンドには、**SOPS + age** を推奨します。 + +SOPS は `.env`、YAML、JSON、INIなどを暗号化したまま編集・Git管理でき、暗号鍵には age、AWS KMS、GCP KMS、Azure Key Vaultなどを選べます。`sops exec-env` で、平文ファイルを作らず子プロセスだけに復号済み環境変数を渡すこともできます。複数利用者への暗号化、鍵追加・削除、データキーのローテーションにも対応しています。CNCF Sandboxプロジェクトで、2026年7月23日にも v3.13.3 がリリースされており、現在も活発に保守されています。 ([GitHub][1]) + +さらに devbase は、すでに `devbase env export/import` のバンドル暗号化で age を採用しています。したがって暗号方式を増やすのではなく、**既存の age をSOPSの鍵バックエンドとして日常運用にも広げる**形になります。 + +## 候補比較 + +| 候補 | 特徴 | devbaseへの適合 | +| -------------- | ------------------------------------------------- | ---------------------: | +| **SOPS + age** | 汎用的な暗号化設定ファイル管理。ENV対応、複数recipient、KMS移行、ローテーション対応 | **◎ 第一推奨** | +| **dotenvx** | `.env`専用。導入・操作が非常に簡単 | ○ 簡単だがdevbaseでは追加対応が多い | +| **Infisical** | サーバー型Secrets Manager。権限、監査、履歴、ローテーション | ○ チーム・企業利用向け | +| **age単体** | 単純で堅牢なファイル暗号化 | △ CRUDや差分管理が弱い | +| **git-crypt** | Git filterによる透過的暗号化 | × 今回の目的には不向き | + +### SOPS + age + +最も「一般的」「ツール非依存」「将来拡張可能」に寄っています。 + +SOPSは値だけを暗号化し、環境変数名は読める状態で残すため、どのキーが変更されたかGit上で確認できます。開発者ごとにage公開鍵を登録でき、`.sops.yaml` と `sops updatekeys` でメンバー追加・削除、`sops rotate` でデータキー更新ができます。ローカルではage、CIではAWS KMSやGCP KMS、といった切り替えも同じファイル形式のまま可能です。 ([GitHub][1]) + +### dotenvx + +`.env`に特化するなら、操作性は最も分かりやすいです。 + +```bash +dotenvx encrypt +dotenvx set OPENAI_API_KEY ... +dotenvx run -- command +``` + +暗号化後もキー名を残して値だけを暗号化し、秘密鍵は `.env.keys` や外部Secrets Managerで管理します。公式にもDocker Compose向けの手順があります。 ([GitHub][2]) + +ただし、公式のDocker連携は基本的に「コンテナ内にdotenvxを入れ、アプリケーションコマンドを `dotenvx run --` で包む」方式です。devbaseはアプリを直接起動するというより、`tail -f /dev/null` で開発コンテナを維持し、その後 `devbase login` やVS Code Attachで新しいプロセスを起動します。そのため、PID 1だけをdotenvxで包んでも、後から `docker exec` したシェルにはその環境変数が自動的には引き継がれません。devbaseでは結局、ログイン・エディタ接続・deployなどを個別に包む必要があります。 ([Dotenvx][3]) + +dotenvxは悪くありませんが、**普通のWebアプリには簡単、開発コンテナマネージャーであるdevbaseにはやや相性が悪い**という評価です。 + +### Infisical + +チーム利用で次が必要なら有力です。 + +* 誰がどの秘密にアクセスできるか +* 監査ログ +* シークレットの履歴・復元 +* 定期ローテーション +* 開発、ステージング、本番の一元管理 +* Gitに暗号文すら置かない + +CLIから `infisical run -- command` として子プロセスへ注入でき、Linux/macOSに対応しています。Cloud版とセルフホスト版があります。 ([GitHub][4]) + +一方、サーバー、アカウント、認証トークン、ネットワーク接続などが必要になります。devbaseのデフォルト機能にすると重いため、将来的な**オプションSecrets Provider**として用意するのがよいでしょう。 + +### age単体・git-crypt + +age単体はシンプルで堅牢ですが、ファイル全体を一つの暗号データとして扱うので、環境変数単位の編集、マージ、Git差分、recipient管理をdevbase側で再実装することになります。ageはSOPSの暗号バックエンドとして使う方が自然です。 ([GitHub][5]) + +git-cryptはチェックアウト後の作業ファイルが平文になり、ローカルディスク上の常置平文をなくす目的には合いません。また公式にも、Git filterは暗号化目的で設計されたものではないこと、アクセス取り消しや鍵ローテーションに対応していないことが制約として記載されています。 ([GitHub][6]) + +## devbaseの現状で必要になる変更 + +現在のdevbaseには、次の二つの平文機密ファイルがあります。 + +* `$DEVBASE_ROOT/.env` +* `$DEVBASE_ROOT/projects//.env` + +`bin/devbase` はルート `.env` を直接 `source` し、プロジェクト側 `.env` はDocker Composeの `env_file` に読み込ませています。`EnvFile.save()` も値を平文で保存し、権限だけを `0600` にしています。 + +サンプルComposeも次の構造です。 + +```yaml +env_file: + - ${DEVBASE_ROOT}/.env + - env + - .env +``` + +したがって、`.env`をSOPSで暗号化するだけでは動きません。Composeの `env_file` はSOPS暗号文を復号せず、そのまま環境変数値として扱ってしまいます。 ([Docker Documentation][7]) + +## 推奨するファイル構成 + +非機密設定と機密情報を明確に分けます。 + +```text +$DEVBASE_ROOT/ +├── env # グローバル非機密設定 +├── .sops.yaml # recipient公開鍵 +├── secrets/ +│ ├── global.env # SOPS暗号化済み +│ └── projects/ +│ ├── project-a.env # SOPS暗号化済み +│ └── project-b.env +└── projects/ + └── project-a/ + ├── env # 従来どおりGit管理する非機密設定 + └── compose.yml +``` + +SOPSは拡張子から形式を判定するため、暗号化されたファイルでも末尾を `.env` にしておくと扱いやすくなります。内容は次のように値だけが暗号文になります。 + +```dotenv +OPENAI_API_KEY=ENC[AES256_GCM,...] +AWS_SECRET_ACCESS_KEY=ENC[AES256_GCM,...] +sops_age__list_0__map_recipient=age1... +... +``` + +`.sops.yaml` は例えば次のようにします。 + +```yaml +creation_rules: + - path_regex: '(^|/)secrets/.*\.env$' + age: >- + age1alice..., + age1bob... +``` + +## devbaseへの組み込み方 + +### 1. `SopsSecretStore`を追加する + +現在の `EnvFile` の上位に、暗号化ストレージのインターフェースを置きます。 + +```python +class SecretStore(Protocol): + def load(self, scope: str) -> dict[str, str]: ... + def set(self, scope: str, key: str, value: str) -> None: ... + def delete(self, scope: str, key: str) -> None: ... + def edit(self, scope: str) -> None: ... +``` + +最初の実装を `SopsSecretStore` とし、暗号処理は自前実装せず、SOPS CLIへ委譲します。 + +* `devbase env init` +* `devbase env sync` +* `devbase env set` +* `devbase env get` +* `devbase env delete` +* `devbase env edit` + +既存のCLI UXは維持し、保存先だけをSOPSに変えます。 + +### 2. 起動時にメモリ上でマージする + +優先順位は現在と同じでよいでしょう。 + +```text +グローバル暗号化secret + ↓ +プロジェクトの公開env + ↓ +プロジェクト暗号化secret +``` + +`devbase up` がSOPSで復号し、Pythonの辞書または子プロセス環境上でマージします。恒久的な平文 `.env` は作りません。 + +SOPSには、平文ファイルを作らず子プロセスだけに環境変数を渡す `exec-env` もあります。devbase内部では複数ファイルの優先順位処理が必要なので、`sops decrypt` の標準出力をメモリ上でパースして `subprocess.run(..., env=...)` に渡す実装の方が扱いやすそうです。 ([GitHub][1]) + +### 3. Composeにはキー名だけ渡す + +プラグインの `compose.yml` から機密 `.env` の指定を外します。 + +```yaml +services: + dev: + env_file: + - env +``` + +devbaseが実行時に次のようなoverride Composeを生成します。 + +```yaml +services: + dev: + environment: + - OPENAI_API_KEY + - ANTHROPIC_API_KEY + - AWS_ACCESS_KEY_ID + - AWS_SECRET_ACCESS_KEY +``` + +値なしの `environment` エントリは、Compose実行プロセスの環境変数から値を受け取ります。また `environment` は `env_file` より優先されます。これにより、暗号文や平文ファイルをComposeへ直接渡さずに済みます。 ([Docker Documentation][7]) + +初期実装を簡単にするなら、復号結果を権限 `0600` の一時ファイルに書き、生成したoverride Composeの `env_file` に指定し、`docker compose up` 完了後に削除する方法もあります。ただしmacOSでは短時間とはいえ平文がディスクに置かれるため、最終形としてはメモリ経由を推奨します。 + +## age鍵の管理方針 + +SSH鍵の流用も技術的には可能ですが、**devbase専用のage鍵を開発者ごとに作る**方がよいです。SSH鍵と暗号化鍵では、失効・保管・バックアップのライフサイクルが異なるためです。ageの公式ドキュメントも、SSH鍵は長期的な復号鍵として保護されていない可能性がある点に注意を促しています。 ([GitHub][5]) + +```bash +mkdir -p ~/.config/devbase/age +chmod 700 ~/.config/devbase/age + +age-keygen -o ~/.config/devbase/age/keys.txt +chmod 600 ~/.config/devbase/age/keys.txt + +age-keygen -y ~/.config/devbase/age/keys.txt +``` + +LinuxとmacOSではSOPSのデフォルト鍵配置先が異なるため、devbase側で明示的に統一すると運用が楽です。 + +```bash +export SOPS_AGE_KEY_FILE="$HOME/.config/devbase/age/keys.txt" +``` + +SOPSはこの環境変数による鍵パス指定を正式にサポートしています。 ([GitHub][1]) + +チームでは、秘密鍵を共有せず、各メンバーの公開鍵を `.sops.yaml` に登録します。退職・異動時はrecipientを削除して `sops updatekeys` と `sops rotate` を行い、必要に応じてAPIキーそのものもローテーションします。 + +## セキュリティ上の限界 + +SOPSによって守られるのは、主に次の範囲です。 + +* ローカルディスク上の保存ファイル +* Gitリポジトリ +* バックアップ +* ファイル転送中 + +実行時には、最終的に環境変数として平文になります。Dockerもパスワードなどの機密値については、環境変数よりCompose Secretsの利用を推奨しています。Compose Secretsはホスト環境変数やファイルを元に、コンテナ内の `/run/secrets/` にファイルとして提供できます。 ([Docker Documentation][8]) + +そのため将来的には、 + +* AWS:AWS SSOまたは `~/.aws` のread-only mount +* GCP:credential JSONをCompose Secretとしてmount +* Git:SSH Agentまたはcredential helper +* 一般APIキー:環境変数 + +というように、認証方式ごとに環境変数以外も選べるとさらに安全です。 + +## 最終推薦 + +devbaseの標準構成は次の方針が最もバランスがよいです。 + +> **標準:SOPS + 開発者ごとのage鍵** +> **将来オプション:SOPS + クラウドKMS、またはInfisical Provider** + +dotenvxは単独アプリにはかなり魅力的ですが、devbaseのような対話型開発コンテナではSOPSをホスト側に一度組み込む方が、Linux/macOS、Docker Compose、VS Code Attach、複数プロジェクト、将来のKMS対応まで一貫して扱えます。 + +[1]: https://github.com/getsops/sops?utm_source=chatgpt.com "GitHub - getsops/sops: Simple and flexible tool for managing secrets · GitHub" +[2]: https://github.com/dotenvx/dotenvx?utm_source=chatgpt.com "GitHub - dotenvx/dotenvx: a secure dotenv–from the creator of `dotenv` · GitHub" +[3]: https://dotenvx.com/docs/secrets-in-docker-compose?utm_source=chatgpt.com "Encrypt a .env file for Docker Compose · Dotenvx" +[4]: https://github.com/infisical/infisical?utm_source=chatgpt.com "GitHub - Infisical/infisical: Infisical is the open-source platform for secrets, certificates, and privileged access management. · GitHub" +[5]: https://github.com/filosottile/age?utm_source=chatgpt.com "GitHub - FiloSottile/age: A simple, modern and secure encryption tool (and Go library) with small explicit keys, no config options, and UNIX-style composability. · GitHub" +[6]: https://github.com/AGWA/git-crypt?utm_source=chatgpt.com "GitHub - AGWA/git-crypt: Transparent file encryption in git · GitHub" +[7]: https://docs.docker.com/reference/compose-file/services/?utm_source=chatgpt.com "Define services in Docker Compose | Docker Docs" +[8]: https://docs.docker.com/compose/how-tos/environment-variables/set-environment-variables/?utm_source=chatgpt.com "Set environment variables within your container's environment | Docker Docs" diff --git a/issues/i35.md b/issues/i35.md new file mode 100644 index 00000000..0f04c842 --- /dev/null +++ b/issues/i35.md @@ -0,0 +1,10 @@ +# devbase env 暗号化 + +* 現在 devbase/.envファイルは平文で管理されている +* 暗号化して管理できるようにしたい +* 全てを独自実装するのではなく、既存のOSSツールやライブラリをとりこんで開発したい + +* issues/REPORT35.mdにchatgptのアドバイスがあるが、いったんこれは見ずに独自に方針を出してください +* 方針策定後、issues/REPORT35.md を確認してお互いの良いところを取り込んだ修正方針案にブラッシュアップしてください。 + +* issues/plan35.mdに最終的な方針案を出力してください。 \ No newline at end of file diff --git a/issues/plan35.md b/issues/plan35.md new file mode 100644 index 00000000..c9188654 --- /dev/null +++ b/issues/plan35.md @@ -0,0 +1,350 @@ +# plan35: 環境変数ファイルの暗号化方針 + +> 元 issue: `issues/i35.md` +> 種別: 設計方針(multi-PR 想定) / base branch: `main` / release branch: `release/PLAN35` +> ステータス: 実装完了(段階 1〜5。完了サマリは §12 参照) + +## 1. 目的 + +devbase が扱う認証情報(クラウドのアクセスキー、コード管理サービスの個人アクセストークン、各種 AI サービスの API キーなど)は、現在すべて平文のテキストファイルとして開発者のマシン上に置かれている。これを保存時に暗号化し、ディスク・バックアップ・同期フォルダ・誤共有の経路から機密が漏れない状態にする。 + +暗号処理そのものは自前実装せず、既存の OSS を組み込む。 + +## 2. 現状 + +### 2.1 平文で保存されている機密 + +| ファイル | 内容 | 機密性 | +|---|---|---| +| `$DEVBASE_ROOT/.env` | 全プロジェクト共通の認証情報(クラウド・コード管理・AI サービス各種) | 高 | +| `$DEVBASE_ROOT/projects//.env` | プロジェクト実行時の秘密(アプリケーションキー、データベース接続情報など) | 高 | +| `$DEVBASE_ROOT/projects//env` | プロジェクト設定(対象リポジトリ、コンテナ台数など) | 低(機密ではない) | +| `$DEVBASE_ROOT/.env.sources.yml` | 取り込み元ファイルのハッシュと同期時刻 | 低(値は含まない) | + +共通設定ファイルは権限 `0600` で保存されているが、内容は平文である。 + +### 2.2 平文ファイルを読む 2 つの経路 + +暗号化を難しくしている本質は、平文ファイルを読む経路が devbase 自身の外側にもある点にある。 + +```mermaid +graph LR + ENV["共通設定ファイル
(平文)"] + W["起動ラッパー
bin/devbase:44"] + C["Docker Compose
projects/<name>/compose.yml"] + P["Python 本体"] + K["開発コンテナ"] + + ENV -->|"source (bash)"| W + ENV -->|"env_file:"| C + W -->|"環境変数を継承"| P + C -->|"環境変数を注入"| K + + classDef plain fill:#fdd,stroke:#933 + class ENV plain +``` + +**起動ラッパー経由**: `bin/devbase` は起動直後に共通設定ファイルを `set -a` 付きで読み込み、ホスト側で動く処理(クラウド CLI の呼び出しなど)に環境変数として渡している。 + +**Docker Compose 経由**: 各プロジェクトの構成ファイルは、共通設定ファイルを直接ファイルパスとして参照している。 + +```yaml +env_file: + - ${DEVBASE_ROOT}/.env + - env + - .env +``` + +Docker Compose はこのファイルを暗号文のまま読み、値として扱ってしまう。したがって**ファイルを暗号化するだけでは、コンテナに壊れた値が入って起動が失敗する**。この 2 経路の切り替えが本方針の中心課題になる。 + +### 2.3 平文が滞留する副次経路 + +暗号化の対象を主ファイルだけに絞ると、以下が取り残される。 + +- **設定取り込み時のバックアップ**: `$DEVBASE_ROOT/backups/env-import/<日時>/` に、上書き前の平文コピーが残る(世代 GC はあるが平文のまま) +- **除外設定の穴**: 除外設定(`.gitignore`)には `.env` / `.env.bak` / `.env.backup` の 3 つが列挙されているが、日時付きの手動バックアップは対象外である。実際に日時付きの平文バックアップが未追跡ファイルとして検出されている + + ```console + $ git check-ignore -v .env .env.bak .env.bak-20260807172231 + .gitignore:3:.env .env + .gitignore:4:.env.bak .env.bak + ``` + + 3 番目のファイルは出力に現れない = 除外されない。この状態でコミットすれば、平文の認証情報がリポジトリ履歴に残る。 + +## 3. 技術選定 + +### 3.1 決定 + +**保存形式は age を標準とし、暗号処理の呼び出し口を差し替え可能な層として設ける。** 既定の実装には既存の Python バインディング(`pyrage`)を使い、外部バイナリを必要としない。チーム運用や鍵管理サービス連携が要るときのために、SOPS を選べる差し替え先として設計に織り込む。 + +### 3.2 比較 + +| 候補 | 強み | devbase での評価 | +|---|---|---| +| **age(Python バインディング経由)** | 追加インストール不要。ファイル単位で堅牢。複数受信者に対応 | **標準採用**。すでに依存関係に含まれ、設定の書き出し・取り込み機能で実績がある | +| **SOPS + age** | 値だけを暗号化し変数名は可読。受信者の追加・削除と鍵更新の専用コマンドを持つ。鍵管理サービスへ移行可能 | **差し替え先として採用**。ただし別途バイナリの導入が必要なため既定にはしない | +| **dotenv 拡張ツール** | 導入と操作が平易 | 不採用。実行中コンテナに後から接続して作業する使い方と噛み合わない | +| **サーバー型の秘密管理サービス** | 権限管理・監査ログ・履歴・自動更新 | 将来の選択肢。サーバーと認証基盤を必要とし、ローカル開発ツールの既定としては重い | +| **Git 透過暗号化ツール** | リポジトリ内ファイルの透過的な暗号化 | 不採用。対象ファイルはリポジトリ管理外であり、作業ファイルは平文で置かれる | + +### 3.3 標準を age に置く理由 + +**追加インストールを増やさない。** devbase は 1 行のコマンドで導入でき、Python の依存関係は実行時に自動解決される。暗号化を既定で有効にする以上、その前提条件も同じ水準で自動的に満たされる必要がある。SOPS を標準にすると、全利用者が別途バイナリを導入し、macOS・Linux・WSL それぞれで版を管理することになる。 + +```console +$ grep -n "pyrage" pyproject.toml +8: "pyrage>=1.2", + +$ command -v sops age age-keygen +(出力なし = いずれも未導入) +``` + +**値単位の暗号化が効く場面が限定的。** 値だけを暗号化する形式の主な利点は、リポジトリ上で「どの変数が変わったか」を差分で追えることにある。対象ファイルはリポジトリ管理外の端末ローカル状態であり、この利点はほとんど働かない。 + +**変数単位の操作層はすでに存在する。** 設定の追加・取得・削除・編集は既存の解析処理が担っており、暗号化は「読み込み直後」と「書き出し直前」に差し込むだけで成立する。ファイル全体の暗号化でも操作性は落ちない。 + +## 4. 目標構成 + +### 4.1 ファイル配置 + +機密と非機密を分離する。 + +``` +$DEVBASE_ROOT/ +├── env # 共通の非機密設定(平文のまま) +├── secrets/ +│ ├── global.env.age # 共通の機密(暗号化) +│ └── projects/ +│ └── .env.age # プロジェクトの機密(暗号化) +└── projects/ + └── / + ├── env # プロジェクト設定(平文のまま) + └── compose.yml +``` + +### 4.2 読み込みフロー + +**恒久的な平文ファイルを作らない。** 復号結果はプロセスのメモリ上だけで合成し、子プロセスの環境変数として渡す。 + +```mermaid +sequenceDiagram + participant U as 利用者 + participant W as 起動ラッパー + participant P as Python 本体 + participant S as 秘密ストア + participant D as Docker Compose + + U->>W: devbase up + W->>W: 非機密設定のみ読み込み + W->>P: 起動(機密は渡さない) + P->>S: 復号を要求 + S-->>P: 変数の対応表(メモリ上) + P->>P: 共通機密 → 非機密設定 → プロジェクト機密 の順に合成 + P->>D: 合成した環境変数を渡して起動 + D-->>U: コンテナ起動完了 +``` + +優先順位は現行の重ね順を維持する。共通の機密を土台に、プロジェクトの非機密設定、プロジェクトの機密の順で上書きする。 + +### 4.3 コンテナへの受け渡し + +各プロジェクトの構成ファイルから機密ファイルの参照を外し、非機密設定だけを残す。 + +```yaml +services: + dev: + env_file: + - env +``` + +機密は、devbase が起動時に生成する上書き構成ファイルで**変数名だけを列挙**して渡す。値を持たない列挙は、Docker Compose を実行しているプロセスの環境変数から解決される。 + +```yaml +services: + dev: + environment: + - ANTHROPIC_API_KEY + - AWS_SECRET_ACCESS_KEY +``` + +この形なら、暗号文も平文ファイルも Docker Compose に渡らない。上書き構成ファイル自体にも値は含まれない。 + +### 4.4 起動ラッパーの扱い + +起動ラッパーは非機密設定だけを読み込むよう変更し、機密は Python 本体が必要になった時点で復号する。ホスト側で動く処理のうち機密を必要とするものを事前に洗い出し、それぞれ Python 側から環境変数を渡す形へ移す。この洗い出しを実装前の必須作業とする(対象は主にクラウド CLI の呼び出し系)。 + +## 5. 鍵管理 + +### 5.1 devbase 専用鍵を開発者ごとに作る + +SSH 鍵の流用は技術的には可能だが、失効・保管・バックアップの扱いが署名用の鍵とは異なるため、専用鍵を分けて作る。 + +```bash +mkdir -p ~/.config/devbase/age && chmod 700 ~/.config/devbase/age +age-keygen -o ~/.config/devbase/age/keys.txt +chmod 600 ~/.config/devbase/age/keys.txt +``` + +鍵ファイルの場所は環境変数で明示的に統一し、OS ごとの既定位置の違いを吸収する。鍵の生成は `devbase env keygen` として devbase 側に取り込み、外部コマンドの導入を利用者に要求しない。 + +### 5.2 保護の強度を選べるようにする + +| 段階 | 鍵の保管 | 守れる範囲 | +|---|---|---| +| 既定 | パスフレーズなしの鍵ファイル(`0600`) | バックアップ・同期フォルダ・誤共有・端末の紛失 | +| 強化 | OS の資格情報ストア(macOS のキーチェーン、Linux の秘密情報サービス、Windows の資格情報マネージャー) | 上記に加え、ファイル読み取りのみを持つ攻撃者 | +| チーム | 各人の公開鍵を受信者として登録 | 秘密鍵を共有せずに同じファイルを共同利用 | + +既定の段階は「鍵が錠前の隣にある」状態であり、端末上で利用者権限を得た攻撃者は防げない。これは実運用上の妥協として明示する。 + +### 5.3 鍵を失ったときの備え + +暗号化した機密は、鍵を失えば復旧できない。以下を必須とする。 + +- 暗号化を有効にする操作の中で、鍵のバックアップを促す確認を入れる +- 復旧用の受信者(パスワード管理ツールなどに保管する予備の公開鍵)を登録できるようにする +- 平文へ戻す退避コマンドを用意する + +## 6. コマンド + +既存の操作性は変えず、保存先だけを差し替える。 + +| コマンド | 変更内容 | +|---|---| +| `devbase env init` / `sync` / `set` / `get` / `delete` / `edit` / `list` | 保存先を秘密ストアに変更。操作は現行のまま | +| `devbase env keygen` | 新設。専用鍵の生成と設定 | +| `devbase env encrypt` | 新設。平文から暗号化構成へ移行 | +| `devbase env decrypt` | 新設。平文へ戻す退避 | +| `devbase env rekey` | 新設。受信者の追加・削除と再暗号化 | +| `devbase env doctor` | 新設。端末上に残る平文の走査と除外設定の点検 | +| `devbase env export` / `import` | 既存の書き出し・取り込みは暗号化済みの機密をそのまま持ち運ぶ形に整理 | + +## 7. 脅威モデル + +**守れるもの**: 端末のディスク上に残る保存ファイル、バックアップ、クラウド同期フォルダ、ファイル転送中、リポジトリへの誤コミット、画面共有時の誤表示。 + +**守れないもの**: + +- **実行時の平文化**: 最終的にコンテナへは環境変数として平文で渡る +- **コンテナ環境の可視性**: コンテナの詳細情報を参照する権限があれば、注入済みの環境変数は読める。devbase は開発コンテナに Docker の制御ソケットを渡す構成を既定に含むため、**コンテナ内から他コンテナの環境変数も参照できる** +- **構成の展開結果**: 構成の確認コマンドは変数名だけの列挙を実際の値へ解決して表示する +- **利用者権限を得た攻撃者**: 既定の鍵保管では鍵も同時に読める +- **同名キーの由来分離**: 生成する構成はサービスごとに「元々参照していた由来(共通/プロジェクト)のキーだけ」を列挙するが、同じキーが共通機密とプロジェクト機密の両方にある場合、値は devbase 自身の環境変数から解決されるため 1 つ(プロジェクト側が優先)に定まる。結果として共通側だけを参照していたサービスにも合成後の値が渡る。サービスごとに異なる値を渡すには生成ファイルへ値を書き込むしかなく、「生成物に機密の値を残さない」という本方針の前提と矛盾するため受け入れる + +実行時の露出を下げる手段(コンテナの秘密情報機能によるファイル渡し、クラウドの一時認証、認証エージェントの転送)は、本方針の範囲外として将来の課題に置く。 + +## 8. 段階実装 + +| 段階 | 内容 | 完了条件 | +|---|---|---| +| 1 | 秘密ストアの抽象層と age 実装、鍵の生成・登録 | 暗号化ファイルに対して読み書きが通り、既存テストが退行しない | +| 2 | 設定操作コマンド群の保存先切り替え | 追加・取得・削除・編集・一覧が暗号化構成で動作する | +| 3 | 起動ラッパーの機密読み込み廃止とホスト側処理の移行 | 機密を必要とするホスト側処理の一覧と移行が完了する | +| 4 | 変数名のみを列挙する上書き構成の生成、既存プロジェクト構成の移行 | 平文ファイルを介さずにコンテナへ機密が渡る | +| 5 | 移行・退避・受信者更新・平文走査の各コマンド、除外設定の修正 | 平文から暗号化への往復が非破壊で完了する | +| 6 | 差し替え先としての SOPS 実装、ドキュメント整備 | 同じ操作性で保存形式だけを切り替えられる | + +段階 4 までで保護の目的は達成される。段階 6 は運用要件が出てから着手してよい。 + +## 9. 移行と後方互換 + +- **自動判定**: 暗号化ファイルがあればそれを使い、無ければ平文を使う。両方ある場合はエラーで停止し、利用者に解消させる +- **既存プロジェクト構成の書き換え**: 各プロジェクトの構成ファイルにある機密ファイル参照を移行コマンドで除去する。利用者が独自に編集した構成も検出できるよう、書き換え前に差分を提示する +- **平文の後始末**: 移行完了後、元の平文ファイルと取り込み時のバックアップを削除対象として提示する。無言で消さず、削除するまでは警告を出し続ける +- **除外設定の修正**: 日時付きバックアップと秘密ディレクトリを除外対象へ追加する + +## 10. 未確認事項・残リスク + +- ~~**値を持たない変数名の列挙の挙動**~~: 確認済み。Docker Compose v5.1.4 では、実行プロセス側で未設定の変数は失敗ではなく空 (`null`) として扱われ、その変数はコンテナへ渡らない。 + + ```console + $ DEFINED_VAR=hello docker compose config + environment: + DEFINED_VAR: hello + UNDEFINED_VAR: null + ``` + + 同時に、構成の確認コマンドが設定済みの値をそのまま表示することも確認できた (§7「守れないもの」に挙げた挙動)。 +- ~~**ホスト側処理の機密依存範囲**~~: 洗い出し済み。§11.2 を参照 +- ~~**コンテナ台数を増やした構成での上書き順序**~~: 別ファイルの上書きを重ねる方式をやめ、台数拡張時に生成する構成そのものへ変数名の列挙を書き込む方式にした。適用順序の問題自体が発生しない +- **復号の実行回数**: 現在は devbase の実行ごとに設定ファイルを読み込んでいる。復号を毎回行う場合の所要時間は未計測であり、体感が悪ければ実行単位での保持を検討する +- **バックアップ機能との関係**: バックアップ取得がボリューム内の機密を平文で保存するかは未確認 + +## 11. PR 分割計画 + +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`) の保存先切替 | 段階 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 に切り出す。 + +PR4 では上記に加えて、書き出し・取り込み (`export` / `import`) を秘密ストア経由へ揃えた。移行後の環境で `import` が平文の `.env` を作ると、暗号化ファイルと平文が同時に存在する状態 (§9 でエラーとして停止させる状態) を自分で作り出してしまうため。あわせて取り込み時の控えも暗号文のまま保存されるようになり、§2.3 で挙げた「バックアップに平文が滞留する」経路が塞がる。 + +### 11.1 依存が直列になる理由 + +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 系統のみだった。 + +```console +$ grep -rhno '\${[A-Za-z_][A-Za-z0-9_]*[^}]*}' projects/*/compose.yml | sed 's/^[0-9]*://' | sort | uniq -c | sort -rn + 84 ${DEVBASE_ROOT} + 43 ${DOCKER_GID} + 12 ${DB_PASSWORD:-password} + 8 ${COMPOSE_PROJECT_NAME} + ... + 1 ${AWS_SECRET_ACCESS_KEY:-localpassword} + 1 ${AWS_ACCESS_KEY_ID:-localid} +``` + +| 経路 | 機密への依存 | 移行方法 | +|---|---|---| +| Docker Compose の変数展開 | `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY` (ローカル S3 互換サービスの資格情報として展開される) | Python 本体が復号結果を自プロセスの環境変数へ載せてから Compose を起動する | +| 起動ラッパーのビルド処理 | 上と同じ変数を `docker compose build` が展開しうる | ラッパーから `devbase env exec -- docker compose build ...` 経由で呼び、Python 側で注入する | +| 設定収集 (クラウド CLI 呼び出し) | ホームディレクトリ配下の設定ファイルを直接読むため、環境変数への依存なし | 変更不要 | + +`DEVBASE_ROOT` / `DOCKER_GID` / `COMPOSE_PROJECT_NAME` は機密ではなく、ラッパーが従来どおり自前で設定する。プロジェクトごとの `DB_PASSWORD` 等はプロジェクトの `.env` に属し、共通設定ファイルの読み込み廃止とは独立である。 + +## 12. 完了サマリ + +段階 1〜5 を実装し、release ブランチ `release/PLAN35` へ取り込んだ。段階 6 (SOPS を差し替え先として実装) はスコープ外のまま。 + +| PR | 内容 | 状態 | +|---|---|---| +| #91 | 秘密ストアの抽象層と age 実装、鍵生成 | merged | +| #92 | 設定操作コマンドの保存先切替 | merged | +| #93 | 起動ラッパーの機密読み込み廃止、コンテナへの受け渡し、移行コマンド | merged | +| #94 | 受信者更新・平文走査・除外設定・ドキュメント | merged | +| #90 | release → main | レビュー待ち | + +いずれの個別 PR も codex / gemini の両方が APPROVE に収束するまでレビューを回した (#91: 10 ラウンド / #92: 4 / #93: 13 / #94: 3)。 + +### 検証したこと + +- 複数プロジェクトを含む構成で、平文 → 暗号化 → 平文の往復が非破壊で完了する (構成ファイルがバイト単位で元に戻る) +- 台数を増やした構成でも機密の受け渡しが壊れない。生成物に値が残らない +- 共通設定だけを参照していたサービスに、プロジェクト専用の機密が渡らない +- 元の構成が持っていた非機密の設定値が、生成後も失われない +- 暗号化構成で書き出し・取り込みが往復し、取り込みが平文ファイルを作らない +- 移行していない環境では従来どおり動作する +- テスト 1221 件が green / lint パス + +### §10 の未確認事項の帰結 + +| 項目 | 結果 | +|---|---| +| 値を持たない変数名の列挙の挙動 | 確認済み。未設定の変数は失敗ではなく空として扱われる (§10 参照) | +| ホスト側処理の機密依存範囲 | 洗い出し済み。2 系統のみ (§11.2) | +| コンテナ台数を増やした構成での上書き順序 | 方式変更により論点が消滅 (§10 参照) | +| 復号の実行回数 | 実行ごとに 1 回。体感できる遅延は観測されなかったため、実行単位での保持は入れていない | +| バックアップ機能との関係 | 取り込み時のバックアップは保存形式に追随し、暗号化構成では暗号文のまま保存される。ボリューム内のスナップショットは本方針の対象外 | diff --git a/lib/devbase/cli.py b/lib/devbase/cli.py index 734c7ce1..063e2a51 100644 --- a/lib/devbase/cli.py +++ b/lib/devbase/cli.py @@ -6,6 +6,7 @@ import sys from importlib import import_module from pathlib import Path +from typing import Optional from devbase.errors import DevbaseError from devbase.log import get_logger, setup @@ -52,7 +53,9 @@ SUBCMD_MAP = { ('project',): ['up', 'down', 'ps', 'login', 'logs', 'scale', 'build', 'rebuild', 'list'], ('container', 'ct'): ['up', 'down', 'ps', 'login', 'logs', 'scale', 'build', 'rebuild'], - ('env',): ['init', 'sync', 'list', 'set', 'get', 'delete', 'edit', 'project', 'export', 'import'], + ('env',): ['init', 'sync', 'list', 'set', 'get', 'delete', 'edit', 'project', 'keygen', + 'exec', 'encrypt', 'decrypt', 'rekey', 'doctor', + 'export', 'import'], ('plugin', 'pl'): ['list', 'install', 'uninstall', 'update', 'info', 'sync', 'repo', 'migrate'], ('snapshot', 'ss'): ['create', 'list', 'restore', 'copy', 'delete', 'rotate'], } @@ -66,6 +69,15 @@ # `import` 追加で `i` が `init` / `import` の両方にマッチして ambiguous に # なるため、既存ショートカット (`devbase env i` → `init`) を維持する。 'i': 'init', + # `exec` 追加で `ex` が `exec` / `export` の両方にマッチするため、 + # 既存ショートカット (`devbase env ex` → `export`) を維持する。 + # `exec` は `exe` 以降で一意に決まる。 + 'ex': 'export', + # `decrypt` 追加で `d` / `de` が `delete` とも一致するため、既存 + # ショートカット (`devbase env d` → `delete`) を維持する。 + # `decrypt` は `dec` 以降で一意に決まる。 + 'd': 'delete', + 'de': 'delete', }, } @@ -274,12 +286,72 @@ 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 か既定パスしか + # 探索しないため、任意のパスへ生成できると「その鍵で保存した機密を復号できない」 + # 状態を作れてしまう。場所を変えたい場合は環境変数を設定してから実行してもらう。 + # 説明中の環境変数名は devbase.env.agekeys.KEY_FILE_ENV と対。agekeys は pyrage を + # 引き込むため、parser 構築時に import せず文字列で持つ (暗号機能を使わない + # コマンドまで pyrage のロード失敗に巻き込まないため)。 + env_exec = env_sub.add_parser( + 'exec', + help='Run a command with the decrypted secrets in its environment') + env_exec.add_argument('argv', nargs=argparse.REMAINDER, + metavar='-- CMD [ARGS...]', + help='Command to run (prefix with -- to pass flags)') + + for name, action in (('encrypt', 'Move plaintext settings into the encrypted store'), + ('decrypt', 'Move encrypted settings back to plaintext')): + sub = env_sub.add_parser(name, help=action) + sub.add_argument('--project', action='append', default=[], + metavar='NAME', dest='projects', + help='Limit to the specified project (repeatable)') + sub.add_argument('--dry-run', action='store_true', + help='Show what would change without writing') + sub.add_argument('--yes', '-y', action='store_true', dest='assume_yes', + help='Skip the confirmation prompt') + + env_rekey = env_sub.add_parser( + 'rekey', help='Change who can decrypt the secrets and re-encrypt them') + env_rekey.add_argument('--add-recipient', action='append', default=[], + metavar='KEY', dest='add_recipients', + help=("Public key to add (repeatable). Formats: " + "'age1...', 'ssh-ed25519 ...', '@PATH'")) + env_rekey.add_argument('--remove-recipient', action='append', default=[], + metavar='KEY', dest='remove_recipients', + help='Public key to remove (repeatable)') + env_rekey.add_argument('--dry-run', action='store_true', + help='Show what would change without writing') + env_rekey.add_argument('--yes', '-y', action='store_true', dest='assume_yes', + help='Skip the confirmation prompt') + + env_sub.add_parser( + 'doctor', + help='Check for leftover plaintext secrets and ignore-rule gaps') + + env_keygen = env_sub.add_parser( + 'keygen', + help='Generate the devbase age key used by the secret store ' + '(written to $DEVBASE_AGE_KEY_FILE or ~/.config/devbase/age/keys.txt; ' + 'set $DEVBASE_AGE_KEY_FILE before running to use another location)') + env_keygen.add_argument('--force', action='store_true', + help='Overwrite an existing key (previously encrypted ' + 'secrets may become unrecoverable)') + env_keygen.add_argument('--yes', '-y', action='store_true', dest='assume_yes', + help='Skip the confirmation prompt for --force') + _add_env_export_parser(env_sub) _add_env_import_parser(env_sub) @@ -603,6 +675,8 @@ def main(): cmd = args.command + _load_secret_env(cmd, getattr(args, 'subcommand', None)) + try: return _dispatch(cmd, args) except DevbaseError as e: @@ -610,6 +684,53 @@ def main(): return 1 +# 機密の注入を行わないコマンド。鍵の生成や暗号化・復号は「まだ鍵が無い」 +# 「復号できない」状態でこそ実行されるため、注入を試みると本来の操作の前に +# 落ちてしまう。 +# +# `env` のように注入が要るサブコマンド (`env list` など) と要らないサブコマンド +# が同居するグループがあるため、``(コマンド, サブコマンド)`` の組で持つ。 +# サブコマンドが ``None`` の項目は「そのコマンド全体をスキップする」意味。 +_NO_SECRET_INJECTION = frozenset({ + ('init', None), + ('env', 'keygen'), + ('env', 'encrypt'), + ('env', 'decrypt'), +}) + + +def _skip_secret_injection(cmd: str, subcommand: Optional[str]) -> bool: + return ((cmd, None) in _NO_SECRET_INJECTION + or (cmd, subcommand) in _NO_SECRET_INJECTION) + + +def _load_secret_env(cmd: str, subcommand: Optional[str] = None) -> None: + """機密を復号して自プロセスの環境変数へ載せる。 + + 起動ラッパーは共通の機密ファイルを読み込まなくなった (plan35 §4.4)。 + 従来はラッパーが全コマンドに対して値を環境変数として渡していたため、 + 同じ範囲を Python 側で肩代わりする。ここで載せておけば、エディタ起動や + Docker Compose の変数展開など、値を必要とする処理が従来どおり動く。 + + 復号に失敗しても停止しない。鍵が未整備でも `env keygen` や `--help` は + 使えるべきで、値が本当に要る操作 (コンテナ起動など) は各コマンド側で + 改めて必須として読み込む。 + """ + if _skip_secret_injection(cmd, subcommand): + return + root = os.environ.get('DEVBASE_ROOT') + if not root: + return + try: + from devbase.env import runtime as _runtime + + _runtime.inject(Path(root), _runtime.current_project_name(Path(root))) + except DevbaseError as e: + logger.debug("機密を読み込めませんでした: %s", e) + except Exception as e: # noqa: BLE001 - 通常コマンドを暗号化都合で倒さない + logger.debug("機密の読み込みで想定外のエラー: %s", e) + + # DEVBASE_ROOT 必須コマンドの定義: cmd -> (module, function, args を渡すか)。 # 起動コストを抑えるため import は dispatch 時に遅延させる (従来の関数内 import と同等)。 _ROOT_COMMANDS = { diff --git a/lib/devbase/commands/container.py b/lib/devbase/commands/container.py index 8c7c65e3..b34ec6fa 100644 --- a/lib/devbase/commands/container.py +++ b/lib/devbase/commands/container.py @@ -7,6 +7,7 @@ import subprocess import sys import time +from contextlib import contextmanager from datetime import datetime, timezone from pathlib import Path from typing import Optional @@ -35,8 +36,101 @@ # 共通ヘルパー # --------------------------------------------------------------------------- +def _devbase_root() -> Optional[Path]: + root = os.environ.get('DEVBASE_ROOT') + return Path(root) if root else None + + +def _inject_secrets(*, required: bool): + """機密を復号して自プロセスの環境変数へ載せ、載せた内容を返す。 + + ``docker compose`` は自分を起動したプロセスの環境変数から値を解決するため、 + Compose を呼ぶ前にここを通す。生成する構成には変数名しか書かないので、 + 暗号文も平文ファイルも Compose には渡らない (plan35 §4.3)。 + + 戻り値を変数名の一覧ではなく :class:`~devbase.env.runtime.SecretEnv` に + しているのは、構成生成側が**由来 (共通 / プロジェクト) ごとの内訳**を必要 + とするため。サービスが元々参照していなかった由来の機密まで渡さないための + 材料になる。 + + ``required=False`` の経路 (down / ps / logs など) では、鍵が無い・復号に + 失敗したというだけでコンテナを止められなくなるのは困るため、警告に留めて + 続行する。値が要るのは主に起動時の変数展開であり、停止や状態確認には + 要らない。 + + 載せ直す前に :func:`~devbase.env.runtime.clear_injected` を通すのは、 + プロジェクト切替の残留対策。``cli._load_secret_env`` は dispatch の**前**に + 「現在地のプロジェクト」の機密を載せるが、TUI や + ``python -m devbase.cli project up `` の直接起動ではその後 + ``_resolve_project_name`` が対象プロジェクトへ切り替わる。ここは切替・chdir + の**後**に呼ばれるので、載せ直しで上書きできる。ただし**上書きだけでは + 足りない**: 切替先に同名のキーが無い機密 (切替元プロジェクト固有のもの) は + 上書きされず残り、Compose や子プロセスへ引き継がれてしまうため、先に + 取り除く。非機密設定 (``env``) 側の ``_CALLER_ENV_KEYS`` / + :func:`_resolve_project_name` と同じ扱いを機密にも与えることになる。 + """ + from devbase.env import runtime as _runtime + from devbase.errors import DevbaseError + + root = _devbase_root() + if root is None: + return _runtime.SecretEnv() + _runtime.clear_injected() + try: + return _runtime.inject(root, _runtime.current_project_name(root)) + except DevbaseError as e: + if required: + raise + logger.warning("機密を読み込めませんでした (続行します): %s", e) + return _runtime.SecretEnv() + + +def _generate_compose_for(scale: int, secrets) -> Path: + """機密の内訳を渡してスケール構成を生成する""" + return generate_scaled_compose( + scale, + secret_env_names=secrets.names, + global_env_names=secrets.global_names, + project_env_names=secrets.project_names, + ) + + +@contextmanager +def _previous_scale_compose(): + """生成前の override compose を退避し、``down`` へ渡すパスとして貸し出す。 + + 起動時の構成生成 (= 機密の復号) は既存コンテナの停止より**前**に済ませたい。 + 停止してから復号に失敗すると、起動できないだけでなく稼働中の開発環境まで + 止まったままになるため。一方で + :func:`~devbase.volume.compose.generate_scaled_compose` は + ``.docker-compose.scale.yml`` を上書きするので、停止には**旧**構成が要る。 + 新構成で停止すると、スケールを縮める起動で新構成に無いインスタンスが + 取り残されるため。 + + 退避が無い (初回起動) 場合は ``None`` を返し、呼び出し側は素の + ``docker compose down`` へ委ねる。ブロック内で例外が起きたときは旧構成を + 書き戻す。生成が途中で失敗しても ``down`` / ``ps`` が参照する構成を壊さない + ため。 + """ + original = _SCALE_COMPOSE_FILE.read_bytes() if _SCALE_COMPOSE_FILE.exists() else None + if original is None: + yield None + return + + backup = Path(f'{_SCALE_COMPOSE_FILE}.prev') + backup.write_bytes(original) + try: + yield backup + except BaseException: + _SCALE_COMPOSE_FILE.write_bytes(original) + raise + finally: + backup.unlink(missing_ok=True) + + def _compose_run(subcommand: str, *extra_args: str) -> int: """docker compose コマンドを実行する共通関数""" + _inject_secrets(required=False) cmd = ['docker', 'compose'] if _SCALE_COMPOSE_FILE.exists(): cmd.extend(['-f', str(_SCALE_COMPOSE_FILE)]) @@ -290,6 +384,12 @@ def _resolve_project_name(project_name: str) -> bool: if not already_there: caller_env_keys = _env_var_keys(Path('env')) os.chdir(target) + # ``PWD`` も併せて切り替える。機密の解決 ( + # :func:`devbase.env.runtime.current_project_name`) は wrapper の cd を前提に + # ``os.environ['PWD']`` を先に見るため、os.chdir だけだと切替前の PWD が残り、 + # 切替先ではなく呼び出し元プロジェクトの機密を読んでしまう + # (TUI の ``_run_in_project`` が PWD を差し替えているのと同じ理由)。 + os.environ['PWD'] = str(target) target_env_keys = _env_var_keys(Path('env')) for key in caller_env_keys - target_env_keys: os.environ.pop(key, None) @@ -548,15 +648,16 @@ def cmd_up(project_name: str = None, scale: int = None, logger.info("[1.5/6] Ensuring network exists...") ensure_network('devbase_net') - logger.info("[2/6] Stopping existing containers...") - if _SCALE_COMPOSE_FILE.exists(): - docker_compose_down(compose_file=_SCALE_COMPOSE_FILE) - else: - docker_compose_down() + # 復号と構成生成は既存コンテナを止める**前**に済ませる。鍵の紛失・権限 + # 不備・暗号文の破損でここが失敗しても、稼働中の開発環境を落としたまま + # にしないため。 + with _previous_scale_compose() as down_compose_file: + logger.info("[2/6] Generating scaled compose file...") + override_file = _generate_compose_for(scale, _inject_secrets(required=True)) + logger.info("Generated: %s", override_file) - logger.info("[3/6] Generating scaled compose file...") - override_file = generate_scaled_compose(scale) - logger.info("Generated: %s", override_file) + logger.info("[3/6] Stopping existing containers...") + docker_compose_down(compose_file=down_compose_file) logger.info("[4/6] Starting containers...") docker_compose_up(compose_file=override_file, detach=True) @@ -594,6 +695,7 @@ def cmd_up(project_name: str = None, scale: int = None, def cmd_down() -> int: """Stop and remove containers""" + _inject_secrets(required=False) compose_file = _SCALE_COMPOSE_FILE if _SCALE_COMPOSE_FILE.exists() else None docker_compose_down(compose_file=compose_file) @@ -615,6 +717,7 @@ def cmd_down() -> int: def cmd_login(index: str = '1') -> int: """Login to container""" + _inject_secrets(required=False) dev_service = get_dev_service_name() if _SCALE_COMPOSE_FILE.exists(): @@ -687,7 +790,8 @@ def cmd_scale(new_scale: int, project_name: str = None) -> int: ensure_network('devbase_net') logger.info("[3/5] Generating scaled compose file...") - override_file = generate_scaled_compose(new_scale) + override_file = _generate_compose_for( + new_scale, _inject_secrets(required=True)) logger.info("Generated: %s", override_file) logger.info("[4/5] Starting new containers (%d..%d)...", current_scale + 1, new_scale) @@ -871,13 +975,27 @@ def _ensure_env_files() -> bool: return False devbase_root_env = devbase_root / '.env' - if project_env.exists() and devbase_root_env.exists(): + # 機密が暗号化されていれば平文の .env は存在しない。ファイルの有無ではなく + # 秘密ストアに設定があるかで判定しないと、移行済みの環境で毎回 env init が + # 走ってしまう。 + from devbase.env import runtime as _runtime + from devbase.env.secret_store import SecretRef, SecretStore + + store = SecretStore(devbase_root) + has_global = store.exists(SecretRef.for_global()) + + project_name = _runtime.current_project_name(devbase_root) + has_project = project_env.exists() + if not has_project and project_name: + has_project = store.exists(SecretRef.for_project(project_name)) + + if has_project and has_global: return True missing_files = [] - if not project_env.exists(): + if not has_project: missing_files.append("project .env") - if not devbase_root_env.exists(): + if not has_global: missing_files.append(f"devbase root .env ({devbase_root_env})") logger.info("Missing: %s", ', '.join(missing_files)) @@ -886,7 +1004,7 @@ def _ensure_env_files() -> bool: success = True child_env = {**os.environ, 'PYTHONPATH': str(devbase_root / 'lib')} - if not devbase_root_env.exists(): + if not has_global: logger.info("Creating devbase root .env...") try: result = subprocess.run( @@ -902,7 +1020,7 @@ def _ensure_env_files() -> bool: logger.error("Running env init for devbase root: %s", e) success = False - if not project_env.exists(): + if not has_project: logger.info("Creating project .env...") try: project_env.touch() diff --git a/lib/devbase/commands/env.py b/lib/devbase/commands/env.py index 9d5c0393..2d99f463 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,69 @@ 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 からプロジェクト名を解決する (実体は :mod:`devbase.env.runtime`)。 + + 同じ判定をコンテナ起動側 (機密の合成) でも使うため、実装は 1 箇所に置く。 + """ + from devbase.env import runtime as _runtime + + return _runtime.current_project_name(devbase_root, cwd) + + +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,11 +94,35 @@ 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), + 'exec': lambda: cmd_env_exec(devbase_root, + list(getattr(args, 'argv', []) or [])), + 'encrypt': lambda: _migrate(args).cmd_env_encrypt( + devbase_root, + dry_run=getattr(args, 'dry_run', False), + assume_yes=getattr(args, 'assume_yes', False), + projects=list(getattr(args, 'projects', []) or []) or None), + 'decrypt': lambda: _migrate(args).cmd_env_decrypt( + devbase_root, + dry_run=getattr(args, 'dry_run', False), + assume_yes=getattr(args, 'assume_yes', False), + projects=list(getattr(args, 'projects', []) or []) or None), + 'rekey': lambda: _ops().cmd_env_rekey( + devbase_root, + add=list(getattr(args, 'add_recipients', []) or []), + remove=list(getattr(args, 'remove_recipients', []) or []), + dry_run=getattr(args, 'dry_run', False), + assume_yes=getattr(args, 'assume_yes', False)), + 'doctor': lambda: _ops().cmd_env_doctor(devbase_root), + 'keygen': lambda: cmd_env_keygen(devbase_root, + force=getattr(args, 'force', False), + assume_yes=getattr(args, 'assume_yes', False)), } handler = handlers.get(subcmd) @@ -45,10 +133,54 @@ def cmd_env(devbase_root: Path, args) -> int: return 1 +def _ops(): + """受信者更新 / 点検の実装モジュール (import を遅延させる)""" + from devbase.commands import env_ops + + return env_ops + + +def _migrate(_args=None): + """移行コマンドの実装モジュール (import を遅延させる)""" + from devbase.commands import env_migrate + + return env_migrate + + +def cmd_env_exec(devbase_root: Path, argv) -> int: + """機密を環境変数として渡した状態でコマンドを実行する。 + + 起動ラッパーは共通の機密ファイルを読み込まなくなったため、ホスト側で動く + 処理のうち値を必要とするもの (Docker Compose の変数展開など) は、この + コマンドを通して実行する (plan35 §4.4)。復号結果は子プロセスの環境変数 + としてのみ渡り、ファイルには書き出さない。 + """ + from devbase.env import runtime as _runtime + + # argparse.REMAINDER は区切りの `--` も残すため、先頭のものだけ取り除く。 + # 2 つ目以降はコマンド自身への引数なのでそのまま渡す。 + if argv and argv[0] == '--': + argv = argv[1:] + + if not argv: + logger.error("実行するコマンドを指定してください: devbase env exec -- CMD [ARGS...]") + return 1 + + env = _runtime.child_env(devbase_root, + _runtime.current_project_name(devbase_root)) + try: + return subprocess.run(argv, env=env).returncode + except FileNotFoundError: + logger.error("コマンドが見つかりません: %s", argv[0]) + return 127 + except OSError as e: + logger.error("コマンドを実行できませんでした (%s): %s", argv[0], e) + return 1 + + 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: @@ -57,11 +189,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) @@ -80,14 +210,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) @@ -218,37 +347,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' - - 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() + proj_env = _project_env(devbase_root) + if proj_env is not None and proj_env.file_exists(): + 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') @@ -273,36 +396,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) @@ -312,44 +425,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)]) + + return _edit_encrypted(env_file, editor) -def cmd_env_project(devbase_root: Path) -> int: - """プロジェクト固有変数の設定(対話式)""" - current_dir = Path(os.environ.get('PWD', os.getcwd())) - projects_dir = devbase_root / 'projects' + +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/ 配下で実行してください") + # 辞書ではなく原文のバイト列を取り出す。辞書経由だとコメント・空行・ + # ``export`` 表記が落ち、編集しただけで利用者の書いた内容が消えてしまう。 + original = env_file.load_bytes() + 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, original) + 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 + + env_file.save_bytes(after) + logger.info("保存しました: %s (%d変数)", env_file.path, len(edited)) + 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} プロジェクト環境変数 ===") @@ -406,7 +587,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 @@ -458,6 +639,124 @@ def cmd_env_import(devbase_root: Path, args) -> int: return import_bundle(devbase_root, opts) +def _has_encrypted_secrets(devbase_root: Path) -> bool: + """暗号化済みの機密が 1 つでも存在するか""" + from devbase.env.secret_store import SecretStore, SecretRef + + store = SecretStore(devbase_root) + if store.age.exists(SecretRef.for_global()): + return True + return bool(store.project_names()) + + +def _print_key_backup_notice(path, public: str) -> None: + print() + print("=" * 60) + print("鍵のバックアップを必ず取ってください") + print("=" * 60) + print(f" 鍵ファイル: {path}") + print(f" 公開鍵 : {public}") + print() + print(" この鍵を失うと、暗号化した機密は誰にも復号できません。") + print(" パスワード管理ツールなど、端末とは別の場所へ複製を保管してください。") + print("=" * 60) + + +def cmd_env_keygen(devbase_root: Path, force: bool = False, + assume_yes: bool = False) -> int: + """devbase 専用の age 鍵を生成する + + 生成先は必ず ``agekeys.key_file_path()`` (= ``DEVBASE_AGE_KEY_FILE`` があれば + それ、無ければ ``~/.config/devbase/age/keys.txt``) にする。生成先を CLI 引数で + 自由に選べるようにすると、復号側の ``agekeys.resolve_identities()`` はそのパスを + 探索しないため「生成した鍵で保存した機密を復号できない」状態を作れてしまう。 + 場所を変えたい場合は ``DEVBASE_AGE_KEY_FILE`` を設定してから実行してもらい、 + 生成先と探索先が構造的に一致する契約を保つ。 + """ + from devbase.env import agekeys + from devbase.errors import DevbaseError + + path = agekeys.key_file_path() + + if path.exists() and not force: + try: + public = agekeys.read_public_key(path) + except DevbaseError as e: + logger.error("%s", e) + return 1 + print(f"鍵は既に存在します: {path}") + print(f" 公開鍵: {public}") + print(" 作り直す場合: devbase env keygen --force") + return 0 + + # keygen はワークスペース固有の受信者リスト (secrets/recipients.txt) を触らない。 + # 鍵はグローバル (~/.config/devbase/age/keys.txt) なのに受信者リストは + # ワークスペースごとに存在するため、ここで書き込むと別ワークスペースには旧公開鍵が + # 取り残され、既に失われた秘密鍵に対応する公開鍵で暗号化してしまう。 + # agekeys.resolve_recipients() は recipients.txt が無ければ鍵ファイルの公開鍵へ + # フォールバックするので、単独利用ではリストを作る必要がない。チーム運用で明示的に + # 受信者を足す経路 (rekey) だけが recipients.txt を作る。 + # + # 書き込みの原子性は agekeys.generate_key_file → + # io_common.write_secure_bytes_atomic (一時ファイル + fsync + os.replace) が + # 担保しており、生成が途中で失敗しても既存の鍵ファイルは元のまま残る。 + # したがってこの層で「内容をメモリへ退避して書き戻す」手動ロールバックは重ねない。 + # 重ねてもリストア自体が失敗しうるぶん壊れ方の種類が増えるだけで、守れるものが + # 増えないため。将来ここへロールバックを足したくなったら、まず io 層の原子性が + # 破れていないかを疑うこと。 + # + # 一方で「既存鍵が在るのに読めない」ときに中止するガードは、原子性とは別の目的で + # 残す。読めないだけなら権限を直せば回収できる可能性があるのに、生成が成功すると + # 旧鍵は上書きで確実に消えるため。判定は確認プロンプトより前に置き、 + # 「同意させてから中止する」空振りを避ける。 + if path.exists() and not os.access(path, os.R_OK): + logger.error( + "既存の鍵ファイルを読めないため、上書きを中止しました: %s", path) + logger.error( + "権限を確認するか、不要と判断できる場合は手動で退避してから" + "再実行してください") + return 1 + + # ここへ来るのは「鍵が無い」か「--force で作り直す」場合だけ。後者は既存鍵を + # 捨てる操作なので、常に明示的な同意を取る。 + # + # 鍵は ~/.config/devbase/age/keys.txt = 全ワークスペース共通のグローバル資産 + # なのに対し、暗号化された機密はワークスペースごとに散らばっている。同意の要否を + # カレントの DEVBASE_ROOT に機密があるか (_has_encrypted_secrets) で決めると、 + # まだ機密の無い別プロジェクトで --force した瞬間に無警告で鍵が消え、他プロジェクトの + # 機密が復旧不能になる。カレントの状況は「文言をどれだけ強くするか」にだけ使う。 + if path.exists() and not assume_yes: + print("鍵ファイルを作り直します。この鍵は全プロジェクト共通です。") + print(f" 鍵ファイル: {path}") + if _has_encrypted_secrets(devbase_root): + print(" このワークスペースには暗号化済みの機密があり、" + "旧鍵でしか復号できないものは失われます。") + print(" 他のワークスペースで暗号化した機密も、" + "旧鍵を失うと復号できなくなります。") + print(" 続行前に旧鍵のバックアップがあるか確認してください。") + answer = safe_input("続行しますか? (yes と入力): ") + if answer != 'yes': + print("中止しました") + return 1 + + # force はコマンドの --force をそのまま渡す。ここで無条件に force=True に + # すると、上の path.exists() 判定から実際の書き込みまでの隙間に他プロセスが + # 鍵を作っていた場合、利用者が上書きを要求していないのにその鍵を消してしまう + # (TOCTOU)。force=False なら agekeys 側が O_CREAT|O_EXCL で作るため、隙間に + # 現れた鍵は上書きされずエラーで止まる。 + try: + path, public = agekeys.generate_key_file(path, force=force) + except (DevbaseError, OSError) as e: + # 新規生成は排他作成、--force の差し替えは atomic なので、いずれの失敗でも + # 既に在る鍵はそのまま残っている。 + logger.error("%s", e) + return 1 + + logger.info("鍵を生成しました: %s", path) + _print_key_backup_notice(path, public) + return 0 + + def _update_source_metadata(devbase_root: Path, env_file: EnvFile) -> None: """ソースメタデータを更新する""" sources = SourcesManager(devbase_root) diff --git a/lib/devbase/commands/env_migrate.py b/lib/devbase/commands/env_migrate.py new file mode 100644 index 00000000..04dd4849 --- /dev/null +++ b/lib/devbase/commands/env_migrate.py @@ -0,0 +1,641 @@ +"""平文と暗号化構成のあいだを往復する移行コマンド + +``devbase env encrypt`` は平文の設定を暗号化ストアへ移し、``devbase env decrypt`` +は平文へ戻す。どちらも以下を守る (plan35 §9): + +- **無言で消さない**: 元の平文はバックアップへ退避し、削除は利用者に委ねる。 + 退避先は排他的に作り、既存のバックアップへは決して書き込まない + (:func:`_create_backup_dir`) +- **原文のまま往復させる**: 機密は ``KEY=VALUE`` の辞書へ畳まず、ファイルの + バイト列のまま暗号化する。``decrypt`` するとコメント・空行・``export`` + 表記まで含めて暗号化前のファイルへ戻る。ただし原文が保たれるのは値を + 書き換えるまでで、``devbase env set`` などで更新すると内容は ``EnvFile`` + の書式へ正規化される (平文だけを使っていた頃と同じ挙動) +- **読み戻せることを確認してから消す**: 暗号化した直後に復号し、元の内容と + 一致した対象だけ平文を退避する。鍵の設定を間違えたまま平文を失うと復旧できない +- **構成ファイルの変更は差分を見せてから行う**: 利用者が独自に編集した + ``compose.yml`` を黙って書き換えない +- **中途半端な状態で終わらない**: 移行は「機密ファイルの移動」と + ``compose.yml`` の書き換えが噛み合って初めて意味を持つ。どちらか片方だけ + 済んだ状態は「構成ファイルが存在しないファイルを参照する」壊れた設定になる + ため、実行した操作ごとに取り消し手続きを積み、どこで失敗しても逆順に + 巻き戻してから ``1`` を返す (:class:`devbase.env.rollback.Rollback`) +""" + +from __future__ import annotations + +import os +import shutil +import stat +from dataclasses import dataclass, field +from datetime import datetime +from pathlib import Path +from typing import Dict, List, Optional, Sequence, Set, Tuple + +from devbase.env import agekeys, compose_migrate, io_common +from devbase.env.rollback import Rollback +from devbase.env.secret_store import ( + MODE_AGE, + MODE_PLAINTEXT, + SecretRef, + SecretStore, +) +from devbase.env.store import safe_input +from devbase.errors import DevbaseError +from devbase.log import get_logger + +logger = get_logger(__name__) + + +class MigrationError(DevbaseError): + """移行を中止して巻き戻すべき失敗""" + + +@dataclass +class Target: + """移行対象の 1 参照""" + + ref: SecretRef + values: Dict[str, str] = field(default_factory=dict) + + @property + def label(self) -> str: + return self.ref.label() + + +def _timestamp() -> str: + return datetime.now().strftime('%Y%m%d%H%M%S') + + +def _project_names(devbase_root: Path) -> List[str]: + projects_dir = Path(devbase_root) / 'projects' + if not projects_dir.is_dir(): + return [] + return sorted(p.name for p in projects_dir.iterdir() if p.is_dir()) + + +def _select_refs(devbase_root: Path, store: SecretStore, wanted_mode: str, + projects: Optional[Sequence[str]]) -> List[SecretRef]: + """指定された保存形式で存在する参照を集める。 + + ``projects`` を指定した場合は共通設定を対象から外す。「このプロジェクトだけ」 + と言われたのに全体に効く共通設定まで動かすと、取り消しの利かない操作を + 利用者の意図より広く実行してしまう。 + """ + refs: List[SecretRef] = [] + if not projects and store.mode(SecretRef.for_global()) == wanted_mode: + refs.append(SecretRef.for_global()) + + names = list(projects) if projects else _project_names(devbase_root) + for name in names: + ref = SecretRef.for_project(name) + if store.mode(ref) == wanted_mode: + refs.append(ref) + return refs + + +def _affected_projects(refs: Sequence[SecretRef]) -> List[str]: + return [ref.name for ref in refs if ref.kind == 'project' and ref.name] + + +def _confirm(prompt: str, assume_yes: bool) -> bool: + if assume_yes: + return True + return safe_input(prompt) == 'yes' + + +# 巻き戻し (:class:`devbase.env.rollback.Rollback`) は ``env rekey`` と共有する。 +# 移行は複数の破壊的な操作 (暗号化・平文の退避・構成ファイルの書き換え・暗号文の +# 削除) が連なるため、操作ごとに取り消し手続きを積んで逆順に戻せるようにする。 + + +# --------------------------------------------------------------------------- +# encrypt +# --------------------------------------------------------------------------- + +def cmd_env_encrypt(devbase_root: Path, *, dry_run: bool = False, + assume_yes: bool = False, + projects: Optional[Sequence[str]] = None) -> int: + """平文の設定を暗号化ストアへ移す""" + root = Path(devbase_root) + store = SecretStore(root) + + try: + recipients = agekeys.resolve_recipients(root) + except DevbaseError as e: + logger.error("%s", e) + return 1 + + refs = _select_refs(root, store, MODE_PLAINTEXT, projects) + if not refs: + print("暗号化する平文の設定はありません") + return 0 + + print("\n=== 暗号化する設定 ===") + for ref in refs: + print(f" {ref.label():<24} {store.plaintext.path(ref)}" + f" → {store.age.path(ref)}") + print(f"\n受信者 ({len(recipients)} 件):") + for spec in recipients: + print(f" {spec}") + + # 構成ファイルを読めない / 自動では直せない機密参照がある場合はここで中止する。 + # 平文にはまだ触れていないので、返すだけで元の状態が保たれる。 + try: + compose_changes = _plan_compose_changes(root, refs) + except MigrationError as e: + logger.error("暗号化を中止しました: %s", e) + return 1 + if compose_changes: + print("\n=== コンテナ構成の変更 ===") + for path, (_, _, patch) in compose_changes.items(): + print(f"\n--- {path}") + print(patch, end='' if patch.endswith('\n') else '\n') + + if dry_run: + print("\n(--dry-run のため変更していません)") + return 0 + + print("\n" + "=" * 60) + print("暗号化すると、この鍵を失った時点で設定は復旧できなくなります。") + print(f" 鍵ファイル: {agekeys.key_file_path()}") + print(" 鍵のバックアップを取ってから続行してください。") + print("=" * 60) + if not _confirm("続行しますか? (yes と入力): ", assume_yes): + print("中止しました") + return 1 + + rollback = Rollback() + try: + # 1. 全対象を暗号化して読み戻せることを確認する (平文にはまだ触れない) + # 2. バックアップ先を排他的に作り、平文をそこへ移す + # 3. compose.yml を書き換える + # 平文を消すのは「全対象の暗号文が読み戻せた」と分かってからにする。 + _encrypt_and_verify(store, refs, rollback) + backup_dir = _create_backup_dir( + root / 'backups' / 'env-encrypt' / _timestamp()) + # 中身を戻したあとに空のバックアップ先だけ残ると「まだ退避された + # ものがある」と誤解させるので、作ったディレクトリも巻き戻しで畳む。 + rollback.push( + f"空になったバックアップ先 {backup_dir} を削除する", + lambda d=backup_dir: _prune_empty_dirs(d, d)) + moved = _move_plaintext_to_backup(store, refs, backup_dir, rollback) + _apply_compose_changes(compose_changes, rollback) + except (DevbaseError, OSError) as e: + logger.error("暗号化を中止し、変更を巻き戻します: %s", e) + rollback.unwind() + return 1 + + print("\n=== 完了 ===") + print("元の平文は次の場所へ退避しました。内容を確認したうえで削除してください:") + for path in moved: + print(f" {path}") + print("\n削除する場合:") + print(f" rm -rf {backup_dir}") + return 0 + + +def _encrypt_and_verify(store: SecretStore, refs: Sequence[SecretRef], + rollback: Rollback) -> None: + """全対象を暗号化し、読み戻して元の内容と一致することを確認する。 + + ここでは平文に一切触れない。鍵の指定を誤ったまま平文を失うと、誰にも + 復号できないファイルだけが残るため、「読み戻せた」ことを全対象について + 確かめてから次のフェーズへ進む。途中で失敗しても、この実行で作った + 暗号文を消せば元の状態に戻る。 + + 運ぶのは辞書ではなく **平文ファイルの生バイト列** である。``KEY=VALUE`` の + 辞書へ畳むとコメント・空行・``export KEY=...`` 表記・値のクォートが落ち、 + ``decrypt`` しても暗号化前の状態には戻らない。バイト列のまま暗号化し、 + バイト列のまま読み戻して一致を確かめる。 + + なお原文が保たれるのは **値を書き換えるまで** である。``devbase env set`` + などで値を更新すると辞書経由の ``save`` が走り、内容は ``EnvFile`` の書式へ + 正規化される。平文しか無かった頃と同じ挙動であり、暗号化しても変わらない。 + """ + for ref in refs: + original = store.plaintext.load_bytes(ref) + store.age.save_bytes(ref, original) + # 対象は MODE_PLAINTEXT で選んである = この .age はこの実行で作った + # ものだけ。巻き戻しで既存の暗号文を巻き添えにする心配はない。 + rollback.push( + f"{ref.label()}の暗号文 {store.age.path(ref)} を削除する", + lambda r=ref: store.age.remove(r)) + + restored = store.age.load_bytes(ref) + if restored != original: + raise MigrationError( + f"{ref.label()}の暗号化結果が元の内容と一致しません") + logger.info("%s を暗号化しました: %s", ref.label(), store.age.path(ref)) + + +#: バックアップ先の名前が衝突したときに試す一意な suffix の上限。 +#: ここまでぶつかるのは「同じ秒に何十回も移行している」か「先回りして名前を +#: 作られている」異常事態なので、無限に別名を探し続けずに中止して知らせる。 +_BACKUP_DIR_MAX_ATTEMPTS = 100 + + +def _create_backup_dir(preferred: Path) -> Path: + """バックアップ先を **排他的に** 作成し、実際に作れたパスを返す。 + + ディレクトリ名は秒単位の日時なので、同じ秒に 2 回移行すると衝突しうる。 + 既存のディレクトリへそのまま書くと :func:`shutil.move` が同名の + ``global.env`` やプロジェクトの env を上書きし、「削除しないはずの過去の + 平文」を失う。そこで ``exist_ok=False`` で作り、既にあれば ``-2`` ``-3`` … + と一意な名前へ寄せて、**既存のディレクトリへは決して書き込まない**。 + + Raises: + MigrationError: 上限まで試しても空きが見つからない場合、または + ディレクトリを作成できない場合 (どちらも平文にはまだ触れていない + 段階なので、返せば元の状態が保たれる) + """ + # 親階層 (backups/env-encrypt) はスナップショットなど他機能とも共有するので + # 権限は既定のまま。退避先そのものは平文の機密が置かれるため 0700 で作る。 + try: + preferred.parent.mkdir(parents=True, exist_ok=True) + except OSError as e: + raise MigrationError( + f"バックアップ先を作成できませんでした ({preferred.parent}): {e}") from e + + for attempt in range(1, _BACKUP_DIR_MAX_ATTEMPTS + 1): + candidate = (preferred if attempt == 1 + else preferred.with_name(f'{preferred.name}-{attempt}')) + try: + candidate.mkdir(mode=0o700, exist_ok=False) + except FileExistsError: + continue + except OSError as e: + raise MigrationError( + f"バックアップ先を作成できませんでした ({candidate}): {e}") from e + return candidate + raise MigrationError( + f"バックアップ先 {preferred} が既にあり、" + f"{_BACKUP_DIR_MAX_ATTEMPTS} 回試しても空いている名前が見つかりません" + "でした。既存のバックアップを片付けてから再実行してください") + + +def _move_plaintext_to_backup(store: SecretStore, refs: Sequence[SecretRef], + backup_dir: Path, + rollback: Rollback) -> List[Path]: + """全対象の平文をバックアップへ移す (取り消し: 元の場所へ戻す)""" + moved: List[Path] = [] + for ref in refs: + source = store.plaintext.path(ref) + dest = _move_to_backup(source, ref, backup_dir) + rollback.push( + f"{ref.label()}の平文を {source} へ戻す", + lambda s=source, d=dest: _move_back(d, s, backup_dir)) + moved.append(dest) + return moved + + +def _move_to_backup(source: Path, ref: SecretRef, backup_dir: Path) -> Path: + """平文ファイルをバックアップへ移す (コピーではなく移動)。 + + ``backup_dir`` は :func:`_create_backup_dir` がこの実行のために排他的に + 作ったディレクトリで、既存のバックアップとは決して重ならない。その内側の + ``projects/`` は複数の対象で共有するので ``exist_ok=True`` で掘る + (対象ごとにファイル名が一意なため、ここで上書きは起こらない)。 + """ + if ref.kind == 'global': + dest = backup_dir / 'global.env' + else: + dest = backup_dir / 'projects' / f'{ref.name}.env' + dest.parent.mkdir(mode=0o700, parents=True, exist_ok=True) + shutil.move(str(source), str(dest)) + return dest + + +def _move_back(dest: Path, source: Path, backup_dir: Path) -> None: + """バックアップへ移した平文を元の場所へ戻す""" + shutil.move(str(dest), str(source)) + _prune_empty_dirs(dest.parent, backup_dir) + + +def _prune_empty_dirs(start: Path, stop: Path) -> None: + """``start`` から ``stop`` まで、空になったディレクトリを畳む。 + + 中身を戻したのに空のバックアップディレクトリだけ残ると「まだ退避された + ものがある」と誤解させる。見た目の掃除でしかないので、消せなくても + 巻き戻しの失敗としては扱わない (中身は既に元の場所へ戻っている)。 + """ + current = start + while True: + try: + os.rmdir(current) + except OSError: + return + if current == stop or current == current.parent: + return + current = current.parent + + +# --------------------------------------------------------------------------- +# decrypt +# --------------------------------------------------------------------------- + +def cmd_env_decrypt(devbase_root: Path, *, dry_run: bool = False, + assume_yes: bool = False, + projects: Optional[Sequence[str]] = None) -> int: + """暗号化された設定を平文へ戻す""" + root = Path(devbase_root) + store = SecretStore(root) + + refs = _select_refs(root, store, MODE_AGE, projects) + if not refs: + print("平文へ戻す暗号化済みの設定はありません") + return 0 + + print("\n=== 平文へ戻す設定 ===") + for ref in refs: + print(f" {ref.label():<24} {store.age.path(ref)}" + f" → {store.plaintext.path(ref)}") + + try: + compose_changes = _plan_compose_changes(root, refs, restore=True) + except MigrationError as e: + logger.error("復号を中止しました: %s", e) + return 1 + if compose_changes: + print("\n=== コンテナ構成の変更 ===") + for path, (_, _, patch) in compose_changes.items(): + print(f"\n--- {path}") + print(patch, end='' if patch.endswith('\n') else '\n') + + if dry_run: + print("\n(--dry-run のため変更していません)") + return 0 + + print("\n平文に戻すと、ディスク上に認証情報がそのまま置かれた状態になります。") + if not _confirm("続行しますか? (yes と入力): ", assume_yes): + print("中止しました") + return 1 + + rollback = Rollback() + try: + # 1. 全対象の暗号文を読み込み、復号できることを確認する + # 2. 平文を書き出す + # 3. compose.yml を復元する + # 4. 最後に暗号文を削除する + # + # 破壊的な削除を最後に置くのは、途中で失敗したときに失うものを最小に + # するため。2 や 3 で失敗しても暗号文はまだディスク上にあり、巻き戻しは + # 「書いた平文を消す」だけで済む。逆に先に消してしまうと、以降の失敗の + # 巻き戻しがメモリ上の内容頼みになり、復旧の余地が狭くなる。 + loaded = _load_encrypted(store, refs) + _write_plaintext(store, loaded, rollback) + _apply_compose_changes(compose_changes, rollback) + _remove_encrypted(store, loaded, rollback) + except (DevbaseError, OSError) as e: + logger.error("復号を中止し、変更を巻き戻します: %s", e) + rollback.unwind() + return 1 + + print("\n=== 完了 ===") + return 0 + + +def _load_encrypted(store: SecretStore, refs: Sequence[SecretRef], + ) -> List[Tuple[SecretRef, bytes, bytes]]: + """全対象の暗号文を読み込み、復号できることを確認する。 + + Returns: + ``(参照, 復号した平文のバイト列, 暗号文のバイト列)`` の並び + + 平文は辞書ではなくバイト列で控える。``encrypt`` が原文をそのまま暗号化して + いるので、そのまま書き戻せばコメント・空行・``export`` 表記まで含めて + 暗号化前のファイルへ戻る。 + + 暗号文の生バイト列も控える。最後に削除した ``.age`` を、巻き戻しでそのまま + 書き戻せるようにするため (再暗号化すると内容が同じでもバイト列は変わり、 + 「元に戻した」と言い切れなくなる)。 + """ + loaded: List[Tuple[SecretRef, bytes, bytes]] = [] + for ref in refs: + path = store.age.path(ref) + try: + blob = path.read_bytes() + except OSError as e: + raise MigrationError(f"暗号文を読み込めませんでした ({path}): {e}") from e + loaded.append((ref, store.age.load_bytes(ref), blob)) + return loaded + + +def _write_plaintext(store: SecretStore, + loaded: Sequence[Tuple[SecretRef, bytes, bytes]], + rollback: Rollback) -> None: + """全対象の平文を書き出す (取り消し: 書いた平文を削除)""" + for ref, plain, _ in loaded: + store.plaintext.save_bytes(ref, plain) + # 対象は MODE_AGE で選んである = この平文はこの実行で作ったものだけ。 + rollback.push( + f"{ref.label()}の平文 {store.plaintext.path(ref)} を削除する", + lambda r=ref: store.plaintext.remove(r)) + logger.info("%s を平文へ戻しました: %s", ref.label(), + store.plaintext.path(ref)) + + +def _remove_encrypted(store: SecretStore, + loaded: Sequence[Tuple[SecretRef, bytes, bytes]], + rollback: Rollback) -> None: + """暗号文を削除する (取り消し: 控えた生バイト列で復元)""" + for ref, _, blob in loaded: + path = store.age.path(ref) + store.age.remove(ref) + rollback.push( + f"{ref.label()}の暗号文 {path} を復元する", + lambda p=path, b=blob: io_common.write_secure_bytes_atomic(p, b)) + + +# --------------------------------------------------------------------------- +# コンテナ構成の書き換え +# --------------------------------------------------------------------------- + +def _compose_targets(path: Path, *, has_global: bool, + project_names: Sequence[str]) -> Set[str]: + """この ``compose.yml`` で触ってよい参照の種別を決める。 + + 暗号化 (無効化) と復号 (復元) で同じ判定を使う。「一部だけ復号したのに + 全マーカーを戻す」と、まだ暗号化されたままの共通設定への参照まで有効に + なり、存在しないファイルを指したまま Compose が起動に失敗する。 + """ + wanted: Set[str] = set() + if has_global: + wanted.add(compose_migrate.TARGET_GLOBAL) + if path.parent.name in project_names: + wanted.add(compose_migrate.TARGET_PROJECT) + return wanted + + +def _plan_compose_changes(devbase_root: Path, refs: Sequence[SecretRef], + *, restore: bool = False): + """``compose.yml`` の書き換え内容を組み立てる (書き込みはしない)。 + + Returns: + ``{パス: (書き換え前のテキスト, 書き換え後のテキスト, 差分)}`` + + 書き換え前のテキストも返すのは、適用後に別の操作が失敗したとき、 + 差分を計算したのと同じ内容へ書き戻して巻き戻せるようにするため。 + + 暗号化側では、書き換え内容を確定したあとに **YAML としてパースし直して** + 機密参照が残っていないことを確かめる (:func:`_verify_secrets_are_unreferenced`)。 + 行ベースの走査が未知の記法を取りこぼしても、ここで必ず中止に落ちる。 + + Raises: + MigrationError: 構成ファイルを読めない場合、自動では書き換えられない + 機密参照が残っている場合、または書き換え後も機密参照が残っている + ことを事後検証が見つけた場合 (いずれも「平文だけ退避されて構成は + 存在しないファイルを指したまま」という壊れた結果になる) + """ + root = Path(devbase_root) + has_global = any(ref.kind == 'global' for ref in refs) + project_names = _affected_projects(refs) + + # 共通の機密を暗号化する場合、その参照は全プロジェクトの構成に現れるため、 + # 対象プロジェクトだけでなく全プロジェクトを見る必要がある。 + targets = _project_names(root) if has_global else project_names + changes = {} + + for path in compose_migrate.compose_files(root, targets): + try: + # `read_text` は改行を LF へ揃えて読む (universal newlines) ため、 + # CRLF の compose.yml を書き戻すとファイル全体の改行コードが + # 変わってしまう。書き換えた行以外は 1 バイトも動かさない。 + before = path.read_bytes().decode('utf-8') + except (OSError, UnicodeDecodeError) as e: + # 読めないファイルを飛ばして続けると、機密の参照が残ったまま平文 + # だけが退避され、コマンドは成功を返す。壊れた構成に気付けるのは + # 次の起動時になるため、ここで移行全体を中止する。 + raise MigrationError( + f"構成ファイルを読めませんでした ({path}): {e}") from e + + # 行単位では書き換えられない記法 (インライン配列・続きの行を持つ + # long syntax など) は対象から漏れる。黙って漏らすと壊れた構成の + # まま起動して初めて気付くため、どのファイルの何行目かを警告しておく。 + compose_migrate.warn_unsupported_env_file(before, path) + + wanted = _compose_targets(path, has_global=has_global, + project_names=project_names) + if not restore: + # 扱えない記法のうち **機密を指しているもの** は警告では済まない。 + # 平文を退避したあとも参照が有効なまま残り、Compose が存在しない + # ファイルを読もうとして起動できなくなる。手で直してから再実行して + # もらう (機密と無関係なものは移行に影響しないので警告のみ)。 + # + # 復元 (decrypt) 側では止めない。平文が戻る以上その参照は有効に + # なるうえ、ここで失敗させると壊れた状態からの復帰手段まで + # 塞いでしまう。 + blocking = compose_migrate.secret_unsupported_env_file_lines( + before, wanted) + if blocking: + detail = '\n'.join(f" {path}:{number}: {line}" + for number, line in blocking) + raise MigrationError( + "自動で書き換えられない env_file の記法が機密ファイルを" + "参照しています。次の行を `env_file:` の下に `- ...` を" + "並べる書き方へ手で直してから再実行してください:\n" + f"{detail}") + if restore: + after, touched = compose_migrate.enable(before, wanted) + else: + after, touched = compose_migrate.disable(before, wanted) + # 行ベースの走査が終わったところで、書き換えた結果を YAML として + # 読み直し、機密参照が本当に消えたことを確かめる。走査は記法の + # 判別に頼っている以上いつでも取りこぼしうるので、記法に依らない + # この検証を最後の砦として必ず通す (compose_migrate 冒頭 + # 「二段構えの保証」)。差分が出なかったファイルも対象にする。 + _verify_secrets_are_unreferenced(path, after, wanted) + + if touched and after != before: + changes[path] = (before, after, + compose_migrate.diff(before, after, path)) + + return changes + + +def _verify_secrets_are_unreferenced(path: Path, after: str, + wanted: Set[str]) -> None: + """書き換え後の ``compose.yml`` に機密参照が残っていないことを確かめる。 + + ``compose_migrate`` の書き換えは行ベースなので、YAML の記法が想定から + 外れると (``env_file: >-`` のようなブロックスカラーなど) 参照を取りこぼす。 + 取りこぼしたまま進むと「平文だけ退避され、構成は存在しないファイルを指した + まま」でコマンドが成功してしまう。**記法の判別に依らない事後検証**をここに + 置き、取りこぼしを必ず移行の中止へ落とす。 + + 復元 (decrypt) 側では行わない。平文が戻る以上その参照は有効で正しく、 + 残っていることが期待される状態だからである。 + + Raises: + MigrationError: 参照が残っている場合、または YAML として読めない場合 + """ + try: + remaining = compose_migrate.remaining_secret_env_file_refs( + after, wanted) + except compose_migrate.ComposeParseError as e: + # 検証できない = 参照が残っていないと言い切れない。読み取り失敗と + # 同じ扱いで中止する (「たぶん大丈夫」で平文を消してはいけない)。 + raise MigrationError( + f"構成ファイルを読めませんでした ({path}): {e}") from e + + if remaining: + detail = '\n'.join(f" {path}: サービス {service} の env_file: {ref}" + for service, ref in remaining) + raise MigrationError( + "暗号化で消える機密ファイルへの env_file 参照を自動で外せません" + "でした。次の参照を手で削除するか、`env_file:` の下に `- ...` を " + "1 行ずつ並べる書き方へ直してから再実行してください:\n" + f"{detail}") + + +#: 既存の ``compose.yml`` の権限を読めなかったときに使う既定値。 +#: 機密ではないので ``0600`` ではなく「誰でも読める」側に倒す。 +_COMPOSE_FALLBACK_MODE = 0o644 + + +def _compose_file_mode(path: Path) -> int: + """既存の ``compose.yml`` の権限をそのまま返す。 + + 書き込みに使う :func:`io_common.write_secure_bytes_atomic` は機密ファイル + 向けに既定が ``0600`` になっている。``compose.yml`` は機密ではなく、他の + 利用者や CI から読めることを前提に置かれているため、**既存の権限を勝手に + 狭めない**よう元の mode を引き継ぐ。 + """ + try: + return stat.S_IMODE(path.stat().st_mode) + except OSError: + return _COMPOSE_FALLBACK_MODE + + +def _write_compose(path: Path, text: str, mode: int) -> None: + """``compose.yml`` を **原子的に** 差し替える。 + + ``Path.write_text`` は既存ファイルを truncate してから書くため、途中で + ``OSError`` が起きると部分的な ``compose.yml`` が残る。しかもこの書き込みは + 取り消し手続きを積む前に走るので、壊れた内容を巻き戻せない。一時ファイル + → ``os.replace`` の方式なら、途中で失敗しても元の内容がそのまま残る。 + """ + io_common.write_secure_bytes_atomic(path, text.encode('utf-8'), mode=mode) + + +def _apply_compose_changes(changes, rollback: Rollback) -> None: + """計画した書き換えを適用する (取り消し: 元のテキストを書き戻す)。 + + 1 つでも書けなければ例外で呼び出し元へ返す。ここでログだけ出して次の + ファイルへ進むと、機密の移動・削除が済んだあとでもコマンドが成功扱いに + なり、構成ファイルが存在しないファイルを参照したまま残ってしまう。 + 書けたぶんの取り消しは既に積んであるので、呼び出し元が巻き戻せる。 + """ + for path, (before, after, _) in changes.items(): + # 元の権限は書き込み前に控える。差し替え後に読むと、こちらが付けた + # 権限を「元の権限」と取り違える。 + mode = _compose_file_mode(path) + try: + _write_compose(path, after, mode) + except OSError as e: + raise MigrationError( + f"構成ファイルを更新できませんでした ({path}): {e}") from e + rollback.push( + f"{path} を元の内容へ書き戻す", + lambda p=path, t=before, m=mode: _write_compose(p, t, m)) + logger.info("構成ファイルを更新しました: %s", path) diff --git a/lib/devbase/commands/env_ops.py b/lib/devbase/commands/env_ops.py new file mode 100644 index 00000000..6b8e6447 --- /dev/null +++ b/lib/devbase/commands/env_ops.py @@ -0,0 +1,496 @@ +"""受信者の更新と、端末上に残る平文の点検 + +``devbase env rekey`` は誰が機密を復号できるかを変え、``devbase env doctor`` は +「暗号化したつもりで平文が残っていないか」を点検する (plan35 §6)。 + +暗号化は「平文がどこにも残っていないこと」で初めて意味を持つ。移行の途中で +取り残されたバックアップや、除外設定の穴は黙って残り続けるため、点検する手段を +用意して繰り返し確認できるようにする。 +""" + +from __future__ import annotations + +import stat +import subprocess +from dataclasses import dataclass, field +from pathlib import Path +from typing import List, Optional, Sequence, Tuple + +from devbase.env import agekeys, io_common +from devbase.env.rollback import Rollback +from devbase.env.secret_store import ( + MODE_AGE, + SecretRef, + SecretStore, +) +from devbase.env.store import safe_input +from devbase.errors import DevbaseError +from devbase.log import get_logger + +logger = get_logger(__name__) + + +class EnvOpsError(DevbaseError): + """受信者更新 / 点検の操作エラー""" + + +# --------------------------------------------------------------------------- +# rekey +# --------------------------------------------------------------------------- + +def _encrypted_refs(devbase_root: Path, store: SecretStore) -> List[SecretRef]: + """暗号化済みの参照をすべて集める""" + refs: List[SecretRef] = [] + if store.mode(SecretRef.for_global()) == MODE_AGE: + refs.append(SecretRef.for_global()) + for name in store.project_names(): + ref = SecretRef.for_project(name) + if store.mode(ref) == MODE_AGE: + refs.append(ref) + return refs + + +def _own_public_key() -> Optional[str]: + """自分の公開鍵 (専用鍵が無ければ ``None``)""" + if not agekeys.key_file_path().exists(): + return None + try: + return agekeys.read_public_key() + except DevbaseError: + return None + + +def cmd_env_rekey(devbase_root: Path, *, + add: Sequence[str] = (), + remove: Sequence[str] = (), + dry_run: bool = False, + assume_yes: bool = False) -> int: + """受信者を追加・削除し、暗号化済みの機密を新しい受信者宛に暗号化し直す""" + root = Path(devbase_root) + store = SecretStore(root) + + try: + current = agekeys.load_recipients(root) + except DevbaseError as e: + logger.error("%s", e) + return 1 + + own = _own_public_key() + if not current: + # 受信者リストが無い状態は「自分の鍵だけが受信者」という意味なので、 + # そこから始める。リストを作らずに追加だけすると、自分が受信者から + # 外れて自分の機密を復号できなくなる。 + if own is None: + logger.error( + "受信者リストも devbase 専用鍵もありません。" + "先に `devbase env keygen` を実行してください") + return 1 + current = [own] + + updated = list(current) + for spec in add: + spec = spec.strip() + if not spec: + continue + try: + from devbase.env import cipher as _cipher + + _cipher.validate_recipient(spec) + except DevbaseError as e: + logger.error("%s", e) + return 1 + if spec not in updated: + updated.append(spec) + + missing = [spec for spec in remove if spec.strip() not in updated] + if missing: + logger.error("受信者リストに無いため削除できません: %s", ', '.join(missing)) + return 1 + for spec in remove: + updated = [r for r in updated if r != spec.strip()] + + if not updated: + logger.error( + "受信者を全員削除すると、以後の機密を誰も復号できなくなります。" + "少なくとも 1 人は残してください") + return 1 + + if updated == current: + print("受信者に変更はありません") + return 0 + + refs = _encrypted_refs(root, store) + + print("\n=== 受信者の変更 ===") + for spec in updated: + mark = '+' if spec not in current else ' ' + print(f" {mark} {spec}") + for spec in current: + if spec not in updated: + print(f" - {spec}") + + print(f"\n再暗号化する機密: {len(refs)} 件") + for ref in refs: + print(f" {ref.label():<24} {store.age.path(ref)}") + + if own is not None and own not in updated: + print("\n⚠ 自分の公開鍵が受信者から外れています。" + "再暗号化後、この端末では機密を復号できなくなります。") + + if dry_run: + print("\n(--dry-run のため変更していません)") + return 0 + + if not assume_yes and safe_input("続行しますか? (yes と入力): ") != 'yes': + print("中止しました") + return 1 + + # 受信者リストの更新と全暗号文の差し替えは、**片方だけ済んだ状態を残さない** + # 単一のまとまりとして扱う。中途半端に終わると旧受信者宛と新受信者宛の暗号文が + # 混在し、しかも自分の鍵を外す操作だと残った旧暗号文をもう復号できないため、 + # `devbase env rekey` の再実行でも復旧できなくなる。 + # + # env_migrate と同じ考え方で **破壊的な操作をできるだけ後ろへ寄せ**、実行した + # 操作ごとに取り消し手続きを積む (Rollback)。失うものが無い準備 (復号・暗号化) + # を先に全件済ませてからディスクへ触るので、途中で失敗しても巻き戻しは + # 「控えたバイト列を書き戻す」だけで済む。 + rollback = Rollback() + try: + # 1. 全件を復号し、旧暗号文の生バイト列も控える (ディスクは触らない) + # 2. 新しい受信者宛の暗号文を全件用意する (ここもまだ触らない) + # 3. 受信者リストを更新する + # 4. 各暗号文を差し替える + prepared = _prepare_reencryption(root, store, refs, updated) + _replace_recipients(root, updated, rollback) + _replace_ciphertexts(store, prepared, rollback) + except (DevbaseError, OSError) as e: + logger.error("受信者の更新を中止し、変更を巻き戻します: %s", e) + rollback.unwind() + return 1 + + print(f"\n=== 完了 === (受信者 {len(updated)} 名 / 機密 {len(refs)} 件)") + return 0 + + +def _prepare_reencryption(root: Path, store: SecretStore, + refs: Sequence[SecretRef], + updated: Sequence[str], + ) -> List[Tuple[SecretRef, bytes, bytes]]: + """全件を復号し、新しい受信者宛の暗号文を用意する (ディスクは触らない)。 + + Returns: + ``(参照, 旧暗号文の生バイト列, 新受信者宛の暗号文)`` の並び + + 旧暗号文は再暗号化ではなく **生バイト列のまま** 控える。巻き戻しで元の + ファイルへ 1 バイト違わず戻せるようにするため (age は暗号化のたびに異なる + 出力になるので、作り直したものでは「元に戻した」と言い切れない)。 + + ここで失敗しても、受信者リストも暗号文もまだ 1 つも書き換えていない。 + """ + rewritten = SecretStore(root, recipients=list(updated)) + prepared: List[Tuple[SecretRef, bytes, bytes]] = [] + for ref in refs: + path = store.age.path(ref) + try: + old_blob = path.read_bytes() + except OSError as e: + raise EnvOpsError(f"暗号文を読み込めませんでした ({path}): {e}") from e + try: + plain = store.age.load_bytes(ref) + except DevbaseError as e: + raise EnvOpsError(f"{ref.label()}を復号できませんでした: {e}") from e + try: + new_blob = rewritten.age.encrypt_bytes(plain) + except DevbaseError as e: + raise EnvOpsError( + f"{ref.label()}を新しい受信者宛に暗号化できませんでした: {e}") from e + prepared.append((ref, old_blob, new_blob)) + return prepared + + +def _write_blob(path: Path, blob: bytes) -> None: + """暗号文 / 受信者リストを atomic に差し替える。 + + 書き込みを 1 箇所に集約しておくと、巻き戻し側も同じ経路を通るので + 「戻したつもりで別の書き方をしていた」というずれが起きない。 + """ + io_common.write_secure_bytes_atomic(path, blob) + + +def _replace_recipients(root: Path, updated: Sequence[str], + rollback: Rollback) -> None: + """受信者リストを差し替える (取り消し: 元の内容へ戻す / 元が無ければ削除)""" + path = agekeys.recipients_file(root) + try: + before = path.read_bytes() if path.is_file() else None + except OSError as e: + raise EnvOpsError(f"受信者リストを読み込めませんでした ({path}): {e}") from e + + try: + agekeys.save_recipients(root, list(updated)) + except OSError as e: + raise EnvOpsError(f"受信者リストを更新できませんでした ({path}): {e}") from e + + if before is None: + # 元々リストが無かった場合は「作る前」= 存在しない状態へ戻す。 + rollback.push(f"作成した受信者リスト {path} を削除する", + lambda p=path: p.unlink()) + else: + rollback.push(f"受信者リスト {path} を元の内容へ戻す", + lambda p=path, b=before: _write_blob(p, b)) + + +def _replace_ciphertexts(store: SecretStore, + prepared: Sequence[Tuple[SecretRef, bytes, bytes]], + rollback: Rollback) -> None: + """用意済みの暗号文でファイルを差し替える (取り消し: 旧バイト列を書き戻す)""" + for ref, old_blob, new_blob in prepared: + path = store.age.path(ref) + try: + _write_blob(path, new_blob) + except OSError as e: + raise EnvOpsError( + f"{ref.label()}の再暗号化に失敗しました ({path}): {e}") from e + rollback.push(f"{ref.label()}の暗号文 {path} を元の内容へ戻す", + lambda p=path, b=old_blob: _write_blob(p, b)) + logger.info("%s を再暗号化しました", ref.label()) + + +# --------------------------------------------------------------------------- +# doctor +# --------------------------------------------------------------------------- + +@dataclass +class Finding: + """点検で見つかった問題""" + + level: str # 'error' | 'warning' + title: str + detail: str = '' + hint: str = '' + + +@dataclass +class Report: + findings: List[Finding] = field(default_factory=list) + checked: List[str] = field(default_factory=list) + + def add(self, level: str, title: str, detail: str = '', hint: str = '') -> None: + self.findings.append(Finding(level, title, detail, hint)) + + @property + def errors(self) -> List[Finding]: + return [f for f in self.findings if f.level == 'error'] + + +#: 平文が残りやすい場所。移行や過去のバックアップで取り残される。 +_PLAINTEXT_GLOBS = ( + '.env.bak*', + '.env.backup*', + '.env.orig', + '.env.save', +) + +#: 除外できているかを Git に確かめてもらう代表パス (``DEVBASE_ROOT`` からの相対)。 +#: パターンの書き方ではなく「このパスが実際に除外されるか」で見る。 +_IGNORE_PROBE_PATHS = ( + # 共通の平文 + '.env', + # 日時付きの控え。実際に未追跡のまま検出された名前をそのまま使う + '.env.bak-20260807172231', + # 暗号文の保存先 + 'secrets/global.env.age', + 'secrets/projects/sample.env.age', + # ``secrets/*.age`` のように配下の一部だけを除外していると漏れる位置 + 'secrets/leftover.env', +) + +#: ``projects/`` が空のときに使うプロジェクト名。``projects//.env`` が +#: 除外されるかは実在のプロジェクトが無くても確かめたい。 +_SAMPLE_PROJECT_NAME = 'sample' + + +def _mode_of(path: Path) -> Optional[int]: + try: + return stat.S_IMODE(path.stat().st_mode) + except OSError: + return None + + +def _check_key(report: Report) -> None: + key_file = agekeys.key_file_path() + report.checked.append(f"鍵ファイル: {key_file}") + if not key_file.exists(): + report.add('warning', '暗号化に使う鍵がありません', + f'{key_file} が存在しません', + '`devbase env keygen` で生成してください') + return + + mode = _mode_of(key_file) + if mode is not None and mode & 0o077: + report.add('error', '鍵ファイルが他ユーザーから読めます', + f'{key_file} (mode {mode:04o})', + f'chmod 600 {key_file}') + + dir_mode = _mode_of(key_file.parent) + if dir_mode is not None and dir_mode & 0o077: + report.add('warning', '鍵の置き場が他ユーザーからアクセスできます', + f'{key_file.parent} (mode {dir_mode:04o})', + f'chmod 700 {key_file.parent}') + + +def _check_conflicts(root: Path, store: SecretStore, report: Report) -> None: + """暗号化ファイルと平文が同時に存在していないか""" + refs = [SecretRef.for_global()] + projects_dir = root / 'projects' + if projects_dir.is_dir(): + refs.extend(SecretRef.for_project(p.name) + for p in sorted(projects_dir.iterdir()) if p.is_dir()) + + for ref in refs: + if store.age.exists(ref) and store.plaintext.exists(ref): + report.add('error', f'{ref.label()}の機密が暗号化・平文の両方にあります', + f'暗号化: {store.age.path(ref)}\n' + f' 平文: {store.plaintext.path(ref)}', + 'どちらが正しいか確認し、不要な方を削除してください') + + +def _check_leftovers(root: Path, store: SecretStore, report: Report) -> None: + """移行で取り残された平文を探す""" + encrypted = store.age.exists(SecretRef.for_global()) or bool(store.project_names()) + + backups = root / 'backups' + for name, label in (('env-encrypt', '暗号化への移行時に退避した平文'), + ('env-import', '取り込み時に退避した設定')): + base = backups / name + if not base.is_dir(): + continue + found = [p for p in sorted(base.rglob('*')) if p.is_file()] + plain = [p for p in found if p.suffix != '.age'] + if plain and (encrypted or name == 'env-encrypt'): + report.add('warning', f'{label}が残っています', + '\n '.join(str(p) for p in plain[:10]) + + (f'\n ... 他 {len(plain) - 10} 件' if len(plain) > 10 else ''), + f'内容を確認したうえで削除してください: rm -rf {base}') + + stale: List[Path] = [] + for pattern in _PLAINTEXT_GLOBS: + stale.extend(sorted(root.glob(pattern))) + projects_dir = root / 'projects' + if projects_dir.is_dir(): + stale.extend(sorted(projects_dir.glob(f'*/{pattern}'))) + if stale: + report.add('warning', '平文の控えファイルが残っています', + '\n '.join(str(p) for p in stale), + '不要なら削除してください') + + +def _git_check_ignore(root: Path, rel_path: str) -> Optional[bool]: + """``rel_path`` が Git の除外設定で無視されるか (判定できなければ ``None``)。 + + ``.gitignore`` の解釈は Git の実装が正であり、独自に真似ると必ず食い違う。 + たとえば Git は行頭の ``#`` だけをコメントとして扱うので ``.env # 機密`` は + 「``.env # 機密`` というパターン」であって ``.env`` を除外しないし、後ろに + ``!.env`` があれば再包含されて除外は取り消される。文字列を自前で正規化して + 「除外できている」と誤って判定すると平文の誤コミットに直結するため、判定は + Git 自身に任せる。 + + - ``--no-index``: まだ存在しないパスや、すでに追跡済みのパスであっても + 除外設定だけで評価させる (追跡済みだと既定では何も報告されない) + - 作業ディレクトリは ``DEVBASE_ROOT``。除外設定は評価するパスの位置で + 決まるため、必ず点検対象のリポジトリの中で実行する + - 終了コード 0 = 除外される / 1 = 除外されない / それ以外 (128 など) は + git が無い・Git リポジトリでないといった「判定できない」状態 + """ + try: + proc = subprocess.run( + ['git', 'check-ignore', '--no-index', '-q', '--', rel_path], + cwd=str(root), + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + check=False, + ) + except (OSError, ValueError): + # git が入っていない / 実行できない。例外で点検全体を落とさない。 + return None + if proc.returncode == 0: + return True + if proc.returncode == 1: + return False + return None + + +def _ignore_probe_paths(root: Path) -> List[str]: + """除外されているか確かめる代表パスを組み立てる""" + paths = list(_IGNORE_PROBE_PATHS) + + # プロジェクトごとの平文。実在するものがあればその名前で確かめるほうが、 + # 報告をそのまま直す手がかりにできる。 + names: List[str] = [] + projects_dir = root / 'projects' + if projects_dir.is_dir(): + names = [p.name for p in sorted(projects_dir.iterdir()) if p.is_dir()] + paths.extend(f'projects/{name}/.env' for name in names or [_SAMPLE_PROJECT_NAME]) + return paths + + +def _check_gitignore(root: Path, report: Report) -> None: + """平文が置かれうるパスが実際に除外されるかを Git に確かめてもらう""" + path = root / '.gitignore' + report.checked.append(f"除外設定: {path} (git check-ignore で確認)") + + exposed: List[str] = [] + for rel in _ignore_probe_paths(root): + ignored = _git_check_ignore(root, rel) + if ignored is None: + # 「確認できなかった」と「問題なし」を混同しない。ここで独自の + # 文字列判定へ落とすと、Git と食い違う判定が復活してしまう。 + report.add('warning', '除外設定を確認できませんでした', + f'{root} で git check-ignore を実行できません ' + '(git が無い、または Git リポジトリではありません)', + 'Git 管理下で `git check-ignore -v .env secrets/global.env.age` ' + 'を実行し、除外されることを確かめてください') + return + if not ignored: + exposed.append(rel) + + if exposed: + report.add('error', '除外設定から漏れているパスがあります', + '除外されない: ' + '\n '.join(exposed), + f'{path} へ `.env` / `.env.bak*` / `secrets/` などを追記し、' + '`git check-ignore -v <パス>` で除外されることを確かめてください') + + +def cmd_env_doctor(devbase_root: Path) -> int: + """端末上に残る平文と設定の穴を点検する""" + root = Path(devbase_root) + store = SecretStore(root) + report = Report() + + _check_key(report) + _check_conflicts(root, store, report) + _check_leftovers(root, store, report) + _check_gitignore(root, report) + + print("\n=== devbase env doctor ===") + for line in report.checked: + print(f" 確認: {line}") + + if not report.findings: + print("\n問題は見つかりませんでした") + return 0 + + print() + for finding in report.findings: + marker = '✗' if finding.level == 'error' else '!' + print(f"{marker} {finding.title}") + if finding.detail: + print(f" {finding.detail}") + if finding.hint: + print(f" → {finding.hint}") + + errors = len(report.errors) + warnings = len(report.findings) - errors + print(f"\n問題 {errors} 件 / 注意 {warnings} 件") + # 問題があれば非ゼロで返す。定期実行して気付ける形にするため。 + return 1 if report.findings else 0 diff --git a/lib/devbase/env/_import_merge.py b/lib/devbase/env/_import_merge.py index 0fa019ff..a586a966 100644 --- a/lib/devbase/env/_import_merge.py +++ b/lib/devbase/env/_import_merge.py @@ -227,7 +227,9 @@ def _plan_replace_keys(incoming: Dict[str, str], existing: Dict[str, str], def plan_env_merge(target: Path, incoming_bytes: bytes, arcname: str, *, merge: str = 'keep-existing', replace: bool = False, - replace_keys: Sequence[str] = ()) -> Plan: + replace_keys: Sequence[str] = (), + existing_bytes: Optional[bytes] = None, + target_exists: Optional[bool] = None) -> Plan: """1 つの ``.env`` に対する merge / replace 計画を作る 新規作成 (= target 不在) ケースでは ``incoming_bytes`` をそのまま採用する。 @@ -239,8 +241,13 @@ def plan_env_merge(target: Path, incoming_bytes: bytes, arcname: str, *, 既存のコメント / 空行 / キー順を保持したまま値だけ差し替える (PR #15 gemini 指摘)。 """ incoming = EnvFile.parse_bytes(incoming_bytes) - target_exists = target.exists() - existing_bytes = target.read_bytes() if target_exists else b'' + # 既存内容は呼び出し側から渡せる。保存先が暗号化されている場合、ファイルを + # そのまま読むと暗号文を .env として解釈してしまうため、秘密ストア越しに + # 復号したバイト列を渡してもらう (呼び出し側が渡さなければ従来どおり読む)。 + if target_exists is None: + target_exists = target.exists() + if existing_bytes is None: + existing_bytes = target.read_bytes() if target_exists else b'' existing = EnvFile.parse_bytes(existing_bytes) if target_exists else {} if replace: diff --git a/lib/devbase/env/agekeys.py b/lib/devbase/env/agekeys.py new file mode 100644 index 00000000..9f19dda1 --- /dev/null +++ b/lib/devbase/env/agekeys.py @@ -0,0 +1,333 @@ +"""devbase 専用 age 鍵と受信者リストの管理 + +``devbase env export`` / ``import`` が使う ``~/.ssh`` の鍵とは別に、devbase が +機密の保存に使う専用鍵を扱う。署名用の SSH 鍵とは失効・保管・バックアップの +扱いが異なるため、鍵を分けて管理する (plan35 §5.1)。 + +鍵ファイルの場所は ``DEVBASE_AGE_KEY_FILE`` で上書きでき、既定は +``~/.config/devbase/age/keys.txt`` (``XDG_CONFIG_HOME`` があればそれを尊重)。 +""" + +from __future__ import annotations + +import os +from datetime import datetime, timezone +from pathlib import Path +from typing import List, Optional, Tuple + +import pyrage + +from devbase.env import cipher as _cipher +from devbase.env import io_common as _io_common +from devbase.errors import DevbaseError +from devbase.log import get_logger + +logger = get_logger(__name__) + + +class AgeKeyError(DevbaseError): + """鍵ファイル / 受信者リストの操作エラー""" + + +#: 鍵ファイルの場所を明示するための環境変数。OS ごとの既定位置の違いを +#: 利用者が 1 箇所で吸収できるようにする (plan35 §5.1)。 +KEY_FILE_ENV = 'DEVBASE_AGE_KEY_FILE' + +#: 受信者リストのファイル名 (``$DEVBASE_ROOT/secrets/`` 配下)。 +RECIPIENTS_FILENAME = 'recipients.txt' + +_AGE_SECRET_PREFIX = 'AGE-SECRET-KEY-1' + + +# --------------------------------------------------------------------------- +# パス解決 +# --------------------------------------------------------------------------- + +def default_key_dir() -> Path: + """既定の鍵ディレクトリ ``$XDG_CONFIG_HOME/devbase/age`` を返す。 + + ``XDG_CONFIG_HOME`` が未設定なら ``~/.config`` を使う。 + """ + base = os.environ.get('XDG_CONFIG_HOME') + root = Path(base).expanduser() if base else Path.home() / '.config' + return root / 'devbase' / 'age' + + +def key_file_path() -> Path: + """使用する鍵ファイルのパス (環境変数の指定を優先)""" + override = os.environ.get(KEY_FILE_ENV) + if override: + return Path(override).expanduser() + return default_key_dir() / 'keys.txt' + + +def recipients_file(devbase_root: Path) -> Path: + """受信者リストのパス ``$DEVBASE_ROOT/secrets/recipients.txt``""" + return Path(devbase_root) / 'secrets' / RECIPIENTS_FILENAME + + +# --------------------------------------------------------------------------- +# 鍵の生成・読み取り +# --------------------------------------------------------------------------- + +def _ensure_private_dir(path: Path) -> None: + """鍵 / 受信者リストの置き場を ``0700`` で用意する。 + + 実装は ``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: + """「既に鍵がある」エラー。事前チェックと排他生成の両方から使う""" + return AgeKeyError( + f"鍵ファイルが既に存在します: {path}\n" + "上書きすると既存の暗号化ファイルを復号できなくなります。" + "意図的に作り直す場合のみ --force を指定してください" + ) + + +def _create_key_file_exclusive(path: Path, data: bytes) -> None: + """新規鍵を ``O_CREAT|O_EXCL`` で **排他的に** 作成する。 + + 「存在チェック → 生成」を別々に行うと、その隙間に他プロセスが同じ判定を + 通り抜けられる。両者が生成へ進むと後発の書き込みが先発の鍵を消し、先発鍵で + 暗号化した機密がその瞬間から復号不能になる (TOCTOU)。``O_EXCL`` は + 「存在しなければ作る」をカーネル側で不可分に行うため、この隙間が原理的に + 消える。既存ファイルが無い状況では守るべき旧内容も無いので、一時ファイル + + ``os.replace`` は不要なだけでなく有害 — ``os.replace`` は既存を無条件に + 置き換えてしまい、まさに塞ぎたい上書きを許すため。 + + 書き込み途中で失敗したら、中途半端な鍵ファイルを残さないよう自分で作った + ファイルを消す。半端な鍵が残ると以後の生成が「既に存在します」で止まり、 + しかもその鍵では何も復号できない。 + """ + try: + fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600) + except FileExistsError as e: + raise _key_exists_error(path) from e + try: + with os.fdopen(fd, 'wb') as f: + f.write(data) + f.flush() + os.fsync(f.fileno()) + except BaseException: + try: + os.close(fd) + except OSError: + pass + try: + path.unlink() + except OSError: + pass + raise + # mode 引数が無視される環境 (Windows 等) に備えて明示的に揃える + try: + os.chmod(path, 0o600) + except OSError: + pass + + +def generate_key_file(path: Optional[Path] = None, *, + force: bool = False) -> Tuple[Path, str]: + """devbase 専用の age 鍵を生成して ``0600`` で保存する。 + + 書き込み方法は ``force`` で変える。守るべき旧内容の有無が違うため。 + + - ``force=False`` (新規生成): ``O_CREAT|O_EXCL`` で直接排他作成する。 + 判定と作成の隙間を閉じ、並行実行しても先に作った側の鍵が生き残る。 + - ``force=True`` (作り直し): 同一ディレクトリの一時ファイルへ書いて fsync + してから atomic に差し替える。直接 ``O_TRUNC`` で上書きすると、書き込み + 途中の失敗 (ディスク枯渇・強制終了など) で旧鍵だけが失われ、既存の暗号文を + 誰も復号できなくなるため。差し替えに成功するまで旧鍵はそのまま残る。 + + ``--force`` 同士の並行実行にはロックを掛けない。どちらも利用者が「既存鍵を + 捨てて作り直す」と明示的に要求した操作であり、後勝ちで最後の鍵が残ること自体が + 要求どおりの結果だから。ロックで直列化しても「先の鍵が消える」事実は変わらず、 + グローバルな鍵ファイルにロックの残骸 (stale lock) という別の詰まり方を持ち込む + ぶん損になる。塞ぐべきだったのは「誰も上書きを要求していないのに上書きされる」 + 新規生成側だけで、そこは ``O_EXCL`` で閉じている。 + + Returns: + ``(鍵ファイルのパス, 公開鍵文字列)`` + + Raises: + AgeKeyError: 既存の鍵があり ``force`` が偽のとき + """ + path = Path(path) if path is not None else key_file_path() + # 早期に弾いて無駄な鍵生成を避けるための事前チェック。ここを通り抜けた + # 並行プロセスは下の O_EXCL で確実に止まるので、この判定は最適化にすぎない。 + if path.exists() and not force: + raise _key_exists_error(path) + + identity = pyrage.x25519.Identity.generate() + public = str(identity.to_public()) + created = datetime.now(timezone.utc).isoformat(timespec='seconds') + content = ( + "# devbase age key file\n" + f"# created: {created}\n" + f"# public key: {public}\n" + "# この鍵を失うと暗号化した機密は復旧できません。\n" + "# パスワード管理ツール等へ必ず複製を保管してください。\n" + f"{identity}\n" + ) + + _ensure_private_dir(path.parent) + if force: + _io_common.write_secure_bytes_atomic(path, content.encode('utf-8')) + else: + _create_key_file_exclusive(path, content.encode('utf-8')) + return path, public + + +def read_public_key(path: Optional[Path] = None) -> str: + """鍵ファイルから公開鍵を導出する。 + + ファイル中のコメント (``# public key:``) は信頼せず、秘密鍵行から都度導出する。 + コメントは手で書き換えられうるため、そこを信じると「登録した受信者と実際の + 鍵が食い違ったまま暗号化してしまう」事故が起きる。 + """ + path = Path(path) if path is not None else key_file_path() + if not path.exists(): + raise AgeKeyError( + f"鍵ファイルが見つかりません: {path}\n" + "`devbase env keygen` で生成してください" + ) + try: + text = path.read_text(encoding='utf-8') + except (OSError, UnicodeDecodeError) as e: + raise AgeKeyError(f"鍵ファイルを読み込めませんでした ({path}): {e}") from e + + for line in text.splitlines(): + stripped = line.strip() + if not stripped or stripped.startswith('#'): + continue + if not stripped.startswith(_AGE_SECRET_PREFIX): + break + try: + return str(pyrage.x25519.Identity.from_str(stripped).to_public()) + except Exception as e: + raise AgeKeyError(f"age 秘密鍵の解釈に失敗しました ({path}): {e}") from e + + raise AgeKeyError( + f"age 秘密鍵 ({_AGE_SECRET_PREFIX}...) が含まれていません: {path}\n" + "OpenSSH 鍵など age 形式以外を使う場合は、対応する公開鍵を " + "`devbase env keygen` ではなく受信者リストへ直接登録してください" + ) + + +# --------------------------------------------------------------------------- +# 受信者リスト +# --------------------------------------------------------------------------- + +def load_recipients(devbase_root: Path) -> List[str]: + """受信者リストの有効行 (コメント・空行を除く) を返す""" + path = recipients_file(devbase_root) + if not path.exists(): + return [] + try: + text = path.read_text(encoding='utf-8') + except (OSError, UnicodeDecodeError) as e: + raise AgeKeyError(f"受信者リストを読み込めませんでした ({path}): {e}") from e + return [ + line.strip() for line in text.splitlines() + if line.strip() and not line.strip().startswith('#') + ] + + +def save_recipients(devbase_root: Path, recipients: List[str]) -> Path: + """受信者リストを書き出す。 + + 公開鍵そのものは秘密ではないが、ファイルは ``0600`` で保護する。第三者が + 自分の公開鍵をここへ追記できると、以後の暗号化がその相手にも復号可能に + なるため、機密性ではなく**改竄防止**のために権限を絞る。 + + 書き込みは鍵ファイルと同じく atomic に行う。途中失敗で受信者が欠けたリストが + 残ると、以後の暗号化から一部の受信者が黙って外れてしまうため。 + + 置き場 (``secrets/``) の扱いも鍵ファイルと揃えて ``_ensure_private_dir`` に + 任せる。既に存在する ``secrets/`` — 例えば git clone 直後の 0755 — を勝手に + 0700 へ落とすと、ワークスペースを共有している他ユーザーの参照を壊すため。 + """ + path = recipients_file(devbase_root) + _ensure_private_dir(path.parent) + header = ( + "# devbase secret store recipients\n" + "# 1 行に 1 つの公開鍵 (age1... / ssh-ed25519 ... / ssh-rsa ...)。\n" + "# ここに列挙した全員が機密を復号できる。\n" + "# 編集しても既存の暗号化ファイルは変わらないため、変更後は再暗号化すること。\n" + ) + body = ''.join(f"{r}\n" for r in recipients) + _io_common.write_secure_bytes_atomic(path, (header + body).encode('utf-8')) + return path + + +def add_recipient(devbase_root: Path, spec: str) -> bool: + """受信者を追加する。既に登録済みなら ``False`` を返して何もしない。""" + spec = spec.strip() + if not spec: + raise AgeKeyError("受信者が空です") + # 形式不正をここで弾いておく。登録後に初めて暗号化で落ちるより早い。 + _cipher.validate_recipient(spec) + + current = load_recipients(devbase_root) + if spec in current: + return False + save_recipients(devbase_root, current + [spec]) + return True + + +def remove_recipient(devbase_root: Path, spec: str) -> bool: + """受信者を削除する。登録が無ければ ``False`` を返す。""" + spec = spec.strip() + current = load_recipients(devbase_root) + if spec not in current: + return False + save_recipients(devbase_root, [r for r in current if r != spec]) + return True + + +# --------------------------------------------------------------------------- +# 暗号化・復号に渡す鍵の解決 +# --------------------------------------------------------------------------- + +def resolve_recipients(devbase_root: Path) -> List[str]: + """暗号化に使う受信者を解決する。 + + 受信者リストに登録があればそれを使い、無ければ専用鍵の公開鍵を使う。 + どちらも無ければ ``AgeKeyError``。 + """ + registered = load_recipients(devbase_root) + if registered: + return registered + + key_file = key_file_path() + if key_file.exists(): + return [read_public_key(key_file)] + + raise AgeKeyError( + "暗号化に使う公開鍵がありません。\n" + " `devbase env keygen` で devbase 専用鍵を生成するか、\n" + f" {recipients_file(devbase_root)} へ公開鍵を登録してください" + ) + + +def resolve_identities() -> List[str]: + """復号に使う秘密鍵の候補を返す。 + + 専用鍵を先頭に置き、続けて ``~/.ssh`` の既定鍵を候補に加える。``pyrage`` は + 複数 identity を受け取り一致したものだけを使うため、旧来 ``~/.ssh`` の鍵で + 暗号化したファイルも移行期間中そのまま復号できる。 + """ + found: List[str] = [] + key_file = key_file_path() + if key_file.exists(): + found.append(str(key_file)) + for path in _cipher.default_identity_paths(): + if path.exists() and str(path) not in found: + found.append(str(path)) + return found diff --git a/lib/devbase/env/bundle.py b/lib/devbase/env/bundle.py index e957096d..76c81493 100644 --- a/lib/devbase/env/bundle.py +++ b/lib/devbase/env/bundle.py @@ -230,17 +230,30 @@ def make_entries_from_disk(devbase_root, """ from pathlib import Path + from devbase.env.secret_store import SecretRef, SecretStore + devbase_root = Path(devbase_root) entries: List[BundleEntry] = [] + # 保存先は秘密ストアに聞く。暗号化済みの機密でも export できるようにするため、 + # ファイルパスを直接読まずに復号後のバイト列を受け取る。バンドル自体は age で + # 暗号化されるので、ここで平文に戻しても保存時の平文は生まれない。 + store = SecretStore(devbase_root) + + def _origin(path) -> str: + """保存先を ``$DEVBASE_ROOT`` 相対の表記へ直す (manifest の可読性のため)""" + try: + return f'$DEVBASE_ROOT/{Path(path).relative_to(devbase_root)}' + except ValueError: + return str(path) + if include_global: - global_env = devbase_root / '.env' - # is_file() でディレクトリ等を除外し、IsADirectoryError 等の例外を防ぐ - if global_env.is_file(): + global_ref = SecretRef.for_global() + if store.exists(global_ref): entries.append(BundleEntry( arcname='env/global.env', - origin='$DEVBASE_ROOT/.env', - data=global_env.read_bytes(), + origin=_origin(store.path(global_ref)), + data=store.load_bytes(global_ref), )) if include_metadata: @@ -282,12 +295,12 @@ def make_entries_from_disk(devbase_root, name, proj_dir, ) continue - env_path = proj_dir / '.env' - if env_path.is_file(): + project_ref = SecretRef.for_project(name) + if store.exists(project_ref): entries.append(BundleEntry( arcname=f'env/projects/{name}/.env', - origin=f'$DEVBASE_ROOT/projects/{name}/.env', - data=env_path.read_bytes(), + origin=_origin(store.path(project_ref)), + data=store.load_bytes(project_ref), )) return entries diff --git a/lib/devbase/env/cipher.py b/lib/devbase/env/cipher.py index e3c3f812..1cdd2e0f 100644 --- a/lib/devbase/env/cipher.py +++ b/lib/devbase/env/cipher.py @@ -8,6 +8,9 @@ import pyrage from devbase.errors import DevbaseError +from devbase.log import get_logger + +logger = get_logger(__name__) class CipherError(DevbaseError): @@ -150,6 +153,14 @@ def _resolve_identity(path_spec: str): ) from e +def validate_recipient(spec: str) -> None: + """recipient 仕様文字列が解釈可能かを検証する (不正なら CipherError)。 + + 受信者リストへの登録時など、実際に暗号化する前に形式不正を弾くために使う。 + """ + _resolve_recipient(spec) + + def encrypt(data: bytes, recipients: Sequence[str] = (), passphrase: Optional[str] = None) -> bytes: @@ -198,7 +209,36 @@ def decrypt(data: bytes, if not identities: raise CipherError("identity または passphrase を指定してください") - resolved = [_resolve_identity(p) for p in identities] + # identities は「devbase 専用鍵 → ~/.ssh の既定鍵」のように複数候補を並べて + # 渡される (agekeys.resolve_identities)。ここで 1 つでも解決に失敗した時点で + # 例外にすると、壊れた / 読めない鍵ファイルが 1 つ混ざっているだけで後続の + # 有効な鍵を試せず、旧来 ~/.ssh の鍵で暗号化した暗号文を移行期間中に復号 + # できるという意図が壊れる。そこで候補ごとに解決を試し、失敗した候補は + # 理由を warning に残したうえで読み飛ばす。黙って捨てると「鍵を指定したのに + # 復号できない」原因を利用者が追えなくなるため、ログは必須。 + resolved = [] + failures: List[str] = [] + for spec in identities: + try: + resolved.append(_resolve_identity(spec)) + except Exception as e: + # 想定外の例外もここで握り潰さず失敗理由として蓄積する。全滅時には + # 下で CipherError に含めて送出するので、情報は失われない。 + failures.append(f"{spec}: {e}") + logger.warning( + "identity を解決できなかったため復号候補から除外します (%s): %s", + spec, e, + ) + + # 全候補が解決できなかったときだけ失敗させる。どの候補がなぜ駄目だったかを + # 並べて示し、鍵の置き場所・権限・形式のどれが原因かを切り分けられるようにする。 + if not resolved: + detail = '\n'.join(f" - {f}" for f in failures) + raise CipherError( + "復号に使える identity がありません " + f"(候補 {len(failures)} 件をいずれも解決できませんでした):\n{detail}" + ) + try: return pyrage.decrypt(data, resolved) except Exception as e: diff --git a/lib/devbase/env/compose_migrate.py b/lib/devbase/env/compose_migrate.py new file mode 100644 index 00000000..abfc81c2 --- /dev/null +++ b/lib/devbase/env/compose_migrate.py @@ -0,0 +1,901 @@ +"""プロジェクト構成ファイルから機密ファイルの参照を外す / 戻す + +各プロジェクトの ``compose.yml`` は共通設定とプロジェクト設定を ``env_file`` で +直接参照している。機密を暗号化すると、そのファイルは平文としては存在しなくなる +ため、参照を残したままでは Docker Compose が起動時に失敗する (plan35 §2.2)。 + +書き換えは **行単位のコメントアウト** で行い、元の行をそのまま残す: + + env_file: + # devbase(PLAN35) 機密は環境変数で注入: - ${DEVBASE_ROOT}/.env + - env + +こうする理由は 2 つある。1 つは、YAML として読み書きし直すと利用者が自分で書いた +コメントや整形が失われること。もう 1 つは、平文へ戻す操作 (``devbase env decrypt``) +で**元の行を機械的に復元できる**こと。行を削除してしまうと、どの位置に何を書き戻せば +よいか分からなくなる。 + +この走査が扱う範囲 (契約) +------------------------- + +行単位で書き換える以上、YAML の記法すべてを扱えるわけではない。**何を扱い、何を +扱わないか**をここに明示する。走査を直すときはこの契約と突き合わせること。 + +1. 書き換える記法 — 1 行がちょうど 1 エントリに対応するもの:: + + env_file: + - .env + - "${DEVBASE_ROOT}/.env" + - path: .env # long syntax でも 1 行で閉じているもの + + env_file: .env # 値が単一文字列。キー行ごと無効化する + env_file: "${DEVBASE_ROOT}/.env" + + 行ごとコメントアウトしても他の指定を巻き込まないため、無効化も復元も機械的に + できる。行末コメント・前後の空行・利用者のコメント行が混ざっていてもよい。 + 単一文字列の形はエントリが 1 つしかないので、``env_file:`` の行そのものを + コメントアウトする (シーケンスの全エントリを落としたときと同じ扱い)。 + +2. 移行を中止する記法 — 1 行に閉じていない、または 1 行に複数の指定が同居する + もの:: + + env_file: [ "${DEVBASE_ROOT}/.env", .env ] + env_file: >- # 続きの行に値を持つブロックスカラー + .env + env_file: + - path: .env + required: false # 続きの行を持つ long syntax + - { path: .env } # フロー記法のマッピング + + 行ごとコメントアウトすると無関係な指定まで巻き添えにする (あるいは 1 エントリ + の一部だけが残って YAML が壊れる)。**機密を指している場合は移行を止め**、 + 利用者に手で直してもらう (:func:`secret_unsupported_env_file_lines`)。 + ``env_file:`` の値がシーケンスでない場合 (下がマッピングになっている等) も + ここに含める。Compose の仕様上は不正な書き方だが、参照を見落とすよりは中止・ + 警告する方が安全なため。 + +3. 触らない記法 — 機密と無関係な参照:: + + env_file: + - config/app.env + + env_file: config/app.env + + 移行で消えるファイルではないので書き換える必要がない。ただし 2. の記法で + 書かれている場合は、機密でなくても警告する + (:func:`warn_unsupported_env_file`)。 + +**不変条件**: 扱えない記法に当たったときに黙って通してはいけない。機密を指して +いれば中止 (2.)、指していなければ警告に落ちる。黙って見逃すと、平文を退避した +あとも参照だけが残り、次の起動で初めて壊れていることに気付くことになる。 + +二段構えの保証 (行ベースの走査 + 事後検証) +------------------------------------------ + +上の契約は「行を見て記法を判別できる」ことに依存している。YAML の記法は多く、 +判別を 1 つ取りこぼすたびに同じ穴が空く。例えば ``env_file: >-`` のブロック +スカラーは先頭行に参照先が書かれておらず、行だけを見ても機密を指しているか +分からない。**記法ごとに穴を塞ぎ続ける限り、この種の見落としは無くならない**。 + +そこで記法の判別に頼らない**事後検証**を最後の砦として置く: + +1. 行ベースの走査は「うまく書き換えられれば書き換える」(契約 1.)。扱えないと + *分かった* ものは早い段階で中止・警告する + (契約 2., :func:`secret_unsupported_env_file_lines`) +2. 書き換えたあとのテキストを **YAML としてパースし**、機密参照が本当に残って + いないことを確かめる (:func:`remaining_secret_env_file_refs`)。残っていれば + 移行そのものを中止し、手で直してもらう + +1. が取りこぼしても 2. が必ず捕まえるので、**未知の記法でも「平文だけ退避されて +参照が残る」結果にはならない**。1. を直す意味は「分かりやすいエラーを早い段階で +出す」ことであって、不変条件そのものを支えているのは 2. である。 +""" + +from __future__ import annotations + +import difflib +import re +from pathlib import Path +from typing import (Any, Dict, Iterable, List, NamedTuple, Optional, Sequence, + Set, Tuple) + +import yaml + +from devbase.errors import DevbaseError +from devbase.log import get_logger + +logger = get_logger(__name__) + + +class ComposeParseError(DevbaseError): + """``compose.yml`` を YAML として読めない + + 事後検証 (:func:`remaining_secret_env_file_refs`) は「機密参照が残って + いないこと」をパース結果で確かめる。パースできなければ確かめようがない + ため、「参照が無い」と読み替えて先へ進んではいけない。呼び出し側は + 構成ファイルの読み取り失敗と同じ扱いで移行を中止する。 + """ + +#: コメントアウトした行に付ける目印。復元時はこれを取り除くだけで元に戻る。 +DISABLED_MARK = '# devbase(PLAN35) 機密は環境変数で注入: ' + +#: 共通の機密ファイルを指す ``env_file`` エントリ +GLOBAL_ENTRIES = ('${DEVBASE_ROOT}/.env', '$DEVBASE_ROOT/.env') + +#: プロジェクトの機密ファイルを指す ``env_file`` エントリ +PROJECT_ENTRIES = ('.env', './.env') + +TARGET_GLOBAL = 'global' +TARGET_PROJECT = 'project' + +_ENV_FILE_KEY_RE = re.compile(r'^(\s*)env_file:\s*(#.*)?$') +_LIST_ITEM_RE = re.compile(r'^(\s*)-\s*(.*?)\s*$') + +#: ``env_file:`` の後ろに値が続く書き方 (インライン配列・単一文字列)。 +#: このうち単一文字列は 1 行で完結するため書き換えの対象にできる +#: (:func:`_inline_scalar_ref`)。それ以外は検出して警告・中止に回す。 +_ENV_FILE_INLINE_RE = re.compile(r'^\s*env_file:\s*(?!#)(\S.*)$') + +#: 「1 行で完結する単一文字列」とはみなせない値の先頭文字。フロー記法 +#: (``[`` ``{``) は 1 行に複数の指定が同居し、ブロックスカラー (``|`` ``>``) や +#: アンカー・別名・タグ (``&`` ``*`` ``!``) は続きの行を持ちうる。どちらも行ごと +#: コメントアウトすると無関係な指定を巻き込む / YAML が壊れるため、契約 2. 側へ +#: 回して中止・警告に落とす。 +_UNSAFE_SCALAR_HEADS = ('[', '{', '|', '>', '&', '*', '!', '?', '%', '@', '`', + ',', '-') + +#: long syntax (``- path: .env``) の ``path`` キー。Compose はエントリを +#: マッピングでも書けるため、文字列としてだけ見ると参照を取りこぼす。 +_LONG_SYNTAX_PATH_RE = re.compile(r"""^(?:path|'path'|"path")\s*:\s*(.*)$""") + +#: フロー記法 (``{ path: .env, required: false }``) から ``path`` の値だけを拾う。 +#: 書き換えの対象にはしないが、「機密を指しているか」の判定には要る。 +_FLOW_PATH_RE = re.compile( + r"""(?:^|[\[{,]\s*)(?:path|'path'|"path")\s*:\s*([^,}\]]*)""") + +#: ``services:`` セクションの開始行 +_SERVICES_KEY_RE = re.compile(r'^(\s*)services:\s*(#.*)?$') + +#: サービス名の行 (`` dev:`` / `` "db":`` / `` db: # コメント``) +_SERVICE_KEY_RE = re.compile( + r"""^\s*(?:"([^"]*)"|'([^']*)'|([^\s#:][^:]*)):\s*(#.*)?$""") + + +class _Entry(NamedTuple): + """``env_file`` ブロックの 1 エントリ + + Attributes: + index: エントリが始まる行の位置。 + refs: そのエントリが指しうる参照先。続きの行に書かれた ``path`` も + 含める。移行を止めるべきかの判定に使う。 + disabled: すでにコメントアウトされているか。 + supported: **行単位で無効化・復元できるか** (モジュール冒頭の契約 1.)。 + 偽なら書き換えず、警告か中止のどちらかに落とす。 + """ + + index: int + refs: Tuple[str, ...] + disabled: bool + supported: bool + + +def _indent_of(line: str) -> int: + return len(line) - len(line.lstrip(' ')) + + +def _split_eol(line: str) -> Tuple[str, str]: + """行を ``(中身, 行末)`` に分ける。 + + ``rstrip('\\n')`` で行末を落として ``'\\n'`` を付け直すと、CRLF の行が + LF になってしまう。暗号化 → 復号の往復で元の ``compose.yml`` に戻らず、 + 書き換えた行だけ改行コードが混ざる。元の行末をそのまま付け直せるよう + ここで分けておく。 + """ + for eol in ('\r\n', '\n', '\r'): + if line.endswith(eol): + return line[:-len(eol)], eol + return line, '' + + +def _strip_quotes(value: str) -> str: + if len(value) >= 2 and value[0] == value[-1] and value[0] in ('"', "'"): + value = value[1:-1] + return value.strip() + + +def _entry_value(raw: str) -> str: + """``- "${DEVBASE_ROOT}/.env" # comment`` から参照先だけを取り出す""" + return _strip_quotes(raw.split('#', 1)[0].strip()) + + +def _long_syntax_ref(text: str) -> Optional[str]: + """``path: .env`` から参照先を取り出す (long syntax でなければ None)""" + match = _LONG_SYNTAX_PATH_RE.match(text.strip()) + if not match: + return None + return _strip_quotes(match.group(1).split('#', 1)[0].strip()) + + +def _flow_map_refs(text: str) -> List[str]: + """フロー記法の中の ``path`` の値をすべて拾う。 + + ``- { path: .env, required: false }`` や ``env_file: [{path: .env}]`` は + 書き換えの対象にしない (契約 2.) が、機密を指しているなら移行を止める + 必要があるため、判定に使う参照先だけは取り出す。 + """ + return [_strip_quotes(value.strip()) + for value in _FLOW_PATH_RE.findall(text) + if value.strip()] + + +def _inline_scalar_ref(raw: str) -> Optional[str]: + """``env_file: .env`` の値が **1 行で完結する単一文字列** なら参照先を返す。 + + この形はエントリが 1 つしかなく、``env_file:`` の行ごとコメントアウトしても + 他の指定を巻き込まない。だから中止 (契約 2.) ではなく書き換えの対象にできる + (契約 1.)。1 行で安全に判断できない値 — フロー記法・ブロックスカラー・ + 閉じていないクォート — は ``None`` を返し、従来どおり中止・警告へ回す。 + + Args: + raw: ``env_file:`` の後ろに続く部分 (行末コメントを含みうる) + + Returns: + 参照先の文字列。単一文字列として扱えない場合は ``None``。 + """ + value = raw.strip() + if value[:1] in ('"', "'"): + # クォートされた値は閉じ引用符まで見る。`"a # b"` のように値の中へ + # `#` が入る場合、先にコメントで切ると参照先を取り違える。 + quote = value[0] + end = value.find(quote, 1) + if end < 0: + return None # 閉じていない = 続きの行を持つ可能性がある + rest = value[end + 1:].strip() + if rest and not rest.startswith('#'): + return None # 引用符の後ろに別の指定が続く + return value[1:end] + value = value.split('#', 1)[0].strip() + if not value or value[0] in _UNSAFE_SCALAR_HEADS: + return None + return value + + +def _inline_entries(raw: str) -> List[str]: + """``env_file:`` の後ろに直接書かれた値から参照の一覧を取り出す。 + + ``[ "${DEVBASE_ROOT}/.env", .env ]`` のようなインライン配列と、 + ``.env`` のような単一文字列の両方を受ける。「機密を指しているかどうか」の + 判定に使う (単一文字列は書き換えもできるが、その判定は + :func:`_inline_scalar_ref` が行う)。 + """ + value = raw.split('#', 1)[0].strip() + if value.startswith('['): + inner = value[1:] + if inner.endswith(']'): + inner = inner[:-1] + parts = inner.split(',') + else: + parts = [value] + found = [item for item in (_strip_quotes(part.strip()) for part in parts) + if item] + # 要素がフロー記法のマッピングだと上の分割では参照先にならない + # (``{path: .env}`` がそのまま 1 要素になる)。``path`` の値も足しておく。 + found.extend(_flow_map_refs(value)) + return found + + +def _list_item_refs(body: str) -> Tuple[Tuple[str, ...], bool]: + """リスト項目の中身から ``(参照先, 行単位で扱えるか)`` を返す。 + + ``- .env`` のような文字列と ``- path: .env`` の long syntax はどちらも + 1 行で閉じているので書き換えられる。フロー記法のマッピングだけは 1 行に + 複数の指定が同居するため対象外にする (契約 2.)。 + """ + value = body.split('#', 1)[0].strip() + if value.startswith('{') or value.startswith('['): + return tuple(_flow_map_refs(value)), False + ref = _long_syntax_ref(value) + if ref is not None: + return (ref,), True + return (_strip_quotes(value),), True + + +def _service_name(line: str) -> Optional[str]: + """サービス名の行から **YAML と同じ姿の** 名前を取り出す。 + + ``"db":`` のようにクォートされたキーも有効な YAML で、PyYAML は ``db`` を + 返す。引用符込みで記録すると、パース済みのサービス名と照合する生成側 + (``devbase.volume.compose``) と一致せず、そのサービスへ機密が渡らない。 + ここで引用符を外して揃える (二重引用符の中のバックスラッシュ表記までは + 解釈しない。構成ファイルのサービス名には現れないため)。 + """ + match = _SERVICE_KEY_RE.match(line) + if not match: + return None + double, single, bare = match.group(1), match.group(2), match.group(3) + if double is not None: + return double + if single is not None: + return single.replace("''", "'") + return bare.strip() + + +def _target_of(value: str) -> Optional[str]: + """``env_file`` の 1 エントリが**どちらの機密**を指しているかを返す。 + + 「機密かどうか」だけでなく由来 (共通 / プロジェクト) まで返すのは、機密の + 渡し先を決める側 (``devbase.volume.compose``) が「そのサービスが元々 + 受け取っていた由来のキーだけ」を列挙できるようにするため。真偽値だけでは + 共通設定しか読んでいなかったサービスにプロジェクト固有の機密まで渡って + しまう。 + """ + if value in GLOBAL_ENTRIES: + return TARGET_GLOBAL + if value in PROJECT_ENTRIES: + return TARGET_PROJECT + return None + + +def _is_target(value: str, targets: Set[str]) -> bool: + return _target_of(value) in targets + + +def is_secret_entry(value: str, + targets: Iterable[str] = (TARGET_GLOBAL, TARGET_PROJECT) + ) -> bool: + """``env_file`` の 1 エントリが「暗号化移行で消える既知の機密参照」かを返す。 + + 判定そのものは :func:`_is_target` と同じだが、あちらは private なので、 + 構成生成側 (``devbase.volume.compose``) から同じ基準で判定するための公開窓口 + として置く。判定を 1 箇所に集めておかないと、移行が外す参照と生成が落とす + 参照がずれる。 + """ + if not isinstance(value, str): + return False + return _is_target(value.strip(), set(targets)) + + +def _is_disabled(line: str) -> bool: + return line.lstrip(' ').startswith(DISABLED_MARK) + + +def _disable_line(content: str) -> str: + """行末は含めずに受け取り、目印を付けた姿を返す。 + + 末尾の空白まで含めてそのまま残すのは、復元したときに元のバイト列へ戻す + ため (行末は :func:`_split_eol` で別に持ち回る)。 + """ + indent = ' ' * _indent_of(content) + return f"{indent}{DISABLED_MARK}{content.lstrip(' ')}" + + +def _enable_line(content: str) -> str: + indent = ' ' * _indent_of(content) + return f"{indent}{content.lstrip(' ')[len(DISABLED_MARK):]}" + + +def _source_line(line: str) -> str: + """無効化されているかに関わらず、その行の「YAML としての姿」を返す。 + + 復元側はキー行もエントリ行もコメントアウトされている場合があるため、 + インデントや記法の判定は目印を外した姿に対して行う必要がある。 + """ + return _enable_line(line) if _is_disabled(line) else line + + +def _is_skippable(line: str) -> bool: + """空行、または利用者が書いた単独のコメント行かを返す。 + + どちらも YAML としての構造を持たないので、走査の途中で出てきても + ブロックの終わりとみなしてはいけない。ここで打ち切ると、**コメント行より + 後ろに書かれた機密参照が無効化されないまま残り**、平文を退避したあとに + Compose が存在しないファイルを読もうとして起動できなくなる。 + + 無効化済みの行 (:data:`DISABLED_MARK` 付き) も見た目はコメント行だが、 + 中身はエントリなので読み飛ばしてはいけない。判定の順序を間違えると + ``enable`` が何も復元できなくなるため、先に :func:`_is_disabled` で除く。 + """ + stripped = line.strip() + if not stripped: + return True + if _is_disabled(stripped): + return False + return stripped.startswith('#') + + +def _with_continuation(entry: _Entry, source: str) -> _Entry: + """続きの行を持つエントリに「行単位では扱えない」印を付ける。 + + ``- path: .env`` の下に ``required: false`` が続く形は、``- path:`` の行だけ + コメントアウトすると ``required: false`` が宙に浮いて YAML が壊れる。行を + またぐ範囲を安全に無効化・復元する術がないので、書き換えの対象から外して + 中止・警告へ回す (契約 2.)。続きの行に書かれた ``path`` も控えておかないと、 + ``-`` の行に参照が現れない書き方で機密を見落とす。 + """ + refs = entry.refs + ref = _long_syntax_ref(source) + if ref: + refs = refs + (ref,) + return entry._replace(refs=refs, supported=False) + + +def _scan_env_file_block(lines: Sequence[str], key_index: int, key_indent: int + ) -> Tuple[List[_Entry], int]: + """``env_file:`` ブロックのエントリを集め、ブロックの終端を返す。 + + ``disable`` と ``enable`` は向きが逆なだけで「どこからどこまでがブロックで、 + どの行がエントリか」の判定は同じである。二重に持つと片方だけ直したときに + 無効化と復元がずれるため、走査はここ 1 箇所に集める。扱えない記法の検出も + 同じ走査に相乗りさせる (:func:`_unsupported_entries`)。 + + Args: + key_index: ``env_file:`` キー行の位置。 + key_indent: キー行のインデント (目印を外した姿で数えたもの)。 + + Returns: + ``([エントリ], ブロック終端の行の位置)`` + """ + entries: List[_Entry] = [] + index = key_index + 1 + item_indent: Optional[int] = None + while index < len(lines): + raw = _split_eol(lines[index])[0] + if _is_skippable(raw): + index += 1 + continue + # 無効化済みの行も「YAML としての姿」に戻してインデントと記法を見る + source = _source_line(raw) + if _indent_of(source) <= key_indent: + break + item = _LIST_ITEM_RE.match(source) + if item is None: + # `- ` で始まらないのにブロックの中にある行。long syntax の続き + # (`required: false` など) か、そもそもシーケンスでない値である。 + # どちらも行単位では扱えないので、直前のエントリに印を付けて先へ + # 進む。ここで走査を打ち切る方が危険で、後ろに並ぶエントリを丸ごと + # 取りこぼし、機密の参照が有効なまま残ってしまう。 + if item_indent is None: + # `env_file:` の直下がシーケンスでない。ブロック全体を 1 つの + # 扱えないエントリとみなし、キーより深い行はすべて続きとして + # 束ねる。 + entries.append(_Entry(index, (), False, False)) + item_indent = key_indent + entries[-1] = _with_continuation(entries[-1], source) + index += 1 + continue + item_indent = _indent_of(source) + refs, supported = _list_item_refs(item.group(2)) + entries.append(_Entry(index, refs, _is_disabled(raw), supported)) + index += 1 + return entries, index + + +def disable(text: str, targets: Iterable[str] = (TARGET_GLOBAL, TARGET_PROJECT) + ) -> Tuple[str, List[str]]: + """機密ファイルを指す ``env_file`` エントリをコメントアウトする。 + + 書き換えるのは契約 1. の記法だけで、扱えない記法には触れない。触れない分は + :func:`secret_unsupported_env_file_lines` が中止の理由として拾う。 + + Returns: + ``(書き換え後のテキスト, 無効化した参照の一覧)`` + """ + wanted = set(targets) + lines = text.splitlines(keepends=True) + disabled: List[str] = [] + + index = 0 + while index < len(lines): + content, eol = _split_eol(lines[index]) + + # `env_file: .env` のように値が単一文字列で 1 行に収まっている形は、 + # その行がそのまま 1 エントリなのでキー行ごと落とす (契約 1.) + inline = _ENV_FILE_INLINE_RE.match(content) + if inline: + ref = _inline_scalar_ref(inline.group(1)) + if ref is not None and _is_target(ref, wanted): + lines[index] = _disable_line(content) + eol + disabled.append(ref) + index += 1 + continue + + match = _ENV_FILE_KEY_RE.match(content) + if not match: + index += 1 + continue + + key_index = index + key_indent = len(match.group(1)) + touched_here = False + active_entries = 0 + + entries, block_end = _scan_env_file_block(lines, key_index, key_indent) + for entry in entries: + if entry.disabled: + # すでに無効化されている。有効なエントリとしても数えない + continue + if entry.supported and _is_target(entry.refs[0], wanted): + entry_content, entry_eol = _split_eol(lines[entry.index]) + lines[entry.index] = _disable_line(entry_content) + entry_eol + disabled.append(entry.refs[0]) + touched_here = True + else: + active_entries += 1 + + # 全エントリを落とすと `env_file:` だけが残り、Compose が + # 「env_file は文字列かリスト」で失敗する。キー行ごと無効化する。 + if touched_here and active_entries == 0: + lines[key_index] = _disable_line(content) + eol + + index = block_end + + return ''.join(lines), disabled + + +def enable(text: str, targets: Iterable[str] = (TARGET_GLOBAL, TARGET_PROJECT) + ) -> Tuple[str, List[str]]: + """``disable`` が付けた目印を外し、元の行へ戻す。 + + ``disable`` と同じく **種別を絞れる**。一部のプロジェクトだけを復号した + ときに全マーカーを戻すと、まだ暗号化されたままの共通設定 + (``${DEVBASE_ROOT}/.env``) の参照まで有効になり、存在しないファイルを + 指したまま Compose の起動が失敗する。 + + Returns: + ``(書き換え後のテキスト, 復元した行の一覧)`` + """ + wanted = set(targets) + lines = text.splitlines(keepends=True) + restored: List[str] = [] + + index = 0 + while index < len(lines): + content, eol = _split_eol(lines[index]) + # キー行そのものが無効化されている場合があるため、目印を外した姿で判定する + source = _source_line(content) + + # 単一文字列の形は行ごと無効化されている。同じ条件で戻す (契約 1.) + inline = _ENV_FILE_INLINE_RE.match(source) + if inline: + if _is_disabled(content): + ref = _inline_scalar_ref(inline.group(1)) + if ref is not None and _is_target(ref, wanted): + lines[index] = _enable_line(content) + eol + restored.append(lines[index].strip()) + index += 1 + continue + + match = _ENV_FILE_KEY_RE.match(source) + if not match: + index += 1 + continue + + key_index = index + key_disabled = _is_disabled(content) + key_indent = _indent_of(_source_line(content)) + active_entries = 0 + + entries, block_end = _scan_env_file_block(lines, key_index, key_indent) + for entry in entries: + if not entry.disabled: + active_entries += 1 + continue + if entry.supported and _is_target(entry.refs[0], wanted): + entry_content, entry_eol = _split_eol(lines[entry.index]) + lines[entry.index] = _enable_line(entry_content) + entry_eol + restored.append(lines[entry.index].strip()) + active_entries += 1 + + # キー行は「有効なエントリが 1 つも残らない」場合に無効化されている。 + # 逆向きも同じ条件で判断し、エントリが戻ったときにだけ復元する。 + # まだ全エントリが無効なまま `env_file:` を戻すと Compose が失敗する。 + if key_disabled and active_entries > 0: + lines[key_index] = _enable_line(content) + eol + restored.append(lines[key_index].strip()) + + index = block_end + + return ''.join(lines), restored + + +def _unsupported_entries(text: str) -> List[Tuple[int, str, Tuple[str, ...]]]: + """行単位では扱えない ``env_file`` の記述を、指しうる参照つきで列挙する。 + + 契約 2. に当たるものをすべて集める。参照先まで返すのは、呼び出し側が + 「警告で済ませてよい行」と「移行を止めるべき行」を区別できるようにするため。 + + Returns: + ``[(1 始まりの行番号, 行の内容, その記述が指しうる参照)]`` + """ + lines = text.splitlines() + found: List[Tuple[int, str, Tuple[str, ...]]] = [] + + index = 0 + while index < len(lines): + stripped = lines[index].rstrip() + if not _is_disabled(stripped): + inline = _ENV_FILE_INLINE_RE.match(stripped) + if inline: + # `env_file:` の後ろに値が続く書き方。単一文字列は行ごと + # 無効化できる (契約 1.) ので挙げない。フロー記法など 1 行で + # 安全に判断できないものだけを中止・警告の対象にする + if _inline_scalar_ref(inline.group(1)) is None: + found.append((index + 1, stripped.strip(), + tuple(_inline_entries(inline.group(1))))) + index += 1 + continue + + source = _source_line(stripped) + match = _ENV_FILE_KEY_RE.match(source) + if not match: + index += 1 + continue + + # ブロックの中に潜む扱えない記法 (続きの行を持つ long syntax など) は + # 行を単独で見ても分からない。無効化と同じ走査で拾う。 + entries, block_end = _scan_env_file_block( + lines, index, len(match.group(1))) + for entry in entries: + if not entry.supported: + found.append((entry.index + 1, + lines[entry.index].strip(), entry.refs)) + index = block_end + + return found + + +def unsupported_env_file_lines(text: str) -> List[Tuple[int, str]]: + """行単位では扱えない ``env_file`` 記法を列挙する (契約 2.)。 + + Returns: + ``[(1 始まりの行番号, 行の内容)]`` + """ + return [(number, line) for number, line, _ in _unsupported_entries(text)] + + +def warn_unsupported_env_file(text: str, path: Optional[Path] = None + ) -> List[Tuple[int, str]]: + """扱えない ``env_file`` 記法を見つけたら警告する。 + + 移行の対象から外れることを黙っていると、利用者は「移行できた」と思った + まま起動して初めて壊れていることに気付く。どのファイルの何行目を手で + 直せばよいかまで示す。 + """ + found = unsupported_env_file_lines(text) + for number, line in found: + logger.warning( + "%s:%d の env_file は行単位では自動で書き換えられない記法です" + " (対応しているのは `env_file:` の下に `- ...` を 1 行ずつ並べる" + "書き方だけです)。手動で書き換えてください: %s", + path if path is not None else '', number, line) + return found + + +def secret_unsupported_env_file_lines( + text: str, + targets: Iterable[str] = (TARGET_GLOBAL, TARGET_PROJECT) +) -> List[Tuple[int, str]]: + """**機密ファイルを指している**扱えない記法の行だけを列挙する。 + + :func:`unsupported_env_file_lines` は契約 2. に当たるものをすべて返すが、 + そのうち ``env_file: config/app.env`` のように機密と無関係なものは移行に + 影響しない (書き換える必要が無い)。一方 ``env_file: [.env]`` や + ``- path: .env`` + ``required: false`` のように機密を指しているものは、平文を + 退避したあとも参照が有効なまま残り、Compose が存在しないファイルを読もうと + して起動できなくなる。**警告で流すのではなく移行を止める**必要があるため、 + その 2 つをここで区別する。 + + Returns: + ``[(1 始まりの行番号, 行の内容)]`` + """ + wanted = set(targets) + return [(number, line) for number, line, refs in _unsupported_entries(text) + if any(_is_target(ref, wanted) for ref in refs)] + + +def _parsed_env_file_refs(value: Any) -> List[str]: + """パース済みの ``env_file`` の値から参照文字列を平坦化して取り出す。 + + Compose の ``env_file`` は 3 通りの姿を取る。どれか 1 つでも見落とすと + 事後検証がその形を素通りさせてしまうため、すべてここで畳む:: + + env_file: .env # 文字列 + env_file: [.env, config/app.env] # 文字列のリスト + env_file: + - path: .env # long syntax (dict) のリスト + required: false + + 文字列にならない値 (数値や入れ子など Compose としては不正なもの) は + 参照として扱えないので落とす。落としたものが機密を指していることは + ありえない (機密参照は必ず文字列で書かれる)。 + """ + entries = value if isinstance(value, list) else [value] + refs: List[str] = [] + for entry in entries: + if isinstance(entry, dict): + entry = entry.get('path') + if isinstance(entry, str): + refs.append(entry.strip()) + return refs + + +def remaining_secret_env_file_refs( + text: str, + targets: Iterable[str] = (TARGET_GLOBAL, TARGET_PROJECT) +) -> List[Tuple[str, str]]: + """**YAML としてパースし**、有効なままの機密参照を列挙する (事後検証)。 + + モジュール冒頭「二段構えの保証」の 2. にあたる最後の砦。行ベースの走査 + (:func:`disable`) が書き換えを終えたテキストを渡すと、記法の判別に一切 + 頼らずに「機密を指す ``env_file`` が有効なまま残っていないか」を確かめ + られる。無効化した行は YAML のコメントなので、パーサからは最初から + 見えない = 残っていれば**走査が取りこぼした**ということになる。 + + 差分が出なかった (何も書き換えなかった) ``compose.yml`` にも掛ける。 + 走査が何も見つけられなかったファイルこそ取りこぼしの疑いが濃く、素通り + させると平文だけ退避された壊れた構成が残る。 + + Args: + text: 検証するテキスト (書き換え後のもの) + targets: 消える機密の種別。復号しない種別の参照は残っていて当然 + なので対象から外す。 + + Returns: + ``[(サービス名, 残っている参照)]``。空なら参照は残っていない。 + + Raises: + ComposeParseError: YAML として読めない場合 + """ + wanted = set(targets) + try: + document = yaml.safe_load(text) + except yaml.YAMLError as e: + raise ComposeParseError(f"YAML として読めません: {e}") from e + + if not isinstance(document, dict): + return [] + services = document.get('services') + if not isinstance(services, dict): + return [] + + found: List[Tuple[str, str]] = [] + for name, config in services.items(): + if not isinstance(config, dict) or 'env_file' not in config: + continue + for ref in _parsed_env_file_refs(config['env_file']): + if _is_target(ref, wanted): + found.append((str(name), ref)) + return found + + +def services_with_secret_env_file( + text: str, + targets: Iterable[str] = (TARGET_GLOBAL, TARGET_PROJECT) +) -> Dict[str, Set[str]]: + """機密ファイルを参照している (していた) サービスを **生テキスト** から集める。 + + 移行後の ``compose.yml`` では機密の ``env_file`` 参照がコメントアウトされ、 + YAML としてパースすると見えなくなる。パース結果だけを見ると「元々その参照 + から機密を受け取っていたサービス」(例: DB パスワードを読む ``db``) を + 取りこぼし、機密が渡らないまま起動して失敗する。そこで生テキストを走査し、 + **有効なエントリとコメントアウトされたエントリの両方**を拾う。 + + 行単位で書き換えられない記法 (契約 2.) も対象に含める。移行は止まるが、 + 利用者が手で直したあとも同じ判定が使えるようにするため。 + + Returns: + ``{サービス名: 参照していた種別の集合}``。種別は ``TARGET_GLOBAL`` / + ``TARGET_PROJECT``。単なるサービス名の集合ではなく種別まで返すのは、 + 機密を渡す側が**元々受け取っていた由来のキーだけ**へ絞れるようにする + ため。共通設定だけを読んでいたサービスにプロジェクト固有のトークンまで + 渡すのは、元の構成より機密の範囲を広げてしまう。 + """ + wanted = set(targets) + found: Dict[str, Set[str]] = {} + + def record(service: str, value: str) -> None: + target = _target_of(value) + if target in wanted: + found.setdefault(service, set()).add(target) + + services_indent: Optional[int] = None + service_indent: Optional[int] = None + current: Optional[str] = None + env_file_indent: Optional[int] = None + + for raw_line in text.splitlines(): + stripped = raw_line.rstrip() + # 空行と利用者のコメント行は構造を持たない。ここで env_file ブロックを + # 打ち切ると、その後ろのエントリを取りこぼして機密が渡らなくなる + if _is_skippable(stripped): + continue + # コメントアウト済みの行も「YAML としての姿」に戻して判定する + line = _source_line(stripped) + indent = _indent_of(line) + + if services_indent is None: + match = _SERVICES_KEY_RE.match(line) + if match: + services_indent = len(match.group(1)) + service_indent = None + current = None + env_file_indent = None + continue + + if indent <= services_indent: + # services: セクションを抜けた (volumes: / networks: など) + services_indent = None + service_indent = None + current = None + env_file_indent = None + match = _SERVICES_KEY_RE.match(line) + if match: + services_indent = len(match.group(1)) + continue + + if service_indent is None: + service_indent = indent + + if indent <= service_indent: + # クォート付きのキーも YAML と同じ姿へ揃える。引用符込みで記録すると + # パース済みのサービス名と照合できず、機密が渡らない + current = _service_name(line) + env_file_indent = None + continue + + if current is None: + continue + + if env_file_indent is not None and indent > env_file_indent: + item = _LIST_ITEM_RE.match(line) + if item: + for ref in _list_item_refs(item.group(2))[0]: + record(current, ref) + continue + # `- ` で始まらない行は long syntax の続き (`path:` / `required:`)。 + # ブロックを抜けたことにすると後続のエントリを取りこぼす + ref = _long_syntax_ref(line) + if ref is not None: + record(current, ref) + continue + env_file_indent = None + + if _ENV_FILE_KEY_RE.match(line): + env_file_indent = indent + continue + + inline = _ENV_FILE_INLINE_RE.match(line) + if inline: + for value in _inline_entries(inline.group(1)): + record(current, value) + + return found + + +def find_secret_entries(text: str, + targets: Iterable[str] = (TARGET_GLOBAL, TARGET_PROJECT) + ) -> List[str]: + """有効なままの機密ファイル参照を列挙する (書き換えはしない)""" + _, found = disable(text, targets) + return found + + +def diff(before: str, after: str, path: Path) -> str: + """利用者へ提示するための差分を作る""" + return ''.join(difflib.unified_diff( + before.splitlines(keepends=True), + after.splitlines(keepends=True), + fromfile=f'{path} (現在)', + tofile=f'{path} (変更後)', + )) + + +def compose_files(devbase_root: Path, projects: Sequence[str]) -> List[Path]: + """対象プロジェクトの ``compose.yml`` のうち実在するものを返す""" + root = Path(devbase_root) + found = [] + for name in projects: + path = root / 'projects' / name / 'compose.yml' + if path.is_file(): + found.append(path) + return found diff --git a/lib/devbase/env/io_common.py b/lib/devbase/env/io_common.py index a0b27daa..ec078608 100644 --- a/lib/devbase/env/io_common.py +++ b/lib/devbase/env/io_common.py @@ -3,13 +3,19 @@ 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 from typing import List, Optional, Sequence, Type @@ -87,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`` を強制)。 @@ -98,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) @@ -120,3 +194,65 @@ def write_secure_bytes(path: Path, data: bytes, *, mode: int = 0o600) -> None: os.chmod(path, mode) except OSError: pass + + +def _fsync_dir(directory: Path) -> None: + """ディレクトリエントリを fsync する (対応しない環境では黙って諦める)。 + + ``os.replace`` 自体は atomic でも、rename の記録がディスクへ届く前に電源断 + すると差し替えが失われうる。ディレクトリを fsync して rename を永続化する。 + Windows などディレクトリを開けない環境では何もしない。 + """ + try: + fd = os.open(str(directory), os.O_RDONLY) + except OSError: + return + try: + os.fsync(fd) + except OSError: + pass + finally: + os.close(fd) + + +def write_secure_bytes_atomic(path: Path, data: bytes, *, mode: int = 0o600) -> None: + """``path`` の中身を ``data`` へ **atomic に** 差し替える (``mode`` を強制)。 + + ``write_secure_bytes`` は既存ファイルを ``O_TRUNC`` で直接上書きするため、 + ディスク枯渇やプロセス中断が起きると「旧内容は消えたが新内容も揃っていない」 + 中途半端なファイルが残る。age 鍵のように失うと復旧不能なファイルでは、 + + - 同一ディレクトリの一時ファイルへ ``0600`` で書く (別 FS だと rename が + atomic にならないため、必ず同じディレクトリに作る) + - ``fsync`` して中身をディスクへ確定させる + - ``os.replace`` で差し替え、ディレクトリも ``fsync`` する + + という順序にして、途中のどこで失敗しても旧内容がそのまま残るようにする。 + 失敗時は一時ファイルを掃除してから例外を送出する。 + + 親ディレクトリの扱いは ``write_secure_bytes`` と同じく ``ensure_private_dir`` + に任せる (新規に掘る階層だけ ``0700``、既存の権限は変えない)。 + """ + ensure_private_dir(path.parent) + # mkstemp は 0600 で作成するため、作成時点から権限が広がらない。 + fd, tmp_name = tempfile.mkstemp( + prefix=f'.{path.name}.', suffix='.tmp', dir=str(path.parent)) + tmp = Path(tmp_name) + try: + with os.fdopen(fd, 'wb') as f: + f.write(data) + f.flush() + os.fsync(f.fileno()) + # mkstemp の mode が無視される環境 (Windows 等) に備えて明示的に揃える + try: + os.chmod(tmp, mode) + except OSError: + pass + os.replace(tmp, path) + except BaseException: + try: + tmp.unlink() + except OSError: + pass + raise + _fsync_dir(path.parent) diff --git a/lib/devbase/env/io_import.py b/lib/devbase/env/io_import.py index 6107e1f1..344a7dcc 100644 --- a/lib/devbase/env/io_import.py +++ b/lib/devbase/env/io_import.py @@ -114,29 +114,67 @@ def _decrypt_if_needed(blob: bytes, opts: ImportOptions) -> bytes: return _cipher.decrypt(blob, identities=identities) +def _secret_ref_for(arcname: str): + """バンドル内 arcname に対応する秘密ストアの参照 (機密でなければ ``None``)""" + from devbase.env.secret_store import SecretRef + + if arcname == 'env/global.env': + return SecretRef.for_global() + match = _merge._PROJECT_ENV_RE.match(arcname) + if match: + return SecretRef.for_project(match.group(1)) + return None + + def _build_plans( filtered: dict, devbase_root: Path, opts: ImportOptions ) -> Tuple[List[_merge.Plan], Optional[Tuple[Path, bytes]]]: - """フィルタ済みメンバーから書き出し計画と sources.yml の参照用コピー対象を返す""" + """フィルタ済みメンバーから書き出し計画と sources.yml の参照用コピー対象を返す + + 機密の書き出し先は秘密ストアに聞く。暗号化されている環境へ import したときに + 平文の ``.env`` を作ってしまうと、暗号化ファイルと平文が同時に存在する状態に + なり、以後どちらが正か判断できなくなる (plan35 §9)。既存内容の読み取りと + 書き出しの両方をストア越しに行い、保存形式を維持する。 + """ + from dataclasses import replace as _dc_replace + + from devbase.env.secret_store import SecretStore + + store = SecretStore(devbase_root) plans: List[_merge.Plan] = [] sources_reference: Optional[Tuple[Path, bytes]] = None try: for arcname, data in sorted(filtered.items()): - target = _merge.target_for(arcname, devbase_root) if arcname == 'env/sources.yml': + target = _merge.target_for(arcname, devbase_root) plan = _merge.plan_sources(target, data, merge_metadata=opts.merge_metadata) if plan is not None: plans.append(plan) else: sources_reference = (target, data) - else: - plans.append(_merge.plan_env_merge( - target, data, arcname, - merge=opts.merge, - replace=opts.replace, - replace_keys=opts.replace_keys, - )) + continue + + ref = _secret_ref_for(arcname) + if ref is None: + raise _merge.MergeError(f"未対応のバンドルエントリ: {arcname}") + + exists = store.exists(ref) + plan = _merge.plan_env_merge( + store.path(ref), data, arcname, + merge=opts.merge, + replace=opts.replace, + replace_keys=opts.replace_keys, + existing_bytes=store.load_bytes(ref) if exists else b'', + target_exists=exists, + ) + if store.is_encrypted(ref): + # merge の結果は平文のバイト列なので、暗号化されている保存先へ + # 書く前にここで暗号文へ変換する。以降の原子的書き込み・ + # ロールバックはバイト列とパスだけを扱うため、そのまま通せる。 + plan = _dc_replace( + plan, new_bytes=store.age.encrypt_bytes(plan.new_bytes)) + plans.append(plan) except _merge.MergeError as e: raise ImportError(str(e)) from e return plans, sources_reference diff --git a/lib/devbase/env/rollback.py b/lib/devbase/env/rollback.py new file mode 100644 index 00000000..96493426 --- /dev/null +++ b/lib/devbase/env/rollback.py @@ -0,0 +1,61 @@ +"""複数の破壊的な操作を「途中失敗で不整合を残さない」単位にまとめる仕組み + +機密の移行 (``env encrypt`` / ``decrypt``) も受信者の更新 (``env rekey``) も、 +複数のファイルを書き換えて初めて意味を持つ操作である。「全部検証してから全部 +実行する」とフェーズを分けるだけでは、実行フェーズの途中で失敗したぶんが +中間状態として残る。そこで **操作を 1 つ実行するたびにその取り消し手続きを積み**、 +どこで失敗しても逆順に巻き戻せるようにする。 + +実装は元々 ``commands/env_migrate`` の内部クラスだったが、``env rekey`` でも +同じ保証が要る (受信者リストだけ更新され、暗号文の一部が旧受信者宛のまま残ると、 +自分の鍵を外す操作では再実行すらできなくなる) ため、共有モジュールへ移した。 +""" + +from __future__ import annotations + +from typing import Callable, List, Tuple + +from devbase.log import get_logger + +logger = get_logger(__name__) + + +class Rollback: + """実行した操作の取り消し手続きを積み、失敗時に逆順で実行する。 + + 使う側は「破壊的な操作をできるだけ後ろへ寄せる」ことと合わせて設計する。 + 先に失うものが少ない操作から実行しておけば、途中で失敗しても巻き戻しは + 「作ったものを消す」だけで済み、復旧の余地が広く残る。 + """ + + def __init__(self) -> None: + self._undo: List[Tuple[str, Callable[[], None]]] = [] + + def push(self, description: str, undo: Callable[[], None]) -> None: + """実行済みの操作に対する取り消し手続きを積む。 + + Args: + description: 取り消しが何をするか (巻き戻しに失敗したときに + 「何が残っているか」として利用者へ見せる) + undo: 取り消し手続き + """ + self._undo.append((description, undo)) + + def unwind(self) -> None: + """積んだ取り消し手続きを逆順に実行する。 + + 後の操作は前の操作を前提にしているため、必ず逆順で戻す。巻き戻しの + 途中で失敗しても残りは試みるが、**握り潰さずに何が残っているかを + 具体的に列挙する**。ここで黙ると、利用者は壊れた状態に気付けない。 + """ + failures: List[str] = [] + for description, undo in reversed(self._undo): + try: + undo() + except Exception as e: # 1 つ失敗しても残りの巻き戻しは続ける + failures.append(f" - {description}: {e}") + self._undo.clear() + if failures: + logger.error( + "巻き戻しに失敗しました。次の操作が完了しておらず、" + "手動での復旧が必要です:\n%s", "\n".join(failures)) diff --git a/lib/devbase/env/runtime.py b/lib/devbase/env/runtime.py new file mode 100644 index 00000000..a8a94e6d --- /dev/null +++ b/lib/devbase/env/runtime.py @@ -0,0 +1,272 @@ +"""実行時に機密をメモリ上で合成し、子プロセスへ渡す + +暗号化した機密は、恒久的な平文ファイルを介さずにコンテナへ届ける必要がある +(plan35 §4.2)。本モジュールは復号結果をプロセス内で合成し、 + + - ``docker compose`` を起動する devbase 自身の環境変数へ載せる + - コンテナへ渡すべき**変数名の一覧**を返す + +の 2 つを提供する。値を持たない変数名の列挙を構成ファイルに書けば、Docker +Compose は自分を起動したプロセスの環境変数からその値を解決する。結果として +暗号文も平文ファイルも Compose には渡らない。 +""" + +from __future__ import annotations + +import os +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any, Dict, List, Optional, Tuple + +from devbase.env.secret_store import SecretRef, SecretStore +from devbase.env.store import EnvFile +from devbase.log import get_logger + +logger = get_logger(__name__) + + +# --------------------------------------------------------------------------- +# プロジェクトの特定 +# --------------------------------------------------------------------------- + +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 + + +# --------------------------------------------------------------------------- +# 機密の合成 +# --------------------------------------------------------------------------- + +@dataclass +class SecretEnv: + """合成した機密と、コンテナへ渡すべき変数名 + + 変数名を**由来 (共通 / プロジェクト) ごとに分けて**持つ。構成生成側は、 + サービスが元々 ``env_file`` で参照していた由来のキーだけを列挙する必要が + あり、全キーをまとめた一覧しか無いと、共通設定だけを読んでいたサービスへ + プロジェクト固有のトークンまで渡ってしまうため (plan35 §4.3)。 + """ + + values: Dict[str, str] = field(default_factory=dict) + #: 共通機密 (``$DEVBASE_ROOT/.env``) 由来のキー + global_names: List[str] = field(default_factory=list) + #: プロジェクト機密 (``projects//.env``) 由来のキー + project_names: List[str] = field(default_factory=list) + + @property + def names(self) -> List[str]: + """コンテナの構成へ列挙する変数名の全体 (共通 → プロジェクトの順) + + 由来を問わず全件が要る場面 (dev サービス、注入した件数のログ) 向けの + 従来どおりの一覧。重複は先に現れた側の位置で 1 件に畳む。 + """ + return list(dict.fromkeys([*self.global_names, *self.project_names])) + + def __bool__(self) -> bool: + return bool(self.global_names or self.project_names) + + +def _project_env_overrides(devbase_root: Path, project: str) -> Dict[str, str]: + """プロジェクトの非機密設定 (``projects//env``) による上書き値。 + + 値そのものはファイルから読まず、既に環境変数へ載っているものだけを採用する。 + ``env`` は ``WORK_DIR=/work/$GIT_REPO`` のように同一ファイル内の変数を参照 + するため、起動ラッパー (または ``_load_project_env``) が展開した後の値が + 正しく、ここで生の行を読み直すと未展開の文字列を掴んでしまう。 + """ + path = Path(devbase_root) / 'projects' / project / 'env' + if not path.is_file(): + return {} + try: + raw = path.read_bytes() + except OSError as e: + logger.warning("プロジェクト設定を読めませんでした (%s): %s", path, e) + return {} + try: + keys = EnvFile.parse_bytes(raw).keys() + except UnicodeDecodeError as e: + logger.warning("プロジェクト設定を UTF-8 として読めませんでした (%s): %s", path, e) + return {} + return {key: os.environ[key] for key in keys if key in os.environ} + + +def resolve(devbase_root: Path, project: Optional[str] = None, + *, store: Optional[SecretStore] = None) -> SecretEnv: + """機密を合成して返す。 + + 重ね順は従来の ``env_file`` の並びを踏襲する: + 共通の機密 → プロジェクトの非機密設定 → プロジェクトの機密。 + + コンテナへ列挙するのは共通機密とプロジェクト機密のキーだけで、非機密設定は + 構成ファイルが ``env_file`` として直接読むため列挙しない。ただし両方に同じ + キーがある場合は、列挙した変数の**値**として非機密設定側を採用する。 + ``environment`` は ``env_file`` より優先されるため、こうしないと + 「プロジェクト設定が共通設定を上書きする」という従来の関係が反転する。 + """ + root = Path(devbase_root) + store = store if store is not None else SecretStore(root) + + global_secrets = store.load(SecretRef.for_global()) + global_names = list(global_secrets) + project_names: List[str] = [] + + merged: Dict[str, str] = dict(global_secrets) + + if project: + merged.update(_project_env_overrides(root, project)) + project_secrets = store.load(SecretRef.for_project(project)) + merged.update(project_secrets) + project_names = list(project_secrets) + + resolved = SecretEnv(global_names=global_names, project_names=project_names) + resolved.values = { + name: merged[name] for name in resolved.names if name in merged + } + return resolved + + +#: この実行で :func:`inject` が載せた履歴。 +#: +#: 値は ``(対象の環境マッピング, {変数名: 載せる**前**の値 (未設定なら None)})``。 +#: +#: 「載せた変数名」だけでなく元の値まで控えるのは、解除時に利用者がシェルで +#: 設定していた同名の変数まで消さないため。元々あった変数は元の値へ戻し、 +#: 元々無かった変数だけを削除する。 +#: +#: さらに**対象マッピングごとに**分けて持つ。:func:`inject` / :func:`clear_injected` +#: は ``environ`` 引数で ``os.environ`` 以外のマッピングを渡され得る (テストや、 +#: 将来「子プロセス用の辞書へ載せて後で戻す」ような呼び出し) ため。履歴が全体で +#: 1 つしか無いと、``inject(..., environ=A)`` の後に ``clear_injected(environ=B)`` +#: を呼んだとき、A に対して記録した内容で B を書き換えてしまい (誤って B の値を +#: 「復元」し)、かつ A には機密が載ったまま残る。 +#: +#: ``dict`` は hashable ではないのでキーには ``id()`` を使うが、対象そのものへの +#: 参照も一緒に保持する。参照を持つ限り対象オブジェクトは生存し続けるので、 +#: 解放済みアドレスの ``id`` が別のマッピングへ再利用されて履歴が誤爆すること +#: がない。解除した時点でその対象の履歴ごと捨てる。 +_injected_originals: Dict[int, Tuple[Any, Dict[str, Optional[str]]]] = {} + + +def _history_for(target) -> Dict[str, Optional[str]]: + """対象マッピングに紐づく注入履歴を返す (無ければ作る)""" + _, originals = _injected_originals.setdefault(id(target), (target, {})) + return originals + + +def clear_injected(environ=None) -> List[str]: + """この実行で載せた機密を取り除き、注入前の状態へ戻す。 + + プロジェクトを切り替える経路 (TUI や ``project up `` の直接起動) では、 + 切替元プロジェクトの機密を載せた後に切替先の機密を載せ直すことになる。この + とき**単に上書きするだけでは足りない**: 切替先に同名のキーが無ければ、切替元 + 固有の機密が ``os.environ`` に残ったまま Compose や子プロセスへ引き継がれて + しまうため。載せ直す前にここを通して、切替元の値を確実に落とす。 + + 非機密設定 (``env``) について起動ラッパーの ``_CALLER_ENV_KEYS`` や + :func:`devbase.commands.container._resolve_project_name` が行っている + 「呼び出し元固有のキーを unset してから対象を読む」のと同じ性質を、機密に + ついても満たすための関数。 + + 自分が **その対象マッピングへ** 載せたキーだけを対象にする。利用者がシェルで + 設定していた同名の変数は注入前の値へ戻すので、消えることはない。他の + マッピングへの注入は、ここでは一切触らない。 + + Returns: + 取り除いた (または元へ戻した) 変数名の一覧 + """ + target = environ if environ is not None else os.environ + entry = _injected_originals.pop(id(target), None) + if entry is None: + return [] + _, originals = entry + cleared = list(originals) + for name, original in originals.items(): + if original is None: + target.pop(name, None) + else: + target[name] = original + if cleared: + logger.debug("機密 %d 件を環境変数から取り除きました", len(cleared)) + return cleared + + +def inject(devbase_root: Path, project: Optional[str] = None, + *, environ=None, store: Optional[SecretStore] = None) -> SecretEnv: + """合成した機密を環境変数へ載せ、載せた内容を返す。 + + ``docker compose`` は devbase 自身の環境変数から値を解決するため、Compose を + 起動する前にここを通す。 + + 載せた変数名と注入前の値を **載せた対象マッピングごとに** 記録し、 + :func:`clear_injected` で元へ戻せるようにする。プロジェクト切替時に切替元の + 機密を落とすために必要 (詳細は :func:`clear_injected` の説明を参照)。 + """ + resolved = resolve(devbase_root, project, store=store) + target = environ if environ is not None else os.environ + if resolved.values: + # 履歴は対象マッピングごとに持つ (理由は _injected_originals の説明を参照)。 + # 載せるものが無いときは記録も作らない (空の履歴が対象への参照を抱え込む + # のを避ける)。 + originals = _history_for(target) + for name in resolved.values: + # 既に記録済みなら上書きしない。記録したいのは「devbase が最初に載せる + # 前の値」であって、前回の注入で載せた機密ではないため。 + if name not in originals: + originals[name] = target.get(name) + target.update(resolved.values) + if resolved.names: + logger.debug("機密 %d 件を環境変数へ載せました", len(resolved.names)) + return resolved + + +def child_env(devbase_root: Path, project: Optional[str] = None, + *, base=None, store: Optional[SecretStore] = None) -> Dict[str, str]: + """機密を載せた子プロセス用の環境変数辞書を作る (``os.environ`` は変えない)""" + env = dict(base if base is not None else os.environ) + env.update(resolve(devbase_root, project, store=store).values) + return env diff --git a/lib/devbase/env/secret_store.py b/lib/devbase/env/secret_store.py new file mode 100644 index 00000000..2f09ca30 --- /dev/null +++ b/lib/devbase/env/secret_store.py @@ -0,0 +1,384 @@ +"""機密の保存先を抽象化する層 (平文 / age) + +``devbase`` が扱う機密は、これまで平文の ``.env`` に直接置かれていた。本モジュールは +「どこに」「どの形式で」保存するかを 1 箇所に閉じ込め、上位の設定操作コマンドからは +``load`` / ``save`` だけを見えるようにする (plan35 §3.1)。 + +保存先の対応: + +=================== ================================== ========================================== +参照 平文 (従来) age (暗号化) +=================== ================================== ========================================== +共通 ``$DEVBASE_ROOT/.env`` ``$DEVBASE_ROOT/secrets/global.env.age`` +プロジェクト ``projects//.env`` ``secrets/projects/.env.age`` +=================== ================================== ========================================== + +どちらを使うかは**ファイルの存在で自動判定**する。暗号化ファイルがあればそれを使い、 +無ければ平文を使う。同じ参照に対して両方が存在する状態は、どちらが正なのか判断できない +ため明示的なエラーにして利用者に解消させる (plan35 §9)。 + +読み書きの経路は 2 つある: + +- ``load`` / ``save``: ``KEY=VALUE`` の辞書として扱う。``devbase env set`` など + 「値を書き換える」操作はこちらを使う +- ``load_bytes`` / ``save_bytes``: **原文のバイト列をそのまま** 扱う。 + ``devbase env encrypt`` / ``decrypt`` の移行はこちらを使い、コメント・空行・ + ``export KEY=...`` のような表記を保ったまま往復させる + +このため「**値を書き換えるまでは原文が保たれ、書き換えると正規化される**」という +性質になる。``env set`` で 1 つでも値を更新すると ``EnvFile.dump_bytes`` の書式 +(キーの昇順・コメントの消失) へ揃うが、これは平文しか無かった頃と同じ挙動であり、 +暗号化したからといって変わるものではない。 +""" + +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path +from typing import Dict, List, Optional, Protocol, Sequence + +from devbase.env import agekeys +from devbase.env import cipher as _cipher +from devbase.env import io_common as _io_common +from devbase.env.store import EnvFile +from devbase.errors import DevbaseError +from devbase.log import get_logger + +logger = get_logger(__name__) + + +class SecretStoreError(DevbaseError): + """秘密ストアの操作エラー""" + + +#: 暗号化された機密を置くディレクトリ名 (``$DEVBASE_ROOT`` 相対) +SECRETS_DIRNAME = 'secrets' + +GLOBAL_ENCRYPTED_FILENAME = 'global.env.age' + +MODE_AGE = 'age' +MODE_PLAINTEXT = 'plaintext' +MODE_ABSENT = 'absent' + + +def _validate_project_name(name: str) -> str: + """プロジェクト名がパスを跨がないことを確認する。 + + 参照名はそのままファイル名に使われるため、``..`` や区切り文字を許すと + ``secrets/`` の外側へ書き出せてしまう。 + """ + if not name: + raise SecretStoreError("プロジェクト名が空です") + if name != Path(name).name or name in ('.', '..'): + raise SecretStoreError( + f"プロジェクト名にパス区切りは使えません: {name!r}" + ) + return name + + +@dataclass(frozen=True) +class SecretRef: + """機密の参照 (共通 / プロジェクト)""" + kind: str # 'global' | 'project' + name: Optional[str] = None + + @staticmethod + def for_global() -> 'SecretRef': + return SecretRef(kind='global') + + @staticmethod + def for_project(name: str) -> 'SecretRef': + return SecretRef(kind='project', name=_validate_project_name(name)) + + def label(self) -> str: + return 'グローバル' if self.kind == 'global' else f"プロジェクト '{self.name}'" + + +class SecretBackend(Protocol): + name: str + + def path(self, ref: SecretRef) -> Path: ... + def exists(self, ref: SecretRef) -> bool: ... + def load(self, ref: SecretRef) -> Dict[str, str]: ... + def save(self, ref: SecretRef, data: Dict[str, str]) -> Path: ... + def load_bytes(self, ref: SecretRef) -> bytes: ... + def save_bytes(self, ref: SecretRef, data: bytes) -> Path: ... + def remove(self, ref: SecretRef) -> bool: ... + + +class PlaintextBackend: + """従来どおり平文の ``.env`` を読み書きする""" + + name = MODE_PLAINTEXT + + def __init__(self, devbase_root: Path): + self._root = Path(devbase_root) + + def path(self, ref: SecretRef) -> Path: + if ref.kind == 'global': + return self._root / '.env' + return self._root / 'projects' / _validate_project_name(ref.name or '') / '.env' + + def exists(self, ref: SecretRef) -> bool: + return self.path(ref).is_file() + + def load_bytes(self, ref: SecretRef) -> bytes: + """``.env`` の中身を **原文のバイト列のまま** 返す (不在なら空)""" + path = self.path(ref) + if not path.is_file(): + return b'' + try: + return path.read_bytes() + except OSError as e: + raise SecretStoreError(f"読み込みに失敗しました ({path}): {e}") from e + + def save_bytes(self, ref: SecretRef, data: bytes) -> Path: + """バイト列を **加工せずそのまま** ``.env`` へ書き出す""" + path = self.path(ref) + try: + # 平文とはいえ機密の入れ物なので、暗号化側と同じく atomic に差し替える。 + # 直接 O_TRUNC すると書き込み途中の失敗で旧値も新値も失った空ファイルが + # 残り、その状態で暗号化すると中身の無い機密を保存してしまう。 + _io_common.write_secure_bytes_atomic(path, data) + except OSError as e: + raise SecretStoreError(f"書き込みに失敗しました ({path}): {e}") from e + return path + + def load(self, ref: SecretRef) -> Dict[str, str]: + try: + return EnvFile.parse_bytes(self.load_bytes(ref)) + except UnicodeDecodeError as e: + raise SecretStoreError( + f"{self.path(ref)} を UTF-8 として読めませんでした: {e}\n" + "暗号化済みファイルを平文として読もうとしていないか確認してください" + ) from e + + def save(self, ref: SecretRef, data: Dict[str, str]) -> Path: + """辞書を ``EnvFile`` の書式へ整形して保存する。 + + コメント・空行・``export`` 表記は辞書に載らないため、この経路を通ると + 内容が正規化される。原文を保ちたい移行系は :meth:`save_bytes` を使う。 + """ + return self.save_bytes(ref, EnvFile.dump_bytes(data)) + + def remove(self, ref: SecretRef) -> bool: + path = self.path(ref) + if not path.exists(): + return False + try: + path.unlink() + except OSError as e: + raise SecretStoreError(f"削除に失敗しました ({path}): {e}") from e + return True + + +class AgeBackend: + """age で暗号化したファイルを読み書きする""" + + name = MODE_AGE + + def __init__(self, devbase_root: Path, *, + recipients: Optional[Sequence[str]] = None, + identities: Optional[Sequence[str]] = None): + self._root = Path(devbase_root) + self._recipients = list(recipients) if recipients is not None else None + self._identities = list(identities) if identities is not None else None + + # -- 鍵の解決 ----------------------------------------------------------- + + def recipients(self) -> List[str]: + if self._recipients is not None: + return self._recipients + return agekeys.resolve_recipients(self._root) + + def identities(self) -> List[str]: + if self._identities is not None: + return self._identities + found = agekeys.resolve_identities() + if not found: + raise SecretStoreError( + "復号に使える秘密鍵が見つかりません。\n" + f" `devbase env keygen` で生成するか、{agekeys.KEY_FILE_ENV} " + "で鍵ファイルの場所を指定してください" + ) + return found + + # -- 保存先 ------------------------------------------------------------- + + def path(self, ref: SecretRef) -> Path: + base = self._root / SECRETS_DIRNAME + if ref.kind == 'global': + return base / GLOBAL_ENCRYPTED_FILENAME + name = _validate_project_name(ref.name or '') + return base / 'projects' / f'{name}.env.age' + + def exists(self, ref: SecretRef) -> bool: + return self.path(ref).is_file() + + # -- 読み書き ----------------------------------------------------------- + + def load_bytes(self, ref: SecretRef) -> bytes: + """復号した **生バイト列** を返す (不在なら空)。 + + 中身を ``KEY=VALUE`` として解釈しないので、コメント・空行・``export`` + 表記を含む原文をそのまま取り出せる。 + """ + path = self.path(ref) + if not path.is_file(): + return b'' + try: + blob = path.read_bytes() + except OSError as e: + raise SecretStoreError(f"読み込みに失敗しました ({path}): {e}") from e + try: + return _cipher.decrypt(blob, identities=self.identities()) + except _cipher.CipherError as e: + raise SecretStoreError( + f"{ref.label()}の機密を復号できませんでした ({path}): {e}" + ) from e + + def encrypt_bytes(self, data: bytes) -> bytes: + """バイト列を受信者宛に暗号化して返す (保存はしない)。 + + 書き出し先を自前で扱う処理 (``devbase env import`` の原子的な書き込み等) + が、暗号化だけをこの層に任せられるようにする。 + """ + try: + return _cipher.encrypt(data, recipients=self.recipients()) + except _cipher.CipherError as e: + raise SecretStoreError( + f"機密を暗号化できませんでした: {e}" + ) from e + + def save_bytes(self, ref: SecretRef, data: bytes) -> Path: + """バイト列を **加工せずそのまま** 暗号化して保存する""" + path = self.path(ref) + blob = self.encrypt_bytes(data) + try: + # 暗号文は失うと復旧不能なので、既存ファイルを直接 O_TRUNC せず + # 一時ファイル → fsync → os.replace で差し替える。ディスク枯渇や中断が + # 起きても旧 ciphertext はそのまま残り、書きかけの一時ファイルも消える。 + _io_common.write_secure_bytes_atomic(path, blob) + except OSError as e: + raise SecretStoreError(f"書き込みに失敗しました ({path}): {e}") from e + return path + + def load(self, ref: SecretRef) -> Dict[str, str]: + try: + return EnvFile.parse_bytes(self.load_bytes(ref)) + except UnicodeDecodeError as e: + # 復号は成功したのに中身が UTF-8 でない = 元々 .env ではない + # バイナリを暗号化していた、というケース。PlaintextBackend.load と + # 同じく SecretStoreError へ包み、呼び出し側が扱う例外を 1 種類に保つ。 + raise SecretStoreError( + f"{ref.label()}の機密を復号しましたが、UTF-8 として読めませんでした " + f"({self.path(ref)}): {e}\n" + "KEY=VALUE 形式以外のファイルを暗号化していないか確認してください" + ) from e + + def save(self, ref: SecretRef, data: Dict[str, str]) -> Path: + """辞書を ``EnvFile`` の書式へ整形して暗号化する。 + + ``PlaintextBackend.save`` と同じく、この経路を通ると内容が正規化される。 + 原文を保ちたい移行系は :meth:`save_bytes` を使う。 + """ + return self.save_bytes(ref, EnvFile.dump_bytes(data)) + + def remove(self, ref: SecretRef) -> bool: + path = self.path(ref) + if not path.exists(): + return False + try: + path.unlink() + except OSError as e: + raise SecretStoreError(f"削除に失敗しました ({path}): {e}") from e + return True + + +class SecretStore: + """保存先を自動判定して機密を読み書きする窓口""" + + def __init__(self, devbase_root: Path, *, + recipients: Optional[Sequence[str]] = None, + identities: Optional[Sequence[str]] = None): + self.root = Path(devbase_root) + self.plaintext = PlaintextBackend(self.root) + self.age = AgeBackend(self.root, recipients=recipients, + identities=identities) + + # -- 判定 --------------------------------------------------------------- + + def backend_for(self, ref: SecretRef) -> SecretBackend: + """参照に対して使うべき backend を返す。 + + 暗号化ファイルと平文ファイルが同時に存在する場合は、どちらが最新なのか + devbase 側では判断できない。黙って一方を採用すると「編集したはずの値が + 反映されない」形で事故になるため、明示的に停止して利用者に解消させる。 + """ + age_exists = self.age.exists(ref) + plain_exists = self.plaintext.exists(ref) + if age_exists and plain_exists: + raise SecretStoreError( + f"{ref.label()}の機密が暗号化・平文の両方に存在します:\n" + f" 暗号化: {self.age.path(ref)}\n" + f" 平文: {self.plaintext.path(ref)}\n" + "どちらが正しいか判断できないため中止しました。" + "不要な方を削除 (または退避) してから再実行してください" + ) + return self.age if age_exists else self.plaintext + + def mode(self, ref: SecretRef) -> str: + """``'age'`` / ``'plaintext'`` / ``'absent'`` のいずれかを返す""" + if self.age.exists(ref): + if self.plaintext.exists(ref): + # backend_for と同じ理由でここでも停止させる + self.backend_for(ref) + return MODE_AGE + if self.plaintext.exists(ref): + return MODE_PLAINTEXT + return MODE_ABSENT + + def is_encrypted(self, ref: SecretRef) -> bool: + return self.mode(ref) == MODE_AGE + + # -- 読み書き ----------------------------------------------------------- + + def exists(self, ref: SecretRef) -> bool: + return self.mode(ref) != MODE_ABSENT + + def path(self, ref: SecretRef) -> Path: + return self.backend_for(ref).path(ref) + + def load(self, ref: SecretRef) -> Dict[str, str]: + return self.backend_for(ref).load(ref) + + def save(self, ref: SecretRef, data: Dict[str, str]) -> Path: + """既存の保存形式を維持したまま保存する。 + + まだ何も無い参照は平文に落とす。暗号化へ移すのは ``devbase env encrypt`` + の役目であり、``set`` や ``sync`` が暗黙に形式を変えるべきではない。 + + 辞書を経由するため、保存した時点で内容は ``EnvFile`` の書式へ正規化される + (コメント・空行・``export`` 表記は残らない)。原文のまま運びたい場合は + :meth:`save_bytes` を使う。 + """ + return self.backend_for(ref).save(ref, data) + + def load_bytes(self, ref: SecretRef) -> bytes: + """保存形式を問わず、中身を **原文のバイト列のまま** 返す""" + return self.backend_for(ref).load_bytes(ref) + + def save_bytes(self, ref: SecretRef, data: bytes) -> Path: + """既存の保存形式を維持したまま、バイト列を **そのまま** 保存する""" + return self.backend_for(ref).save_bytes(ref, data) + + def project_names(self) -> List[str]: + """暗号化済みの機密を持つプロジェクト名を返す""" + base = self.root / SECRETS_DIRNAME / 'projects' + if not base.is_dir(): + return [] + return sorted( + p.name[: -len('.env.age')] + for p in base.iterdir() + if p.is_file() and p.name.endswith('.env.age') + ) diff --git a/lib/devbase/env/secret_view.py b/lib/devbase/env/secret_view.py new file mode 100644 index 00000000..6c1f52bc --- /dev/null +++ b/lib/devbase/env/secret_view.py @@ -0,0 +1,142 @@ +"""秘密ストア上の 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() + + # -- 原文のまま扱う経路 -------------------------------------------------- + # + # 辞書経由の load / save はコメント・空行・``export`` 表記を落とす。 + # エディタ編集のように利用者の書いた原文を保ちたい経路はこちらを使う。 + + def load_bytes(self) -> bytes: + """保存されている内容を **原文のバイト列のまま** 返す (不在なら空)""" + return self._store.load_bytes(self._ref) + + def save_bytes(self, data: bytes) -> None: + """バイト列を **加工せずそのまま** 保存する""" + from devbase.env.store import EnvFile + + self._store.save_bytes(self._ref, data) + # 保存後に辞書側のキャッシュがずれないよう、書いた内容で作り直す + self._data = EnvFile.parse_bytes(data) + self._loaded = True + + # -- 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/lib/devbase/volume/compose.py b/lib/devbase/volume/compose.py index e4d5cacd..4b8a219f 100644 --- a/lib/devbase/volume/compose.py +++ b/lib/devbase/volume/compose.py @@ -4,12 +4,18 @@ import os import yaml from pathlib import Path -from typing import Any, Dict, Optional +from typing import ( + Any, Dict, Iterable, List, Mapping, Optional, Sequence, Set, +) +from devbase.env import compose_migrate from devbase.errors import DockerError +from devbase.log import get_logger from .manager import get_work_volume_for_index, get_ai_volume_for_index +logger = get_logger(__name__) + # 旧 /home/ubuntu マウントは非推奨のため scale 生成時に除去する _DEPRECATED_TARGET = '/home/ubuntu' @@ -141,8 +147,121 @@ def _load_compose_config(compose_file: Path) -> dict: raise DockerError(f"Failed to parse compose file: {e}") +def _mask_secret_environment( + service: dict, secret_env_names: Sequence[str], +) -> None: + """機密キーだけを「値なしの参照」へ置き換える (それ以外の値は残す)。 + + 以前は ``environment`` を丸ごと落としていたが、それでは元の ``compose.yml`` + が持つ**非機密の固定値や機能フラグ**まで消え、スケールした途端に生成コンテナ + の挙動が変わってしまう。生成ファイルに残してはいけないのは機密の値だけなので、 + ``secret_env_names`` に挙がったキーに限って値を落とし、devbase 自身の環境変数 + から解決させる書き方へ置き換える (plan35 §4.3)。 + + 元の記法は尊重する。map 形式なら値を ``None`` にした map (Compose は ``KEY:`` + を「実行プロセスの環境変数から解決」と解釈する)、list 形式なら裸のキー名を + 並べた list として出力する。 + """ + # 重複を除きつつ、指定された順序は保つ + secrets = list(dict.fromkeys(secret_env_names)) + secret_set = set(secrets) + existing = service.get('environment') + + if existing is None: + # 元から environment が無ければ、機密が無い限り作らない + if secrets: + service['environment'] = list(secrets) + return + + if isinstance(existing, dict): + masked = { + key: (None if key in secret_set else value) + for key, value in existing.items() + } + for name in secrets: + masked.setdefault(name, None) + service['environment'] = masked + return + + if isinstance(existing, list): + masked_list = [] + listed = set() + for item in existing: + if not isinstance(item, str): + masked_list.append(item) + continue + name = item.split('=', 1)[0].strip() + listed.add(name) + # 機密キーは `KEY=value` でも `KEY` でも、値なし参照に揃える + masked_list.append(name if name in secret_set else item) + masked_list.extend(name for name in secrets if name not in listed) + service['environment'] = masked_list + return + + # map / list 以外は Compose が受け付けない書き方。手掛かりを残しつつ、 + # 機密が渡らない事故を避けるため名前の列挙で置き換える。 + logger.warning( + "environment の形式 (%s) を解釈できないため、機密の変数名の列挙で" + "置き換えます", type(existing).__name__) + service['environment'] = list(secrets) + + +class _SecretNames: + """機密の変数名を**由来別**に保持し、参照種別に応じた部分集合を切り出す。 + + 共通機密 (``$DEVBASE_ROOT/.env``) 由来とプロジェクト機密 + (``projects//.env``) 由来を分けて持つのは、サービスごとに「元々 + ``env_file`` で参照していた由来のキーだけ」を列挙するため。全件をまとめて + 渡すと、共通設定だけを読んでいた ``db`` のようなサービスにプロジェクト固有 + のトークンまで届き、元の構成より機密の範囲が広がってしまう。 + + 由来の内訳が分からない場合 (``global_names`` / ``project_names`` が + ``None``) は、全キーが両方の由来を持つものとして扱う。従来どおりの動作へ + 落ちるだけで、渡し先が狭まって起動できなくなる事故は起こさない。 + + 既知の限界: 同じキーが共通機密とプロジェクト機密の**両方**にある場合、 + Compose は値を devbase 自身の環境変数から解決するため、実際に渡る値は + 合成後の 1 つ (プロジェクト側が優先) に決まる。したがって共通側だけを参照 + していたサービスにもプロジェクト側の値が渡る。サービスごとに違う値を渡す + には生成ファイルへ値を書き込むしかなく、それは「生成物に機密の値を残さない」 + という本方式の前提と矛盾するため受け入れる (plan35 §7)。 + """ + + def __init__( + self, + all_names: Sequence[str] = (), + global_names: Optional[Sequence[str]] = None, + project_names: Optional[Sequence[str]] = None, + ) -> None: + split_known = global_names is not None or project_names is not None + globals_ = list(global_names or ()) + projects = list(project_names or ()) + # 重複を除きつつ、呼び出し側が渡した順序は保つ + self.all: List[str] = list(dict.fromkeys( + [*all_names, *globals_, *projects])) + if split_known: + self._by_target = { + compose_migrate.TARGET_GLOBAL: set(globals_), + compose_migrate.TARGET_PROJECT: set(projects), + } + else: + everything = set(self.all) + self._by_target = { + compose_migrate.TARGET_GLOBAL: everything, + compose_migrate.TARGET_PROJECT: everything, + } + + def for_targets(self, targets: Iterable[str]) -> List[str]: + """指定の参照種別に由来するキーだけを、全体と同じ順序で返す""" + allowed: Set[str] = set() + for target in targets: + allowed |= self._by_target.get(target, set()) + return [name for name in self.all if name in allowed] + + def _build_dev_instance( dev_service: dict, dev_service_name: str, index: int, + secret_env_names: Sequence[str] = (), ) -> dict: """Build the service definition for one scaled dev instance (dev-).""" service = copy.deepcopy(dev_service) @@ -152,8 +271,7 @@ def _build_dev_instance( # setdefault keeps an explicit `init: false` if the project set one. service.setdefault('init', True) - # Remove environment section (use env_file instead to avoid exposing secrets) - service.pop('environment', None) + _mask_secret_environment(service, secret_env_names) # Update volume mounts for /persistent/ai and /work ai_volume = get_ai_volume_for_index(index) @@ -167,9 +285,17 @@ def _build_dev_instance( def _build_scaled_services( services: dict, dev_service: dict, dev_service_name: str, scale: int, + secret_names: Optional[_SecretNames] = None, + secret_services: Optional[Mapping[str, Set[str]]] = None, ) -> dict: - """Build the services section: non-dev services + dev-1..dev-N instances.""" + """Build the services section: non-dev services + dev-1..dev-N instances. + + ``secret_services`` は「サービス名 → 元々参照していた機密の種別 + (``TARGET_GLOBAL`` / ``TARGET_PROJECT``)」の対応。 + """ scaled_services = {} + secret_names = secret_names if secret_names is not None else _SecretNames() + receivers = dict(secret_services or {}) # Copy non-dev services (mysql, valkey, etc.) — rewriting any # `depends_on: ` reference to the scaled instances (dev-1..N) so @@ -182,20 +308,134 @@ def _build_scaled_services( # Insert tini as PID 1 so orphaned children are reaped (no zombies). # setdefault keeps an explicit `init: false` if the project set one. copied.setdefault('init', True) + # 元々機密ファイルを env_file で参照していたサービスにだけ機密を渡す。 + # 参照が外れたあと dev だけに渡すと、DB パスワードを読んでいた db の + # ような非 dev サービスが値を受け取れず起動に失敗する。逆に参照を + # 持たないサービスへ注入すると、元の構成に無い変数を勝手に増やす。 + # + # さらに、渡すのは**そのサービスが参照していた由来のキーだけ**に絞る。 + # 共通設定 (${DEVBASE_ROOT}/.env) だけを読んでいたサービスへプロジェクト + # 固有のトークンまで列挙するのは、元の構成に無かった機密を渡すことに + # なり、範囲の拡大にあたる。 + targets = receivers.get(service_name) + if targets: + _mask_secret_environment(copied, secret_names.for_targets(targets)) scaled_services[service_name] = copied - # Generate a service for each instance + # dev サービスは従来どおり全件 (共通 + プロジェクト) を対象にする。 + # devbase 自身が機密を注入する前提のサービスであり、env_file を書いていない + # 構成でも両方の機密を必要とする。 for i in range(1, scale + 1): scaled_services[f'{dev_service_name}-{i}'] = _build_dev_instance( - dev_service, dev_service_name, i, + dev_service, dev_service_name, i, secret_names.all, ) return scaled_services +def _env_file_ref(entry: Any) -> Optional[str]: + """``env_file`` の 1 エントリから参照先の文字列を取り出す (短縮形 / dict 形)""" + if isinstance(entry, dict): + entry = entry.get('path') + return entry if isinstance(entry, str) else None + + +def _resolve_env_file_path(entry: Any, base_dir: Path) -> Optional[Path]: + """``env_file`` の 1 エントリを実パスへ解決する (解釈できなければ None)""" + entry = _env_file_ref(entry) + if entry is None: + return None + expanded = os.path.expandvars(entry) + if '$' in expanded: + # 未定義の変数が残っている = ここでは存在判定できない。触らずに残す。 + return None + path = Path(expanded) + return path if path.is_absolute() else base_dir / path + + +def _drop_missing_env_files(service: dict, base_dir: Path, service_name: str) -> None: + """暗号化移行で消える機密ファイルへの ``env_file`` 参照のうち、実在しないものを落とす。 + + 機密を暗号化すると、それまで参照していた平文ファイルは無くなる。参照を + 残したままだと Docker Compose が起動時に落ちるため、生成する構成からは + 外す。値は環境変数として別途注入されるので失われない。 + + 落とす対象を :func:`compose_migrate.is_secret_entry` が真を返す既知の参照に + **限る**のが要点。実在しない参照を無条件に落とすと、利用者のタイプミスや + 未配置の必須設定まで黙って成功扱いになり、本来 Compose が起動時に知らせて + くれる構成の不備を隠してしまう。機密以外の欠落はそのまま残し、Compose に + エラーを出させる。 + + 移行コマンドが ``compose.yml`` を書き換え済みなら、ここに来る時点で該当 + エントリは無い。手で書いた構成や書き換え前の状態に対する保険として働く。 + """ + entries = service.get('env_file') + if entries is None: + return + if not isinstance(entries, list): + entries = [entries] + + kept = [] + for entry in entries: + ref = _env_file_ref(entry) + resolved = _resolve_env_file_path(entry, base_dir) + if (resolved is not None and not resolved.exists() + and ref is not None and compose_migrate.is_secret_entry(ref)): + logger.info( + "%s: 実在しない機密の env_file 参照を除きました (%s)。" + "機密は環境変数として渡されます", service_name, resolved) + continue + kept.append(entry) + + if kept: + service['env_file'] = kept + else: + service.pop('env_file', None) + + +def _services_receiving_secrets( + compose_file: Path, dev_service_name: str, +) -> Dict[str, Set[str]]: + """機密を渡すべきサービスと、その**参照種別**を決める。 + + 判定は :func:`compose_migrate.services_with_secret_env_file` に任せ、 + **パース済みの YAML ではなく生テキスト**を渡す。移行後の ``compose.yml`` + では機密の ``env_file`` 参照がコメントアウトされ、YAML からは消えている + ため、パース結果だけでは「元々その参照から機密を受け取っていたサービス」 + を復元できない。生テキストなら有効な参照とコメントアウトされた参照の + 両方を拾える。 + + 返すのがサービス名の集合ではなく種別つきの対応なのは、共通設定だけを + 参照していたサービスへプロジェクト固有の機密まで渡さないため。 + + dev サービスは常に両方の種別を持つものとして含める。devbase 自身が機密を + 注入する前提のサービスで、``env_file`` を書いていない構成でも機密は渡す + 必要があるため。 + + 生テキストを読めない場合は dev サービスだけ (全件) にフォールバックする。 + 判定に失敗したことを理由に全サービスへ機密を撒くと、必要のないコンテナに + まで認証情報を渡すことになる。 + """ + both = {compose_migrate.TARGET_GLOBAL, compose_migrate.TARGET_PROJECT} + receivers: Dict[str, Set[str]] = {dev_service_name: set(both)} + try: + text = compose_file.read_text(encoding='utf-8') + except (OSError, UnicodeDecodeError) as e: + logger.warning( + "%s を読めなかったため、機密は %s サービスにのみ渡します: %s", + compose_file, dev_service_name, e) + return receivers + for name, targets in compose_migrate.services_with_secret_env_file(text).items(): + receivers.setdefault(name, set()).update(targets) + return receivers + + def generate_scaled_compose( scale: int, compose_file: Path = None, dev_service_name: str = None, + secret_env_names: Sequence[str] = (), + global_env_names: Optional[Sequence[str]] = None, + project_env_names: Optional[Sequence[str]] = None, ) -> Path: """ Generate scaled docker-compose file with per-instance volumes @@ -204,6 +444,13 @@ def generate_scaled_compose( scale: Number of container instances compose_file: Source compose file path (default: compose.yml) dev_service_name: Name of the development service to scale (default: from DEV_SERVICE_NAME env or 'dev') + secret_env_names: コンテナへ列挙する機密の変数名 (全件) + global_env_names: そのうち共通機密 (``$DEVBASE_ROOT/.env``) 由来のキー + project_env_names: そのうちプロジェクト機密由来のキー + + 非 dev サービスへは、そのサービスが元々 ``env_file`` で参照していた由来の + キーだけを列挙する。由来の内訳が渡されない場合 (両方 ``None``) は全キーを + 両方の由来とみなす。 Returns: Path to generated .docker-compose.scale.yml @@ -217,13 +464,23 @@ def generate_scaled_compose( # Extract dev service (configurable via DEV_SERVICE_NAME) services = config.get('services', {}) + base_dir = compose_file.resolve().parent + for service_name, service_config in services.items(): + if isinstance(service_config, dict): + _drop_missing_env_files(service_config, base_dir, service_name) dev_service = services.get(dev_service_name) if not dev_service: raise DockerError(f"No '{dev_service_name}' service found in compose file") + secret_services = _services_receiving_secrets(compose_file, dev_service_name) + secret_names = _SecretNames( + secret_env_names, global_env_names, project_env_names) + scaled_config = { 'services': _build_scaled_services( services, dev_service, dev_service_name, scale, + secret_names=secret_names, + secret_services=secret_services, ), 'volumes': _build_volumes_section(config, scale), 'networks': _build_networks_section(config), diff --git a/tests/cli/test_prefix_resolution.py b/tests/cli/test_prefix_resolution.py index 06f849ac..0acc12c6 100644 --- a/tests/cli/test_prefix_resolution.py +++ b/tests/cli/test_prefix_resolution.py @@ -69,3 +69,22 @@ def test_expand_argv_env_in_resolves_to_init(monkeypatch): monkeypatch.setattr(sys, "argv", ["devbase", "env", "in"]) cli._expand_argv() assert sys.argv == ["devbase", "env", "init"] + + +def test_expand_argv_env_k_resolves_to_keygen(monkeypatch): + """`devbase env k` は唯一の候補 (`keygen`) に解決される (PLAN35)""" + monkeypatch.setattr(sys, "argv", ["devbase", "env", "k"]) + cli._expand_argv() + assert sys.argv == ["devbase", "env", "keygen"] + + +def test_env_subcmd_map_covers_all_registered_subcommands(): + """SUBCMD_MAP['env'] が parser 登録済みサブコマンドを漏れなく含む。 + + `keygen` のように parser にだけ追加して SUBCMD_MAP を更新し忘れると、 + prefix 展開の対象から外れて短縮形が invalid choice で落ちる。 + """ + parser = cli._create_parser() + env_parser = parser._subparsers._group_actions[0].choices["env"] + registered = set(env_parser._subparsers._group_actions[0].choices) + assert registered == set(cli.SUBCMD_MAP[("env",)]) diff --git a/tests/cli/test_project_name_resolution.py b/tests/cli/test_project_name_resolution.py index d8d376d7..f6022b89 100644 --- a/tests/cli/test_project_name_resolution.py +++ b/tests/cli/test_project_name_resolution.py @@ -204,6 +204,44 @@ def test_resolve_clears_caller_only_env_keys(fake_root, monkeypatch): assert os.environ["COMPOSE_PROJECT_NAME"] == "other" +def test_switching_projects_drops_caller_only_secrets(fake_root, monkeypatch): + """切替経路 (`_resolve_project_name` → `_inject_secrets`) で機密が残留しない。 + + codex 指摘の回帰テスト。`cli._load_secret_env` は dispatch 前に現在地 + (呼び出し元) の機密を載せるため、`project up ` の直接起動では切替元 + 固有の機密が os.environ に残り Compose や子プロセスへ引き継がれてしまう。 + 切替後の載せ直しでこれが落ちること、共通の機密は残ることを固定する。 + """ + from devbase.env import runtime + + monkeypatch.setattr(runtime, "_injected_originals", {}) + for k in ("CALLER_TOKEN", "OTHER_TOKEN", "SHARED_SECRET"): + monkeypatch.delenv(k, raising=False) + + # 平文の機密 (移行前と同じ配置) を用意する。鍵が無くても同じ経路を通る。 + (fake_root / ".env").write_text("SHARED_SECRET=common\n") + caller = fake_root / "projects" / "caller" + caller.mkdir() + (caller / ".env").write_text("CALLER_TOKEN=caller_only\n") + other = fake_root / "projects" / "other" + other.mkdir() + (other / ".env").write_text("OTHER_TOKEN=other_only\n") + + # 呼び出し元プロジェクト内で起動した状況 (cli._load_secret_env 相当)。 + monkeypatch.chdir(caller) + monkeypatch.setenv("PWD", str(caller)) + container._inject_secrets(required=False) + assert os.environ["CALLER_TOKEN"] == "caller_only" + + assert container._resolve_project_name("other") is True + container._inject_secrets(required=False) + + # 切替元固有の機密は残らない / 切替先の機密が載る / 共通の機密は残る + assert "CALLER_TOKEN" not in os.environ + assert os.environ["OTHER_TOKEN"] == "other_only" + assert os.environ["SHARED_SECRET"] == "common" + + def test_load_project_env_diverges_from_shell_source(tmp_path, monkeypatch): """shell ``source`` との仕様乖離を固定する回帰テスト (docstring の note 対応)。 diff --git a/tests/cli/test_secret_injection.py b/tests/cli/test_secret_injection.py new file mode 100644 index 00000000..5da67b26 --- /dev/null +++ b/tests/cli/test_secret_injection.py @@ -0,0 +1,60 @@ +"""機密の注入をスキップするコマンドの判定 + +鍵の生成や暗号化・復号は「まだ鍵が無い」「復号できない」状態でこそ実行される。 +グループ (`env`) 単位ではなくサブコマンドまで見ないと、`env keygen` などでも +注入が走ってしまう。 +""" + +from __future__ import annotations + +import pytest + +from devbase import cli + + +@pytest.fixture +def calls(tmp_path, monkeypatch): + """`runtime.inject` の呼び出し回数を数える""" + from devbase.env import runtime + + recorded = [] + monkeypatch.setenv('DEVBASE_ROOT', str(tmp_path)) + monkeypatch.setattr(runtime, 'current_project_name', lambda root: None) + monkeypatch.setattr(runtime, 'inject', + lambda root, project: recorded.append((root, project))) + return recorded + + +@pytest.mark.parametrize('subcommand', ['keygen', 'encrypt', 'decrypt']) +def test_env_key_and_migration_subcommands_skip_injection(calls, subcommand): + cli._load_secret_env('env', subcommand) + assert calls == [] + + +@pytest.mark.parametrize('subcommand', ['list', 'set', 'get', 'edit', 'sync', + 'export', 'import']) +def test_other_env_subcommands_still_inject(calls, subcommand): + cli._load_secret_env('env', subcommand) + assert len(calls) == 1 + + +def test_init_skips_injection_regardless_of_subcommand(calls): + cli._load_secret_env('init', None) + assert calls == [] + + +def test_unrelated_commands_inject(calls): + cli._load_secret_env('project', 'up') + assert len(calls) == 1 + + +def test_env_without_a_subcommand_injects(calls): + """`devbase env` 単体 (ヘルプ表示) はグループ丸ごとの除外にはしない""" + cli._load_secret_env('env', None) + assert len(calls) == 1 + + +def test_injection_is_skipped_before_devbase_root_is_read(monkeypatch): + """DEVBASE_ROOT が無くても判定自体は成立する (例外を出さない)""" + monkeypatch.delenv('DEVBASE_ROOT', raising=False) + cli._load_secret_env('env', 'keygen') diff --git a/tests/cli/test_wrapper_secrets.py b/tests/cli/test_wrapper_secrets.py new file mode 100644 index 00000000..857fd93f --- /dev/null +++ b/tests/cli/test_wrapper_secrets.py @@ -0,0 +1,57 @@ +"""起動ラッパーが機密ファイルを読まないことの回帰テスト + +暗号化の前提は「シェルから読める場所に機密を置かない」こと。ラッパーが +``$DEVBASE_ROOT/.env`` を ``source`` に戻ると、暗号化していても起動のたびに +平文が必要になり、方針そのものが崩れる (plan35 §4.4)。 +""" + +from __future__ import annotations + +import re +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[2] +WRAPPER = REPO_ROOT / 'bin' / 'devbase' + + +def wrapper_lines(): + return [ + line for line in WRAPPER.read_text(encoding='utf-8').splitlines() + if line.strip() and not line.lstrip().startswith('#') + ] + + +def test_wrapper_does_not_source_the_global_secret_file(): + sourced = [ + line for line in wrapper_lines() + if re.search(r'source\s+"?\$\{?DEVBASE_ROOT\}?/\.env', line) + ] + assert sourced == [], ( + "起動ラッパーが共通の機密ファイルを source しています: " + repr(sourced)) + + +def test_wrapper_sources_the_non_secret_settings(): + sourced = [ + line for line in wrapper_lines() + if re.search(r'source\s+"?\$\{?DEVBASE_ROOT\}?/env"?', line) + ] + assert len(sourced) == 1, sourced + + +def test_compose_build_goes_through_the_secret_injection(): + """`docker compose build` は機密を必要としうるので env exec 経由で呼ぶ""" + lines = wrapper_lines() + direct = [line for line in lines + if 'docker compose build' in line + and 'compose_with_secrets' not in line] + assert direct == [], ("機密注入を経ずに compose build を呼んでいます: " + + repr(direct)) + + wrapped = [line for line in lines if 'compose_with_secrets docker compose build' in line] + assert len(wrapped) == 3, wrapped + + +def test_secret_injection_helper_uses_env_exec(): + text = WRAPPER.read_text(encoding='utf-8') + assert 'compose_with_secrets()' in text + assert 'devbase.cli env exec --' in text diff --git a/tests/commands/test_container_up_order.py b/tests/commands/test_container_up_order.py new file mode 100644 index 00000000..a96c850f --- /dev/null +++ b/tests/commands/test_container_up_order.py @@ -0,0 +1,127 @@ +"""`container up` の順序: 復号と構成生成を既存コンテナの停止より前に済ませる。 + +codex 指摘 (PR #90) の回帰テスト。機密の復号は鍵の紛失・権限不備・暗号文の破損で +失敗しうる。停止してから復号すると、起動できないだけでなく稼働中の開発環境まで +止まったままになるため、停止より前に構成生成まで終える必要がある。 + +併せて、停止に渡す compose は**生成前**の構成であることも固定する。生成は +``.docker-compose.scale.yml`` を上書きするので、新構成で停止するとスケールを縮める +起動で旧インスタンスが取り残される。 +""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from devbase.commands import container +from devbase.errors import DevbaseError + + +OLD_COMPOSE = "services:\n dev-1: {}\n dev-2: {}\n" +NEW_COMPOSE = "services:\n dev-1: {}\n" + + +@pytest.fixture +def up_harness(tmp_path, monkeypatch): + """cmd_up の外部作用をすべてスタブ化し、呼び出し順を記録する。""" + monkeypatch.chdir(tmp_path) + calls: list = [] + + monkeypatch.setattr(container, 'get_project_name', lambda: 'proj') + monkeypatch.setattr(container, 'get_container_scale', lambda: 1) + monkeypatch.setattr(container, 'get_dev_service_name', lambda: 'dev') + monkeypatch.setattr(container, '_ensure_env_files', lambda: True) + monkeypatch.setattr(container, '_run_pre_up_hook', lambda: True) + monkeypatch.setattr(container, '_ensure_images', lambda: True) + monkeypatch.setattr(container, '_auto_snapshot', lambda: None) + monkeypatch.setattr(container, 'ensure_volumes', lambda *a, **k: None) + monkeypatch.setattr(container, 'ensure_network', lambda *a, **k: None) + monkeypatch.setattr(container, 'docker_compose_up', lambda **k: calls.append(('up', k))) + monkeypatch.setattr(container, 'wait_for_containers_ready', lambda **k: None) + monkeypatch.setattr(container, '_maybe_open_editor', lambda *a, **k: None) + + def fake_down(compose_file=None): + # 停止時点で渡された compose の中身も記録する (旧構成であること) + text = Path(compose_file).read_text() if compose_file else None + calls.append(('down', text)) + + monkeypatch.setattr(container, 'docker_compose_down', fake_down) + return calls + + +def test_generate_precedes_down_and_uses_previous_compose(up_harness, monkeypatch): + """生成 → 停止の順で、停止には生成前の構成が渡る。""" + calls = up_harness + container._SCALE_COMPOSE_FILE.write_text(OLD_COMPOSE) + + def fake_generate(scale, secrets): + calls.append(('generate', scale)) + container._SCALE_COMPOSE_FILE.write_text(NEW_COMPOSE) + return container._SCALE_COMPOSE_FILE + + monkeypatch.setattr(container, '_inject_secrets', lambda *, required: object()) + monkeypatch.setattr(container, '_generate_compose_for', fake_generate) + + assert container.cmd_up() == 0 + + assert [c[0] for c in calls] == ['generate', 'down', 'up'] + assert calls[1][1] == OLD_COMPOSE # 停止は旧構成で行う + assert container._SCALE_COMPOSE_FILE.read_text() == NEW_COMPOSE + assert not Path(f'{container._SCALE_COMPOSE_FILE}.prev').exists() + + +def test_decrypt_failure_keeps_containers_running(up_harness, monkeypatch): + """復号に失敗したら停止も起動もせず、旧構成を残したまま失敗する。""" + calls = up_harness + container._SCALE_COMPOSE_FILE.write_text(OLD_COMPOSE) + + def boom(*, required): + assert required is True + raise DevbaseError('鍵が見つかりません') + + monkeypatch.setattr(container, '_inject_secrets', boom) + + assert container.cmd_up() == 1 + + assert calls == [] # down が呼ばれていない + assert container._SCALE_COMPOSE_FILE.read_text() == OLD_COMPOSE + assert not Path(f'{container._SCALE_COMPOSE_FILE}.prev').exists() + + +def test_generation_failure_restores_previous_compose(up_harness, monkeypatch): + """生成が途中で失敗しても、down / ps が参照する旧構成は壊さない。""" + calls = up_harness + container._SCALE_COMPOSE_FILE.write_text(OLD_COMPOSE) + + def half_written(scale, secrets): + container._SCALE_COMPOSE_FILE.write_text('services:\n dev-1:') + raise DevbaseError('compose.yml が壊れています') + + monkeypatch.setattr(container, '_inject_secrets', lambda *, required: object()) + monkeypatch.setattr(container, '_generate_compose_for', half_written) + + assert container.cmd_up() == 1 + + assert calls == [] + assert container._SCALE_COMPOSE_FILE.read_text() == OLD_COMPOSE + assert not Path(f'{container._SCALE_COMPOSE_FILE}.prev').exists() + + +def test_first_run_without_previous_compose(up_harness, monkeypatch): + """初回起動 (退避なし) は素の docker compose down に委ねる。""" + calls = up_harness + assert not container._SCALE_COMPOSE_FILE.exists() + + def fake_generate(scale, secrets): + container._SCALE_COMPOSE_FILE.write_text(NEW_COMPOSE) + return container._SCALE_COMPOSE_FILE + + monkeypatch.setattr(container, '_inject_secrets', lambda *, required: object()) + monkeypatch.setattr(container, '_generate_compose_for', fake_generate) + + assert container.cmd_up() == 0 + + assert [c[0] for c in calls] == ['down', 'up'] + assert calls[0][1] is None diff --git a/tests/commands/test_env_keygen.py b/tests/commands/test_env_keygen.py new file mode 100644 index 00000000..95e3d640 --- /dev/null +++ b/tests/commands/test_env_keygen.py @@ -0,0 +1,344 @@ +"""cmd_env_keygen: 生成先の契約と、鍵ローテーションの原子性""" + +from __future__ import annotations + +import stat +from pathlib import Path + +import pyrage +import pytest + +from devbase.commands import env as env_cmd +from devbase.env import agekeys +from devbase.env.secret_store import SecretRef, SecretStore +from devbase.errors import DevbaseError + + +@pytest.fixture +def devbase_root(tmp_path, monkeypatch): + """鍵を tmp_path 配下へ閉じ込める。 + + keygen は生成先を CLI で選べず必ず ``agekeys.key_file_path()`` へ書くため、 + テストからは ``DEVBASE_AGE_KEY_FILE`` を差し替えて既定パスごと tmp へ向ける。 + 実運用で別の場所へ置きたい利用者と同じ経路を通ることになる。 + """ + monkeypatch.setenv(agekeys.KEY_FILE_ENV, str(tmp_path / 'keys' / 'keys.txt')) + root = tmp_path / 'devbase' + root.mkdir() + return root + + +def _keygen(root, **kwargs): + return env_cmd.cmd_env_keygen(root, assume_yes=True, **kwargs) + + +# --------------------------------------------------------------------------- +# 生成先の契約 +# --------------------------------------------------------------------------- + +def test_keygen_writes_to_the_resolved_key_file_path(devbase_root, tmp_path): + """生成先は常に agekeys.key_file_path() = 復号側が探索する場所""" + assert _keygen(devbase_root) == 0 + + key_path = agekeys.key_file_path() + assert key_path == tmp_path / 'keys' / 'keys.txt' + assert key_path.exists() + assert stat.S_IMODE(key_path.stat().st_mode) == 0o600 + + +def test_generated_key_is_discoverable_for_decryption(devbase_root): + """生成した鍵が resolve_identities() / resolve_recipients() の双方から見える。 + + 生成先と探索先がずれると「保存はできるが復号できない」機密ができてしまうため、 + keygen 直後に暗号化・復号の両側が同じ鍵へ到達することを固定する。 + """ + assert _keygen(devbase_root) == 0 + + key_path = agekeys.key_file_path() + assert str(key_path) in agekeys.resolve_identities() + assert agekeys.resolve_recipients(devbase_root) == \ + [agekeys.read_public_key(key_path)] + + +def test_keygen_does_not_write_recipients_file(devbase_root): + """keygen はワークスペース固有の recipients.txt を作らない。 + + 鍵はグローバルなのに受信者リストはワークスペースごとに存在するため、ここで + 書き込むと別ワークスペースへ古い公開鍵が取り残され、失われた秘密鍵に対応する + 公開鍵で暗号化してしまう。 + """ + assert _keygen(devbase_root) == 0 + assert not agekeys.recipients_file(devbase_root).exists() + + assert _keygen(devbase_root, force=True) == 0 + assert not agekeys.recipients_file(devbase_root).exists() + + +def test_keygen_leaves_an_existing_recipients_file_untouched(devbase_root): + """明示的に登録済みの受信者リストは keygen が書き換えない (チーム運用の保全)""" + other = str(pyrage.x25519.Identity.generate().to_public()) + agekeys.save_recipients(devbase_root, [other]) + before = agekeys.recipients_file(devbase_root).read_bytes() + + assert _keygen(devbase_root, force=True) == 0 + assert agekeys.recipients_file(devbase_root).read_bytes() == before + + +# --------------------------------------------------------------------------- +# 鍵の保全 +# --------------------------------------------------------------------------- + +def test_keygen_without_force_keeps_existing_key(devbase_root): + assert _keygen(devbase_root) == 0 + before = agekeys.key_file_path().read_bytes() + + assert _keygen(devbase_root) == 0 + assert agekeys.key_file_path().read_bytes() == before + + +def test_keygen_without_force_does_not_request_an_overwrite(devbase_root, + monkeypatch): + """非 --force 実行は generate_key_file(force=True) を呼ばない。 + + 無条件に force=True を渡すと、コマンド側の存在チェックから実際の書き込みまで + の隙間に他プロセスが作った鍵を、利用者が要求していないのに消してしまう。 + """ + seen = [] + real = agekeys.generate_key_file + + def spy(path, *, force=False): + seen.append(force) + return real(path, force=force) + + monkeypatch.setattr(agekeys, 'generate_key_file', spy) + + assert _keygen(devbase_root) == 0 + assert seen == [False] + + +def test_keygen_aborts_when_a_key_appears_after_the_check(devbase_root, + monkeypatch): + """事前チェック後に鍵が現れたら、上書きせずエラー終了する (TOCTOU)。 + + ``_ensure_private_dir`` の直後に鍵を差し込んで、判定と書き込みの隙間で + 並行プロセスが先に生成した状況を再現する。 + """ + key_path = agekeys.key_file_path() + real_ensure = agekeys._ensure_private_dir + rival = b'AGE-SECRET-KEY-1RIVAL\n' + + def ensure_then_race(parent): + real_ensure(parent) + if not key_path.exists(): + key_path.write_bytes(rival) + + monkeypatch.setattr(agekeys, '_ensure_private_dir', ensure_then_race) + + assert _keygen(devbase_root) == 1 + assert key_path.read_bytes() == rival + + +def test_keygen_force_replaces_the_key(devbase_root): + assert _keygen(devbase_root) == 0 + old_public = agekeys.read_public_key(agekeys.key_file_path()) + + assert _keygen(devbase_root, force=True) == 0 + new_public = agekeys.read_public_key(agekeys.key_file_path()) + + assert new_public != old_public + + +# --------------------------------------------------------------------------- +# --force の確認プロンプト +# +# 鍵はグローバル (全ワークスペース共通) なので、確認の要否をカレントの +# DEVBASE_ROOT に機密があるかで決めてはいけない。機密がまだ無いプロジェクトから +# --force しても、他プロジェクトの機密は旧鍵でしか復号できないため。 +# --------------------------------------------------------------------------- + +def _answers(monkeypatch, *values): + """safe_input の応答をスクリプト化し、実際に聞かれた回数を返す""" + asked = [] + + def fake_input(prompt, default=''): + asked.append(prompt) + return values[len(asked) - 1] if len(asked) <= len(values) else default + + monkeypatch.setattr(env_cmd, 'safe_input', fake_input) + return asked + + +def test_keygen_force_prompts_even_without_encrypted_secrets(devbase_root, + monkeypatch): + """機密がまだ無いワークスペースでも --force は必ず確認する""" + assert _keygen(devbase_root) == 0 + before = agekeys.key_file_path().read_bytes() + + asked = _answers(monkeypatch, 'yes') + assert env_cmd.cmd_env_keygen(devbase_root, force=True) == 0 + + assert asked, "確認プロンプトが出ていない" + assert agekeys.key_file_path().read_bytes() != before + + +def test_keygen_force_prompt_mentions_other_workspaces(devbase_root, + monkeypatch, capsys): + """鍵がグローバルで他ワークスペースにも影響する旨をプロンプトで明示する""" + assert _keygen(devbase_root) == 0 + + _answers(monkeypatch, 'yes') + assert env_cmd.cmd_env_keygen(devbase_root, force=True) == 0 + + out = capsys.readouterr().out + assert '全プロジェクト共通' in out + assert 'ワークスペース' in out + + +def test_keygen_force_aborts_and_keeps_the_key_when_not_confirmed(devbase_root, + monkeypatch): + """yes 以外を入力したら中止し、鍵は 1 バイトも変えない""" + assert _keygen(devbase_root) == 0 + before = agekeys.key_file_path().read_bytes() + + _answers(monkeypatch, 'y') + assert env_cmd.cmd_env_keygen(devbase_root, force=True) == 1 + + assert agekeys.key_file_path().read_bytes() == before + + +def test_keygen_force_aborts_on_empty_answer(devbase_root, monkeypatch): + """非対話 (EOF → 空文字) でも黙って上書きせず中止する""" + assert _keygen(devbase_root) == 0 + before = agekeys.key_file_path().read_bytes() + + _answers(monkeypatch, '') + assert env_cmd.cmd_env_keygen(devbase_root, force=True) == 1 + + assert agekeys.key_file_path().read_bytes() == before + + +def test_keygen_force_skips_the_prompt_with_assume_yes(devbase_root, monkeypatch): + """--yes / -y でのみ確認を飛ばせる""" + assert _keygen(devbase_root) == 0 + before = agekeys.key_file_path().read_bytes() + + asked = _answers(monkeypatch) + assert env_cmd.cmd_env_keygen(devbase_root, force=True, assume_yes=True) == 0 + + assert asked == [] + assert agekeys.key_file_path().read_bytes() != before + + +def test_keygen_does_not_prompt_when_no_key_exists(devbase_root, monkeypatch): + """初回生成は失うものが無いので確認しない""" + asked = _answers(monkeypatch) + assert env_cmd.cmd_env_keygen(devbase_root, force=True) == 0 + assert asked == [] + + +def test_keygen_force_prompt_is_stronger_when_local_secrets_exist(devbase_root, + monkeypatch, + capsys): + """カレントに機密があるときは、その旨も併せて警告する""" + assert _keygen(devbase_root) == 0 + store = SecretStore(devbase_root) + store.age.save(SecretRef.for_global(), {'TOKEN': 'x'}) + + _answers(monkeypatch, 'yes') + assert env_cmd.cmd_env_keygen(devbase_root, force=True) == 0 + + out = capsys.readouterr().out + assert 'このワークスペースには暗号化済みの機密があり' in out + + +def test_keygen_keeps_the_old_key_when_generation_fails(devbase_root, monkeypatch): + """鍵生成が落ちても旧鍵はそのまま残る。 + + keygen 側に手動ロールバックは無く、原子性は + ``agekeys.generate_key_file`` → ``io_common.write_secure_bytes_atomic`` + (一時ファイル + fsync + os.replace) が担保する。ここではその契約が + コマンド層から見て守られていることだけを確認する。 + """ + assert _keygen(devbase_root) == 0 + key_path = agekeys.key_file_path() + key_before = key_path.read_bytes() + + def boom(*args, **kwargs): + raise DevbaseError('鍵を書けませんでした') + + monkeypatch.setattr(agekeys, 'generate_key_file', boom) + + assert _keygen(devbase_root, force=True) == 1 + assert key_path.read_bytes() == key_before + assert stat.S_IMODE(key_path.stat().st_mode) == 0o600 + + +def test_keygen_keeps_the_old_key_on_oserror(devbase_root, monkeypatch): + """OSError (ディスク枯渇など) でも旧鍵は無傷のまま残る""" + assert _keygen(devbase_root) == 0 + key_path = agekeys.key_file_path() + key_before = key_path.read_bytes() + + def boom(*args, **kwargs): + raise OSError(28, 'No space left on device') + + monkeypatch.setattr(agekeys, 'generate_key_file', boom) + + assert _keygen(devbase_root, force=True) == 1 + assert key_path.read_bytes() == key_before + + +# --------------------------------------------------------------------------- +# 読み取り不能な既存鍵 +# +# 原子性とは別の保護。読めないだけの鍵は権限を直せば回収できる可能性があるのに、 +# 生成が成功すると上書きで確実に消えるため、その前に中止する。 +# --------------------------------------------------------------------------- + +def _unreadable(monkeypatch, target: Path): + """``target`` にだけ「読み取り権限が無い」と見せる。 + + chmod 000 は root 実行だと読めてしまい、コンテナ内 CI とローカルで結果が + 変わる。読めない状態は権限ではなく ``os.access`` の差し替えで作る。 + """ + real_access = env_cmd.os.access + + def fake_access(path, mode, **kwargs): + if Path(path) == target and mode & env_cmd.os.R_OK: + return False + return real_access(path, mode, **kwargs) + + monkeypatch.setattr(env_cmd.os, 'access', fake_access) + + +def test_keygen_force_aborts_when_the_existing_key_is_unreadable(devbase_root, + monkeypatch, + caplog): + """読めない既存鍵は上書きせず中止する (削除も生成もしない)""" + assert _keygen(devbase_root) == 0 + key_path = agekeys.key_file_path() + key_before = key_path.read_bytes() + + generated = [] + monkeypatch.setattr(agekeys, 'generate_key_file', + lambda *a, **kw: generated.append(a)) + _unreadable(monkeypatch, key_path) + + assert _keygen(devbase_root, force=True) == 1 + + assert generated == [], "読めない鍵を上書きしようとしている" + assert key_path.exists(), "読めなかっただけの鍵を削除している" + assert key_path.read_bytes() == key_before + assert '上書きを中止' in caplog.text + + +def test_keygen_aborts_before_asking_for_confirmation_when_unreadable( + devbase_root, monkeypatch): + """中止が確定しているなら同意を求めない (空振りの確認を出さない)""" + assert _keygen(devbase_root) == 0 + _unreadable(monkeypatch, agekeys.key_file_path()) + + asked = _answers(monkeypatch, 'yes') + assert env_cmd.cmd_env_keygen(devbase_root, force=True) == 1 + + assert asked == [] diff --git a/tests/commands/test_env_migrate.py b/tests/commands/test_env_migrate.py new file mode 100644 index 00000000..c135ea4e --- /dev/null +++ b/tests/commands/test_env_migrate.py @@ -0,0 +1,861 @@ +"""env encrypt / decrypt: 平文と暗号化構成の往復""" + +from __future__ import annotations + +import logging +import os +import stat +from pathlib import Path + +import pytest + +from devbase.commands import env_migrate +from devbase.env.secret_store import SecretRef, SecretStore + + +GLOBAL = SecretRef.for_global() +WEB = SecretRef.for_project('web') +API = SecretRef.for_project('api') + +COMPOSE = """services: + dev: + image: alpine + env_file: + - ${DEVBASE_ROOT}/.env + - env + - .env +""" + +#: 辞書へ畳むと落ちる要素を全部入れた ``.env`` (コメント・空行・``export`` +#: 表記・クォート・キーの並び順) +RAW_ENV = b"""# devbase \xe3\x81\xae\xe5\x85\xb1\xe9\x80\x9a\xe8\xa8\xad\xe5\xae\x9a +ZZZ_LAST=1 + +ANTHROPIC_API_KEY=sk-1 +export EDITOR=vim +QUOTED="a b c" # \xe6\x9c\xab\xe5\xb0\xbe\xe3\x82\xb3\xe3\x83\xa1\xe3\x83\xb3\xe3\x83\x88 +""" + + +@pytest.fixture +def root(tmp_path, monkeypatch): + from devbase.env import agekeys + + (tmp_path / 'projects' / 'web').mkdir(parents=True) + (tmp_path / 'projects' / 'web' / 'compose.yml').write_text(COMPOSE) + 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(root): + from devbase.env import agekeys + + agekeys.generate_key_file() + return root + + +def seed_plaintext(root): + store = SecretStore(root) + store.plaintext.save(GLOBAL, {'ANTHROPIC_API_KEY': 'sk-1'}) + store.plaintext.save(WEB, {'DB_PASSWORD': 'pw'}) + return store + + +@pytest.fixture +def two_projects(with_key): + """複数対象の途中失敗を見るための構成 (対象は global → api → web の順)""" + (with_key / 'projects' / 'api').mkdir(parents=True) + (with_key / 'projects' / 'api' / 'compose.yml').write_text(COMPOSE) + store = seed_plaintext(with_key) + store.plaintext.save(API, {'API_TOKEN': 'tk'}) + return with_key + + +def compose_texts(root): + return {name: (root / 'projects' / name / 'compose.yml').read_text() + for name in ('api', 'web')} + + +def age_files(root): + return sorted(p.name for p in root.glob('secrets/**/*.age')) + + +def plaintext_files(root): + paths = [root / '.env'] + paths += [root / 'projects' / name / '.env' for name in ('api', 'web')] + return sorted(str(p) for p in paths if p.exists()) + + +# --------------------------------------------------------------------------- +# encrypt +# --------------------------------------------------------------------------- + +def test_encrypt_requires_a_key(root, capsys): + seed_plaintext(root) + + assert env_migrate.cmd_env_encrypt(root, assume_yes=True) == 1 + assert (root / '.env').exists() # 平文はそのまま + + +def test_encrypt_reports_nothing_to_do(with_key, capsys): + assert env_migrate.cmd_env_encrypt(with_key, assume_yes=True) == 0 + assert '暗号化する平文の設定はありません' in capsys.readouterr().out + + +def test_encrypt_moves_plaintext_into_the_store(with_key): + seed_plaintext(with_key) + + assert env_migrate.cmd_env_encrypt(with_key, assume_yes=True) == 0 + + store = SecretStore(with_key) + assert store.is_encrypted(GLOBAL) + assert store.is_encrypted(WEB) + assert store.load(GLOBAL) == {'ANTHROPIC_API_KEY': 'sk-1'} + assert store.load(WEB) == {'DB_PASSWORD': 'pw'} + assert not (with_key / '.env').exists() + assert not (with_key / 'projects' / 'web' / '.env').exists() + + +def test_encrypt_keeps_the_plaintext_in_backups(with_key, capsys): + seed_plaintext(with_key) + + env_migrate.cmd_env_encrypt(with_key, assume_yes=True) + + backups = list((with_key / 'backups' / 'env-encrypt').iterdir()) + assert len(backups) == 1 + assert (backups[0] / 'global.env').read_text().strip() == 'ANTHROPIC_API_KEY=sk-1' + assert (backups[0] / 'projects' / 'web.env').exists() + # 消すのは利用者の判断。場所を案内する + assert '退避しました' in capsys.readouterr().out + + +def test_encrypt_never_overwrites_an_existing_backup(with_key, monkeypatch): + """退避先の名前が衝突しても、過去に退避した平文を上書きしない""" + seed_plaintext(with_key) + monkeypatch.setattr(env_migrate, '_timestamp', lambda: '20240101000000') + + existing = with_key / 'backups' / 'env-encrypt' / '20240101000000' + (existing / 'projects').mkdir(parents=True) + (existing / 'global.env').write_text('OLD_GLOBAL=1\n') + (existing / 'projects' / 'web.env').write_text('OLD_WEB=1\n') + + assert env_migrate.cmd_env_encrypt(with_key, assume_yes=True) == 0 + + # 過去のバックアップは 1 バイトも動いていない + assert (existing / 'global.env').read_text() == 'OLD_GLOBAL=1\n' + assert (existing / 'projects' / 'web.env').read_text() == 'OLD_WEB=1\n' + # 今回のぶんは一意な suffix を付けた別ディレクトリへ入る + fresh = with_key / 'backups' / 'env-encrypt' / '20240101000000-2' + assert 'ANTHROPIC_API_KEY=sk-1' in (fresh / 'global.env').read_text() + assert 'DB_PASSWORD=pw' in (fresh / 'projects' / 'web.env').read_text() + + +def test_encrypt_aborts_when_no_backup_name_is_free(with_key, monkeypatch): + """一意な退避先を作れないなら、平文に触れないまま中止する""" + seed_plaintext(with_key) + monkeypatch.setattr(env_migrate, '_timestamp', lambda: '20240101000000') + monkeypatch.setattr(env_migrate, '_BACKUP_DIR_MAX_ATTEMPTS', 2) + + base = with_key / 'backups' / 'env-encrypt' + for name in ('20240101000000', '20240101000000-2'): + (base / name).mkdir(parents=True) + + assert env_migrate.cmd_env_encrypt(with_key, assume_yes=True) == 1 + + # 平文はそのまま。作りかけの暗号文も退避物も残らない + assert (with_key / '.env').exists() + assert (with_key / 'projects' / 'web' / '.env').exists() + assert age_files(with_key) == [] + assert list(base.glob('**/*.env')) == [] + + +def test_encrypt_rewrites_the_compose_file(with_key): + from devbase.env import compose_migrate + + seed_plaintext(with_key) + + env_migrate.cmd_env_encrypt(with_key, assume_yes=True) + + text = (with_key / 'projects' / 'web' / 'compose.yml').read_text() + assert f'{compose_migrate.DISABLED_MARK}- ${{DEVBASE_ROOT}}/.env' in text + assert f'{compose_migrate.DISABLED_MARK}- .env' in text + assert ' - env\n' in text + + +def test_encrypt_dry_run_changes_nothing(with_key, capsys): + seed_plaintext(with_key) + before = (with_key / 'projects' / 'web' / 'compose.yml').read_text() + + assert env_migrate.cmd_env_encrypt(with_key, dry_run=True) == 0 + + assert (with_key / '.env').exists() + assert not (with_key / 'secrets' / 'global.env.age').exists() + assert (with_key / 'projects' / 'web' / 'compose.yml').read_text() == before + assert '--dry-run' in capsys.readouterr().out + + +def test_encrypt_shows_the_compose_diff(with_key, capsys): + seed_plaintext(with_key) + + env_migrate.cmd_env_encrypt(with_key, dry_run=True) + + out = capsys.readouterr().out + assert 'コンテナ構成の変更' in out + assert '- - ${DEVBASE_ROOT}/.env' in out + + +def test_encrypt_can_target_one_project(with_key): + seed_plaintext(with_key) + + env_migrate.cmd_env_encrypt(with_key, assume_yes=True, projects=['web']) + + store = SecretStore(with_key) + assert store.is_encrypted(WEB) + # 共通設定は対象外なので平文のまま + assert not store.is_encrypted(GLOBAL) + + +def test_encrypt_aborts_without_confirmation(with_key, monkeypatch): + seed_plaintext(with_key) + monkeypatch.setattr(env_migrate, 'safe_input', lambda prompt: 'no') + + assert env_migrate.cmd_env_encrypt(with_key) == 1 + assert (with_key / '.env').exists() + assert not (with_key / 'secrets' / 'global.env.age').exists() + + +def test_encrypt_keeps_plaintext_when_the_result_cannot_be_read_back(with_key, + monkeypatch): + """読み戻せない暗号文のために平文を失わない""" + seed_plaintext(with_key) + + from devbase.env.secret_store import AgeBackend, SecretStoreError + + def broken_load(self, ref): + raise SecretStoreError('復号できません') + + monkeypatch.setattr(AgeBackend, 'load_bytes', broken_load) + + assert env_migrate.cmd_env_encrypt(with_key, assume_yes=True) == 1 + assert (with_key / '.env').exists() + assert not (with_key / 'secrets' / 'global.env.age').exists() + + +def test_encrypt_keeps_plaintext_when_the_result_differs(with_key, monkeypatch): + seed_plaintext(with_key) + + from devbase.env.secret_store import AgeBackend + + monkeypatch.setattr(AgeBackend, 'load_bytes', lambda self, ref: b'WRONG=x\n') + + assert env_migrate.cmd_env_encrypt(with_key, assume_yes=True) == 1 + assert (with_key / '.env').exists() + + +# --------------------------------------------------------------------------- +# encrypt: 途中で失敗したときの巻き戻し +# --------------------------------------------------------------------------- + +def test_encrypt_rolls_back_when_a_later_target_fails(two_projects, monkeypatch): + """後続対象の失敗で「先行対象だけ移行済み」の中間状態を残さない""" + root = two_projects + before = compose_texts(root) + + from devbase.env.secret_store import AgeBackend, SecretStoreError + + original_save = AgeBackend.save_bytes + + def fail_on_web(self, ref, data): + if ref == WEB: + raise SecretStoreError('暗号化できません') + return original_save(self, ref, data) + + monkeypatch.setattr(AgeBackend, 'save_bytes', fail_on_web) + + assert env_migrate.cmd_env_encrypt(root, assume_yes=True) == 1 + + # 先行対象の平文は元の場所のまま。暗号文も compose.yml も動いていない + assert plaintext_files(root) == sorted([ + str(root / '.env'), + str(root / 'projects' / 'api' / '.env'), + str(root / 'projects' / 'web' / '.env'), + ]) + assert age_files(root) == [] + assert compose_texts(root) == before + assert list(root.glob('backups/**/*.env')) == [] + + +def test_encrypt_rolls_back_when_the_compose_write_fails(two_projects, + monkeypatch): + """構成ファイルを書けなければ、機密の移動ごと巻き戻して失敗を返す""" + root = two_projects + before = compose_texts(root) + + original_write = env_migrate._write_compose + + def fail_on_web_compose(path, text, mode): + # api → web の順に書くので、api だけ書けた状態から巻き戻すことになる + if path.name == 'compose.yml' and path.parent.name == 'web': + raise OSError('読み取り専用ファイルシステムです') + return original_write(path, text, mode) + + monkeypatch.setattr(env_migrate, '_write_compose', fail_on_web_compose) + + assert env_migrate.cmd_env_encrypt(root, assume_yes=True) == 1 + + assert plaintext_files(root) == sorted([ + str(root / '.env'), + str(root / 'projects' / 'api' / '.env'), + str(root / 'projects' / 'web' / '.env'), + ]) + assert age_files(root) == [] + assert list(root.glob('backups/**/*.env')) == [] + # 先に書けてしまった api の compose.yml も元へ戻る + assert compose_texts(root) == before + + +# --------------------------------------------------------------------------- +# 構成ファイルの書き込みは原子的か / 権限を保つか +# --------------------------------------------------------------------------- + +def test_compose_is_not_left_partially_written(two_projects, monkeypatch): + """途中で失敗しても、壊れかけの compose.yml をディスクに残さない""" + root = two_projects + before = compose_texts(root) + + original_replace = os.replace + + def fail_on_web_compose(src, dst, **kwargs): + dst_path = Path(dst) + if dst_path.name == 'compose.yml' and dst_path.parent.name == 'web': + raise OSError('デバイスに空き領域がありません') + return original_replace(src, dst, **kwargs) + + monkeypatch.setattr(os, 'replace', fail_on_web_compose) + + assert env_migrate.cmd_env_encrypt(root, assume_yes=True) == 1 + + # 元の内容がそのまま残っている (truncate された痕跡が無い) + assert compose_texts(root) == before + # 一時ファイルも掃除されている + assert list((root / 'projects' / 'web').glob('.compose.yml.*')) == [] + + +def test_compose_permissions_are_preserved(with_key): + """compose.yml は機密ではない。原子的書き込みの既定 0600 へ落とさない""" + compose = with_key / 'projects' / 'web' / 'compose.yml' + compose.chmod(0o644) + seed_plaintext(with_key) + + assert env_migrate.cmd_env_encrypt(with_key, assume_yes=True) == 0 + + assert stat.S_IMODE(compose.stat().st_mode) == 0o644 + + +# --------------------------------------------------------------------------- +# decrypt +# --------------------------------------------------------------------------- + +def test_decrypt_reports_nothing_to_do(with_key, capsys): + assert env_migrate.cmd_env_decrypt(with_key, assume_yes=True) == 0 + assert '平文へ戻す暗号化済みの設定はありません' in capsys.readouterr().out + + +def test_round_trip_restores_everything(with_key): + seed_plaintext(with_key) + compose = with_key / 'projects' / 'web' / 'compose.yml' + before_compose = compose.read_text() + + env_migrate.cmd_env_encrypt(with_key, assume_yes=True) + assert env_migrate.cmd_env_decrypt(with_key, assume_yes=True) == 0 + + store = SecretStore(with_key) + assert store.mode(GLOBAL) == 'plaintext' + assert store.load(GLOBAL) == {'ANTHROPIC_API_KEY': 'sk-1'} + assert store.load(WEB) == {'DB_PASSWORD': 'pw'} + assert compose.read_text() == before_compose + assert not (with_key / 'secrets' / 'global.env.age').exists() + + +def test_round_trip_preserves_the_original_bytes(with_key): + """コメント・空行・``export`` 表記・クォートまでバイト単位で元へ戻る""" + seed_plaintext(with_key) + env = with_key / '.env' + env.write_bytes(RAW_ENV) + + assert env_migrate.cmd_env_encrypt(with_key, assume_yes=True) == 0 + assert not env.exists() + + assert env_migrate.cmd_env_decrypt(with_key, assume_yes=True) == 0 + assert env.read_bytes() == RAW_ENV + + +def test_env_set_normalizes_the_encrypted_content(with_key): + """暗号化済みでも値の更新は従来どおり効く。 + + 原文が保たれるのは「書き換えるまで」で、``env set`` が走ると平文だけを + 使っていた頃と同じく ``EnvFile`` の書式へ正規化される。 + """ + from devbase.commands import env as env_cmd + + seed_plaintext(with_key) + (with_key / '.env').write_bytes(RAW_ENV) + assert env_migrate.cmd_env_encrypt(with_key, assume_yes=True) == 0 + + assert env_cmd.cmd_env_set(with_key, 'ANTHROPIC_API_KEY=sk-2') == 0 + + store = SecretStore(with_key) + assert store.is_encrypted(GLOBAL) + assert store.load(GLOBAL)['ANTHROPIC_API_KEY'] == 'sk-2' + + assert env_migrate.cmd_env_decrypt(with_key, assume_yes=True) == 0 + after = (with_key / '.env').read_bytes() + assert b'ANTHROPIC_API_KEY=sk-2\n' in after + assert '# devbase の共通設定'.encode('utf-8') not in after + + +def test_decrypt_dry_run_changes_nothing(with_key): + seed_plaintext(with_key) + env_migrate.cmd_env_encrypt(with_key, assume_yes=True) + + assert env_migrate.cmd_env_decrypt(with_key, dry_run=True) == 0 + + assert SecretStore(with_key).is_encrypted(GLOBAL) + assert not (with_key / '.env').exists() + + +def test_decrypt_aborts_without_confirmation(with_key, monkeypatch): + seed_plaintext(with_key) + env_migrate.cmd_env_encrypt(with_key, assume_yes=True) + monkeypatch.setattr(env_migrate, 'safe_input', lambda prompt: '') + + assert env_migrate.cmd_env_decrypt(with_key) == 1 + assert SecretStore(with_key).is_encrypted(GLOBAL) + + +# --------------------------------------------------------------------------- +# decrypt: 途中で失敗したときの巻き戻し +# --------------------------------------------------------------------------- + +def test_decrypt_rolls_back_when_a_later_target_fails(two_projects, monkeypatch): + """後続対象を復号できないなら、先行対象の暗号文も消さない""" + root = two_projects + assert env_migrate.cmd_env_encrypt(root, assume_yes=True) == 0 + encrypted_compose = compose_texts(root) + + from devbase.env.secret_store import AgeBackend, SecretStoreError + + original_load = AgeBackend.load_bytes + + def fail_on_web(self, ref): + if ref == WEB: + raise SecretStoreError('復号できません') + return original_load(self, ref) + + monkeypatch.setattr(AgeBackend, 'load_bytes', fail_on_web) + + assert env_migrate.cmd_env_decrypt(root, assume_yes=True) == 1 + + assert age_files(root) == ['api.env.age', 'global.env.age', 'web.env.age'] + assert plaintext_files(root) == [] + assert compose_texts(root) == encrypted_compose + + +def test_decrypt_rolls_back_when_removing_the_ciphertext_fails(two_projects, + monkeypatch): + """削除は最後。失敗しても控えたバイト列から暗号文を復元して元へ戻す""" + root = two_projects + assert env_migrate.cmd_env_encrypt(root, assume_yes=True) == 0 + encrypted_compose = compose_texts(root) + + from devbase.env.secret_store import AgeBackend, SecretStoreError + + original_remove = AgeBackend.remove + + def fail_on_web(self, ref): + if ref == WEB: + raise SecretStoreError('削除できません') + return original_remove(self, ref) + + monkeypatch.setattr(AgeBackend, 'remove', fail_on_web) + + assert env_migrate.cmd_env_decrypt(root, assume_yes=True) == 1 + + assert age_files(root) == ['api.env.age', 'global.env.age', 'web.env.age'] + assert plaintext_files(root) == [] + assert compose_texts(root) == encrypted_compose + # 復元した暗号文はそのまま復号できる + store = SecretStore(root) + assert store.load(GLOBAL) == {'ANTHROPIC_API_KEY': 'sk-1'} + assert store.load(API) == {'API_TOKEN': 'tk'} + + +def test_decrypt_of_one_project_leaves_the_global_reference_disabled(two_projects): + """部分復号で、まだ暗号化されたままの共通設定の参照まで戻さない""" + root = two_projects + assert env_migrate.cmd_env_encrypt(root, assume_yes=True) == 0 + + assert env_migrate.cmd_env_decrypt(root, assume_yes=True, + projects=['web']) == 0 + + store = SecretStore(root) + assert not store.is_encrypted(WEB) + assert store.is_encrypted(GLOBAL) + + from devbase.env import compose_migrate as cm + + web = (root / 'projects' / 'web' / 'compose.yml').read_text() + # プロジェクト側だけが戻り、共通設定の参照は無効のまま + assert ' - .env\n' in web + assert f'{cm.DISABLED_MARK}- ${{DEVBASE_ROOT}}/.env' in web + # 対象外のプロジェクトの構成には手を触れない + api = (root / 'projects' / 'api' / 'compose.yml').read_text() + assert f'{cm.DISABLED_MARK}- .env' in api + + +def test_decrypt_of_everything_after_a_partial_decrypt_restores_the_original( + two_projects): + root = two_projects + before = compose_texts(root) + assert env_migrate.cmd_env_encrypt(root, assume_yes=True) == 0 + + assert env_migrate.cmd_env_decrypt(root, assume_yes=True, + projects=['web']) == 0 + assert env_migrate.cmd_env_decrypt(root, assume_yes=True) == 0 + + assert compose_texts(root) == before + + +def test_inline_env_file_is_warned_about(with_key, caplog): + """自動で書き換えられない記法は黙って見逃さない""" + compose = with_key / 'projects' / 'web' / 'compose.yml' + compose.write_text("""services: + dev: + env_file: [ "${DEVBASE_ROOT}/.env", .env ] +""") + seed_plaintext(with_key) + + with caplog.at_level(logging.WARNING, + logger='devbase.env.compose_migrate'): + env_migrate.cmd_env_encrypt(with_key, dry_run=True) + + messages = [r.getMessage() for r in caplog.records] + assert any('compose.yml:3' in m and 'env_file' in m for m in messages) + + +def test_inline_secret_env_file_aborts_the_migration(with_key, capsys): + """機密を指すインライン記法は警告では済まない (参照が有効なまま残る)""" + compose = with_key / 'projects' / 'web' / 'compose.yml' + compose.write_text("""services: + dev: + env_file: [ "${DEVBASE_ROOT}/.env", .env ] +""") + before = compose.read_text() + seed_plaintext(with_key) + + assert env_migrate.cmd_env_encrypt(with_key, assume_yes=True) == 1 + + # 何も動いていない: 平文も暗号文も構成ファイルもそのまま + assert (with_key / '.env').exists() + assert (with_key / 'projects' / 'web' / '.env').exists() + assert age_files(with_key) == [] + assert list(with_key.glob('backups/**/*.env')) == [] + assert compose.read_text() == before + + +def test_inline_env_file_without_secrets_only_warns(with_key, caplog): + """機密と無関係なインライン記法は移行に影響しない。警告だけで続行する""" + compose = with_key / 'projects' / 'web' / 'compose.yml' + compose.write_text("""services: + dev: + env_file: [ config/app.env ] + worker: + env_file: + - ${DEVBASE_ROOT}/.env + - env +""") + seed_plaintext(with_key) + + with caplog.at_level(logging.WARNING, + logger='devbase.env.compose_migrate'): + assert env_migrate.cmd_env_encrypt(with_key, assume_yes=True) == 0 + + from devbase.env import compose_migrate as cm + + messages = [r.getMessage() for r in caplog.records] + assert any('compose.yml:3' in m for m in messages) + # ブロックシーケンスで書かれた機密参照はいつも通り無効化される + assert f'{cm.DISABLED_MARK}- ${{DEVBASE_ROOT}}/.env' in compose.read_text() + + +def test_scalar_env_file_is_migrated_instead_of_aborting(with_key): + """単一文字列で書かれた機密参照は中止せず、行ごと無効化して往復する""" + from devbase.env import compose_migrate as cm + + compose = with_key / 'projects' / 'web' / 'compose.yml' + original = """services: + dev: + image: alpine + env_file: ${DEVBASE_ROOT}/.env + db: + image: alpine + env_file: .env + batch: + image: alpine + env_file: config/app.env +""" + compose.write_text(original) + seed_plaintext(with_key) + + assert env_migrate.cmd_env_encrypt(with_key, assume_yes=True) == 0 + + after = compose.read_text() + assert f' {cm.DISABLED_MARK}env_file: ${{DEVBASE_ROOT}}/.env\n' in after + assert f' {cm.DISABLED_MARK}env_file: .env\n' in after + # 機密と無関係な参照は残す + assert ' env_file: config/app.env\n' in after + + assert env_migrate.cmd_env_decrypt(with_key, assume_yes=True) == 0 + assert compose.read_text() == original + + +def test_scalar_env_file_keeps_crlf_line_endings(with_key): + """CRLF の compose.yml でも単一文字列の往復でバイト単位に戻る""" + compose = with_key / 'projects' / 'web' / 'compose.yml' + original = """services: + dev: + image: alpine + env_file: ${DEVBASE_ROOT}/.env +""".replace('\n', '\r\n').encode('utf-8') + compose.write_bytes(original) + seed_plaintext(with_key) + + assert env_migrate.cmd_env_encrypt(with_key, assume_yes=True) == 0 + assert b'\r\n' in compose.read_bytes() + assert env_migrate.cmd_env_decrypt(with_key, assume_yes=True) == 0 + assert compose.read_bytes() == original + + +def test_scalar_env_file_reaches_the_service_that_read_it(with_key): + """無効化したあとも、そのサービスへ機密を渡す先として拾えている""" + from devbase.env import compose_migrate as cm + + compose = with_key / 'projects' / 'web' / 'compose.yml' + compose.write_text("""services: + dev: + image: alpine + env_file: ${DEVBASE_ROOT}/.env + db: + image: alpine + env_file: .env +""") + seed_plaintext(with_key) + + assert env_migrate.cmd_env_encrypt(with_key, assume_yes=True) == 0 + + assert cm.services_with_secret_env_file(compose.read_text()) == { + 'dev': {cm.TARGET_GLOBAL}, + 'db': {cm.TARGET_PROJECT}, + } + + +def test_multi_line_long_syntax_secret_aborts_the_migration(with_key): + """続きの行を持つ long syntax は行単位で外せない。移行ごと止める""" + compose = with_key / 'projects' / 'web' / 'compose.yml' + compose.write_text("""services: + dev: + env_file: + - path: ${DEVBASE_ROOT}/.env + required: false +""") + before = compose.read_text() + seed_plaintext(with_key) + + assert env_migrate.cmd_env_encrypt(with_key, assume_yes=True) == 1 + + # 何も動いていない: 平文も暗号文も構成ファイルもそのまま + assert (with_key / '.env').exists() + assert age_files(with_key) == [] + assert compose.read_text() == before + + +# --------------------------------------------------------------------------- +# 事後検証: 行ベースの走査が取りこぼしても移行を止める +# --------------------------------------------------------------------------- + +def test_block_scalar_secret_env_file_aborts_the_migration(with_key, caplog): + """`env_file: >-` は先頭行に参照先が無い。行ベースの走査では外せない + + 走査が何も書き換えられず差分ゼロで素通りしかけるところを、書き換え後の + テキストを YAML としてパースする事後検証が捕まえる。 + """ + compose = with_key / 'projects' / 'web' / 'compose.yml' + compose.write_text("""services: + dev: + image: alpine + env_file: >- + .env +""") + before = compose.read_text() + seed_plaintext(with_key) + + with caplog.at_level(logging.ERROR, logger='devbase.commands.env_migrate'): + assert env_migrate.cmd_env_encrypt(with_key, assume_yes=True) == 1 + + # 何も動いていない: 平文も暗号文も構成ファイルもそのまま + assert (with_key / '.env').exists() + assert (with_key / 'projects' / 'web' / '.env').exists() + assert age_files(with_key) == [] + assert list(with_key.glob('backups/**/*.env')) == [] + assert compose.read_text() == before + # どのファイルのどのサービスに何が残っているのかまで示す + message = '\n'.join(r.getMessage() for r in caplog.records) + assert 'サービス dev の env_file: .env' in message + assert str(compose) in message + + +def test_aliased_long_syntax_secret_aborts_the_migration(with_key): + """long syntax の dict を別名で参照する形も事後検証が平坦化して見つける""" + compose = with_key / 'projects' / 'web' / 'compose.yml' + compose.write_text("""x-secret: &secret + path: ${DEVBASE_ROOT}/.env + +services: + dev: + image: alpine + env_file: + - *secret +""") + before = compose.read_text() + seed_plaintext(with_key) + + assert env_migrate.cmd_env_encrypt(with_key, assume_yes=True) == 1 + + assert (with_key / '.env').exists() + assert age_files(with_key) == [] + assert compose.read_text() == before + + +def test_broken_yaml_compose_aborts_the_migration(with_key): + """YAML として読めなければ「参照が残っていない」と言い切れない""" + compose = with_key / 'projects' / 'web' / 'compose.yml' + compose.write_text("""services: + dev: + image: alpine + labels: [unclosed +""") + seed_plaintext(with_key) + + assert env_migrate.cmd_env_encrypt(with_key, assume_yes=True) == 1 + + assert (with_key / '.env').exists() + assert (with_key / 'projects' / 'web' / '.env').exists() + assert age_files(with_key) == [] + assert list(with_key.glob('backups/**/*.env')) == [] + + +def test_encrypt_leaves_no_secret_env_file_in_the_parsed_result(with_key): + """全部外せたケースは従来どおり成功し、パースしても機密参照が残らない""" + from devbase.env import compose_migrate as cm + + compose = with_key / 'projects' / 'web' / 'compose.yml' + compose.write_text("""services: + dev: + image: alpine + env_file: + - ${DEVBASE_ROOT}/.env + - env + db: + image: alpine + env_file: .env + batch: + image: alpine + env_file: + - path: .env + - config/app.env +""") + seed_plaintext(with_key) + + assert env_migrate.cmd_env_encrypt(with_key, assume_yes=True) == 0 + + after = compose.read_text() + assert cm.remaining_secret_env_file_refs(after) == [] + # 機密と無関係な参照は残したまま + assert ' - env\n' in after + assert ' - config/app.env\n' in after + + +def test_broken_yaml_does_not_block_the_decrypt(with_key): + """復号は壊れた状態からの復帰手段。事後検証で塞いではいけない""" + compose = with_key / 'projects' / 'web' / 'compose.yml' + seed_plaintext(with_key) + assert env_migrate.cmd_env_encrypt(with_key, assume_yes=True) == 0 + + compose.write_text(compose.read_text() + ' labels: [unclosed\n') + + assert env_migrate.cmd_env_decrypt(with_key, assume_yes=True) == 0 + assert (with_key / '.env').exists() + + +def test_crlf_compose_keeps_its_line_endings(with_key): + """CRLF の compose.yml を LF へ潰さない (往復でバイト単位に戻る)""" + compose = with_key / 'projects' / 'web' / 'compose.yml' + original = COMPOSE.replace('\n', '\r\n').encode('utf-8') + compose.write_bytes(original) + seed_plaintext(with_key) + + assert env_migrate.cmd_env_encrypt(with_key, assume_yes=True) == 0 + + encrypted = compose.read_bytes() + # 書き換えた行も含めて LF 単独の行は生まれない + assert b'\n' not in encrypted.replace(b'\r\n', b'') + assert b'# devbase(PLAN35)' in encrypted + + assert env_migrate.cmd_env_decrypt(with_key, assume_yes=True) == 0 + assert compose.read_bytes() == original + + +def test_unreadable_compose_aborts_the_migration(with_key, monkeypatch): + """読めない構成ファイルを飛ばすと、機密だけ退避されて参照が残る""" + seed_plaintext(with_key) + + # 構成ファイルは改行コードを保つために read_bytes で読む + original_read = Path.read_bytes + + def fail_on_compose(self, *args, **kwargs): + if self.name == 'compose.yml': + raise OSError('アクセスが拒否されました') + return original_read(self, *args, **kwargs) + + monkeypatch.setattr(Path, 'read_bytes', fail_on_compose) + + assert env_migrate.cmd_env_encrypt(with_key, assume_yes=True) == 1 + + # 平文は退避されず、暗号文も作られていない + assert (with_key / '.env').exists() + assert (with_key / 'projects' / 'web' / '.env').exists() + assert age_files(with_key) == [] + assert list(with_key.glob('backups/**/*.env')) == [] + + +def test_unreadable_compose_aborts_the_decrypt(two_projects, monkeypatch): + root = two_projects + assert env_migrate.cmd_env_encrypt(root, assume_yes=True) == 0 + + original_read = Path.read_bytes + + def fail_on_compose(self, *args, **kwargs): + if self.name == 'compose.yml' and self.parent.name == 'web': + raise OSError('アクセスが拒否されました') + return original_read(self, *args, **kwargs) + + monkeypatch.setattr(Path, 'read_bytes', fail_on_compose) + + assert env_migrate.cmd_env_decrypt(root, assume_yes=True) == 1 + + # 暗号文はそのまま。平文も書かれていない + assert age_files(root) == ['api.env.age', 'global.env.age', 'web.env.age'] + assert plaintext_files(root) == [] diff --git a/tests/commands/test_env_ops.py b/tests/commands/test_env_ops.py new file mode 100644 index 00000000..2abdbfa0 --- /dev/null +++ b/tests/commands/test_env_ops.py @@ -0,0 +1,412 @@ +"""env rekey / doctor: 受信者の更新と、端末に残る平文の点検""" + +from __future__ import annotations + +import os +import shutil +import stat +import subprocess + +import pyrage +import pytest + +from devbase.commands import env_ops +from devbase.env import agekeys +from devbase.env.secret_store import SecretRef, SecretStore + + +GLOBAL = SecretRef.for_global() +WEB = SecretRef.for_project('web') + + +def git_init(path): + """点検用に Git リポジトリを作る。 + + 除外設定の点検は ``git check-ignore`` に委ねているため、テストも実際に + ``git init`` したリポジトリで確かめる。利用者の global / system の除外設定に + 左右されないよう、設定ファイルは空に固定する。 + """ + subprocess.run(['git', 'init', '-q'], cwd=str(path), check=True, + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + + +@pytest.fixture +def root(tmp_path, monkeypatch): + (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.setenv('GIT_CONFIG_GLOBAL', os.devnull) + monkeypatch.setenv('GIT_CONFIG_SYSTEM', os.devnull) + monkeypatch.chdir(tmp_path) + git_init(tmp_path) + return tmp_path + + +@pytest.fixture +def with_key(root): + _, public = agekeys.generate_key_file() + return public + + +@pytest.fixture +def colleague(tmp_path): + """同僚の鍵 (公開鍵と、復号を確かめるための秘密鍵ファイル)""" + identity = pyrage.x25519.Identity.generate() + path = tmp_path / 'colleague.key' + path.write_text(str(identity)) + return str(identity.to_public()), str(path) + + +def seed_encrypted(root): + store = SecretStore(root) + store.age.save(GLOBAL, {'TOKEN': 'sk-1'}) + store.age.save(WEB, {'DB_PASSWORD': 'pw'}) + return store + + +# --------------------------------------------------------------------------- +# rekey +# --------------------------------------------------------------------------- + +def test_rekey_without_a_key_fails(root): + assert env_ops.cmd_env_rekey(root, add=['age1invalid'], assume_yes=True) == 1 + + +def test_rekey_adds_a_recipient_and_reencrypts(root, with_key, colleague): + public, key_path = colleague + seed_encrypted(root) + + assert env_ops.cmd_env_rekey(root, add=[public], assume_yes=True) == 0 + + # 同僚の鍵で読める + reader = SecretStore(root, identities=[key_path]) + assert reader.load(GLOBAL) == {'TOKEN': 'sk-1'} + assert reader.load(WEB) == {'DB_PASSWORD': 'pw'} + # 自分の鍵でも引き続き読める + assert SecretStore(root).load(GLOBAL) == {'TOKEN': 'sk-1'} + + +def test_rekey_registers_the_own_key_when_the_list_was_empty(root, with_key, colleague): + """リストが無い状態から追加しても、自分が受信者から外れない""" + public, _ = colleague + seed_encrypted(root) + assert agekeys.load_recipients(root) == [] + + env_ops.cmd_env_rekey(root, add=[public], assume_yes=True) + + assert agekeys.load_recipients(root) == [with_key, public] + + +def test_rekey_removes_a_recipient(root, with_key, colleague): + public, key_path = colleague + seed_encrypted(root) + env_ops.cmd_env_rekey(root, add=[public], assume_yes=True) + + assert env_ops.cmd_env_rekey(root, remove=[public], assume_yes=True) == 0 + + assert agekeys.load_recipients(root) == [with_key] + reader = SecretStore(root, identities=[key_path]) + from devbase.env.secret_store import SecretStoreError + + with pytest.raises(SecretStoreError): + reader.load(GLOBAL) + + +def test_rekey_rejects_removing_an_unknown_recipient(root, with_key, colleague): + public, _ = colleague + seed_encrypted(root) + + assert env_ops.cmd_env_rekey(root, remove=[public], assume_yes=True) == 1 + + +def test_rekey_refuses_to_empty_the_list(root, with_key): + seed_encrypted(root) + agekeys.save_recipients(root, [with_key]) + + assert env_ops.cmd_env_rekey(root, remove=[with_key], assume_yes=True) == 1 + assert agekeys.load_recipients(root) == [with_key] + + +def test_rekey_reports_no_change(root, with_key, capsys): + seed_encrypted(root) + agekeys.save_recipients(root, [with_key]) + + assert env_ops.cmd_env_rekey(root, add=[with_key], assume_yes=True) == 0 + assert '受信者に変更はありません' in capsys.readouterr().out + + +def test_rekey_dry_run_changes_nothing(root, with_key, colleague): + public, key_path = colleague + store = seed_encrypted(root) + before = store.age.path(GLOBAL).read_bytes() + + assert env_ops.cmd_env_rekey(root, add=[public], dry_run=True) == 0 + + assert agekeys.load_recipients(root) == [] + assert store.age.path(GLOBAL).read_bytes() == before + + +def test_rekey_aborts_without_confirmation(root, with_key, colleague, monkeypatch): + public, _ = colleague + seed_encrypted(root) + monkeypatch.setattr(env_ops, 'safe_input', lambda prompt: 'no') + + assert env_ops.cmd_env_rekey(root, add=[public]) == 1 + assert agekeys.load_recipients(root) == [] + + +def test_rekey_warns_when_dropping_your_own_key(root, with_key, colleague, capsys): + public, _ = colleague + seed_encrypted(root) + agekeys.save_recipients(root, [with_key, public]) + + env_ops.cmd_env_rekey(root, remove=[with_key], dry_run=True) + + assert '自分の公開鍵が受信者から外れています' in capsys.readouterr().out + + +def test_rekey_keeps_the_recipients_when_decryption_fails(root, with_key, + colleague, monkeypatch): + public, _ = colleague + seed_encrypted(root) + + from devbase.env.secret_store import AgeBackend, SecretStoreError + + def broken(self, ref): + raise SecretStoreError('復号できません') + + monkeypatch.setattr(AgeBackend, 'load_bytes', broken) + + assert env_ops.cmd_env_rekey(root, add=[public], assume_yes=True) == 1 + assert agekeys.load_recipients(root) == [] + + +def test_rekey_rolls_back_when_a_later_rewrite_fails(root, with_key, colleague, + monkeypatch): + """途中で書き込みに失敗しても、受信者リストも暗号文も元のまま残る""" + public, key_path = colleague + store = seed_encrypted(root) + agekeys.save_recipients(root, [with_key]) + before_global = store.age.path(GLOBAL).read_bytes() + before_web = store.age.path(WEB).read_bytes() + + # 2 件目 (プロジェクト web) の差し替えだけを失敗させる + original = env_ops._write_blob + web_path = store.age.path(WEB) + + def fail_on_web(path, blob): + if path == web_path: + raise OSError('ディスクがいっぱいです') + return original(path, blob) + + monkeypatch.setattr(env_ops, '_write_blob', fail_on_web) + + assert env_ops.cmd_env_rekey(root, add=[public], assume_yes=True) == 1 + + # 受信者リストは元のまま + assert agekeys.load_recipients(root) == [with_key] + # 1 件目も元の暗号文へ戻っている (同僚の鍵ではまだ読めない) + assert store.age.path(GLOBAL).read_bytes() == before_global + assert store.age.path(WEB).read_bytes() == before_web + # 旧受信者 (自分) の鍵で引き続き全件読める + assert SecretStore(root).load(GLOBAL) == {'TOKEN': 'sk-1'} + assert SecretStore(root).load(WEB) == {'DB_PASSWORD': 'pw'} + + from devbase.env.secret_store import SecretStoreError + + reader = SecretStore(root, identities=[key_path]) + with pytest.raises(SecretStoreError): + reader.load(GLOBAL) + + +def test_rekey_removes_the_created_recipients_file_on_rollback(root, with_key, + colleague, + monkeypatch): + """リストが無い状態から始めた場合、巻き戻しで作ったリストごと消える""" + public, _ = colleague + store = seed_encrypted(root) + assert not agekeys.recipients_file(root).exists() + + original = env_ops._write_blob + web_path = store.age.path(WEB) + + def fail_on_web(path, blob): + if path == web_path: + raise OSError('ディスクがいっぱいです') + return original(path, blob) + + monkeypatch.setattr(env_ops, '_write_blob', fail_on_web) + + assert env_ops.cmd_env_rekey(root, add=[public], assume_yes=True) == 1 + assert not agekeys.recipients_file(root).exists() + + +def test_rekey_rewrites_every_secret_on_success(root, with_key, colleague): + """成功時は全件が新しい受信者で読める""" + public, key_path = colleague + seed_encrypted(root) + + assert env_ops.cmd_env_rekey(root, add=[public], assume_yes=True) == 0 + + reader = SecretStore(root, identities=[key_path]) + assert reader.load(GLOBAL) == {'TOKEN': 'sk-1'} + assert reader.load(WEB) == {'DB_PASSWORD': 'pw'} + assert agekeys.load_recipients(root) == [with_key, public] + + +# --------------------------------------------------------------------------- +# doctor +# --------------------------------------------------------------------------- + +def write_gitignore(root, *extra): + lines = ['.env', '.env.bak*', 'secrets/', *extra] + (root / '.gitignore').write_text('\n'.join(lines) + '\n') + + +def test_doctor_is_quiet_on_a_healthy_setup(root, with_key, capsys): + seed_encrypted(root) + write_gitignore(root) + + assert env_ops.cmd_env_doctor(root) == 0 + assert '問題は見つかりませんでした' in capsys.readouterr().out + + +def test_doctor_reports_both_formats_present(root, with_key, capsys): + store = seed_encrypted(root) + store.plaintext.save(GLOBAL, {'TOKEN': 'plain'}) + write_gitignore(root) + + assert env_ops.cmd_env_doctor(root) == 1 + out = capsys.readouterr().out + assert '両方にあります' in out + assert '問題 1 件' in out + + +def test_doctor_reports_leftover_migration_backups(root, with_key, capsys): + seed_encrypted(root) + write_gitignore(root) + backup = root / 'backups' / 'env-encrypt' / '20260101000000' + backup.mkdir(parents=True) + (backup / 'global.env').write_text('TOKEN=sk-1\n') + + assert env_ops.cmd_env_doctor(root) == 1 + assert '退避した平文が残っています' in capsys.readouterr().out + + +def test_doctor_ignores_encrypted_backups(root, with_key, capsys): + seed_encrypted(root) + write_gitignore(root) + backup = root / 'backups' / 'env-import' / 'dbenv-1' + backup.mkdir(parents=True) + (backup / 'global.env.age').write_bytes(b'ciphertext') + + assert env_ops.cmd_env_doctor(root) == 0 + + +def test_doctor_reports_stale_plaintext_copies(root, with_key, capsys): + seed_encrypted(root) + write_gitignore(root) + (root / '.env.bak-20260807172231').write_text('TOKEN=sk-1\n') + + assert env_ops.cmd_env_doctor(root) == 1 + assert '平文の控えファイルが残っています' in capsys.readouterr().out + + +def test_doctor_reports_which_paths_are_not_ignored(root, with_key, capsys): + """不足はパターン名ではなく、除外されない実パスで報告する""" + seed_encrypted(root) + (root / '.gitignore').write_text('.env\n.env.bak*\n') # secrets/ が無い + + assert env_ops.cmd_env_doctor(root) == 1 + out = capsys.readouterr().out + assert '除外設定から漏れているパスがあります' in out + assert 'secrets/global.env.age' in out + + +def test_doctor_reports_wildcardless_backup_pattern(root, with_key, capsys): + """日時付きの控えは完全一致では弾けない""" + seed_encrypted(root) + (root / '.gitignore').write_text('.env\n.env.bak\nsecrets/\n') + + assert env_ops.cmd_env_doctor(root) == 1 + out = capsys.readouterr().out + assert '除外設定から漏れているパスがあります' in out + assert '.env.bak-20260807172231' in out + + +@pytest.mark.parametrize('body', [ + '/.env\n/.env.bak*\n/secrets/\n/projects/*\n', # ルート指定 + '.env\n.env.bak*\nsecrets\n', # 末尾スラッシュ無し + '**/.env\n**/.env.bak*\n/secrets/\n', # 任意階層 + '.env\n.env*\nsecrets/\n', # 控えを広く拾う指定 + '# 機密は暗号化して secrets/ へ\n\n.env\n.env.bak*\nsecrets/\n', # 行頭コメント・空行 +]) +def test_doctor_accepts_equivalent_ignore_notations(root, with_key, capsys, body): + """Git が実際に除外できている書き方は「漏れ」と誤検知しない""" + seed_encrypted(root) + (root / '.gitignore').write_text(body) + + assert env_ops.cmd_env_doctor(root) == 0 + assert '問題は見つかりませんでした' in capsys.readouterr().out + + +@pytest.mark.parametrize('body', [ + # Git は行頭の `#` だけをコメントとして扱う。`.env # 機密` は + # 「`.env # 機密`」というパターンであって `.env` を除外しない + '.env # 機密\n.env.bak*\nsecrets/\n', + # 後段の `!` で再包含されると除外は取り消される + '.env\n!.env\n.env.bak*\nsecrets/\n', + # 行頭の空白は落とされない (落ちるのは行末だけ) + ' .env\n.env.bak*\nsecrets/\n', +]) +def test_doctor_reports_patterns_git_does_not_honor(root, with_key, capsys, body): + """Git の解釈では除外できていない書き方を「問題なし」にしない""" + seed_encrypted(root) + (root / '.gitignore').write_text(body) + + assert env_ops.cmd_env_doctor(root) == 1 + out = capsys.readouterr().out + assert '除外設定から漏れているパスがあります' in out + assert '除外されない: .env' in out + + +def test_doctor_reports_partially_ignored_secrets_dir(root, with_key, capsys): + """`secrets/*.age` だけでは配下の平文が漏れる""" + seed_encrypted(root) + (root / '.gitignore').write_text('.env\n.env.bak*\nsecrets/*.age\n') + + assert env_ops.cmd_env_doctor(root) == 1 + out = capsys.readouterr().out + assert '除外設定から漏れているパスがあります' in out + assert 'secrets/leftover.env' in out + + +def test_doctor_cannot_check_ignores_without_a_git_repository(root, with_key, capsys): + """Git リポジトリでなければ「確認できなかった」と言う (成功にしない)""" + seed_encrypted(root) + write_gitignore(root) + shutil.rmtree(root / '.git') + + assert env_ops.cmd_env_doctor(root) == 1 + out = capsys.readouterr().out + assert '除外設定を確認できませんでした' in out + assert '問題は見つかりませんでした' not in out + + +def test_doctor_reports_a_world_readable_key(root, with_key, capsys): + seed_encrypted(root) + write_gitignore(root) + key_file = agekeys.key_file_path() + os.chmod(key_file, 0o644) + + assert env_ops.cmd_env_doctor(root) == 1 + out = capsys.readouterr().out + assert '鍵ファイルが他ユーザーから読めます' in out + assert stat.S_IMODE(key_file.stat().st_mode) == 0o644 # 勝手に直さない + + +def test_doctor_reports_a_missing_key(root, capsys): + write_gitignore(root) + + assert env_ops.cmd_env_doctor(root) == 1 + assert '暗号化に使う鍵がありません' in capsys.readouterr().out diff --git a/tests/commands/test_env_store_switch.py b/tests/commands/test_env_store_switch.py new file mode 100644 index 00000000..9fd17ae5 --- /dev/null +++ b/tests/commands/test_env_store_switch.py @@ -0,0 +1,515 @@ +"""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_keeps_comments_and_blank_lines(devbase_root, with_key, monkeypatch): + """エディタ編集はコメント・空行を落とさない (辞書経由にすると消える)""" + from pathlib import Path + + original = b'# top comment\n\nFOO=bar\n\n# tail comment\n' + SecretStore(devbase_root).age.save_bytes(GLOBAL, original) + + def mutate(path): + # 復号結果は原文そのまま渡っている + assert Path(path).read_bytes() == original + Path(path).write_bytes(original.replace(b'FOO=bar', b'FOO=changed')) + 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.age.load_bytes(GLOBAL) == b'# top comment\n\nFOO=changed\n\n# tail comment\n' + + +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_agekeys.py b/tests/env/test_agekeys.py new file mode 100644 index 00000000..94c72d61 --- /dev/null +++ b/tests/env/test_agekeys.py @@ -0,0 +1,513 @@ +"""agekeys.py: devbase 専用 age 鍵と受信者リストの管理""" + +from __future__ import annotations + +import os +import stat + +import pyrage +import pytest + +from devbase.env import agekeys + + +@pytest.fixture +def isolated_home(tmp_path, monkeypatch): + """鍵の既定パスを tmp_path 配下へ閉じ込める""" + monkeypatch.setenv('XDG_CONFIG_HOME', str(tmp_path / 'config')) + monkeypatch.delenv(agekeys.KEY_FILE_ENV, raising=False) + monkeypatch.setattr(agekeys.Path, 'home', staticmethod(lambda: tmp_path / 'home')) + return tmp_path + + +# --------------------------------------------------------------------------- +# パス解決 +# --------------------------------------------------------------------------- + +def test_key_file_path_uses_xdg_config_home(isolated_home): + assert agekeys.key_file_path() == isolated_home / 'config' / 'devbase' / 'age' / 'keys.txt' + + +def test_key_file_path_env_override_wins(isolated_home, monkeypatch): + monkeypatch.setenv(agekeys.KEY_FILE_ENV, str(isolated_home / 'custom' / 'k.txt')) + assert agekeys.key_file_path() == isolated_home / 'custom' / 'k.txt' + + +def test_key_file_path_falls_back_to_home_config(isolated_home, monkeypatch): + monkeypatch.delenv('XDG_CONFIG_HOME', raising=False) + assert agekeys.key_file_path() == isolated_home / 'home' / '.config' / 'devbase' / 'age' / 'keys.txt' + + +# --------------------------------------------------------------------------- +# 鍵の生成 +# --------------------------------------------------------------------------- + +def test_generate_key_file_writes_private_key_with_0600(isolated_home): + path, public = agekeys.generate_key_file() + + assert path.exists() + assert public.startswith('age1') + assert stat.S_IMODE(path.stat().st_mode) == 0o600 + assert 'AGE-SECRET-KEY-1' in path.read_text() + + +def test_generate_key_file_creates_dir_with_0700(isolated_home): + path, _ = agekeys.generate_key_file() + assert stat.S_IMODE(path.parent.stat().st_mode) == 0o700 + + +def test_generate_key_file_creates_every_missing_level_with_0700(isolated_home, + monkeypatch): + """親を複数階層まとめて作る場合、作った階層はすべて 0700 になる""" + key_path = isolated_home / 'a' / 'b' / 'c' / 'keys.txt' + monkeypatch.setenv(agekeys.KEY_FILE_ENV, str(key_path)) + + agekeys.generate_key_file() + + for level in (key_path.parent, key_path.parent.parent, + key_path.parent.parent.parent): + assert stat.S_IMODE(level.stat().st_mode) == 0o700 + + +def test_created_dirs_are_0700_from_the_moment_of_creation(isolated_home, + monkeypatch): + """umask 0 でも各階層は「作成した瞬間から」0700。 + + 一括作成してから chmod する実装だと、作成〜chmod の間だけ umask 依存の + 緩い権限が露出する。作成直後の mode を記録して、その隙が無いことを見る。 + """ + key_path = isolated_home / 'u1' / 'u2' / 'keys.txt' + monkeypatch.setenv(agekeys.KEY_FILE_ENV, str(key_path)) + + real_mkdir = agekeys.Path.mkdir + modes_at_creation = {} + + def recording_mkdir(self, *args, **kwargs): + result = real_mkdir(self, *args, **kwargs) + modes_at_creation[self] = stat.S_IMODE(self.stat().st_mode) + return result + + monkeypatch.setattr(agekeys.Path, 'mkdir', recording_mkdir) + + old = os.umask(0) + try: + agekeys.generate_key_file() + finally: + os.umask(old) + + levels = (key_path.parent, key_path.parent.parent) + for level in levels: + assert modes_at_creation[level] == 0o700, f'{level} が作成時点で緩い' + assert stat.S_IMODE(level.stat().st_mode) == 0o700 + + +def test_generate_key_file_survives_a_concurrently_created_level(isolated_home, + monkeypatch): + """途中の階層を別プロセスが先に作っていても失敗しない。 + + 先に作られた階層は「既存ディレクトリ」なので、権限は触らず素通りする + (既存ディレクトリを chmod しないという方針と一貫させる)。 + """ + key_path = isolated_home / 'x' / 'y' / 'keys.txt' + monkeypatch.setenv(agekeys.KEY_FILE_ENV, str(key_path)) + + racy = isolated_home / 'x' + real_mkdir = agekeys.Path.mkdir + + def racing_mkdir(self, *args, **kwargs): + if self == racy and not self.exists(): + # 別プロセスが一足先に作った状況を再現する + real_mkdir(self) + os.chmod(self, 0o755) + raise FileExistsError(17, 'File exists', str(self)) + return real_mkdir(self, *args, **kwargs) + + monkeypatch.setattr(agekeys.Path, 'mkdir', racing_mkdir) + + path, _ = agekeys.generate_key_file() + + assert stat.S_IMODE(path.stat().st_mode) == 0o600 + # 他プロセスが作った階層の権限は変えない + assert stat.S_IMODE(racy.stat().st_mode) == 0o755 + # 自分で作った階層は 0700 + assert stat.S_IMODE(key_path.parent.stat().st_mode) == 0o700 + + +def test_generate_key_file_does_not_chmod_an_existing_dir(isolated_home, + monkeypatch): + """既存の共有ディレクトリを鍵の置き場に指定しても、その権限を変えない。 + + ``DEVBASE_AGE_KEY_FILE=/tmp/devbase-key`` のように既に在る共有ディレクトリを + 指されたとき、そこを 0700 に落とすと他ユーザーやサービスのアクセスを壊す。 + devbase が作っていないディレクトリは devbase の管轄外として触らない。 + """ + shared = isolated_home / 'shared' + shared.mkdir() + os.chmod(shared, 0o755) + monkeypatch.setenv(agekeys.KEY_FILE_ENV, str(shared / 'devbase-key')) + + path, _ = agekeys.generate_key_file() + + assert stat.S_IMODE(shared.stat().st_mode) == 0o755 + # ディレクトリを緩いままにする代わり、鍵ファイル自体は 0600 で守る + assert stat.S_IMODE(path.stat().st_mode) == 0o600 + + +def test_generate_key_file_warns_about_a_permissive_existing_dir(isolated_home, + monkeypatch, + caplog): + """権限を変えない代わりに、緩い既存ディレクトリは警告で知らせる""" + shared = isolated_home / 'shared' + shared.mkdir() + os.chmod(shared, 0o777) + monkeypatch.setenv(agekeys.KEY_FILE_ENV, str(shared / 'devbase-key')) + + with caplog.at_level('WARNING'): + agekeys.generate_key_file() + + assert any('shared' in r.getMessage() for r in caplog.records) + + +def test_generate_key_file_keeps_quiet_for_an_already_tight_existing_dir( + isolated_home, monkeypatch, caplog): + """既存でも 0700 なら警告しない (毎回鳴ると本当の警告が埋もれる)""" + tight = isolated_home / 'tight' + tight.mkdir() + os.chmod(tight, 0o700) + monkeypatch.setenv(agekeys.KEY_FILE_ENV, str(tight / 'devbase-key')) + + with caplog.at_level('WARNING'): + agekeys.generate_key_file() + + assert caplog.records == [] + + +def test_save_recipients_does_not_chmod_an_existing_secrets_dir(tmp_path): + """受信者リスト側も既存ディレクトリの権限を変えない (鍵ファイルと一貫)""" + secrets = tmp_path / 'secrets' + secrets.mkdir(parents=True) + os.chmod(secrets, 0o755) + + path = agekeys.save_recipients( + tmp_path, [str(pyrage.x25519.Identity.generate().to_public())]) + + assert stat.S_IMODE(secrets.stat().st_mode) == 0o755 + assert stat.S_IMODE(path.stat().st_mode) == 0o600 + + +def test_save_recipients_creates_the_secrets_dir_with_0700(tmp_path): + """自分で作った secrets/ は 0700 にする""" + agekeys.save_recipients( + tmp_path, [str(pyrage.x25519.Identity.generate().to_public())]) + assert stat.S_IMODE((tmp_path / 'secrets').stat().st_mode) == 0o700 + + +def test_generate_key_file_refuses_overwrite_without_force(isolated_home): + path, _ = agekeys.generate_key_file() + before = path.read_bytes() + + with pytest.raises(agekeys.AgeKeyError, match='既に存在'): + agekeys.generate_key_file() + + assert path.read_bytes() == before + + +# --------------------------------------------------------------------------- +# 新規生成の排他性 (TOCTOU) +# +# 「存在チェック → 生成」の隙間に他プロセスが鍵を作れると、後発が先発の鍵を +# 消し、先発鍵で暗号化した機密がその瞬間から復号不能になる。新規生成は +# O_CREAT|O_EXCL で不可分に作り、隙間そのものを無くす。 +# --------------------------------------------------------------------------- + +def test_generate_key_file_creates_a_new_key_exclusively(isolated_home, monkeypatch): + """新規生成は O_EXCL 付きで open する (os.replace で置き換えない)""" + seen = [] + real_open = os.open + + def spy_open(target, flags, *args, **kwargs): + seen.append((str(target), flags)) + return real_open(target, flags, *args, **kwargs) + + monkeypatch.setattr(agekeys.os, 'open', spy_open) + + path, _ = agekeys.generate_key_file() + + key_flags = [flags for target, flags in seen if target == str(path)] + assert key_flags, "鍵ファイルが os.open 経由で作られていない" + assert all(flags & os.O_EXCL for flags in key_flags), \ + "新規生成に O_EXCL が付いていない (判定と作成の隙間が残る)" + + +def test_generate_key_file_does_not_clobber_a_key_created_after_the_check( + isolated_home, monkeypatch): + """事前チェック通過後に他プロセスが鍵を作っても、その鍵を上書きしない。 + + ``_ensure_private_dir`` の直後に鍵を差し込んで、判定と書き込みの隙間で + 並行プロセスが先に生成した状況を再現する。排他生成なら後発 (このテストの + 呼び出し) が負けて、先発の鍵が 1 バイトも変わらずに残る。 + """ + path = agekeys.key_file_path() + real_ensure = agekeys._ensure_private_dir + rival = b'AGE-SECRET-KEY-1RIVAL\n' + + def ensure_then_race(parent): + real_ensure(parent) + if not path.exists(): + path.write_bytes(rival) + + monkeypatch.setattr(agekeys, '_ensure_private_dir', ensure_then_race) + + with pytest.raises(agekeys.AgeKeyError, match='既に存在'): + agekeys.generate_key_file() + + assert path.read_bytes() == rival + + +def test_generate_key_file_leaves_no_temp_file_on_first_generation(isolated_home): + """新規生成は一時ファイルを経由しない (鍵ディレクトリに残骸を残さない)""" + path, _ = agekeys.generate_key_file() + assert [p.name for p in path.parent.iterdir()] == [path.name] + + +def test_generate_key_file_removes_a_half_written_key_on_failure(isolated_home, + monkeypatch): + """書き込み途中で落ちたら中途半端な鍵を残さない。 + + 半端な鍵が残ると、以後の生成が「既に存在します」で止まるうえ、その鍵では + 何も復号できないという最悪の状態になる。 + """ + path = agekeys.key_file_path() + + def boom(fd): + raise OSError(28, 'No space left on device') + + monkeypatch.setattr(agekeys.os, 'fsync', boom) + + with pytest.raises(OSError): + agekeys.generate_key_file() + + assert not path.exists() + + +def test_generate_key_file_force_replaces_key(isolated_home): + path, first = agekeys.generate_key_file() + _, second = agekeys.generate_key_file(force=True) + assert first != second + assert agekeys.read_public_key(path) == second + + +def test_generate_key_file_force_keeps_0600(isolated_home): + """一時ファイル経由の差し替えでも権限が広がらない""" + path, _ = agekeys.generate_key_file() + agekeys.generate_key_file(force=True) + assert stat.S_IMODE(path.stat().st_mode) == 0o600 + + +def test_generate_key_file_force_leaves_no_temp_file(isolated_home): + """差し替え用の一時ファイルが鍵ディレクトリに残らない""" + path, _ = agekeys.generate_key_file() + agekeys.generate_key_file(force=True) + assert [p.name for p in path.parent.iterdir()] == [path.name] + + +def test_generate_key_file_force_keeps_old_key_when_replace_fails(isolated_home, + monkeypatch): + """差し替えに失敗しても旧鍵は無傷のまま残る (O_TRUNC 直書きなら失われる)""" + path, first = agekeys.generate_key_file() + before = path.read_bytes() + + def boom(src, dst): + raise OSError(28, 'No space left on device') + + monkeypatch.setattr(agekeys._io_common.os, 'replace', boom) + + with pytest.raises(OSError): + agekeys.generate_key_file(force=True) + + assert path.read_bytes() == before + assert agekeys.read_public_key(path) == first + # 書きかけの一時ファイルも掃除されている + assert [p.name for p in path.parent.iterdir()] == [path.name] + + +def test_save_recipients_keeps_old_list_when_replace_fails(tmp_path, monkeypatch): + """受信者リストも差し替え失敗時に旧内容を保つ""" + pubs = [str(pyrage.x25519.Identity.generate().to_public()) for _ in range(2)] + agekeys.save_recipients(tmp_path, pubs) + path = agekeys.recipients_file(tmp_path) + before = path.read_bytes() + + def boom(src, dst): + raise OSError(28, 'No space left on device') + + monkeypatch.setattr(agekeys._io_common.os, 'replace', boom) + + with pytest.raises(OSError): + agekeys.save_recipients(tmp_path, pubs[:1]) + + assert path.read_bytes() == before + assert agekeys.load_recipients(tmp_path) == pubs + + +def test_generated_key_can_decrypt_what_its_public_key_encrypted(isolated_home): + from devbase.env import cipher + + path, public = agekeys.generate_key_file() + blob = cipher.encrypt(b'payload', recipients=[public]) + assert cipher.decrypt(blob, identities=[str(path)]) == b'payload' + + +# --------------------------------------------------------------------------- +# 公開鍵の読み取り +# --------------------------------------------------------------------------- + +def test_read_public_key_derives_from_secret_not_comment(isolated_home): + """コメント行が嘘でも、秘密鍵から導出した公開鍵を返す""" + path, public = agekeys.generate_key_file() + tampered = path.read_text().replace(f'# public key: {public}', + '# public key: age1deadbeef') + path.write_text(tampered) + + assert agekeys.read_public_key(path) == public + + +def test_read_public_key_missing_file(isolated_home): + with pytest.raises(agekeys.AgeKeyError, match='見つかりません'): + agekeys.read_public_key(isolated_home / 'nope.txt') + + +def test_read_public_key_rejects_non_age_key(isolated_home): + path = isolated_home / 'ssh_like.txt' + path.write_text('-----BEGIN OPENSSH PRIVATE KEY-----\nzzz\n') + with pytest.raises(agekeys.AgeKeyError, match='AGE-SECRET-KEY-1'): + agekeys.read_public_key(path) + + +# --------------------------------------------------------------------------- +# 受信者リスト +# --------------------------------------------------------------------------- + +def test_recipients_roundtrip(tmp_path): + pub = str(pyrage.x25519.Identity.generate().to_public()) + + assert agekeys.load_recipients(tmp_path) == [] + assert agekeys.add_recipient(tmp_path, pub) is True + assert agekeys.load_recipients(tmp_path) == [pub] + + # 重複登録は no-op + assert agekeys.add_recipient(tmp_path, pub) is False + assert agekeys.load_recipients(tmp_path) == [pub] + + assert agekeys.remove_recipient(tmp_path, pub) is True + assert agekeys.load_recipients(tmp_path) == [] + assert agekeys.remove_recipient(tmp_path, pub) is False + + +def test_recipients_file_is_0600(tmp_path): + pub = str(pyrage.x25519.Identity.generate().to_public()) + agekeys.add_recipient(tmp_path, pub) + path = agekeys.recipients_file(tmp_path) + assert stat.S_IMODE(path.stat().st_mode) == 0o600 + + +def test_add_recipient_rejects_malformed_key(tmp_path): + from devbase.env.cipher import CipherError + + with pytest.raises(CipherError): + agekeys.add_recipient(tmp_path, 'not-a-key') + assert not agekeys.recipients_file(tmp_path).exists() + + +def test_load_recipients_skips_comments_and_blanks(tmp_path): + pub = str(pyrage.x25519.Identity.generate().to_public()) + path = agekeys.recipients_file(tmp_path) + path.parent.mkdir(parents=True) + path.write_text(f"# header\n\n{pub}\n \n") + assert agekeys.load_recipients(tmp_path) == [pub] + + +# --------------------------------------------------------------------------- +# 鍵の解決 +# --------------------------------------------------------------------------- + +def test_resolve_recipients_prefers_registered_list(isolated_home, tmp_path): + _, own = agekeys.generate_key_file() + other = str(pyrage.x25519.Identity.generate().to_public()) + agekeys.add_recipient(tmp_path, other) + + assert agekeys.resolve_recipients(tmp_path) == [other] + assert own not in agekeys.resolve_recipients(tmp_path) + + +def test_resolve_recipients_falls_back_to_own_public_key(isolated_home, tmp_path): + _, own = agekeys.generate_key_file() + assert agekeys.resolve_recipients(tmp_path) == [own] + + +def test_resolve_recipients_without_any_key_raises(isolated_home, tmp_path): + with pytest.raises(agekeys.AgeKeyError, match='公開鍵がありません'): + agekeys.resolve_recipients(tmp_path) + + +def test_resolve_identities_puts_devbase_key_first(isolated_home, monkeypatch): + ssh_key = isolated_home / 'id_ed25519' + ssh_key.write_text('dummy') + monkeypatch.setattr(agekeys._cipher, 'default_identity_paths', + lambda: [ssh_key]) + + path, _ = agekeys.generate_key_file() + assert agekeys.resolve_identities() == [str(path), str(ssh_key)] + + +def test_resolve_identities_empty_when_nothing_exists(isolated_home, monkeypatch): + monkeypatch.setattr(agekeys._cipher, 'default_identity_paths', lambda: []) + assert agekeys.resolve_identities() == [] + + +def test_save_recipients_is_idempotent_for_content(tmp_path): + pubs = [str(pyrage.x25519.Identity.generate().to_public()) for _ in range(2)] + agekeys.save_recipients(tmp_path, pubs) + first = agekeys.recipients_file(tmp_path).read_text() + agekeys.save_recipients(tmp_path, pubs) + assert agekeys.recipients_file(tmp_path).read_text() == first + assert agekeys.load_recipients(tmp_path) == pubs + + +def test_umask_does_not_widen_key_permissions(isolated_home): + """umask 0 でも鍵が 0600 で作られる (作成時点から権限を絞る)""" + old = os.umask(0) + try: + path, _ = agekeys.generate_key_file() + finally: + os.umask(old) + assert stat.S_IMODE(path.stat().st_mode) == 0o600 + + +def test_resolve_identities_still_decrypts_ssh_ciphertext_when_devbase_key_broken( + isolated_home, monkeypatch): + """専用鍵ファイルが壊れていても、旧来 ``~/.ssh`` の鍵で暗号化した暗号文を + ``resolve_identities()`` 経由で復号できる (移行互換性そのものの検証)。 + + ``resolve_identities`` は専用鍵を先頭に置くため、専用鍵の解決失敗でそこで + 止まってしまうと ``~/.ssh`` の鍵を試せない (PR #91 codex 指摘)。 + """ + from devbase.env import cipher + + ssh_identity = pyrage.x25519.Identity.generate() + ssh_key = isolated_home / 'id_ed25519' + ssh_key.write_text(str(ssh_identity)) + monkeypatch.setattr(agekeys._cipher, 'default_identity_paths', + lambda: [ssh_key]) + + # 専用鍵ファイルは存在するが中身が壊れている状態を作る + key_file = agekeys.key_file_path() + key_file.parent.mkdir(parents=True, exist_ok=True) + key_file.write_text('broken key material\n') + + identities = agekeys.resolve_identities() + assert identities == [str(key_file), str(ssh_key)] + + blob = cipher.encrypt(b'legacy-secret', + recipients=[str(ssh_identity.to_public())]) + assert cipher.decrypt(blob, identities=identities) == b'legacy-secret' diff --git a/tests/env/test_cipher.py b/tests/env/test_cipher.py index 65f1ce35..16b9de8b 100644 --- a/tests/env/test_cipher.py +++ b/tests/env/test_cipher.py @@ -2,6 +2,8 @@ from __future__ import annotations +import logging + import pyrage import pytest @@ -215,3 +217,62 @@ def test_resolve_identity_accepts_age_keygen_output_with_comments( blob = cipher.encrypt(b"payload", recipients=[pub]) assert cipher.decrypt(blob, identities=[str(id_path)]) == b"payload" + + +def test_decrypt_skips_unresolvable_identity_and_uses_valid_one( + tmp_path, x25519_keypair, caplog): + """解決できない identity が先頭にあっても、後続の有効な identity で復号できる。 + + ``agekeys.resolve_identities`` は「devbase 専用鍵 → ``~/.ssh`` の既定鍵」の順に + 候補を並べる。専用鍵ファイルが壊れているだけで復号が止まると、旧来 ``~/.ssh`` + の鍵で暗号化した暗号文を移行期間中に復号できるという意図が失われるため、 + 解決できない候補は警告を出して読み飛ばす (PR #91 codex 指摘)。 + """ + pub, priv_str = x25519_keypair + broken = tmp_path / "broken.key" + broken.write_text("not a key at all\n") + valid = tmp_path / "valid.key" + valid.write_text(priv_str) + + blob = cipher.encrypt(b"migrated", recipients=[pub]) + + with caplog.at_level(logging.WARNING, logger="devbase.env.cipher"): + plain = cipher.decrypt(blob, identities=[str(broken), str(valid)]) + + assert plain == b"migrated" + # 黙って読み飛ばすと原因が追えないので、理由が warning に残ること + warnings = [r.getMessage() for r in caplog.records + if r.levelno >= logging.WARNING] + assert any(str(broken) in m for m in warnings) + assert not any(str(valid) in m for m in warnings) + + +def test_decrypt_reports_every_failure_when_no_identity_resolves( + tmp_path, x25519_keypair): + """候補が全滅した場合は従来どおり CipherError。各候補の失敗理由を含む""" + pub, _ = x25519_keypair + missing = tmp_path / "missing.key" + broken = tmp_path / "broken.key" + broken.write_text("not a key at all\n") + + blob = cipher.encrypt(b"x", recipients=[pub]) + + with pytest.raises(cipher.CipherError) as excinfo: + cipher.decrypt(blob, identities=[str(missing), str(broken)]) + + message = str(excinfo.value) + assert str(missing) in message and "見つかりません" in message + assert str(broken) in message and "秘密鍵の解釈に失敗" in message + + +def test_decrypt_keeps_message_when_identity_resolves_but_mismatches( + tmp_path, x25519_keypair): + """解決には成功したが鍵が一致しない場合のメッセージは従来どおり""" + pub, _ = x25519_keypair + other = tmp_path / "other.key" + other.write_text(str(pyrage.x25519.Identity.generate())) + + blob = cipher.encrypt(b"x", recipients=[pub]) + + with pytest.raises(cipher.CipherError, match="復号に失敗しました"): + cipher.decrypt(blob, identities=[str(other)]) diff --git a/tests/env/test_compose_migrate.py b/tests/env/test_compose_migrate.py new file mode 100644 index 00000000..ac3830e5 --- /dev/null +++ b/tests/env/test_compose_migrate.py @@ -0,0 +1,1136 @@ +"""compose_migrate.py: 構成ファイルの機密参照を外す / 戻す""" + +from __future__ import annotations + +import logging +from pathlib import Path + +import pytest +import yaml + +from devbase.env import compose_migrate as cm + + +BASIC = """services: + + dev: + image: carmo:latest + env_file: + - ${DEVBASE_ROOT}/.env + - env + - .env + command: tail -f /dev/null +""" + + +def test_disable_comments_out_secret_entries_only(): + after, touched = cm.disable(BASIC) + + assert '- env\n' in after + assert f'{cm.DISABLED_MARK}- ${{DEVBASE_ROOT}}/.env' in after + assert f'{cm.DISABLED_MARK}- .env' in after + assert touched == ['${DEVBASE_ROOT}/.env', '.env'] + + +def test_disable_keeps_indentation(): + after, _ = cm.disable(BASIC) + line = next(l for l in after.splitlines() if cm.DISABLED_MARK in l) + assert line.startswith(' #') + + +def test_round_trip_restores_the_original_text(): + disabled, _ = cm.disable(BASIC) + restored, touched = cm.enable(disabled) + + assert restored == BASIC + assert len(touched) == 2 + + +def test_disable_is_idempotent(): + once, _ = cm.disable(BASIC) + twice, touched = cm.disable(once) + + assert twice == once + assert touched == [] + + +def test_only_the_requested_targets_are_disabled(): + after, touched = cm.disable(BASIC, {cm.TARGET_GLOBAL}) + + assert touched == ['${DEVBASE_ROOT}/.env'] + assert ' - .env\n' in after + + +def test_project_only_leaves_the_global_entry(): + after, touched = cm.disable(BASIC, {cm.TARGET_PROJECT}) + + assert touched == ['.env'] + assert ' - ${DEVBASE_ROOT}/.env\n' in after + + +def test_env_file_key_is_disabled_when_no_entry_remains(): + """全エントリを落とすと `env_file:` だけが残り Compose が失敗するため""" + text = """services: + dev: + env_file: + - ${DEVBASE_ROOT}/.env + image: x +""" + after, _ = cm.disable(text) + + assert f'{cm.DISABLED_MARK}env_file:' in after + assert cm.enable(after)[0] == text + + +def test_other_env_files_keep_the_key_active(): + after, _ = cm.disable(BASIC) + assert ' env_file:\n' in after + + +def test_user_comments_are_preserved(): + text = """services: + dev: + env_file: + # 共通設定 + - ${DEVBASE_ROOT}/.env + - env # プロジェクト設定 + image: x +""" + after, touched = cm.disable(text) + + assert ' # 共通設定\n' in after + assert ' - env # プロジェクト設定\n' in after + # コメント行で走査が止まると、その後ろの機密参照が無効化されないまま残る。 + # 「往復で元に戻る」だけでは何も書き換えられなかった場合と区別できない。 + assert touched == ['${DEVBASE_ROOT}/.env'] + assert f'{cm.DISABLED_MARK}- ${{DEVBASE_ROOT}}/.env' in after + assert cm.enable(after)[0] == text + + +def test_quoted_entries_are_recognised(): + text = """services: + dev: + env_file: + - "${DEVBASE_ROOT}/.env" + - env +""" + after, touched = cm.disable(text) + + assert touched == ['${DEVBASE_ROOT}/.env'] + assert cm.enable(after)[0] == text + + +def test_bare_dollar_form_is_recognised(): + text = """services: + dev: + env_file: + - $DEVBASE_ROOT/.env + - env +""" + _, touched = cm.disable(text) + assert touched == ['$DEVBASE_ROOT/.env'] + + +def test_unrelated_env_files_are_left_alone(): + text = """services: + dev: + env_file: + - config/app.env + - env +""" + after, touched = cm.disable(text) + + assert touched == [] + assert after == text + + +def test_multiple_services_are_handled(): + text = """services: + dev: + env_file: + - ${DEVBASE_ROOT}/.env + - env + worker: + env_file: + - ${DEVBASE_ROOT}/.env + - env +""" + after, touched = cm.disable(text) + + assert len(touched) == 2 + assert after.count(cm.DISABLED_MARK) == 2 + assert cm.enable(after)[0] == text + + +# --------------------------------------------------------------------------- +# enable: 種別を絞った復元 (部分復号) +# --------------------------------------------------------------------------- + +def test_enable_restores_only_the_requested_targets(): + """共通設定が暗号化されたままなら、その参照は戻してはいけない""" + disabled, _ = cm.disable(BASIC) + + after, restored = cm.enable(disabled, {cm.TARGET_PROJECT}) + + assert ' - .env\n' in after + assert restored == ['- .env'] + assert f'{cm.DISABLED_MARK}- ${{DEVBASE_ROOT}}/.env' in after + + +def test_enable_is_the_inverse_of_disable_per_target(): + disabled, _ = cm.disable(BASIC) + partial, _ = cm.enable(disabled, {cm.TARGET_PROJECT}) + full, _ = cm.enable(partial, {cm.TARGET_GLOBAL}) + + assert full == BASIC + + +def test_enable_leaves_the_key_disabled_while_entries_stay_disabled(): + """エントリを戻さないのに `env_file:` だけ戻すと Compose が失敗する""" + text = """services: + dev: + env_file: + - ${DEVBASE_ROOT}/.env + image: x +""" + disabled, _ = cm.disable(text) + + after, restored = cm.enable(disabled, {cm.TARGET_PROJECT}) + + assert after == disabled + assert restored == [] + + +def test_enable_restores_the_key_together_with_the_last_entry(): + text = """services: + dev: + env_file: + - ${DEVBASE_ROOT}/.env + image: x +""" + disabled, _ = cm.disable(text) + + after, restored = cm.enable(disabled, {cm.TARGET_GLOBAL}) + + assert after == text + assert restored == ['- ${DEVBASE_ROOT}/.env', 'env_file:'] + + +# --------------------------------------------------------------------------- +# 空行を含むリスト +# --------------------------------------------------------------------------- + +BLANK_IN_LIST = """services: + dev: + env_file: + - ${DEVBASE_ROOT}/.env + + - .env + image: x +""" + + +def test_blank_lines_inside_the_list_do_not_stop_the_scan(): + after, touched = cm.disable(BLANK_IN_LIST) + + assert touched == ['${DEVBASE_ROOT}/.env', '.env'] + assert f'{cm.DISABLED_MARK}- .env' in after + + +def test_blank_lines_do_not_make_the_key_look_used(): + """空行で走査が止まると「有効なエントリ 0 件」と誤判定してキーを落とす""" + text = """services: + dev: + env_file: + - ${DEVBASE_ROOT}/.env + + - env + image: x +""" + after, _ = cm.disable(text) + + assert ' env_file:\n' in after + assert f'{cm.DISABLED_MARK}env_file:' not in after + + +def test_blank_lines_round_trip(): + disabled, _ = cm.disable(BLANK_IN_LIST) + assert cm.enable(disabled)[0] == BLANK_IN_LIST + + +def test_blank_line_does_not_leak_into_the_next_block(): + text = """services: + dev: + env_file: + - .env + + worker: + image: x +""" + after, touched = cm.disable(text) + + assert touched == ['.env'] + assert ' worker:\n' in after + assert cm.enable(after)[0] == text + + +# --------------------------------------------------------------------------- +# コメント行を含むリスト +# --------------------------------------------------------------------------- + +COMMENT_IN_LIST = """services: + dev: + env_file: + - env + # 機密はここから + - ${DEVBASE_ROOT}/.env + - .env + image: x +""" + + +def test_comment_lines_inside_the_list_do_not_stop_the_scan(): + """コメント行で打ち切ると、その後ろの機密参照が有効なまま残ってしまう""" + after, touched = cm.disable(COMMENT_IN_LIST) + + assert touched == ['${DEVBASE_ROOT}/.env', '.env'] + assert f'{cm.DISABLED_MARK}- ${{DEVBASE_ROOT}}/.env' in after + assert f'{cm.DISABLED_MARK}- .env' in after + assert ' # 機密はここから\n' in after + assert ' - env\n' in after + + +def test_comment_lines_round_trip(): + disabled, _ = cm.disable(COMMENT_IN_LIST) + restored, touched = cm.enable(disabled) + + assert restored == COMMENT_IN_LIST + assert len(touched) == 2 + + +def test_comment_lines_do_not_make_the_key_look_used(): + """コメント行の後ろに有効なエントリが残るなら `env_file:` は落とせない""" + text = """services: + dev: + env_file: + - ${DEVBASE_ROOT}/.env + # プロジェクト設定 + - env + image: x +""" + after, _ = cm.disable(text) + + assert ' env_file:\n' in after + assert f'{cm.DISABLED_MARK}env_file:' not in after + + +def test_comments_and_blank_lines_mixed_do_not_stop_the_scan(): + text = """services: + dev: + env_file: + + # 共通設定 + - ${DEVBASE_ROOT}/.env + + # プロジェクト設定 + - .env + image: x +""" + after, touched = cm.disable(text) + + assert touched == ['${DEVBASE_ROOT}/.env', '.env'] + # 有効なエントリが 1 つも残らないのでキー行も無効化される + assert f'{cm.DISABLED_MARK}env_file:' in after + assert cm.enable(after)[0] == text + + +def test_comment_does_not_leak_into_the_next_block(): + """ブロックの外のコメントを読み飛ばしても、次のサービスは壊さない""" + text = """services: + dev: + env_file: + - .env + + # ここから worker + worker: + env_file: + - ${DEVBASE_ROOT}/.env +""" + after, touched = cm.disable(text) + + assert touched == ['.env', '${DEVBASE_ROOT}/.env'] + assert ' # ここから worker\n' in after + assert ' worker:\n' in after + assert cm.enable(after)[0] == text + + +# --------------------------------------------------------------------------- +# 対応していない記法 +# --------------------------------------------------------------------------- + +INLINE = """services: + dev: + env_file: [ "${DEVBASE_ROOT}/.env", .env ] + worker: + env_file: .env + batch: + env_file: + - ${DEVBASE_ROOT}/.env + - env +""" + + +def test_inline_notation_is_reported(): + """フロー記法は挙がる。単一文字列は書き換えられるので挙がらない""" + found = cm.unsupported_env_file_lines(INLINE) + + assert [number for number, _ in found] == [3] + assert found[0][1] == 'env_file: [ "${DEVBASE_ROOT}/.env", .env ]' + + +def test_block_sequence_alone_reports_nothing(): + assert cm.unsupported_env_file_lines(BASIC) == [] + + +def test_env_file_key_with_a_trailing_comment_is_not_reported(): + text = """services: + dev: + env_file: # 共通設定 + - env +""" + assert cm.unsupported_env_file_lines(text) == [] + + +def test_warn_unsupported_env_file_names_the_file_and_line(caplog): + with caplog.at_level(logging.WARNING, logger='devbase.env.compose_migrate'): + cm.warn_unsupported_env_file(INLINE, Path('projects/web/compose.yml')) + + messages = [r.getMessage() for r in caplog.records] + assert len(messages) == 1 + assert 'projects/web/compose.yml:3' in messages[0] + assert 'env_file: [ "${DEVBASE_ROOT}/.env", .env ]' in messages[0] + + +def test_inline_notation_does_not_break_the_block_sequence(): + """対象外の記法が混ざっていても、扱える書き方は従来どおり処理する""" + after, touched = cm.disable(INLINE) + + # フロー記法 (3 行目) は残り、単一文字列とブロックシーケンスは無効化される + assert touched == ['.env', '${DEVBASE_ROOT}/.env'] + assert ' env_file: [ "${DEVBASE_ROOT}/.env", .env ]\n' in after + assert ' - env\n' in after + assert cm.enable(after)[0] == INLINE + + +def test_secret_inline_lines_are_separated_from_harmless_ones(): + """機密を指すインライン記法だけが「移行を止める理由」になる""" + text = """services: + dev: + env_file: config/app.env + worker: + env_file: [ "${DEVBASE_ROOT}/.env", config/app.env ] + batch: + env_file: .env # プロジェクト設定 +""" + # 単一文字列 (3 行目・7 行目) は書き換えられるので挙がらない + assert [n for n, _ in cm.unsupported_env_file_lines(text)] == [5] + # 機密を指すフロー記法だけが移行を止める + assert [n for n, _ in cm.secret_unsupported_env_file_lines(text)] == [5] + + +def test_secret_inline_lines_respect_the_requested_targets(): + """プロジェクトだけを暗号化するなら、共通設定のインライン記法は止めない""" + text = """services: + dev: + env_file: [ "${DEVBASE_ROOT}/.env" ] +""" + assert cm.secret_unsupported_env_file_lines(text, {cm.TARGET_PROJECT}) == [] + assert len(cm.secret_unsupported_env_file_lines(text, {cm.TARGET_GLOBAL})) == 1 + + +def test_disabled_inline_lines_are_not_reported_again(): + """コメントアウト済みの行を再び「止める理由」に数えない""" + text = f"""services: + dev: + {cm.DISABLED_MARK}env_file: .env +""" + assert cm.secret_unsupported_env_file_lines(text) == [] + + +# --------------------------------------------------------------------------- +# 単一文字列の env_file (契約 1.: 1 行で完結するので行ごと無効化できる) +# --------------------------------------------------------------------------- + +SCALAR = """services: + dev: + image: alpine + env_file: ${DEVBASE_ROOT}/.env + worker: + image: alpine + env_file: ".env" + batch: + image: alpine + env_file: config/app.env +""" + + +def test_scalar_env_file_is_disabled_and_restored(): + """単一文字列の機密参照は行ごと無効化し、復元で元のテキストに戻る""" + after, touched = cm.disable(SCALAR) + + assert touched == ['${DEVBASE_ROOT}/.env', '.env'] + assert f' {cm.DISABLED_MARK}env_file: ${{DEVBASE_ROOT}}/.env\n' in after + assert f' {cm.DISABLED_MARK}env_file: ".env"\n' in after + # 機密と無関係な単一文字列は触らない + assert ' env_file: config/app.env\n' in after + # コメントアウトした行は YAML としては消えている + assert 'env_file' not in yaml.safe_load(after)['services']['dev'] + + assert cm.enable(after)[0] == SCALAR + + +def test_scalar_env_file_round_trips_with_crlf(): + """CRLF でも往復でバイト単位に戻る""" + original = SCALAR.replace('\n', '\r\n') + + after, touched = cm.disable(original) + + assert touched == ['${DEVBASE_ROOT}/.env', '.env'] + assert '\n' not in after.replace('\r\n', '') + assert cm.enable(after)[0] == original + + +def test_scalar_env_file_with_a_trailing_comment_round_trips(): + """行末コメントや余分な空白があってもそのまま戻る""" + text = """services: + dev: + env_file: .env # プロジェクト設定 +""" + after, touched = cm.disable(text) + + assert touched == ['.env'] + assert cm.enable(after)[0] == text + + +def test_scalar_env_file_without_secrets_is_untouched(): + """機密を指さない単一文字列は無効化も警告も中止もしない""" + text = """services: + dev: + env_file: config/app.env +""" + after, touched = cm.disable(text) + + assert touched == [] + assert after == text + assert cm.unsupported_env_file_lines(text) == [] + assert cm.secret_unsupported_env_file_lines(text) == [] + + +def test_scalar_env_file_respects_the_requested_targets(): + """一部だけ暗号化するときは、その種別の単一文字列だけを無効化する""" + after, touched = cm.disable(SCALAR, {cm.TARGET_PROJECT}) + + assert touched == ['.env'] + assert ' env_file: ${DEVBASE_ROOT}/.env\n' in after + # 共通設定が暗号化されたままなら、その行は戻さない + restored, names = cm.enable(after, {cm.TARGET_GLOBAL}) + assert names == [] + assert restored == after + assert cm.enable(after, {cm.TARGET_PROJECT})[0] == SCALAR + + +def test_scalar_env_file_is_not_reported_as_unsupported(): + """単一文字列は契約 1. に入ったので、中止の理由にはならない""" + assert cm.unsupported_env_file_lines(SCALAR) == [] + assert cm.secret_unsupported_env_file_lines(SCALAR) == [] + + +def test_scalar_env_file_disable_is_idempotent(): + once, _ = cm.disable(SCALAR) + twice, touched = cm.disable(once) + + assert touched == [] + assert twice == once + + +def test_services_with_secret_env_file_reads_scalar_notation(): + """単一文字列でも「どの種別を参照していたか」を拾う""" + assert cm.services_with_secret_env_file(SCALAR) == { + 'dev': {cm.TARGET_GLOBAL}, + 'worker': {cm.TARGET_PROJECT}, + } + + +def test_services_with_secret_env_file_sees_disabled_scalar_notation(): + """無効化したあとも参照元のサービスを見失わない""" + after, _ = cm.disable(SCALAR) + + assert cm.services_with_secret_env_file(after) == { + 'dev': {cm.TARGET_GLOBAL}, + 'worker': {cm.TARGET_PROJECT}, + } + + +def test_flow_sequence_is_still_unsupported(): + """1 行で安全に判断できない記法は従来どおり中止の対象のまま""" + text = """services: + dev: + env_file: [ .env ] + worker: + env_file: { path: .env } +""" + after, touched = cm.disable(text) + + assert touched == [] + assert after == text + assert [n for n, _ in cm.secret_unsupported_env_file_lines(text)] == [3, 5] + + +def test_block_scalar_env_file_is_still_unsupported(): + """続きの行に値を持つブロックスカラーは単一文字列として扱わない""" + text = """services: + dev: + env_file: >- + .env +""" + after, touched = cm.disable(text) + + assert touched == [] + assert after == text + assert [n for n, _ in cm.unsupported_env_file_lines(text)] == [3] + + +def test_unclosed_quote_env_file_is_still_unsupported(): + """クォートが閉じていない値は 1 行で判断できない。中止側へ回す""" + text = """services: + dev: + env_file: ".env +""" + after, touched = cm.disable(text) + + assert touched == [] + assert after == text + assert [n for n, _ in cm.unsupported_env_file_lines(text)] == [3] + + +# --------------------------------------------------------------------------- +# 機密参照を持つサービスの列挙 (生成側が機密を渡す先を決めるのに使う) +# --------------------------------------------------------------------------- + +MULTI_SERVICE = """services: + + dev: + image: alpine + env_file: + - ${DEVBASE_ROOT}/.env + - env + volumes: + - x:/work + db: + image: mysql + env_file: + - .env + cache: + image: redis + env_file: + - config/app.env + worker: + env_file: [ ".env" ] +volumes: + x: {} +networks: + net: + driver: bridge +""" + + +def test_services_with_secret_env_file_lists_only_the_referencing_ones(): + """参照していたサービスと、その参照種別 (共通 / プロジェクト) を返す""" + assert cm.services_with_secret_env_file(MULTI_SERVICE) == { + 'dev': {cm.TARGET_GLOBAL}, + 'db': {cm.TARGET_PROJECT}, + 'worker': {cm.TARGET_PROJECT}, + } + + +def test_services_with_secret_env_file_reports_both_targets(): + """両方を参照するサービスは両方の種別を持つ""" + text = """services: + dev: + env_file: + - ${DEVBASE_ROOT}/.env + - env + - .env +""" + assert cm.services_with_secret_env_file(text) == { + 'dev': {cm.TARGET_GLOBAL, cm.TARGET_PROJECT}} + + +def test_services_with_secret_env_file_sees_disabled_entries(): + """移行後は参照がコメントアウトされる。種別まで含めて同じ結果を返す必要がある""" + disabled, _ = cm.disable(MULTI_SERVICE) + + assert cm.services_with_secret_env_file(disabled) == { + 'dev': {cm.TARGET_GLOBAL}, + 'db': {cm.TARGET_PROJECT}, + 'worker': {cm.TARGET_PROJECT}, + } + + +def test_services_with_secret_env_file_ignores_other_sections(): + """`volumes:` などの `- .env` らしき行をサービス扱いしない""" + text = """services: + dev: + volumes: + - ./.env:/etc/x +volumes: + data: {} +""" + assert cm.services_with_secret_env_file(text) == {} + + +def test_services_with_secret_env_file_sees_past_comment_lines(): + """コメント行で走査が止まると、その後ろの参照を持つサービスを取りこぼす""" + text = """services: + dev: + env_file: + - env + # 機密はここから + - ${DEVBASE_ROOT}/.env + + # プロジェクト設定 + - .env + # ここから db + db: + env_file: + # プロジェクト設定 + - .env +""" + assert cm.services_with_secret_env_file(text) == { + 'dev': {cm.TARGET_GLOBAL, cm.TARGET_PROJECT}, + 'db': {cm.TARGET_PROJECT}, + } + + +def test_services_with_secret_env_file_sees_past_comments_after_disable(): + """移行後も同じ結果でなければ、機密が渡らないまま起動して失敗する""" + text = """services: + db: + env_file: + # プロジェクト設定 + - .env + - env +""" + disabled, touched = cm.disable(text) + + assert touched == ['.env'] + assert cm.services_with_secret_env_file(disabled) == { + 'db': {cm.TARGET_PROJECT}} + + +def test_services_with_secret_env_file_respects_targets(): + assert cm.services_with_secret_env_file( + MULTI_SERVICE, {cm.TARGET_GLOBAL}) == {'dev': {cm.TARGET_GLOBAL}} + assert cm.services_with_secret_env_file( + MULTI_SERVICE, {cm.TARGET_PROJECT}) == { + 'db': {cm.TARGET_PROJECT}, 'worker': {cm.TARGET_PROJECT}} + + +# --------------------------------------------------------------------------- +# long syntax (`- path: .env`) +# --------------------------------------------------------------------------- + +LONG_SYNTAX = """services: + dev: + env_file: + - path: ${DEVBASE_ROOT}/.env + - path: env + - .env +""" + + +def test_single_line_long_syntax_is_disabled_and_restored(): + """1 行で閉じている long syntax は通常のエントリと同じように扱える""" + after, touched = cm.disable(LONG_SYNTAX) + + assert touched == ['${DEVBASE_ROOT}/.env', '.env'] + assert f'{cm.DISABLED_MARK}- path: ${{DEVBASE_ROOT}}/.env' in after + # 機密と無関係な long syntax は触らない + assert ' - path: env\n' in after + assert cm.enable(after)[0] == LONG_SYNTAX + + +def test_single_line_long_syntax_is_not_reported_as_unsupported(): + assert cm.unsupported_env_file_lines(LONG_SYNTAX) == [] + + +def test_quoted_long_syntax_is_recognised(): + text = """services: + dev: + env_file: + - "path": ".env" # プロジェクト設定 + - env +""" + after, touched = cm.disable(text) + + assert touched == ['.env'] + assert cm.enable(after)[0] == text + + +MULTI_LINE_LONG_SYNTAX = """services: + dev: + env_file: + - path: .env + required: false + - env +""" + + +def test_multi_line_long_syntax_is_reported_and_blocks_the_migration(): + """`required: false` が続く形は行単位で無効化できない (契約 2.)""" + assert cm.unsupported_env_file_lines(MULTI_LINE_LONG_SYNTAX) == [ + (4, '- path: .env')] + assert [n for n, _ in + cm.secret_unsupported_env_file_lines(MULTI_LINE_LONG_SYNTAX)] == [4] + + +def test_multi_line_long_syntax_is_left_untouched(): + """行だけ落とすと `required: false` が宙に浮いて YAML が壊れる""" + after, touched = cm.disable(MULTI_LINE_LONG_SYNTAX) + + assert touched == [] + assert after == MULTI_LINE_LONG_SYNTAX + + +def test_entries_after_a_multi_line_entry_are_still_scanned(): + """続きの行で走査を打ち切ると、後ろの機密参照が有効なまま残る""" + text = """services: + dev: + env_file: + - path: config/app.env + required: false + - ${DEVBASE_ROOT}/.env +""" + after, touched = cm.disable(text) + + assert touched == ['${DEVBASE_ROOT}/.env'] + assert cm.enable(after)[0] == text + # 機密と無関係な long syntax は警告だけで、移行は止めない + assert [n for n, _ in cm.unsupported_env_file_lines(text)] == [4] + assert cm.secret_unsupported_env_file_lines(text) == [] + + +def test_multi_line_entry_without_a_path_on_the_dash_line_is_still_seen(): + """`-` の行に参照が現れない書き方でも機密を見落とさない""" + text = """services: + dev: + env_file: + - + path: .env + required: false +""" + assert len(cm.secret_unsupported_env_file_lines(text)) == 1 + assert cm.disable(text)[0] == text + + +def test_flow_mapping_entry_blocks_the_migration(): + """1 行に複数の指定が同居するフロー記法は書き換えの対象外 (契約 2.)""" + text = """services: + dev: + env_file: + - { path: .env, required: false } +""" + assert cm.unsupported_env_file_lines(text) == [ + (4, '- { path: .env, required: false }')] + assert len(cm.secret_unsupported_env_file_lines(text)) == 1 + assert cm.disable(text)[0] == text + + +def test_env_file_that_is_not_a_sequence_is_not_passed_silently(): + """シーケンスでない値 (Compose としては不正) も黙って通さない""" + text = """services: + dev: + env_file: + path: .env + required: false +""" + assert [n for n, _ in cm.unsupported_env_file_lines(text)] == [4] + assert len(cm.secret_unsupported_env_file_lines(text)) == 1 + assert cm.disable(text)[0] == text + + +def test_inline_flow_mapping_is_seen_as_a_secret_reference(): + text = """services: + dev: + env_file: [ { path: .env } ] +""" + assert len(cm.secret_unsupported_env_file_lines(text)) == 1 + + +def test_services_with_secret_env_file_reads_long_syntax(): + text = """services: + db: + env_file: + - path: .env + cache: + env_file: + - path: ${DEVBASE_ROOT}/.env + required: false + none: + env_file: + - path: config/app.env +""" + assert cm.services_with_secret_env_file(text) == { + 'db': {cm.TARGET_PROJECT}, + 'cache': {cm.TARGET_GLOBAL}, + } + + +# --------------------------------------------------------------------------- +# クォートされたサービス名 +# --------------------------------------------------------------------------- + +QUOTED_SERVICES = """services: + "db": + image: mysql + env_file: + - .env + 'cache': + image: redis + env_file: + - ${DEVBASE_ROOT}/.env +""" + + +def test_quoted_service_names_match_the_parsed_ones(): + """PyYAML は `"db":` を `db` と読む。引用符込みで記録すると照合できない""" + parsed = set(yaml.safe_load(QUOTED_SERVICES)['services']) + found = cm.services_with_secret_env_file(QUOTED_SERVICES) + + assert set(found) <= parsed + assert found == { + 'db': {cm.TARGET_PROJECT}, + 'cache': {cm.TARGET_GLOBAL}, + } + + +def test_quoted_service_names_survive_the_migration(): + """移行でコメントアウトされたあとも同じサービス名で拾えること""" + disabled, _ = cm.disable(QUOTED_SERVICES) + + assert cm.services_with_secret_env_file(disabled) == { + 'db': {cm.TARGET_PROJECT}, + 'cache': {cm.TARGET_GLOBAL}, + } + + +# --------------------------------------------------------------------------- +# 改行コード (CRLF / 混在) +# --------------------------------------------------------------------------- + +def test_crlf_round_trip_is_byte_identical(): + """行末を LF へ潰すと、往復しても元の compose.yml に戻らない""" + text = BASIC.replace('\n', '\r\n') + + disabled, touched = cm.disable(text) + + assert touched == ['${DEVBASE_ROOT}/.env', '.env'] + assert f'{cm.DISABLED_MARK}- .env\r\n' in disabled + # LF 単独の行が紛れ込んでいない + assert '\n' not in disabled.replace('\r\n', '') + assert cm.enable(disabled)[0] == text + + +def test_crlf_key_line_round_trip(): + """キー行ごと無効化する場合も行末を保つ""" + text = ('services:\r\n dev:\r\n env_file:\r\n' + ' - ${DEVBASE_ROOT}/.env\r\n image: x\r\n') + + disabled, _ = cm.disable(text) + + assert f'{cm.DISABLED_MARK}env_file:\r\n' in disabled + assert cm.enable(disabled)[0] == text + + +def test_mixed_line_endings_are_preserved(): + """混在していても、書き換えた行の行末だけをそのまま引き継ぐ""" + text = ('services:\n dev:\r\n env_file:\n' + ' - ${DEVBASE_ROOT}/.env\r\n - .env\n - env\r\n') + + disabled, touched = cm.disable(text) + + assert touched == ['${DEVBASE_ROOT}/.env', '.env'] + assert f'{cm.DISABLED_MARK}- ${{DEVBASE_ROOT}}/.env\r\n' in disabled + assert f'{cm.DISABLED_MARK}- .env\n' in disabled + assert cm.enable(disabled)[0] == text + + +def test_a_file_without_a_trailing_newline_round_trips(): + text = 'services:\n dev:\n env_file:\n - .env' + + disabled, touched = cm.disable(text) + + assert touched == ['.env'] + assert not disabled.endswith('\n') + assert cm.enable(disabled)[0] == text + + +# --------------------------------------------------------------------------- +# 末尾スペース / 行末コメントを伴うエントリ +# --------------------------------------------------------------------------- + +def test_entries_with_trailing_comments_and_spaces_are_disabled(): + """`- ${DEVBASE_ROOT}/.env # 共通設定` のような行も取りこぼさない""" + text = """services: + dev: + env_file: + - ${DEVBASE_ROOT}/.env # 共通設定 + - ".env" # プロジェクト設定 + - env +""" + after, touched = cm.disable(text) + + assert touched == ['${DEVBASE_ROOT}/.env', '.env'] + assert f'{cm.DISABLED_MARK}- ${{DEVBASE_ROOT}}/.env # 共通設定' in after + # 行末コメントごと元の姿へ戻る + assert cm.enable(after)[0] == text + + +def test_services_with_secret_env_file_handles_trailing_comments(): + text = """services: + db: + env_file: + - .env # プロジェクト設定 +""" + assert cm.services_with_secret_env_file(text) == { + 'db': {cm.TARGET_PROJECT}} + + +# --------------------------------------------------------------------------- +# 事後検証: 書き換え後のテキストを YAML としてパースして確かめる +# --------------------------------------------------------------------------- + +def test_remaining_refs_is_empty_after_a_successful_disable(): + """行ベースの走査で全部外せたケースは、事後検証も素通りする""" + after, _ = cm.disable(BASIC) + + assert cm.remaining_secret_env_file_refs(after) == [] + + +def test_remaining_refs_ignores_files_without_secrets(): + text = """services: + dev: + env_file: + - config/app.env +""" + assert cm.remaining_secret_env_file_refs(text) == [] + + +def test_remaining_refs_finds_a_block_scalar_the_line_scan_misses(): + """`env_file: >-` は先頭行に参照先が無い。行ベースでは取りこぼす""" + text = """services: + dev: + env_file: >- + .env +""" + # 行ベースの走査は何も書き換えられていない (取りこぼしている) + assert cm.disable(text)[1] == [] + assert cm.remaining_secret_env_file_refs(text) == [('dev', '.env')] + + +def test_remaining_refs_flattens_long_syntax_dicts(): + text = """services: + dev: + env_file: + - path: ${DEVBASE_ROOT}/.env + required: false + - path: config/app.env +""" + assert cm.remaining_secret_env_file_refs(text) == [ + ('dev', '${DEVBASE_ROOT}/.env')] + + +def test_remaining_refs_accepts_a_plain_string_value(): + text = """services: + db: + env_file: .env +""" + assert cm.remaining_secret_env_file_refs(text) == [('db', '.env')] + + +def test_remaining_refs_reports_every_service(): + text = """services: + dev: + env_file: ${DEVBASE_ROOT}/.env + db: + env_file: + - .env +""" + assert cm.remaining_secret_env_file_refs(text) == [ + ('dev', '${DEVBASE_ROOT}/.env'), ('db', '.env')] + + +def test_remaining_refs_honours_the_target_filter(): + """復号しない種別の参照は残っていて当然。検証の対象から外す""" + text = """services: + dev: + env_file: + - ${DEVBASE_ROOT}/.env + - .env +""" + assert cm.remaining_secret_env_file_refs(text, [cm.TARGET_PROJECT]) == [ + ('dev', '.env')] + + +def test_remaining_refs_does_not_see_disabled_lines(): + """無効化した行は YAML のコメント = パーサからは見えない""" + after, _ = cm.disable(BASIC) + + assert cm.DISABLED_MARK in after + assert cm.remaining_secret_env_file_refs(after) == [] + + +def test_remaining_refs_tolerates_files_without_services(): + assert cm.remaining_secret_env_file_refs('') == [] + assert cm.remaining_secret_env_file_refs('volumes:\n data:\n') == [] + assert cm.remaining_secret_env_file_refs('services:\n') == [] + + +def test_remaining_refs_ignores_non_string_entries(): + """Compose としては不正な値。機密参照ではないので検証は素通りさせる""" + text = """services: + dev: + env_file: + - 123 + - [] +""" + assert cm.remaining_secret_env_file_refs(text) == [] + + +def test_remaining_refs_raises_on_broken_yaml(): + """検証できない = 参照が無いと言い切れない。黙って通してはいけない""" + with pytest.raises(cm.ComposeParseError): + cm.remaining_secret_env_file_refs('services:\n dev:\n - [oops\n') + + +def test_find_secret_entries_does_not_modify(): + found = cm.find_secret_entries(BASIC) + assert found == ['${DEVBASE_ROOT}/.env', '.env'] + + +def test_diff_mentions_both_sides(): + after, _ = cm.disable(BASIC) + patch = cm.diff(BASIC, after, Path('compose.yml')) + + assert '(現在)' in patch and '(変更後)' in patch + assert '- - ${DEVBASE_ROOT}/.env' in patch + + +def test_compose_files_lists_only_existing(tmp_path): + (tmp_path / 'projects' / 'web').mkdir(parents=True) + (tmp_path / 'projects' / 'web' / 'compose.yml').write_text(BASIC) + (tmp_path / 'projects' / 'api').mkdir() + + found = cm.compose_files(tmp_path, ['web', 'api', 'missing']) + + assert found == [tmp_path / 'projects' / 'web' / 'compose.yml'] diff --git a/tests/env/test_io_common.py b/tests/env/test_io_common.py index c65392d5..33176c94 100644 --- a/tests/env/test_io_common.py +++ b/tests/env/test_io_common.py @@ -120,3 +120,149 @@ def test_decrypt_uses_correct_identity_from_multiple_defaults(tmp_path, fake_hom # 両 identity を渡して復号 → pyrage が正しい鍵 (id2) を選んで復号する plain = cipher.decrypt(blob, identities=identities) assert plain == b"team-secret" + + +# --------------------------------------------------------------------------- +# write_secure_bytes_atomic +# --------------------------------------------------------------------------- + +def test_write_secure_bytes_atomic_creates_file_with_0600(tmp_path): + import os + import stat + + path = tmp_path / "nested" / "secret.bin" + old = os.umask(0) + try: + io_common.write_secure_bytes_atomic(path, b"payload") + finally: + os.umask(old) + + assert path.read_bytes() == b"payload" + assert stat.S_IMODE(path.stat().st_mode) == 0o600 + + +def test_write_secure_bytes_atomic_replaces_and_leaves_no_temp(tmp_path): + path = tmp_path / "secret.bin" + io_common.write_secure_bytes_atomic(path, b"old") + io_common.write_secure_bytes_atomic(path, b"new") + + assert path.read_bytes() == b"new" + assert [p.name for p in tmp_path.iterdir()] == [path.name] + + +def test_write_secure_bytes_atomic_keeps_old_content_on_failure(tmp_path, + monkeypatch): + """差し替えに失敗しても旧内容は残る (直接上書きなら失われる差分)""" + import os + + path = tmp_path / "secret.bin" + io_common.write_secure_bytes_atomic(path, b"old") + + def boom(src, dst): + raise OSError(28, "No space left on device") + + monkeypatch.setattr(os, "replace", boom) + + with pytest.raises(OSError): + io_common.write_secure_bytes_atomic(path, b"new") + + 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 diff --git a/tests/env/test_runtime.py b/tests/env/test_runtime.py new file mode 100644 index 00000000..7dffd530 --- /dev/null +++ b/tests/env/test_runtime.py @@ -0,0 +1,331 @@ +"""runtime.py: 機密の合成とコンテナへ渡す変数名""" + +from __future__ import annotations + +import os + +import pyrage +import pytest + +from devbase.env import runtime +from devbase.env.secret_store import SecretRef, SecretStore + + +@pytest.fixture +def root(tmp_path): + (tmp_path / 'projects' / 'web').mkdir(parents=True) + return tmp_path + + +@pytest.fixture +def store(root, tmp_path): + identity = pyrage.x25519.Identity.generate() + key = tmp_path / 'id.key' + key.write_text(str(identity)) + return SecretStore(root, recipients=[str(identity.to_public())], + identities=[str(key)]) + + +@pytest.fixture(autouse=True) +def _isolate_injection_state(monkeypatch): + """注入記録 (モジュールレベル) をテストごとに独立させる""" + monkeypatch.setattr(runtime, '_injected_originals', {}) + + +GLOBAL = SecretRef.for_global() +WEB = SecretRef.for_project('web') +API = SecretRef.for_project('api') + + +# --------------------------------------------------------------------------- +# 重ね順 +# --------------------------------------------------------------------------- + +def test_global_secrets_are_listed_for_the_container(root, store): + store.age.save(GLOBAL, {'ANTHROPIC_API_KEY': 'sk-1'}) + + resolved = runtime.resolve(root, None, store=store) + + assert resolved.values == {'ANTHROPIC_API_KEY': 'sk-1'} + assert resolved.names == ['ANTHROPIC_API_KEY'] + + +def test_project_secrets_override_global(root, store): + store.age.save(GLOBAL, {'TOKEN': 'global', 'ONLY_GLOBAL': 'g'}) + store.age.save(WEB, {'TOKEN': 'project'}) + + resolved = runtime.resolve(root, 'web', store=store) + + assert resolved.values['TOKEN'] == 'project' + assert resolved.values['ONLY_GLOBAL'] == 'g' + assert sorted(resolved.names) == ['ONLY_GLOBAL', 'TOKEN'] + + +def test_names_are_kept_per_origin(root, store): + """由来ごとに分けて持つ (構成生成側がサービスごとに絞り込むため)""" + store.age.save(GLOBAL, {'TOKEN': 'global', 'ONLY_GLOBAL': 'g'}) + store.age.save(WEB, {'TOKEN': 'project', 'ONLY_PROJECT': 'p'}) + + resolved = runtime.resolve(root, 'web', store=store) + + assert sorted(resolved.global_names) == ['ONLY_GLOBAL', 'TOKEN'] + assert sorted(resolved.project_names) == ['ONLY_PROJECT', 'TOKEN'] + # 両方にあるキーは全体としては 1 件に畳む + assert sorted(resolved.names) == ['ONLY_GLOBAL', 'ONLY_PROJECT', 'TOKEN'] + + +def test_no_secrets_is_falsy(root, store): + resolved = runtime.resolve(root, None, store=store) + + assert not resolved + assert resolved.names == [] + + +def test_project_env_overrides_global_for_the_same_key(root, store, monkeypatch): + """非機密設定が共通設定を上書きする従来の関係を保つ""" + (root / 'projects' / 'web' / 'env').write_text('AWS_DEFAULT_REGION=us-east-1\n') + monkeypatch.setenv('AWS_DEFAULT_REGION', 'us-east-1') + store.age.save(GLOBAL, {'AWS_DEFAULT_REGION': 'ap-northeast-1'}) + + resolved = runtime.resolve(root, 'web', store=store) + + assert resolved.values['AWS_DEFAULT_REGION'] == 'us-east-1' + + +def test_project_env_only_keys_are_not_listed(root, store, monkeypatch): + """非機密設定は env_file が直接読むので変数名を列挙しない""" + (root / 'projects' / 'web' / 'env').write_text('GIT_REPO=web\n') + monkeypatch.setenv('GIT_REPO', 'web') + store.age.save(GLOBAL, {'TOKEN': 't'}) + + resolved = runtime.resolve(root, 'web', store=store) + + assert resolved.names == ['TOKEN'] + assert 'GIT_REPO' not in resolved.values + + +def test_project_env_value_comes_from_the_environment(root, store, monkeypatch): + """展開済みの値を採用する (生の行を読み直さない)""" + (root / 'projects' / 'web' / 'env').write_text('WORK_DIR=/work/$GIT_REPO\n') + monkeypatch.setenv('WORK_DIR', '/work/web') + store.age.save(GLOBAL, {'WORK_DIR': '/work/unset'}) + + resolved = runtime.resolve(root, 'web', store=store) + + assert resolved.values['WORK_DIR'] == '/work/web' + + +def test_project_env_is_ignored_when_not_in_the_environment(root, store, monkeypatch): + monkeypatch.delenv('WORK_DIR', raising=False) + (root / 'projects' / 'web' / 'env').write_text('WORK_DIR=/work/$GIT_REPO\n') + store.age.save(GLOBAL, {'WORK_DIR': '/work/global'}) + + resolved = runtime.resolve(root, 'web', store=store) + + assert resolved.values['WORK_DIR'] == '/work/global' + + +def test_resolve_without_any_secrets_is_empty(root, store): + resolved = runtime.resolve(root, 'web', store=store) + assert resolved.values == {} + assert resolved.names == [] + assert not resolved + + +def test_plaintext_secrets_are_resolved_too(root, store): + """移行前 (平文のまま) でも同じ経路で読める""" + store.plaintext.save(GLOBAL, {'TOKEN': 'plain'}) + + resolved = runtime.resolve(root, None, store=store) + + assert resolved.values == {'TOKEN': 'plain'} + + +# --------------------------------------------------------------------------- +# 注入 +# --------------------------------------------------------------------------- + +def test_inject_puts_values_into_the_given_environ(root, store): + store.age.save(GLOBAL, {'TOKEN': 'sk-1'}) + environ = {} + + resolved = runtime.inject(root, None, environ=environ, store=store) + + assert environ == {'TOKEN': 'sk-1'} + assert resolved.names == ['TOKEN'] + + +# --------------------------------------------------------------------------- +# 注入の解除 (プロジェクト切替時の残留対策) +# --------------------------------------------------------------------------- + +def test_switching_projects_drops_the_source_only_secret(root, store): + """切替元にしか無い機密は、切替先の機密を載せ直すと消える。 + + 単に上書きするだけでは、切替先に同名キーが無い機密が残ってしまう。 + """ + (root / 'projects' / 'api').mkdir() + store.age.save(GLOBAL, {'SHARED': 'common'}) + store.age.save(WEB, {'WEB_ONLY': 'w'}) + store.age.save(API, {'API_ONLY': 'a'}) + environ = {} + + runtime.inject(root, 'web', environ=environ, store=store) + assert environ['WEB_ONLY'] == 'w' + + runtime.clear_injected(environ) + runtime.inject(root, 'api', environ=environ, store=store) + + # 切替元固有の機密は残らない + assert 'WEB_ONLY' not in environ + assert environ['API_ONLY'] == 'a' + # 共通の機密は切替後も残る + assert environ['SHARED'] == 'common' + + +def test_clear_injected_restores_the_users_own_value(root, store): + """利用者がシェルで設定していた同名の変数は消さず元の値へ戻す""" + store.age.save(GLOBAL, {'TOKEN': 'from-secret'}) + environ = {'TOKEN': 'from-shell', 'PATH': '/bin'} + + runtime.inject(root, None, environ=environ, store=store) + assert environ['TOKEN'] == 'from-secret' + + cleared = runtime.clear_injected(environ) + + assert environ['TOKEN'] == 'from-shell' + assert environ['PATH'] == '/bin' + assert cleared == ['TOKEN'] + + +def test_clear_injected_removes_keys_that_did_not_exist(root, store): + store.age.save(GLOBAL, {'TOKEN': 'from-secret'}) + environ = {} + + runtime.inject(root, None, environ=environ, store=store) + runtime.clear_injected(environ) + + assert environ == {} + + +def test_repeated_injection_keeps_the_original_value(root, store): + """載せ直しても記録するのは「最初に載せる前の値」""" + store.age.save(GLOBAL, {'TOKEN': 'from-secret'}) + environ = {'TOKEN': 'from-shell'} + + runtime.inject(root, None, environ=environ, store=store) + runtime.inject(root, None, environ=environ, store=store) + runtime.clear_injected(environ) + + assert environ['TOKEN'] == 'from-shell' + + +def test_clear_injected_without_injection_is_noop(root): + environ = {'TOKEN': 'from-shell'} + + assert runtime.clear_injected(environ) == [] + assert environ == {'TOKEN': 'from-shell'} + + +def test_clear_injected_only_touches_the_given_mapping(root, store): + """履歴は注入先ごとに持つ (別のマッピングを巻き込まない) + + 履歴が全体で 1 つしか無いと、A へ注入した記録で B を「復元」してしまい、 + B の値が壊れるうえ A には機密が残る。 + """ + store.age.save(GLOBAL, {'TOKEN': 'from-secret'}) + a = {'TOKEN': 'a-shell'} + b = {'TOKEN': 'b-shell'} + + runtime.inject(root, None, environ=a, store=store) + runtime.inject(root, None, environ=b, store=store) + + assert runtime.clear_injected(a) == ['TOKEN'] + + # A だけが元へ戻り、B は注入したままで壊れない + assert a == {'TOKEN': 'a-shell'} + assert b == {'TOKEN': 'from-secret'} + + # B の履歴は残っているので、後から解除すれば B も元へ戻る + assert runtime.clear_injected(b) == ['TOKEN'] + assert b == {'TOKEN': 'b-shell'} + + +def test_clearing_one_mapping_keeps_secrets_out_of_the_other(root, store): + """A の解除が B の機密を消し残さない (逆に A には機密を残さない)""" + store.age.save(GLOBAL, {'ONLY_SECRET': 's'}) + a = {} + b = {} + + runtime.inject(root, None, environ=a, store=store) + runtime.inject(root, None, environ=b, store=store) + runtime.clear_injected(b) + + assert b == {} + assert a == {'ONLY_SECRET': 's'} + + runtime.clear_injected(a) + assert a == {} + + +def test_inject_and_clear_default_to_os_environ(root, store, monkeypatch): + """既定の対象は従来どおり os.environ""" + monkeypatch.delenv('TOKEN', raising=False) + store.age.save(GLOBAL, {'TOKEN': 'from-secret'}) + other = {'TOKEN': 'other'} + + runtime.inject(root, None, store=store) + assert os.environ['TOKEN'] == 'from-secret' + + # 別マッピングへの注入は os.environ の履歴に混ざらない + runtime.inject(root, None, environ=other, store=store) + + assert runtime.clear_injected() == ['TOKEN'] + assert 'TOKEN' not in os.environ + assert other == {'TOKEN': 'from-secret'} + + +def test_child_env_does_not_touch_os_environ(root, store, monkeypatch): + monkeypatch.delenv('TOKEN', raising=False) + store.age.save(GLOBAL, {'TOKEN': 'sk-1'}) + + env = runtime.child_env(root, None, store=store) + + assert env['TOKEN'] == 'sk-1' + assert 'TOKEN' not in os.environ + + +def test_child_env_keeps_the_existing_environment(root, store): + store.age.save(GLOBAL, {'TOKEN': 'sk-1'}) + + env = runtime.child_env(root, None, base={'PATH': '/bin'}, store=store) + + assert env['PATH'] == '/bin' + assert env['TOKEN'] == 'sk-1' + + +# --------------------------------------------------------------------------- +# プロジェクトの特定 +# --------------------------------------------------------------------------- + +def test_current_project_name_from_a_subdirectory(root): + sub = root / 'projects' / 'web' / 'src' + sub.mkdir() + assert runtime.current_project_name(root, sub) == 'web' + + +def test_current_project_name_outside_projects(root): + assert runtime.current_project_name(root, root) is None + + +def test_current_project_name_rejects_paths_escaping_projects(root): + escaped = root / 'projects' / 'web' / '..' / '..' / 'outside' + assert runtime.current_project_name(root, escaped) is None + + +def test_current_project_name_follows_a_symlinked_project(root, tmp_path): + target = tmp_path / 'linked-target' + target.mkdir() + (root / 'projects' / 'linked').symlink_to(target) + + assert runtime.current_project_name(root, root / 'projects' / 'linked') == 'linked' diff --git a/tests/env/test_secret_store.py b/tests/env/test_secret_store.py new file mode 100644 index 00000000..00dddc81 --- /dev/null +++ b/tests/env/test_secret_store.py @@ -0,0 +1,357 @@ +"""secret_store.py: 平文 / age の保存先抽象と自動判定""" + +from __future__ import annotations + +import stat + +import pyrage +import pytest + +from devbase.env.secret_store import ( + MODE_ABSENT, + MODE_AGE, + MODE_PLAINTEXT, + AgeBackend, + SecretRef, + SecretStore, + SecretStoreError, +) + + +@pytest.fixture +def keypair(): + identity = pyrage.x25519.Identity.generate() + return str(identity.to_public()), str(identity) + + +@pytest.fixture +def store(tmp_path, keypair): + """明示的な鍵を渡した SecretStore (ホームの鍵に依存しない)""" + public, secret = keypair + id_path = tmp_path / 'identity.key' + id_path.write_text(secret) + (tmp_path / 'projects').mkdir() + return SecretStore(tmp_path, recipients=[public], identities=[str(id_path)]) + + +GLOBAL = SecretRef.for_global() +SAMPLE = {'ANTHROPIC_API_KEY': 'sk-test', 'AWS_SECRET_ACCESS_KEY': 'secret value'} + + +# --------------------------------------------------------------------------- +# 参照 +# --------------------------------------------------------------------------- + +def test_project_ref_rejects_path_traversal(): + for bad in ('../evil', 'a/b', '.', '..'): + with pytest.raises(SecretStoreError): + SecretRef.for_project(bad) + + +def test_project_ref_rejects_empty_name(): + with pytest.raises(SecretStoreError): + SecretRef.for_project('') + + +# --------------------------------------------------------------------------- +# 保存先パス +# --------------------------------------------------------------------------- + +def test_paths_follow_the_documented_layout(tmp_path, store): + proj = SecretRef.for_project('web') + + assert store.plaintext.path(GLOBAL) == tmp_path / '.env' + assert store.plaintext.path(proj) == tmp_path / 'projects' / 'web' / '.env' + assert store.age.path(GLOBAL) == tmp_path / 'secrets' / 'global.env.age' + assert store.age.path(proj) == tmp_path / 'secrets' / 'projects' / 'web.env.age' + + +# --------------------------------------------------------------------------- +# ラウンドトリップ +# --------------------------------------------------------------------------- + +def test_age_backend_roundtrip(store): + path = store.age.save(GLOBAL, SAMPLE) + + assert path.exists() + assert b'sk-test' not in path.read_bytes() # 平文が残っていない + assert store.age.load(GLOBAL) == SAMPLE + + +def test_age_backend_file_is_0600(store): + path = store.age.save(GLOBAL, SAMPLE) + assert stat.S_IMODE(path.stat().st_mode) == 0o600 + + +def test_plaintext_backend_roundtrip(store): + store.plaintext.save(GLOBAL, SAMPLE) + assert store.plaintext.load(GLOBAL) == SAMPLE + + +def test_project_secrets_roundtrip(store): + proj = SecretRef.for_project('web') + store.age.save(proj, {'DB_PASSWORD': 'p@ss word'}) + assert store.age.load(proj) == {'DB_PASSWORD': 'p@ss word'} + + +def test_load_of_missing_file_is_empty(store): + assert store.age.load(GLOBAL) == {} + assert store.plaintext.load(GLOBAL) == {} + + +RAW = b'# comment\n\nexport EDITOR=vim\nQUOTED="a b"\n' + + +def test_bytes_roundtrip_keeps_the_original_content(store): + """バイト列経路はコメント・空行・``export`` 表記をそのまま往復させる""" + for backend in (store.age, store.plaintext): + backend.save_bytes(GLOBAL, RAW) + assert backend.load_bytes(GLOBAL) == RAW + backend.remove(GLOBAL) + + +def test_load_bytes_of_missing_file_is_empty(store): + assert store.age.load_bytes(GLOBAL) == b'' + assert store.plaintext.load_bytes(GLOBAL) == b'' + + +def test_dict_save_normalizes_what_bytes_save_preserved(store): + """辞書経由で保存し直すと従来どおり正規化される (原文は残らない)""" + store.plaintext.save_bytes(GLOBAL, RAW) + values = store.plaintext.load(GLOBAL) + store.plaintext.save(GLOBAL, values) + + assert b'# comment' not in store.plaintext.load_bytes(GLOBAL) + assert store.plaintext.load(GLOBAL) == values + + +def test_age_load_with_wrong_identity_raises(tmp_path, keypair): + public, _ = keypair + other = tmp_path / 'other.key' + other.write_text(str(pyrage.x25519.Identity.generate())) + + writer = AgeBackend(tmp_path, recipients=[public]) + writer.save(GLOBAL, SAMPLE) + + reader = AgeBackend(tmp_path, identities=[str(other)]) + with pytest.raises(SecretStoreError, match='復号'): + reader.load(GLOBAL) + + +def test_age_load_wraps_invalid_utf8_plaintext(tmp_path, keypair): + """復号は通ったが中身が UTF-8 でない場合も SecretStoreError にする。 + + 素の UnicodeDecodeError が漏れると、呼び出し側は DevbaseError だけを捕まえて + いるためトレースバックのまま落ちる。PlaintextBackend.load と例外を揃える。 + """ + from devbase.env import cipher as _cipher + + public, secret = keypair + id_path = tmp_path / 'identity.key' + id_path.write_text(secret) + + backend = AgeBackend(tmp_path, recipients=[public], + identities=[str(id_path)]) + path = backend.path(GLOBAL) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(_cipher.encrypt(b'\xff\xfe not utf-8', recipients=[public])) + + with pytest.raises(SecretStoreError, match='UTF-8'): + backend.load(GLOBAL) + + +# --------------------------------------------------------------------------- +# 自動判定 +# --------------------------------------------------------------------------- + +def test_mode_is_absent_when_nothing_exists(store): + assert store.mode(GLOBAL) == MODE_ABSENT + assert store.exists(GLOBAL) is False + + +def test_mode_is_plaintext_when_only_plain_exists(store): + store.plaintext.save(GLOBAL, SAMPLE) + assert store.mode(GLOBAL) == MODE_PLAINTEXT + assert store.load(GLOBAL) == SAMPLE + + +def test_mode_is_age_when_only_encrypted_exists(store): + store.age.save(GLOBAL, SAMPLE) + assert store.mode(GLOBAL) == MODE_AGE + assert store.is_encrypted(GLOBAL) is True + assert store.load(GLOBAL) == SAMPLE + + +def test_both_present_is_an_error(store): + store.plaintext.save(GLOBAL, SAMPLE) + store.age.save(GLOBAL, SAMPLE) + + with pytest.raises(SecretStoreError, match='両方に存在'): + store.load(GLOBAL) + with pytest.raises(SecretStoreError, match='両方に存在'): + store.mode(GLOBAL) + + +def test_both_present_error_names_both_paths(store): + store.plaintext.save(GLOBAL, SAMPLE) + store.age.save(GLOBAL, SAMPLE) + with pytest.raises(SecretStoreError) as exc: + store.path(GLOBAL) + message = str(exc.value) + assert str(store.age.path(GLOBAL)) in message + assert str(store.plaintext.path(GLOBAL)) in message + + +def test_save_keeps_the_existing_format(store): + """set / sync 相当の保存が形式を勝手に変えない""" + store.age.save(GLOBAL, SAMPLE) + store.save(GLOBAL, {**SAMPLE, 'NEW': '1'}) + + assert store.mode(GLOBAL) == MODE_AGE + assert store.plaintext.path(GLOBAL).exists() is False + assert store.load(GLOBAL)['NEW'] == '1' + + +def test_save_defaults_to_plaintext_for_new_refs(store): + store.save(GLOBAL, SAMPLE) + assert store.mode(GLOBAL) == MODE_PLAINTEXT + + +# --------------------------------------------------------------------------- +# 削除・一覧 +# --------------------------------------------------------------------------- + +def test_remove_reports_whether_a_file_was_deleted(store): + store.age.save(GLOBAL, SAMPLE) + assert store.age.remove(GLOBAL) is True + assert store.age.remove(GLOBAL) is False + + +def test_project_names_lists_encrypted_projects_only(store): + store.age.save(SecretRef.for_project('web'), {'A': '1'}) + store.age.save(SecretRef.for_project('api'), {'B': '2'}) + store.plaintext.save(SecretRef.for_project('legacy'), {'C': '3'}) + + assert store.project_names() == ['api', 'web'] + + +def test_project_names_empty_without_secrets_dir(store): + assert store.project_names() == [] + + +# --------------------------------------------------------------------------- +# 鍵未整備時のエラー +# --------------------------------------------------------------------------- + +def test_age_save_without_recipients_raises(tmp_path, monkeypatch): + from devbase.env import agekeys + + monkeypatch.setenv(agekeys.KEY_FILE_ENV, str(tmp_path / 'absent' / 'keys.txt')) + backend = AgeBackend(tmp_path) + with pytest.raises(agekeys.AgeKeyError, match='公開鍵がありません'): + backend.save(GLOBAL, SAMPLE) + + +def test_age_load_without_identities_raises(tmp_path, keypair, monkeypatch): + from devbase.env import agekeys + + public, _ = keypair + AgeBackend(tmp_path, recipients=[public]).save(GLOBAL, SAMPLE) + + monkeypatch.setenv(agekeys.KEY_FILE_ENV, str(tmp_path / 'absent' / 'keys.txt')) + monkeypatch.setattr(agekeys._cipher, 'default_identity_paths', lambda: []) + with pytest.raises(SecretStoreError, match='秘密鍵が見つかりません'): + AgeBackend(tmp_path).load(GLOBAL) + + +def test_plaintext_load_of_binary_reports_a_useful_error(store): + path = store.plaintext.path(GLOBAL) + path.write_bytes(b'\xff\xfe\x00binary') + with pytest.raises(SecretStoreError, match='UTF-8'): + store.plaintext.load(GLOBAL) + + +# --------------------------------------------------------------------------- +# 保存の原子性 +# +# 暗号文を失うと機密は復旧できない。既存ファイルを直接 truncate せず、一時ファイル +# → os.replace で差し替えているので、書き込みの途中で落ちても旧内容が残る。 +# --------------------------------------------------------------------------- + +def _fail_replace(monkeypatch, exc): + """``os.replace`` だけを失敗させる (差し替え直前までは正常に進む)""" + from devbase.env import io_common + + def boom(src, dst): + raise exc + + monkeypatch.setattr(io_common.os, 'replace', boom) + + +def test_age_save_keeps_the_old_ciphertext_when_replace_fails(store, monkeypatch): + store.age.save(GLOBAL, SAMPLE) + path = store.age.path(GLOBAL) + before = path.read_bytes() + + _fail_replace(monkeypatch, OSError(28, 'No space left on device')) + + with pytest.raises(SecretStoreError, match='書き込みに失敗'): + store.age.save(GLOBAL, {**SAMPLE, 'NEW': '1'}) + + # 旧 ciphertext が無傷 = まだ旧内容を復号できる + assert path.read_bytes() == before + monkeypatch.undo() + assert store.age.load(GLOBAL) == SAMPLE + + +def test_age_save_leaves_no_temp_file_when_replace_fails(store, monkeypatch): + store.age.save(GLOBAL, SAMPLE) + path = store.age.path(GLOBAL) + + _fail_replace(monkeypatch, OSError(28, 'No space left on device')) + + with pytest.raises(SecretStoreError): + store.age.save(GLOBAL, {**SAMPLE, 'NEW': '1'}) + + # 書きかけの一時ファイル (中身は新しい暗号文) を放置しない + assert sorted(p.name for p in path.parent.iterdir()) == [path.name] + + +def test_age_save_keeps_the_old_ciphertext_when_interrupted(store, monkeypatch): + """KeyboardInterrupt のような BaseException でも旧内容と後始末は変わらない""" + store.age.save(GLOBAL, SAMPLE) + path = store.age.path(GLOBAL) + before = path.read_bytes() + + _fail_replace(monkeypatch, KeyboardInterrupt()) + + with pytest.raises(KeyboardInterrupt): + store.age.save(GLOBAL, {**SAMPLE, 'NEW': '1'}) + + assert path.read_bytes() == before + assert sorted(p.name for p in path.parent.iterdir()) == [path.name] + + +def test_plaintext_save_keeps_the_old_content_when_replace_fails(store, + monkeypatch): + store.plaintext.save(GLOBAL, SAMPLE) + path = store.plaintext.path(GLOBAL) + before = path.read_bytes() + + _fail_replace(monkeypatch, OSError(28, 'No space left on device')) + + with pytest.raises(SecretStoreError, match='書き込みに失敗'): + store.plaintext.save(GLOBAL, {**SAMPLE, 'NEW': '1'}) + + assert path.read_bytes() == before + + +def test_age_save_is_atomic_across_updates(store): + """通常経路では差し替えが成功し、一時ファイルも残らない""" + store.age.save(GLOBAL, SAMPLE) + store.age.save(GLOBAL, {**SAMPLE, 'NEW': '1'}) + + path = store.age.path(GLOBAL) + assert sorted(p.name for p in path.parent.iterdir()) == [path.name] + assert store.age.load(GLOBAL)['NEW'] == '1' + assert stat.S_IMODE(path.stat().st_mode) == 0o600 diff --git a/tests/env/test_store_roundtrip.py b/tests/env/test_store_roundtrip.py new file mode 100644 index 00000000..e336c073 --- /dev/null +++ b/tests/env/test_store_roundtrip.py @@ -0,0 +1,142 @@ +"""export / import が暗号化された保存先を壊さないことの検証 + +移行後の環境で ``devbase env import`` が平文の ``.env`` を作ってしまうと、 +暗号化ファイルと平文が同時に存在する状態になり、以後どちらが正か判断できなく +なる (plan35 §9)。往復しても保存形式が保たれることを確かめる。 +""" + +from __future__ import annotations + +import pyrage +import pytest + +from devbase.env import agekeys +from devbase.env.io_export import ExportOptions, export +from devbase.env.io_import import ImportOptions, import_bundle +from devbase.env.secret_store import SecretRef, SecretStore + + +GLOBAL = SecretRef.for_global() +WEB = SecretRef.for_project('web') + + +@pytest.fixture +def keypair(tmp_path): + identity = pyrage.x25519.Identity.generate() + path = tmp_path / 'bundle.key' + path.write_text(str(identity)) + return str(identity.to_public()), str(path) + + +@pytest.fixture +def root(tmp_path, monkeypatch): + root = tmp_path / 'devbase' + (root / 'projects' / 'web').mkdir(parents=True) + monkeypatch.setenv(agekeys.KEY_FILE_ENV, str(tmp_path / 'age' / 'keys.txt')) + monkeypatch.setenv('PWD', str(root)) + monkeypatch.chdir(root) + agekeys.generate_key_file() + return root + + +def do_export(root, dest, recipient): + return export(root, ExportOptions(dest=str(dest), recipients=[recipient])) + + +def do_import(root, source, identity, **kwargs): + return import_bundle(root, ImportOptions( + source=str(source), identities=[identity], **kwargs)) + + +def test_export_reads_the_encrypted_store(root, keypair, tmp_path): + public, key = keypair + store = SecretStore(root) + store.age.save(GLOBAL, {'TOKEN': 'sk-1'}) + store.age.save(WEB, {'DB_PASSWORD': 'pw'}) + + bundle = tmp_path / 'b.dbenv' + assert do_export(root, bundle, public) == 0 + + # 別の空の DEVBASE_ROOT へ取り込むと平文で復元される (移行前と同じ形) + other = tmp_path / 'other' + (other / 'projects' / 'web').mkdir(parents=True) + assert do_import(other, bundle, key) == 0 + assert SecretStore(other).load(GLOBAL) == {'TOKEN': 'sk-1'} + assert SecretStore(other).load(WEB) == {'DB_PASSWORD': 'pw'} + + +def test_import_keeps_the_destination_encrypted(root, keypair, tmp_path): + public, key = keypair + store = SecretStore(root) + store.age.save(GLOBAL, {'TOKEN': 'old', 'KEEP': '1'}) + + bundle = tmp_path / 'b.dbenv' + do_export(root, bundle, public) + + # バンドルの値を書き換えて取り込む + store.age.save(GLOBAL, {'TOKEN': 'newer'}) + assert do_import(root, bundle, key, merge='prefer-incoming') == 0 + + assert store.is_encrypted(GLOBAL) + assert not (root / '.env').exists() # 平文が生まれていない + assert store.load(GLOBAL)['TOKEN'] == 'old' # バンドル側が勝つ + assert store.load(GLOBAL)['KEEP'] == '1' + + +def test_import_merges_into_the_encrypted_store(root, keypair, tmp_path): + public, key = keypair + store = SecretStore(root) + store.age.save(GLOBAL, {'FROM_BUNDLE': 'b'}) + + bundle = tmp_path / 'b.dbenv' + do_export(root, bundle, public) + + store.age.save(GLOBAL, {'LOCAL_ONLY': 'l'}) + assert do_import(root, bundle, key) == 0 + + merged = store.load(GLOBAL) + assert merged == {'LOCAL_ONLY': 'l', 'FROM_BUNDLE': 'b'} + + +def test_import_backups_are_ciphertext(root, keypair, tmp_path): + """取り込み前の控えが平文でディスクに残らない (plan35 §2.3)""" + public, key = keypair + store = SecretStore(root) + store.age.save(GLOBAL, {'TOKEN': 'secret-value'}) + + bundle = tmp_path / 'b.dbenv' + do_export(root, bundle, public) + do_import(root, bundle, key, merge='prefer-incoming') + + backups = list((root / 'backups' / 'env-import').rglob('*')) + files = [p for p in backups if p.is_file()] + assert files, '控えが作られていない' + for path in files: + assert b'secret-value' not in path.read_bytes(), path + + +def test_import_into_a_plaintext_store_stays_plaintext(root, keypair, tmp_path): + """移行していない環境では従来どおり平文へ書く""" + public, key = keypair + store = SecretStore(root) + store.plaintext.save(GLOBAL, {'TOKEN': 'sk-1'}) + + bundle = tmp_path / 'b.dbenv' + do_export(root, bundle, public) + assert do_import(root, bundle, key, merge='prefer-incoming') == 0 + + assert (root / '.env').exists() + assert not (root / 'secrets' / 'global.env.age').exists() + + +def test_import_creates_plaintext_when_nothing_exists(root, keypair, tmp_path): + """まだ何も無い参照は平文に落ちる (暗号化は encrypt の役目)""" + public, key = keypair + SecretStore(root).plaintext.save(GLOBAL, {'TOKEN': 'sk-1'}) + bundle = tmp_path / 'b.dbenv' + do_export(root, bundle, public) + + other = tmp_path / 'fresh' + other.mkdir() + assert do_import(other, bundle, key) == 0 + assert (other / '.env').exists() diff --git a/tests/volume/test_compose_secret_env.py b/tests/volume/test_compose_secret_env.py new file mode 100644 index 00000000..82894c46 --- /dev/null +++ b/tests/volume/test_compose_secret_env.py @@ -0,0 +1,459 @@ +"""生成する構成ファイルへの機密の渡し方 (変数名のみの列挙)""" + +from __future__ import annotations + +import pytest +import yaml + +from devbase.volume.compose import generate_scaled_compose + + +COMPOSE = """services: + dev: + image: alpine + env_file: + - ${DEVBASE_ROOT}/.env + - env + environment: + FEATURE_FLAG: enabled + DB_PASSWORD: has-a-value + volumes: + - x:/work + db: + image: mysql +volumes: + x: {} +""" + +COMPOSE_LIST_ENV = """services: + dev: + image: alpine + environment: + - FEATURE_FLAG=enabled + - DB_PASSWORD=has-a-value + - PASSTHROUGH + volumes: + - x:/work +volumes: + x: {} +""" + +COMPOSE_NO_ENV = """services: + dev: + image: alpine + volumes: + - x:/work +volumes: + x: {} +""" + + +@pytest.fixture +def project(tmp_path, monkeypatch): + (tmp_path / 'compose.yml').write_text(COMPOSE) + (tmp_path / 'env').write_text('GIT_REPO=web\n') + monkeypatch.setenv('DEVBASE_ROOT', str(tmp_path / 'root')) + (tmp_path / 'root').mkdir() + monkeypatch.chdir(tmp_path) + return tmp_path + + +@pytest.fixture +def project_factory(tmp_path, monkeypatch): + """任意の compose.yml でプロジェクトを組み立てる""" + def build(compose_text): + (tmp_path / 'compose.yml').write_text(compose_text) + monkeypatch.setenv('DEVBASE_ROOT', str(tmp_path / 'root')) + (tmp_path / 'root').mkdir(exist_ok=True) + monkeypatch.chdir(tmp_path) + return tmp_path + return build + + +def generated(path): + return yaml.safe_load((path / '.docker-compose.scale.yml').read_text()) + + +def test_secret_names_are_listed_without_values(project): + generate_scaled_compose(1, secret_env_names=['ANTHROPIC_API_KEY', 'DB_PASSWORD']) + + # 元が map 形式なら map のまま。機密キーだけ値なし参照 (None) になる + assert generated(project)['services']['dev-1']['environment'] == { + 'FEATURE_FLAG': 'enabled', + 'DB_PASSWORD': None, + 'ANTHROPIC_API_KEY': None, + } + + +def test_non_secret_environment_is_preserved_in_list_form(project_factory): + path = project_factory(COMPOSE_LIST_ENV) + + generate_scaled_compose(1, secret_env_names=['DB_PASSWORD', 'ANTHROPIC_API_KEY']) + + # 元が list 形式なら list のまま。機密キーは裸のキー名へ落とす + assert generated(path)['services']['dev-1']['environment'] == [ + 'FEATURE_FLAG=enabled', + 'DB_PASSWORD', + 'PASSTHROUGH', + 'ANTHROPIC_API_KEY', + ] + assert 'has-a-value' not in (path / '.docker-compose.scale.yml').read_text() + + +def test_generated_file_contains_no_secret_values(project): + generate_scaled_compose(1, secret_env_names=['ANTHROPIC_API_KEY', 'DB_PASSWORD']) + + text = (project / '.docker-compose.scale.yml').read_text() + assert 'ANTHROPIC_API_KEY' in text + # 機密キーの値は生成物に残さない。非機密の固定値はそのまま残す + assert 'has-a-value' not in text + assert 'enabled' in text + + +def test_every_instance_gets_the_names(project): + generate_scaled_compose(3, secret_env_names=['TOKEN']) + + config = generated(project) + for index in (1, 2, 3): + assert config['services'][f'dev-{index}']['environment']['TOKEN'] is None + + +def test_no_environment_section_without_secrets(project_factory): + path = project_factory(COMPOSE_NO_ENV) + + generate_scaled_compose(1, secret_env_names=[]) + + assert 'environment' not in generated(path)['services']['dev-1'] + + +def test_names_are_listed_when_original_has_no_environment(project_factory): + path = project_factory(COMPOSE_NO_ENV) + + generate_scaled_compose(1, secret_env_names=['ANTHROPIC_API_KEY', 'TOKEN']) + + assert generated(path)['services']['dev-1']['environment'] == [ + 'ANTHROPIC_API_KEY', 'TOKEN'] + + +def test_missing_env_file_entries_are_dropped(project): + """暗号化で平文が無くなった参照を残すと Compose が起動時に落ちる""" + generate_scaled_compose(1, secret_env_names=['TOKEN']) + + config = generated(project) + assert config['services']['dev-1']['env_file'] == ['env'] + + +def test_missing_non_secret_env_file_entries_are_kept(project_factory): + """機密以外の欠落は隠さない (タイプミスや未配置を Compose に知らせる)""" + path = project_factory("""services: + dev: + image: alpine + env_file: + - ${DEVBASE_ROOT}/.env + - config/app.env + - .env +""") + + generate_scaled_compose(1, secret_env_names=['TOKEN']) + + # 既知の機密参照 (${DEVBASE_ROOT}/.env, .env) だけが落ち、残りは残る + assert generated(path)['services']['dev-1']['env_file'] == ['config/app.env'] + + +def test_existing_env_file_entries_are_kept(project): + (project / 'root' / '.env').write_text('TOKEN=x\n') + + generate_scaled_compose(1, secret_env_names=['TOKEN']) + + config = generated(project) + assert config['services']['dev-1']['env_file'] == [ + '${DEVBASE_ROOT}/.env', 'env'] + + +def test_env_file_key_is_removed_when_nothing_remains(tmp_path, monkeypatch): + (tmp_path / 'compose.yml').write_text("""services: + dev: + image: alpine + env_file: + - ${DEVBASE_ROOT}/.env +""") + monkeypatch.setenv('DEVBASE_ROOT', str(tmp_path / 'root')) + (tmp_path / 'root').mkdir() + monkeypatch.chdir(tmp_path) + + generate_scaled_compose(1, secret_env_names=['TOKEN']) + + config = yaml.safe_load((tmp_path / '.docker-compose.scale.yml').read_text()) + assert 'env_file' not in config['services']['dev-1'] + + +def test_unresolvable_env_file_entries_are_left_alone(tmp_path, monkeypatch): + """未定義の変数を含む参照は存在判定できないので触らない""" + (tmp_path / 'compose.yml').write_text("""services: + dev: + image: alpine + env_file: + - ${SOME_UNDEFINED_ROOT}/.env +""") + monkeypatch.delenv('SOME_UNDEFINED_ROOT', raising=False) + monkeypatch.chdir(tmp_path) + + generate_scaled_compose(1) + + config = yaml.safe_load((tmp_path / '.docker-compose.scale.yml').read_text()) + assert config['services']['dev-1']['env_file'] == ['${SOME_UNDEFINED_ROOT}/.env'] + + +def test_non_dev_services_are_untouched(project): + """機密ファイルを参照していないサービスには余計な変数を注入しない""" + generate_scaled_compose(1, secret_env_names=['TOKEN']) + + config = generated(project) + assert 'environment' not in config['services']['db'] + + +# --------------------------------------------------------------------------- +# 元々機密ファイルを参照していた非 dev サービスへの受け渡し +# --------------------------------------------------------------------------- + +COMPOSE_DB_WITH_SECRET = """services: + dev: + image: alpine + volumes: + - x:/work + db: + image: mysql + env_file: + - ${DEVBASE_ROOT}/.env + environment: + MYSQL_DATABASE: app + cache: + image: redis + env_file: + - config/app.env +volumes: + x: {} +""" + + +def test_non_dev_service_with_a_secret_reference_gets_the_names(project_factory): + """DB パスワードを env_file から受け取っていたサービスに機密を渡す""" + path = project_factory(COMPOSE_DB_WITH_SECRET) + + generate_scaled_compose(1, secret_env_names=['DB_PASSWORD']) + + config = generated(path) + # 非機密の値は残したまま、機密は値なし参照として列挙される + assert config['services']['db']['environment'] == { + 'MYSQL_DATABASE': 'app', + 'DB_PASSWORD': None, + } + # 機密を参照していないサービスには注入しない + assert 'environment' not in config['services']['cache'] + + +def test_commented_out_references_still_receive_the_secrets(project_factory): + """移行後は参照がコメントアウトされる。YAML から消えても渡し先は変えない""" + from devbase.env import compose_migrate + + disabled, _ = compose_migrate.disable(COMPOSE_DB_WITH_SECRET) + path = project_factory(disabled) + + generate_scaled_compose(1, secret_env_names=['DB_PASSWORD']) + + config = generated(path) + assert config['services']['db']['environment']['DB_PASSWORD'] is None + assert 'environment' not in config['services']['cache'] + + +# --------------------------------------------------------------------------- +# 由来 (共通 / プロジェクト) ごとの絞り込み +# --------------------------------------------------------------------------- + +COMPOSE_MIXED_ORIGINS = """services: + dev: + image: alpine + env_file: + - ${DEVBASE_ROOT}/.env + - env + - .env + volumes: + - x:/work + global_only: + image: mysql + env_file: + - ${DEVBASE_ROOT}/.env + project_only: + image: redis + env_file: + - .env + both: + image: nginx + env_file: + - ${DEVBASE_ROOT}/.env + - .env + none: + image: busybox +volumes: + x: {} +""" + +ORIGINS = dict( + secret_env_names=['SHARED_KEY', 'PROJECT_TOKEN'], + global_env_names=['SHARED_KEY'], + project_env_names=['PROJECT_TOKEN'], +) + + +def _env_names(service_config): + """map / list どちらの記法でも、列挙された変数名を集合で返す""" + environment = service_config.get('environment') + if isinstance(environment, dict): + return set(environment) + return {item.split('=', 1)[0] for item in (environment or [])} + + +def test_global_only_service_gets_only_global_names(project_factory): + """共通の .env だけを読んでいたサービスにプロジェクト固有の機密は渡さない""" + path = project_factory(COMPOSE_MIXED_ORIGINS) + + generate_scaled_compose(1, **ORIGINS) + + assert _env_names(generated(path)['services']['global_only']) == {'SHARED_KEY'} + + +def test_project_only_service_gets_only_project_names(project_factory): + path = project_factory(COMPOSE_MIXED_ORIGINS) + + generate_scaled_compose(1, **ORIGINS) + + assert _env_names(generated(path)['services']['project_only']) == { + 'PROJECT_TOKEN'} + + +def test_service_referencing_both_gets_every_name(project_factory): + path = project_factory(COMPOSE_MIXED_ORIGINS) + + generate_scaled_compose(1, **ORIGINS) + + config = generated(path) + assert _env_names(config['services']['both']) == { + 'SHARED_KEY', 'PROJECT_TOKEN'} + # dev は従来どおり全件 (env_file を書いていない構成でも両方が要る) + assert _env_names(config['services']['dev-1']) == { + 'SHARED_KEY', 'PROJECT_TOKEN'} + # 機密を参照していないサービスには何も注入しない + assert 'environment' not in config['services']['none'] + + +def test_origins_are_respected_after_migration(project_factory): + """移行で参照がコメントアウトされたあとも由来ごとの絞り込みを保つ""" + from devbase.env import compose_migrate + + disabled, _ = compose_migrate.disable(COMPOSE_MIXED_ORIGINS) + path = project_factory(disabled) + + generate_scaled_compose(1, **ORIGINS) + + config = generated(path) + assert _env_names(config['services']['global_only']) == {'SHARED_KEY'} + assert _env_names(config['services']['project_only']) == {'PROJECT_TOKEN'} + + +def test_without_the_split_every_receiver_gets_every_name(project_factory): + """由来の内訳が渡されない場合は従来どおり全件 (渡し漏れで壊さない)""" + path = project_factory(COMPOSE_MIXED_ORIGINS) + + generate_scaled_compose(1, secret_env_names=['SHARED_KEY', 'PROJECT_TOKEN']) + + config = generated(path) + assert _env_names(config['services']['global_only']) == { + 'SHARED_KEY', 'PROJECT_TOKEN'} + + +def test_unreadable_compose_falls_back_to_dev_only(project_factory, monkeypatch): + """生テキストを読めない場合は dev だけ・全件へフォールバックする""" + from pathlib import Path as _Path + + path = project_factory(COMPOSE_MIXED_ORIGINS) + + original = _Path.read_text + + def fail_on_compose(self, *args, **kwargs): + if self.name == 'compose.yml': + raise OSError('boom') + return original(self, *args, **kwargs) + + monkeypatch.setattr(_Path, 'read_text', fail_on_compose) + + generate_scaled_compose(1, **ORIGINS) + + config = generated(path) + assert _env_names(config['services']['dev-1']) == { + 'SHARED_KEY', 'PROJECT_TOKEN'} + for name in ('global_only', 'project_only', 'both', 'none'): + assert 'environment' not in config['services'][name] + + +# --------------------------------------------------------------------------- +# クォートされたサービス名 / long syntax の env_file +# --------------------------------------------------------------------------- + +COMPOSE_QUOTED_SERVICE = """services: + dev: + image: alpine + volumes: + - x:/work + "db": + image: mysql + env_file: + - ${DEVBASE_ROOT}/.env + 'cache': + image: redis + env_file: + - .env +volumes: + x: {} +""" + + +def test_quoted_service_names_receive_their_secrets(project_factory): + """`"db":` は PyYAML では `db`。引用符込みで拾うと機密が渡らない""" + path = project_factory(COMPOSE_QUOTED_SERVICE) + + generate_scaled_compose(1, **ORIGINS) + + config = generated(path) + # 生成後もサービス名はパース済みの姿 (引用符なし) + assert {'db', 'cache'} <= set(config['services']) + assert _env_names(config['services']['db']) == {'SHARED_KEY'} + assert _env_names(config['services']['cache']) == {'PROJECT_TOKEN'} + + +COMPOSE_LONG_SYNTAX = """services: + dev: + image: alpine + volumes: + - x:/work + db: + image: mysql + env_file: + - path: ${DEVBASE_ROOT}/.env + cache: + image: redis + env_file: + - path: config/app.env +volumes: + x: {} +""" + + +def test_long_syntax_reference_receives_its_secrets(project_factory): + """long syntax (`- path: ...`) の参照も由来つきで拾う""" + path = project_factory(COMPOSE_LONG_SYNTAX) + + generate_scaled_compose(1, **ORIGINS) + + config = generated(path) + assert _env_names(config['services']['db']) == {'SHARED_KEY'} + assert 'environment' not in config['services']['cache']