diff --git a/.gitignore b/.gitignore index d945644..b11348e 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ __pycache__/ .venv/ .env .env.backup +.gemini/ .docker-compose.scale.yml plugins.yml plugins/*/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 04229bf..6e950a7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,22 @@ ## [Unreleased] +### Added +- `devbase env export` / `devbase env import` で **S3 URI (`s3://bucket/key`) を入出力先として指定**できるようになりました (PLAN03-1 PR3)。 + - 既定でオブジェクト単位の SSE (`aws:kms` または `AES256`) を強制し、export 時はバケット側のデフォルト暗号化も `GetBucketEncryption` で事前確認します。 + - 暗号化が未設定のバケットへ export する場合は `--unsafe-allow-unencrypted-bucket` の明示が必要です (オブジェクト単位の SSE はこのフラグに関係なく常に付与されます)。 + - SSE 種別 (`DEVBASE_S3_SSE`) / KMS 鍵 (`DEVBASE_S3_SSE_KMS_KEY_ID`) / エンドポイント (`DEVBASE_S3_ENDPOINT_URL`) / リージョン (`DEVBASE_S3_REGION`) は環境変数で上書きできます。MinIO / LocalStack の利用も可能です。 + - `boto3` は main dependency として常に同梱されます (S3 を使わないユーザにも 25MB 程度入りますが、引数検出や lazy install の複雑さを避けるトレードオフです)。 +- `devbase env export` / `devbase env import` の利用者向けドキュメント [`docs/user/env-export-import.md`](docs/user/env-export-import.md) を新設しました (PLAN03-1 PR5)。 + - バンドル構造、age 暗号化 (recipient / identity / passphrase)、入出力先 (local / stdio / S3)、merge モード比較、`.env.sources.yml` の扱い、2 フェーズ書き込みとバックアップ、典型ワークフロー、トラブルシューティングまでを網羅します。 + - README と環境変数ガイドからのリンクも追加しました。 + +### Changed +- `gs://` (GCS) スキームは **PLAN03-1 PR4 廃案** により対応しません。指定すると明示的なエラーメッセージで失敗します (旧: "未実装")。 +- `lib/devbase/env/` 配下の export / import モジュールをリファクタリングしました (PLAN03-1 PR5)。公開 API (`ExportOptions`, `ImportOptions`, `export`, `import_bundle`) に互換性のない変更はありません。 + - export / import で重複していた passphrase 読み取り / 既定鍵 fallback / セキュアな bytes 書き込みを `io_common.py` に集約。 + - 711 行に肥大化していた `io_import.py` を「orchestration (`io_import.py`, 209 行)」「merge 計画 (`_import_merge.py`)」「2 フェーズ atomic 書き込み + backup GC (`_import_atomic.py`)」の 3 モジュールに分割。 + ## [2.2.0] - 2026-04-20 OSS 化に伴う初回リリース。devbase は本バージョンより `devbasex` Organization 配下で公開されます。 diff --git a/README.md b/README.md index 9f1cab5..9635598 100644 --- a/README.md +++ b/README.md @@ -76,7 +76,7 @@ devbaseのコマンドは4つのグループにまとめられています。 | グループ | 略記 | 説明 | |---------|------|------| | `container` | `ct` | コンテナ管理(up / down / login / ps / logs / scale / build) | -| `env` | — | 環境変数管理(init / sync / list / set / get / delete / edit / project) | +| `env` | — | 環境変数管理(init / sync / list / set / get / delete / edit / project / export / import) | | `plugin` | `pl` | プラグイン管理(list / install / uninstall / update / info / sync / repo) | | `snapshot` | `ss` | スナップショット管理(create / list / restore / copy / delete / rotate) | @@ -106,6 +106,7 @@ devbaseのコマンドは4つのグループにまとめられています。 | [CLIリファレンス](docs/user/cli-reference.md) | 全コマンドの構文・オプション・使用例 | | [プラグインレジストリ](docs/user/plugin-registries.md) | 公開・社内レジストリの一覧と追加方法 | | [環境変数ガイド](docs/user/environment-variables.md) | 3レベル構造、コレクター、ソース同期 | +| [環境変数の export/import ガイド](docs/user/env-export-import.md) | バンドル形式・age 暗号化・S3 連携・merge/replace の運用 | | [コンテナ操作ガイド](docs/user/container-operations.md) | ライフサイクル、並行開発、ボリューム構造 | | [スナップショットガイド](docs/user/snapshot-guide.md) | 増分バックアップ、世代管理、復元手順 | | [トラブルシューティング](docs/user/troubleshooting.md) | カテゴリ別の問題と解決策 | diff --git a/bin/migrate_ai_to_home.sh b/bin/migrate_ai_to_home.sh deleted file mode 100755 index 150b590..0000000 --- a/bin/migrate_ai_to_home.sh +++ /dev/null @@ -1,77 +0,0 @@ -#!/bin/bash -# devbase_ai_1 から devbase_home_ubuntu へのデータ移行スクリプト - -set -euo pipefail - -# 色付きログ関数 -log_info() { - echo -e "\033[0;32m[INFO]\033[0m $1" -} - -log_warn() { - echo -e "\033[0;33m[WARN]\033[0m $1" -} - -log_error() { - echo -e "\033[0;31m[ERROR]\033[0m $1" -} - -# ボリューム名 -SOURCE_VOLUME="devbase_ai_1" -TARGET_VOLUME="devbase_home_ubuntu" - -log_info "devbase_ai_1 -> devbase_home_ubuntu データ移行を開始します" - -# ソースボリュームの存在確認 -if ! docker volume inspect "$SOURCE_VOLUME" &>/dev/null; then - log_error "ソースボリューム '$SOURCE_VOLUME' が見つかりません" - exit 1 -fi - -log_info "✓ ソースボリューム '$SOURCE_VOLUME' を確認しました" - -# ターゲットボリュームの作成 -if docker volume inspect "$TARGET_VOLUME" &>/dev/null; then - log_warn "ターゲットボリューム '$TARGET_VOLUME' は既に存在します" - read -p "上書きしますか? (y/N): " -n 1 -r - echo - if [[ ! $REPLY =~ ^[Yy]$ ]]; then - log_info "移行を中止しました" - exit 0 - fi - log_info "既存のボリューム '$TARGET_VOLUME' を削除します" - docker volume rm "$TARGET_VOLUME" -fi - -log_info "ターゲットボリューム '$TARGET_VOLUME' を作成します" -docker volume create "$TARGET_VOLUME" - -# データコピー(一時コンテナを使用) -log_info "データをコピー中..." -docker run --rm \ - -v "$SOURCE_VOLUME:/source:ro" \ - -v "$TARGET_VOLUME:/target" \ - alpine sh -c "cp -a /source/. /target/" - -log_info "✓ データコピーが完了しました" - -# コピー結果の確認 -log_info "コピー結果を確認します" - -SOURCE_FILES=$(docker run --rm -v "$SOURCE_VOLUME:/data:ro" alpine find /data -type f | wc -l) -TARGET_FILES=$(docker run --rm -v "$TARGET_VOLUME:/data:ro" alpine find /data -type f | wc -l) - -log_info " ソースファイル数: $SOURCE_FILES" -log_info " ターゲットファイル数: $TARGET_FILES" - -if [ "$SOURCE_FILES" -eq "$TARGET_FILES" ]; then - log_info "✓ ファイル数が一致しています" -else - log_warn "! ファイル数が一致しません(差分: $((TARGET_FILES - SOURCE_FILES)))" -fi - -log_info "移行が完了しました" -log_info "" -log_info "次のステップ:" -log_info " 1. devbaseコードを更新してdevbase_home_ubuntuを使用するようにする" -log_info " 2. 動作確認後、古いボリュームを削除: docker volume rm $SOURCE_VOLUME" diff --git a/docs/user/env-export-import.md b/docs/user/env-export-import.md new file mode 100644 index 0000000..6f515d5 --- /dev/null +++ b/docs/user/env-export-import.md @@ -0,0 +1,456 @@ +# 環境変数の export / import ガイド + +`devbase env export` / `devbase env import` は、複数プロジェクトにまたがる `.env` 群を **暗号化したまま 1 つのバンドル**にまとめ、別マシン・別ストレージ・チーム内で再利用するためのコマンドです。 + +> **どんなときに使うか** +> - 新しい開発マシン / WSL / コンテナで `devbase` を再構築するときに、認証情報・API キー一式を一括移植したい +> - チームで「同じ環境」を共有したい (S3 経由でローテ済みクレデンシャルを配布する等) +> - 個別 `.env` を `scp -r` する代わりに、機密を暗号化したまま安全に転送したい + +## 目次 + +- [概要 / 対象ファイル](#概要--対象ファイル) +- [クイックスタート](#クイックスタート) +- [バンドル構造](#バンドル構造) +- [暗号化 (age)](#暗号化-age) +- [入出力先 (ローカル / stdio / S3)](#入出力先-ローカル--stdio--s3) +- [`devbase env export` リファレンス](#devbase-env-export-リファレンス) +- [`devbase env import` リファレンス](#devbase-env-import-リファレンス) +- [`.env.sources.yml` の扱い](#envsourcesyml-の扱い) +- [バックアップとロールバック](#バックアップとロールバック) +- [典型ワークフロー](#典型ワークフロー) +- [トラブルシューティング](#トラブルシューティング) + +## 概要 / 対象ファイル + +`devbase env export` がバンドルに含めるのは以下の 3 種類のファイルです: + +| ファイル | 役割 | 機密性 | 既定で含む | +|---|---|---|:---:| +| `$DEVBASE_ROOT/.env` | グローバル変数 (`AWS_CONFIG_BASE64` などの認証情報) | 高 | ✓ | +| `$DEVBASE_ROOT/projects//.env` | プロジェクト固有変数 (API キー / DB パスワード等) | 高 | ✓ | +| `$DEVBASE_ROOT/.env.sources.yml` | コレクターの同期メタデータ (ファイルハッシュ / 同期時刻) | 中 | ✓ | + +`$DEVBASE_ROOT/projects//env` (公開可能な雛形, git 管理対象) は **対象外** です。雛形は git で配布する設計のためバンドルに含めません。 + +3 レベル構造の全体像は [環境変数ガイド](environment-variables.md) を参照してください。 + +## クイックスタート + +```bash +# 既存マシンで export (~/.ssh/id_ed25519.pub があれば暗号化キー指定省略可) +devbase env export ./bundle.dbenv + +# バンドルを転送 (scp / S3 / メール添付など、暗号化済みなので経路は問わない) +scp ./bundle.dbenv newhost:/tmp/ + +# 新しいマシンで import (~/.ssh/id_ed25519 があれば identity 指定省略可) +ssh newhost +devbase env import /tmp/bundle.dbenv +``` + +import は **既定で `merge=keep-existing`** です。既存の `.env` に同じキーがあれば**保持**し、新規キーだけが追加されます。確認したいときは: + +```bash +devbase env import /tmp/bundle.dbenv --dry-run +``` + +書き込みは行わず、追加 / 上書き / スキップされるキーが一覧表示されます。 + +## バンドル構造 + +バンドルファイル ( `*.dbenv` または `*.dbenv.tar.gz` ) は内部的に **tar.gz**、外側を **age** で暗号化した 1 つのファイルです。 + +``` +manifest.yml # version / created_at / 各ファイルの sha256 +env/global.env # $DEVBASE_ROOT/.env をそのままコピー +env/sources.yml # .env.sources.yml (--no-metadata で除外可) +env/projects//.env +... +``` + +`manifest.yml` の例: + +```yaml +version: 1 +created_at: '2026-05-21T10:00:00+09:00' +devbase_version: 2.2.0 +files: + - path: env/global.env + sha256: <64 文字 hex> + origin: $DEVBASE_ROOT/.env + - path: env/projects/carmo/.env + sha256: <64 文字 hex> + origin: $DEVBASE_ROOT/projects/carmo/.env +``` + +import 時は以下を検証します: + +- `manifest.version` が devbase 本体のサポート最大値以下であること +- 各ファイルの sha256 が manifest と一致すること +- manifest に記載のないファイルがバンドル内に存在しないこと + +`version` が大きすぎる場合 (= 新しい devbase で作られたバンドルを古い devbase で開いた場合) は明示的にエラーになり、devbase 本体の更新を促すメッセージが出ます。 + +## 暗号化 (age) + +devbase は [age](https://age-encryption.org/) (`pyrage` 同梱) でバンドルを暗号化します。鍵の渡し方は **recipient 公開鍵** / **identity 秘密鍵** / **passphrase** の 3 通りで、export と import で対称に使い分けます。 + +### 鍵種別 + +| 鍵 | recipient (export 用) | identity (import 用) | 備考 | +|---|---|---|---| +| age X25519 (`age-keygen` 生成) | `age1...` | `AGE-SECRET-KEY-1...` | age ネイティブ、最も推奨 | +| OpenSSH ed25519 (`~/.ssh/id_ed25519`) | `ssh-ed25519 AAAA...` | `~/.ssh/id_ed25519` | そのまま使える | +| OpenSSH RSA (`~/.ssh/id_rsa`) | `ssh-rsa AAAA...` | `~/.ssh/id_rsa` | そのまま使える | +| OpenSSH ECDSA / DSA | ✗ | ✗ | **age 非対応**。下記参照 | +| scrypt パスフレーズ | (鍵不要) | (鍵不要) | `--passphrase-env` / `--passphrase-stdin` | + +`--recipient` には公開鍵文字列を直接渡すか、`@PATH` でファイル参照できます (例: `--recipient @~/.ssh/id_ed25519.pub`)。`--identity` は秘密鍵ファイルのパスを渡します。 + +### 既定鍵 + +- export の `--recipient` 省略時: **`~/.ssh/id_ed25519.pub` → `~/.ssh/id_rsa.pub`** を順に探し、存在する公開鍵を使用 +- import の `--identity` 省略時: **`~/.ssh/id_ed25519` → `~/.ssh/id_rsa`** を順に探し、存在する秘密鍵を使用 + +どちらも存在しない場合はエラーになります。明示指定 (`--recipient` / `--identity`) するか、`age-keygen` で age 専用鍵を生成してください。 + +### ssh-ecdsa 鍵しか持っていない場合 + +age は ssh-ecdsa / ssh-dss に対応していません。`ssh-ed25519` をまだ持っていない場合は、いずれかの方法で鍵を用意してください: + +```bash +# 方法 1: ed25519 鍵を作る (汎用、SSH と兼用可) +ssh-keygen -t ed25519 + +# 方法 2: age 専用鍵を作る (この用途だけに使いたい場合) +age-keygen -o ~/.config/devbase/age.key +# 公開鍵は最後の行に "Public key: age1..." と表示される +``` + +### passphrase ベース + +CI など鍵配布が難しい環境では passphrase 方式が使えます。**コマンドラインに直接書かない** (プロセス一覧に残るため) のがルールです。 + +```bash +# 環境変数経由 +DEVBASE_BUNDLE_PASS='change-me-strong' devbase env export ./bundle.dbenv \ + --passphrase-env DEVBASE_BUNDLE_PASS + +# stdin 経由 (パイプ運用) +echo 'change-me-strong' | devbase env export ./bundle.dbenv --passphrase-stdin +``` + +> tty で `--passphrase-stdin` を指定した場合は `getpass` でエコー抑止された対話プロンプトに切り替わるので、パスフレーズが画面に出ません。 + +### 平文 export (デバッグ用途のみ) + +通常は暗号化必須ですが、デバッグや構造確認のためにあえて平文で書き出したい場合は `--force-unencrypted` を明示します: + +```bash +devbase env export ./bundle.dbenv.tar.gz --force-unencrypted +``` + +- 拡張子は意図的に `*.dbenv.tar.gz` (拡張子で暗号化有無を判別できるようにするため) +- ファイル中に `KEY` / `SECRET` / `TOKEN` / `PASSWORD` / `CREDENTIALS` / `BASE64` を含むキーが見つかると **強い警告**が出ます +- ファイルパーミッションは引き続き `0600` で書き出されます + +## 入出力先 (ローカル / stdio / S3) + +`DEST` / `SOURCE` には以下を指定できます: + +| 形式 | 例 | 用途 | +|---|---|---| +| ローカルファイル | `./bundle.dbenv`, `/tmp/x.dbenv` | 既定。1 ファイルとして保存 | +| `file://` URI | `file:///tmp/x.dbenv`, `file://localhost/tmp/x.dbenv` | URI 形式が必要なツールとの連携 | +| stdio | `-` | パイプ運用 (gpg / age と組み合わせる、ssh 経由など) | +| S3 URI | `s3://bucket/path/to/bundle.dbenv` | チーム共有 / クラウドバックアップ | + +### S3 の暗号化要件 + +S3 への書き込み時は以下が **常に** 適用されます (`--force-unencrypted` でも上書きできません): + +- オブジェクト個別の SSE (`aws:kms` 既定、`AES256` も選択可) +- export 前にバケット側の既定暗号化を `GetBucketEncryption` で確認 + - 未設定の場合は **export を拒否** (`--unsafe-allow-unencrypted-bucket` で明示的にバイパスは可能) + - `AccessDenied` で確認できない場合も既定では拒否 (権限を付けるか同フラグでバイパス) + +S3 関連の環境変数: + +| 変数 | 役割 | 既定 | +|---|---|---| +| `DEVBASE_S3_SSE` | オブジェクト単位の SSE 種別 (`aws:kms` / `AES256`) | `aws:kms` | +| `DEVBASE_S3_SSE_KMS_KEY_ID` | `aws:kms` 時の KMS 鍵 ID | (バケット既定) | +| `DEVBASE_S3_ENDPOINT_URL` | カスタムエンドポイント (MinIO / LocalStack 用) | (AWS S3) | +| `DEVBASE_S3_REGION` | リージョン上書き | (AWS SDK 設定に依存) | + +`AWS_PROFILE` / `AWS_REGION` / `AWS_ACCESS_KEY_ID` 等 boto3 が認識する標準変数はそのまま尊重されます。 + +### stdio (パイプ運用) + +`DEST='-'` / `SOURCE='-'` で stdout / stdin を使えます。GPG など別の暗号化ツールと組み合わせたい場合に便利です: + +```bash +# devbase の age 暗号化を切って GPG で再暗号化したい (極めて例外的な構成) +devbase env export - --force-unencrypted | gpg --encrypt -r alice@example.com > bundle.gpg + +# 逆方向 +gpg --decrypt bundle.gpg | devbase env import - +``` + +> **制約**: `SOURCE='-'` と `--passphrase-stdin` は **併用不可** (どちらも stdin を要求するため衝突します。import 側のみエラーになります)。`DEST='-'` (export) は stdin (passphrase) と stdout (bundle) で別ストリームを使うため `--passphrase-stdin` と併用できます。 + +## `devbase env export` リファレンス + +``` +devbase env export [DEST] [options] +``` + +### 引数 + +- `DEST`: 出力先。省略時は `./devbase-env-.dbenv` (`--force-unencrypted` 時は `.dbenv.tar.gz`) + +### オプション + +| オプション | 説明 | +|---|---| +| `--include-project NAME` | 対象プロジェクトを限定 (複数指定可) | +| `--exclude-project NAME` | 除外プロジェクト (複数指定可) | +| `--no-global` | グローバル `.env` を含めない | +| `--no-metadata` | `.env.sources.yml` を含めない | +| `--force-unencrypted` | 平文 tar.gz として書き出す (機密キー検知時は警告) | +| `--recipient KEY` | age 公開鍵で暗号化 (複数指定可)。`age1...` / `ssh-ed25519 ...` / `ssh-rsa ...` / `@PATH` | +| `--passphrase-env VAR` | 環境変数 VAR からパスフレーズ取得 | +| `--passphrase-stdin` | stdin の最初の行をパスフレーズとして使用 | +| `--unsafe-allow-unencrypted-bucket` | S3: バケット既定暗号化未設定でも export を許可 (オブジェクト単位の SSE は引き続き付与) | + +### 使用例 + +```bash +# 既定鍵 (~/.ssh/id_ed25519.pub or id_rsa.pub) で暗号化 +devbase env export ./bundle.dbenv + +# 複数 recipient (チームメンバー全員に配布) +devbase env export ./team.dbenv \ + --recipient @~/.ssh/id_ed25519.pub \ + --recipient 'age1abc...' \ + --recipient @charlie.pub + +# 特定プロジェクトのみ +devbase env export ./carmo.dbenv --include-project carmo + +# S3 に直接保存 (KMS 暗号化) +DEVBASE_S3_SSE_KMS_KEY_ID=alias/devbase \ + devbase env export s3://my-bucket/envs/2026-05-23.dbenv \ + --recipient @~/.ssh/id_ed25519.pub +``` + +## `devbase env import` リファレンス + +``` +devbase env import SOURCE [options] +``` + +### 引数 + +- `SOURCE`: 入力元。ローカルパス / `s3://...` / `-` (stdin) + +### オプション + +| オプション | 説明 | +|---|---| +| `--merge MODE` | キー単位マージ。`keep-existing` (既定) / `prefer-incoming` | +| `--replace-keys KEY,...` | 指定キーのみバンドル値で上書き (残りは keep-existing 相当) | +| `--replace` | 既存 `.env` を丸ごと差し替え (バックアップは取る) | +| `--dry-run` | 書き込まず差分のみ表示 | +| `--identity FILE` | age / OpenSSH 秘密鍵ファイル (複数指定可) | +| `--passphrase-env VAR` | 環境変数 VAR からパスフレーズ取得 | +| `--passphrase-stdin` | stdin の最初の行をパスフレーズとして使用 | +| `--include-project NAME` | 対象プロジェクトを限定 | +| `--exclude-project NAME` | 除外プロジェクト | +| `--no-global` | グローバル `.env` を import しない | +| `--no-metadata` | バンドル内 sources.yml を完全に無視 | +| `--merge-metadata` | sources.yml で新規 source エントリのみ追加 | +| `--backup-dir DIR` | 上書き前バックアップの保存先 (既定: `$DEVBASE_ROOT/backups/env-import/`) | +| `--keep-last N` | backup-dir 内の古い backup を最新 N 個に整理 (既定 10、0 で無効) | + +### merge モード比較 + +| モード | 既存にある同名キー | 既存に無いキー | 主な用途 | +|---|---|---|---| +| `--merge keep-existing` (既定) | **既存を残す** (skip) | 追加 | 既存環境を壊さず新規キーだけ取り込む | +| `--merge prefer-incoming` | バンドル値で上書き | 追加 | ローテ済みクレデンシャルを一斉配布 | +| `--replace-keys K1,K2,...` | K1/K2 のみ上書き、それ以外は keep-existing | 追加 | 特定キーだけピンポイント更新 | +| `--replace` | (ファイル単位で) バンドル内容で**丸ごと差し替え** | 追加 | クリーンな再同期 (バックアップ必須) | + +`--replace` 以外のモードは、既存 `.env` 内の **コメント・空行・キー順** を保持したまま値だけ差し替えます。 + +### 使用例 + +```bash +# 既定: 既存を保持しつつ新規キーのみ追加 +devbase env import ./bundle.dbenv + +# 何が起こるか先に見たい +devbase env import ./bundle.dbenv --dry-run + +# ローテ済み credentials を一斉配布 +devbase env import ./bundle.dbenv --merge prefer-incoming + +# 特定キーだけ更新 (例: AWS credentials のローテ) +devbase env import ./bundle.dbenv --replace-keys AWS_CONFIG_BASE64,AWS_SESSION_TOKEN + +# 特定プロジェクトだけ復元 +devbase env import ./bundle.dbenv --include-project carmo + +# S3 から取得 + passphrase で復号 +devbase env import s3://my-bucket/envs/2026-05-23.dbenv \ + --passphrase-env DEVBASE_BUNDLE_PASS +``` + +## `.env.sources.yml` の扱い + +`.env.sources.yml` には **マシン固有の絶対パス・同期時刻・元ファイルのハッシュ** が含まれます。別マシンでそのまま上書きすると整合性が壊れるため、以下のポリシーで扱います: + +- **既定**: import 時に既存 `.env.sources.yml` は **上書きしない**。バンドル内の sources.yml は `backups/env-import//sources.yml.imported` に参照用コピーのみ残す +- `--no-metadata`: バンドル内 sources.yml を完全に無視 (既定挙動と等価だが明示用) +- `--merge-metadata`: バンドル側で新規に登場する source エントリのみ追加 (既存エントリの `origin_path` / `synced_at` などのマシン固有値は再計算されず、import 先環境の値が保持される) + +## バックアップとロールバック + +import は部分適用を最小化するため **2 フェーズ書き込み** + **ファイル単位 backup** で動きます。 + +1. **Phase 0 (backup)**: 全対象ファイルを `backups/env-import//` にコピー (元ファイルが存在する場合のみ) +2. **Phase 1 (prepare)**: 全ファイルの新内容を `.import.tmp` に 0600 で書き出し、全件成功するまで rename しない +3. **Phase 2 (commit)**: 全 tmp の書き出し成功を確認してから `os.replace` で順次差し替え + +Phase 2 の途中で失敗した場合は backup から **best-effort で `_rollback()`** します。元ファイルが無かった (= 新規作成) ファイルは backup が無いので unlink で削除し、元の「不在」状態に戻します。 + +> **重要**: OS / FS の制約上、厳密な ACID は保証しません (途中の電源断やディスク full などは tmp 残骸を残しうる)。本来は別マシンへの初回投入のような大移動でのみ使い、稼働中の環境では `--dry-run` で確認してから実行してください。 + +### backup ディレクトリ + +``` +$DEVBASE_ROOT/backups/env-import/ + 20260523-101530-123456/ # ts (microsecond + 連番付き、衝突回避) + .env # 既存 global .env のコピー + projects/alpha/.env # 既存 project .env のコピー + sources.yml.imported # バンドル内 sources.yml の参照用コピー + 20260524-094210-456789/ + ... +``` + +ディレクトリ名はタイムスタンプ命名 (`YYYYMMDD-HHMMSS[-NNNNNN[-NN]]`)。同一秒に複数回 import しても上書きされません。 + +### 古い backup の GC + +`--keep-last N` (既定 10) で古い backup を自動 GC します: + +```bash +# 最新 5 個だけ残す +devbase env import ./bundle.dbenv --keep-last 5 + +# GC 無効化 +devbase env import ./bundle.dbenv --keep-last 0 +``` + +GC 対象は **devbase が生成するタイムスタンプ命名のディレクトリのみ**。`--backup-dir` で指定した親ディレクトリに無関係なファイル / サブディレクトリがあっても、それらは触らない設計です。 + +## 典型ワークフロー + +### A. 新しいマシンへ移行 + +```bash +# 既存マシン +devbase env export ~/devbase-2026-05-23.dbenv + +# 転送 (経路はなんでも良い、暗号化済み) +scp ~/devbase-2026-05-23.dbenv newhost:~ + +# 新マシン +ssh newhost +devbase env import ~/devbase-2026-05-23.dbenv +devbase env list # 復元確認 +``` + +### B. 単一マシンの定期バックアップ + +```bash +# cron で週次バックアップ (~/.ssh/id_ed25519.pub があれば鍵指定不要) +0 3 * * 0 cd /home/me/devbase && devbase env export \ + ~/backups/devbase-$(date +\%Y\%m\%d).dbenv +``` + +### C. S3 経由のチーム共有 + +```bash +# 管理者: ローテ済みクレデンシャルを team 全員の鍵で暗号化して S3 へ +devbase env export s3://team-secrets/devbase/latest.dbenv \ + --recipient @keys/alice.pub \ + --recipient @keys/bob.pub \ + --recipient @keys/charlie.pub + +# 各メンバー: 既存キーは保持しつつローテ分だけ更新 +devbase env import s3://team-secrets/devbase/latest.dbenv \ + --replace-keys AWS_CONFIG_BASE64,GCP_CREDENTIALS_BASE64_default +``` + +### D. CI でローテキーを配布 + +```bash +# CI ジョブ: secret manager から passphrase を取って復号 +export DEVBASE_BUNDLE_PASS=$(aws secretsmanager get-secret-value \ + --secret-id devbase/bundle-pass --query SecretString --output text) + +devbase env import s3://team-secrets/devbase/latest.dbenv \ + --passphrase-env DEVBASE_BUNDLE_PASS \ + --merge prefer-incoming +``` + +## トラブルシューティング + +### `バンドルは暗号化されていますが復号キーが指定されていません` + +`~/.ssh/id_ed25519` も `~/.ssh/id_rsa` も無く、`--identity` / `--passphrase-*` も指定されていない状態です。export 時に使った鍵に対応する秘密鍵を `--identity` で渡してください。 + +### `OpenSSH 秘密鍵の解釈に失敗しました` / `age は ssh-ed25519 / ssh-rsa のみ対応です` + +age が **ssh-ecdsa / ssh-dss に対応していない**ことが原因です。`age-keygen` で age 専用鍵を作るか、`ssh-keygen -t ed25519` で ed25519 鍵を作ってください。 + +### `passphrase 復号に失敗しました (パスフレーズが誤っている可能性があります)` + +パスフレーズが間違っているか、バンドルが破損しています。`--passphrase-env` で渡した変数の中身に余計な改行 / 空白が無いかを確認してください。 + +### `manifest.version=N はこの devbase ではサポートされていません` + +新しい devbase で作られたバンドルを古い devbase で開こうとしています。devbase 本体を更新してください。 + +### `S3 バケット 'X' のデフォルト暗号化が未設定です` + +S3 バケットに既定の SSE が設定されていません。以下のいずれかで対応: + +```bash +# 推奨: バケット側に SSE を有効化 +aws s3api put-bucket-encryption --bucket X --server-side-encryption-configuration ... + +# あるいは明示的にバイパス (オブジェクト単位の SSE は引き続き付与される) +devbase env export s3://X/key --unsafe-allow-unencrypted-bucket ... +``` + +### `平文 export に機密キーが含まれます` + +`--force-unencrypted` で平文 tar.gz を作ろうとし、`AWS_CONFIG_BASE64` などの機密キーが検出されました。**警告であり継続します**が、保管・転送時の暗号化を強く推奨します。 + +### `SOURCE='-' (stdin) と --passphrase-stdin は併用できません` + +stdin から同時に「バンドル本体」と「パスフレーズ」を読むことはできません。`--passphrase-env` で環境変数経由に切り替えるか、`SOURCE` をファイルパスにしてください。 + +### import 後に `.import.tmp` ファイルが残った + +Phase 2 (commit) の途中で異常終了した可能性があります。次回 import 時に同じファイル名で書き直すので、通常は自動的にクリーンアップされます。気になる場合は `find $DEVBASE_ROOT -name '*.import.tmp' -delete` で削除できます。 + +## 関連ドキュメント + +- [環境変数ガイド](environment-variables.md) — 3 レベル構造とコレクター +- [CLI リファレンス](cli-reference.md) — 全コマンド一覧 +- [はじめに](getting-started.md) — 初回セットアップ diff --git a/docs/user/environment-variables.md b/docs/user/environment-variables.md index 656ea8d..431d570 100644 --- a/docs/user/environment-variables.md +++ b/docs/user/environment-variables.md @@ -240,9 +240,22 @@ ls ~/.aws/ > **Warning:** 環境変数を変更した後は `devbase up` でコンテナを再起動してください。起動中のコンテナには反映されません。 +## 別マシンへの移行 / バックアップ + +複数プロジェクトの `.env` 群を 1 つのバンドルにまとめ、暗号化したまま転送・復元するには `devbase env export` / `devbase env import` を使います。詳細は [環境変数の export/import ガイド](env-export-import.md) を参照してください。 + +```bash +# 既存マシンで export (~/.ssh/id_ed25519.pub があれば鍵指定省略可) +devbase env export ./bundle.dbenv + +# 新マシンで import (既定は keep-existing マージ) +devbase env import ./bundle.dbenv +``` + ## ベストプラクティス 1. **機密情報は `.env` に格納する** -- Git 管理対象の `env` ファイルには機密情報を含めない 2. **プロジェクト固有の設定は `-p` フラグを使う** -- グローバル設定を汚染しない 3. **`env sync` を定期的に実行する** -- ホストマシンの認証情報更新後は必ず同期 4. **`.env.sources.yml` を Git 管理しない** -- 環境固有のハッシュ情報のため +5. **別マシンへの移行は `devbase env export` を使う** -- `scp -r` で個別コピーする代わりに、暗号化バンドル 1 ファイルで安全に移動できる ([詳細](env-export-import.md)) diff --git a/etc/_devbase b/etc/_devbase index 9df7250..cb3a5cb 100644 --- a/etc/_devbase +++ b/etc/_devbase @@ -73,6 +73,8 @@ _devbase() { 'delete:Delete a variable' 'edit:Open .env in editor' 'project:Setup project-specific variables' + 'export:Export .env files as an encrypted bundle (age)' + 'import:Import .env bundle (age decrypt + merge)' ) plugin_subcommands=( @@ -150,6 +152,37 @@ _devbase() { get|delete) _arguments '1:key:' ;; + export) + _arguments \ + '1:dest:_files' \ + '*--include-project[Limit to specified project (repeatable)]:name:' \ + '*--exclude-project[Exclude project (repeatable)]:name:' \ + '--no-global[Exclude $DEVBASE_ROOT/.env]' \ + '--no-metadata[Exclude $DEVBASE_ROOT/.env.sources.yml]' \ + '*--recipient[age / OpenSSH public key (repeatable)]:key:' \ + '--passphrase-env[Read passphrase from env var]:var:' \ + '--passphrase-stdin[Read passphrase from stdin]' \ + '--force-unencrypted[Write as plaintext tar.gz]' \ + '--unsafe-allow-unencrypted-bucket[Allow uploading unencrypted tar.gz to S3 (off by default)]' + ;; + import) + _arguments \ + '1:source:_files' \ + '--merge[Merge strategy]:mode:(keep-existing prefer-incoming)' \ + '--replace-keys[Replace only these keys (comma-separated)]:keys:' \ + '--replace[Replace existing files entirely]' \ + '--dry-run[Preview changes without writing]' \ + '*--identity[age / OpenSSH private key file (repeatable)]:file:_files' \ + '--passphrase-env[Read passphrase from env var]:var:' \ + '--passphrase-stdin[Read passphrase from stdin]' \ + '*--include-project[Limit to specified project (repeatable)]:name:' \ + '*--exclude-project[Exclude project (repeatable)]:name:' \ + '--no-global[Do not import $DEVBASE_ROOT/.env]' \ + '--no-metadata[Do not import $DEVBASE_ROOT/.env.sources.yml]' \ + '--merge-metadata[Add only new sources entries from bundle]' \ + '--backup-dir[Override backup directory]:dir:_files -/' \ + '--keep-last[Keep only the last N backup directories]:n:' + ;; *) _describe -t env-commands 'env command' env_subcommands ;; diff --git a/etc/devbase-completion.bash b/etc/devbase-completion.bash index cc62a91..fdba9df 100644 --- a/etc/devbase-completion.bash +++ b/etc/devbase-completion.bash @@ -12,7 +12,7 @@ _devbase_completions() { local commands="init status shell-rc container ct env plugin pl snapshot ss up down login build ps help" local container_subcommands="up down ps login logs scale build" - local env_subcommands="init sync list set get delete edit project" + local env_subcommands="init sync list set get delete edit project export import" 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" @@ -81,6 +81,16 @@ _devbase_completions() { COMPREPLY=($(compgen -W "--project -p" -- "$cur")) fi ;; + export) + if [[ "$cur" == -* ]]; then + COMPREPLY=($(compgen -W "--include-project --exclude-project --no-global --no-metadata --recipient --passphrase-env --passphrase-stdin --force-unencrypted --unsafe-allow-unencrypted-bucket" -- "$cur")) + fi + ;; + import) + if [[ "$cur" == -* ]]; then + COMPREPLY=($(compgen -W "--merge --replace-keys --replace --dry-run --identity --passphrase-env --passphrase-stdin --include-project --exclude-project --no-global --no-metadata --merge-metadata --backup-dir --keep-last" -- "$cur")) + fi + ;; esac fi # plugin subcommand arguments diff --git a/lib/devbase/cli.py b/lib/devbase/cli.py index 3201679..1e20f38 100644 --- a/lib/devbase/cli.py +++ b/lib/devbase/cli.py @@ -36,11 +36,23 @@ # Subcommand map for prefix resolution: {(aliases...): [subcmds]} SUBCMD_MAP = { ('container', 'ct'): ['up', 'down', 'ps', 'login', 'logs', 'scale', 'build'], - ('env',): ['init', 'sync', 'list', 'set', 'get', 'delete', 'edit', 'project'], + ('env',): ['init', 'sync', 'list', 'set', 'get', 'delete', 'edit', 'project', 'export', 'import'], ('plugin', 'pl'): ['list', 'install', 'uninstall', 'update', 'info', 'sync', 'repo'], ('snapshot', 'ss'): ['create', 'list', 'restore', 'copy', 'delete', 'rotate'], } +# 後方互換: prefix が複数候補にマッチする場合に、特定の入力を特定のサブコマンドに +# 優先的に解決させる。例えば `devbase env e` は従来 `edit` のみに解決されていたが、 +# `export` 追加後は ambiguous になるため、既存ショートカットを維持するために維持先を明示する。 +SUBCMD_PREFIX_PREFERENCES = { + ('env',): { + 'e': 'edit', + # `import` 追加で `i` が `init` / `import` の両方にマッチして ambiguous に + # なるため、既存ショートカット (`devbase env i` → `init`) を維持する。 + 'i': 'init', + }, +} + def _require_devbase_root() -> Path: """Get DEVBASE_ROOT from environment, exiting if not set.""" @@ -109,6 +121,90 @@ def _add_env_parser(subparsers): env_sub.add_parser('edit', help='Open .env in editor') env_sub.add_parser('project', help='Setup project-specific variables') + env_export = env_sub.add_parser( + 'export', + help='Export .env files as an encrypted bundle (age)', + ) + env_export.add_argument('dest', nargs='?', default=None, + help="Output path (default: ./devbase-env-.dbenv, '-' for stdout)") + env_export.add_argument('--include-project', action='append', default=None, + metavar='NAME', dest='include_projects', + help='Limit to specified project (repeatable)') + env_export.add_argument('--exclude-project', action='append', default=[], + metavar='NAME', dest='exclude_projects', + help='Exclude project (repeatable)') + env_export.add_argument('--no-global', action='store_true', + help='Exclude $DEVBASE_ROOT/.env') + env_export.add_argument('--no-metadata', action='store_true', + help='Exclude $DEVBASE_ROOT/.env.sources.yml') + env_export.add_argument('--recipient', action='append', default=[], + metavar='KEY', dest='recipients', + help=("age / OpenSSH public key (repeatable). " + "Formats: 'age1...', 'ssh-ed25519 AAAA...', 'ssh-rsa AAAA...', " + "'@PATH' for file reference. " + "Default: ~/.ssh/id_ed25519.pub, then ~/.ssh/id_rsa.pub " + "(first existing one)")) + env_export.add_argument('--passphrase-env', metavar='VAR', default=None, + help='Read passphrase from environment variable VAR') + env_export.add_argument('--passphrase-stdin', action='store_true', + help='Read passphrase from the first line of stdin') + env_export.add_argument('--force-unencrypted', action='store_true', + help='Write as plaintext tar.gz (rejected by default; ' + 'warns when sensitive keys are detected)') + env_export.add_argument('--unsafe-allow-unencrypted-bucket', action='store_true', + help='Allow S3 export to buckets without default encryption ' + '(per-object SSE is always applied regardless of this flag). ' + 'Has no effect for non-s3:// destinations.') + + env_import = env_sub.add_parser( + 'import', + help='Import .env files from a bundle (age-encrypted or plaintext tar.gz)', + ) + env_import.add_argument('source', + help="Bundle path or '-' for stdin") + env_import.add_argument('--merge', choices=['keep-existing', 'prefer-incoming'], + default='keep-existing', + help=("Key-level merge mode. keep-existing (default) keeps " + "existing keys and adds new ones; prefer-incoming " + "overwrites with bundle values")) + env_import.add_argument('--replace-keys', metavar='KEYS', default='', + help=("Comma-separated keys to force-overwrite from bundle " + "(other keys behave like keep-existing). " + "Cannot be combined with --replace")) + env_import.add_argument('--replace', action='store_true', + help='Replace each target .env file wholesale (backup is taken)') + env_import.add_argument('--dry-run', action='store_true', + help='Show planned diff without writing') + env_import.add_argument('--identity', action='append', default=[], + metavar='FILE', dest='identities', + help=("age / OpenSSH private key file (repeatable). " + "Default: ~/.ssh/id_ed25519, then ~/.ssh/id_rsa " + "(all existing ones)")) + env_import.add_argument('--passphrase-env', metavar='VAR', default=None, + help='Read passphrase from environment variable VAR') + env_import.add_argument('--passphrase-stdin', action='store_true', + help='Read passphrase from the first line of stdin') + env_import.add_argument('--include-project', action='append', default=None, + metavar='NAME', dest='include_projects', + help='Limit to specified project (repeatable)') + env_import.add_argument('--exclude-project', action='append', default=[], + metavar='NAME', dest='exclude_projects', + help='Exclude project (repeatable)') + env_import.add_argument('--no-global', action='store_true', + help='Do not import $DEVBASE_ROOT/.env') + env_import.add_argument('--no-metadata', action='store_true', + help='Do not import $DEVBASE_ROOT/.env.sources.yml ' + '(default behavior is reference-only copy; this fully ignores it)') + env_import.add_argument('--merge-metadata', action='store_true', + help='Merge new source entries into existing .env.sources.yml ' + '(machine-specific fields are preserved as-is from bundle; ' + 'run `devbase env sync` after import to refresh)') + env_import.add_argument('--backup-dir', metavar='DIR', default=None, + help='Override backup directory ' + '(default: $DEVBASE_ROOT/backups/env-import/)') + env_import.add_argument('--keep-last', type=int, default=10, metavar='N', + help='Keep only the last N backup directories (default: 10, 0 to disable)') + def _add_plugin_parser(subparsers): """Plugin group parser""" @@ -250,14 +346,22 @@ def _create_parser(): return parser -def _resolve_prefix(input_cmd, candidates): +def _resolve_prefix(input_cmd, candidates, preferences=None): """Resolve an abbreviated command to its full name via unique prefix matching. - Returns the full command name if exactly one candidate matches, - otherwise returns the input as-is (ambiguous or no match). + Returns the full command name if exactly one candidate matches. + If ambiguous, falls back to `preferences[input_cmd]` (if provided) to keep + backward compatibility with previously-unique abbreviations. + Otherwise returns the input as-is. """ matches = [c for c in candidates if c.startswith(input_cmd)] - return matches[0] if len(matches) == 1 else input_cmd + if len(matches) == 1: + return matches[0] + if preferences and input_cmd in preferences: + preferred = preferences[input_cmd] + if preferred in matches: + return preferred + return input_cmd def _expand_argv(): @@ -273,7 +377,8 @@ def _expand_argv(): cmd = sys.argv[1] for aliases, subcmds in SUBCMD_MAP.items(): if cmd in aliases: - sys.argv[2] = _resolve_prefix(sys.argv[2], subcmds) + preferences = SUBCMD_PREFIX_PREFERENCES.get(aliases) + sys.argv[2] = _resolve_prefix(sys.argv[2], subcmds, preferences) break # plugin repo sub-subcommand diff --git a/lib/devbase/commands/env.py b/lib/devbase/commands/env.py index 87eba3e..2c46b9c 100644 --- a/lib/devbase/commands/env.py +++ b/lib/devbase/commands/env.py @@ -33,6 +33,8 @@ def cmd_env(devbase_root: Path, args) -> int: 'delete': lambda: cmd_env_delete(devbase_root, getattr(args, 'key', '')), 'edit': lambda: cmd_env_edit(devbase_root), 'project': lambda: cmd_env_project(devbase_root), + 'export': lambda: cmd_env_export(devbase_root, args), + 'import': lambda: cmd_env_import(devbase_root, args), } handler = handlers.get(subcmd) @@ -382,6 +384,54 @@ def cmd_env_project(devbase_root: Path) -> int: return 0 +def cmd_env_export(devbase_root: Path, args) -> int: + """devbase env export""" + from devbase.env.io_export import ExportOptions, export + + opts = ExportOptions( + dest=getattr(args, 'dest', None), + include_global=not getattr(args, 'no_global', False), + include_metadata=not getattr(args, 'no_metadata', False), + include_projects=getattr(args, 'include_projects', None), + exclude_projects=list(getattr(args, 'exclude_projects', []) or []), + recipients=list(getattr(args, 'recipients', []) or []), + passphrase_env=getattr(args, 'passphrase_env', None), + passphrase_stdin=getattr(args, 'passphrase_stdin', False), + force_unencrypted=getattr(args, 'force_unencrypted', False), + unsafe_allow_unencrypted_bucket=getattr( + args, 'unsafe_allow_unencrypted_bucket', False + ), + ) + return export(devbase_root, opts) + + +def cmd_env_import(devbase_root: Path, args) -> int: + """devbase env import""" + from devbase.env.io_import import ImportOptions, import_bundle + + replace_keys_arg = getattr(args, 'replace_keys', '') or '' + replace_keys = [k.strip() for k in replace_keys_arg.split(',') if k.strip()] + + opts = ImportOptions( + source=getattr(args, 'source'), + merge=getattr(args, 'merge', 'keep-existing'), + replace_keys=replace_keys, + replace=getattr(args, 'replace', False), + dry_run=getattr(args, 'dry_run', False), + identities=list(getattr(args, 'identities', []) or []), + passphrase_env=getattr(args, 'passphrase_env', None), + passphrase_stdin=getattr(args, 'passphrase_stdin', False), + include_projects=getattr(args, 'include_projects', None), + exclude_projects=list(getattr(args, 'exclude_projects', []) or []), + include_global=not getattr(args, 'no_global', False), + include_metadata=not getattr(args, 'no_metadata', False), + merge_metadata=getattr(args, 'merge_metadata', False), + backup_dir=getattr(args, 'backup_dir', None), + keep_last=getattr(args, 'keep_last', 10), + ) + return import_bundle(devbase_root, opts) + + def _update_source_metadata(devbase_root: Path, env_file: EnvFile) -> None: """ソースメタデータを更新する""" sources = SourcesManager(devbase_root) diff --git a/lib/devbase/env/_import_atomic.py b/lib/devbase/env/_import_atomic.py new file mode 100644 index 0000000..bf271ba --- /dev/null +++ b/lib/devbase/env/_import_atomic.py @@ -0,0 +1,212 @@ +"""``devbase env import`` の 2 フェーズ atomic 書き込み + backup / GC + +import_bundle が作る :class:`_import_merge.Plan` 群を、以下の手順で適用する: + + 1. ``backup_existing`` — 既存 target をタイムスタンプ付き backup_dir にコピー + 2. ``write_atomic`` (per plan) — ``.import.tmp`` に 0600 で書き出し + 3. ``commit`` — 全 tmp を ``os.replace`` で一括 rename。途中失敗時は backup から + best-effort で rollback し、未 rename の tmp も後始末する + +加えて ``gc_backups`` で古い backup ディレクトリを ``keep_last`` 個まで圧縮する。 +モジュール内で ``os`` を直接参照しており、テストはこの ``os.replace`` を +monkeypatch することで commit 失敗パスを再現する。 +""" + +from __future__ import annotations + +import os +import re +import shutil +from datetime import datetime +from pathlib import Path +from typing import List, Optional, Sequence, Tuple + +from devbase.errors import DevbaseError +from devbase.log import get_logger + +from devbase.env import io_common as _io_common +from devbase.env._import_merge import Plan + +logger = get_logger(__name__) + +# make_backup_dir が生成するディレクトリ名形式のみを GC 対象にする。 +# prefix ``dbenv-`` を付けることで、--backup-dir 親に無関係なタイムスタンプ +# ディレクトリ (他ツールの backup 等) が存在しても誤って rmtree しない。 +# dbenv-YYYYMMDD-HHMMSS-NNNNNN (microsecond 付き) +# dbenv-YYYYMMDD-HHMMSS-NNNNNN-NN (同一マイクロ秒内の連番付き) +# (旧フォーマット ``YYYYMMDD-HHMMSS`` は prefix 無しだが後方互換のため残す) +_BACKUP_DIR_PREFIX = 'dbenv-' +_BACKUP_DIR_NAME_RE = re.compile( + r'^dbenv-\d{8}-\d{6}-\d{6}(?:-\d+)?$' # 新フォーマット (prefix 付き) + r'|' + r'^\d{8}-\d{6}(?:-\d{6}(?:-\d+)?)?$' # 旧フォーマット (後方互換) +) + + +class AtomicError(DevbaseError): + """atomic 書き込み中のエラー (ImportError へ委譲する用途で投げる)""" + + +def make_backup_dir(devbase_root: Path, backup_dir: Optional[str]) -> Path: + """``--backup-dir`` 指定 or ``$DEVBASE_ROOT/backups/env-import/`` 配下に + タイムスタンプ命名の backup ディレクトリを作る。 + + 秒精度のみだと同一秒に 2 回 import を走らせたときに同じディレクトリを再利用して + 前回バックアップを上書きしてしまうため、microsecond + 連番を付与して衝突を回避する + (PR #15 codex 指摘)。 + """ + base = (Path(backup_dir).expanduser() if backup_dir + else devbase_root / 'backups' / 'env-import') + base.mkdir(parents=True, exist_ok=True) + + stem = _BACKUP_DIR_PREFIX + datetime.now().strftime('%Y%m%d-%H%M%S-%f') + primary = base / stem + if not primary.exists(): + primary.mkdir(parents=True) + return primary + # 同一マイクロ秒に複数回走った場合の安全弁: 連番を付与 + for n in range(1, 1000): + candidate = base / f'{stem}-{n:02d}' + if not candidate.exists(): + candidate.mkdir(parents=True) + return candidate + raise AtomicError( + f"backup ディレクトリの衝突回避に失敗しました (base={base}, stem={stem})" + ) + + +def _backup_relative(target: Path, devbase_root: Path) -> Path: + """target を devbase_root 相対表現に変換。外にあるパスはファイル名のみを使う。""" + try: + return target.relative_to(devbase_root) + except ValueError: + return Path(target.name) + + +def backup_existing(plans: Sequence[Plan], + sources_copy: Optional[Tuple[Path, bytes]], + backup_dir: Path, devbase_root: Path) -> None: + """phase 1 前に既存ファイルの内容を ``backup_dir`` にコピーする""" + for plan in plans: + if not plan.target.exists(): + continue + dst = backup_dir / _backup_relative(plan.target, devbase_root) + dst.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(plan.target, dst) + + # バンドルに含まれていた sources.yml の参照用コピー (上書きしないケース) + if sources_copy is not None: + _, data = sources_copy + dst = backup_dir / 'sources.yml.imported' + dst.parent.mkdir(parents=True, exist_ok=True) + _io_common.write_secure_bytes(dst, data) + + +def write_atomic(plan: Plan) -> Path: + """phase 1: 新内容を ``.import.tmp`` として 0600 で書き出す""" + tmp = plan.target.with_suffix(plan.target.suffix + '.import.tmp') + if tmp.exists(): + # 過去の失敗の残骸を掃除 + try: + tmp.unlink() + except OSError: + pass + _io_common.write_secure_bytes(tmp, plan.new_bytes) + return tmp + + +def commit(plans_and_tmps: List[Tuple[Plan, Path]], backup_dir: Path, + devbase_root: Path) -> List[Path]: + """phase 2: tmp → target に rename。 + + 途中失敗時は best-effort で rollback したうえで、まだ rename されていない + ``.import.tmp`` ファイルもクリーンアップする (PR #15 gemini 指摘)。 + """ + committed: List[Tuple[Plan, Path]] = [] + remaining = list(plans_and_tmps) + try: + while remaining: + plan, tmp = remaining[0] + os.replace(tmp, plan.target) + try: + os.chmod(plan.target, 0o600) + except OSError: + pass + committed.append((plan, plan.target)) + remaining.pop(0) + except OSError as e: + logger.error("commit フェーズで失敗しました: %s", e) + try: + _rollback(committed, backup_dir, devbase_root) + finally: + cleanup_tmps(tmp for _, tmp in remaining) + raise AtomicError(f"commit フェーズで失敗しました: {e}") from e + return [t for _, t in committed] + + +def _rollback(committed: Sequence[Tuple[Plan, Path]], backup_dir: Path, + devbase_root: Path) -> None: + """best-effort ロールバック: + - 既存上書き (backup あり) → backup から復元 + - backup が無いケース → 元ファイルが存在しなかった (= 新規作成) と + みなして unlink し、元の「不在」状態に戻す。``op='create'`` だけでなく + ``op='sources-merge'`` で sources.yml を新規作成したケースもここで + unlink する (PR #15 gemini 指摘)。 + + ``backup_existing`` は target が存在した場合のみ backup を作る。よって + 「backup が無い」事実は「元ファイルが存在しなかった」ことを示している。 + """ + for _, target in committed: + src = backup_dir / _backup_relative(target, devbase_root) + if src.exists(): + try: + shutil.copy2(src, target) + logger.warning("rollback: %s を %s から復元しました", target, src) + except OSError as e: + logger.error("rollback 失敗: %s -> %s: %s", src, target, e) + continue + # 元ファイル不在 → 新規作成された target を unlink して元の状態に戻す + try: + target.unlink() + logger.warning("rollback: 新規作成された %s を削除しました", target) + except FileNotFoundError: + pass + except OSError as e: + logger.error("rollback unlink 失敗: %s: %s", target, e) + + +def cleanup_tmps(tmps) -> None: + """``.import.tmp`` の残骸を削除する (失敗は無視)""" + for tmp in tmps: + try: + if tmp.exists(): + tmp.unlink() + except OSError: + pass + + +def gc_backups(backup_dir: Path, keep_last: int) -> None: + """``backup_dir`` の親ディレクトリで古い backup を ``keep_last`` 個まで残して GC する。 + + ``dbenv-`` prefix 付きの devbase 生成ディレクトリ、または旧フォーマットの + タイムスタンプ形式 (``YYYYMMDD-HHMMSS[-NNNNNN[-NN]]``) にマッチする + ディレクトリのみが GC 対象。``--backup-dir`` 親に無関係なディレクトリは触らない。 + """ + if keep_last <= 0: + return + parent = backup_dir.parent + if not parent.is_dir(): + return + candidates = [p for p in parent.iterdir() + if p.is_dir() and _BACKUP_DIR_NAME_RE.match(p.name)] + if len(candidates) <= keep_last: + return + # 名前 (= タイムスタンプ) でソート、古いものから捨てる。keep_last は通常 10 程度なので + # 全件ソートで十分 (heapq.nsmallest を使うほどの規模ではない)。 + candidates.sort(key=lambda p: p.name) + for d in candidates[:-keep_last]: + try: + shutil.rmtree(d) + logger.info("古い backup を削除しました: %s", d) + except OSError as e: + logger.warning("backup 削除に失敗 (%s): %s", d, e) diff --git a/lib/devbase/env/_import_merge.py b/lib/devbase/env/_import_merge.py new file mode 100644 index 0000000..0fa019f --- /dev/null +++ b/lib/devbase/env/_import_merge.py @@ -0,0 +1,349 @@ +"""``devbase env import`` の merge / replace 計画 + +ファイル単位の操作内容 (新規作成 / マージ / 置換 / sources-merge) を +:class:`Plan` として表現し、``incoming`` と ``existing`` から差分計算する。 + +実書き込み (atomic rename / backup / rollback) は :mod:`_import_atomic` の役割で、 +このモジュールは「何を書くか」だけを決定する。 +""" + +from __future__ import annotations + +import re +from dataclasses import dataclass, field +from pathlib import Path +from typing import Dict, List, Optional, Sequence, Tuple + +import yaml + +from devbase.errors import DevbaseError +from devbase.log import get_logger + +from devbase.env import bundle +from devbase.env.store import EnvFile + +logger = get_logger(__name__) + +# project 名は通常のディレクトリ名のみ許容する。 +# - 先頭文字: 英数字 / `_` (`.` を許可すると `env/projects/./.env` が +# `$DEVBASE_ROOT/projects/.env` に正規化され、グローバル .env を上書きする +# path traversal 系の問題になる — PR #13 codex round 3 指摘) +# - 2文字目以降: 英数字 / `_` / `-` / `.` +# - `.` / `..` のような特殊セグメント、空文字、`/` を含む値は弾く +# bundle._validate_manifest や tar 展開側 (`..` のみ拒否) では塞ぎきれないため、 +# arcname を path に解決する側で project 名を制限する。 +# +# 名前部分の validator は export 側 (`bundle.is_valid_project_name`) と共有して +# round-trip 不整合を防ぐ (PR #13 codex round 5 指摘)。ここでは arcname 全体パターン +# (`env/projects//.env`) を捕捉するために改めて regex を組む。 +_PROJECT_ENV_RE = re.compile( + r'^env/projects/(' + bundle._VALID_PROJECT_NAME_RE.pattern.strip('^$') + r')/\.env$' +) + +# import_bundle が許容する --merge モード一覧。CLI の choices と一致させる。 +MERGE_MODES: Tuple[str, ...] = ('keep-existing', 'prefer-incoming') + + +class MergeError(DevbaseError): + """merge 計画作成中のエラー (ImportError へ委譲する用途で投げる)""" + + +@dataclass +class Plan: + """1 ファイル分の書き出し計画。 + + ``added_keys`` / ``overwritten_keys`` / ``skipped_keys`` は dry-run およびログ表示で + "何が起こるか" をユーザに伝えるために保持する。 + """ + target: Path + arcname: str + new_bytes: bytes + added_keys: List[str] = field(default_factory=list) + overwritten_keys: List[str] = field(default_factory=list) + skipped_keys: List[str] = field(default_factory=list) + op: str = 'merge' # 'merge' | 'replace' | 'create' | 'sources-merge' + + +def target_for(arcname: str, devbase_root: Path) -> Path: + """バンドル内 arcname を ``devbase_root`` 配下の書き出し先 Path に解決する""" + if arcname == 'env/global.env': + return devbase_root / '.env' + if arcname == 'env/sources.yml': + return devbase_root / '.env.sources.yml' + m = _PROJECT_ENV_RE.match(arcname) + if m: + return devbase_root / 'projects' / m.group(1) / '.env' + raise MergeError(f"未対応のバンドルエントリ: {arcname}") + + +def filter_members( + members: Dict[str, bytes], + *, + include_global: bool, + include_metadata: bool, + include_projects: Optional[Sequence[str]], + exclude_projects: Sequence[str], +) -> Dict[str, bytes]: + """include/exclude 指定で展開済みメンバーを絞り込む""" + included = set(include_projects) if include_projects else None + excluded = set(exclude_projects) + result: Dict[str, bytes] = {} + + for arcname, data in members.items(): + if arcname == 'env/global.env': + if include_global: + result[arcname] = data + continue + if arcname == 'env/sources.yml': + if include_metadata: + result[arcname] = data + continue + m = _PROJECT_ENV_RE.match(arcname) + if not m: + # manifest 検証 (bundle._validate_manifest) は path のパターンを制限していないため、 + # 未対応 arcname がここに来た場合は黙って捨てると "manifest と適用結果が食い違う" + # 整合性問題になる。明示的にエラーで止める (PR #13 codex 指摘)。 + raise MergeError( + f"バンドルに未対応の arcname が含まれています: {arcname} " + "(対応形式: env/global.env / env/sources.yml / env/projects//.env)" + ) + name = m.group(1) + if name in excluded: + continue + if included is not None and name not in included: + continue + result[arcname] = data + return result + + +def _merge_into_existing_bytes(existing_bytes: bytes, + merged: Dict[str, str]) -> bytes: + """既存 ``.env`` のコメント / 空行 / キー順を保持したまま、``merged`` で値を差し替える。 + + 既存に無いキーは末尾に sorted 順で append。``merged`` から除外されたキーは + 出力からも除外する (現状の merge ロジック上発生しないが、安全側で対応)。 + + 値が変更されていないキーは ``raw`` 行をそのまま温存して出力する。これにより + 例えば ``PATH=$HOME/bin`` のような未クオート値が ``PATH="\\$HOME/bin"`` に + 勝手にエスケープされて source 時の意味が変わるのを防ぐ (PR #13 codex 指摘)。 + 値が変わったキーと新規キーのみ ``EnvFile._format_kv_line`` でフォーマットする。 + + ``EnvFile.dump_bytes`` で再シリアライズするとコメント・空行が失われるため、 + ``EnvFile.parse_entries`` ベースで再構成している (PR #15 gemini 指摘)。 + """ + seen: set[str] = set() + out_lines: List[str] = [] + for e in EnvFile.parse_entries(existing_bytes): + if e.kind != 'kv' or e.key is None: + out_lines.append(e.raw + '\n') + continue + if e.key in merged: + seen.add(e.key) + new_value = merged[e.key] + if e.value == new_value: + # 値が変わっていないキーは元の raw 行を温存する (escape 形式や + # クオート有無を保持して source 時の意味が変わらないように) + out_lines.append(e.raw + '\n') + else: + out_lines.append( + EnvFile._format_kv_line(e.key, new_value) + ) + # merged から除外されているキーは entries からも落とす + for key in sorted(k for k in merged if k not in seen): + out_lines.append(EnvFile._format_kv_line(key, merged[key])) + return ''.join(out_lines).encode('utf-8') + + +def _plan_replace(target: Path, arcname: str, incoming: Dict[str, str], + existing: Dict[str, str], incoming_bytes: bytes, + target_exists: bool) -> Plan: + """--replace: ファイル丸ごとを incoming で置き換える""" + added = sorted(set(incoming) - set(existing)) + overwritten = sorted( + k for k in incoming if k in existing and incoming[k] != existing[k] + ) + return Plan( + target=target, + arcname=arcname, + new_bytes=incoming_bytes, + added_keys=added, + overwritten_keys=overwritten, + # op 判定はファイル実体の有無で行う: + # コメントのみの既存 .env を 'create' と誤判定しないため (PR #15 round5 指摘)。 + op='replace' if target_exists else 'create', + ) + + +def _plan_keep_existing(incoming: Dict[str, str], existing: Dict[str, str], + merged: Dict[str, str], added: List[str], + skipped: List[str]) -> None: + """既存キーは保持。新規キーのみ追加""" + for key, value in incoming.items(): + if key in existing: + skipped.append(key) + else: + merged[key] = value + added.append(key) + + +def _plan_prefer_incoming(incoming: Dict[str, str], existing: Dict[str, str], + merged: Dict[str, str], added: List[str], + overwritten: List[str]) -> None: + """incoming で既存キーを上書き""" + for key, value in incoming.items(): + if key in existing: + if existing[key] != value: + overwritten.append(key) + else: + added.append(key) + merged[key] = value + + +def _plan_replace_keys(incoming: Dict[str, str], existing: Dict[str, str], + replace_keys: Sequence[str], merged: Dict[str, str], + added: List[str], overwritten: List[str], + skipped: List[str]) -> None: + """--replace-keys: 指定キーのみ上書き、残りは keep-existing 相当 + + keep-existing 相当 = 既存にあれば残す、無ければ新規追加 (skipped は + 上書きを抑止したキーのみ)。 + """ + replace_set = set(replace_keys) + for key, value in incoming.items(): + if key in replace_set: + if key in existing and existing[key] != value: + overwritten.append(key) + elif key not in existing: + added.append(key) + merged[key] = value + elif key in existing: + if existing[key] != value: + skipped.append(key) + else: + added.append(key) + merged[key] = value + + +def plan_env_merge(target: Path, incoming_bytes: bytes, arcname: str, *, + merge: str = 'keep-existing', + replace: bool = False, + replace_keys: Sequence[str] = ()) -> Plan: + """1 つの ``.env`` に対する merge / replace 計画を作る + + 新規作成 (= target 不在) ケースでは ``incoming_bytes`` をそのまま採用する。 + ``EnvFile.dump_bytes`` で再シリアライズすると、export 側で既に escape された値が + parse_bytes 経由でも完全に round-trip できる前提が崩れた瞬間に二重エスケープが + 発生するためである (PR #15 codex 指摘)。 + + 既存ファイルが存在する merge 経路では :func:`_merge_into_existing_bytes` で + 既存のコメント / 空行 / キー順を保持したまま値だけ差し替える (PR #15 gemini 指摘)。 + """ + incoming = EnvFile.parse_bytes(incoming_bytes) + target_exists = target.exists() + existing_bytes = target.read_bytes() if target_exists else b'' + existing = EnvFile.parse_bytes(existing_bytes) if target_exists else {} + + if replace: + return _plan_replace(target, arcname, incoming, existing, + incoming_bytes, target_exists) + + merged: Dict[str, str] = dict(existing) + added: List[str] = [] + overwritten: List[str] = [] + skipped: List[str] = [] + + if replace_keys: + _plan_replace_keys(incoming, existing, replace_keys, + merged, added, overwritten, skipped) + elif merge == 'keep-existing': + _plan_keep_existing(incoming, existing, merged, added, skipped) + elif merge == 'prefer-incoming': + _plan_prefer_incoming(incoming, existing, merged, added, overwritten) + else: + raise MergeError(f"不明な --merge モード: {merge!r}") + + new_bytes = (_merge_into_existing_bytes(existing_bytes, merged) + if target_exists else incoming_bytes) + return Plan( + target=target, + arcname=arcname, + new_bytes=new_bytes, + added_keys=sorted(added), + overwritten_keys=sorted(overwritten), + skipped_keys=sorted(skipped), + op='merge' if target_exists else 'create', + ) + + +def plan_sources(target: Path, incoming_bytes: bytes, *, + merge_metadata: bool) -> Optional[Plan]: + """``.env.sources.yml`` の取り扱い計画 + + 既定: 上書きしないため ``None`` を返す (参照用コピーの保存は呼び出し側で実施)。 + ``merge_metadata=True``: 新規 source エントリのみ追加した内容で更新する。 + """ + if not merge_metadata: + return None + + try: + incoming = yaml.safe_load(incoming_bytes) or {} + except yaml.YAMLError as e: + raise MergeError(f"バンドルの sources.yml が壊れています: {e}") from e + if not isinstance(incoming, dict): + raise MergeError("バンドルの sources.yml が dict ではありません") + incoming_sources = incoming.get('sources') or {} + if not isinstance(incoming_sources, dict): + raise MergeError("バンドルの sources.yml の sources が dict ではありません") + + existing: Dict = {} + if target.exists(): + try: + existing = yaml.safe_load(target.read_bytes()) or {} + except yaml.YAMLError as e: + raise MergeError( + f"既存の {target.name} のパースに失敗しました: {e}" + ) from e + if not isinstance(existing, dict): + existing = {} + existing_sources = existing.get('sources') + if not isinstance(existing_sources, dict): + existing_sources = {} + + merged_sources = dict(existing_sources) + added: List[str] = [] + for name, entry in incoming_sources.items(): + if name in merged_sources: + continue + merged_sources[name] = entry + added.append(name) + if not added: + return None # 変化なし + + existing['sources'] = merged_sources + new_bytes = yaml.safe_dump( + existing, default_flow_style=False, allow_unicode=True + ).encode('utf-8') + return Plan( + target=target, + arcname='env/sources.yml', + new_bytes=new_bytes, + added_keys=sorted(added), + op='sources-merge', + ) + + +def log_plans(plans: Sequence[Plan], dry_run: bool) -> None: + """dry-run / 通常実行のいずれでも plan の内容を logger.info で表示する""" + prefix = "[dry-run] " if dry_run else "" + for plan in plans: + logger.info( + "%s%s: %s (+%d add / ~%d overwrite / -%d skip)", + prefix, plan.op, plan.target, + len(plan.added_keys), len(plan.overwritten_keys), len(plan.skipped_keys), + ) + if plan.added_keys: + logger.info(" added: %s", ", ".join(plan.added_keys)) + if plan.overwritten_keys: + logger.info(" overwrite: %s", ", ".join(plan.overwritten_keys)) + if plan.skipped_keys: + logger.info(" skip (existing kept): %s", ", ".join(plan.skipped_keys)) diff --git a/lib/devbase/env/bundle.py b/lib/devbase/env/bundle.py new file mode 100644 index 0000000..33fdd4b --- /dev/null +++ b/lib/devbase/env/bundle.py @@ -0,0 +1,293 @@ +"""env export/import バンドル (tar.gz + manifest.yml) の構築・展開""" + +from __future__ import annotations + +import gzip +import hashlib +import io +import re +import tarfile +from dataclasses import dataclass +from datetime import datetime, timezone +from typing import Dict, List, Optional, Sequence, Tuple + +import yaml + +from devbase.errors import DevbaseError +from devbase.log import get_logger + +try: + from devbase import __version__ as _DEVBASE_VERSION +except ImportError: + _DEVBASE_VERSION = "unknown" + +logger = get_logger(__name__) + +MANIFEST_NAME = "manifest.yml" +SUPPORTED_MANIFEST_VERSION = 1 + +# import/export 共通の project 名 validator。 +# 詳細仕様は `_import_merge._PROJECT_ENV_RE` の docstring を参照: +# - 先頭文字: 英数字 / `_` (`.` 始まりは `./` / `../` 等の特殊セグメント拒否のため) +# - 2文字目以降: 英数字 / `_` / `-` / `.` +# - `.` / `..` / 空文字 / 空白 / `/` を含む値は弾く +# import 側 (`_import_merge.filter_members`) で `MergeError` にする一方、 +# export 側 (`make_entries_from_disk`) でも同じ validator を使い、 +# round-trip できない bundle を export しないようにする (PR #13 codex round 5 指摘)。 +_VALID_PROJECT_NAME_RE = re.compile(r'^[A-Za-z0-9_][A-Za-z0-9_.\-]*$') + + +def is_valid_project_name(name: str) -> bool: + """bundle arcname (`env/projects//.env`) に使える project 名かを判定する""" + return bool(_VALID_PROJECT_NAME_RE.match(name)) + + +class BundleError(DevbaseError): + """バンドル構築・展開エラー""" + + +@dataclass(frozen=True) +class BundleEntry: + """バンドル内ファイル 1 件""" + arcname: str # tar 内パス (例: 'env/global.env') + origin: str # 元ファイルの DEVBASE_ROOT 相対表記 (例: '$DEVBASE_ROOT/.env') + data: bytes + + +def _sha256(data: bytes) -> str: + return hashlib.sha256(data).hexdigest() + + +def _local_now_iso() -> str: + return datetime.now(timezone.utc).astimezone().isoformat(timespec='seconds') + + +def build_manifest(entries: Sequence[BundleEntry], + devbase_version: str = _DEVBASE_VERSION, + created_at: Optional[str] = None) -> Dict: + """manifest.yml の dict 表現を生成する""" + return { + 'version': SUPPORTED_MANIFEST_VERSION, + 'created_at': created_at or _local_now_iso(), + 'devbase_version': devbase_version, + 'files': [ + {'path': e.arcname, 'sha256': _sha256(e.data), 'origin': e.origin} + for e in entries + ], + } + + +def pack(entries: Sequence[BundleEntry], + devbase_version: str = _DEVBASE_VERSION, + created_at: Optional[str] = None) -> bytes: + """エントリ群を manifest.yml 付きの tar.gz バイト列にまとめる""" + manifest = build_manifest(entries, devbase_version=devbase_version, + created_at=created_at) + manifest_bytes = yaml.safe_dump(manifest, sort_keys=False, + allow_unicode=True).encode('utf-8') + + buf = io.BytesIO() + # 再現性を確保: + # - tarfile の mode='w:gz' は gzip ヘッダに現在時刻を埋め込むため出力が + # 非決定的になる。gzip.GzipFile を mtime=0 で明示的に作成し、その上に + # tarfile を mode='w' で書き出すことで完全に決定的なバイト列にする。 + # - PAX_FORMAT を指定して各エントリの mtime=0 等のメタも安定させる。 + with gzip.GzipFile(fileobj=buf, mode='wb', mtime=0) as gz: + with tarfile.open(fileobj=gz, mode='w', format=tarfile.PAX_FORMAT) as tf: + _add_member(tf, MANIFEST_NAME, manifest_bytes) + for entry in entries: + _add_member(tf, entry.arcname, entry.data) + return buf.getvalue() + + +def _add_member(tf: tarfile.TarFile, arcname: str, data: bytes) -> None: + info = tarfile.TarInfo(name=arcname) + info.size = len(data) + info.mtime = 0 + info.mode = 0o600 + tf.addfile(info, io.BytesIO(data)) + + +def unpack(blob: bytes) -> Tuple[Dict, Dict[str, bytes]]: + """tar.gz バイト列から (manifest, {arcname: bytes}) を取り出す + + sha256 / version の検証も行う。 + """ + buf = io.BytesIO(blob) + try: + tf = tarfile.open(fileobj=buf, mode='r:gz') + except tarfile.TarError as e: + raise BundleError(f"tar.gz の読み込みに失敗しました: {e}") from e + except OSError as e: + raise BundleError(f"tar.gz の読み込みに失敗しました: {e}") from e + + members: Dict[str, bytes] = {} + try: + with tf: + for info in tf.getmembers(): + if not info.isfile(): + continue + if info.name.startswith('/') or '..' in info.name.split('/'): + raise BundleError(f"不正なパスを含んでいます: {info.name}") + if info.name in members: + raise BundleError(f"重複エントリを検出しました: {info.name}") + f = tf.extractfile(info) + if f is None: + continue + members[info.name] = f.read() + except BundleError: + raise + except tarfile.TarError as e: + raise BundleError(f"tar の展開に失敗しました: {e}") from e + except OSError as e: + raise BundleError(f"tar の展開に失敗しました: {e}") from e + + manifest_bytes = members.pop(MANIFEST_NAME, None) + if manifest_bytes is None: + raise BundleError(f"{MANIFEST_NAME} がバンドルに含まれていません") + + try: + manifest = yaml.safe_load(manifest_bytes) or {} + except yaml.YAMLError as e: + raise BundleError(f"{MANIFEST_NAME} のパースに失敗しました: {e}") from e + + _validate_manifest(manifest, members) + return manifest, members + + +def _validate_manifest(manifest: Dict, members: Dict[str, bytes]) -> None: + if not isinstance(manifest, dict): + raise BundleError( + f"{MANIFEST_NAME} の top-level が mapping ではありません " + f"(type={type(manifest).__name__})" + ) + version = manifest.get('version') + if not isinstance(version, int): + raise BundleError("manifest.version が不正です") + if version != SUPPORTED_MANIFEST_VERSION: + raise BundleError( + f"manifest.version={version} はこの devbase ではサポートされていません " + f"(対応={SUPPORTED_MANIFEST_VERSION})。devbase 本体を更新してください" + ) + + files = manifest.get('files') or [] + if not isinstance(files, list): + raise BundleError("manifest.files が list ではありません") + + manifest_paths: set = set() + for entry in files: + if not isinstance(entry, dict): + raise BundleError(f"manifest.files の要素が dict ではありません: {type(entry).__name__}") + path = entry.get('path') + expected = entry.get('sha256') + if not isinstance(path, str) or not path: + raise BundleError(f"manifest.files の path が不正です: {path!r}") + if path in manifest_paths: + # 重複 path は origin/metadata の解釈が曖昧になるため拒否する + raise BundleError(f"manifest.files に同じ path が重複しています: {path}") + if not isinstance(expected, str) or len(expected) != 64 or not all( + c in '0123456789abcdef' for c in expected.lower() + ): + raise BundleError( + f"manifest.files の sha256 が不正です (path={path}): " + f"64文字の16進文字列が必要です" + ) + expected = expected.lower() + if path not in members: + raise BundleError(f"manifest に記載されたファイルが見つかりません: {path}") + actual = _sha256(members[path]) + if expected != actual: + raise BundleError( + f"sha256 が一致しません (path={path}, expected={expected[:12]}..., " + f"actual={actual[:12]}...)" + ) + manifest_paths.add(path) + + # tar 内のファイルセットと manifest のファイルセットの完全一致を検証する。 + # manifest に記載のないファイルが tar に混入していても検知できるようにする + # (バンドル内未知ファイルの混入はセキュリティ・整合性リスクのため拒否)。 + unknown = sorted(set(members) - manifest_paths) + if unknown: + raise BundleError( + "manifest に記載のないファイルがバンドルに含まれています: " + + ", ".join(unknown) + ) + + +def make_entries_from_disk(devbase_root, + include_global: bool = True, + include_metadata: bool = True, + include_projects: Optional[Sequence[str]] = None, + exclude_projects: Sequence[str] = ()) -> List[BundleEntry]: + """DEVBASE_ROOT 配下から export 対象を収集して BundleEntry のリストを返す + + Args: + devbase_root: Path + include_global: True なら $DEVBASE_ROOT/.env を含める + include_metadata: True なら $DEVBASE_ROOT/.env.sources.yml を含める + include_projects: 指定があればこのプロジェクト名のみを対象 + exclude_projects: 除外するプロジェクト名 + """ + from pathlib import Path + + devbase_root = Path(devbase_root) + entries: List[BundleEntry] = [] + + if include_global: + global_env = devbase_root / '.env' + # is_file() でディレクトリ等を除外し、IsADirectoryError 等の例外を防ぐ + if global_env.is_file(): + entries.append(BundleEntry( + arcname='env/global.env', + origin='$DEVBASE_ROOT/.env', + data=global_env.read_bytes(), + )) + + if include_metadata: + sources_yml = devbase_root / '.env.sources.yml' + if sources_yml.is_file(): + entries.append(BundleEntry( + arcname='env/sources.yml', + origin='$DEVBASE_ROOT/.env.sources.yml', + data=sources_yml.read_bytes(), + )) + + projects_dir = devbase_root / 'projects' + if projects_dir.is_dir(): + excluded = set(exclude_projects) + included = set(include_projects) if include_projects else None + + candidates = sorted(p for p in projects_dir.iterdir() if p.is_dir()) + for proj_dir in candidates: + name = proj_dir.name + if name in excluded: + continue + if included is not None and name not in included: + continue + # import 側で `_PROJECT_ENV_RE` により制限されている project 名と同じ + # validator で fail-fast する。空白や先頭 `.` などを含むディレクトリを + # そのまま arcname にすると export は成功しても後続の import が + # `未対応の arcname` で失敗し、round-trip できない bundle が生成される + # (PR #13 codex round 5 指摘)。明示エラーではなく "警告 + スキップ" 方針: + # - レビュー指摘の選択肢が「明示エラー or skip with warning」だったこと + # - 一時ディレクトリや leftover (e.g. `.git`, `.DS_Store` でも `.` 始まりで弾かれる) + # が混在しても valid な project だけは export を成功させたいユースケース + # のため後者を採用。include_projects で明示指定された名前が invalid な + # ときも warning のみで落とすことで、暗黙的に round-trip 不能なバンドル + # を作らないようにする。 + if not is_valid_project_name(name): + logger.warning( + "project '%s' は bundle に含められない名前 (空白 / 先頭 `.` / `/` 等) " + "のためスキップします: %s", + name, proj_dir, + ) + continue + env_path = proj_dir / '.env' + if env_path.is_file(): + entries.append(BundleEntry( + arcname=f'env/projects/{name}/.env', + origin=f'$DEVBASE_ROOT/projects/{name}/.env', + data=env_path.read_bytes(), + )) + + return entries diff --git a/lib/devbase/env/cipher.py b/lib/devbase/env/cipher.py new file mode 100644 index 0000000..e3c3f81 --- /dev/null +++ b/lib/devbase/env/cipher.py @@ -0,0 +1,225 @@ +"""age (pyrage) を用いた env バンドルの暗号化・復号""" + +from __future__ import annotations + +from pathlib import Path +from typing import List, Optional, Sequence + +import pyrage + +from devbase.errors import DevbaseError + + +class CipherError(DevbaseError): + """暗号化・復号エラー""" + + +_MAX_RECIPIENT_REF_DEPTH = 5 + + +def _resolve_recipient(spec: str, _depth: int = 0): + """recipient 仕様文字列を pyrage Recipient に解決する + + 形式: + 'age1...' -> X25519 公開鍵 + 'ssh-ed25519 AAAA...' -> OpenSSH ed25519 公開鍵 + 'ssh-rsa AAAA...' -> OpenSSH RSA 公開鍵 + '@PATH' -> ファイル参照 (中身を再帰的に解釈, 深さ上限あり) + """ + spec = spec.strip() + if not spec: + raise CipherError("recipient が空です") + + if spec.startswith('@'): + if _depth >= _MAX_RECIPIENT_REF_DEPTH: + raise CipherError( + f"recipient の @PATH 参照が深すぎます (上限={_MAX_RECIPIENT_REF_DEPTH})。" + "循環参照の可能性があります" + ) + path = Path(spec[1:]).expanduser() + if not path.exists(): + raise CipherError(f"recipient ファイルが見つかりません: {path}") + try: + content = path.read_text(encoding='utf-8') + except UnicodeDecodeError as e: + raise CipherError( + f"recipient ファイルの UTF-8 デコードに失敗しました: {path}: {e}" + ) from e + except OSError as e: + raise CipherError( + f"recipient ファイルの読み込みに失敗しました ({path}): {e}" + ) from e + # ファイル中に複数行 / コメント / 空行が混在していても扱えるよう、 + # 空行と '#' で始まるコメント行を除いた有効行のみを取り出す。 + valid = [ + line.strip() for line in content.splitlines() + if line.strip() and not line.strip().startswith('#') + ] + if not valid: + raise CipherError(f"recipient ファイルに有効な行がありません: {path}") + if len(valid) > 1: + # 複数公開鍵を 1 ファイルに列挙したケース (team_keys.txt 等)。 + # 暗黙に「最初の 1 人」だけ採用するとチーム運用で暗号化が壊れるため、 + # 明示的に複数 `--recipient` で指定するよう要求する (PR #13 gemini 指摘)。 + raise CipherError( + f"recipient ファイルに複数行の鍵が含まれています ({path}, {len(valid)} 件)。" + "複数の公開鍵で暗号化したい場合は `--recipient @file_a.pub --recipient @file_b.pub` " + "のように 1 ファイルにつき 1 鍵で指定してください" + ) + return _resolve_recipient(valid[0], _depth + 1) + + if spec.startswith('age1'): + try: + return pyrage.x25519.Recipient.from_str(spec) + except Exception as e: + raise CipherError(f"age 公開鍵の解釈に失敗しました: {e}") from e + + if spec.startswith('ssh-ed25519 ') or spec.startswith('ssh-rsa '): + try: + return pyrage.ssh.Recipient.from_str(spec) + except Exception as e: + raise CipherError(f"OpenSSH 公開鍵の解釈に失敗しました: {e}") from e + + if spec.startswith('ssh-'): + raise CipherError( + f"age は ssh-ed25519 / ssh-rsa のみ対応です (入力: {spec.split()[0]})。" + "ssh-ecdsa / ssh-dss などは `age-keygen` で age 専用鍵を生成してください" + ) + + raise CipherError( + f"recipient の形式を判別できません: {spec[:32]!r}... " + "(対応形式: age1... / ssh-ed25519 ... / ssh-rsa ... / @PATH)" + ) + + +def _resolve_identity(path_spec: str): + """秘密鍵ファイルパスを pyrage Identity に解決する""" + path = Path(path_spec).expanduser() + if not path.exists(): + raise CipherError(f"identity ファイルが見つかりません: {path}") + + try: + raw = path.read_bytes() + except OSError as e: + raise CipherError(f"identity ファイルの読み込みに失敗しました ({path}): {e}") from e + + # OpenSSH 秘密鍵は PEM 風の決まったヘッダを持つため、age 鍵より先に + # ヘッダで判別する。これにより鍵形式判別が明示的になり、将来の鍵形式 + # 追加時にも分岐を増やすだけで済む。 + if b'-----BEGIN OPENSSH PRIVATE KEY-----' in raw: + try: + return pyrage.ssh.Identity.from_buffer(raw) + except Exception as e: + raise CipherError( + f"OpenSSH 秘密鍵の解釈に失敗しました ({path}): {e}" + ) from e + + # age-keygen が生成する秘密鍵ファイルは先頭に `# created: ...` などの + # コメント行を含むため、`raw.strip().startswith(b'AGE-SECRET-KEY-1')` では + # 検出できない。`_resolve_recipient` と同様に行単位で走査して、コメント / + # 空行を除いた最初の有効行が AGE-SECRET-KEY-1 で始まるかで判定する + # (PR #13 gemini 指摘)。 + try: + text = raw.decode('utf-8') + except UnicodeDecodeError: + text = None + if text is not None: + for line in text.splitlines(): + stripped = line.strip() + if not stripped or stripped.startswith('#'): + continue + if stripped.startswith('AGE-SECRET-KEY-1'): + try: + # pyrage.x25519.Identity.from_str は単独の AGE-SECRET-KEY-1 + # 行のみを受け付けるため、ファイル全体ではなく該当行を渡す。 + return pyrage.x25519.Identity.from_str(stripped) + except Exception as e: + raise CipherError( + f"age 秘密鍵の解釈に失敗しました ({path}): {e}" + ) from e + break # 最初の有効行が AGE-SECRET-KEY-1 でなければ age 鍵ではない + + # ヘッダから判別できなかった場合のフォールバック。OpenSSH 互換の他形式 + # (rsa 以外の PEM など) を pyrage に任せて受け付ける。 + try: + return pyrage.ssh.Identity.from_buffer(raw) + except Exception as e: + raise CipherError( + f"秘密鍵の解釈に失敗しました ({path}): {e}\n" + "対応形式: AGE-SECRET-KEY-1... / OpenSSH (ed25519, rsa)" + ) from e + + +def encrypt(data: bytes, + recipients: Sequence[str] = (), + passphrase: Optional[str] = None) -> bytes: + """data を age で暗号化する + + recipients と passphrase のどちらか一方のみ指定する。両方指定はエラー。 + """ + if passphrase and recipients: + raise CipherError("recipient と passphrase は同時に指定できません") + + if passphrase is not None: + if not passphrase: + raise CipherError("passphrase が空です") + try: + return pyrage.passphrase.encrypt(data, passphrase) + except Exception as e: + raise CipherError(f"passphrase 暗号化に失敗しました: {e}") from e + + if not recipients: + raise CipherError("recipient または passphrase を指定してください") + + resolved = [_resolve_recipient(r) for r in recipients] + try: + return pyrage.encrypt(data, resolved) + except Exception as e: + raise CipherError(f"recipient 暗号化に失敗しました: {e}") from e + + +def decrypt(data: bytes, + identities: Sequence[str] = (), + passphrase: Optional[str] = None) -> bytes: + """age 暗号化済みデータを復号する""" + if passphrase and identities: + raise CipherError("identity と passphrase は同時に指定できません") + + if passphrase is not None: + if not passphrase: + raise CipherError("passphrase が空です") + try: + return pyrage.passphrase.decrypt(data, passphrase) + except Exception as e: + raise CipherError( + "passphrase 復号に失敗しました (パスフレーズが誤っている可能性があります)" + ) from e + + if not identities: + raise CipherError("identity または passphrase を指定してください") + + resolved = [_resolve_identity(p) for p in identities] + try: + return pyrage.decrypt(data, resolved) + except Exception as e: + raise CipherError( + "復号に失敗しました (identity が一致しない / バンドルが破損している可能性があります)" + ) from e + + +def default_recipient_paths() -> List[Path]: + """recipient 省略時に試す既定の公開鍵パス候補 + + ed25519 を優先し、次に rsa を試す。 + """ + ssh = Path.home() / '.ssh' + return [ssh / 'id_ed25519.pub', ssh / 'id_rsa.pub'] + + +def default_identity_paths() -> List[Path]: + """identity 省略時に試す既定の秘密鍵パス候補 + + ed25519 を優先し、次に rsa を試す。 + """ + ssh = Path.home() / '.ssh' + return [ssh / 'id_ed25519', ssh / 'id_rsa'] diff --git a/lib/devbase/env/io_common.py b/lib/devbase/env/io_common.py new file mode 100644 index 0000000..a0b27da --- /dev/null +++ b/lib/devbase/env/io_common.py @@ -0,0 +1,122 @@ +"""env export / import で共通利用する I/O ヘルパ + +io_export / io_import の両方で必要になる「ファイル不在を許容する passphrase 読み取り」 +「省略時の既定 age 鍵 fallback」「0600 でセキュアにバイト列を書き出す」処理を +1 箇所に集約する。 +""" + +from __future__ import annotations + +import getpass +import os +import sys +from pathlib import Path +from typing import List, Optional, Sequence, Type + +from devbase.errors import DevbaseError +from devbase.log import get_logger + +from devbase.env import cipher as _cipher + +logger = get_logger(__name__) + + +def read_passphrase( + passphrase_env: Optional[str], + passphrase_stdin: bool, + error_class: Type[DevbaseError], +) -> Optional[str]: + """env 変数 / stdin から passphrase を読み取る。どちらも指定が無ければ ``None``。 + + 両方指定済みかなどの組み合わせ検証は呼び出し側の責務 (エラーメッセージを + 文脈に合わせるため)。tty 入力時は ``getpass.getpass`` でエコー抑止、 + パイプ入力時は ``stdin.readline()`` で 1 行読む。 + """ + if passphrase_env: + value = os.environ.get(passphrase_env) + if not value: + raise error_class(f"環境変数 {passphrase_env} が空または未設定です") + return value + if passphrase_stdin: + if sys.stdin.isatty(): + try: + return getpass.getpass("passphrase: ", stream=sys.stderr) + except EOFError as e: + raise error_class("stdin からパスフレーズを読み取れませんでした") from e + line = sys.stdin.readline() + if not line: + raise error_class("stdin からパスフレーズを読み取れませんでした") + # CRLF (Windows/WSL からのパイプ) を考慮して \r も剥がす。 + # パスフレーズ末尾に \r が残ると複合化が一致せず原因不明の失敗になる。 + return line.rstrip('\r\n') + return None + + +def resolve_recipient_specs(specs: Sequence[str]) -> List[str]: + """recipient 指定の解決。 + + 明示指定があればそのまま返す。空なら ``~/.ssh/id_ed25519.pub`` → ``id_rsa.pub`` + の順で存在する公開鍵を探し、最初に見つかったものを ``@PATH`` 参照として返す。 + """ + if specs: + return list(specs) + for path in _cipher.default_recipient_paths(): + if path.exists(): + logger.info("recipient 既定鍵を使用: %s", path) + return [f'@{path}'] + return [] + + +def resolve_identity_specs(specs: Sequence[str]) -> List[str]: + """identity 指定の解決。 + + 明示指定があればそのまま返す。空なら ``~/.ssh/id_ed25519`` / ``id_rsa`` の + うち **存在するものをすべて** 返す。``pyrage.decrypt`` は複数 identity を + 受け付け、バンドル内の暗号化対象と一致した identity だけ復号に使われるため、 + 両方を渡しておけば「どの鍵で暗号化されたか分からない」状況でも復号できる + (PR #13 gemini 指摘)。一方 ``resolve_recipient_specs`` は明確に「どの鍵で + 暗号化するか」を選ぶ必要があるため最初の 1 つだけを返す (非対称な仕様)。 + """ + if specs: + return list(specs) + found: List[str] = [] + for path in _cipher.default_identity_paths(): + if path.exists(): + logger.info("identity 既定鍵を使用: %s", path) + found.append(str(path)) + return found + + +def write_secure_bytes(path: Path, data: bytes, *, mode: int = 0o600) -> None: + """``path`` に ``data`` を書き出す (新規・既存どちらも ``mode`` を強制)。 + + ``open(..., 'wb')`` 直後に ``chmod`` する素朴な実装では、umask が緩い環境で + 作成→chmod の間にパーミッションが一瞬広がるウィンドウがある。これを避けるため: + + - 既存ファイルは書き込み前に ``chmod`` で権限を絞ってから ``O_TRUNC`` で上書き + - ``os.open(..., flags, mode)`` で作成時点から ``mode`` を適用 + - mode 引数が無視される環境 (Windows 等) のため後追いでも ``chmod`` を試みる + + ``chmod`` が失敗するプラットフォームでは例外を握りつぶす (主に Windows)。 + """ + path.parent.mkdir(parents=True, exist_ok=True) + if path.exists(): + try: + os.chmod(path, mode) + except OSError: + pass + flags = os.O_WRONLY | os.O_CREAT | os.O_TRUNC + fd = os.open(path, flags, mode) + try: + with os.fdopen(fd, 'wb') as f: + f.write(data) + except BaseException: + try: + os.close(fd) + except OSError: + pass + raise + try: + os.chmod(path, mode) + except OSError: + pass diff --git a/lib/devbase/env/io_export.py b/lib/devbase/env/io_export.py new file mode 100644 index 0000000..be18c51 --- /dev/null +++ b/lib/devbase/env/io_export.py @@ -0,0 +1,186 @@ +"""devbase env export の高レベル実装""" + +from __future__ import annotations + +import getpass # noqa: F401 (tests monkey-patch devbase.env.io_export.getpass) +import re +from dataclasses import dataclass, field +from datetime import datetime +from pathlib import Path +from typing import List, Optional, Sequence + +from devbase.errors import DevbaseError +from devbase.log import get_logger + +from devbase.env import bundle as _bundle +from devbase.env import cipher as _cipher +from devbase.env import io_common as _io_common +from devbase.env import storage as _storage + +logger = get_logger(__name__) + +# 平文出力時に "機密キーが含まれます" の警告を出す判定パターン +_SENSITIVE_PATTERNS = ('KEY', 'SECRET', 'TOKEN', 'PASSWORD', 'CREDENTIALS', 'BASE64') +_ENV_KEY_RE = re.compile(r'^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=', re.MULTILINE) + + +class ExportError(DevbaseError): + """export エラー""" + + +@dataclass +class ExportOptions: + dest: Optional[str] = None + include_global: bool = True + include_metadata: bool = True + include_projects: Optional[List[str]] = None + exclude_projects: List[str] = field(default_factory=list) + recipients: List[str] = field(default_factory=list) + passphrase_env: Optional[str] = None + passphrase_stdin: bool = False + force_unencrypted: bool = False + # S3 backend 専用: バケット既定暗号化が未設定でも export を許可するか + # (オブジェクト単位の SSE はこのフラグに関係なく常に付与される) + unsafe_allow_unencrypted_bucket: bool = False + + +def _default_dest(force_unencrypted: bool) -> str: + # microsecond まで含めて衝突を回避する (PR #22 codex round 3 指摘) + ts = datetime.now().strftime('%Y%m%d-%H%M%S-%f') + suffix = '.dbenv.tar.gz' if force_unencrypted else '.dbenv' + return f'./devbase-env-{ts}{suffix}' + + +def _read_passphrase(opts: ExportOptions) -> Optional[str]: + """既存テストとの互換のために残している thin wrapper。 + 実体は :mod:`devbase.env.io_common.read_passphrase`。""" + return _io_common.read_passphrase( + opts.passphrase_env, opts.passphrase_stdin, ExportError + ) + + +def _sensitive_keys(entries: Sequence[_bundle.BundleEntry]) -> List[str]: + """平文出力に含まれる機密キー候補を返す (警告表示用、.env エントリのみ走査)""" + hits: set[str] = set() + for entry in entries: + if not entry.arcname.endswith('.env'): + continue + try: + text = entry.data.decode('utf-8', errors='ignore') + except Exception: + continue + for key in _ENV_KEY_RE.findall(text): + if any(p in key.upper() for p in _SENSITIVE_PATTERNS): + hits.add(key) + return sorted(hits) + + +def _validate_options(opts: ExportOptions) -> None: + # NOTE: DEST='-' (stdout) と --passphrase-stdin の併用は許可する。 + # export は stdin (passphrase) と stdout (bundle) で別ストリームを使うため + # `echo "pass" | devbase env export - --passphrase-stdin > out` は適法。 + # (import 側は両方 stdin なので併用不可。io_import._validate_options 参照) + if opts.passphrase_env and opts.passphrase_stdin: + raise ExportError("--passphrase-env と --passphrase-stdin は併用できません") + if (opts.passphrase_env or opts.passphrase_stdin) and opts.recipients: + raise ExportError( + "--recipient と --passphrase-env/--passphrase-stdin は併用できません" + ) + if opts.force_unencrypted and ( + opts.recipients or opts.passphrase_env or opts.passphrase_stdin + ): + raise ExportError( + "--force-unencrypted は recipient / passphrase と併用できません" + ) + + +def _encrypt_payload(tar_blob: bytes, opts: ExportOptions) -> bytes: + """``opts`` の鍵指定に従って tar.gz を暗号化する。鍵が無ければ既定鍵を試す""" + passphrase = _read_passphrase(opts) + # NOTE: --recipient と --passphrase-* の排他チェックは _validate_options で + # fail-fast 済み。cipher.encrypt 側にも防御的チェックがある。 + recipients = ( + [] if passphrase is not None + else _io_common.resolve_recipient_specs(opts.recipients) + ) + if not recipients and not passphrase: + raise ExportError( + "暗号化キーが指定されていません。次のいずれかを指定してください:\n" + " --recipient KEY age / OpenSSH 公開鍵\n" + " --passphrase-env VAR 環境変数からパスフレーズ取得\n" + " --passphrase-stdin stdin の最初の行をパスフレーズとして使用\n" + " --force-unencrypted 平文 tar.gz として書き出す (機密キー検知時は警告)\n" + " ~/.ssh/id_ed25519.pub または ~/.ssh/id_rsa.pub があれば " + "--recipient 省略時の既定として使用されます (ed25519 優先)" + ) + return _cipher.encrypt(tar_blob, recipients=recipients, passphrase=passphrase) + + +def _warn_if_plaintext_sensitive(entries: Sequence[_bundle.BundleEntry]) -> None: + sensitive = _sensitive_keys(entries) + if not sensitive: + return + head = ', '.join(sensitive[:10]) + suffix = ' ...' if len(sensitive) > 10 else '' + logger.warning("平文 export に機密キーが含まれます: %s%s", head, suffix) + logger.warning( + "ファイルパーミッションは 0600 で書き出されますが、保管・転送時の暗号化を強く推奨します" + ) + + +def export(devbase_root: Path, opts: ExportOptions) -> int: + """export 本体。CLI ハンドラから呼ばれる""" + _validate_options(opts) + + entries = _bundle.make_entries_from_disk( + devbase_root, + include_global=opts.include_global, + include_metadata=opts.include_metadata, + include_projects=opts.include_projects, + exclude_projects=opts.exclude_projects, + ) + if not entries: + raise ExportError( + "export 対象のファイルがありません " + "(--no-global / --exclude-project の指定や DEVBASE_ROOT を確認してください)" + ) + + logger.info("export 対象 %d 件:", len(entries)) + for entry in entries: + logger.info(" - %s (%d bytes) <- %s", + entry.arcname, len(entry.data), entry.origin) + + tar_blob = _bundle.pack(entries) + logger.debug("tar.gz サイズ: %d bytes", len(tar_blob)) + + if opts.force_unencrypted: + # NOTE: --force-unencrypted と鍵指定の排他チェックは _validate_options で + # fail-fast 済み。ここでは平文出力の警告のみ。 + _warn_if_plaintext_sensitive(entries) + payload = tar_blob + else: + payload = _encrypt_payload(tar_blob, opts) + logger.debug("暗号化後サイズ: %d bytes", len(payload)) + + dest = opts.dest or _default_dest(opts.force_unencrypted) + # 既定名 (opts.dest 未指定) かつローカルパスの場合、既存ファイルの上書きを拒否する + # (microsecond 精度でも理論上は衝突しうるため防御的にチェック) + if not opts.dest and not _storage.is_s3(dest) and not _storage.is_stdio(dest): + if Path(dest).exists(): + raise ExportError( + f"既定出力先 {dest} が既に存在します。" + "出力先を明示的に指定するか、既存ファイルを移動してください" + ) + # S3 など backend 固有のオプションを渡したい場合は s3_options を組み立てる。 + # それ以外 (local/stdio) では未使用なので無害。 + s3_options = (_storage.S3Options.from_env( + unsafe_allow_unencrypted_bucket=opts.unsafe_allow_unencrypted_bucket, + ) if _storage.is_s3(dest) else None) + backend = _storage.resolve(dest, s3_options=s3_options) + backend.write_bytes(dest, payload) + + if _storage.is_stdio(dest): + logger.info("export 完了 (stdout, %d bytes)", len(payload)) + else: + logger.info("export 完了: %s (%d bytes)", dest, len(payload)) + return 0 diff --git a/lib/devbase/env/io_import.py b/lib/devbase/env/io_import.py new file mode 100644 index 0000000..616b9b7 --- /dev/null +++ b/lib/devbase/env/io_import.py @@ -0,0 +1,213 @@ +"""devbase env import の高レベル実装 + +責務: + - SOURCE (file / stdio / s3) の読み込み + - age 復号 (バンドルが暗号化されていれば) + - tar.gz バンドルの展開と sha256 / manifest version の検証 (bundle.unpack) + - merge / replace / replace-keys 計画の作成と適用 + - .env.sources.yml は既定で上書きせず参照用コピーのみ (--merge-metadata で + 新規 source のみ追加) + - 2 フェーズ書き出し (prepare → commit) で部分適用を最小化 + - --backup-dir / --keep-last N で backup を GC + - --dry-run で差分プレビュー + +実装の詳細は :mod:`_import_merge` (merge 計画) と :mod:`_import_atomic` +(backup / atomic 書き込み / rollback) に分割している。 +""" + +from __future__ import annotations + +import getpass # noqa: F401 (tests monkey-patch devbase.env.io_import.getpass) +from dataclasses import dataclass, field +from pathlib import Path +from typing import List, Optional, Tuple + +from devbase.errors import DevbaseError +from devbase.log import get_logger + +from devbase.env import _import_atomic as _atomic +from devbase.env import _import_merge as _merge +from devbase.env import bundle as _bundle +from devbase.env import cipher as _cipher +from devbase.env import io_common as _io_common +from devbase.env import storage as _storage + +logger = get_logger(__name__) + +# 暗号化済みは age テキストヘッダ "age-encryption.org/v1\n" で始まるのに対し、 +# 平文 tar.gz は先頭 2 byte が gzip magic (0x1f 0x8b) となる。これで判別する。 +_GZIP_MAGIC = b'\x1f\x8b' + + +class ImportError(DevbaseError): + """import エラー""" + + +@dataclass +class ImportOptions: + source: str + merge: str = 'keep-existing' + replace_keys: List[str] = field(default_factory=list) + replace: bool = False + dry_run: bool = False + identities: List[str] = field(default_factory=list) + passphrase_env: Optional[str] = None + passphrase_stdin: bool = False + include_projects: Optional[List[str]] = None + exclude_projects: List[str] = field(default_factory=list) + include_global: bool = True + include_metadata: bool = True + merge_metadata: bool = False + backup_dir: Optional[str] = None + keep_last: int = 10 + + +def _read_passphrase(opts: ImportOptions) -> Optional[str]: + """既存テストとの互換のために残している thin wrapper。 + 実体は :mod:`devbase.env.io_common.read_passphrase`。""" + return _io_common.read_passphrase( + opts.passphrase_env, opts.passphrase_stdin, ImportError + ) + + +def _validate_options(opts: ImportOptions) -> None: + if opts.merge not in _merge.MERGE_MODES: + raise ImportError( + f"--merge の値が不正です: {opts.merge!r} " + f"(許可: {', '.join(_merge.MERGE_MODES)})" + ) + if opts.replace and opts.replace_keys: + raise ImportError("--replace と --replace-keys は併用できません") + if opts.passphrase_stdin and opts.source == '-': + raise ImportError( + "SOURCE='-' (stdin) と --passphrase-stdin は併用できません " + "(stdin が衝突します)" + ) + if opts.passphrase_env and opts.passphrase_stdin: + raise ImportError("--passphrase-env と --passphrase-stdin は併用できません") + if opts.identities and (opts.passphrase_env or opts.passphrase_stdin): + raise ImportError( + "--identity と --passphrase-env/--passphrase-stdin は併用できません" + ) + + +def _decrypt_if_needed(blob: bytes, opts: ImportOptions) -> bytes: + """先頭バイトで暗号化済みかを判定して必要なら復号する""" + if blob[:2] == _GZIP_MAGIC: + if opts.identities or opts.passphrase_env or opts.passphrase_stdin: + logger.warning( + "バンドルは平文ですが identity / passphrase が指定されています " + "(使用されません)" + ) + return blob + + passphrase = _read_passphrase(opts) + if passphrase is not None: + return _cipher.decrypt(blob, passphrase=passphrase) + + identities = _io_common.resolve_identity_specs(opts.identities) + if not identities: + raise ImportError( + "バンドルは暗号化されていますが復号キーが指定されていません。\n" + " --identity FILE age / OpenSSH 秘密鍵ファイル\n" + " --passphrase-env VAR 環境変数からパスフレーズ取得\n" + " --passphrase-stdin stdin の最初の行をパスフレーズとして使用\n" + " ~/.ssh/id_ed25519 または ~/.ssh/id_rsa があれば " + "--identity 省略時の既定として使用されます (ed25519 優先)" + ) + return _cipher.decrypt(blob, identities=identities) + + +def _build_plans( + filtered: dict, devbase_root: Path, opts: ImportOptions +) -> Tuple[List[_merge.Plan], Optional[Tuple[Path, bytes]]]: + """フィルタ済みメンバーから書き出し計画と sources.yml の参照用コピー対象を返す""" + 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': + 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, + )) + except _merge.MergeError as e: + raise ImportError(str(e)) from e + return plans, sources_reference + + +def import_bundle(devbase_root: Path, opts: ImportOptions) -> int: + """import 本体。CLI ハンドラから呼ばれる""" + _validate_options(opts) + + backend = _storage.resolve(opts.source) + blob = backend.read_bytes(opts.source) + logger.debug("読み込みサイズ: %d bytes", len(blob)) + + tar_blob = _decrypt_if_needed(blob, opts) + manifest, members = _bundle.unpack(tar_blob) + logger.info("バンドル version=%s, 生成=%s, devbase=%s", + manifest.get('version'), manifest.get('created_at'), + manifest.get('devbase_version')) + + filtered = _merge.filter_members( + members, + include_global=opts.include_global, + include_metadata=opts.include_metadata, + include_projects=opts.include_projects, + exclude_projects=opts.exclude_projects, + ) + if not filtered: + raise ImportError( + "import 対象がありません " + "(--no-global / --include-project の指定とバンドル内容を確認してください)" + ) + + plans, sources_reference = _build_plans(filtered, devbase_root, opts) + + _merge.log_plans(plans, opts.dry_run) + if sources_reference is not None and not opts.merge_metadata: + logger.info( + "%ssources.yml は上書きしません (--merge-metadata 指定時のみ更新, " + "参照用コピーを backup ディレクトリに残します)", + "[dry-run] " if opts.dry_run else "", + ) + + if opts.dry_run: + logger.info("[dry-run] 書き込みは行いません") + return 0 + if not plans and sources_reference is None: + logger.info("変更はありません") + return 0 + + backup_dir = _atomic.make_backup_dir(devbase_root, opts.backup_dir) + logger.info("backup ディレクトリ: %s", backup_dir) + _atomic.backup_existing(plans, sources_reference, backup_dir, devbase_root) + + plans_and_tmps: List[Tuple[_merge.Plan, Path]] = [] + try: + for plan in plans: + tmp = _atomic.write_atomic(plan) + plans_and_tmps.append((plan, tmp)) + except Exception: + _atomic.cleanup_tmps(tmp for _, tmp in plans_and_tmps) + raise + + try: + _atomic.commit(plans_and_tmps, backup_dir, devbase_root) + except _atomic.AtomicError as e: + raise ImportError(str(e)) from e + logger.info("import 完了: %d ファイル更新", len(plans)) + + _atomic.gc_backups(backup_dir, opts.keep_last) + return 0 diff --git a/lib/devbase/env/storage.py b/lib/devbase/env/storage.py new file mode 100644 index 0000000..549e654 --- /dev/null +++ b/lib/devbase/env/storage.py @@ -0,0 +1,341 @@ +"""env バンドルの入出力先 (local / stdio / s3) を抽象化する""" + +from __future__ import annotations + +import os +import sys +from dataclasses import dataclass +from pathlib import Path +from typing import Optional, Protocol, Tuple +from urllib.parse import urlparse + +from devbase.errors import DevbaseError +from devbase.log import get_logger + +logger = get_logger(__name__) + + +class StorageError(DevbaseError): + """ストレージ操作エラー""" + + +class StorageBackend(Protocol): + def write_bytes(self, dest: str, data: bytes) -> None: ... + def read_bytes(self, source: str) -> bytes: ... + + +def _to_local_path(uri: str) -> Path: + """ローカルパス文字列または file:// URI を Path に正規化する""" + parsed = urlparse(uri) + if parsed.scheme.lower() == 'file': + # file:///tmp/x や file://localhost/tmp/x のみ許容 + # file://other-host/tmp/x はホスト情報が脱落するので拒否 + netloc = (parsed.netloc or '').lower() + if netloc not in ('', 'localhost'): + raise StorageError( + f"file:// URI のホスト指定はサポートされていません " + f"(netloc={parsed.netloc!r}, 許可: '' / 'localhost')" + ) + from urllib.request import url2pathname + return Path(url2pathname(parsed.path)).expanduser() + return Path(uri).expanduser() + + +class LocalBackend: + """ローカルファイルシステム""" + + def write_bytes(self, dest: str, data: bytes) -> None: + # 暗号化済みバンドルでも平文 export でも 0600 強制 (TOCTOU 回避)。 + # 共通実装は io_common.write_secure_bytes へ集約。 + from devbase.env import io_common as _io_common + + path = _to_local_path(dest) + try: + _io_common.write_secure_bytes(path, data) + except OSError as e: + raise StorageError(f"書き込みに失敗しました ({path}): {e}") from e + + def read_bytes(self, source: str) -> bytes: + path = _to_local_path(source) + if not path.exists(): + raise StorageError(f"ファイルが見つかりません: {path}") + try: + return path.read_bytes() + except OSError as e: + raise StorageError(f"読み込みに失敗しました ({path}): {e}") from e + + +class StdioBackend: + """`-` 指定での stdin/stdout 入出力 (パイプ運用向け)""" + + def write_bytes(self, dest: str, data: bytes) -> None: + sys.stdout.buffer.write(data) + sys.stdout.buffer.flush() + + def read_bytes(self, source: str) -> bytes: + return sys.stdin.buffer.read() + + +@dataclass +class S3Options: + """S3Backend の挙動パラメータ。 + + `unsafe_allow_unencrypted_bucket` は **export 専用**: True にすると + バケット側のデフォルト暗号化未設定でも export を許可する。 + オブジェクト個別の SSE は `sse` / `sse_kms_key_id` で常に強制される。 + """ + unsafe_allow_unencrypted_bucket: bool = False + sse: str = 'aws:kms' # 'aws:kms' or 'AES256' + sse_kms_key_id: Optional[str] = None + endpoint_url: Optional[str] = None + region: Optional[str] = None + + @classmethod + def from_env( + cls, + *, + unsafe_allow_unencrypted_bucket: bool = False, + ) -> 'S3Options': + """環境変数から既定値を読み取って組み立てる。 + + env vars (任意): + DEVBASE_S3_SSE -> sse (既定: aws:kms) + DEVBASE_S3_SSE_KMS_KEY_ID -> sse_kms_key_id + DEVBASE_S3_ENDPOINT_URL -> endpoint_url (MinIO/LocalStack 用) + DEVBASE_S3_REGION -> region + + boto3 が認識する AWS_PROFILE / AWS_REGION / AWS_ENDPOINT_URL[_S3] / + AWS_ACCESS_KEY_ID 等はそのまま尊重される。 + """ + sse = os.environ.get('DEVBASE_S3_SSE', 'aws:kms') + if sse not in ('aws:kms', 'AES256'): + raise StorageError( + f"DEVBASE_S3_SSE は 'aws:kms' か 'AES256' を指定してください: {sse!r}" + ) + return cls( + unsafe_allow_unencrypted_bucket=unsafe_allow_unencrypted_bucket, + sse=sse, + sse_kms_key_id=os.environ.get('DEVBASE_S3_SSE_KMS_KEY_ID'), + endpoint_url=os.environ.get('DEVBASE_S3_ENDPOINT_URL'), + region=os.environ.get('DEVBASE_S3_REGION'), + ) + + +def _parse_s3_uri(uri: str) -> Tuple[str, str]: + """s3://bucket/key/path を (bucket, key) に分解する + + `urlparse` は S3 キー名に含まれる `?` / `#` を `query` / `fragment` として + 切り落としてしまうため、AWS CLI の挙動に合わせてスキームを除去した上で + 直接 `/` 分割する。 + """ + if not uri[:5].lower() == 's3://': + raise StorageError(f"S3 URI が期待されますが: {uri!r}") + rest = uri[5:] + bucket, sep, key = rest.partition('/') + if not bucket: + raise StorageError( + f"S3 URI のバケット名が空です: {uri!r} " + "(s3://bucket/key の形式で指定してください)" + ) + if not sep or not key: + raise StorageError( + f"S3 URI のキーが空です: {uri!r} " + "(s3://bucket/key の形式で指定してください)" + ) + return bucket, key + + +class S3Backend: + """AWS S3 / S3 互換ストレージ (MinIO 等)。 + + - write_bytes: PutObject 時に ServerSideEncryption を常に付与し、 + `unsafe_allow_unencrypted_bucket=False` のときは + GetBucketEncryption で**バケット側の既定暗号化**も事前確認する。 + - read_bytes: GetObject (暗号化はバケット/オブジェクト側設定に従う)。 + """ + + def __init__(self, options: Optional[S3Options] = None): + self._options = options or S3Options() + self._client = None + + def _get_client(self): + if self._client is not None: + return self._client + import boto3 + + kwargs = {} + if self._options.endpoint_url: + kwargs['endpoint_url'] = self._options.endpoint_url + if self._options.region: + kwargs['region_name'] = self._options.region + try: + self._client = boto3.client('s3', **kwargs) + except Exception as e: + raise StorageError(f"S3 クライアントの生成に失敗しました: {e}") from e + return self._client + + @staticmethod + def _error_code(exc: BaseException) -> Optional[str]: + """botocore.exceptions.ClientError から AWS error code を取り出す""" + resp = getattr(exc, 'response', None) + if isinstance(resp, dict): + return resp.get('Error', {}).get('Code') + return None + + def _verify_bucket_encryption(self, client, bucket: str) -> None: + """バケットレベルの既定暗号化を確認。 + + - 暗号化が設定済み: OK + - 暗号化が未設定 (ServerSideEncryptionConfigurationNotFoundError): + unsafe フラグがあれば警告のみ、無ければ StorageError + - AccessDenied 等で確認できなかった場合は事故防止のため拒否 + (`--unsafe-allow-unencrypted-bucket` でのみバイパス可) + - NoSuchBucket / 認証・接続エラー (AWS API のレスポンスが取れない種類): + ``--unsafe-allow-unencrypted-bucket`` の有無にかかわらず即座に + StorageError を送出する。これらは暗号化未設定とは無関係な根本的失敗で、 + unsafe フラグで「続行」しても後段の put_object が同じエラーで失敗する + だけなので、早期にエラーを返してユーザのトラブルシューティングを助ける + (PR #13 gemini round 5 指摘)。 + """ + try: + client.get_bucket_encryption(Bucket=bucket) + return + except Exception as e: + code = self._error_code(e) + # NoSuchBucket は暗号化設定の問題ではなくバケット不在。続行しても + # put_object が同じ NoSuchBucket で失敗するだけなので、unsafe フラグ + # の有無に関わらず即エラーで返す。 + if code == 'NoSuchBucket': + raise StorageError( + f"S3 バケット '{bucket}' が見つかりません " + "(NoSuchBucket)。URI のバケット名・リージョン・" + "エンドポイント設定を確認してください" + ) from e + # `code is None` は botocore.exceptions.ClientError ではなく + # NoCredentialsError / EndpointConnectionError 等の AWS API + # レスポンスを伴わないローカルエラー (認証・接続系)。 + # これも続行する意味がないため即エラー。 + if code is None: + raise StorageError( + f"S3 への接続・認証設定に問題があります ({bucket}): {e} " + "(AWS_PROFILE / 認証情報 / エンドポイント / ネットワークを確認してください)" + ) from e + if code == 'ServerSideEncryptionConfigurationNotFoundError': + problem = f"S3 バケット '{bucket}' のデフォルト暗号化が未設定です。" + guidance = ( + "バケットポリシーで SSE-KMS or SSE-S3 を有効化するか、" + "明示的に '--unsafe-allow-unencrypted-bucket' を指定してください " + "(オブジェクト単位の SSE はこのオプションに関係なく常に付与されます)" + ) + if self._options.unsafe_allow_unencrypted_bucket: + logger.warning("%s (unsafe フラグにより続行)", problem) + return + raise StorageError(f"{problem}{guidance}") from e + if code in ('AccessDenied', 'AccessDeniedException'): + problem = ( + f"S3 バケット '{bucket}' の暗号化設定を確認できません " + "(GetBucketEncryption 権限がありません)。" + ) + guidance = ( + "バケットポリシーの確認が取れないため export を中止します。" + "権限を付与するか、'--unsafe-allow-unencrypted-bucket' を明示してください" + ) + if self._options.unsafe_allow_unencrypted_bucket: + logger.warning("%s (unsafe フラグにより続行)", problem) + return + raise StorageError(f"{problem}{guidance}") from e + # MinIO / LocalStack 等の S3 互換ストレージでは + # GetBucketEncryption が NotImplemented / MethodNotAllowed / 501 等を返す + # ことがある。`--unsafe-allow-unencrypted-bucket` 指定時は逃げ道として + # 警告のみで続行する (オブジェクト個別の SSE は引き続き付与される)。 + problem = f"バケット暗号化設定の確認に失敗しました ({bucket}): {e}" + if self._options.unsafe_allow_unencrypted_bucket: + logger.warning("%s (unsafe フラグにより続行)", problem) + return + raise StorageError(problem) from e + + def write_bytes(self, dest: str, data: bytes) -> None: + bucket, key = _parse_s3_uri(dest) + client = self._get_client() + self._verify_bucket_encryption(client, bucket) + + put_kwargs = { + 'Bucket': bucket, + 'Key': key, + 'Body': data, + 'ServerSideEncryption': self._options.sse, + } + if self._options.sse == 'aws:kms' and self._options.sse_kms_key_id: + put_kwargs['SSEKMSKeyId'] = self._options.sse_kms_key_id + + try: + client.put_object(**put_kwargs) + except Exception as e: + raise StorageError( + f"S3 への書き込みに失敗しました ({dest}): {e}" + ) from e + logger.info("S3 へ書き込みました: %s (sse=%s)", dest, self._options.sse) + + def read_bytes(self, source: str) -> bytes: + bucket, key = _parse_s3_uri(source) + client = self._get_client() + try: + response = client.get_object(Bucket=bucket, Key=key) + body = response['Body'] + except Exception as e: + code = self._error_code(e) + if code in ('NoSuchKey', 'NoSuchBucket', '404'): + raise StorageError( + f"S3 オブジェクトが見つかりません: {source}" + ) from e + raise StorageError( + f"S3 からの読み込みに失敗しました ({source}): {e}" + ) from e + try: + return body.read() + except Exception as e: + raise StorageError( + f"S3 レスポンスボディの読み取りに失敗しました ({source}): {e}" + ) from e + + +def resolve(uri: str, *, s3_options: Optional[S3Options] = None) -> StorageBackend: + """URI スキームから対応する backend を返す。 + + s3:// は `s3_options` を受け取れる (省略時は S3Options.from_env())。 + `gs://` は PLAN03-1 PR4 廃案により対応しない。 + """ + if uri == '-': + return StdioBackend() + + parsed = urlparse(uri) + scheme = parsed.scheme.lower() + + if scheme in ('', 'file'): + return LocalBackend() + + if scheme == 's3': + return S3Backend(s3_options if s3_options is not None else S3Options.from_env()) + + if scheme == 'gs': + raise StorageError( + "スキーム 'gs://' (GCS) は PLAN03-1 PR4 廃案により対応していません。" + "必要な場合は s3:// 経由 (S3 互換ゲートウェイ) を検討するか、" + "ローカルファイルを介して転送してください" + ) + + # Windows のドライブレター付きパス (例: C:\path, d:/path) は + # urlparse が scheme='c' / 'd' と誤認するため、1 文字アルファベットで + # かつ `://` を伴わないものは LocalBackend にフォールバックする + if len(scheme) == 1 and scheme.isalpha() and '://' not in uri: + return LocalBackend() + + raise StorageError(f"未対応のスキームです: {scheme}://") + + +def is_stdio(uri: str) -> bool: + return uri == '-' + + +def is_s3(uri: str) -> bool: + return urlparse(uri).scheme.lower() == 's3' diff --git a/lib/devbase/env/store.py b/lib/devbase/env/store.py index 057fe0e..13eb6b3 100644 --- a/lib/devbase/env/store.py +++ b/lib/devbase/env/store.py @@ -1,9 +1,13 @@ """Environment variable file store""" +from __future__ import annotations + import os +import re import shutil +from dataclasses import dataclass from pathlib import Path -from typing import Optional, Dict, Union +from typing import Optional, Dict, Union, List from devbase.log import get_logger @@ -49,6 +53,52 @@ def collect_key(env_file, key, *, auto_value=None, prompt=None, mask_after=10): return False +@dataclass +class EnvEntry: + """``.env`` ファイルの 1 行を表すトークン。 + + - ``kind='kv'`` のとき ``key`` / ``value`` が有効 (``raw`` は元の行全体) + - ``kind='comment'`` / ``kind='blank'`` のとき ``raw`` のみ有効 + + コメント・空行を保持してマージ出力するために使う (PR #15 gemini 指摘)。 + """ + kind: str # 'kv' | 'comment' | 'blank' + raw: str = '' + key: Optional[str] = None + value: Optional[str] = None + + +# ``EnvFile.dump_bytes`` / :meth:`EnvFile.save` で値を quote する閾値となる文字集合。 +# シェル ``source`` 時に展開・解釈されうる metachar をすべて含める。``$`` を含む値も +# ``\$`` にエスケープして出力するため、ここで quoting 対象として捕捉する +# (PR #15 gemini 指摘)。 +_NEEDS_QUOTE_CHARS = (' ', '"', "'", '$', '`', '\\', '<', '>', '|', '&', ';', + '(', ')', '#') + + +def _escape_double_quoted(value: str) -> str: + """``"..."`` 内で安全な escape を施す。 + + - ``\\`` → ``\\\\`` + - ``"`` → ``\\"`` + - ``\n`` → ``\\n`` (改行をリテラル化) + - ``$`` → ``\\$`` (``.env`` を ``source`` した際の変数展開を抑止) + """ + return (value.replace('\\', '\\\\') + .replace('"', '\\"') + .replace('\n', '\\n') + .replace('$', '\\$')) + + +# ``_unescape_double_quoted`` 用の逆引きテーブル。``re.sub(r'\\.', ...)`` で +# マッチした 2 文字を 1 文字に置換する。未知エスケープ (例: ``\x``) は +# バックスラッシュごとそのまま残す必要があるため、``dict.get`` の default に +# マッチ文字列自身を返す。末尾単独 ``\`` は ``\\.`` のドットが 2 文字目を +# 要求するため自然にマッチせず、そのまま保持される。 +_DOUBLE_QUOTE_UNESCAPES = {'\\\\': '\\', '\\"': '"', '\\n': '\n', '\\$': '$'} +_DOUBLE_QUOTE_UNESCAPE_RE = re.compile(r'\\.') + + class EnvFile: """ .envファイルの読み書き・バックアップ・バリデーションを管理する。 @@ -59,48 +109,114 @@ def __init__(self, file_path: Union[str, Path]): self._data: Dict[str, str] = {} self._loaded = False + @staticmethod + def parse_bytes(data: bytes) -> Dict[str, str]: + """bytes 列を load と同じ規則でパースして dict を返す (ファイル不要) + + ``save`` / :meth:`EnvFile.dump_bytes` の inverse として振る舞うため、 + ダブルクオート内の ``\\\\`` / ``\\"`` / ``\\n`` / ``\\$`` を unescape する。 + formatter と round-trip 整合性が取れていないと、parse → format で + 二重エスケープが発生する (PR #15 codex 指摘)。 + """ + result: Dict[str, str] = {} + for entry in EnvFile.parse_entries(data): + if entry.kind == 'kv' and entry.key is not None: + result[entry.key] = entry.value or '' + return result + + @staticmethod + def parse_entries(data: bytes) -> List[EnvEntry]: + """``.env`` の各行をトークン化して返す。 + + コメント (``#`` 始まり) と空行は ``EnvEntry(kind='comment'|'blank', raw=...)`` + として保持される。これにより merge 出力時に元のコメント/空白構造を残せる + (PR #15 gemini 指摘)。 + """ + entries: List[EnvEntry] = [] + for raw_line in data.decode('utf-8').splitlines(): + stripped = raw_line.strip() + if not stripped: + entries.append(EnvEntry(kind='blank', raw=raw_line)) + continue + if stripped.startswith('#'): + entries.append(EnvEntry(kind='comment', raw=raw_line)) + continue + if '=' not in stripped: + # ``key=value`` 形式でない行は (滅多に無いが) 原文保持する + entries.append(EnvEntry(kind='comment', raw=raw_line)) + continue + key, _, value = stripped.partition('=') + key = key.strip() + value = value.strip() + if len(value) >= 2 and value[0] == value[-1] and value[0] in ('"', "'"): + quote = value[0] + value = value[1:-1] + if quote == '"': + value = EnvFile._unescape_double_quoted(value) + entries.append(EnvEntry(kind='kv', raw=raw_line, key=key, value=value)) + return entries + + @staticmethod + def _unescape_double_quoted(s: str) -> str: + """``save`` が double-quote 値に対して施した escape を 1 パスで戻す。 + + 単純な逐次 ``replace`` は ``"\\\\n"`` (リテラル ``\\\\`` + ``n``) と + ``"\\n"`` (改行) の区別が付かないため、``\\`` を一括で捉える + ``re.sub`` + 逆引き辞書で処理する。 + """ + return _DOUBLE_QUOTE_UNESCAPE_RE.sub( + lambda m: _DOUBLE_QUOTE_UNESCAPES.get(m.group(0), m.group(0)), s + ) + + @staticmethod + def _format_kv_line(key: str, value: str) -> str: + """1 つの ``key=value`` を ``.env`` 行 (末尾 ``\\n`` 含む) にフォーマットする""" + needs_quote = ( + '\n' in value + or any(c in value for c in _NEEDS_QUOTE_CHARS) + ) + if needs_quote: + return f'{key}="{_escape_double_quoted(value)}"\n' + return f'{key}={value}\n' + + @staticmethod + def dump_bytes(data: Dict[str, str]) -> bytes: + """``save`` と同一フォーマットで dict をバイト列化する (ファイル不要)。 + + ``io_import`` 側でも merge 結果を bytes として持つ必要があるため、 + フォーマット規則を 1 箇所 (このメソッド) に集約する (PR #15 gemini 指摘)。 + """ + lines = [EnvFile._format_kv_line(k, data[k]) for k in sorted(data)] + return ''.join(lines).encode('utf-8') + + @staticmethod + def dump_entries_bytes(entries: List[EnvEntry]) -> bytes: + """``parse_entries`` で得た entries を ``.env`` バイト列に戻す。 + + ``kv`` エントリは現在の ``value`` を ``dump_bytes`` と同じ規則で再フォーマット + する。``comment`` / ``blank`` は ``raw`` をそのまま保持して出力する。 + """ + lines: List[str] = [] + for e in entries: + if e.kind == 'kv' and e.key is not None: + lines.append(EnvFile._format_kv_line(e.key, e.value or '')) + else: + lines.append(e.raw + '\n') + return ''.join(lines).encode('utf-8') + def load(self) -> Dict[str, str]: """ファイルを読み込みkey=valueをパースする""" - self._data = {} - if not self.file_path.exists(): - self._loaded = True - return self._data - - with open(self.file_path, 'r', encoding='utf-8') as f: - for line in f: - line = line.strip() - - if not line or line.startswith('#'): - continue - - if '=' not in line: - continue - - key, _, value = line.partition('=') - key = key.strip() - value = value.strip() - - if value and value[0] == value[-1] and value[0] in ('"', "'"): - value = value[1:-1] - - self._data[key] = value - + self._data = {} + else: + self._data = self.parse_bytes(self.file_path.read_bytes()) self._loaded = True return self._data def save(self) -> None: """現在のデータを.envファイルに保存する""" self.file_path.parent.mkdir(parents=True, exist_ok=True) - - with open(self.file_path, 'w', encoding='utf-8') as f: - for key, value in sorted(self._data.items()): - if '\n' in value or any(c in value for c in (' ', '"', "'", '$', '`', '\\', '<', '>', '|', '&', ';', '(', ')', '#')): - value = value.replace('\\', '\\\\').replace('"', '\\"').replace('\n', '\\n') - f.write(f'{key}="{value}"\n') - else: - f.write(f'{key}={value}\n') - + self.file_path.write_bytes(self.dump_bytes(self._data)) os.chmod(self.file_path, 0o600) def backup(self) -> Optional[Path]: diff --git a/pyproject.toml b/pyproject.toml index e7aefd3..989c1f9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,9 +5,20 @@ description = "Docker-based Development Environment Manager" requires-python = ">=3.10" dependencies = [ "pyyaml>=6.0", + "pyrage>=1.2", + "boto3>=1.34", +] + +[dependency-groups] +dev = [ + "pytest>=8.0", ] [tool.uv] package = false +[tool.pytest.ini_options] +testpaths = ["tests"] +pythonpath = ["lib"] + [tool.uv.sources] diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/cli/__init__.py b/tests/cli/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/cli/test_env_export.py b/tests/cli/test_env_export.py new file mode 100644 index 0000000..ac47db2 --- /dev/null +++ b/tests/cli/test_env_export.py @@ -0,0 +1,345 @@ +"""devbase env export の統合テスト (擬似 DEVBASE_ROOT)""" + +from __future__ import annotations + +import io +from pathlib import Path + +import pyrage +import pytest + +from devbase.env import bundle, cipher +from devbase.env.io_export import ( + ExportOptions, ExportError, _default_dest, _read_passphrase, + _validate_options, export, +) + + +@pytest.fixture +def fake_root(tmp_path): + root = tmp_path / "devbase-root" + (root / "projects" / "alpha").mkdir(parents=True) + (root / "projects" / "beta").mkdir(parents=True) + (root / ".env").write_text("AWS_CONFIG_BASE64=AAAA\nGLOBAL=1\n") + (root / ".env.sources.yml").write_text("sources: {}\n") + (root / "projects" / "alpha" / ".env").write_text("ALPHA_API_KEY=xyz\n") + (root / "projects" / "beta" / ".env").write_text("BETA_DB_PASSWORD=p\n") + return root + + +@pytest.fixture +def age_keys(tmp_path): + identity = pyrage.x25519.Identity.generate() + pub_file = tmp_path / "age.pub" + pub_file.write_text(str(identity.to_public()) + "\n") + id_file = tmp_path / "age.key" + id_file.write_text(str(identity)) + return pub_file, id_file + + +def test_export_local_with_recipient_roundtrips(fake_root, age_keys, tmp_path): + pub_file, id_file = age_keys + dest = tmp_path / "out.dbenv" + + rc = export(fake_root, ExportOptions( + dest=str(dest), + recipients=[f"@{pub_file}"], + )) + assert rc == 0 + assert dest.exists() + assert dest.stat().st_mode & 0o777 == 0o600 + + decrypted = cipher.decrypt(dest.read_bytes(), identities=[str(id_file)]) + manifest, members = bundle.unpack(decrypted) + + assert {e["path"] for e in manifest["files"]} == { + "env/global.env", + "env/sources.yml", + "env/projects/alpha/.env", + "env/projects/beta/.env", + } + assert members["env/global.env"] == b"AWS_CONFIG_BASE64=AAAA\nGLOBAL=1\n" + assert members["env/projects/alpha/.env"] == b"ALPHA_API_KEY=xyz\n" + + +def test_export_rejects_unencrypted_by_default(fake_root, tmp_path, monkeypatch): + monkeypatch.setattr(Path, "home", classmethod(lambda cls: tmp_path / "no-ssh")) + dest = tmp_path / "out.dbenv" + + with pytest.raises(ExportError, match="暗号化キー"): + export(fake_root, ExportOptions(dest=str(dest))) + + +def test_export_force_unencrypted_writes_plaintext_tar_gz(fake_root, tmp_path, caplog): + dest = tmp_path / "out.dbenv.tar.gz" + caplog.set_level("WARNING") + rc = export(fake_root, ExportOptions(dest=str(dest), force_unencrypted=True)) + assert rc == 0 + + # 機密キーが検知されて警告が出ること + assert any("機密キー" in r.message for r in caplog.records) + + manifest, members = bundle.unpack(dest.read_bytes()) + assert "env/global.env" in members + assert dest.stat().st_mode & 0o777 == 0o600 + + +def test_export_allows_stdout_with_passphrase_stdin( + fake_root, age_keys, monkeypatch, capsysbinary +): + """DEST='-' (stdout) と --passphrase-stdin の併用は許可される。 + + stdin (passphrase) と stdout (bundle) は別ストリームのため衝突しない: + echo "pass" | devbase env export - --passphrase-stdin > out.dbenv + """ + fake_stdin = io.StringIO("hunter2\n") + monkeypatch.setattr(fake_stdin, "isatty", lambda: False, raising=False) + monkeypatch.setattr("sys.stdin", fake_stdin) + + rc = export(fake_root, ExportOptions(dest="-", passphrase_stdin=True)) + assert rc == 0 + + out_bytes = capsysbinary.readouterr().out + assert len(out_bytes) > 0 + # age 暗号化ヘッダ (passphrase mode) — `age-encryption.org/v1` を含む + decrypted = cipher.decrypt(out_bytes, passphrase="hunter2") + manifest, members = bundle.unpack(decrypted) + assert "env/global.env" in members + + +def test_export_rejects_both_passphrase_env_and_stdin(fake_root): + with pytest.raises(ExportError, match="--passphrase-env"): + export(fake_root, ExportOptions( + dest="/dev/null", passphrase_env="X", passphrase_stdin=True)) + + +def test_export_rejects_recipient_and_passphrase_combo( + fake_root, age_keys, tmp_path, monkeypatch +): + """--recipient と --passphrase-* を同時指定したら ExportError を上げる。 + 黙って recipients=[] に上書きしてパスフレーズだけで暗号化するのは + ユーザの意図と異なるため明示的に拒否する (cipher.encrypt 側のチェックに + 到達する前にここで弾く)。""" + pub_file, _ = age_keys + monkeypatch.setenv("DEVBASE_TEST_PASS", "s3cr3t") + dest = tmp_path / "out.dbenv" + with pytest.raises(ExportError, match="--recipient"): + export(fake_root, ExportOptions( + dest=str(dest), + recipients=[f"@{pub_file}"], + passphrase_env="DEVBASE_TEST_PASS", + )) + assert not dest.exists() + + +def test_read_passphrase_uses_getpass_on_tty(monkeypatch): + """tty 入力時は getpass.getpass を使い stdin.readline は呼ばない (エコー抑止)""" + fake_stdin = io.StringIO("should-not-be-read\n") + monkeypatch.setattr(fake_stdin, "isatty", lambda: True, raising=False) + monkeypatch.setattr("sys.stdin", fake_stdin) + + calls = {} + + def fake_getpass(prompt='', stream=None): + calls['prompt'] = prompt + calls['stream'] = stream + return "hunter2" + + monkeypatch.setattr("devbase.env.io_export.getpass.getpass", fake_getpass) + + pw = _read_passphrase(ExportOptions(passphrase_stdin=True)) + assert pw == "hunter2" + assert calls['prompt'] == "passphrase: " + assert fake_stdin.read() == "should-not-be-read\n" # stdin は消費されていない + + +def test_read_passphrase_falls_back_to_stdin_on_pipe(monkeypatch, capsys): + """パイプ (非 tty) 入力時は getpass を使わず stdin.readline で読む""" + fake_stdin = io.StringIO("hunter2\n") + monkeypatch.setattr(fake_stdin, "isatty", lambda: False, raising=False) + monkeypatch.setattr("sys.stdin", fake_stdin) + + def fail_getpass(*args, **kwargs): + raise AssertionError("getpass.getpass should not be called for piped stdin") + + monkeypatch.setattr("devbase.env.io_export.getpass.getpass", fail_getpass) + + pw = _read_passphrase(ExportOptions(passphrase_stdin=True)) + assert pw == "hunter2" + assert "passphrase" not in capsys.readouterr().err + + +def test_read_passphrase_strips_crlf_from_pipe(monkeypatch): + """Windows/WSL 由来の CRLF パイプ入力でも末尾 \\r が混入しないこと。 + + `\\r` が残ると age 復号は無音で失敗するため、対称的に `rstrip('\\r\\n')` が必要。 + """ + fake_stdin = io.StringIO("hunter2\r\n") + monkeypatch.setattr(fake_stdin, "isatty", lambda: False, raising=False) + monkeypatch.setattr("sys.stdin", fake_stdin) + + pw = _read_passphrase(ExportOptions(passphrase_stdin=True)) + assert pw == "hunter2" + + +def test_read_passphrase_tty_eof_raises_export_error(monkeypatch): + """tty で getpass が EOFError を投げた場合は ExportError に変換される""" + fake_stdin = io.StringIO("") + monkeypatch.setattr(fake_stdin, "isatty", lambda: True, raising=False) + monkeypatch.setattr("sys.stdin", fake_stdin) + + def raise_eof(*args, **kwargs): + raise EOFError() + + monkeypatch.setattr("devbase.env.io_export.getpass.getpass", raise_eof) + + with pytest.raises(ExportError, match="パスフレーズを読み取れません"): + _read_passphrase(ExportOptions(passphrase_stdin=True)) + + +def test_export_with_passphrase_env(fake_root, tmp_path, monkeypatch): + dest = tmp_path / "out.dbenv" + monkeypatch.setenv("DEVBASE_TEST_PASS", "s3cr3t") + rc = export(fake_root, ExportOptions( + dest=str(dest), passphrase_env="DEVBASE_TEST_PASS")) + assert rc == 0 + decrypted = cipher.decrypt(dest.read_bytes(), passphrase="s3cr3t") + bundle.unpack(decrypted) + + +def test_export_include_exclude_projects(fake_root, age_keys, tmp_path): + pub_file, id_file = age_keys + dest = tmp_path / "out.dbenv" + export(fake_root, ExportOptions( + dest=str(dest), + recipients=[f"@{pub_file}"], + include_projects=["alpha"], + )) + decrypted = cipher.decrypt(dest.read_bytes(), identities=[str(id_file)]) + _, members = bundle.unpack(decrypted) + assert "env/projects/alpha/.env" in members + assert "env/projects/beta/.env" not in members + + +def test_export_stdout_with_recipient(fake_root, age_keys, capsysbinary): + pub_file, id_file = age_keys + rc = export(fake_root, ExportOptions(dest="-", recipients=[f"@{pub_file}"])) + assert rc == 0 + out = capsysbinary.readouterr().out + decrypted = cipher.decrypt(out, identities=[str(id_file)]) + bundle.unpack(decrypted) + + +def test_export_uses_default_recipient_if_present(fake_root, tmp_path, monkeypatch, age_keys): + pub_file, id_file = age_keys + fake_home = tmp_path / "fake-home" + (fake_home / ".ssh").mkdir(parents=True) + (fake_home / ".ssh" / "id_rsa.pub").write_text(pub_file.read_text()) + monkeypatch.setattr(Path, "home", classmethod(lambda cls: fake_home)) + + dest = tmp_path / "out.dbenv" + rc = export(fake_root, ExportOptions(dest=str(dest))) + assert rc == 0 + decrypted = cipher.decrypt(dest.read_bytes(), identities=[str(id_file)]) + bundle.unpack(decrypted) + + +# --- fail-fast 排他チェック (PR #22 round2 gemini 指摘) --- + + +def test_validate_rejects_recipient_and_passphrase_env(): + """_validate_options で --recipient + --passphrase-env が即座に弾かれること。 + + ディスク I/O (make_entries_from_disk / pack) より前に ExportError になる。 + """ + with pytest.raises(ExportError, match="--recipient"): + _validate_options(ExportOptions( + recipients=["age1dummy"], + passphrase_env="SOME_VAR", + )) + + +def test_validate_rejects_recipient_and_passphrase_stdin(): + """_validate_options で --recipient + --passphrase-stdin が即座に弾かれること。""" + with pytest.raises(ExportError, match="--recipient"): + _validate_options(ExportOptions( + recipients=["age1dummy"], + passphrase_stdin=True, + )) + + +def test_validate_rejects_force_unencrypted_with_recipient(): + """_validate_options で --force-unencrypted + --recipient が即座に弾かれること。""" + with pytest.raises(ExportError, match="--force-unencrypted"): + _validate_options(ExportOptions( + force_unencrypted=True, + recipients=["age1dummy"], + )) + + +def test_validate_rejects_force_unencrypted_with_passphrase_env(): + """_validate_options で --force-unencrypted + --passphrase-env が即座に弾かれること。""" + with pytest.raises(ExportError, match="--force-unencrypted"): + _validate_options(ExportOptions( + force_unencrypted=True, + passphrase_env="SOME_VAR", + )) + + +def test_validate_rejects_force_unencrypted_with_passphrase_stdin(): + """_validate_options で --force-unencrypted + --passphrase-stdin が即座に弾かれること。""" + with pytest.raises(ExportError, match="--force-unencrypted"): + _validate_options(ExportOptions( + force_unencrypted=True, + passphrase_stdin=True, + )) + + +# --- default dest 衝突回避 (PR #22 codex round 3 指摘) --- + + +def test_default_dest_includes_microsecond(): + """既定出力名が microsecond 精度を含むこと""" + name = _default_dest(force_unencrypted=False) + # ./devbase-env-YYYYMMDD-HHMMSS-ffffff.dbenv + import re + assert re.match(r'^\./devbase-env-\d{8}-\d{6}-\d{6}\.dbenv$', name), name + + +def test_export_default_dest_rejects_existing_file( + fake_root, age_keys, tmp_path, monkeypatch): + """既定出力先に同名ファイルが既に存在する場合は ExportError を上げる""" + pub_file, _ = age_keys + # _default_dest を固定して衝突を再現する + fixed_name = "./devbase-env-20240101-120000-000000.dbenv" + monkeypatch.setattr("devbase.env.io_export._default_dest", lambda fu: fixed_name) + # 既存ファイルを作成 + existing = tmp_path / "devbase-env-20240101-120000-000000.dbenv" + existing.write_bytes(b"old data") + monkeypatch.chdir(tmp_path) + + with pytest.raises(ExportError, match="既に存在します"): + export(fake_root, ExportOptions( + recipients=[f"@{pub_file}"], + )) + + +def test_export_empty_dest_rejects_existing_file( + fake_root, age_keys, tmp_path, monkeypatch): + """opts.dest が空文字 "" の場合も既定名が使われ、既存ファイル上書きを拒否する。 + + opts.dest="" は falsy なので `not opts.dest` で None と同様に既定名ガードが + 有効になること。(PR #22 round4 gemini 指摘) + """ + pub_file, _ = age_keys + fixed_name = "./devbase-env-20240101-120000-000000.dbenv" + monkeypatch.setattr("devbase.env.io_export._default_dest", lambda fu: fixed_name) + existing = tmp_path / "devbase-env-20240101-120000-000000.dbenv" + existing.write_bytes(b"old data") + monkeypatch.chdir(tmp_path) + + with pytest.raises(ExportError, match="既に存在します"): + export(fake_root, ExportOptions( + dest="", # 空文字 — None と同様に既定名ガードが効くこと + recipients=[f"@{pub_file}"], + )) diff --git a/tests/cli/test_env_import.py b/tests/cli/test_env_import.py new file mode 100644 index 0000000..5926a58 --- /dev/null +++ b/tests/cli/test_env_import.py @@ -0,0 +1,1100 @@ +"""devbase env import の統合テスト (擬似 DEVBASE_ROOT で round-trip / merge / replace / dry-run)""" + +from __future__ import annotations + +import io +import os +from pathlib import Path +from typing import Tuple + +import pyrage +import pytest + +from devbase.env import bundle, cipher +from devbase.env.io_export import ExportOptions, export +from devbase.env.io_import import ( + ImportError as ImportBundleError, + ImportOptions, + _read_passphrase, + import_bundle, +) + + +@pytest.fixture +def fake_root(tmp_path): + """export 用の擬似 DEVBASE_ROOT (PR1 と同じ構造)""" + root = tmp_path / "src-root" + (root / "projects" / "alpha").mkdir(parents=True) + (root / "projects" / "beta").mkdir(parents=True) + (root / ".env").write_text("AWS_CONFIG_BASE64=AAAA\nGLOBAL=1\n") + (root / ".env.sources.yml").write_text( + "sources:\n aws:\n type: tar_base64\n hash: deadbeef\n" + ) + (root / "projects" / "alpha" / ".env").write_text("ALPHA_API_KEY=xyz\n") + (root / "projects" / "beta" / ".env").write_text("BETA_DB_PASSWORD=p\n") + return root + + +@pytest.fixture +def dest_root(tmp_path): + """import 先の擬似 DEVBASE_ROOT (空 or 既存ファイルあり)""" + root = tmp_path / "dst-root" + (root / "projects" / "alpha").mkdir(parents=True) + (root / "projects" / "beta").mkdir(parents=True) + return root + + +@pytest.fixture +def age_keys(tmp_path): + identity = pyrage.x25519.Identity.generate() + pub_file = tmp_path / "age.pub" + pub_file.write_text(str(identity.to_public()) + "\n") + id_file = tmp_path / "age.key" + id_file.write_text(str(identity)) + return pub_file, id_file + + +def _export_bundle(fake_root: Path, age_keys: Tuple[Path, Path], + tmp_path: Path) -> Path: + pub_file, _ = age_keys + dest = tmp_path / "out.dbenv" + rc = export(fake_root, ExportOptions( + dest=str(dest), recipients=[f"@{pub_file}"])) + assert rc == 0 + return dest + + +def test_import_roundtrip_creates_files_with_0600(fake_root, dest_root, age_keys, tmp_path): + _, id_file = age_keys + bundle_path = _export_bundle(fake_root, age_keys, tmp_path) + + rc = import_bundle(dest_root, ImportOptions( + source=str(bundle_path), identities=[str(id_file)])) + assert rc == 0 + + # global と各 project の .env が復元されている + assert (dest_root / ".env").read_text() == "AWS_CONFIG_BASE64=AAAA\nGLOBAL=1\n" + assert (dest_root / "projects" / "alpha" / ".env").read_text() == "ALPHA_API_KEY=xyz\n" + assert (dest_root / "projects" / "beta" / ".env").read_text() == "BETA_DB_PASSWORD=p\n" + + # パーミッションが 0600 + assert (dest_root / ".env").stat().st_mode & 0o777 == 0o600 + assert (dest_root / "projects" / "alpha" / ".env").stat().st_mode & 0o777 == 0o600 + + # sources.yml は既定では上書きしないので存在しない + assert not (dest_root / ".env.sources.yml").exists() + # backup ディレクトリに参照用 sources.yml.imported が残る + backup_root = dest_root / "backups" / "env-import" + assert backup_root.is_dir() + sub = next(backup_root.iterdir()) + assert (sub / "sources.yml.imported").exists() + + +def test_import_dry_run_does_not_modify(fake_root, dest_root, age_keys, tmp_path): + _, id_file = age_keys + bundle_path = _export_bundle(fake_root, age_keys, tmp_path) + + # 既存ファイルを置く + (dest_root / ".env").write_text("EXISTING=keep\n") + os.chmod(dest_root / ".env", 0o600) + + rc = import_bundle(dest_root, ImportOptions( + source=str(bundle_path), identities=[str(id_file)], dry_run=True)) + assert rc == 0 + + # 元の .env は変更されていない + assert (dest_root / ".env").read_text() == "EXISTING=keep\n" + # backup も作られない + assert not (dest_root / "backups").exists() + + +def test_import_keep_existing_only_adds_new_keys(fake_root, dest_root, age_keys, tmp_path): + _, id_file = age_keys + bundle_path = _export_bundle(fake_root, age_keys, tmp_path) + + (dest_root / ".env").write_text("AWS_CONFIG_BASE64=OLD\nKEEP=this\n") + os.chmod(dest_root / ".env", 0o600) + + rc = import_bundle(dest_root, ImportOptions( + source=str(bundle_path), identities=[str(id_file)])) + assert rc == 0 + + text = (dest_root / ".env").read_text() + # 既存の AWS_CONFIG_BASE64 は OLD のまま (keep-existing) + assert "AWS_CONFIG_BASE64=OLD" in text + # 新規キー GLOBAL=1 は追加される + assert "GLOBAL=1" in text + # 既存キー KEEP は残る + assert "KEEP=this" in text + + +def test_import_prefer_incoming_overwrites_existing(fake_root, dest_root, age_keys, tmp_path): + _, id_file = age_keys + bundle_path = _export_bundle(fake_root, age_keys, tmp_path) + + (dest_root / ".env").write_text("AWS_CONFIG_BASE64=OLD\nKEEP=this\n") + os.chmod(dest_root / ".env", 0o600) + + rc = import_bundle(dest_root, ImportOptions( + source=str(bundle_path), identities=[str(id_file)], + merge='prefer-incoming')) + assert rc == 0 + + text = (dest_root / ".env").read_text() + # バンドル側で上書きされる + assert "AWS_CONFIG_BASE64=AAAA" in text + # incoming に無い既存キーは残る + assert "KEEP=this" in text + + +def test_import_replace_keys_only_overwrites_specified(fake_root, dest_root, age_keys, tmp_path): + _, id_file = age_keys + bundle_path = _export_bundle(fake_root, age_keys, tmp_path) + + (dest_root / ".env").write_text("AWS_CONFIG_BASE64=OLD\nGLOBAL=KEEP\n") + os.chmod(dest_root / ".env", 0o600) + + rc = import_bundle(dest_root, ImportOptions( + source=str(bundle_path), identities=[str(id_file)], + replace_keys=['AWS_CONFIG_BASE64'])) + assert rc == 0 + + text = (dest_root / ".env").read_text() + assert "AWS_CONFIG_BASE64=AAAA" in text # 上書きされる + assert "GLOBAL=KEEP" in text # 指定外なので keep + + +def test_import_replace_takes_backup_and_replaces(fake_root, dest_root, age_keys, tmp_path): + _, id_file = age_keys + bundle_path = _export_bundle(fake_root, age_keys, tmp_path) + + (dest_root / ".env").write_text("OLD=value\n") + os.chmod(dest_root / ".env", 0o600) + + rc = import_bundle(dest_root, ImportOptions( + source=str(bundle_path), identities=[str(id_file)], replace=True)) + assert rc == 0 + + text = (dest_root / ".env").read_text() + assert "AWS_CONFIG_BASE64=AAAA" in text + assert "GLOBAL=1" in text + assert "OLD=value" not in text # 完全に置き換わる + + backup_root = dest_root / "backups" / "env-import" + sub = next(backup_root.iterdir()) + assert (sub / ".env").read_text() == "OLD=value\n" + + +def test_import_rejects_replace_with_replace_keys(fake_root, dest_root, age_keys, tmp_path): + _, id_file = age_keys + bundle_path = _export_bundle(fake_root, age_keys, tmp_path) + with pytest.raises(ImportBundleError, match="--replace と --replace-keys"): + import_bundle(dest_root, ImportOptions( + source=str(bundle_path), identities=[str(id_file)], + replace=True, replace_keys=['A'])) + + +def test_import_rejects_stdin_with_passphrase_stdin(dest_root): + with pytest.raises(ImportBundleError, match="SOURCE='-'"): + import_bundle(dest_root, ImportOptions( + source='-', passphrase_stdin=True)) + + +def test_import_rejects_both_passphrase_env_and_stdin(dest_root): + with pytest.raises(ImportBundleError, match="--passphrase-env"): + import_bundle(dest_root, ImportOptions( + source='/dev/null', passphrase_env='X', passphrase_stdin=True)) + + +def test_import_rejects_identity_with_passphrase(dest_root): + """--identity と --passphrase-env/--passphrase-stdin の同時指定は拒否される""" + with pytest.raises(ImportBundleError, match="--identity と --passphrase"): + import_bundle(dest_root, ImportOptions( + source='/dev/null', identities=['/tmp/fake.key'], + passphrase_env='X')) + with pytest.raises(ImportBundleError, match="--identity と --passphrase"): + import_bundle(dest_root, ImportOptions( + source='/tmp/dummy', identities=['/tmp/fake.key'], + passphrase_stdin=True)) + + +def test_read_passphrase_uses_getpass_on_tty(monkeypatch): + """tty 入力時は getpass.getpass を使い stdin.readline は呼ばない (エコー抑止)""" + fake_stdin = io.StringIO("should-not-be-read\n") + monkeypatch.setattr(fake_stdin, "isatty", lambda: True, raising=False) + monkeypatch.setattr("sys.stdin", fake_stdin) + + calls = {} + + def fake_getpass(prompt='', stream=None): + calls['prompt'] = prompt + calls['stream'] = stream + return "hunter2" + + monkeypatch.setattr("devbase.env.io_import.getpass.getpass", fake_getpass) + + pw = _read_passphrase(ImportOptions(source='/dev/null', passphrase_stdin=True)) + assert pw == "hunter2" + assert calls['prompt'] == "passphrase: " + assert fake_stdin.read() == "should-not-be-read\n" # stdin は消費されていない + + +def test_read_passphrase_falls_back_to_stdin_on_pipe(monkeypatch, capsys): + """パイプ (非 tty) 入力時は getpass を使わず stdin.readline で読む""" + fake_stdin = io.StringIO("piped-pass\n") + monkeypatch.setattr(fake_stdin, "isatty", lambda: False, raising=False) + monkeypatch.setattr("sys.stdin", fake_stdin) + + def fail_getpass(*args, **kwargs): + raise AssertionError("getpass.getpass should not be called for piped stdin") + + monkeypatch.setattr("devbase.env.io_import.getpass.getpass", fail_getpass) + + pw = _read_passphrase(ImportOptions(source='/dev/null', passphrase_stdin=True)) + assert pw == "piped-pass" + assert "passphrase" not in capsys.readouterr().err + + +def test_read_passphrase_tty_eof_raises_import_error(monkeypatch): + """tty で getpass が EOFError を投げた場合は ImportError に変換される""" + fake_stdin = io.StringIO("") + monkeypatch.setattr(fake_stdin, "isatty", lambda: True, raising=False) + monkeypatch.setattr("sys.stdin", fake_stdin) + + def raise_eof(*args, **kwargs): + raise EOFError() + + monkeypatch.setattr("devbase.env.io_import.getpass.getpass", raise_eof) + + with pytest.raises(ImportBundleError, match="パスフレーズを読み取れません"): + _read_passphrase(ImportOptions(source='/dev/null', passphrase_stdin=True)) + + +def test_import_rejects_unknown_manifest_version(fake_root, dest_root, age_keys, tmp_path): + """manifest.version が SUPPORTED_MANIFEST_VERSION より大きいバンドルは拒否される""" + import gzip + import io as _io + import tarfile + import yaml + + pub_file, id_file = age_keys + # 通常 export + bundle_path = _export_bundle(fake_root, age_keys, tmp_path) + # 復号して中身を書き換えてから age で再暗号化する + plain = cipher.decrypt(bundle_path.read_bytes(), identities=[str(id_file)]) + + # tar.gz を再構築して manifest.version=999 にする + buf_in = _io.BytesIO(plain) + tin = tarfile.open(fileobj=buf_in, mode='r:gz') + out = _io.BytesIO() + with gzip.GzipFile(fileobj=out, mode='wb', mtime=0) as gz: + with tarfile.open(fileobj=gz, mode='w', format=tarfile.PAX_FORMAT) as tout: + for info in tin.getmembers(): + data = tin.extractfile(info).read() + if info.name == bundle.MANIFEST_NAME: + manifest = yaml.safe_load(data) + manifest['version'] = 999 + data = yaml.safe_dump(manifest, sort_keys=False).encode('utf-8') + ti = tarfile.TarInfo(name=info.name) + ti.size = len(data) + ti.mtime = 0 + ti.mode = 0o600 + tout.addfile(ti, _io.BytesIO(data)) + tin.close() + + bad_plain = out.getvalue() + bad = pyrage.encrypt(bad_plain, + [pyrage.ssh.Recipient.from_str(pub_file.read_text().strip())] + if pub_file.read_text().strip().startswith('ssh-') + else [pyrage.x25519.Recipient.from_str(pub_file.read_text().strip())]) + bad_path = tmp_path / "bad.dbenv" + bad_path.write_bytes(bad) + + with pytest.raises(bundle.BundleError, match="サポートされていません"): + import_bundle(dest_root, ImportOptions( + source=str(bad_path), identities=[str(id_file)])) + + +def test_import_preserves_lf_line_endings(fake_root, dest_root, age_keys, tmp_path): + _, id_file = age_keys + # CRLF を排除した想定: export → import で LF が保持されること + (fake_root / ".env").write_text("A=1\nB=2\n") + bundle_path = _export_bundle(fake_root, age_keys, tmp_path) + + rc = import_bundle(dest_root, ImportOptions( + source=str(bundle_path), identities=[str(id_file)])) + assert rc == 0 + raw = (dest_root / ".env").read_bytes() + assert b'\r' not in raw + assert raw.endswith(b'\n') + + +def test_import_keep_last_gc_removes_old_backups(fake_root, dest_root, age_keys, tmp_path): + _, id_file = age_keys + bundle_path = _export_bundle(fake_root, age_keys, tmp_path) + + backup_root = dest_root / "backups" / "env-import" + # 既存の古い backup を 5 個事前作成する (タイムスタンプ命名規則に合わせる) + backup_root.mkdir(parents=True) + for i in range(5): + (backup_root / f"20260101-00000{i}").mkdir() + + rc = import_bundle(dest_root, ImportOptions( + source=str(bundle_path), identities=[str(id_file)], keep_last=3)) + assert rc == 0 + + remaining = sorted(p.name for p in backup_root.iterdir()) + assert len(remaining) == 3 + # 最新 3 個に絞られる: 既存の旧フォーマット 2 個 + dbenv- prefix 付き新規 backup + # 新規 backup は dbenv- prefix 付き (ソート順で末尾) + assert remaining[-1].startswith('dbenv-') + + +def test_import_include_project_filter(fake_root, dest_root, age_keys, tmp_path): + _, id_file = age_keys + bundle_path = _export_bundle(fake_root, age_keys, tmp_path) + + rc = import_bundle(dest_root, ImportOptions( + source=str(bundle_path), identities=[str(id_file)], + include_projects=['alpha'])) + assert rc == 0 + assert (dest_root / "projects" / "alpha" / ".env").exists() + assert not (dest_root / "projects" / "beta" / ".env").exists() + + +def test_import_plaintext_bundle(fake_root, dest_root, tmp_path): + """--force-unencrypted で出力した平文 tar.gz もそのまま import できる""" + dest = tmp_path / "out.dbenv.tar.gz" + rc = export(fake_root, ExportOptions(dest=str(dest), force_unencrypted=True)) + assert rc == 0 + + rc = import_bundle(dest_root, ImportOptions(source=str(dest))) + assert rc == 0 + assert (dest_root / ".env").exists() + + +def test_import_merge_metadata_adds_only_new_sources(fake_root, dest_root, age_keys, tmp_path): + _, id_file = age_keys + # 既存 sources.yml を用意 (aws のみ。bundle 側も aws を持つ) + (dest_root / ".env.sources.yml").write_text( + "sources:\n aws:\n type: tar_base64\n hash: existinghash\n" + ) + bundle_path = _export_bundle(fake_root, age_keys, tmp_path) + + rc = import_bundle(dest_root, ImportOptions( + source=str(bundle_path), identities=[str(id_file)], + merge_metadata=True)) + assert rc == 0 + + import yaml as _yaml + data = _yaml.safe_load((dest_root / ".env.sources.yml").read_text()) + # 既存 aws は維持される (hash=existinghash のまま) + assert data['sources']['aws']['hash'] == 'existinghash' + + +def test_import_no_metadata_skips_sources_yml(fake_root, dest_root, age_keys, tmp_path): + _, id_file = age_keys + bundle_path = _export_bundle(fake_root, age_keys, tmp_path) + + rc = import_bundle(dest_root, ImportOptions( + source=str(bundle_path), identities=[str(id_file)], + include_metadata=False)) + assert rc == 0 + # 参照用コピーも作られない (filter で除外されるため) + backup_root = dest_root / "backups" / "env-import" + sub = next(backup_root.iterdir()) + assert not (sub / "sources.yml.imported").exists() + + +def test_import_replace_keys_adds_unspecified_new_keys(fake_root, dest_root, age_keys, tmp_path): + """--replace-keys 指定外でも、既存ファイルに無い incoming キーは追加される + (CLI help 'other keys behave like keep-existing' に整合)""" + _, id_file = age_keys + bundle_path = _export_bundle(fake_root, age_keys, tmp_path) + + # 既存は AWS_CONFIG_BASE64 のみ。incoming は AWS_CONFIG_BASE64 + GLOBAL=1 + (dest_root / ".env").write_text("AWS_CONFIG_BASE64=OLD\n") + os.chmod(dest_root / ".env", 0o600) + + rc = import_bundle(dest_root, ImportOptions( + source=str(bundle_path), identities=[str(id_file)], + replace_keys=['AWS_CONFIG_BASE64'])) + assert rc == 0 + + text = (dest_root / ".env").read_text() + assert "AWS_CONFIG_BASE64=AAAA" in text # 指定キーは上書き + assert "GLOBAL=1" in text # 指定外でも既存に無い新規キーは追加される (keep-existing 相当) + + +def test_rollback_unlinks_newly_created_files_on_commit_failure( + fake_root, dest_root, age_keys, tmp_path, monkeypatch): + """commit フェーズ途中失敗時、元ファイル不在で新規作成された target は unlink され、 + 部分適用状態が残らないこと""" + from devbase.env import _import_atomic as _atomic + from devbase.env import io_import as _io_import + + _, id_file = age_keys + bundle_path = _export_bundle(fake_root, age_keys, tmp_path) + + # dest には元ファイルが一切無い (= 全 plan op='create') + assert not (dest_root / ".env").exists() + assert not (dest_root / "projects" / "alpha" / ".env").exists() + + # 2 つ目以降の os.replace で失敗させる + original_replace = os.replace + call_count = {'n': 0} + + def failing_replace(src, dst): + call_count['n'] += 1 + if call_count['n'] >= 2: + raise OSError("simulated commit failure") + return original_replace(src, dst) + + monkeypatch.setattr(_atomic.os, 'replace', failing_replace) + + with pytest.raises(_io_import.ImportError, match="commit フェーズで失敗"): + import_bundle(dest_root, ImportOptions( + source=str(bundle_path), identities=[str(id_file)])) + + # rollback で新規作成 (op='create') の .env は削除されていること + assert not (dest_root / ".env").exists() + # まだ commit されていない target ももちろん存在しない + assert not (dest_root / "projects" / "beta" / ".env").exists() + + +def test_gc_backups_only_removes_timestamp_dirs(fake_root, dest_root, age_keys, tmp_path): + """--backup-dir 指定時でも、devbase が作った timestamp 形式以外のディレクトリは + GC で削除されない""" + _, id_file = age_keys + bundle_path = _export_bundle(fake_root, age_keys, tmp_path) + + custom_backup_root = tmp_path / "user-backups" + custom_backup_root.mkdir() + # 関係ないディレクトリ + unrelated = custom_backup_root / "important-user-data" + unrelated.mkdir() + (unrelated / "keep.txt").write_text("must not be deleted") + # 関係ないファイル + unrelated_file = custom_backup_root / "readme.txt" + unrelated_file.write_text("must not be deleted") + # devbase 命名 (旧フォーマット) の古い backup を keep_last 超に置く + for i in range(5): + (custom_backup_root / f"20240101-00000{i}").mkdir() + + rc = import_bundle(dest_root, ImportOptions( + source=str(bundle_path), identities=[str(id_file)], + backup_dir=str(custom_backup_root), keep_last=3)) + assert rc == 0 + + # 無関係なディレクトリ/ファイルは残る + assert unrelated.exists() + assert (unrelated / "keep.txt").exists() + assert unrelated_file.exists() + # timestamp 形式は keep_last=3 まで絞られる (新規 backup 含む) + timestamp_dirs = sorted( + p.name for p in custom_backup_root.iterdir() + if p.is_dir() and p.name not in ('important-user-data',) + ) + assert len(timestamp_dirs) == 3 + + +def test_gc_backups_ignores_bare_timestamp_dirs_from_other_tools( + fake_root, dest_root, age_keys, tmp_path): + """--backup-dir 親にタイムスタンプ形式だが prefix 無しの無関係ディレクトリがあっても + 旧フォーマット (後方互換) 以外は GC 対象にならない。新たに作られる backup は + dbenv- prefix 付きになる""" + _, id_file = age_keys + bundle_path = _export_bundle(fake_root, age_keys, tmp_path) + + custom_backup_root = tmp_path / "shared-backups" + custom_backup_root.mkdir() + # 他ツールが作ったタイムスタンプ風ディレクトリ (prefix 無し・microsecond 付き形式に + # 一致しないパターン: 例えば "backup-20240101-120000" は regex に引っかからない) + other_tool_dir = custom_backup_root / "backup-20240101-120000" + other_tool_dir.mkdir() + (other_tool_dir / "data.db").write_text("important") + + rc = import_bundle(dest_root, ImportOptions( + source=str(bundle_path), identities=[str(id_file)], + backup_dir=str(custom_backup_root), keep_last=1)) + assert rc == 0 + + # 他ツールのディレクトリは無傷 + assert other_tool_dir.exists() + assert (other_tool_dir / "data.db").read_text() == "important" + + # 新しい backup は dbenv- prefix 付き + new_backups = [p for p in custom_backup_root.iterdir() + if p.is_dir() and p.name.startswith("dbenv-")] + assert len(new_backups) == 1 + + +def test_import_passphrase_env_roundtrip(fake_root, dest_root, tmp_path, monkeypatch): + dest = tmp_path / "out.dbenv" + monkeypatch.setenv("DEVBASE_TEST_PASS", "s3cr3t") + rc = export(fake_root, ExportOptions( + dest=str(dest), passphrase_env="DEVBASE_TEST_PASS")) + assert rc == 0 + + rc = import_bundle(dest_root, ImportOptions( + source=str(dest), passphrase_env="DEVBASE_TEST_PASS")) + assert rc == 0 + assert (dest_root / ".env").exists() + + +def test_import_preserves_escaped_values_no_double_escape( + dest_root, age_keys, tmp_path): + """値に backslash / quote / newline / spaces が含まれていても + export → import で二重エスケープされないことを保証する (PR #15 codex 指摘)""" + _, id_file = age_keys + pub_file, _ = age_keys + + # 特殊文字を含む .env を持つ source root を構築 + src_root = tmp_path / "esc-src" + (src_root / "projects" / "alpha").mkdir(parents=True) + raw_env = ( + 'BACKSLASH="a\\\\b"\n' # 値: a\b (3 chars) + 'QUOTE_IN_VALUE="he said \\"hi\\""\n' # 値: he said "hi" + 'WITH_NEWLINE="line1\\nline2"\n' # 値: line1line2 + 'WITH_SPACE="value with space"\n' # 値: value with space + 'PLAIN=simple\n' # 値: simple + ) + (src_root / ".env").write_text(raw_env) + (src_root / "projects" / "alpha" / ".env").write_text( + 'ALPHA_BACK="a\\\\b"\n' + ) + + bundle_path = tmp_path / "esc.dbenv" + rc = export(src_root, ExportOptions( + dest=str(bundle_path), recipients=[f"@{pub_file}"])) + assert rc == 0 + + # 新規作成 (dest 側に既存ファイル無し) + rc = import_bundle(dest_root, ImportOptions( + source=str(bundle_path), identities=[str(id_file)])) + assert rc == 0 + + # 新規作成時は incoming_bytes をそのまま使うので元バイト列と一致する + assert (dest_root / ".env").read_text() == raw_env + + # EnvFile から読んだ際に escape が正しく解釈されること (parse_bytes round-trip) + from devbase.env.store import EnvFile + parsed = EnvFile.parse_bytes((dest_root / ".env").read_bytes()) + assert parsed['BACKSLASH'] == 'a\\b' + assert parsed['QUOTE_IN_VALUE'] == 'he said "hi"' + assert parsed['WITH_NEWLINE'] == 'line1\nline2' + assert parsed['WITH_SPACE'] == 'value with space' + assert parsed['PLAIN'] == 'simple' + + +def test_import_merge_round_trips_escaped_values( + dest_root, age_keys, tmp_path): + """既存ファイルがあって merge する場合でも、parse → format の round-trip で + 値が壊れない (二重エスケープしない)""" + _, id_file = age_keys + pub_file, _ = age_keys + + src_root = tmp_path / "esc-src2" + (src_root / "projects" / "alpha").mkdir(parents=True) + (src_root / ".env").write_text('NEW_BACK="a\\\\b"\n') + + bundle_path = tmp_path / "esc2.dbenv" + rc = export(src_root, ExportOptions( + dest=str(bundle_path), recipients=[f"@{pub_file}"])) + assert rc == 0 + + # dest に既存ファイルを置く (merge 経路に入る) + (dest_root / ".env").write_text('EXISTING="x\\\\y"\n') + os.chmod(dest_root / ".env", 0o600) + + rc = import_bundle(dest_root, ImportOptions( + source=str(bundle_path), identities=[str(id_file)], + merge='prefer-incoming')) + assert rc == 0 + + from devbase.env.store import EnvFile + parsed = EnvFile.parse_bytes((dest_root / ".env").read_bytes()) + # 二重エスケープされていないので、parse 後の値は元の 3 文字 "a\\b" + assert parsed['NEW_BACK'] == 'a\\b' + assert parsed['EXISTING'] == 'x\\y' + + +def test_rollback_unlinks_newly_created_sources_yml( + fake_root, dest_root, age_keys, tmp_path, monkeypatch): + """sources.yml を --merge-metadata で新規作成中に commit 失敗すると、 + ロールバックで sources.yml が削除されること (PR #15 gemini 指摘)""" + from devbase.env import _import_atomic as _atomic + from devbase.env import io_import as _io_import + + _, id_file = age_keys + bundle_path = _export_bundle(fake_root, age_keys, tmp_path) + + # dest には sources.yml が無い状態。--merge-metadata で新規作成パスに入る + assert not (dest_root / ".env.sources.yml").exists() + + # commit 中に sources.yml の rename だけ失敗させる (最後のファイル) + original_replace = os.replace + + def failing_replace(src, dst): + if str(dst).endswith('.env.sources.yml'): + raise OSError("simulated commit failure on sources.yml") + return original_replace(src, dst) + + monkeypatch.setattr(_atomic.os, 'replace', failing_replace) + + with pytest.raises(_io_import.ImportError, match="commit"): + import_bundle(dest_root, ImportOptions( + source=str(bundle_path), identities=[str(id_file)], + merge_metadata=True)) + + # sources.yml はもともと存在しなかったので、ロールバックで unlink されているはず + assert not (dest_root / ".env.sources.yml").exists() + + +def test_commit_failure_cleans_remaining_import_tmp_files( + fake_root, dest_root, age_keys, tmp_path, monkeypatch): + """_commit 失敗時に、まだ rename されていない .import.tmp ファイルが残らないこと + (PR #15 gemini 指摘)""" + from devbase.env import _import_atomic as _atomic + from devbase.env import io_import as _io_import + + _, id_file = age_keys + bundle_path = _export_bundle(fake_root, age_keys, tmp_path) + + original_replace = os.replace + call_count = {'n': 0} + + def failing_replace(src, dst): + call_count['n'] += 1 + if call_count['n'] >= 2: + raise OSError("simulated commit failure") + return original_replace(src, dst) + + monkeypatch.setattr(_atomic.os, 'replace', failing_replace) + + with pytest.raises(_io_import.ImportError, match="commit"): + import_bundle(dest_root, ImportOptions( + source=str(bundle_path), identities=[str(id_file)])) + + # 残骸の .import.tmp ファイルが無いこと + leftover = list(dest_root.rglob('*.import.tmp')) + assert leftover == [], f"残骸の tmp が残っている: {leftover}" + + +def test_backup_dir_collision_avoidance(fake_root, dest_root, age_keys, tmp_path): + """同じプロセス内で連続して import を実行しても、backup ディレクトリ名が衝突せず + 前回バックアップを上書きしないこと (PR #15 codex 指摘)""" + _, id_file = age_keys + bundle_path = _export_bundle(fake_root, age_keys, tmp_path) + + # 1 回目 + rc = import_bundle(dest_root, ImportOptions( + source=str(bundle_path), identities=[str(id_file)])) + assert rc == 0 + # 2 回目 (同一プロセス内, おそらく同一秒) + rc = import_bundle(dest_root, ImportOptions( + source=str(bundle_path), identities=[str(id_file)])) + assert rc == 0 + + backup_root = dest_root / "backups" / "env-import" + subdirs = sorted(p.name for p in backup_root.iterdir() if p.is_dir()) + # 2 つの異なる backup ディレクトリが残っていること + assert len(subdirs) == 2, f"backup が衝突して 1 つになっている: {subdirs}" + + +def test_envfile_parse_bytes_round_trip_with_escapes(): + """``EnvFile.parse_bytes`` が ``save`` が施す escape を正しく逆変換すること + (PR #15 codex 指摘の double-escape 回避テスト)""" + from devbase.env.store import EnvFile + + # 直接 EnvFile.save と同じ規則で encode したものを parse_bytes で復元 + raw = ( + 'BACKSLASH="a\\\\b"\n' # a\b + 'QUOTED="he said \\"hi\\""\n' # he said "hi" + 'NL="x\\ny"\n' # xy + 'PLAIN=simple\n' + 'EMPTY=""\n' # empty string with quotes + ) + parsed = EnvFile.parse_bytes(raw.encode('utf-8')) + assert parsed['BACKSLASH'] == 'a\\b' + assert parsed['QUOTED'] == 'he said "hi"' + assert parsed['NL'] == 'x\ny' + assert parsed['PLAIN'] == 'simple' + assert parsed['EMPTY'] == '' + + # 「リテラル ``\\n``」(2 文字: backslash + 'n') を含む値も区別できること + # save は ``a\\nb`` (3 chars) を ``"a\\\\nb"`` に変換するので、これを parse_bytes + # に通せば元の 3 文字に戻る + raw2 = 'LITERAL="a\\\\nb"\n' + parsed2 = EnvFile.parse_bytes(raw2.encode('utf-8')) + assert parsed2['LITERAL'] == 'a\\nb' # backslash + 'n' + 'b' (3 chars) + + +def test_envfile_dollar_escape_round_trip(): + """``$`` を含む値は ``\\$`` にエスケープされ、``source`` 時に変数展開されない + (PR #15 gemini 指摘)""" + from devbase.env.store import EnvFile + + # dump → parse の round-trip で値が保たれる + data = { + 'DOLLAR': '$HOME', # 単純な変数展開を含む + 'PRICE': 'cost is $100', # 値内の $ + 'ESCAPED_LIKE': 'a\\$b', # backslash + $ の組み合わせ + 'PLAIN_NUM': '12345', # quote 不要なケース + } + dumped = EnvFile.dump_bytes(data) + text = dumped.decode('utf-8') + # $ が裸 (バックスラッシュ無し) で出力されていないこと + # ($ の直前は必ず \ である or 行の終端 / 別の \\) + for line in text.splitlines(): + if '=' in line and '"' in line: + # ダブルクオート内に裸の $ があるかチェック + _, _, val = line.partition('=') + # 値部分の $ をすべて検査: 直前の文字が \\ であること + for idx, ch in enumerate(val): + if ch == '$': + assert idx > 0 and val[idx - 1] == '\\', ( + f"unescaped $ in dump: {line!r}" + ) + parsed = EnvFile.parse_bytes(dumped) + assert parsed == data + + +def test_env_import_merge_preserves_comments_and_blanks( + fake_root, dest_root, age_keys, tmp_path): + """merge 経路で既存 ``.env`` のコメントと空行が保持されること (PR #15 gemini 指摘)""" + _, id_file = age_keys + pub_file, _ = age_keys + + # incoming bundle: AWS_CONFIG_BASE64=AAAA + GLOBAL=1 + src_root = tmp_path / "comment-src" + src_root.mkdir() + (src_root / ".env").write_text("AWS_CONFIG_BASE64=AAAA\nGLOBAL=1\n") + bundle_path = tmp_path / "comment.dbenv" + rc = export(src_root, ExportOptions( + dest=str(bundle_path), recipients=[f"@{pub_file}"])) + assert rc == 0 + + # 既存 dest .env にコメント・空行・既存キーを配置 + existing_text = ( + "# Top-level header comment\n" + "\n" + "# AWS section\n" + "AWS_CONFIG_BASE64=OLD\n" + "\n" + "# user-managed key\n" + "KEEP=this\n" + ) + (dest_root / ".env").write_text(existing_text) + os.chmod(dest_root / ".env", 0o600) + + rc = import_bundle(dest_root, ImportOptions( + source=str(bundle_path), identities=[str(id_file)], + merge='prefer-incoming')) + assert rc == 0 + + out = (dest_root / ".env").read_text() + # コメント・空行が保持されている + assert "# Top-level header comment" in out + assert "# AWS section" in out + assert "# user-managed key" in out + # 既存値は prefer-incoming で AAAA に書き換わる + assert "AWS_CONFIG_BASE64=AAAA" in out + # 既存にしか無かった KEEP は維持 + assert "KEEP=this" in out + # incoming にしか無かった GLOBAL は末尾に追加 + assert "GLOBAL=1" in out + # 空行も最低 1 つ残っている + assert "\n\n" in out + + +def test_env_import_keep_existing_preserves_comments( + fake_root, dest_root, age_keys, tmp_path): + """keep-existing 経路でもコメントが保持されること""" + _, id_file = age_keys + pub_file, _ = age_keys + + src_root = tmp_path / "keep-src" + src_root.mkdir() + (src_root / ".env").write_text("INCOMING_KEY=incoming\n") + bundle_path = tmp_path / "keep.dbenv" + rc = export(src_root, ExportOptions( + dest=str(bundle_path), recipients=[f"@{pub_file}"])) + assert rc == 0 + + (dest_root / ".env").write_text( + "# This comment must survive\nKEEP_ME=v\n" + ) + os.chmod(dest_root / ".env", 0o600) + + rc = import_bundle(dest_root, ImportOptions( + source=str(bundle_path), identities=[str(id_file)])) + assert rc == 0 + + out = (dest_root / ".env").read_text() + assert "# This comment must survive" in out + assert "KEEP_ME=v" in out + assert "INCOMING_KEY=incoming" in out + + +def test_env_import_dollar_value_is_escaped_after_merge( + fake_root, dest_root, age_keys, tmp_path): + """``$`` を含む値が merge 後の ``.env`` 上でエスケープされていること + (シェルで source した時の変数展開を防ぐ / PR #15 gemini 指摘)""" + _, id_file = age_keys + pub_file, _ = age_keys + + src_root = tmp_path / "dollar-src" + src_root.mkdir() + # 値に $ を含む。export 側 (EnvFile.save 形式) で書き出される + (src_root / ".env").write_text('PRICE="cost is \\$100"\n') + bundle_path = tmp_path / "dollar.dbenv" + rc = export(src_root, ExportOptions( + dest=str(bundle_path), recipients=[f"@{pub_file}"])) + assert rc == 0 + + # 既存 dest .env (merge 経路に入る) + (dest_root / ".env").write_text("EXISTING=keep\n") + os.chmod(dest_root / ".env", 0o600) + + rc = import_bundle(dest_root, ImportOptions( + source=str(bundle_path), identities=[str(id_file)], + merge='prefer-incoming')) + assert rc == 0 + + raw_text = (dest_root / ".env").read_text() + # 裸の $ が現れていないこと (\ の直後でなければ NG) + for line in raw_text.splitlines(): + if 'PRICE' not in line: + continue + # 値の中の $ は必ず \\ の直後 + _, _, val = line.partition('=') + for idx, ch in enumerate(val): + if ch == '$': + assert idx > 0 and val[idx - 1] == '\\', ( + f"unescaped $ in merged .env: {line!r}" + ) + + from devbase.env.store import EnvFile + parsed = EnvFile.parse_bytes((dest_root / ".env").read_bytes()) + assert parsed['PRICE'] == 'cost is $100' + assert parsed['EXISTING'] == 'keep' + + +# --- PR #15 round5: コメント / 空行のみの既存 .env が create 扱いされて +# 上書きされないこと (`existing` dict が空でも target.exists() で merge に入る)。 + +def _setup_comment_only_dest(dest_root: Path) -> str: + """key=value を含まずコメント / 空行のみで構成された既存 .env を作る""" + text = ( + "# user-managed header (no kv yet)\n" + "\n" + "# section: aws\n" + "\n" + ) + (dest_root / ".env").write_text(text) + os.chmod(dest_root / ".env", 0o600) + return text + + +def _build_simple_bundle(tmp_path: Path, pub_file: Path, + name: str = "comment-only") -> Path: + src_root = tmp_path / f"{name}-src" + src_root.mkdir() + (src_root / ".env").write_text("INCOMING_KEY=incoming\n") + bundle_path = tmp_path / f"{name}.dbenv" + rc = export(src_root, ExportOptions( + dest=str(bundle_path), recipients=[f"@{pub_file}"])) + assert rc == 0 + return bundle_path + + +@pytest.mark.parametrize("merge_mode", ["prefer-incoming", "keep-existing"]) +def test_env_import_comment_only_existing_preserves_comments_on_merge( + fake_root, dest_root, age_keys, tmp_path, merge_mode): + """コメント / 空行のみの既存 .env が ``existing`` 辞書の空判定で create 扱いに + なって上書きされ、ヘッダコメントが消失しないこと (PR #15 round5 指摘)""" + _, id_file = age_keys + pub_file, _ = age_keys + bundle_path = _build_simple_bundle(tmp_path, pub_file, "comment-merge") + + _setup_comment_only_dest(dest_root) + + rc = import_bundle(dest_root, ImportOptions( + source=str(bundle_path), identities=[str(id_file)], + merge=merge_mode)) + assert rc == 0 + + out = (dest_root / ".env").read_text() + assert "# user-managed header (no kv yet)" in out + assert "# section: aws" in out + assert "INCOMING_KEY=incoming" in out + + +def test_env_import_comment_only_existing_preserves_comments_on_replace_keys( + fake_root, dest_root, age_keys, tmp_path): + """--replace-keys 経路でも、コメントのみの既存 .env が create 扱いされず + コメントが保持されること (PR #15 round5 指摘)""" + _, id_file = age_keys + pub_file, _ = age_keys + bundle_path = _build_simple_bundle(tmp_path, pub_file, "comment-rk") + + _setup_comment_only_dest(dest_root) + + rc = import_bundle(dest_root, ImportOptions( + source=str(bundle_path), identities=[str(id_file)], + replace_keys=["INCOMING_KEY"])) + assert rc == 0 + + out = (dest_root / ".env").read_text() + assert "# user-managed header (no kv yet)" in out + assert "# section: aws" in out + assert "INCOMING_KEY=incoming" in out + + +def test_env_import_comment_only_existing_replace_reports_op_replace( + fake_root, dest_root, age_keys, tmp_path, caplog): + """--replace 経路では incoming で完全上書きするが、その op は 'create' では + なく 'replace' として報告されること (PR #15 round5 指摘)。 + + --replace の意味論として既存内容は捨てるため、コメント保持は要件ではないが、 + ログ上 ``create`` と表示されるとロールバック挙動など他の経路 (= 新規作成は + backup を取らない) と判別できなくなるため、op 表記の正確性を確認する。 + """ + import logging + _, id_file = age_keys + pub_file, _ = age_keys + bundle_path = _build_simple_bundle(tmp_path, pub_file, "comment-replace") + + _setup_comment_only_dest(dest_root) + + # plan 表示は _import_merge.log_plans で行われるためそのモジュールの logger を捕捉する + with caplog.at_level(logging.INFO, logger="devbase.env._import_merge"): + rc = import_bundle(dest_root, ImportOptions( + source=str(bundle_path), identities=[str(id_file)], + replace=True)) + assert rc == 0 + + # 'replace: ' のような行が出ているはず。少なくとも 'create:' 表記で + # 出力されていないことを確認 (op_replace の正しさ)。 + log_text = "\n".join(r.message for r in caplog.records) + assert "replace: " in log_text, log_text + # backup には元のコメントのみの .env が記録されている (存在判定が正しければ + # _backup_existing が target を見つけてコピーするため) + backup_root = dest_root / "backups" / "env-import" + assert backup_root.is_dir() + snapshots = [p for p in backup_root.iterdir() if p.is_dir()] + assert len(snapshots) >= 1 + backed = (snapshots[0] / ".env").read_text() + assert "# user-managed header (no kv yet)" in backed + + +def test_env_import_merge_preserves_raw_unchanged_unquoted_dollar( + dest_root, age_keys, tmp_path): + """merge 経路で値が変更されていないキーは ``raw`` 行をそのまま温存し、 + ``PATH=$HOME/bin`` のような未クオート値が ``PATH="\\$HOME/bin"`` に + 勝手にエスケープされないこと (PR #13 codex 指摘)。 + + シェル ``source`` 時に ``$HOME`` の変数展開が効くか効かないかは + クオートの有無で意味が変わるため、merge 対象でない既存値は元の形式を + 保たなければならない。 + """ + _, id_file = age_keys + pub_file, _ = age_keys + + src_root = tmp_path / "raw-preserve-src" + src_root.mkdir() + # incoming 側には別キーだけ (PATH は触らない) + (src_root / ".env").write_text("INCOMING=v\n") + bundle_path = tmp_path / "raw-preserve.dbenv" + rc = export(src_root, ExportOptions( + dest=str(bundle_path), recipients=[f"@{pub_file}"])) + assert rc == 0 + + # 既存 dest .env に未クオートの $ を含む値を仕込む (シェルで展開される形) + (dest_root / ".env").write_text( + "PATH=$HOME/bin:/usr/local/bin\n" + "PLAIN=keep_me\n" + ) + os.chmod(dest_root / ".env", 0o600) + + rc = import_bundle(dest_root, ImportOptions( + source=str(bundle_path), identities=[str(id_file)], + merge='prefer-incoming')) + assert rc == 0 + + out = (dest_root / ".env").read_text() + # raw 行が温存されているので、$HOME はそのまま (\\$ にエスケープされていない) + assert "PATH=$HOME/bin:/usr/local/bin" in out, out + # 同じく PLAIN もそのまま + assert "PLAIN=keep_me" in out, out + # 新規追加された incoming キーは appended + assert "INCOMING=v" in out, out + + +def test_env_import_filter_members_rejects_unknown_arcname(): + """``filter_members`` が manifest 範囲外の未対応 arcname を黙って捨てず、 + ``MergeError`` で明示的に止めること (PR #13 codex 指摘)。 + """ + from devbase.env._import_merge import MergeError, filter_members + + members = { + 'env/global.env': b'GLOBAL=1\n', + 'env/secrets.yml': b'secret: x\n', # 未対応 path + } + with pytest.raises(MergeError, match="未対応の arcname"): + filter_members( + members, + include_global=True, + include_metadata=True, + include_projects=None, + exclude_projects=(), + ) + + +@pytest.mark.parametrize( + "bad_arcname", + [ + 'env/projects/./.env', # `.` で global .env 領域に抜ける + 'env/projects/../.env', # `..` での親ディレクトリ脱出 + 'env/projects/.hidden/.env', # 隠しディレクトリ名 + 'env/projects/ /.env', # 空白だけのプロジェクト名 + ], +) +def test_env_import_rejects_unsafe_project_names(bad_arcname): + """``_PROJECT_ENV_RE`` が ``.`` / ``..`` / ``.hidden`` 等の特殊セグメントを + project 名として受け入れず、未対応 arcname として ``MergeError`` で止めること + (PR #13 codex round 3 指摘の path traversal 対策)。 + + 特に ``env/projects/./.env`` は正規表現 ``[^/]+`` だと match してしまい、 + ``target_for`` で ``$DEVBASE_ROOT/projects/.env`` に正規化されてグローバル + ``.env`` を上書きする経路が成立する。ここで明示的に拒否する。 + """ + from devbase.env._import_merge import MergeError, filter_members, target_for + + # filter_members 経路: 未対応 arcname として MergeError + members = {bad_arcname: b'PWNED=1\n'} + with pytest.raises(MergeError, match="未対応の arcname"): + filter_members( + members, + include_global=True, + include_metadata=True, + include_projects=None, + exclude_projects=(), + ) + + # target_for 経路: 直接呼ばれても MergeError + with pytest.raises(MergeError, match="未対応のバンドルエントリ"): + target_for(bad_arcname, Path('/tmp/fake-root')) + + +def test_env_import_accepts_normal_project_names(): + """正常な project 名 (英数字 / `_` / `-` / `.` を含む) は受け入れること。 + 上記の安全性チェックで実用ケースを壊していないことの回帰テスト。 + """ + from devbase.env._import_merge import target_for + + root = Path('/tmp/fake-root') + for name in ['alpha', 'beta_1', 'my-app', 'svc.v2', 'a']: + arc = f'env/projects/{name}/.env' + assert target_for(arc, root) == root / 'projects' / name / '.env' diff --git a/tests/cli/test_prefix_resolution.py b/tests/cli/test_prefix_resolution.py new file mode 100644 index 0000000..06f849a --- /dev/null +++ b/tests/cli/test_prefix_resolution.py @@ -0,0 +1,71 @@ +"""sys.argv の prefix 解決 (`devbase env e` → `edit` 等) の後方互換テスト""" + +from __future__ import annotations + +import sys + +from devbase import cli + + +def test_resolve_prefix_unique_match(): + assert cli._resolve_prefix("ed", ["edit", "export"]) == "edit" + assert cli._resolve_prefix("ex", ["edit", "export"]) == "export" + + +def test_resolve_prefix_ambiguous_returns_input(): + # `e` は edit / export の両方にマッチするため、デフォルトでは入力をそのまま返す + assert cli._resolve_prefix("e", ["edit", "export"]) == "e" + + +def test_resolve_prefix_falls_back_to_preference_when_ambiguous(): + """ambiguous な prefix に対し preference があれば fallback で解決する""" + candidates = ["edit", "export"] + preferences = {"e": "edit"} + assert cli._resolve_prefix("e", candidates, preferences) == "edit" + + +def test_resolve_prefix_ignores_preference_when_target_not_in_candidates(): + """preference の指す値が candidates にない場合は無視される""" + candidates = ["edit", "export"] + preferences = {"e": "explode"} + assert cli._resolve_prefix("e", candidates, preferences) == "e" + + +def test_expand_argv_env_e_resolves_to_edit(monkeypatch): + """`devbase env e` は引き続き `devbase env edit` に解決される (後方互換)""" + monkeypatch.setattr(sys, "argv", ["devbase", "env", "e"]) + cli._expand_argv() + assert sys.argv == ["devbase", "env", "edit"] + + +def test_expand_argv_env_ed_resolves_to_edit(monkeypatch): + monkeypatch.setattr(sys, "argv", ["devbase", "env", "ed"]) + cli._expand_argv() + assert sys.argv == ["devbase", "env", "edit"] + + +def test_expand_argv_env_ex_resolves_to_export(monkeypatch): + monkeypatch.setattr(sys, "argv", ["devbase", "env", "ex"]) + cli._expand_argv() + assert sys.argv == ["devbase", "env", "export"] + + +def test_expand_argv_env_i_resolves_to_init(monkeypatch): + """`devbase env i` は `import` 追加後も `init` に解決される (後方互換 / PR #15 round5)""" + monkeypatch.setattr(sys, "argv", ["devbase", "env", "i"]) + cli._expand_argv() + assert sys.argv == ["devbase", "env", "init"] + + +def test_expand_argv_env_im_resolves_to_import(monkeypatch): + """`devbase env im` は唯一の候補なので `import` に解決される""" + monkeypatch.setattr(sys, "argv", ["devbase", "env", "im"]) + cli._expand_argv() + assert sys.argv == ["devbase", "env", "import"] + + +def test_expand_argv_env_in_resolves_to_init(monkeypatch): + """`devbase env in` も唯一の候補 (`init`) に解決される""" + monkeypatch.setattr(sys, "argv", ["devbase", "env", "in"]) + cli._expand_argv() + assert sys.argv == ["devbase", "env", "init"] diff --git a/tests/env/__init__.py b/tests/env/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/env/test_bundle.py b/tests/env/test_bundle.py new file mode 100644 index 0000000..d70bf2c --- /dev/null +++ b/tests/env/test_bundle.py @@ -0,0 +1,440 @@ +"""bundle.py: tar.gz パック/アンパックと manifest 検証""" + +from __future__ import annotations + +import pytest + +from devbase.env import bundle + + +def _entry(arcname: str, data: bytes, origin: str = "") -> bundle.BundleEntry: + return bundle.BundleEntry(arcname=arcname, origin=origin or arcname, data=data) + + +def test_pack_unpack_roundtrip_preserves_contents(): + entries = [ + _entry("env/global.env", b"FOO=bar\nBAZ=qux\n"), + _entry("env/projects/p1/.env", b"API_KEY=abc\n"), + ] + blob = bundle.pack(entries, devbase_version="test") + manifest, members = bundle.unpack(blob) + + assert manifest["version"] == bundle.SUPPORTED_MANIFEST_VERSION + assert manifest["devbase_version"] == "test" + assert {e["path"] for e in manifest["files"]} == {e.arcname for e in entries} + assert members["env/global.env"] == b"FOO=bar\nBAZ=qux\n" + assert members["env/projects/p1/.env"] == b"API_KEY=abc\n" + + +def test_unpack_rejects_corrupted_sha256(): + entries = [_entry("env/global.env", b"FOO=bar\n")] + blob = bundle.pack(entries) + + # 同じ tar に対し manifest の sha256 を意図的に壊した tar を作る + import io, tarfile, yaml + src = io.BytesIO(blob) + out = io.BytesIO() + with tarfile.open(fileobj=src, mode="r:gz") as tin, \ + tarfile.open(fileobj=out, mode="w:gz") as tout: + for info in tin.getmembers(): + data = tin.extractfile(info).read() + if info.name == bundle.MANIFEST_NAME: + m = yaml.safe_load(data) + m["files"][0]["sha256"] = "0" * 64 + data = yaml.safe_dump(m).encode("utf-8") + info.size = len(data) + tout.addfile(info, io.BytesIO(data)) + + with pytest.raises(bundle.BundleError, match="sha256"): + bundle.unpack(out.getvalue()) + + +@pytest.mark.parametrize("bad_version", [ + bundle.SUPPORTED_MANIFEST_VERSION + 1, + 0, + -1, +]) +def test_unpack_rejects_unknown_version(bad_version): + entries = [_entry("env/global.env", b"FOO=bar\n")] + blob = bundle.pack(entries) + + import io, tarfile, yaml + src = io.BytesIO(blob) + out = io.BytesIO() + with tarfile.open(fileobj=src, mode="r:gz") as tin, \ + tarfile.open(fileobj=out, mode="w:gz") as tout: + for info in tin.getmembers(): + data = tin.extractfile(info).read() + if info.name == bundle.MANIFEST_NAME: + m = yaml.safe_load(data) + m["version"] = bad_version + data = yaml.safe_dump(m).encode("utf-8") + info.size = len(data) + tout.addfile(info, io.BytesIO(data)) + + with pytest.raises(bundle.BundleError, match="version"): + bundle.unpack(out.getvalue()) + + +def test_make_entries_from_disk(tmp_path): + root = tmp_path + (root / ".env").write_text("GLOBAL=1\n") + (root / ".env.sources.yml").write_text("sources: {}\n") + proj_a = root / "projects" / "a" + proj_a.mkdir(parents=True) + (proj_a / ".env").write_text("A=1\n") + proj_b = root / "projects" / "b" + proj_b.mkdir(parents=True) + (proj_b / ".env").write_text("B=1\n") + + entries = bundle.make_entries_from_disk(root) + arcnames = {e.arcname for e in entries} + assert arcnames == { + "env/global.env", + "env/sources.yml", + "env/projects/a/.env", + "env/projects/b/.env", + } + + only_a = bundle.make_entries_from_disk(root, include_projects=["a"], + include_metadata=False) + assert {e.arcname for e in only_a} == {"env/global.env", "env/projects/a/.env"} + + no_global = bundle.make_entries_from_disk(root, include_global=False, + exclude_projects=["b"]) + assert "env/global.env" not in {e.arcname for e in no_global} + assert "env/projects/b/.env" not in {e.arcname for e in no_global} + + +def test_unpack_rejects_traversal_paths(): + import io, tarfile + out = io.BytesIO() + with tarfile.open(fileobj=out, mode="w:gz") as tout: + info = tarfile.TarInfo(name="../escape.txt") + info.size = 3 + tout.addfile(info, io.BytesIO(b"BAD")) + with pytest.raises(bundle.BundleError, match="不正なパス"): + bundle.unpack(out.getvalue()) + + +def _rewrite_manifest(blob: bytes, new_manifest_obj) -> bytes: + """blob 内の manifest.yml を new_manifest_obj に置き換えた tar.gz を返す""" + import io, tarfile, yaml + src = io.BytesIO(blob) + out = io.BytesIO() + with tarfile.open(fileobj=src, mode="r:gz") as tin, \ + tarfile.open(fileobj=out, mode="w:gz") as tout: + for info in tin.getmembers(): + data = tin.extractfile(info).read() + if info.name == bundle.MANIFEST_NAME: + data = yaml.safe_dump(new_manifest_obj).encode("utf-8") + info.size = len(data) + tout.addfile(info, io.BytesIO(data)) + return out.getvalue() + + +def test_unpack_rejects_files_not_list(): + blob = bundle.pack([_entry("env/global.env", b"FOO=bar\n")]) + bad = _rewrite_manifest(blob, { + "version": bundle.SUPPORTED_MANIFEST_VERSION, + "files": "not-a-list", + }) + with pytest.raises(bundle.BundleError, match="files が list"): + bundle.unpack(bad) + + +def test_unpack_rejects_files_entry_not_dict(): + blob = bundle.pack([_entry("env/global.env", b"FOO=bar\n")]) + bad = _rewrite_manifest(blob, { + "version": bundle.SUPPORTED_MANIFEST_VERSION, + "files": ["not-a-dict"], + }) + with pytest.raises(bundle.BundleError, match="dict ではありません"): + bundle.unpack(bad) + + +def test_unpack_rejects_invalid_path_field(): + blob = bundle.pack([_entry("env/global.env", b"FOO=bar\n")]) + bad = _rewrite_manifest(blob, { + "version": bundle.SUPPORTED_MANIFEST_VERSION, + "files": [{"path": 123, "sha256": "x" * 64}], + }) + with pytest.raises(bundle.BundleError, match="path が不正"): + bundle.unpack(bad) + + +def test_unpack_rejects_invalid_sha256_field(): + blob = bundle.pack([_entry("env/global.env", b"FOO=bar\n")]) + bad = _rewrite_manifest(blob, { + "version": bundle.SUPPORTED_MANIFEST_VERSION, + "files": [{"path": "env/global.env", "sha256": 12345}], + }) + with pytest.raises(bundle.BundleError, match="sha256 が不正"): + bundle.unpack(bad) + + +def test_unpack_rejects_missing_sha256_field(): + """sha256 が欠落 (None) している manifest は BundleError""" + blob = bundle.pack([_entry("env/global.env", b"FOO=bar\n")]) + bad = _rewrite_manifest(blob, { + "version": bundle.SUPPORTED_MANIFEST_VERSION, + "files": [{"path": "env/global.env"}], # sha256 欠落 + }) + with pytest.raises(bundle.BundleError, match="sha256 が不正"): + bundle.unpack(bad) + + +def test_unpack_rejects_sha256_none(): + """sha256 が明示的に None でも BundleError (完全性チェック迂回防止)""" + blob = bundle.pack([_entry("env/global.env", b"FOO=bar\n")]) + bad = _rewrite_manifest(blob, { + "version": bundle.SUPPORTED_MANIFEST_VERSION, + "files": [{"path": "env/global.env", "sha256": None}], + }) + with pytest.raises(bundle.BundleError, match="sha256 が不正"): + bundle.unpack(bad) + + +def test_unpack_rejects_sha256_wrong_length(): + """sha256 が 64 文字でない場合は BundleError""" + blob = bundle.pack([_entry("env/global.env", b"FOO=bar\n")]) + bad = _rewrite_manifest(blob, { + "version": bundle.SUPPORTED_MANIFEST_VERSION, + "files": [{"path": "env/global.env", "sha256": "abc123"}], + }) + with pytest.raises(bundle.BundleError, match="sha256 が不正"): + bundle.unpack(bad) + + +def test_unpack_rejects_sha256_non_hex(): + """sha256 が 64 文字でも 16 進でないなら BundleError""" + blob = bundle.pack([_entry("env/global.env", b"FOO=bar\n")]) + bad = _rewrite_manifest(blob, { + "version": bundle.SUPPORTED_MANIFEST_VERSION, + "files": [{"path": "env/global.env", "sha256": "z" * 64}], + }) + with pytest.raises(bundle.BundleError, match="sha256 が不正"): + bundle.unpack(bad) + + +def test_unpack_rejects_duplicate_tar_entries(): + import io, tarfile, yaml + out = io.BytesIO() + manifest = { + "version": bundle.SUPPORTED_MANIFEST_VERSION, + "files": [{"path": "env/global.env", + "sha256": bundle._sha256(b"FOO=bar\n")}], + } + manifest_bytes = yaml.safe_dump(manifest).encode("utf-8") + with tarfile.open(fileobj=out, mode="w:gz") as tout: + m = tarfile.TarInfo(name=bundle.MANIFEST_NAME) + m.size = len(manifest_bytes) + tout.addfile(m, io.BytesIO(manifest_bytes)) + # 同名エントリを 2 回追加 + for payload in (b"FOO=bar\n", b"FOO=other\n"): + info = tarfile.TarInfo(name="env/global.env") + info.size = len(payload) + tout.addfile(info, io.BytesIO(payload)) + with pytest.raises(bundle.BundleError, match="重複エントリ"): + bundle.unpack(out.getvalue()) + + +@pytest.mark.parametrize("payload", [b"- a\n- b\n", b"just a string\n", b"42\n"]) +def test_unpack_rejects_non_mapping_manifest(payload): + """manifest.yaml の top-level が dict でない場合 BundleError""" + import io, tarfile + out = io.BytesIO() + with tarfile.open(fileobj=out, mode="w:gz") as tout: + m = tarfile.TarInfo(name=bundle.MANIFEST_NAME) + m.size = len(payload) + tout.addfile(m, io.BytesIO(payload)) + with pytest.raises(bundle.BundleError, match="mapping ではありません"): + bundle.unpack(out.getvalue()) + + +def test_pack_is_deterministic(): + """同一入力に対し pack() の出力バイト列が完全に一致 (gzip mtime=0 が効いている)""" + entries = [ + _entry("env/global.env", b"FOO=bar\n"), + _entry("env/projects/p1/.env", b"X=1\n"), + ] + blob1 = bundle.pack(entries, devbase_version="test", + created_at="2024-01-01T00:00:00+00:00") + blob2 = bundle.pack(entries, devbase_version="test", + created_at="2024-01-01T00:00:00+00:00") + assert blob1 == blob2 + # gzip マジックで始まる + assert blob1[:2] == b"\x1f\x8b" + + +def test_unpack_rejects_duplicate_manifest_paths(): + """manifest.files に同じ path が複数回現れたら BundleError""" + blob = bundle.pack([_entry("env/global.env", b"FOO=bar\n")]) + bad = _rewrite_manifest(blob, { + "version": bundle.SUPPORTED_MANIFEST_VERSION, + "files": [ + {"path": "env/global.env", + "sha256": bundle._sha256(b"FOO=bar\n")}, + {"path": "env/global.env", + "sha256": bundle._sha256(b"FOO=bar\n")}, + ], + }) + with pytest.raises(bundle.BundleError, match="path が重複"): + bundle.unpack(bad) + + +def test_unpack_rejects_broken_tar_with_bundle_error(): + """壊れた tar.gz は BundleError として送出される (tarfile.TarError を漏らさない)""" + # gzip ヘッダだけ正しいが中身が壊れているバイト列 + broken = b"\x1f\x8b\x08\x00" + b"\x00" * 32 + with pytest.raises(bundle.BundleError): + bundle.unpack(broken) + + +def test_make_entries_from_disk_ignores_directory_named_env(tmp_path): + """対象パスがディレクトリの場合は is_file() で除外され、例外にならない""" + root = tmp_path + # .env がディレクトリだったケース + (root / ".env").mkdir() + # 通常の sources.yml + (root / ".env.sources.yml").write_text("sources: {}\n") + entries = bundle.make_entries_from_disk(root) + arcnames = {e.arcname for e in entries} + assert "env/global.env" not in arcnames + assert "env/sources.yml" in arcnames + + +def test_is_valid_project_name(): + """import/export 共通の project 名 validator の挙動を固定する""" + # OK ケース: 通常のディレクトリ名 + assert bundle.is_valid_project_name("foo") + assert bundle.is_valid_project_name("foo-bar") + assert bundle.is_valid_project_name("foo.bar") + assert bundle.is_valid_project_name("_foo") + assert bundle.is_valid_project_name("p") + assert bundle.is_valid_project_name("project1") + assert bundle.is_valid_project_name("a_b-c.d") + # NG ケース: 空白 / 先頭 `.` / 先頭 `-` / 空文字 / `/` 含み + assert not bundle.is_valid_project_name("") + assert not bundle.is_valid_project_name(".") + assert not bundle.is_valid_project_name("..") + assert not bundle.is_valid_project_name(".hidden") + assert not bundle.is_valid_project_name("-foo") + assert not bundle.is_valid_project_name("foo bar") + assert not bundle.is_valid_project_name("foo/bar") + assert not bundle.is_valid_project_name("foo\nbar") + + +def test_make_entries_from_disk_skips_invalid_project_names(tmp_path, caplog): + """空白 / 先頭 `.` 等の project ディレクトリは export 時に skip + warning で除外する。 + + import 側 (`_import_merge._PROJECT_ENV_RE`) は同じ name 規則を要求するため、 + そのまま arcname にして export すると round-trip できない bundle が出来てしまう。 + bundle.is_valid_project_name で validator を共有し、不正な名前は警告のみで + skip する (PR #13 codex round 5 指摘)。 + """ + root = tmp_path + valid = root / "projects" / "valid_proj" + valid.mkdir(parents=True) + (valid / ".env").write_text("OK=1\n") + + # 各種 invalid なディレクトリ名 + .env + for bad_name in (".hidden", "..weird", "with space", "-leading-dash"): + d = root / "projects" / bad_name + d.mkdir(parents=True) + (d / ".env").write_text("BAD=1\n") + + with caplog.at_level("WARNING"): + entries = bundle.make_entries_from_disk(root) + + arcnames = {e.arcname for e in entries} + # 妥当な project だけが残り、`..weird` 等は arcname に出現しない + assert arcnames == {"env/projects/valid_proj/.env"} + # 各 invalid name について warning が出ていること + for bad_name in (".hidden", "..weird", "with space", "-leading-dash"): + assert any( + "スキップ" in r.message and bad_name in r.message + for r in caplog.records + ), f"warning が出ていない: {bad_name}" + + +def test_make_entries_from_disk_invalid_name_explicitly_included_is_still_skipped( + tmp_path, caplog, +): + """include_projects で明示指定された名前でも invalid なら skip する。 + + 暗黙的に round-trip 不能なバンドルが作られるのを防ぐため、 + CLI からの明示指定でも validator は適用される。 + """ + root = tmp_path + bad = root / "projects" / ".hidden" + bad.mkdir(parents=True) + (bad / ".env").write_text("X=1\n") + + with caplog.at_level("WARNING"): + entries = bundle.make_entries_from_disk( + root, include_projects=[".hidden"], include_global=False, + include_metadata=False, + ) + + assert entries == [] + assert any("スキップ" in r.message for r in caplog.records) + + +def test_make_entries_from_disk_validator_matches_import_side(): + """export 側 (is_valid_project_name) と import 側 (_PROJECT_ENV_RE) の + project 名規則が同期していることを契約として固定する。 + + 名前を変えたい場合は両方を同時に更新する必要がある (PR #13 codex round 5)。 + """ + from devbase.env import _import_merge + + samples = [ + ("foo", True), + ("foo-bar", True), + ("_foo", True), + ("foo.bar", True), + ("", False), + (".", False), + ("..", False), + (".hidden", False), + ("-leading", False), + ("with space", False), + ] + for name, expected in samples: + export_ok = bundle.is_valid_project_name(name) + import_ok = bool( + _import_merge._PROJECT_ENV_RE.match(f"env/projects/{name}/.env") + ) + assert export_ok == expected, f"export side: {name!r}" + assert import_ok == expected, f"import side: {name!r}" + # 重要: 両者が常に一致する (validator 同期) + assert export_ok == import_ok, ( + f"export/import の project 名 validator が乖離: {name!r}" + ) + + +def test_unpack_rejects_unknown_tar_entries(): + """manifest に記載のないファイルが tar に紛れ込んでいたら BundleError""" + import io, tarfile, yaml + out = io.BytesIO() + manifest = { + "version": bundle.SUPPORTED_MANIFEST_VERSION, + "files": [{"path": "env/global.env", + "sha256": bundle._sha256(b"FOO=bar\n")}], + } + manifest_bytes = yaml.safe_dump(manifest).encode("utf-8") + with tarfile.open(fileobj=out, mode="w:gz") as tout: + m = tarfile.TarInfo(name=bundle.MANIFEST_NAME) + m.size = len(manifest_bytes) + tout.addfile(m, io.BytesIO(manifest_bytes)) + # manifest に記載があるファイル + legit = tarfile.TarInfo(name="env/global.env") + legit.size = len(b"FOO=bar\n") + tout.addfile(legit, io.BytesIO(b"FOO=bar\n")) + # manifest に記載のないファイル + stowaway = tarfile.TarInfo(name="env/stowaway.env") + stowaway.size = len(b"EVIL=1\n") + tout.addfile(stowaway, io.BytesIO(b"EVIL=1\n")) + with pytest.raises(bundle.BundleError, match="manifest に記載のないファイル"): + bundle.unpack(out.getvalue()) diff --git a/tests/env/test_cipher.py b/tests/env/test_cipher.py new file mode 100644 index 0000000..65f1ce3 --- /dev/null +++ b/tests/env/test_cipher.py @@ -0,0 +1,217 @@ +"""cipher.py: age 暗号化のラウンドトリップとエラー検出""" + +from __future__ import annotations + +import pyrage +import pytest + +from devbase.env import cipher + + +@pytest.fixture +def x25519_keypair(): + identity = pyrage.x25519.Identity.generate() + return str(identity.to_public()), str(identity) + + +def test_recipient_roundtrip_with_x25519(tmp_path, x25519_keypair): + pub, priv_str = x25519_keypair + id_path = tmp_path / "age_identity.key" + id_path.write_text(priv_str) + + blob = cipher.encrypt(b"hello", recipients=[pub]) + assert blob != b"hello" + assert cipher.decrypt(blob, identities=[str(id_path)]) == b"hello" + + +def test_passphrase_roundtrip(): + blob = cipher.encrypt(b"secret payload", passphrase="correct horse") + assert cipher.decrypt(blob, passphrase="correct horse") == b"secret payload" + + +def test_passphrase_wrong_raises_cipher_error(): + blob = cipher.encrypt(b"x", passphrase="right") + with pytest.raises(cipher.CipherError): + cipher.decrypt(blob, passphrase="wrong") + + +def test_encrypt_requires_recipient_or_passphrase(): + with pytest.raises(cipher.CipherError): + cipher.encrypt(b"x") + + +def test_encrypt_rejects_both_recipient_and_passphrase(x25519_keypair): + pub, _ = x25519_keypair + with pytest.raises(cipher.CipherError): + cipher.encrypt(b"x", recipients=[pub], passphrase="p") + + +def test_recipient_at_file_reference(tmp_path, x25519_keypair): + pub, priv_str = x25519_keypair + pub_file = tmp_path / "age.pub" + pub_file.write_text(pub + "\n") + id_file = tmp_path / "age.key" + id_file.write_text(priv_str) + + blob = cipher.encrypt(b"data", recipients=[f"@{pub_file}"]) + assert cipher.decrypt(blob, identities=[str(id_file)]) == b"data" + + +def test_recipient_rejects_unsupported_ssh_type(): + with pytest.raises(cipher.CipherError, match="ssh-ecdsa|ssh-"): + cipher.encrypt(b"x", recipients=["ssh-ecdsa AAAA dummy"]) + + +def test_recipient_at_file_reference_depth_limit(tmp_path): + """@PATH の循環参照で RecursionError ではなく CipherError を返す""" + # 互いを参照する 2 ファイル + a = tmp_path / "a.txt" + b = tmp_path / "b.txt" + a.write_text(f"@{b}\n") + b.write_text(f"@{a}\n") + with pytest.raises(cipher.CipherError, match="深すぎ|循環"): + cipher.encrypt(b"x", recipients=[f"@{a}"]) + + +def test_recipient_at_file_reference_rejects_non_utf8(tmp_path): + """@PATH ファイルが UTF-8 でない場合 CipherError に包んで送出""" + bad = tmp_path / "bad.pub" + # 0x80 は UTF-8 として不正な開始バイト + bad.write_bytes(b"\x80\x81\x82\n") + with pytest.raises(cipher.CipherError, match="UTF-8 デコード"): + cipher.encrypt(b"x", recipients=[f"@{bad}"]) + + +def test_resolve_identity_wraps_oserror(tmp_path, monkeypatch): + """identity ファイルの read_bytes が OSError を投げた場合 CipherError に包んで送出""" + id_path = tmp_path / "identity.key" + id_path.write_text("dummy") + + from pathlib import Path as _Path + + original_read_bytes = _Path.read_bytes + + def fake_read_bytes(self): + if self == id_path: + raise OSError("simulated I/O error") + return original_read_bytes(self) + + monkeypatch.setattr(_Path, "read_bytes", fake_read_bytes) + with pytest.raises(cipher.CipherError, match="読み込みに失敗"): + cipher.decrypt(b"x", identities=[str(id_path)]) + + +def test_resolve_recipient_at_path_skips_comments_and_blank_lines(tmp_path, x25519_keypair): + """@PATH ファイル中のコメント行 / 空行をスキップして最初の有効な recipient を採用""" + pub_path = tmp_path / "rcpt.pub" + pub_path.write_text( + "# this is a comment\n" + "\n" + f"{x25519_keypair[0]}\n" + "# trailing comment\n" + ) + ciphertext = cipher.encrypt(b"hello", recipients=[f"@{pub_path}"]) + # 復号できれば有効な recipient として解釈されている + id_path = tmp_path / "id.key" + id_path.write_text(x25519_keypair[1]) + plain = cipher.decrypt(ciphertext, identities=[str(id_path)]) + assert plain == b"hello" + + +def test_resolve_recipient_at_path_rejects_only_comments(tmp_path): + """@PATH ファイルがコメント・空行のみだと CipherError""" + pub_path = tmp_path / "empty.pub" + pub_path.write_text("# only comments\n\n# nothing else\n") + with pytest.raises(cipher.CipherError, match="有効な行がありません"): + cipher.encrypt(b"x", recipients=[f"@{pub_path}"]) + + +def test_resolve_recipient_at_path_rejects_multiple_keys(tmp_path, x25519_keypair): + """@PATH ファイルに複数の鍵を列挙したら CipherError で明示的に拒否される。 + + 暗黙に最初の 1 行だけ採用すると、`team_keys.txt` のような複数公開鍵ファイル + を渡したケースで「最初の 1 人」だけにしか暗号化されず、他メンバーの復号が + 壊れる。誤運用を防ぐため明確にエラーを返す (PR #13 gemini 指摘)。 + """ + pub_a, _ = x25519_keypair + # 2 つ目の鍵を別途生成 + pub_b = str(pyrage.x25519.Identity.generate().to_public()) + + team_keys = tmp_path / "team_keys.txt" + team_keys.write_text( + f"# alice\n{pub_a}\n# bob\n{pub_b}\n" + ) + with pytest.raises(cipher.CipherError, match="複数行の鍵|1 鍵で指定"): + cipher.encrypt(b"x", recipients=[f"@{team_keys}"]) + + +def test_resolve_recipient_at_path_wraps_oserror(tmp_path, monkeypatch): + """@PATH の read_text が OSError を投げた場合 CipherError に包んで送出""" + rcpt_path = tmp_path / "rcpt.pub" + rcpt_path.write_text("dummy") + + from pathlib import Path as _Path + + original_read_text = _Path.read_text + + def fake_read_text(self, *args, **kwargs): + if self == rcpt_path: + raise PermissionError("simulated permission denied") + return original_read_text(self, *args, **kwargs) + + monkeypatch.setattr(_Path, "read_text", fake_read_text) + with pytest.raises(cipher.CipherError, match="読み込みに失敗"): + cipher.encrypt(b"x", recipients=[f"@{rcpt_path}"]) + + +def test_resolve_identity_prefers_openssh_header(tmp_path): + """OpenSSH ヘッダで始まる秘密鍵は age 鍵判定より先に SSH として処理される""" + # 中身は不正でも、OpenSSH ヘッダで判別された後 pyrage 側エラーになる + # ことを確認 (= age 経路ではなく SSH 経路に入った証拠) + id_path = tmp_path / "id.key" + id_path.write_bytes( + b"-----BEGIN OPENSSH PRIVATE KEY-----\n" + b"not-a-valid-key\n" + b"-----END OPENSSH PRIVATE KEY-----\n" + ) + with pytest.raises(cipher.CipherError, match="OpenSSH 秘密鍵の解釈"): + cipher.decrypt(b"x", identities=[str(id_path)]) + + +def test_default_recipient_paths_includes_ed25519(): + """ed25519 公開鍵が rsa より先に試される""" + paths = cipher.default_recipient_paths() + names = [p.name for p in paths] + assert "id_ed25519.pub" in names + assert "id_rsa.pub" in names + # ed25519 を rsa より先に優先 + assert names.index("id_ed25519.pub") < names.index("id_rsa.pub") + + +def test_default_identity_paths_includes_ed25519(): + """ed25519 秘密鍵が rsa より先に試される""" + paths = cipher.default_identity_paths() + names = [p.name for p in paths] + assert "id_ed25519" in names + assert "id_rsa" in names + assert names.index("id_ed25519") < names.index("id_rsa") + + +def test_resolve_identity_accepts_age_keygen_output_with_comments( + tmp_path, x25519_keypair): + """``age-keygen`` が生成する秘密鍵ファイル (先頭に ``# created`` / ``# public key`` + のコメント行) を age 鍵として正しく検出して復号できること (PR #13 gemini 指摘)。 + """ + pub, priv_str = x25519_keypair + + # age-keygen の出力フォーマットを再現 + keygen_output = ( + f"# created: 2024-01-01T00:00:00Z\n" + f"# public key: {pub}\n" + f"{priv_str}\n" + ) + id_path = tmp_path / "age-keygen.key" + id_path.write_text(keygen_output) + + blob = cipher.encrypt(b"payload", recipients=[pub]) + assert cipher.decrypt(blob, identities=[str(id_path)]) == b"payload" diff --git a/tests/env/test_io_common.py b/tests/env/test_io_common.py new file mode 100644 index 0000000..c65392d --- /dev/null +++ b/tests/env/test_io_common.py @@ -0,0 +1,122 @@ +"""io_common.py: resolve_recipient_specs / resolve_identity_specs の挙動""" + +from __future__ import annotations + +import pyrage +import pytest + +from devbase.env import cipher +from devbase.env import io_common + + +@pytest.fixture +def fake_home(tmp_path, monkeypatch): + """``Path.home()`` を ``tmp_path`` に差し替える""" + from pathlib import Path + + monkeypatch.setattr(Path, "home", classmethod(lambda cls: tmp_path)) + return tmp_path + + +def test_resolve_recipient_specs_returns_first_existing_default(fake_home): + """recipient は「どの鍵で暗号化するか」を一意に決める必要があるため、 + 既定鍵が複数存在しても最初に見つかったものだけ返す (ed25519 を優先)。""" + ssh = fake_home / ".ssh" + ssh.mkdir() + (ssh / "id_ed25519.pub").write_text("ssh-ed25519 AAAA dummy\n") + (ssh / "id_rsa.pub").write_text("ssh-rsa AAAA dummy\n") + + specs = io_common.resolve_recipient_specs([]) + assert len(specs) == 1 + assert specs[0].endswith("id_ed25519.pub") + + +def test_resolve_recipient_specs_explicit_passthrough(fake_home): + """明示指定があれば既定鍵探索は行わない (そのまま返す)""" + ssh = fake_home / ".ssh" + ssh.mkdir() + (ssh / "id_ed25519.pub").write_text("ssh-ed25519 AAAA dummy\n") + + specs = io_common.resolve_recipient_specs(["age1example"]) + assert specs == ["age1example"] + + +def test_resolve_recipient_specs_returns_empty_when_no_defaults(fake_home): + """既定鍵が見つからなければ空 list""" + assert io_common.resolve_recipient_specs([]) == [] + + +def test_resolve_identity_specs_returns_all_existing_defaults(fake_home): + """identity は「どの鍵で暗号化されたか」が事前に分からないため、 + 存在するすべての既定鍵を返す。``pyrage.decrypt`` は複数 identity を + 受け取れる仕様なので、両方渡しておけばどちらの鍵で暗号化されたバンドル + でも復号できる (PR #13 gemini 指摘)。""" + ssh = fake_home / ".ssh" + ssh.mkdir() + (ssh / "id_ed25519").write_text("dummy ed25519 key\n") + (ssh / "id_rsa").write_text("dummy rsa key\n") + + specs = io_common.resolve_identity_specs([]) + assert len(specs) == 2 + # ed25519 が先に来る (default_identity_paths の順序を維持) + assert specs[0].endswith("id_ed25519") + assert specs[1].endswith("id_rsa") + + +def test_resolve_identity_specs_returns_only_existing(fake_home): + """片方しか存在しなければそれだけ返す""" + ssh = fake_home / ".ssh" + ssh.mkdir() + (ssh / "id_rsa").write_text("dummy\n") + + specs = io_common.resolve_identity_specs([]) + assert len(specs) == 1 + assert specs[0].endswith("id_rsa") + + +def test_resolve_identity_specs_explicit_passthrough(fake_home): + """明示指定があれば既定鍵探索は行わない""" + ssh = fake_home / ".ssh" + ssh.mkdir() + (ssh / "id_ed25519").write_text("dummy\n") + + specs = io_common.resolve_identity_specs(["/path/to/explicit.key"]) + assert specs == ["/path/to/explicit.key"] + + +def test_resolve_identity_specs_returns_empty_when_no_defaults(fake_home): + """既定鍵が一切無ければ空""" + assert io_common.resolve_identity_specs([]) == [] + + +def test_decrypt_uses_correct_identity_from_multiple_defaults(tmp_path, fake_home): + """``resolve_identity_specs`` が返した複数 identity を ``cipher.decrypt`` に + 渡すと、その中から正しい identity が選ばれて復号される。 + + シナリオ: 既定 ssh 鍵が 2 つ (id_ed25519 / id_rsa) 存在する状況を模した上で、 + `id_rsa` (実体は age 鍵) で暗号化したバンドルを「両方の identity を試す」 + `cipher.decrypt(identities=[both])` で復号できることを確認する。 + `id_ed25519` 側は別 age 鍵で、こちらは復号に使われない。 + """ + # 異なる 2 つの age 鍵を用意し、ssh 既定パスに配置して + # resolve_identity_specs から両方が返るようにする + id1 = pyrage.x25519.Identity.generate() + id2 = pyrage.x25519.Identity.generate() + + ssh = fake_home / ".ssh" + ssh.mkdir() + ed_path = ssh / "id_ed25519" + rsa_path = ssh / "id_rsa" + ed_path.write_text(str(id1)) # ed25519 スロットに id1 + rsa_path.write_text(str(id2)) # rsa スロットに id2 (=暗号化に使う鍵) + + # id2 の公開鍵だけで暗号化 → id1 では復号できないバンドル + blob = cipher.encrypt(b"team-secret", recipients=[str(id2.to_public())]) + + # resolve_identity_specs は両方返す + identities = io_common.resolve_identity_specs([]) + assert len(identities) == 2 + + # 両 identity を渡して復号 → pyrage が正しい鍵 (id2) を選んで復号する + plain = cipher.decrypt(blob, identities=identities) + assert plain == b"team-secret" diff --git a/tests/env/test_storage.py b/tests/env/test_storage.py new file mode 100644 index 0000000..1d5eb9f --- /dev/null +++ b/tests/env/test_storage.py @@ -0,0 +1,541 @@ +"""storage.py: Local / Stdio backend + resolve()""" + +from __future__ import annotations + +import io +import sys + +import pytest + +from devbase.env import storage + + +def test_local_backend_roundtrip(tmp_path): + backend = storage.LocalBackend() + dest = tmp_path / "out" / "bundle.bin" + backend.write_bytes(str(dest), b"abc") + + assert backend.read_bytes(str(dest)) == b"abc" + assert dest.stat().st_mode & 0o777 == 0o600 + + +def test_local_backend_missing_file_raises(tmp_path): + backend = storage.LocalBackend() + with pytest.raises(storage.StorageError): + backend.read_bytes(str(tmp_path / "no-such")) + + +def test_resolve_local_for_plain_path(): + assert isinstance(storage.resolve("/tmp/foo"), storage.LocalBackend) + assert isinstance(storage.resolve("relative/path"), storage.LocalBackend) + assert isinstance(storage.resolve("file:///tmp/foo"), storage.LocalBackend) + + +def test_resolve_stdio_for_dash(): + assert isinstance(storage.resolve("-"), storage.StdioBackend) + assert storage.is_stdio("-") + assert not storage.is_stdio("/tmp/foo") + + +def test_resolve_rejects_gs_scheme_dropped(): + """PLAN03-1 PR4 廃案により gs:// は対応しない (S3 と紛らわしいので明示メッセージ)""" + with pytest.raises(storage.StorageError, match="廃案"): + storage.resolve("gs://bucket/object") + + +def test_resolve_returns_s3_backend(): + """s3:// は S3Backend を返し、S3Options を引き渡せる""" + opts = storage.S3Options(unsafe_allow_unencrypted_bucket=True, sse='AES256') + backend = storage.resolve("s3://bucket/key", s3_options=opts) + assert isinstance(backend, storage.S3Backend) + assert backend._options is opts + + +def test_resolve_returns_s3_backend_without_opts(monkeypatch): + """s3_options 省略時は from_env で組み立てられる""" + monkeypatch.delenv("DEVBASE_S3_SSE", raising=False) + monkeypatch.delenv("DEVBASE_S3_SSE_KMS_KEY_ID", raising=False) + monkeypatch.delenv("DEVBASE_S3_ENDPOINT_URL", raising=False) + monkeypatch.delenv("DEVBASE_S3_REGION", raising=False) + backend = storage.resolve("s3://bucket/key") + assert isinstance(backend, storage.S3Backend) + assert backend._options.sse == 'aws:kms' + assert backend._options.unsafe_allow_unencrypted_bucket is False + + +def test_resolve_rejects_unknown_scheme(): + with pytest.raises(storage.StorageError, match="未対応"): + storage.resolve("ftp://host/x") + + +def test_is_s3(): + assert storage.is_s3("s3://bucket/key") + assert not storage.is_s3("/tmp/foo") + assert not storage.is_s3("-") + assert not storage.is_s3("file:///tmp/foo") + + +@pytest.mark.parametrize("uri", [ + r"C:\Users\foo\bundle.tar.gz", + r"c:\tmp\out.bin", + "D:/data/out.bin", +]) +def test_resolve_windows_drive_letter_falls_back_to_local(uri): + """Windows のドライブレター付きパスは urlparse が scheme と誤認するが + LocalBackend にフォールバックされる""" + assert isinstance(storage.resolve(uri), storage.LocalBackend) + + +def test_local_backend_file_uri_roundtrip(tmp_path): + backend = storage.LocalBackend() + dest = tmp_path / "via-uri.bin" + uri = f"file://{dest}" + backend.write_bytes(uri, b"xyz") + assert dest.read_bytes() == b"xyz" + assert backend.read_bytes(uri) == b"xyz" + + # localhost も許容 + uri_localhost = f"file://localhost{dest}" + assert backend.read_bytes(uri_localhost) == b"xyz" + + +def test_local_backend_file_uri_rejects_remote_host(tmp_path): + backend = storage.LocalBackend() + with pytest.raises(storage.StorageError, match="ホスト指定"): + backend.read_bytes("file://other-host/tmp/x") + with pytest.raises(storage.StorageError, match="ホスト指定"): + backend.write_bytes("file://other-host/tmp/x", b"data") + + +def test_stdio_backend_writes_to_stdout(monkeypatch): + buf = io.BytesIO() + + class FakeStdout: + buffer = buf + + monkeypatch.setattr(sys, "stdout", FakeStdout()) + storage.StdioBackend().write_bytes("-", b"hello") + assert buf.getvalue() == b"hello" + + +def test_local_backend_write_creates_with_0600_no_toctou(tmp_path, monkeypatch): + """`os.open` の mode 引数 (0o600) が確実に渡され、umask に依存せず作成時点から + 0600 になることを検証する""" + backend = storage.LocalBackend() + dest = tmp_path / "secure.bin" + + captured = {} + real_os_open = storage.os.open + + def spy_open(path, flags, mode=0o777): + captured['mode'] = mode + captured['flags'] = flags + return real_os_open(path, flags, mode) + + monkeypatch.setattr(storage.os, "open", spy_open) + backend.write_bytes(str(dest), b"secret") + assert captured['mode'] == 0o600 + # O_CREAT|O_TRUNC|O_WRONLY が含まれていること + import os as _os + assert captured['flags'] & _os.O_CREAT + assert captured['flags'] & _os.O_TRUNC + assert dest.stat().st_mode & 0o777 == 0o600 + + +def test_local_backend_overwrite_existing_file_keeps_0600(tmp_path): + """既存ファイル (0644) に上書きしても 0600 まで権限を絞れる""" + backend = storage.LocalBackend() + dest = tmp_path / "exists.bin" + dest.write_bytes(b"old") + dest.chmod(0o644) + + backend.write_bytes(str(dest), b"new") + assert dest.read_bytes() == b"new" + assert dest.stat().st_mode & 0o777 == 0o600 + + +def test_local_backend_write_wraps_oserror_as_storage_error(tmp_path): + """書き込み時の OSError は StorageError にラップされる""" + backend = storage.LocalBackend() + # 書き込み不可能なパス (存在しないルートを起点) — mkdir も失敗する状況を作る + # FileExistsError をテストするため、parent をファイルにして mkdir を阻む + blocker = tmp_path / "blocker" + blocker.write_bytes(b"x") + dest = blocker / "child" / "out.bin" + with pytest.raises(storage.StorageError): + backend.write_bytes(str(dest), b"data") + + +def test_local_backend_read_wraps_oserror_as_storage_error(tmp_path): + """read 時の OSError (例: ディレクトリを read) は StorageError にラップされる""" + backend = storage.LocalBackend() + # ディレクトリを read_bytes すると IsADirectoryError + with pytest.raises(storage.StorageError): + backend.read_bytes(str(tmp_path)) + + +# --------------------------------------------------------------------------- +# S3Backend +# --------------------------------------------------------------------------- + + +class _FakeBody: + def __init__(self, data: bytes): + self._data = data + + def read(self): + return self._data + + +class _FakeS3Client: + """boto3 client のスタブ。呼び出しを記録し、振る舞いをカスタマイズできる""" + + def __init__( + self, + *, + get_encryption_error: Exception | None = None, + put_error: Exception | None = None, + get_object_error: Exception | None = None, + object_payload: bytes = b'', + ): + self.get_encryption_error = get_encryption_error + self.put_error = put_error + self.get_object_error = get_object_error + self.object_payload = object_payload + self.calls: list[tuple[str, dict]] = [] + + def get_bucket_encryption(self, **kwargs): + self.calls.append(('get_bucket_encryption', kwargs)) + if self.get_encryption_error: + raise self.get_encryption_error + return {'ServerSideEncryptionConfiguration': {'Rules': []}} + + def put_object(self, **kwargs): + self.calls.append(('put_object', kwargs)) + if self.put_error: + raise self.put_error + return {'ETag': '"deadbeef"'} + + def get_object(self, **kwargs): + self.calls.append(('get_object', kwargs)) + if self.get_object_error: + raise self.get_object_error + return {'Body': _FakeBody(self.object_payload)} + + +def _make_aws_error(code: str) -> Exception: + """botocore.exceptions.ClientError 相当のダックタイプエラーを作る + (boto3 を実依存に入れず、S3Backend._error_code が response[Error][Code] を + 見るだけなので最小限の構造で再現できる)""" + err = Exception(f"AWS error: {code}") + err.response = {'Error': {'Code': code, 'Message': 'simulated'}} + return err + + +def test_parse_s3_uri_valid(): + assert storage._parse_s3_uri("s3://bucket/key") == ("bucket", "key") + assert storage._parse_s3_uri("s3://bucket/path/to/key.tar.gz") == ( + "bucket", "path/to/key.tar.gz" + ) + + +def test_parse_s3_uri_invalid(): + with pytest.raises(storage.StorageError, match="バケット名が空"): + storage._parse_s3_uri("s3:///key") + with pytest.raises(storage.StorageError, match="キー"): + storage._parse_s3_uri("s3://bucket") + with pytest.raises(storage.StorageError, match="キー"): + storage._parse_s3_uri("s3://bucket/") + with pytest.raises(storage.StorageError, match="S3 URI"): + storage._parse_s3_uri("/tmp/foo") + + +def test_parse_s3_uri_preserves_query_and_fragment_in_key(): + """S3 のキー名は `?` / `#` を含めることができる。urlparse 由来の query/fragment + 切り落としに退行していないことを検証する (AWS CLI と同じ挙動)""" + assert storage._parse_s3_uri("s3://bucket/key?with=query") == ( + "bucket", "key?with=query" + ) + assert storage._parse_s3_uri("s3://bucket/path/to#hash") == ( + "bucket", "path/to#hash" + ) + assert storage._parse_s3_uri("s3://bucket/a?b#c/d") == ( + "bucket", "a?b#c/d" + ) + + +def test_s3_options_from_env_defaults(monkeypatch): + for var in ('DEVBASE_S3_SSE', 'DEVBASE_S3_SSE_KMS_KEY_ID', + 'DEVBASE_S3_ENDPOINT_URL', 'DEVBASE_S3_REGION'): + monkeypatch.delenv(var, raising=False) + opts = storage.S3Options.from_env() + assert opts.sse == 'aws:kms' + assert opts.sse_kms_key_id is None + assert opts.endpoint_url is None + assert opts.region is None + assert opts.unsafe_allow_unencrypted_bucket is False + + +def test_s3_options_from_env_reads_overrides(monkeypatch): + monkeypatch.setenv('DEVBASE_S3_SSE', 'AES256') + monkeypatch.setenv('DEVBASE_S3_SSE_KMS_KEY_ID', 'alias/devbase') + monkeypatch.setenv('DEVBASE_S3_ENDPOINT_URL', 'http://minio:9000') + monkeypatch.setenv('DEVBASE_S3_REGION', 'ap-northeast-1') + opts = storage.S3Options.from_env(unsafe_allow_unencrypted_bucket=True) + assert opts.sse == 'AES256' + assert opts.sse_kms_key_id == 'alias/devbase' + assert opts.endpoint_url == 'http://minio:9000' + assert opts.region == 'ap-northeast-1' + assert opts.unsafe_allow_unencrypted_bucket is True + + +def test_s3_options_from_env_rejects_invalid_sse(monkeypatch): + monkeypatch.setenv('DEVBASE_S3_SSE', 'rot13') + with pytest.raises(storage.StorageError, match="DEVBASE_S3_SSE"): + storage.S3Options.from_env() + + +def _attach_fake_client(backend, fake): + """S3Backend に _get_client をモック付与する""" + backend._client = fake + return fake + + +def test_s3_backend_write_calls_put_object_with_sse(): + backend = storage.S3Backend(storage.S3Options(sse='aws:kms')) + fake = _attach_fake_client(backend, _FakeS3Client()) + + backend.write_bytes("s3://bucket/path/key.bin", b"payload") + + assert ('get_bucket_encryption', {'Bucket': 'bucket'}) in fake.calls + put_calls = [args for name, args in fake.calls if name == 'put_object'] + assert len(put_calls) == 1 + args = put_calls[0] + assert args['Bucket'] == 'bucket' + assert args['Key'] == 'path/key.bin' + assert args['Body'] == b"payload" + assert args['ServerSideEncryption'] == 'aws:kms' + assert 'SSEKMSKeyId' not in args + + +def test_s3_backend_write_passes_kms_key_id_when_specified(): + backend = storage.S3Backend(storage.S3Options( + sse='aws:kms', sse_kms_key_id='alias/devbase', + )) + fake = _attach_fake_client(backend, _FakeS3Client()) + backend.write_bytes("s3://bucket/k", b"x") + args = [a for n, a in fake.calls if n == 'put_object'][0] + assert args['SSEKMSKeyId'] == 'alias/devbase' + + +def test_s3_backend_write_with_aes256_omits_kms_key_id(): + backend = storage.S3Backend(storage.S3Options( + sse='AES256', sse_kms_key_id='alias/should-be-ignored', + )) + fake = _attach_fake_client(backend, _FakeS3Client()) + backend.write_bytes("s3://bucket/k", b"x") + args = [a for n, a in fake.calls if n == 'put_object'][0] + assert args['ServerSideEncryption'] == 'AES256' + assert 'SSEKMSKeyId' not in args + + +def test_s3_backend_write_rejects_unencrypted_bucket(): + backend = storage.S3Backend(storage.S3Options()) + fake = _attach_fake_client(backend, _FakeS3Client( + get_encryption_error=_make_aws_error( + 'ServerSideEncryptionConfigurationNotFoundError' + ), + )) + with pytest.raises(storage.StorageError, match="デフォルト暗号化が未設定"): + backend.write_bytes("s3://bucket/k", b"x") + # PutObject まで到達していない + assert not any(name == 'put_object' for name, _ in fake.calls) + + +def test_s3_backend_write_allows_unencrypted_bucket_with_unsafe_flag(caplog): + backend = storage.S3Backend(storage.S3Options( + unsafe_allow_unencrypted_bucket=True, + )) + fake = _attach_fake_client(backend, _FakeS3Client( + get_encryption_error=_make_aws_error( + 'ServerSideEncryptionConfigurationNotFoundError' + ), + )) + with caplog.at_level('WARNING'): + backend.write_bytes("s3://bucket/k", b"x") + assert any('unsafe' in r.message for r in caplog.records) + assert any(name == 'put_object' for name, _ in fake.calls) + + +def test_s3_backend_write_rejects_access_denied_on_encryption_check(): + backend = storage.S3Backend(storage.S3Options()) + fake = _attach_fake_client(backend, _FakeS3Client( + get_encryption_error=_make_aws_error('AccessDenied'), + )) + with pytest.raises(storage.StorageError, match="GetBucketEncryption"): + backend.write_bytes("s3://bucket/k", b"x") + + +def test_s3_backend_write_allows_access_denied_with_unsafe_flag(): + backend = storage.S3Backend(storage.S3Options( + unsafe_allow_unencrypted_bucket=True, + )) + fake = _attach_fake_client(backend, _FakeS3Client( + get_encryption_error=_make_aws_error('AccessDenied'), + )) + backend.write_bytes("s3://bucket/k", b"x") + assert any(name == 'put_object' for name, _ in fake.calls) + + +def test_s3_backend_write_rejects_unknown_encryption_check_error(): + """未知の GetBucketEncryption エラーは、unsafe フラグ無しでは中止する""" + backend = storage.S3Backend(storage.S3Options()) + _attach_fake_client(backend, _FakeS3Client( + get_encryption_error=_make_aws_error('NotImplemented'), + )) + with pytest.raises(storage.StorageError, match="バケット暗号化設定の確認に失敗"): + backend.write_bytes("s3://bucket/k", b"x") + + +def test_s3_backend_write_allows_unknown_encryption_error_with_unsafe_flag(caplog): + """S3 互換ストレージ (MinIO 等) で GetBucketEncryption が NotImplemented を + 返すケース: unsafe フラグ指定時は警告のみで PutObject へ進む""" + backend = storage.S3Backend(storage.S3Options( + unsafe_allow_unencrypted_bucket=True, + )) + fake = _attach_fake_client(backend, _FakeS3Client( + get_encryption_error=_make_aws_error('NotImplemented'), + )) + with caplog.at_level('WARNING'): + backend.write_bytes("s3://bucket/k", b"x") + assert any('unsafe' in r.message for r in caplog.records) + assert any(name == 'put_object' for name, _ in fake.calls) + + +def test_s3_backend_write_wraps_put_error(): + backend = storage.S3Backend(storage.S3Options()) + _attach_fake_client(backend, _FakeS3Client( + put_error=_make_aws_error('InternalError'), + )) + with pytest.raises(storage.StorageError, match="書き込みに失敗"): + backend.write_bytes("s3://bucket/k", b"x") + + +def test_s3_backend_read_calls_get_object(): + backend = storage.S3Backend() + fake = _attach_fake_client(backend, _FakeS3Client(object_payload=b"hello")) + data = backend.read_bytes("s3://bucket/path/key") + assert data == b"hello" + args = [a for n, a in fake.calls if n == 'get_object'][0] + assert args == {'Bucket': 'bucket', 'Key': 'path/key'} + + +def test_s3_backend_read_raises_for_missing_object(): + backend = storage.S3Backend() + _attach_fake_client(backend, _FakeS3Client( + get_object_error=_make_aws_error('NoSuchKey'), + )) + with pytest.raises(storage.StorageError, match="見つかりません"): + backend.read_bytes("s3://bucket/no-such") + + +def test_s3_backend_read_wraps_unknown_error(): + backend = storage.S3Backend() + _attach_fake_client(backend, _FakeS3Client( + get_object_error=_make_aws_error('SlowDown'), + )) + with pytest.raises(storage.StorageError, match="読み込みに失敗"): + backend.read_bytes("s3://bucket/k") + + +def test_s3_backend_rejects_no_such_bucket_even_with_unsafe_flag(): + """`NoSuchBucket` は暗号化未設定とは無関係な根本エラーなので、 + `--unsafe-allow-unencrypted-bucket` の有無に関わらず即座に StorageError。 + + unsafe フラグで「続行」しても後段の put_object が同じ NoSuchBucket で再失敗 + するだけで、ユーザのトラブルシューティングを妨げる (PR #13 gemini round 5 指摘)。 + """ + # フラグ無し + backend = storage.S3Backend(storage.S3Options()) + fake = _attach_fake_client(backend, _FakeS3Client( + get_encryption_error=_make_aws_error('NoSuchBucket'), + )) + with pytest.raises(storage.StorageError, match="NoSuchBucket"): + backend.write_bytes("s3://bucket/k", b"x") + # put_object は呼ばれない (encryption 段でエラーで止まる) + assert not any(name == 'put_object' for name, _ in fake.calls) + + # unsafe フラグ有りでも同じ挙動 (続行しない) + backend2 = storage.S3Backend(storage.S3Options( + unsafe_allow_unencrypted_bucket=True, + )) + fake2 = _attach_fake_client(backend2, _FakeS3Client( + get_encryption_error=_make_aws_error('NoSuchBucket'), + )) + with pytest.raises(storage.StorageError, match="NoSuchBucket"): + backend2.write_bytes("s3://bucket/k", b"x") + assert not any(name == 'put_object' for name, _ in fake2.calls) + + +def test_s3_backend_rejects_auth_or_network_error_without_aws_code(): + """`response[Error][Code]` が取れないローカルエラー (NoCredentialsError / + EndpointConnectionError 等) は unsafe フラグの有無に関わらず即 StorageError。 + + botocore の ClientError ではなく、AWS API レスポンスを伴わない例外は + `_error_code` が None を返す。これを「未知の暗号化チェック失敗」として + unsafe フラグで続行すると put_object も同じ例外で失敗するだけなので、 + 早期にエラーを返してトラブルシューティングしやすくする + (PR #13 gemini round 5 指摘)。 + """ + # ClientError ではない通常の Exception (code が取れない) + class _LocalError(Exception): + pass + + # フラグ無し + backend = storage.S3Backend(storage.S3Options()) + fake = _attach_fake_client(backend, _FakeS3Client( + get_encryption_error=_LocalError( + "Unable to locate credentials" + ), + )) + with pytest.raises(storage.StorageError, match="接続・認証"): + backend.write_bytes("s3://bucket/k", b"x") + assert not any(name == 'put_object' for name, _ in fake.calls) + + # unsafe フラグ有りでも同じ挙動 (続行しない) + backend2 = storage.S3Backend(storage.S3Options( + unsafe_allow_unencrypted_bucket=True, + )) + fake2 = _attach_fake_client(backend2, _FakeS3Client( + get_encryption_error=_LocalError( + "Could not connect to the endpoint URL" + ), + )) + with pytest.raises(storage.StorageError, match="接続・認証"): + backend2.write_bytes("s3://bucket/k", b"x") + assert not any(name == 'put_object' for name, _ in fake2.calls) + + +def test_s3_backend_get_client_passes_endpoint_and_region(monkeypatch): + """S3Options.endpoint_url / region が boto3.client へ正しく渡る""" + backend = storage.S3Backend(storage.S3Options( + endpoint_url='http://minio:9000', + region='ap-northeast-1', + )) + + captured_kwargs = {} + + def fake_client(service, **kwargs): + captured_kwargs['service'] = service + captured_kwargs.update(kwargs) + return _FakeS3Client() + + fake_boto3 = type(sys)('boto3') + fake_boto3.client = fake_client # type: ignore[attr-defined] + monkeypatch.setitem(sys.modules, 'boto3', fake_boto3) + + backend._get_client() + + assert captured_kwargs['service'] == 's3' + assert captured_kwargs['endpoint_url'] == 'http://minio:9000' + assert captured_kwargs['region_name'] == 'ap-northeast-1' diff --git a/uv.lock b/uv.lock index 28d2ff2..98b3471 100644 --- a/uv.lock +++ b/uv.lock @@ -1,17 +1,167 @@ version = 1 -revision = 2 +revision = 3 requires-python = ">=3.10" +[[package]] +name = "boto3" +version = "1.43.14" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "botocore" }, + { name = "jmespath" }, + { name = "s3transfer" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/79/4b/616367e871ce3f1cb3e8545a97736b6331b9fb081497f2d44c5b2aa6959d/boto3-1.43.14.tar.gz", hash = "sha256:5c0a994b3182061ee101812e721100717a4d664f9f4ceaf4a86b6d032ce9fc2d", size = 113142, upload-time = "2026-05-22T19:28:47.861Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/00/59cb9329c18e2d3aa23062ceaa87d065f2e81e7d2931df24d64e9a7815aa/boto3-1.43.14-py3-none-any.whl", hash = "sha256:574335744656cfed0b362a0a0467aaf2eb2bf15526edcd02d31d3c661f4b09e4", size = 140536, upload-time = "2026-05-22T19:28:46.49Z" }, +] + +[[package]] +name = "botocore" +version = "1.43.14" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jmespath" }, + { name = "python-dateutil" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/78/3c/798d2f7deb118241930c7c6bcfb0b970d3f0245bf580700663199aeed2c3/botocore-1.43.14.tar.gz", hash = "sha256:b9e500737e43d2f147c9d4e23b54360335e77d4c0ba90a318f51b65e06cb8516", size = 15382604, upload-time = "2026-05-22T19:28:36.363Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/27/7e/6e64821077cd2efc4aa51b7d638fb6d48e1c7c450201c529fbaf1de8bfd3/botocore-1.43.14-py3-none-any.whl", hash = "sha256:1f4a2a95ea78c10398e78431e98c1fe47adb54a7b10a32975144c1f541186658", size = 15061424, upload-time = "2026-05-22T19:28:32.682Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + [[package]] name = "devbase" version = "2.2.0" source = { virtual = "." } dependencies = [ + { name = "boto3" }, + { name = "pyrage" }, { name = "pyyaml" }, ] +[package.dev-dependencies] +dev = [ + { name = "pytest" }, +] + [package.metadata] -requires-dist = [{ name = "pyyaml", specifier = ">=6.0" }] +requires-dist = [ + { name = "boto3", specifier = ">=1.34" }, + { name = "pyrage", specifier = ">=1.2" }, + { name = "pyyaml", specifier = ">=6.0" }, +] + +[package.metadata.requires-dev] +dev = [{ name = "pytest", specifier = ">=8.0" }] + +[[package]] +name = "exceptiongroup" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "jmespath" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d3/59/322338183ecda247fb5d1763a6cbe46eff7222eaeebafd9fa65d4bf5cb11/jmespath-1.1.0.tar.gz", hash = "sha256:472c87d80f36026ae83c6ddd0f1d05d4e510134ed462851fd5f754c8c3cbb88d", size = 27377, upload-time = "2026-01-22T16:35:26.279Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/14/2f/967ba146e6d58cf6a652da73885f52fc68001525b4197effc174321d70b4/jmespath-1.1.0-py3-none-any.whl", hash = "sha256:a5663118de4908c91729bea0acadca56526eb2698e83de10cd116ae0f4e97c64", size = 20419, upload-time = "2026-01-22T16:35:24.919Z" }, +] + +[[package]] +name = "packaging" +version = "26.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, +] + +[[package]] +name = "pyrage" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/e8/918161376594d69b294e920bd6444f1d9997e6e6dd2aca18e15f1ef72463/pyrage-1.3.0.tar.gz", hash = "sha256:b283a2e3d688cbf68c707f57d93fdab3304ff57c7e2e6b710c0b4bc9096ad9da", size = 30120, upload-time = "2025-06-14T01:28:04.108Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f7/6e/3095678ee12f0401e1de17f4d6993783b20a4b807daf69e23b170724e5f4/pyrage-1.3.0-cp39-abi3-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:907901ada8d63d674cc9005889150846c7349ef587ee8bf5e9278b79c54b4679", size = 1563258, upload-time = "2025-06-14T01:27:57.886Z" }, + { url = "https://files.pythonhosted.org/packages/3b/e7/f515fbc972a5d83e9fa82d1c23a16f733f4dd6c2c6ae33d9054ca04a8d92/pyrage-1.3.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ea452cb9c9c47083a96b309467dea5614d12530e1de4b6585f10aa04d3d19d1c", size = 785930, upload-time = "2025-06-14T01:27:59.755Z" }, + { url = "https://files.pythonhosted.org/packages/38/f3/e91bf604fd40c42c60e8f95075cddb0b85d0bdf452f736b533b1bad550e0/pyrage-1.3.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ab066b22925c5a0ec5fead2e21e4586b21d5da730055c7e46caa978bd99de936", size = 847692, upload-time = "2025-06-14T01:28:01.042Z" }, + { url = "https://files.pythonhosted.org/packages/88/59/15fd1945b02e6f93eff5a2ff352e67f85f51bf543769484f9bd960868c19/pyrage-1.3.0-cp39-abi3-win_amd64.whl", hash = "sha256:3be314a9746809c2710bfd144a6acf0c54a40f43e306857b9778a9d871ad97b3", size = 767566, upload-time = "2025-06-14T01:28:02.597Z" }, +] + +[[package]] +name = "pytest" +version = "9.0.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" }, +] + +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, +] [[package]] name = "pyyaml" @@ -76,3 +226,96 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, ] + +[[package]] +name = "s3transfer" +version = "0.17.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "botocore" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9b/ec/7c692cde9125b77e84b307354d4fb705f98b8ccad59a036d5957ca75bfc3/s3transfer-0.17.0.tar.gz", hash = "sha256:9edeb6d1c3c2f89d6050348548834ad8289610d886e5bf7b7207728bd43ce33a", size = 155337, upload-time = "2026-04-29T22:07:36.33Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/87/72/c6c32d2b657fa3dad1de340254e14390b1e334ce38268b7ad51abda3c8c2/s3transfer-0.17.0-py3-none-any.whl", hash = "sha256:ce3801712acf4ad3e89fb9990df97b4972e93f4b3b0004d214be5bce12814c20", size = 86811, upload-time = "2026-04-29T22:07:34.966Z" }, +] + +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, +] + +[[package]] +name = "tomli" +version = "2.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/11/db3d5885d8528263d8adc260bb2d28ebf1270b96e98f0e0268d32b8d9900/tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30", size = 154704, upload-time = "2026-03-25T20:21:10.473Z" }, + { url = "https://files.pythonhosted.org/packages/6d/f7/675db52c7e46064a9aa928885a9b20f4124ecb9bc2e1ce74c9106648d202/tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a", size = 149454, upload-time = "2026-03-25T20:21:12.036Z" }, + { url = "https://files.pythonhosted.org/packages/61/71/81c50943cf953efa35bce7646caab3cf457a7d8c030b27cfb40d7235f9ee/tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076", size = 237561, upload-time = "2026-03-25T20:21:13.098Z" }, + { url = "https://files.pythonhosted.org/packages/48/c1/f41d9cb618acccca7df82aaf682f9b49013c9397212cb9f53219e3abac37/tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9", size = 243824, upload-time = "2026-03-25T20:21:14.569Z" }, + { url = "https://files.pythonhosted.org/packages/22/e4/5a816ecdd1f8ca51fb756ef684b90f2780afc52fc67f987e3c61d800a46d/tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c", size = 242227, upload-time = "2026-03-25T20:21:15.712Z" }, + { url = "https://files.pythonhosted.org/packages/6b/49/2b2a0ef529aa6eec245d25f0c703e020a73955ad7edf73e7f54ddc608aa5/tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc", size = 247859, upload-time = "2026-03-25T20:21:17.001Z" }, + { url = "https://files.pythonhosted.org/packages/83/bd/6c1a630eaca337e1e78c5903104f831bda934c426f9231429396ce3c3467/tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049", size = 97204, upload-time = "2026-03-25T20:21:18.079Z" }, + { url = "https://files.pythonhosted.org/packages/42/59/71461df1a885647e10b6bb7802d0b8e66480c61f3f43079e0dcd315b3954/tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e", size = 108084, upload-time = "2026-03-25T20:21:18.978Z" }, + { url = "https://files.pythonhosted.org/packages/b8/83/dceca96142499c069475b790e7913b1044c1a4337e700751f48ed723f883/tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece", size = 95285, upload-time = "2026-03-25T20:21:20.309Z" }, + { url = "https://files.pythonhosted.org/packages/c1/ba/42f134a3fe2b370f555f44b1d72feebb94debcab01676bf918d0cb70e9aa/tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a", size = 155924, upload-time = "2026-03-25T20:21:21.626Z" }, + { url = "https://files.pythonhosted.org/packages/dc/c7/62d7a17c26487ade21c5422b646110f2162f1fcc95980ef7f63e73c68f14/tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085", size = 150018, upload-time = "2026-03-25T20:21:23.002Z" }, + { url = "https://files.pythonhosted.org/packages/5c/05/79d13d7c15f13bdef410bdd49a6485b1c37d28968314eabee452c22a7fda/tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9", size = 244948, upload-time = "2026-03-25T20:21:24.04Z" }, + { url = "https://files.pythonhosted.org/packages/10/90/d62ce007a1c80d0b2c93e02cab211224756240884751b94ca72df8a875ca/tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5", size = 253341, upload-time = "2026-03-25T20:21:25.177Z" }, + { url = "https://files.pythonhosted.org/packages/1a/7e/caf6496d60152ad4ed09282c1885cca4eea150bfd007da84aea07bcc0a3e/tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585", size = 248159, upload-time = "2026-03-25T20:21:26.364Z" }, + { url = "https://files.pythonhosted.org/packages/99/e7/c6f69c3120de34bbd882c6fba7975f3d7a746e9218e56ab46a1bc4b42552/tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1", size = 253290, upload-time = "2026-03-25T20:21:27.46Z" }, + { url = "https://files.pythonhosted.org/packages/d6/2f/4a3c322f22c5c66c4b836ec58211641a4067364f5dcdd7b974b4c5da300c/tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917", size = 98141, upload-time = "2026-03-25T20:21:28.492Z" }, + { url = "https://files.pythonhosted.org/packages/24/22/4daacd05391b92c55759d55eaee21e1dfaea86ce5c571f10083360adf534/tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9", size = 108847, upload-time = "2026-03-25T20:21:29.386Z" }, + { url = "https://files.pythonhosted.org/packages/68/fd/70e768887666ddd9e9f5d85129e84910f2db2796f9096aa02b721a53098d/tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257", size = 95088, upload-time = "2026-03-25T20:21:30.677Z" }, + { url = "https://files.pythonhosted.org/packages/07/06/b823a7e818c756d9a7123ba2cda7d07bc2dd32835648d1a7b7b7a05d848d/tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54", size = 155866, upload-time = "2026-03-25T20:21:31.65Z" }, + { url = "https://files.pythonhosted.org/packages/14/6f/12645cf7f08e1a20c7eb8c297c6f11d31c1b50f316a7e7e1e1de6e2e7b7e/tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a", size = 149887, upload-time = "2026-03-25T20:21:33.028Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e0/90637574e5e7212c09099c67ad349b04ec4d6020324539297b634a0192b0/tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897", size = 243704, upload-time = "2026-03-25T20:21:34.51Z" }, + { url = "https://files.pythonhosted.org/packages/10/8f/d3ddb16c5a4befdf31a23307f72828686ab2096f068eaf56631e136c1fdd/tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f", size = 251628, upload-time = "2026-03-25T20:21:36.012Z" }, + { url = "https://files.pythonhosted.org/packages/e3/f1/dbeeb9116715abee2485bf0a12d07a8f31af94d71608c171c45f64c0469d/tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d", size = 247180, upload-time = "2026-03-25T20:21:37.136Z" }, + { url = "https://files.pythonhosted.org/packages/d3/74/16336ffd19ed4da28a70959f92f506233bd7cfc2332b20bdb01591e8b1d1/tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5", size = 251674, upload-time = "2026-03-25T20:21:38.298Z" }, + { url = "https://files.pythonhosted.org/packages/16/f9/229fa3434c590ddf6c0aa9af64d3af4b752540686cace29e6281e3458469/tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd", size = 97976, upload-time = "2026-03-25T20:21:39.316Z" }, + { url = "https://files.pythonhosted.org/packages/6a/1e/71dfd96bcc1c775420cb8befe7a9d35f2e5b1309798f009dca17b7708c1e/tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36", size = 108755, upload-time = "2026-03-25T20:21:40.248Z" }, + { url = "https://files.pythonhosted.org/packages/83/7a/d34f422a021d62420b78f5c538e5b102f62bea616d1d75a13f0a88acb04a/tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd", size = 95265, upload-time = "2026-03-25T20:21:41.219Z" }, + { url = "https://files.pythonhosted.org/packages/3c/fb/9a5c8d27dbab540869f7c1f8eb0abb3244189ce780ba9cd73f3770662072/tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf", size = 155726, upload-time = "2026-03-25T20:21:42.23Z" }, + { url = "https://files.pythonhosted.org/packages/62/05/d2f816630cc771ad836af54f5001f47a6f611d2d39535364f148b6a92d6b/tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac", size = 149859, upload-time = "2026-03-25T20:21:43.386Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/66341bdb858ad9bd0ceab5a86f90eddab127cf8b046418009f2125630ecb/tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662", size = 244713, upload-time = "2026-03-25T20:21:44.474Z" }, + { url = "https://files.pythonhosted.org/packages/df/6d/c5fad00d82b3c7a3ab6189bd4b10e60466f22cfe8a08a9394185c8a8111c/tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853", size = 252084, upload-time = "2026-03-25T20:21:45.62Z" }, + { url = "https://files.pythonhosted.org/packages/00/71/3a69e86f3eafe8c7a59d008d245888051005bd657760e96d5fbfb0b740c2/tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15", size = 247973, upload-time = "2026-03-25T20:21:46.937Z" }, + { url = "https://files.pythonhosted.org/packages/67/50/361e986652847fec4bd5e4a0208752fbe64689c603c7ae5ea7cb16b1c0ca/tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba", size = 256223, upload-time = "2026-03-25T20:21:48.467Z" }, + { url = "https://files.pythonhosted.org/packages/8c/9a/b4173689a9203472e5467217e0154b00e260621caa227b6fa01feab16998/tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6", size = 98973, upload-time = "2026-03-25T20:21:49.526Z" }, + { url = "https://files.pythonhosted.org/packages/14/58/640ac93bf230cd27d002462c9af0d837779f8773bc03dee06b5835208214/tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7", size = 109082, upload-time = "2026-03-25T20:21:50.506Z" }, + { url = "https://files.pythonhosted.org/packages/d5/2f/702d5e05b227401c1068f0d386d79a589bb12bf64c3d2c72ce0631e3bc49/tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232", size = 96490, upload-time = "2026-03-25T20:21:51.474Z" }, + { url = "https://files.pythonhosted.org/packages/45/4b/b877b05c8ba62927d9865dd980e34a755de541eb65fffba52b4cc495d4d2/tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4", size = 164263, upload-time = "2026-03-25T20:21:52.543Z" }, + { url = "https://files.pythonhosted.org/packages/24/79/6ab420d37a270b89f7195dec5448f79400d9e9c1826df982f3f8e97b24fd/tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c", size = 160736, upload-time = "2026-03-25T20:21:53.674Z" }, + { url = "https://files.pythonhosted.org/packages/02/e0/3630057d8eb170310785723ed5adcdfb7d50cb7e6455f85ba8a3deed642b/tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d", size = 270717, upload-time = "2026-03-25T20:21:55.129Z" }, + { url = "https://files.pythonhosted.org/packages/7a/b4/1613716072e544d1a7891f548d8f9ec6ce2faf42ca65acae01d76ea06bb0/tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41", size = 278461, upload-time = "2026-03-25T20:21:56.228Z" }, + { url = "https://files.pythonhosted.org/packages/05/38/30f541baf6a3f6df77b3df16b01ba319221389e2da59427e221ef417ac0c/tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c", size = 274855, upload-time = "2026-03-25T20:21:57.653Z" }, + { url = "https://files.pythonhosted.org/packages/77/a3/ec9dd4fd2c38e98de34223b995a3b34813e6bdadf86c75314c928350ed14/tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f", size = 283144, upload-time = "2026-03-25T20:21:59.089Z" }, + { url = "https://files.pythonhosted.org/packages/ef/be/605a6261cac79fba2ec0c9827e986e00323a1945700969b8ee0b30d85453/tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8", size = 108683, upload-time = "2026-03-25T20:22:00.214Z" }, + { url = "https://files.pythonhosted.org/packages/12/64/da524626d3b9cc40c168a13da8335fe1c51be12c0a63685cc6db7308daae/tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26", size = 121196, upload-time = "2026-03-25T20:22:01.169Z" }, + { url = "https://files.pythonhosted.org/packages/5a/cd/e80b62269fc78fc36c9af5a6b89c835baa8af28ff5ad28c7028d60860320/tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396", size = 100393, upload-time = "2026-03-25T20:22:02.137Z" }, + { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] + +[[package]] +name = "urllib3" +version = "2.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, +]