From 5861ff05f455c602fda955fe50ec2953a23cd02f Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Thu, 21 May 2026 10:31:21 +0900 Subject: [PATCH 01/16] =?UTF-8?q?chore(PLAN03-1):=20release=20=E3=83=96?= =?UTF-8?q?=E3=83=A9=E3=83=B3=E3=83=81=E4=BD=9C=E6=88=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From 9567b3db29611f8af99863eda13965fe8b795c22 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=A4=A7=E6=B5=9C=E6=AF=85=E7=BE=8E?= Date: Thu, 21 May 2026 20:13:13 +0900 Subject: [PATCH 02/16] feat(env): PLAN03-1 PR1 devbase env export (Local + Stdio) (#14) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore(PLAN03-1-export-local): Draft PR 作成 * feat(env): devbase env export を追加 (PLAN03-1 PR1) - lib/devbase/env/bundle.py: tar.gz + manifest.yml バンドル構築/展開、sha256 検証、未知 version 拒否、パストラバーサル拒否 - lib/devbase/env/cipher.py: pyrage 経由の age 暗号化/復号 (X25519 / OpenSSH ed25519,rsa / passphrase / @PATH 参照) - lib/devbase/env/storage.py: Local + Stdio backend、s3/gs は本 PR では未実装で明示エラー - lib/devbase/env/io_export.py: 機密キー検知警告、既定鍵 (~/.ssh/id_rsa.pub) 自動利用、--passphrase-stdin と DEST='-' 併用拒否 - cli.py / commands/env.py: env export サブコマンド登録 + SUBCMD_MAP 更新 - pyproject.toml: pyrage>=1.2 を deps、pytest>=8.0 を dev group、tool.pytest.ini_options 追加 - tests/env, tests/cli: ラウンドトリップ + 異常系 28 件 Co-Authored-By: Claude Opus 4.7 (1M context) * fix(env): レビュー指摘の修正 (storage/bundle/cipher) - storage.py: LocalBackend で file:// URI を url2pathname で実パスへ変換 - bundle.py: manifest.files の要素型 (dict, path: str, sha256: str) を検証 - cipher.py: age 秘密鍵判定をバイト列で行い、UTF-8 デコード失敗を明示エラー化 Co-Authored-By: Claude Opus 4.7 * fix(env): round 2 レビュー指摘の修正 (堅牢性 + test 追加) - storage: file:// URI の netloc が空/localhost 以外なら StorageError で拒否 (codex major) - bundle: tar 内の重複エントリを BundleError で検出 (codex major) - cipher: _resolve_recipient の @PATH 再帰に深さ制限 (上限 5) を追加 (gemini minor) - tests/storage: file:// URI roundtrip と remote host 拒否の test を追加 (gemini minor) - tests/bundle: _validate_manifest 不正系 (files が list でない / entry が dict でない / path 不正 / sha256 不正) + 重複エントリの test を追加 (gemini minor) - tests/cipher: @PATH 循環参照で CipherError を返す test を追加 (gemini minor) * fix(env): sha256 必須化と ed25519 デフォルト鍵対応 (round 3) - bundle.py: manifest.files[*].sha256 を必須の 64 文字 16 進文字列として検証 None / 欠落 / 長さ違い / 非 16 進は BundleError。完全性チェック迂回を防止 - cipher.py: default_recipient_paths / default_identity_paths に ed25519 (id_ed25519.pub / id_ed25519) を追加し、rsa より優先 - tests: sha256 欠落 / None / 長さ違い / 非 16 進の異常系テストを追加 - tests: ed25519 がデフォルトパス候補に含まれ rsa より優先されることを検証 Co-Authored-By: Claude Opus 4.7 * fix(env): round 4 レビュー指摘の修正 (異常系の堅牢化) - bundle: yaml.safe_load の結果が dict でない場合に BundleError を送出 (top-level が list/str/数値の場合に AttributeError が漏れるのを防止) - cipher: @PATH 参照ファイルが UTF-8 でない場合 CipherError に包んで再送出 (UnicodeDecodeError が呼び出し側に漏れていた) - storage.resolve: Windows ドライブレター (C:\path 等) を urlparse が scheme と誤認する問題に対応し LocalBackend にフォールバック 各修正に対応する異常系 test を追加 (合計 +5 test)。 * fix(env): _resolve_identity の OSError を CipherError に包む (round 5) - lib/devbase/env/cipher.py: path.read_bytes() を try/except OSError で ラップし、I/O エラー時も CipherError で統一されたエラー型を返す - tests/env/test_cipher.py: monkeypatch で read_bytes に OSError を 発生させて CipherError に包まれることを検証する test を追加 gemini round 5 指摘 (minor / 堅牢性) に対応。 Co-Authored-By: Claude Opus 4.7 * fix(env): round 6 レビュー指摘の修正 (決定性 + 完全性 + 堅牢性) - bundle.pack: gzip.GzipFile(mtime=0) でラップし出力を完全に決定的にする - bundle._validate_manifest: tar 内ファイルセットと manifest の完全一致を 検証し、manifest に記載のない未知ファイルを BundleError で拒否する - cipher._resolve_recipient: @PATH の read_text で発生する OSError を CipherError に包んで一貫したエラーハンドリングにする - cipher._resolve_identity: OpenSSH ヘッダで先に SSH 鍵を判別する分岐を 追加し、鍵形式判別を明示化 (将来の形式追加もしやすくする) - tests: pack 決定性 / unknown file 拒否 / @PATH OSError ラップ / OpenSSH ヘッダ優先判別の test を追加 * fix(env): @PATH 参照ファイルのコメント・空行をスキップする (round 6 追加) recipient ファイルにコメント (# 始まり) や空行が混在していても扱えるよう、 有効な最初の行のみを採用する。テストも追加。 * fix(env): round 1 レビュー指摘の修正 (TOCTOU + BundleError 統一 + prefix 互換 + completion) - storage.py: LocalBackend.write_bytes を os.open(mode=0o600, O_CREAT|O_TRUNC|O_WRONLY) で 作成時点から 0600 を強制し、umask に依らない TOCTOU 安全な書き込みに変更 (codex major / gemini minor — 同一指摘)。既存ファイル上書き時も先に chmod で権限を絞る。 read_bytes / write_bytes の OSError を StorageError にラップ (gemini minor)。 - bundle.py: unpack() の tarfile.open / getmembers / extractfile で発生する tarfile.TarError / OSError を BundleError にラップ (gemini major)。 make_entries_from_disk の exists() を is_file() に変更し、対象パスが ディレクトリだった場合の IsADirectoryError を防止 (gemini minor)。 _validate_manifest に manifest.files の path 重複検出を追加 (codex minor)。 - cli.py: SUBCMD_PREFIX_PREFERENCES を追加し、`devbase env e` が引き続き edit に 解決されるように prefix 解決の後方互換を維持 (codex minor)。 - etc/devbase-completion.bash, etc/_devbase: env export サブコマンドと 各オプションを補完に追加 (codex minor)。 - tests: storage の TOCTOU / OSError ラップ / 既存ファイル 0600 上書き、 bundle の path 重複 / 壊れた tar / is_file 切替、CLI prefix の後方互換テストを追加。 Co-Authored-By: Claude Opus 4.7 (1M context) * fix(env): round 2 advisory レビュー指摘の修正 (docstring / help / stdin prompt) - io_export.py: `_resolve_recipients` の docstring を更新し、既定鍵が `id_ed25519.pub` → `id_rsa.pub` の優先順で探索される実態に合わせる - cli.py: `--recipient` の help を `Default: ~/.ssh/id_ed25519.pub, then ~/.ssh/id_rsa.pub (first existing one)` に修正 - io_export.py: `--passphrase-stdin` で `sys.stdin.isatty()` の場合に `passphrase: ` プロンプトを stderr に表示し、対話実行時のハング感を解消 - 暗号化キー未指定エラーメッセージも ed25519 優先を反映 - tests/cli/test_env_export.py: tty / 非 tty 双方の挙動を検証する 2 ケース追加 Refs: PR #14 review comments 3280597873 / 3280597877 / 3280597881 --------- Co-authored-by: Claude Opus 4.7 (1M context) --- etc/_devbase | 13 ++ etc/devbase-completion.bash | 7 +- lib/devbase/cli.py | 61 +++++- lib/devbase/commands/env.py | 19 ++ lib/devbase/env/bundle.py | 256 ++++++++++++++++++++++ lib/devbase/env/cipher.py | 201 +++++++++++++++++ lib/devbase/env/io_export.py | 173 +++++++++++++++ lib/devbase/env/storage.py | 128 +++++++++++ pyproject.toml | 10 + tests/__init__.py | 0 tests/cli/__init__.py | 0 tests/cli/test_env_export.py | 163 ++++++++++++++ tests/cli/test_prefix_resolution.py | 50 +++++ tests/env/__init__.py | 0 tests/env/test_bundle.py | 325 ++++++++++++++++++++++++++++ tests/env/test_cipher.py | 178 +++++++++++++++ tests/env/test_storage.py | 147 +++++++++++++ uv.lock | 164 +++++++++++++- 18 files changed, 1887 insertions(+), 8 deletions(-) create mode 100644 lib/devbase/env/bundle.py create mode 100644 lib/devbase/env/cipher.py create mode 100644 lib/devbase/env/io_export.py create mode 100644 lib/devbase/env/storage.py create mode 100644 tests/__init__.py create mode 100644 tests/cli/__init__.py create mode 100644 tests/cli/test_env_export.py create mode 100644 tests/cli/test_prefix_resolution.py create mode 100644 tests/env/__init__.py create mode 100644 tests/env/test_bundle.py create mode 100644 tests/env/test_cipher.py create mode 100644 tests/env/test_storage.py diff --git a/etc/_devbase b/etc/_devbase index 9df7250..c5d0f14 100644 --- a/etc/_devbase +++ b/etc/_devbase @@ -73,6 +73,7 @@ _devbase() { 'delete:Delete a variable' 'edit:Open .env in editor' 'project:Setup project-specific variables' + 'export:Export .env files as an encrypted bundle (age)' ) plugin_subcommands=( @@ -150,6 +151,18 @@ _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]' + ;; *) _describe -t env-commands 'env command' env_subcommands ;; diff --git a/etc/devbase-completion.bash b/etc/devbase-completion.bash index cc62a91..e7ef68c 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" 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,11 @@ _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" -- "$cur")) + fi + ;; esac fi # plugin subcommand arguments diff --git a/lib/devbase/cli.py b/lib/devbase/cli.py index 3201679..1da2160 100644 --- a/lib/devbase/cli.py +++ b/lib/devbase/cli.py @@ -36,11 +36,20 @@ # 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'], ('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', + }, +} + def _require_devbase_root() -> Path: """Get DEVBASE_ROOT from environment, exiting if not set.""" @@ -109,6 +118,37 @@ 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)') + def _add_plugin_parser(subparsers): """Plugin group parser""" @@ -250,14 +290,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 +321,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..a324fbd 100644 --- a/lib/devbase/commands/env.py +++ b/lib/devbase/commands/env.py @@ -33,6 +33,7 @@ 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), } handler = handlers.get(subcmd) @@ -382,6 +383,24 @@ 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), + ) + return export(devbase_root, opts) + + def _update_source_metadata(devbase_root: Path, env_file: EnvFile) -> None: """ソースメタデータを更新する""" sources = SourcesManager(devbase_root) diff --git a/lib/devbase/env/bundle.py b/lib/devbase/env/bundle.py new file mode 100644 index 0000000..d9b9399 --- /dev/null +++ b/lib/devbase/env/bundle.py @@ -0,0 +1,256 @@ +"""env export/import バンドル (tar.gz + manifest.yml) の構築・展開""" + +from __future__ import annotations + +import gzip +import hashlib +import io +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 + +try: + from devbase import __version__ as _DEVBASE_VERSION +except ImportError: + _DEVBASE_VERSION = "unknown" + +MANIFEST_NAME = "manifest.yml" +SUPPORTED_MANIFEST_VERSION = 1 + + +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 + 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..e0d8540 --- /dev/null +++ b/lib/devbase/env/cipher.py @@ -0,0 +1,201 @@ +"""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}") + 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 + + if raw.strip().startswith(b'AGE-SECRET-KEY-1'): + try: + text = raw.decode('utf-8').strip() + except UnicodeDecodeError as e: + raise CipherError(f"age 秘密鍵が UTF-8 でデコードできません ({path}): {e}") from e + try: + return pyrage.x25519.Identity.from_str(text) + except Exception as e: + raise CipherError(f"age 秘密鍵の解釈に失敗しました ({path}): {e}") from e + + # ヘッダから判別できなかった場合のフォールバック。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_export.py b/lib/devbase/env/io_export.py new file mode 100644 index 0000000..2da2c2c --- /dev/null +++ b/lib/devbase/env/io_export.py @@ -0,0 +1,173 @@ +"""devbase env export の高レベル実装""" + +from __future__ import annotations + +import os +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 storage as _storage + +logger = get_logger(__name__) + +# 機密情報の検出パターン (平文出力時の警告用) +_SENSITIVE_PATTERNS = ('KEY', 'SECRET', 'TOKEN', 'PASSWORD', 'CREDENTIALS', 'BASE64') + + +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 + + +def _default_dest(force_unencrypted: bool) -> str: + ts = datetime.now().strftime('%Y%m%d-%H%M%S') + suffix = '.dbenv.tar.gz' if force_unencrypted else '.dbenv' + return f'./devbase-env-{ts}{suffix}' + + +def _resolve_recipients(specs: Sequence[str]) -> List[str]: + """recipient 指定の解決。 + + 空なら既定鍵を優先順 (``~/.ssh/id_ed25519.pub`` → ``~/.ssh/id_rsa.pub``) で + 探索し、最初に見つかったものを利用する。 + """ + 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 _read_passphrase(opts: ExportOptions) -> Optional[str]: + if opts.passphrase_env: + value = os.environ.get(opts.passphrase_env) + if not value: + raise ExportError( + f"環境変数 {opts.passphrase_env} が空または未設定です" + ) + return value + if opts.passphrase_stdin: + import sys + # tty で対話実行している場合、ユーザーが「ハングしている」と誤解しないよう + # stderr へプロンプトを出してから stdin を待つ (パイプ入力時は出さない)。 + if sys.stdin.isatty(): + print("passphrase: ", end='', file=sys.stderr, flush=True) + line = sys.stdin.readline() + if not line: + raise ExportError("stdin からパスフレーズを読み取れませんでした") + return line.rstrip('\n') + return None + + +def _has_sensitive_keys(entries) -> List[str]: + """env 形式のテキストから機密キーを抽出する (平文出力時の警告用)""" + hits = set() + key_re = re.compile(r'^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=', re.MULTILINE) + 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 key_re.findall(text): + upper = key.upper() + if any(p in upper for p in _SENSITIVE_PATTERNS): + hits.add(key) + return sorted(hits) + + +def export(devbase_root: Path, opts: ExportOptions) -> int: + """export 本体。CLI ハンドラから呼ばれる""" + # 引数組み合わせの早期検証 + if opts.passphrase_stdin and opts.dest == '-': + raise ExportError( + "DEST='-' (stdout) と --passphrase-stdin は併用できません " + "(stdin/stdout が衝突します)" + ) + if opts.passphrase_env and opts.passphrase_stdin: + raise ExportError("--passphrase-env と --passphrase-stdin は併用できません") + + 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: + if opts.recipients or opts.passphrase_env or opts.passphrase_stdin: + raise ExportError( + "--force-unencrypted は recipient / passphrase と併用できません" + ) + sensitive = _has_sensitive_keys(entries) + if sensitive: + logger.warning( + "平文 export に機密キーが含まれます: %s", + ', '.join(sensitive[:10]) + (' ...' if len(sensitive) > 10 else '') + ) + logger.warning( + "ファイルパーミッションは 0600 で書き出されますが、保管・転送時の暗号化を強く推奨します" + ) + payload = tar_blob + else: + passphrase = _read_passphrase(opts) + recipients = _resolve_recipients(opts.recipients) if passphrase is None else [] + 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 優先)" + ) + payload = _cipher.encrypt(tar_blob, recipients=recipients, passphrase=passphrase) + logger.debug("暗号化後サイズ: %d bytes", len(payload)) + + dest = opts.dest or _default_dest(opts.force_unencrypted) + backend = _storage.resolve(dest) + 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/storage.py b/lib/devbase/env/storage.py new file mode 100644 index 0000000..114d0b4 --- /dev/null +++ b/lib/devbase/env/storage.py @@ -0,0 +1,128 @@ +"""env バンドルの入出力先 (local / stdio / 将来 s3, gcs) を抽象化する""" + +from __future__ import annotations + +import os +import sys +from pathlib import Path +from typing import Protocol +from urllib.parse import urlparse + +from devbase.errors import DevbaseError + + +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: + path = _to_local_path(dest) + try: + if path.parent and not path.parent.exists(): + path.parent.mkdir(parents=True, exist_ok=True) + # TOCTOU 回避: open(..., 'wb') 後に chmod すると、umask が緩い環境では + # 一瞬 0644 等で平文 export が露出する。 + # os.open に mode=0o600 を渡し、O_CREAT|O_TRUNC|O_WRONLY で作成時点 + # から 0600 を強制する。既存ファイルも書き込み前に chmod で権限を絞る。 + if path.exists(): + try: + os.chmod(path, 0o600) + except OSError: + # Windows 等で chmod が無効でも処理を続行 + pass + flags = os.O_WRONLY | os.O_CREAT | os.O_TRUNC + fd = os.open(path, flags, 0o600) + try: + with os.fdopen(fd, 'wb') as f: + f.write(data) + except BaseException: + # fdopen 失敗時は fd を明示的に閉じる (fdopen 成功時は with が close) + try: + os.close(fd) + except OSError: + pass + raise + # mode 引数が無視される環境 (Windows 等) でも後追いで chmod を試みる + try: + os.chmod(path, 0o600) + except OSError: + pass + except StorageError: + raise + 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() + + +def resolve(uri: str) -> StorageBackend: + """URI スキームから対応する backend を返す""" + if uri == '-': + return StdioBackend() + + parsed = urlparse(uri) + scheme = parsed.scheme.lower() + + if scheme in ('', 'file'): + return LocalBackend() + + if scheme in ('s3', 'gs'): + raise StorageError( + f"スキーム '{scheme}://' は本 PR では未実装です " + "(後続 PR で対応予定)" + ) + + # 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 == '-' diff --git a/pyproject.toml b/pyproject.toml index e7aefd3..2f680cb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,9 +5,19 @@ description = "Docker-based Development Environment Manager" requires-python = ">=3.10" dependencies = [ "pyyaml>=6.0", + "pyrage>=1.2", +] + +[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..18b89fe --- /dev/null +++ b/tests/cli/test_env_export.py @@ -0,0 +1,163 @@ +"""devbase env export の統合テスト (擬似 DEVBASE_ROOT)""" + +from __future__ import annotations + +import io +import os +from pathlib import Path + +import pyrage +import pytest + +from devbase.env import bundle, cipher +from devbase.env.io_export import ExportOptions, ExportError, _read_passphrase, 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_rejects_stdout_with_passphrase_stdin(fake_root): + with pytest.raises(ExportError, match="DEST='-'"): + export(fake_root, ExportOptions(dest="-", passphrase_stdin=True)) + + +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_read_passphrase_shows_prompt_on_tty(monkeypatch, capsys): + """tty 入力時は stderr に 'passphrase: ' プロンプトを表示する""" + fake_stdin = io.StringIO("hunter2\n") + monkeypatch.setattr(fake_stdin, "isatty", lambda: True, raising=False) + monkeypatch.setattr("sys.stdin", fake_stdin) + + pw = _read_passphrase(ExportOptions(passphrase_stdin=True)) + assert pw == "hunter2" + err = capsys.readouterr().err + assert "passphrase: " in err + + +def test_read_passphrase_no_prompt_on_pipe(monkeypatch, capsys): + """パイプ (非 tty) 入力時はプロンプトを出さない""" + fake_stdin = io.StringIO("hunter2\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" + assert "passphrase" not in capsys.readouterr().err + + +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) diff --git a/tests/cli/test_prefix_resolution.py b/tests/cli/test_prefix_resolution.py new file mode 100644 index 0000000..0a7574e --- /dev/null +++ b/tests/cli/test_prefix_resolution.py @@ -0,0 +1,50 @@ +"""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"] 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..e6458e6 --- /dev/null +++ b/tests/env/test_bundle.py @@ -0,0 +1,325 @@ +"""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()) + + +def test_unpack_rejects_unknown_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"] = bundle.SUPPORTED_MANIFEST_VERSION + 1 + 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_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..2027a02 --- /dev/null +++ b/tests/env/test_cipher.py @@ -0,0 +1,178 @@ +"""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_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") diff --git a/tests/env/test_storage.py b/tests/env/test_storage.py new file mode 100644 index 0000000..385e5b0 --- /dev/null +++ b/tests/env/test_storage.py @@ -0,0 +1,147 @@ +"""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_unimplemented_schemes(): + for uri in ("s3://bucket/key", "gs://bucket/object"): + with pytest.raises(storage.StorageError, match="未実装"): + storage.resolve(uri) + + +def test_resolve_rejects_unknown_scheme(): + with pytest.raises(storage.StorageError, match="未対応"): + storage.resolve("ftp://host/x") + + +@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)) diff --git a/uv.lock b/uv.lock index 28d2ff2..49f3aa8 100644 --- a/uv.lock +++ b/uv.lock @@ -2,16 +2,115 @@ version = 1 revision = 2 requires-python = ">=3.10" +[[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 = "pyrage" }, { name = "pyyaml" }, ] +[package.dev-dependencies] +dev = [ + { name = "pytest" }, +] + [package.metadata] -requires-dist = [{ name = "pyyaml", specifier = ">=6.0" }] +requires-dist = [ + { 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 = "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 = "pyyaml" @@ -76,3 +175,66 @@ 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 = "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" }, +] From a876590b18993b7b2319f9c39f4ab1f24f9be78a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=A4=A7=E6=B5=9C=E6=AF=85=E7=BE=8E?= Date: Sat, 23 May 2026 19:49:38 +0900 Subject: [PATCH 03/16] feat(env): PLAN03-1 PR2 devbase env import (#15) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore: PLAN03-1 PR2 Draft PR 作成 (import 実装) * feat(env): PLAN03-1 PR2 devbase env import (Local + Stdio) `devbase env import` を追加し、export で生成したバンドル (age 暗号化済み or 平文 tar.gz) から `.env` 群を復元できるようにする。 主な機能: - merge セマンティクス: --merge keep-existing (既定) / prefer-incoming - --replace-keys: 指定キーのみ上書き - --replace: 対象 .env を丸ごと差し替え (backup 取得) - --dry-run: 差分プレビュー (書き込みなし) - 2 フェーズ書き出し (prepare → commit) で部分適用を最小化、失敗時は backup から best-effort で rollback - --backup-dir / --keep-last N (既定 10) で backup を GC - .env.sources.yml は既定で上書きせず参照用コピーのみ、--merge-metadata で 新規 source エントリのみ追加、--no-metadata で完全無視 - 暗号化判定: gzip magic で平文 / age 暗号化を識別 - 引数バリデーション: SOURCE='-' と --passphrase-stdin の併用拒否、 --passphrase-env と --passphrase-stdin の併用拒否、--replace と --replace-keys の併用拒否 - 既定 identity: ~/.ssh/id_ed25519 → ~/.ssh/id_rsa の順で探索 テスト (tests/cli/test_env_import.py, 17 ケース): - export → import の round-trip、0600 permission 保持、LF 保持 - merge モード別の挙動 (keep-existing / prefer-incoming / replace-keys / replace)、dry-run が変更しないこと、replace 時の backup 作成 - --include-project / --no-metadata / --merge-metadata の挙動 - 未知 manifest version のバンドル拒否、--keep-last による古い backup GC - 平文バンドル import、passphrase round-trip Co-Authored-By: Claude Opus 4.7 (1M context) * fix(env): import の merge/rollback/GC 安全性を改善 cross-review round 1 で指摘されたデータ損失・部分適用・merge 不整合を修正: - --replace-keys 指定外でも既存に無い incoming 新規キーは追加するように修正 (CLI help の "other keys behave like keep-existing" に整合) - _rollback で op='create' なターゲットは backup が無くても unlink し、 commit 途中失敗時の部分適用残骸を消す - _gc_backups は devbase が生成する timestamp 形式 (YYYYMMDD-HHMMSS) の ディレクトリのみを削除対象にし、--backup-dir 親に置かれた無関係なファイル /ディレクトリを誤って消さないようにする - EnvFile.parse_bytes(data) を新設し、io_import の bytes パースを 一時ファイル経由から直接パースに置き換え (I/O 削減 + 例外安全) - 上記 3 件分の回帰テストを追加 Refs: codex review #4336666744 / gemini review #4336672519 * fix(env): import の二重エスケープ / rollback / tmp 残骸 / completion を修正 PR #15 round 2 のクロスレビュー指摘 (codex 3 件 + gemini 2 件) に対応。 * EnvFile.parse_bytes に double-quote 内 escape の逆変換 (state machine) を 追加し、save / _format_env_bytes との round-trip を成立させる。これにより backslash / quote / 改行を含む値が import → export を経て二重エスケープ される問題を解消する。 * _plan_env_merge の create パスでは _format_env_bytes による再シリアライズを 避け、incoming_bytes をそのまま採用する。バンドル側のバイト列を完全に 保持し、parse/format に潜む副作用を確実に排除する。 * _rollback で「backup が無い = 元ファイル不在」のケースを op に関係なく unlink するように変更。op='sources-merge' で sources.yml を新規作成した ロールバックでも残骸が残らなくなる。 * _commit 失敗時に、まだ rename されていない .import.tmp ファイルを try-finally で確実に削除する。 * _make_backup_dir に microsecond + 連番フォールバックを付与し、同一秒内に import が複数回走っても backup ディレクトリが衝突しない。 * etc/devbase-completion.bash と etc/_devbase の env サブコマンド一覧に import を追加し、各オプションも補完できるようにする。 テストでは parse_bytes round-trip / create 経路の byte preservation / sources.yml rollback unlink / tmp cleanup / backup 衝突回避を検証する。 Co-Authored-By: Claude Opus 4.7 (1M context) * fix(env): import の merge でコメントを保持し $ をエスケープする - ``EnvFile.parse_entries`` を追加し、コメント / 空行 / kv を行単位で トークン化できるようにする (PR #15 gemini 指摘)。 - ``_merge_into_existing_bytes`` で既存 ``.env`` のコメント・空行・キー順を 保持したまま値を差し替えるよう merge 経路を変更。``EnvFile.dump_bytes`` への単純差し替えではコメントが失われていた。 - ダブルクオート値に含まれる ``$`` を ``\$`` にエスケープし、``parse_bytes`` 側でも ``\$`` を ``$`` に戻すように round-trip 対応 (シェル ``source`` 時の 意図しない変数展開を防止 / PR #15 gemini 指摘)。 - ``EnvFile.dump_bytes`` / ``EnvFile.dump_entries_bytes`` にフォーマット ロジックを集約し、``io_import._format_env_bytes`` を廃止。``EnvFile.save`` も ``dump_bytes`` 経由に統一して二重実装を解消 (PR #15 gemini 指摘)。 - 新規テスト: コメント保持マージ (prefer-incoming / keep-existing)、 ``$`` の round-trip、``EnvFile.dump_bytes`` のエスケープ仕様を追加。 * refactor(env): _plan_env_merge の重複を _build_merge_plan に共通化 各マージ戦略 (replace_keys / keep-existing / prefer-incoming) で個別に書かれていた new_bytes 生成と _Plan 構築を `_build_merge_plan` にまとめた。各分岐は merged / added / overwritten / skipped の計算だけを担当するように整理。 動作変更なし (PR #15 gemini round4 指摘 / 既存テスト 102 件 PASS)。 * fix(env): コメントのみ既存 .env を merge 経路に通す + env i 短縮維持 - _build_merge_plan / _plan_env_merge / replace ブランチの op 判定を existing (key=value dict) の有無から target.exists() に変更。 コメント / 空行のみで構成された既存 .env が「create」と誤判定されて incoming_bytes で上書きされ既存コメントが失われる問題を修正 (PR #15 round5 codex/gemini 指摘)。 - SUBCMD_PREFIX_PREFERENCES に i: init を追加。import 追加で devbase env i が init / import の両方にマッチして ambiguous に なっていたため、既存ショートカット (devbase env i → init) を維持する (PR #15 round5 codex 指摘)。 - 回帰テスト追加: - tests/cli/test_env_import.py: コメント/空行のみの既存 .env が prefer-incoming / keep-existing / replace-keys でコメント保持される こと。--replace ブランチでも op='replace' として報告され backup が 取られること - tests/cli/test_prefix_resolution.py: devbase env i → init, devbase env im → import, devbase env in → init Co-Authored-By: Claude Opus 4.7 (1M context) * fix(env): import の sys を先頭 import に / TTY 時 getpass.getpass でエコー抑止 gemini round 6 で挙がった minor 指摘 2 件に対応: 1. lib/devbase/env/io_import.py:97 `import sys` がローカル import になっていたのを ファイル先頭の標準 import セクションに移動 (`import getpass` も同時追加)。 メンテナンス性向上、PEP 8 への準拠。 2. lib/devbase/env/io_import.py:100 TTY 入力時に `sys.stdin.readline()` を使って いたため、パスフレーズがそのまま画面にエコーバックされていた。 `getpass.getpass(prompt, stream=sys.stderr)` を使うように変更。 パイプ入力時 (非 TTY) は従来どおり `sys.stdin.readline()` で読み ( stdin リダイレクト経由のテストフックとして必要)、getpass の EOFError は `ImportError("stdin からパスフレーズを読み取れませんでした")` に変換する。 回帰テスト 3 件を追加 (tests/cli/test_env_import.py): - test_read_passphrase_uses_getpass_on_tty - test_read_passphrase_falls_back_to_stdin_on_pipe - test_read_passphrase_tty_eof_raises_import_error ローカル品質チェック: - uv run pytest tests/ -> 112 passed (107 + 3 + 既存 review round で +2) - uv run python -m compileall lib bin -> OK - uvx ruff check --select=E9,F63,F7,F82 lib -> All checks passed Co-Authored-By: Claude Opus 4.7 (1M context) * fix(env): export 側にも TTY エコー抑止と先頭 import を適用 (import と対称化) PR #15 round 7 で io_import.py に入れた修正 (commit 454425e) を、対称な io_export.py 側にも適用する。両者は _read_passphrase の構造が同じで、 TTY エコー抑止と import 位置の問題も同じパターンで残っていた。 - lib/devbase/env/io_export.py: - ローカル `import sys` をファイル先頭の標準 import セクションに移動 - `import getpass` を追加 - TTY 入力時に `getpass.getpass("passphrase: ", stream=sys.stderr)` を 使うように変更。EOFError は ExportError("stdin からパスフレーズを 読み取れませんでした") に変換 - パイプ入力時 (非 TTY) は従来どおり `sys.stdin.readline()` を使用 - tests/cli/test_env_export.py: - 旧テスト test_read_passphrase_shows_prompt_on_tty / test_read_passphrase_no_prompt_on_pipe は `print` 経由の prompt 表示を 検証する構造だったため、getpass.getpass をモックする方式に書き換え - 追加: test_read_passphrase_uses_getpass_on_tty (TTY 時に getpass.getpass が呼ばれ stdin は消費されないこと) - 追加: test_read_passphrase_falls_back_to_stdin_on_pipe (非 TTY では getpass は呼ばれず stdin.readline 経路に入ること) - 追加: test_read_passphrase_tty_eof_raises_export_error (EOFError → ExportError 変換) ローカル品質チェック: - uv run pytest tests/ -> 113 passed - uvx ruff check --select=E9,F63,F7,F82 lib -> All checks passed Co-Authored-By: Claude Opus 4.7 (1M context) * refactor(env): _unescape_double_quoted を re.sub + 逆引き辞書に書き換え PR #15 ユーザーレビュー (lib/devbase/env/store.py:160) で挙がった 「pythonic に書き直してください」への対応。 before: バックスラッシュ + 次文字を 1 文字ずつ走査する hand-rolled state machine (25 行)。 after: re.compile(r'\\.') で \ を一括捕捉し、逆引き辞書 {'\\\\': '\\', '\\"': '"', '\\n': '\n', '\\$': '$'} で 1 行置換する re.sub 呼び出し 1 つに集約 (5 行)。 挙動は state machine と完全に同等: - 未知エスケープ (例: \x) は dict.get の default にマッチ文字列 自身を返すことでバックスラッシュごと保持 - 末尾の単独 \ は \\. のドットが 2 文字目を要求するため自然に マッチせず、そのまま保持 - \\\\n (リテラル \\ + n) と \\n (改行) も re.sub は左から非重複で マッチするため state machine と同じ結果 差分: 43 → 15 行 (net -28 行 削除 / +15 行 追加 = -13 行)。 既存テスト (test_envfile_parse_bytes_round_trip_with_escapes 等) で double-quote escape の round-trip は完全に検証済みのため新規テストは追加せず。 ローカル品質チェック: - uv run pytest tests/ -> 113 passed - uvx ruff check --select=E9,F63,F7,F82 lib -> All checks passed - uv run python -m compileall lib bin -> OK Co-Authored-By: Claude Opus 4.7 (1M context) --------- Co-authored-by: Claude Opus 4.7 (1M context) --- etc/_devbase | 19 + etc/devbase-completion.bash | 7 +- lib/devbase/cli.py | 54 +- lib/devbase/commands/env.py | 28 + lib/devbase/env/io_export.py | 12 +- lib/devbase/env/io_import.py | 711 +++++++++++++++++++++ lib/devbase/env/store.py | 184 +++++- tests/cli/test_env_export.py | 43 +- tests/cli/test_env_import.py | 941 ++++++++++++++++++++++++++++ tests/cli/test_prefix_resolution.py | 21 + 10 files changed, 1973 insertions(+), 47 deletions(-) create mode 100644 lib/devbase/env/io_import.py create mode 100644 tests/cli/test_env_import.py diff --git a/etc/_devbase b/etc/_devbase index c5d0f14..b7944dd 100644 --- a/etc/_devbase +++ b/etc/_devbase @@ -74,6 +74,7 @@ _devbase() { '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=( @@ -163,6 +164,24 @@ _devbase() { '--passphrase-stdin[Read passphrase from stdin]' \ '--force-unencrypted[Write as plaintext tar.gz]' ;; + 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 e7ef68c..81f6431 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 export" + 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" @@ -86,6 +86,11 @@ _devbase_completions() { COMPREPLY=($(compgen -W "--include-project --exclude-project --no-global --no-metadata --recipient --passphrase-env --passphrase-stdin --force-unencrypted" -- "$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 1da2160..d519db4 100644 --- a/lib/devbase/cli.py +++ b/lib/devbase/cli.py @@ -36,7 +36,7 @@ # 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', 'export'], + ('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'], } @@ -47,6 +47,9 @@ SUBCMD_PREFIX_PREFERENCES = { ('env',): { 'e': 'edit', + # `import` 追加で `i` が `init` / `import` の両方にマッチして ambiguous に + # なるため、既存ショートカット (`devbase env i` → `init`) を維持する。 + 'i': 'init', }, } @@ -149,6 +152,55 @@ def _add_env_parser(subparsers): help='Write as plaintext tar.gz (rejected by default; ' 'warns when sensitive keys are detected)') + 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 " + "(first existing one)")) + 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""" diff --git a/lib/devbase/commands/env.py b/lib/devbase/commands/env.py index a324fbd..3b43598 100644 --- a/lib/devbase/commands/env.py +++ b/lib/devbase/commands/env.py @@ -34,6 +34,7 @@ def cmd_env(devbase_root: Path, args) -> int: '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) @@ -401,6 +402,33 @@ def cmd_env_export(devbase_root: Path, args) -> int: 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/io_export.py b/lib/devbase/env/io_export.py index 2da2c2c..101ceb2 100644 --- a/lib/devbase/env/io_export.py +++ b/lib/devbase/env/io_export.py @@ -2,8 +2,10 @@ from __future__ import annotations +import getpass import os import re +import sys from dataclasses import dataclass, field from datetime import datetime from pathlib import Path @@ -69,11 +71,13 @@ def _read_passphrase(opts: ExportOptions) -> Optional[str]: ) return value if opts.passphrase_stdin: - import sys - # tty で対話実行している場合、ユーザーが「ハングしている」と誤解しないよう - # stderr へプロンプトを出してから stdin を待つ (パイプ入力時は出さない)。 + # tty で対話実行している場合は getpass.getpass でエコー抑止 + # (パイプ入力時は echo の概念がないので従来どおり stdin.readline で読む)。 if sys.stdin.isatty(): - print("passphrase: ", end='', file=sys.stderr, flush=True) + try: + return getpass.getpass("passphrase: ", stream=sys.stderr) + except EOFError as e: + raise ExportError("stdin からパスフレーズを読み取れませんでした") from e line = sys.stdin.readline() if not line: raise ExportError("stdin からパスフレーズを読み取れませんでした") diff --git a/lib/devbase/env/io_import.py b/lib/devbase/env/io_import.py new file mode 100644 index 0000000..5f025b1 --- /dev/null +++ b/lib/devbase/env/io_import.py @@ -0,0 +1,711 @@ +"""devbase env import の高レベル実装 + +責務: + - SOURCE (file / stdio) の読み込み + - age 復号 (バンドルが暗号化されていれば) + - tar.gz バンドルの展開と sha256 / manifest version の検証 (bundle.unpack) + - --merge / --replace-keys / --replace のセマンティクスで .env 群を更新 + - .env.sources.yml は既定で上書きせず参照用コピーのみ (--merge-metadata で + 新規 source のみ追加) + - 2 フェーズ書き出し (prepare → commit) で部分適用を最小化 + - --backup-dir / --keep-last N で backup を GC + - --dry-run で差分プレビュー +""" + +from __future__ import annotations + +import getpass +import os +import re +import shutil +import sys +from dataclasses import dataclass, field +from datetime import datetime +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 as _bundle +from devbase.env import cipher as _cipher +from devbase.env import storage as _storage +from devbase.env.store import EnvEntry, EnvFile + +logger = get_logger(__name__) + +# gzip magic. tar.gz バンドルは先頭 2 byte が 0x1f 0x8b。age 暗号化済みは +# テキストヘッダ "age-encryption.org/v1\n" で始まるため magic で識別できる。 +_GZIP_MAGIC = b'\x1f\x8b' + +_MERGE_MODES = ('keep-existing', 'prefer-incoming') + +# _make_backup_dir が生成するタイムスタンプ形式のみを GC 対象にする。 +# 以下のいずれかにマッチするディレクトリのみ削除する: +# YYYYMMDD-HHMMSS (旧フォーマット, 後方互換) +# YYYYMMDD-HHMMSS-NNNNNN (microsecond 付き) +# YYYYMMDD-HHMMSS-NNNNNN-NN (同一マイクロ秒内の連番付き) +# これ以外のディレクトリは devbase が作ったものではないので削除しない +# (--backup-dir 親に無関係なディレクトリがあっても安全)。 +_BACKUP_DIR_NAME_RE = re.compile(r'^\d{8}-\d{6}(?:-\d{6}(?:-\d+)?)?$') + + +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 + + +@dataclass +class _Plan: + """1 ファイル分の書き出し計画""" + target: Path + arcname: str + new_bytes: bytes + # 差分サマリ (dry-run / ログ用) + 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 _read_passphrase(opts: ImportOptions) -> Optional[str]: + if opts.passphrase_env: + value = os.environ.get(opts.passphrase_env) + if not value: + raise ImportError(f"環境変数 {opts.passphrase_env} が空または未設定です") + return value + if opts.passphrase_stdin: + if sys.stdin.isatty(): + try: + return getpass.getpass("passphrase: ", stream=sys.stderr) + except EOFError as e: + raise ImportError("stdin からパスフレーズを読み取れませんでした") from e + line = sys.stdin.readline() + if not line: + raise ImportError("stdin からパスフレーズを読み取れませんでした") + return line.rstrip('\n') + return None + + +def _resolve_identities(specs: Sequence[str]) -> List[str]: + if specs: + return list(specs) + for path in _cipher.default_identity_paths(): + if path.exists(): + logger.info("identity 既定鍵を使用: %s", path) + return [str(path)] + return [] + + +def _decrypt_if_needed(blob: bytes, opts: ImportOptions) -> bytes: + """先頭バイトで暗号化済みかを判定して必要なら復号する""" + if blob[:2] == _GZIP_MAGIC: + # 平文 tar.gz。鍵指定があっても無視せず警告にとどめる + 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 = _resolve_identities(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 _filter_members(members: Dict[str, bytes], + opts: ImportOptions) -> Dict[str, bytes]: + """include/exclude 指定で展開済みメンバーを絞り込む""" + included = set(opts.include_projects) if opts.include_projects else None + excluded = set(opts.exclude_projects) + result: Dict[str, bytes] = {} + + proj_re = re.compile(r'^env/projects/([^/]+)/\.env$') + + for arcname, data in members.items(): + if arcname == 'env/global.env': + if not opts.include_global: + continue + result[arcname] = data + continue + if arcname == 'env/sources.yml': + if not opts.include_metadata: + continue + result[arcname] = data + continue + m = proj_re.match(arcname) + if m: + name = m.group(1) + if name in excluded: + continue + if included is not None and name not in included: + continue + result[arcname] = data + continue + # 他の形式は manifest 検証で拒否されているはずだが念のため + logger.debug("未対応の arcname を無視します: %s", arcname) + return result + + +def _target_for(arcname: str, devbase_root: Path) -> Path: + if arcname == 'env/global.env': + return devbase_root / '.env' + if arcname == 'env/sources.yml': + return devbase_root / '.env.sources.yml' + m = re.match(r'^env/projects/([^/]+)/\.env$', arcname) + if m: + return devbase_root / 'projects' / m.group(1) / '.env' + raise ImportError(f"未対応のバンドルエントリ: {arcname}") + + +def _merge_into_existing_bytes(existing_bytes: bytes, + merged: Dict[str, str]) -> bytes: + """既存 ``.env`` のコメント / 空行 / キー順を保持したまま、 + ``merged`` の内容で値を上書きしてバイト列化する。 + + ``merged`` のうち既存ファイルに無いキーは末尾に追加する。 + 既存ファイルにあって ``merged`` から削除されているキーは、entries からも除外して + 出力する (現状の merge ロジック上、削除されるケースは無いが安全側で対応)。 + + PR #15 gemini 指摘: ``EnvFile.dump_bytes`` で再シリアライズすると ``key=value`` + だけの出力になりコメント・空行が失われる。これを避けるため entries ベースで + 再構成する。 + """ + entries = EnvFile.parse_entries(existing_bytes) + seen: set = set() + out_entries: List[EnvEntry] = [] + for e in entries: + if e.kind == 'kv' and e.key is not None: + if e.key in merged: + # 値を merge 後のものに差し替え (key 順 / コメントは保持) + out_entries.append(EnvEntry( + kind='kv', raw=e.raw, key=e.key, value=merged[e.key] + )) + seen.add(e.key) + # merged から除外されているキーは entries からも落とす + else: + out_entries.append(e) + # 既存に無かった新規キーは末尾に append (定常的な key 順を維持するため sorted) + for key in sorted(k for k in merged if k not in seen): + out_entries.append(EnvEntry( + kind='kv', raw='', key=key, value=merged[key] + )) + return EnvFile.dump_entries_bytes(out_entries) + + +def _build_merge_plan( + target: Path, + arcname: str, + incoming_bytes: bytes, + existing_bytes: bytes, + target_exists: bool, + merged: Dict[str, str], + added: List[str], + overwritten: List[str], + skipped: List[str], +) -> _Plan: + """merge 系 (replace_keys / keep-existing / prefer-incoming) 共通の _Plan 生成。 + + - 新規作成時 (``target_exists`` が False) は ``incoming_bytes`` をそのまま採用して + 二重エスケープを回避 (PR #15 codex 指摘) + - 既存ファイルへの merge 時は ``_merge_into_existing_bytes`` でコメント / 空行 / + キー順を保持したまま値を差し替える (PR #15 gemini 指摘)。 + ``existing`` (key=value dict) の空判定で create/merge を決めると、コメント / + 空行のみで構成された既存 .env が ``incoming_bytes`` で上書きされてコメントが + 失われるため、ファイル実体の有無で判定する (PR #15 round5 指摘)。 + """ + 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_env_merge(target: Path, incoming_bytes: bytes, + opts: ImportOptions, arcname: str) -> _Plan: + """1 つの .env に対する merge / replace 計画を作る + + 既存ファイルが無い (= create) ケースでは、バンドル側の ``incoming_bytes`` を + そのまま採用する。``EnvFile.dump_bytes`` で再シリアライズすると、export 側で + 既に escape された値を parse_bytes 経由でも完全に round-trip できる前提が + 崩れた瞬間に二重エスケープが発生するためである (PR #15 codex 指摘)。 + + 既存ファイルが存在する merge 経路では ``_merge_into_existing_bytes`` で + 既存のコメント / 空行 / キー順を保持したまま値だけ差し替える + (PR #15 gemini 指摘)。 + + 各分岐で重複していた ``new_bytes`` 生成と ``_Plan`` 構築は + ``_build_merge_plan`` に括り出している (PR #15 gemini round4 指摘)。 + """ + incoming = EnvFile.parse_bytes(incoming_bytes) + existing: Dict[str, str] = {} + existing_bytes: bytes = b'' + target_exists = target.exists() + if target_exists: + existing_bytes = target.read_bytes() + existing = EnvFile.parse_bytes(existing_bytes) + + if opts.replace: + added = sorted(set(incoming) - set(existing)) + overwritten = sorted(k for k in incoming if k in existing and incoming[k] != existing[k]) + # replace は バンドル側の値で完全に置き換える (merge 経路と別系統) + # op 判定は ``existing`` (key=value dict) ではなくファイル実体の有無で行う: + # コメントのみの既存 .env を 'create' と誤判定しないため (PR #15 round5 指摘)。 + return _Plan( + target=target, + arcname=arcname, + new_bytes=incoming_bytes, + added_keys=added, + overwritten_keys=overwritten, + skipped_keys=[], + op='replace' if target_exists else 'create', + ) + + merged: Dict[str, str] = dict(existing) + added: List[str] = [] + overwritten: List[str] = [] + skipped: List[str] = [] + + if opts.replace_keys: + replace_set = set(opts.replace_keys) + for key, value in incoming.items(): + if key in replace_set: + if key in existing: + if existing[key] != value: + overwritten.append(key) + merged[key] = value + else: + added.append(key) + merged[key] = value + else: + # --replace-keys 指定外のキーは keep-existing 相当: + # 既存にあれば残し、無ければ新規追加 (skipped は overwrite を + # 抑止した = 上書きしなかったキーのみ)。 + if key in existing: + if existing[key] != value: + skipped.append(key) + else: + added.append(key) + merged[key] = value + elif opts.merge == 'keep-existing': + for key, value in incoming.items(): + if key in existing: + skipped.append(key) + else: + merged[key] = value + added.append(key) + elif opts.merge == 'prefer-incoming': + for key, value in incoming.items(): + if key in existing: + if existing[key] != value: + overwritten.append(key) + merged[key] = value + else: + merged[key] = value + added.append(key) + else: + raise ImportError(f"不明な --merge モード: {opts.merge!r}") + + return _build_merge_plan( + target=target, + arcname=arcname, + incoming_bytes=incoming_bytes, + existing_bytes=existing_bytes, + target_exists=target_exists, + merged=merged, + added=added, + overwritten=overwritten, + skipped=skipped, + ) + + +def _plan_sources(target: Path, incoming_bytes: bytes, + opts: ImportOptions) -> Optional[_Plan]: + """.env.sources.yml の取り扱い計画 + + 既定: 上書きせず None を返す (backup_dir に参照用コピーのみ書く)。 + --merge-metadata: 新規 source エントリのみ追加した内容で更新する。 + """ + if not opts.merge_metadata: + # 上書きしないので _Plan は返さない。参照用 copy は run() 側で処理。 + return None + + try: + incoming = yaml.safe_load(incoming_bytes) or {} + except yaml.YAMLError as e: + raise ImportError(f"バンドルの sources.yml が壊れています: {e}") from e + if not isinstance(incoming, dict): + raise ImportError("バンドルの sources.yml が dict ではありません") + incoming_sources = incoming.get('sources') or {} + if not isinstance(incoming_sources, dict): + raise ImportError("バンドルの sources.yml の sources が dict ではありません") + + existing: Dict = {} + if target.exists(): + try: + existing = yaml.safe_load(target.read_bytes()) or {} + except yaml.YAMLError as e: + raise ImportError( + f"既存の {target.name} のパースに失敗しました: {e}" + ) from e + if not isinstance(existing, dict): + existing = {} + existing.setdefault('sources', {}) + if not isinstance(existing['sources'], dict): + existing['sources'] = {} + + added: List[str] = [] + merged_sources = dict(existing['sources']) + 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), + overwritten_keys=[], + skipped_keys=[], + op='sources-merge', + ) + + +def _make_backup_dir(devbase_root: Path, opts: ImportOptions) -> Path: + """バックアップディレクトリを作成する。 + + 秒精度のみだと同一秒に 2 回 import を走らせたときに同じディレクトリを再利用して + 前回バックアップを上書きしてしまうため、microsecond + 連番を付与して衝突を回避する + (PR #15 codex 指摘)。 + """ + if opts.backup_dir: + base = Path(opts.backup_dir).expanduser() + else: + base = devbase_root / 'backups' / 'env-import' + base.mkdir(parents=True, exist_ok=True) + + now = datetime.now() + stem = now.strftime('%Y%m%d-%H%M%S-%f') # microsecond まで + path = base / stem + if not path.exists(): + path.mkdir(parents=True) + return path + # 同一マイクロ秒に複数回走った場合の安全弁: 連番を付与 + for n in range(1, 1000): + candidate = base / f'{stem}-{n:02d}' + if not candidate.exists(): + candidate.mkdir(parents=True) + return candidate + raise ImportError( + f"backup ディレクトリの衝突回避に失敗しました (base={base}, stem={stem})" + ) + + +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 + try: + relative = plan.target.relative_to(devbase_root) + except ValueError: + relative = Path(plan.target.name) + dst = backup_dir / relative + dst.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(plan.target, dst) + + # バンドルに含まれていた sources.yml の参照用コピー (上書きしないケース) + if sources_copy is not None: + target, data = sources_copy + dst = backup_dir / 'sources.yml.imported' + dst.parent.mkdir(parents=True, exist_ok=True) + dst.write_bytes(data) + try: + os.chmod(dst, 0o600) + except OSError: + pass + + +def _write_atomic(plan: _Plan) -> Path: + """phase 1: 新内容を .import.tmp として書き出す (0600)""" + tmp = plan.target.with_suffix(plan.target.suffix + '.import.tmp') + tmp.parent.mkdir(parents=True, exist_ok=True) + if tmp.exists(): + # 過去の失敗の残骸を掃除 + try: + tmp.unlink() + except OSError: + pass + flags = os.O_WRONLY | os.O_CREAT | os.O_TRUNC + fd = os.open(tmp, flags, 0o600) + try: + with os.fdopen(fd, 'wb') as f: + f.write(plan.new_bytes) + except BaseException: + try: + os.close(fd) + except OSError: + pass + raise + try: + os.chmod(tmp, 0o600) + except OSError: + pass + 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_tmps = [tmp for _, tmp in plans_and_tmps] + try: + for idx, (plan, tmp) in enumerate(plans_and_tmps): + os.replace(tmp, plan.target) + try: + os.chmod(plan.target, 0o600) + except OSError: + pass + committed.append((plan, plan.target)) + # rename 済みの tmp は残らないが、リストから除外して後続 cleanup を簡潔に + remaining_tmps[idx] = None # type: ignore[call-overload] + except OSError as e: + logger.error("commit フェーズで失敗しました: %s", e) + try: + _rollback(committed, backup_dir, devbase_root) + finally: + # rename 前で残っている .import.tmp を後始末 + _cleanup_tmps([t for t in remaining_tmps if t is not None]) + raise ImportError(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 plan, target in committed: + try: + relative = target.relative_to(devbase_root) + except ValueError: + relative = Path(target.name) + src = backup_dir / relative + 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) + else: + # 元ファイル不在 → 新規作成された 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: Sequence[Path]) -> None: + 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 する。 + + 安全性のため、削除対象は devbase が生成するタイムスタンプ形式 + (YYYYMMDD-HHMMSS) のディレクトリに限定する。--backup-dir で指定された + 親ディレクトリに無関係なファイル/ディレクトリがあっても、それらは触らない。 + """ + if keep_last <= 0: + return + parent = backup_dir.parent + if not parent.is_dir(): + return + siblings = sorted( + (p for p in parent.iterdir() + if p.is_dir() and _BACKUP_DIR_NAME_RE.match(p.name)), + key=lambda p: p.name, + ) + if len(siblings) <= keep_last: + return + to_remove = siblings[:-keep_last] + for d in to_remove: + try: + shutil.rmtree(d) + logger.info("古い backup を削除しました: %s", d) + except OSError as e: + logger.warning("backup 削除に失敗 (%s): %s", d, e) + + +def _log_plans(plans: Sequence[_Plan], dry_run: bool) -> None: + 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)) + + +def import_bundle(devbase_root: Path, opts: ImportOptions) -> int: + """import 本体。CLI ハンドラから呼ばれる""" + # 引数の早期検証 + if opts.merge not in _MERGE_MODES: + raise ImportError( + f"--merge の値が不正です: {opts.merge!r} (許可: {', '.join(_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 は併用できません") + + # SOURCE 読み込み + backend = _storage.resolve(opts.source) + blob = backend.read_bytes(opts.source) + logger.debug("読み込みサイズ: %d bytes", len(blob)) + + # 復号 (必要なら) + 展開 + manifest 検証 (sha256 / version) + 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 = _filter_members(members, opts) + if not filtered: + raise ImportError( + "import 対象がありません " + "(--no-global / --include-project の指定とバンドル内容を確認してください)" + ) + + # 計画作成 + plans: List[_Plan] = [] + sources_reference: Optional[Tuple[Path, bytes]] = None + for arcname, data in sorted(filtered.items()): + target = _target_for(arcname, devbase_root) + if arcname == 'env/sources.yml': + plan = _plan_sources(target, data, opts) + if plan is not None: + plans.append(plan) + else: + # 既定動作: 上書きしないので参照用 copy のみバックアップする + sources_reference = (target, data) + else: + plans.append(_plan_env_merge(target, data, opts, arcname)) + + _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 → phase 1 (tmp 書き出し) → phase 2 (rename) + backup_dir = _make_backup_dir(devbase_root, opts) + logger.info("backup ディレクトリ: %s", backup_dir) + _backup_existing(plans, sources_reference, backup_dir, devbase_root) + + tmps: List[Path] = [] + plans_and_tmps: List[Tuple[_Plan, Path]] = [] + try: + for plan in plans: + tmp = _write_atomic(plan) + tmps.append(tmp) + plans_and_tmps.append((plan, tmp)) + except Exception: + _cleanup_tmps(tmps) + raise + + _commit(plans_and_tmps, backup_dir, devbase_root) + logger.info("import 完了: %d ファイル更新", len(plans)) + + _gc_backups(backup_dir, opts.keep_last) + return 0 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/tests/cli/test_env_export.py b/tests/cli/test_env_export.py index 18b89fe..dd44b47 100644 --- a/tests/cli/test_env_export.py +++ b/tests/cli/test_env_export.py @@ -93,29 +93,58 @@ def test_export_rejects_both_passphrase_env_and_stdin(fake_root): dest="/dev/null", passphrase_env="X", passphrase_stdin=True)) -def test_read_passphrase_shows_prompt_on_tty(monkeypatch, capsys): - """tty 入力時は stderr に 'passphrase: ' プロンプトを表示する""" - fake_stdin = io.StringIO("hunter2\n") +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" - err = capsys.readouterr().err - assert "passphrase: " in err + assert calls['prompt'] == "passphrase: " + assert fake_stdin.read() == "should-not-be-read\n" # stdin は消費されていない -def test_read_passphrase_no_prompt_on_pipe(monkeypatch, capsys): - """パイプ (非 tty) 入力時はプロンプトを出さない""" +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_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") diff --git a/tests/cli/test_env_import.py b/tests/cli/test_env_import.py new file mode 100644 index 0000000..f509895 --- /dev/null +++ b/tests/cli/test_env_import.py @@ -0,0 +1,941 @@ +"""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_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 個に絞られる: 既存の 20260101-000003, 000004, 加えて新規 backup + assert remaining[-1].startswith('20') + + +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 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(_io_import.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_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 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(_io_import.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 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(_io_import.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) + + with caplog.at_level(logging.INFO, logger="devbase.env.io_import"): + 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 diff --git a/tests/cli/test_prefix_resolution.py b/tests/cli/test_prefix_resolution.py index 0a7574e..06f849a 100644 --- a/tests/cli/test_prefix_resolution.py +++ b/tests/cli/test_prefix_resolution.py @@ -48,3 +48,24 @@ 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"] From 9c419fc5e2d75012e9fc5f76709a3904a1b3508c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=A4=A7=E6=B5=9C=E6=AF=85=E7=BE=8E?= Date: Sat, 23 May 2026 23:52:31 +0900 Subject: [PATCH 04/16] feat(env): PLAN03-1 PR3 devbase env export/import S3 backend (#19) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore: PLAN03-1 PR3 Draft PR 作成 (S3 backend) * feat(env): PLAN03-1 PR3 devbase env export/import S3 backend - `s3://bucket/key` を `devbase env export` / `devbase env import` の 入出力先として指定できるようにする - export 時は ServerSideEncryption (`aws:kms` 既定, `AES256` 切替可) を 常に PutObject に付与し、加えて GetBucketEncryption で **バケット側の 既定暗号化** も事前確認する - 暗号化未設定 / 確認不可 (AccessDenied) のバケットへは `--unsafe-allow-unencrypted-bucket` を明示しない限り export を拒否する (オブジェクト単位の SSE はこのフラグに関係なく常に付与される) - SSE 種別 / KMS 鍵 / エンドポイント / リージョンは環境変数 (`DEVBASE_S3_SSE`, `DEVBASE_S3_SSE_KMS_KEY_ID`, `DEVBASE_S3_ENDPOINT_URL`, `DEVBASE_S3_REGION`) で上書きできる - `boto3` は optional dep として `[project.optional-dependencies] s3` に追加 (`pip install 'devbase[s3]'` でインストール) - `gs://` (GCS) は PLAN03-1 PR4 廃案のため明示エラーで拒否する Co-Authored-By: Claude Opus 4.7 (1M context) * fix(env): PLAN03-1 PR3 storage.py minor 修正 (cross-review round 1) PR #19 のクロスレビュー (codex / gemini) で指摘された minor 3 件に対応。 - `_parse_s3_uri`: `urlparse` は S3 キーに含まれる `?` / `#` を query / fragment として落としてしまうため、AWS CLI と同じ挙動になるよう スキームを除去した上で `partition('/')` で分割する。 - boto3 未インストール時のエラーメッセージを `pip install boto3` から 本プロジェクトの optional dependency 経由 (`pip install 'devbase[s3]'` / `uv add 'devbase[s3]'`) に変更。 - `_verify_bucket_encryption`: MinIO / LocalStack 等の S3 互換ストレージで GetBucketEncryption が NotImplemented を返すケースに備え、 `--unsafe-allow-unencrypted-bucket` 指定時は未知エラーも警告のみで続行する 逃げ道として機能させる (CHANGELOG の S3 互換ストレージ対応との整合)。 新規テスト: query/fragment 保持、未知エラーの拒否、unsafe フラグでの続行を追加。 Co-Authored-By: Claude Opus 4.7 (1M context) * chore(env): PLAN03-1 PR3 boto3 を main dependency に昇格 boto3 を `[project.optional-dependencies].s3` から `[project].dependencies` に移し、ImportError ハンドラとフォローアップ案内文を撤去する。 意図: - S3 URI を初めて指定したユーザに `pip install 'devbase[s3]'` を 打たせる UX を廃する。25MB 程度のコスト増 (botocore 24MB) は 実装複雑度ゼロと引き換え。 - 引数検出 (`s3://` 走査) や lazy 自動 install を採らないのは、 CI / オフライン / read-only コンテナで挙動が安定するため。 storage.py / test_storage.py の boto3-missing 関連コードを削除。 CHANGELOG.md の optional 記述も同期更新。 Co-Authored-By: Claude Opus 4.7 (1M context) --------- Co-authored-by: Claude Opus 4.7 (1M context) --- .gitignore | 1 + CHANGELOG.md | 10 ++ lib/devbase/cli.py | 4 + lib/devbase/commands/env.py | 3 + lib/devbase/env/io_export.py | 10 +- lib/devbase/env/storage.py | 229 +++++++++++++++++++++++- pyproject.toml | 1 + tests/env/test_storage.py | 334 ++++++++++++++++++++++++++++++++++- uv.lock | 83 ++++++++- 9 files changed, 662 insertions(+), 13 deletions(-) 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..23027d4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,16 @@ ## [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 の複雑さを避けるトレードオフです)。 + +### Changed +- `gs://` (GCS) スキームは **PLAN03-1 PR4 廃案** により対応しません。指定すると明示的なエラーメッセージで失敗します (旧: "未実装")。 + ## [2.2.0] - 2026-04-20 OSS 化に伴う初回リリース。devbase は本バージョンより `devbasex` Organization 配下で公開されます。 diff --git a/lib/devbase/cli.py b/lib/devbase/cli.py index d519db4..e2fd4be 100644 --- a/lib/devbase/cli.py +++ b/lib/devbase/cli.py @@ -151,6 +151,10 @@ def _add_env_parser(subparsers): 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', diff --git a/lib/devbase/commands/env.py b/lib/devbase/commands/env.py index 3b43598..2c46b9c 100644 --- a/lib/devbase/commands/env.py +++ b/lib/devbase/commands/env.py @@ -398,6 +398,9 @@ def cmd_env_export(devbase_root: Path, args) -> int: 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) diff --git a/lib/devbase/env/io_export.py b/lib/devbase/env/io_export.py index 101ceb2..08645a6 100644 --- a/lib/devbase/env/io_export.py +++ b/lib/devbase/env/io_export.py @@ -39,6 +39,9 @@ class ExportOptions: 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: @@ -167,7 +170,12 @@ def export(devbase_root: Path, opts: ExportOptions) -> int: logger.debug("暗号化後サイズ: %d bytes", len(payload)) dest = opts.dest or _default_dest(opts.force_unencrypted) - backend = _storage.resolve(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): diff --git a/lib/devbase/env/storage.py b/lib/devbase/env/storage.py index 114d0b4..adaa744 100644 --- a/lib/devbase/env/storage.py +++ b/lib/devbase/env/storage.py @@ -1,14 +1,18 @@ -"""env バンドルの入出力先 (local / stdio / 将来 s3, gcs) を抽象化する""" +"""env バンドルの入出力先 (local / stdio / s3) を抽象化する""" from __future__ import annotations import os import sys +from dataclasses import dataclass from pathlib import Path -from typing import Protocol +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): @@ -98,8 +102,211 @@ def read_bytes(self, source: str) -> bytes: return sys.stdin.buffer.read() -def resolve(uri: str) -> StorageBackend: - """URI スキームから対応する backend を返す""" +@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` でのみバイパス可) + """ + try: + client.get_bucket_encryption(Bucket=bucket) + return + except Exception as e: + code = self._error_code(e) + if code == 'ServerSideEncryptionConfigurationNotFoundError': + msg = ( + f"S3 バケット '{bucket}' のデフォルト暗号化が未設定です。" + "バケットポリシーで SSE-KMS or SSE-S3 を有効化するか、" + "明示的に '--unsafe-allow-unencrypted-bucket' を指定してください " + "(オブジェクト単位の SSE はこのオプションに関係なく常に付与されます)" + ) + if self._options.unsafe_allow_unencrypted_bucket: + logger.warning("%s (unsafe フラグにより続行)", msg) + return + raise StorageError(msg) from e + if code in ('AccessDenied', 'AccessDeniedException'): + msg = ( + f"S3 バケット '{bucket}' の暗号化設定を確認できません " + "(GetBucketEncryption 権限がありません)。" + "バケットポリシーの確認が取れないため export を中止します。" + "権限を付与するか、'--unsafe-allow-unencrypted-bucket' を明示してください" + ) + if self._options.unsafe_allow_unencrypted_bucket: + logger.warning("%s (unsafe フラグにより続行)", msg) + return + raise StorageError(msg) from e + # MinIO / LocalStack 等の S3 互換ストレージでは + # GetBucketEncryption が NotImplemented / MethodNotAllowed / 501 等を返す + # ことがある。`--unsafe-allow-unencrypted-bucket` 指定時は逃げ道として + # 警告のみで続行する (オブジェクト個別の SSE は引き続き付与される)。 + msg = ( + f"バケット暗号化設定の確認に失敗しました ({bucket}): {e}" + ) + if self._options.unsafe_allow_unencrypted_bucket: + logger.warning("%s (unsafe フラグにより続行)", msg) + return + raise StorageError(msg) 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() @@ -109,10 +316,14 @@ def resolve(uri: str) -> StorageBackend: if scheme in ('', 'file'): return LocalBackend() - if scheme in ('s3', 'gs'): + if scheme == 's3': + return S3Backend(s3_options if s3_options is not None else S3Options.from_env()) + + if scheme == 'gs': raise StorageError( - f"スキーム '{scheme}://' は本 PR では未実装です " - "(後続 PR で対応予定)" + "スキーム 'gs://' (GCS) は PLAN03-1 PR4 廃案により対応していません。" + "必要な場合は s3:// 経由 (S3 互換ゲートウェイ) を検討するか、" + "ローカルファイルを介して転送してください" ) # Windows のドライブレター付きパス (例: C:\path, d:/path) は @@ -126,3 +337,7 @@ def resolve(uri: str) -> StorageBackend: def is_stdio(uri: str) -> bool: return uri == '-' + + +def is_s3(uri: str) -> bool: + return urlparse(uri).scheme.lower() == 's3' diff --git a/pyproject.toml b/pyproject.toml index 2f680cb..989c1f9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,6 +6,7 @@ requires-python = ">=3.10" dependencies = [ "pyyaml>=6.0", "pyrage>=1.2", + "boto3>=1.34", ] [dependency-groups] diff --git a/tests/env/test_storage.py b/tests/env/test_storage.py index 385e5b0..0f32e3e 100644 --- a/tests/env/test_storage.py +++ b/tests/env/test_storage.py @@ -37,10 +37,30 @@ def test_resolve_stdio_for_dash(): assert not storage.is_stdio("/tmp/foo") -def test_resolve_rejects_unimplemented_schemes(): - for uri in ("s3://bucket/key", "gs://bucket/object"): - with pytest.raises(storage.StorageError, match="未実装"): - storage.resolve(uri) +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(): @@ -48,6 +68,13 @@ def test_resolve_rejects_unknown_scheme(): 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", @@ -145,3 +172,302 @@ def test_local_backend_read_wraps_oserror_as_storage_error(tmp_path): # ディレクトリを 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_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 49f3aa8..98b3471 100644 --- a/uv.lock +++ b/uv.lock @@ -1,7 +1,35 @@ 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" @@ -16,6 +44,7 @@ name = "devbase" version = "2.2.0" source = { virtual = "." } dependencies = [ + { name = "boto3" }, { name = "pyrage" }, { name = "pyyaml" }, ] @@ -27,6 +56,7 @@ dev = [ [package.metadata] requires-dist = [ + { name = "boto3", specifier = ">=1.34" }, { name = "pyrage", specifier = ">=1.2" }, { name = "pyyaml", specifier = ">=6.0" }, ] @@ -55,6 +85,15 @@ 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" @@ -112,6 +151,18 @@ 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" version = "6.0.3" @@ -176,6 +227,27 @@ wheels = [ { 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" @@ -238,3 +310,12 @@ sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac8 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" }, +] From f1fa8373b79308d36c21df2e981baf8443b9beb3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=A4=A7=E6=B5=9C=E6=AF=85=E7=BE=8E?= Date: Sun, 24 May 2026 01:53:54 +0900 Subject: [PATCH 05/16] =?UTF-8?q?feat(env):=20PLAN03-1=20PR5=20=E3=83=89?= =?UTF-8?q?=E3=82=AD=E3=83=A5=E3=83=A1=E3=83=B3=E3=83=88=E8=BF=BD=E5=8A=A0?= =?UTF-8?q?=20+=20import/export=20=E3=83=AA=E3=83=95=E3=82=A1=E3=82=AF?= =?UTF-8?q?=E3=82=BF=20(#20)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor(env): PLAN03-1 PR5 env export/import モジュールを整理 - 711 行に肥大化していた `io_import.py` を以下の 3 モジュールに分割する。 公開 API (`ImportOptions`, `import_bundle`, `ImportError`) は維持し、 テストの `_read_passphrase` 直接 import や `getpass` パッチも継続して動く: - `io_import.py` (209 行): 引数検証 / 復号判定 / 全体オーケストレーション - `_import_merge.py`: `Plan` データクラス、`plan_env_merge` / `plan_sources`、 既存コメント・空行を保持した merge ロジック、ログ整形 - `_import_atomic.py`: 2 フェーズ書き込み (`backup_existing` → `write_atomic` → `commit`)、`gc_backups`、ロールバック - export / import で重複していた共通 helper を `io_common.py` に集約する: - `read_passphrase()` — env / stdin 入力、tty 時の getpass エコー抑止 - `resolve_recipient_specs()` / `resolve_identity_specs()` — 省略時の `~/.ssh/id_ed25519(.pub)` → `id_rsa(.pub)` fallback - `write_secure_bytes()` — `os.open(O_WRONLY|O_CREAT|O_TRUNC, mode=0o600)` で TOCTOU を避けてセキュアにバイト列を書き出す共通実装。`storage.LocalBackend` と `_import_atomic` から呼び出す - `_plan_env_merge()` の 4 段ネスト if/elif を 4 つの小さな関数 (`_plan_replace` / `_plan_replace_keys` / `_plan_keep_existing` / `_plan_prefer_incoming`) に分割し、`plan_env_merge` 本体はモード選択のみに 簡素化する - `storage.LocalBackend.write_bytes` を `io_common.write_secure_bytes` への薄いラッパに置き換え、重複していた `os.open` + chmod パターンを削除 - `io_export.py` (185 → 168 行) の `_read_passphrase` / `_resolve_recipients` は `io_common` への delegation に置き換え、`encrypt_payload` / `validate_options` の helper 関数に export 本体のロジックを分解 - `_commit()` 移動に伴い、テスト 3 件の `monkeypatch.setattr(_io_import.os, 'replace', ...)` パッチ先を `_import_atomic.os` に更新。`log_plans` 移動に伴う caplog の logger 名も `devbase.env._import_merge` に追従 リファクタの動機: - io_import.py が PR1〜PR3 を通じて 711 行まで肥大化し、merge 計画 / atomic 書き込み / orchestration が同居して読みづらかった - io_export と io_import で `_read_passphrase` / 既定鍵 fallback / セキュア書き込みが ほぼ重複していた 挙動の変更は無く、全 136 テストが引き続き green を維持する。 Co-Authored-By: Claude Opus 4.7 (1M context) * docs(env): PLAN03-1 PR5 env export/import 利用者向けガイドを追加 `docs/user/env-export-import.md` (456 行) を新設し、以下を網羅する: - 対象ファイル一覧 (global / projects/*/.env / .env.sources.yml) と 公開可能な雛形 (`projects/*/env`) を含めない設計理由 - クイックスタート (既定鍵での export → 別マシンでの import) - バンドル構造 (manifest.yml の sha256 検証、version 互換ポリシー) - age 暗号化: recipient / identity / passphrase の 3 方式、対応鍵種別表、 ssh-ecdsa 非対応への対処 (`age-keygen` / `ssh-keygen -t ed25519`)、 既定鍵 (`~/.ssh/id_ed25519` → `id_rsa`) - 入出力先: ローカル / stdio / S3。S3 の SSE 強制と `--unsafe-allow-unencrypted-bucket`、`DEVBASE_S3_*` 環境変数 - export / import の全オプション表、merge モード (keep-existing / prefer-incoming / --replace-keys / --replace) の動作比較 - `.env.sources.yml` の取り扱い (既定スキップ + 参照用コピー、 `--merge-metadata` での新規エントリ追加) - 2 フェーズ書き込み + backup + ロールバックの仕組み、 `--keep-last N` での GC、ACID 非保証の注意 - 典型ワークフロー 4 件 (別マシン移行 / 定期バックアップ / S3 チーム共有 / CI 配布) - トラブルシューティング 8 件 加えて以下のリンクを追加: - `README.md`: 「利用者向け」ドキュメント表に env-export-import.md への リンクを追加し、`env` グループの説明に export / import を併記 - `docs/user/environment-variables.md`: 「別マシンへの移行 / バックアップ」 節を新設して env-export-import.md へ誘導、ベストプラクティスに追記 - `CHANGELOG.md` (Unreleased): docs 追加とリファクタリングを記載 Co-Authored-By: Claude Opus 4.7 (1M context) * fix(env): warning ログの文面矛盾を解消 (unsafe フラグ続行時) `_verify_bucket_encryption` で `--unsafe-allow-unencrypted-bucket` 指定時に 「export を中止します。(unsafe フラグにより続行)」という矛盾した警告が出ていた。 問題説明 (problem) と対処案内 (guidance) を分離し、warning は problem のみ、 StorageError raise 時は問題+対処案内を出すよう統一。 Co-Authored-By: Claude Opus 4.7 (1M context) --------- Co-authored-by: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 6 + README.md | 3 +- docs/user/env-export-import.md | 456 ++++++++++++++++++++ docs/user/environment-variables.md | 13 + lib/devbase/env/_import_atomic.py | 208 +++++++++ lib/devbase/env/_import_merge.py | 319 ++++++++++++++ lib/devbase/env/io_common.py | 115 +++++ lib/devbase/env/io_export.py | 131 +++--- lib/devbase/env/io_import.py | 658 ++++------------------------- lib/devbase/env/storage.py | 60 +-- tests/cli/test_env_import.py | 12 +- 11 files changed, 1279 insertions(+), 702 deletions(-) create mode 100644 docs/user/env-export-import.md create mode 100644 lib/devbase/env/_import_atomic.py create mode 100644 lib/devbase/env/_import_merge.py create mode 100644 lib/devbase/env/io_common.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 23027d4..6e950a7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,9 +10,15 @@ - 暗号化が未設定のバケットへ 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 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/docs/user/env-export-import.md b/docs/user/env-export-import.md new file mode 100644 index 0000000..3b363b7 --- /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 - +``` + +> **制約**: `DEST='-'` / `SOURCE='-'` と `--passphrase-stdin` は **併用不可** (stdin/stdout が衝突するため明示的にエラーにします)。 + +## `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/lib/devbase/env/_import_atomic.py b/lib/devbase/env/_import_atomic.py new file mode 100644 index 0000000..9c788d5 --- /dev/null +++ b/lib/devbase/env/_import_atomic.py @@ -0,0 +1,208 @@ +"""``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 対象にする。 +# 以下のいずれかにマッチするディレクトリのみ削除する: +# YYYYMMDD-HHMMSS (旧フォーマット, 後方互換) +# YYYYMMDD-HHMMSS-NNNNNN (microsecond 付き) +# YYYYMMDD-HHMMSS-NNNNNN-NN (同一マイクロ秒内の連番付き) +# これ以外のディレクトリは devbase が作ったものではないので削除しない +# (--backup-dir 親に無関係なディレクトリがあっても安全)。 +_BACKUP_DIR_NAME_RE = re.compile(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 = datetime.now().strftime('%Y%m%d-%H%M%S-%f') # microsecond まで + 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 する。 + + 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..e39444a --- /dev/null +++ b/lib/devbase/env/_import_merge.py @@ -0,0 +1,319 @@ +"""``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.store import EnvEntry, EnvFile + +logger = get_logger(__name__) + +_PROJECT_ENV_RE = re.compile(r'^env/projects/([^/]+)/\.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 検証で拒否されているはずだが念のため。 + logger.debug("未対応の arcname を無視します: %s", arcname) + continue + 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 ロジック上発生しないが、安全側で対応)。 + + ``EnvFile.dump_bytes`` で再シリアライズするとコメント・空行が失われるため、 + ``EnvFile.parse_entries`` ベースで再構成している (PR #15 gemini 指摘)。 + """ + seen: set[str] = set() + out_entries: List[EnvEntry] = [] + for e in EnvFile.parse_entries(existing_bytes): + if e.kind != 'kv' or e.key is None: + out_entries.append(e) + continue + if e.key in merged: + out_entries.append(EnvEntry( + kind='kv', raw=e.raw, key=e.key, value=merged[e.key] + )) + seen.add(e.key) + # merged から除外されているキーは entries からも落とす + for key in sorted(k for k in merged if k not in seen): + out_entries.append(EnvEntry(kind='kv', raw='', key=key, value=merged[key])) + return EnvFile.dump_entries_bytes(out_entries) + + +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/io_common.py b/lib/devbase/env/io_common.py new file mode 100644 index 0000000..f18995a --- /dev/null +++ b/lib/devbase/env/io_common.py @@ -0,0 +1,115 @@ +"""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 からパスフレーズを読み取れませんでした") + return line.rstrip('\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`` の + 順で存在する秘密鍵を探し、最初に見つかったものを返す。 + """ + if specs: + return list(specs) + for path in _cipher.default_identity_paths(): + if path.exists(): + logger.info("identity 既定鍵を使用: %s", path) + return [str(path)] + return [] + + +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 index 08645a6..bed85b2 100644 --- a/lib/devbase/env/io_export.py +++ b/lib/devbase/env/io_export.py @@ -2,10 +2,8 @@ from __future__ import annotations -import getpass -import os +import getpass # noqa: F401 (tests monkey-patch devbase.env.io_export.getpass) import re -import sys from dataclasses import dataclass, field from datetime import datetime from pathlib import Path @@ -16,12 +14,14 @@ 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): @@ -50,48 +50,17 @@ def _default_dest(force_unencrypted: bool) -> str: return f'./devbase-env-{ts}{suffix}' -def _resolve_recipients(specs: Sequence[str]) -> List[str]: - """recipient 指定の解決。 - - 空なら既定鍵を優先順 (``~/.ssh/id_ed25519.pub`` → ``~/.ssh/id_rsa.pub``) で - 探索し、最初に見つかったものを利用する。 - """ - 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 _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 _read_passphrase(opts: ExportOptions) -> Optional[str]: - if opts.passphrase_env: - value = os.environ.get(opts.passphrase_env) - if not value: - raise ExportError( - f"環境変数 {opts.passphrase_env} が空または未設定です" - ) - return value - if opts.passphrase_stdin: - # tty で対話実行している場合は getpass.getpass でエコー抑止 - # (パイプ入力時は echo の概念がないので従来どおり stdin.readline で読む)。 - if sys.stdin.isatty(): - try: - return getpass.getpass("passphrase: ", stream=sys.stderr) - except EOFError as e: - raise ExportError("stdin からパスフレーズを読み取れませんでした") from e - line = sys.stdin.readline() - if not line: - raise ExportError("stdin からパスフレーズを読み取れませんでした") - return line.rstrip('\n') - return None - - -def _has_sensitive_keys(entries) -> List[str]: - """env 形式のテキストから機密キーを抽出する (平文出力時の警告用)""" - hits = set() - key_re = re.compile(r'^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=', re.MULTILINE) +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 @@ -99,16 +68,13 @@ def _has_sensitive_keys(entries) -> List[str]: text = entry.data.decode('utf-8', errors='ignore') except Exception: continue - for key in key_re.findall(text): - upper = key.upper() - if any(p in upper for p in _SENSITIVE_PATTERNS): + 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 export(devbase_root: Path, opts: ExportOptions) -> int: - """export 本体。CLI ハンドラから呼ばれる""" - # 引数組み合わせの早期検証 +def _validate_options(opts: ExportOptions) -> None: if opts.passphrase_stdin and opts.dest == '-': raise ExportError( "DEST='-' (stdout) と --passphrase-stdin は併用できません " @@ -117,6 +83,43 @@ def export(devbase_root: Path, opts: ExportOptions) -> int: if opts.passphrase_env and opts.passphrase_stdin: raise ExportError("--passphrase-env と --passphrase-stdin は併用できません") + +def _encrypt_payload(tar_blob: bytes, opts: ExportOptions) -> bytes: + """``opts`` の鍵指定に従って tar.gz を暗号化する。鍵が無ければ既定鍵を試す""" + passphrase = _read_passphrase(opts) + 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, @@ -143,38 +146,18 @@ def export(devbase_root: Path, opts: ExportOptions) -> int: raise ExportError( "--force-unencrypted は recipient / passphrase と併用できません" ) - sensitive = _has_sensitive_keys(entries) - if sensitive: - logger.warning( - "平文 export に機密キーが含まれます: %s", - ', '.join(sensitive[:10]) + (' ...' if len(sensitive) > 10 else '') - ) - logger.warning( - "ファイルパーミッションは 0600 で書き出されますが、保管・転送時の暗号化を強く推奨します" - ) + _warn_if_plaintext_sensitive(entries) payload = tar_blob else: - passphrase = _read_passphrase(opts) - recipients = _resolve_recipients(opts.recipients) if passphrase is None else [] - 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 優先)" - ) - payload = _cipher.encrypt(tar_blob, recipients=recipients, passphrase=passphrase) + payload = _encrypt_payload(tar_blob, opts) logger.debug("暗号化後サイズ: %d bytes", len(payload)) dest = opts.dest or _default_dest(opts.force_unencrypted) # S3 など backend 固有のオプションを渡したい場合は s3_options を組み立てる。 # それ以外 (local/stdio) では未使用なので無害。 - s3_options = _storage.S3Options.from_env( + s3_options = (_storage.S3Options.from_env( unsafe_allow_unencrypted_bucket=opts.unsafe_allow_unencrypted_bucket, - ) if _storage.is_s3(dest) else None + ) if _storage.is_s3(dest) else None) backend = _storage.resolve(dest, s3_options=s3_options) backend.write_bytes(dest, payload) diff --git a/lib/devbase/env/io_import.py b/lib/devbase/env/io_import.py index 5f025b1..6107e1f 100644 --- a/lib/devbase/env/io_import.py +++ b/lib/devbase/env/io_import.py @@ -1,56 +1,43 @@ """devbase env import の高レベル実装 責務: - - SOURCE (file / stdio) の読み込み + - SOURCE (file / stdio / s3) の読み込み - age 復号 (バンドルが暗号化されていれば) - tar.gz バンドルの展開と sha256 / manifest version の検証 (bundle.unpack) - - --merge / --replace-keys / --replace のセマンティクスで .env 群を更新 + - 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 -import os -import re -import shutil -import sys +import getpass # noqa: F401 (tests monkey-patch devbase.env.io_import.getpass) from dataclasses import dataclass, field -from datetime import datetime from pathlib import Path -from typing import Dict, List, Optional, Sequence, Tuple - -import yaml +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 -from devbase.env.store import EnvEntry, EnvFile logger = get_logger(__name__) -# gzip magic. tar.gz バンドルは先頭 2 byte が 0x1f 0x8b。age 暗号化済みは -# テキストヘッダ "age-encryption.org/v1\n" で始まるため magic で識別できる。 +# 暗号化済みは age テキストヘッダ "age-encryption.org/v1\n" で始まるのに対し、 +# 平文 tar.gz は先頭 2 byte が gzip magic (0x1f 0x8b) となる。これで判別する。 _GZIP_MAGIC = b'\x1f\x8b' -_MERGE_MODES = ('keep-existing', 'prefer-incoming') - -# _make_backup_dir が生成するタイムスタンプ形式のみを GC 対象にする。 -# 以下のいずれかにマッチするディレクトリのみ削除する: -# YYYYMMDD-HHMMSS (旧フォーマット, 後方互換) -# YYYYMMDD-HHMMSS-NNNNNN (microsecond 付き) -# YYYYMMDD-HHMMSS-NNNNNN-NN (同一マイクロ秒内の連番付き) -# これ以外のディレクトリは devbase が作ったものではないので削除しない -# (--backup-dir 親に無関係なディレクトリがあっても安全)。 -_BACKUP_DIR_NAME_RE = re.compile(r'^\d{8}-\d{6}(?:-\d{6}(?:-\d+)?)?$') - class ImportError(DevbaseError): """import エラー""" @@ -75,53 +62,34 @@ class ImportOptions: keep_last: int = 10 -@dataclass -class _Plan: - """1 ファイル分の書き出し計画""" - target: Path - arcname: str - new_bytes: bytes - # 差分サマリ (dry-run / ログ用) - 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 _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 _read_passphrase(opts: ImportOptions) -> Optional[str]: - if opts.passphrase_env: - value = os.environ.get(opts.passphrase_env) - if not value: - raise ImportError(f"環境変数 {opts.passphrase_env} が空または未設定です") - return value - if opts.passphrase_stdin: - if sys.stdin.isatty(): - try: - return getpass.getpass("passphrase: ", stream=sys.stderr) - except EOFError as e: - raise ImportError("stdin からパスフレーズを読み取れませんでした") from e - line = sys.stdin.readline() - if not line: - raise ImportError("stdin からパスフレーズを読み取れませんでした") - return line.rstrip('\n') - return None - - -def _resolve_identities(specs: Sequence[str]) -> List[str]: - if specs: - return list(specs) - for path in _cipher.default_identity_paths(): - if path.exists(): - logger.info("identity 既定鍵を使用: %s", path) - return [str(path)] - return [] +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 は併用できません") def _decrypt_if_needed(blob: bytes, opts: ImportOptions) -> bytes: """先頭バイトで暗号化済みかを判定して必要なら復号する""" if blob[:2] == _GZIP_MAGIC: - # 平文 tar.gz。鍵指定があっても無視せず警告にとどめる if opts.identities or opts.passphrase_env or opts.passphrase_stdin: logger.warning( "バンドルは平文ですが identity / passphrase が指定されています " @@ -133,7 +101,7 @@ def _decrypt_if_needed(blob: bytes, opts: ImportOptions) -> bytes: if passphrase is not None: return _cipher.decrypt(blob, passphrase=passphrase) - identities = _resolve_identities(opts.identities) + identities = _io_common.resolve_identity_specs(opts.identities) if not identities: raise ImportError( "バンドルは暗号化されていますが復号キーが指定されていません。\n" @@ -146,533 +114,64 @@ def _decrypt_if_needed(blob: bytes, opts: ImportOptions) -> bytes: return _cipher.decrypt(blob, identities=identities) -def _filter_members(members: Dict[str, bytes], - opts: ImportOptions) -> Dict[str, bytes]: - """include/exclude 指定で展開済みメンバーを絞り込む""" - included = set(opts.include_projects) if opts.include_projects else None - excluded = set(opts.exclude_projects) - result: Dict[str, bytes] = {} - - proj_re = re.compile(r'^env/projects/([^/]+)/\.env$') - - for arcname, data in members.items(): - if arcname == 'env/global.env': - if not opts.include_global: - continue - result[arcname] = data - continue - if arcname == 'env/sources.yml': - if not opts.include_metadata: - continue - result[arcname] = data - continue - m = proj_re.match(arcname) - if m: - name = m.group(1) - if name in excluded: - continue - if included is not None and name not in included: - continue - result[arcname] = data - continue - # 他の形式は manifest 検証で拒否されているはずだが念のため - logger.debug("未対応の arcname を無視します: %s", arcname) - return result - - -def _target_for(arcname: str, devbase_root: Path) -> Path: - if arcname == 'env/global.env': - return devbase_root / '.env' - if arcname == 'env/sources.yml': - return devbase_root / '.env.sources.yml' - m = re.match(r'^env/projects/([^/]+)/\.env$', arcname) - if m: - return devbase_root / 'projects' / m.group(1) / '.env' - raise ImportError(f"未対応のバンドルエントリ: {arcname}") - - -def _merge_into_existing_bytes(existing_bytes: bytes, - merged: Dict[str, str]) -> bytes: - """既存 ``.env`` のコメント / 空行 / キー順を保持したまま、 - ``merged`` の内容で値を上書きしてバイト列化する。 - - ``merged`` のうち既存ファイルに無いキーは末尾に追加する。 - 既存ファイルにあって ``merged`` から削除されているキーは、entries からも除外して - 出力する (現状の merge ロジック上、削除されるケースは無いが安全側で対応)。 - - PR #15 gemini 指摘: ``EnvFile.dump_bytes`` で再シリアライズすると ``key=value`` - だけの出力になりコメント・空行が失われる。これを避けるため entries ベースで - 再構成する。 - """ - entries = EnvFile.parse_entries(existing_bytes) - seen: set = set() - out_entries: List[EnvEntry] = [] - for e in entries: - if e.kind == 'kv' and e.key is not None: - if e.key in merged: - # 値を merge 後のものに差し替え (key 順 / コメントは保持) - out_entries.append(EnvEntry( - kind='kv', raw=e.raw, key=e.key, value=merged[e.key] - )) - seen.add(e.key) - # merged から除外されているキーは entries からも落とす - else: - out_entries.append(e) - # 既存に無かった新規キーは末尾に append (定常的な key 順を維持するため sorted) - for key in sorted(k for k in merged if k not in seen): - out_entries.append(EnvEntry( - kind='kv', raw='', key=key, value=merged[key] - )) - return EnvFile.dump_entries_bytes(out_entries) - - -def _build_merge_plan( - target: Path, - arcname: str, - incoming_bytes: bytes, - existing_bytes: bytes, - target_exists: bool, - merged: Dict[str, str], - added: List[str], - overwritten: List[str], - skipped: List[str], -) -> _Plan: - """merge 系 (replace_keys / keep-existing / prefer-incoming) 共通の _Plan 生成。 - - - 新規作成時 (``target_exists`` が False) は ``incoming_bytes`` をそのまま採用して - 二重エスケープを回避 (PR #15 codex 指摘) - - 既存ファイルへの merge 時は ``_merge_into_existing_bytes`` でコメント / 空行 / - キー順を保持したまま値を差し替える (PR #15 gemini 指摘)。 - ``existing`` (key=value dict) の空判定で create/merge を決めると、コメント / - 空行のみで構成された既存 .env が ``incoming_bytes`` で上書きされてコメントが - 失われるため、ファイル実体の有無で判定する (PR #15 round5 指摘)。 - """ - 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_env_merge(target: Path, incoming_bytes: bytes, - opts: ImportOptions, arcname: str) -> _Plan: - """1 つの .env に対する merge / replace 計画を作る - - 既存ファイルが無い (= create) ケースでは、バンドル側の ``incoming_bytes`` を - そのまま採用する。``EnvFile.dump_bytes`` で再シリアライズすると、export 側で - 既に escape された値を parse_bytes 経由でも完全に round-trip できる前提が - 崩れた瞬間に二重エスケープが発生するためである (PR #15 codex 指摘)。 - - 既存ファイルが存在する merge 経路では ``_merge_into_existing_bytes`` で - 既存のコメント / 空行 / キー順を保持したまま値だけ差し替える - (PR #15 gemini 指摘)。 - - 各分岐で重複していた ``new_bytes`` 生成と ``_Plan`` 構築は - ``_build_merge_plan`` に括り出している (PR #15 gemini round4 指摘)。 - """ - incoming = EnvFile.parse_bytes(incoming_bytes) - existing: Dict[str, str] = {} - existing_bytes: bytes = b'' - target_exists = target.exists() - if target_exists: - existing_bytes = target.read_bytes() - existing = EnvFile.parse_bytes(existing_bytes) - - if opts.replace: - added = sorted(set(incoming) - set(existing)) - overwritten = sorted(k for k in incoming if k in existing and incoming[k] != existing[k]) - # replace は バンドル側の値で完全に置き換える (merge 経路と別系統) - # op 判定は ``existing`` (key=value dict) ではなくファイル実体の有無で行う: - # コメントのみの既存 .env を 'create' と誤判定しないため (PR #15 round5 指摘)。 - return _Plan( - target=target, - arcname=arcname, - new_bytes=incoming_bytes, - added_keys=added, - overwritten_keys=overwritten, - skipped_keys=[], - op='replace' if target_exists else 'create', - ) - - merged: Dict[str, str] = dict(existing) - added: List[str] = [] - overwritten: List[str] = [] - skipped: List[str] = [] - - if opts.replace_keys: - replace_set = set(opts.replace_keys) - for key, value in incoming.items(): - if key in replace_set: - if key in existing: - if existing[key] != value: - overwritten.append(key) - merged[key] = value - else: - added.append(key) - merged[key] = value - else: - # --replace-keys 指定外のキーは keep-existing 相当: - # 既存にあれば残し、無ければ新規追加 (skipped は overwrite を - # 抑止した = 上書きしなかったキーのみ)。 - if key in existing: - if existing[key] != value: - skipped.append(key) +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: - added.append(key) - merged[key] = value - elif opts.merge == 'keep-existing': - for key, value in incoming.items(): - if key in existing: - skipped.append(key) + sources_reference = (target, data) else: - merged[key] = value - added.append(key) - elif opts.merge == 'prefer-incoming': - for key, value in incoming.items(): - if key in existing: - if existing[key] != value: - overwritten.append(key) - merged[key] = value - else: - merged[key] = value - added.append(key) - else: - raise ImportError(f"不明な --merge モード: {opts.merge!r}") - - return _build_merge_plan( - target=target, - arcname=arcname, - incoming_bytes=incoming_bytes, - existing_bytes=existing_bytes, - target_exists=target_exists, - merged=merged, - added=added, - overwritten=overwritten, - skipped=skipped, - ) - - -def _plan_sources(target: Path, incoming_bytes: bytes, - opts: ImportOptions) -> Optional[_Plan]: - """.env.sources.yml の取り扱い計画 - - 既定: 上書きせず None を返す (backup_dir に参照用コピーのみ書く)。 - --merge-metadata: 新規 source エントリのみ追加した内容で更新する。 - """ - if not opts.merge_metadata: - # 上書きしないので _Plan は返さない。参照用 copy は run() 側で処理。 - return None - - try: - incoming = yaml.safe_load(incoming_bytes) or {} - except yaml.YAMLError as e: - raise ImportError(f"バンドルの sources.yml が壊れています: {e}") from e - if not isinstance(incoming, dict): - raise ImportError("バンドルの sources.yml が dict ではありません") - incoming_sources = incoming.get('sources') or {} - if not isinstance(incoming_sources, dict): - raise ImportError("バンドルの sources.yml の sources が dict ではありません") - - existing: Dict = {} - if target.exists(): - try: - existing = yaml.safe_load(target.read_bytes()) or {} - except yaml.YAMLError as e: - raise ImportError( - f"既存の {target.name} のパースに失敗しました: {e}" - ) from e - if not isinstance(existing, dict): - existing = {} - existing.setdefault('sources', {}) - if not isinstance(existing['sources'], dict): - existing['sources'] = {} - - added: List[str] = [] - merged_sources = dict(existing['sources']) - 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), - overwritten_keys=[], - skipped_keys=[], - op='sources-merge', - ) - - -def _make_backup_dir(devbase_root: Path, opts: ImportOptions) -> Path: - """バックアップディレクトリを作成する。 - - 秒精度のみだと同一秒に 2 回 import を走らせたときに同じディレクトリを再利用して - 前回バックアップを上書きしてしまうため、microsecond + 連番を付与して衝突を回避する - (PR #15 codex 指摘)。 - """ - if opts.backup_dir: - base = Path(opts.backup_dir).expanduser() - else: - base = devbase_root / 'backups' / 'env-import' - base.mkdir(parents=True, exist_ok=True) - - now = datetime.now() - stem = now.strftime('%Y%m%d-%H%M%S-%f') # microsecond まで - path = base / stem - if not path.exists(): - path.mkdir(parents=True) - return path - # 同一マイクロ秒に複数回走った場合の安全弁: 連番を付与 - for n in range(1, 1000): - candidate = base / f'{stem}-{n:02d}' - if not candidate.exists(): - candidate.mkdir(parents=True) - return candidate - raise ImportError( - f"backup ディレクトリの衝突回避に失敗しました (base={base}, stem={stem})" - ) - - -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 - try: - relative = plan.target.relative_to(devbase_root) - except ValueError: - relative = Path(plan.target.name) - dst = backup_dir / relative - dst.parent.mkdir(parents=True, exist_ok=True) - shutil.copy2(plan.target, dst) - - # バンドルに含まれていた sources.yml の参照用コピー (上書きしないケース) - if sources_copy is not None: - target, data = sources_copy - dst = backup_dir / 'sources.yml.imported' - dst.parent.mkdir(parents=True, exist_ok=True) - dst.write_bytes(data) - try: - os.chmod(dst, 0o600) - except OSError: - pass - - -def _write_atomic(plan: _Plan) -> Path: - """phase 1: 新内容を .import.tmp として書き出す (0600)""" - tmp = plan.target.with_suffix(plan.target.suffix + '.import.tmp') - tmp.parent.mkdir(parents=True, exist_ok=True) - if tmp.exists(): - # 過去の失敗の残骸を掃除 - try: - tmp.unlink() - except OSError: - pass - flags = os.O_WRONLY | os.O_CREAT | os.O_TRUNC - fd = os.open(tmp, flags, 0o600) - try: - with os.fdopen(fd, 'wb') as f: - f.write(plan.new_bytes) - except BaseException: - try: - os.close(fd) - except OSError: - pass - raise - try: - os.chmod(tmp, 0o600) - except OSError: - pass - 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_tmps = [tmp for _, tmp in plans_and_tmps] - try: - for idx, (plan, tmp) in enumerate(plans_and_tmps): - os.replace(tmp, plan.target) - try: - os.chmod(plan.target, 0o600) - except OSError: - pass - committed.append((plan, plan.target)) - # rename 済みの tmp は残らないが、リストから除外して後続 cleanup を簡潔に - remaining_tmps[idx] = None # type: ignore[call-overload] - except OSError as e: - logger.error("commit フェーズで失敗しました: %s", e) - try: - _rollback(committed, backup_dir, devbase_root) - finally: - # rename 前で残っている .import.tmp を後始末 - _cleanup_tmps([t for t in remaining_tmps if t is not None]) - raise ImportError(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 plan, target in committed: - try: - relative = target.relative_to(devbase_root) - except ValueError: - relative = Path(target.name) - src = backup_dir / relative - 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) - else: - # 元ファイル不在 → 新規作成された 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: Sequence[Path]) -> None: - 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 する。 - - 安全性のため、削除対象は devbase が生成するタイムスタンプ形式 - (YYYYMMDD-HHMMSS) のディレクトリに限定する。--backup-dir で指定された - 親ディレクトリに無関係なファイル/ディレクトリがあっても、それらは触らない。 - """ - if keep_last <= 0: - return - parent = backup_dir.parent - if not parent.is_dir(): - return - siblings = sorted( - (p for p in parent.iterdir() - if p.is_dir() and _BACKUP_DIR_NAME_RE.match(p.name)), - key=lambda p: p.name, - ) - if len(siblings) <= keep_last: - return - to_remove = siblings[:-keep_last] - for d in to_remove: - try: - shutil.rmtree(d) - logger.info("古い backup を削除しました: %s", d) - except OSError as e: - logger.warning("backup 削除に失敗 (%s): %s", d, e) - - -def _log_plans(plans: Sequence[_Plan], dry_run: bool) -> None: - 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)) + 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 ハンドラから呼ばれる""" - # 引数の早期検証 - if opts.merge not in _MERGE_MODES: - raise ImportError( - f"--merge の値が不正です: {opts.merge!r} (許可: {', '.join(_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 は併用できません") + _validate_options(opts) - # SOURCE 読み込み backend = _storage.resolve(opts.source) blob = backend.read_bytes(opts.source) logger.debug("読み込みサイズ: %d bytes", len(blob)) - # 復号 (必要なら) + 展開 + manifest 検証 (sha256 / version) 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 = _filter_members(members, opts) + 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: List[_Plan] = [] - sources_reference: Optional[Tuple[Path, bytes]] = None - for arcname, data in sorted(filtered.items()): - target = _target_for(arcname, devbase_root) - if arcname == 'env/sources.yml': - plan = _plan_sources(target, data, opts) - if plan is not None: - plans.append(plan) - else: - # 既定動作: 上書きしないので参照用 copy のみバックアップする - sources_reference = (target, data) - else: - plans.append(_plan_env_merge(target, data, opts, arcname)) + plans, sources_reference = _build_plans(filtered, devbase_root, opts) - _log_plans(plans, opts.dry_run) + _merge.log_plans(plans, opts.dry_run) if sources_reference is not None and not opts.merge_metadata: logger.info( "%ssources.yml は上書きしません (--merge-metadata 指定時のみ更新, " @@ -683,29 +182,28 @@ def import_bundle(devbase_root: Path, opts: ImportOptions) -> int: if opts.dry_run: logger.info("[dry-run] 書き込みは行いません") return 0 - if not plans and sources_reference is None: logger.info("変更はありません") return 0 - # backup → phase 1 (tmp 書き出し) → phase 2 (rename) - backup_dir = _make_backup_dir(devbase_root, opts) + backup_dir = _atomic.make_backup_dir(devbase_root, opts.backup_dir) logger.info("backup ディレクトリ: %s", backup_dir) - _backup_existing(plans, sources_reference, backup_dir, devbase_root) + _atomic.backup_existing(plans, sources_reference, backup_dir, devbase_root) - tmps: List[Path] = [] - plans_and_tmps: List[Tuple[_Plan, Path]] = [] + plans_and_tmps: List[Tuple[_merge.Plan, Path]] = [] try: for plan in plans: - tmp = _write_atomic(plan) - tmps.append(tmp) + tmp = _atomic.write_atomic(plan) plans_and_tmps.append((plan, tmp)) except Exception: - _cleanup_tmps(tmps) + _atomic.cleanup_tmps(tmp for _, tmp in plans_and_tmps) raise - _commit(plans_and_tmps, backup_dir, devbase_root) + 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)) - _gc_backups(backup_dir, opts.keep_last) + _atomic.gc_backups(backup_dir, opts.keep_last) return 0 diff --git a/lib/devbase/env/storage.py b/lib/devbase/env/storage.py index adaa744..ffb36c5 100644 --- a/lib/devbase/env/storage.py +++ b/lib/devbase/env/storage.py @@ -45,39 +45,13 @@ 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: - if path.parent and not path.parent.exists(): - path.parent.mkdir(parents=True, exist_ok=True) - # TOCTOU 回避: open(..., 'wb') 後に chmod すると、umask が緩い環境では - # 一瞬 0644 等で平文 export が露出する。 - # os.open に mode=0o600 を渡し、O_CREAT|O_TRUNC|O_WRONLY で作成時点 - # から 0600 を強制する。既存ファイルも書き込み前に chmod で権限を絞る。 - if path.exists(): - try: - os.chmod(path, 0o600) - except OSError: - # Windows 等で chmod が無効でも処理を続行 - pass - flags = os.O_WRONLY | os.O_CREAT | os.O_TRUNC - fd = os.open(path, flags, 0o600) - try: - with os.fdopen(fd, 'wb') as f: - f.write(data) - except BaseException: - # fdopen 失敗時は fd を明示的に閉じる (fdopen 成功時は with が close) - try: - os.close(fd) - except OSError: - pass - raise - # mode 引数が無視される環境 (Windows 等) でも後追いで chmod を試みる - try: - os.chmod(path, 0o600) - except OSError: - pass - except StorageError: - raise + _io_common.write_secure_bytes(path, data) except OSError as e: raise StorageError(f"書き込みに失敗しました ({path}): {e}") from e @@ -223,38 +197,38 @@ def _verify_bucket_encryption(self, client, bucket: str) -> None: except Exception as e: code = self._error_code(e) if code == 'ServerSideEncryptionConfigurationNotFoundError': - msg = ( - f"S3 バケット '{bucket}' のデフォルト暗号化が未設定です。" + 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 フラグにより続行)", msg) + logger.warning("%s (unsafe フラグにより続行)", problem) return - raise StorageError(msg) from e + raise StorageError(f"{problem}{guidance}") from e if code in ('AccessDenied', 'AccessDeniedException'): - msg = ( + problem = ( f"S3 バケット '{bucket}' の暗号化設定を確認できません " "(GetBucketEncryption 権限がありません)。" + ) + guidance = ( "バケットポリシーの確認が取れないため export を中止します。" "権限を付与するか、'--unsafe-allow-unencrypted-bucket' を明示してください" ) if self._options.unsafe_allow_unencrypted_bucket: - logger.warning("%s (unsafe フラグにより続行)", msg) + logger.warning("%s (unsafe フラグにより続行)", problem) return - raise StorageError(msg) from e + raise StorageError(f"{problem}{guidance}") from e # MinIO / LocalStack 等の S3 互換ストレージでは # GetBucketEncryption が NotImplemented / MethodNotAllowed / 501 等を返す # ことがある。`--unsafe-allow-unencrypted-bucket` 指定時は逃げ道として # 警告のみで続行する (オブジェクト個別の SSE は引き続き付与される)。 - msg = ( - f"バケット暗号化設定の確認に失敗しました ({bucket}): {e}" - ) + problem = f"バケット暗号化設定の確認に失敗しました ({bucket}): {e}" if self._options.unsafe_allow_unencrypted_bucket: - logger.warning("%s (unsafe フラグにより続行)", msg) + logger.warning("%s (unsafe フラグにより続行)", problem) return - raise StorageError(msg) from e + raise StorageError(problem) from e def write_bytes(self, dest: str, data: bytes) -> None: bucket, key = _parse_s3_uri(dest) diff --git a/tests/cli/test_env_import.py b/tests/cli/test_env_import.py index f509895..6f063b8 100644 --- a/tests/cli/test_env_import.py +++ b/tests/cli/test_env_import.py @@ -417,6 +417,7 @@ 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 @@ -436,7 +437,7 @@ def failing_replace(src, dst): raise OSError("simulated commit failure") return original_replace(src, dst) - monkeypatch.setattr(_io_import.os, 'replace', failing_replace) + monkeypatch.setattr(_atomic.os, 'replace', failing_replace) with pytest.raises(_io_import.ImportError, match="commit フェーズで失敗"): import_bundle(dest_root, ImportOptions( @@ -578,6 +579,7 @@ 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 @@ -594,7 +596,7 @@ def failing_replace(src, dst): raise OSError("simulated commit failure on sources.yml") return original_replace(src, dst) - monkeypatch.setattr(_io_import.os, 'replace', failing_replace) + monkeypatch.setattr(_atomic.os, 'replace', failing_replace) with pytest.raises(_io_import.ImportError, match="commit"): import_bundle(dest_root, ImportOptions( @@ -609,6 +611,7 @@ 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 @@ -623,7 +626,7 @@ def failing_replace(src, dst): raise OSError("simulated commit failure") return original_replace(src, dst) - monkeypatch.setattr(_io_import.os, 'replace', failing_replace) + monkeypatch.setattr(_atomic.os, 'replace', failing_replace) with pytest.raises(_io_import.ImportError, match="commit"): import_bundle(dest_root, ImportOptions( @@ -921,7 +924,8 @@ def test_env_import_comment_only_existing_replace_reports_op_replace( _setup_comment_only_dest(dest_root) - with caplog.at_level(logging.INFO, logger="devbase.env.io_import"): + # 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)) From c4c4bca54da0a1fb038a324f02456cec87d29ff7 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sun, 24 May 2026 02:14:19 +0900 Subject: [PATCH 06/16] =?UTF-8?q?fix(env):=20PR=20#13=20round1=20codex/gem?= =?UTF-8?q?ini=20=E6=8C=87=E6=91=98=E5=AF=BE=E5=BF=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - _import_merge: 未対応 arcname を黙って捨てず MergeError で停止 (filter_members の logger.debug+continue → raise MergeError) - _import_merge: merge 経路で値が変更されていないキーは raw 行を温存し、 PATH=$HOME/bin のような未クオート値が PATH="\$HOME/bin" に勝手に エスケープされて source 時の意味が変わるのを防ぐ - cipher: age-keygen 出力の先頭コメント (# created / # public key) を 考慮し、行単位でコメント / 空行を除いて AGE-SECRET-KEY-1 行を検出 - 上記 3 件に対する回帰テストを追加 Co-Authored-By: Claude Opus 4.7 (1M context) --- lib/devbase/env/_import_merge.py | 37 +++++++++++++------ lib/devbase/env/cipher.py | 33 ++++++++++++----- tests/cli/test_env_import.py | 63 ++++++++++++++++++++++++++++++++ tests/env/test_cipher.py | 20 ++++++++++ 4 files changed, 133 insertions(+), 20 deletions(-) diff --git a/lib/devbase/env/_import_merge.py b/lib/devbase/env/_import_merge.py index e39444a..8d1ad89 100644 --- a/lib/devbase/env/_import_merge.py +++ b/lib/devbase/env/_import_merge.py @@ -19,7 +19,7 @@ from devbase.errors import DevbaseError from devbase.log import get_logger -from devbase.env.store import EnvEntry, EnvFile +from devbase.env.store import EnvFile logger = get_logger(__name__) @@ -85,9 +85,13 @@ def filter_members( continue m = _PROJECT_ENV_RE.match(arcname) if not m: - # 他の形式は manifest 検証で拒否されているはずだが念のため。 - logger.debug("未対応の arcname を無視します: %s", arcname) - continue + # 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 @@ -104,24 +108,35 @@ def _merge_into_existing_bytes(existing_bytes: bytes, 既存に無いキーは末尾に 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_entries: List[EnvEntry] = [] + out_lines: List[str] = [] for e in EnvFile.parse_entries(existing_bytes): if e.kind != 'kv' or e.key is None: - out_entries.append(e) + out_lines.append(e.raw + '\n') continue if e.key in merged: - out_entries.append(EnvEntry( - kind='kv', raw=e.raw, key=e.key, value=merged[e.key] - )) 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_entries.append(EnvEntry(kind='kv', raw='', key=key, value=merged[key])) - return EnvFile.dump_entries_bytes(out_entries) + 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], diff --git a/lib/devbase/env/cipher.py b/lib/devbase/env/cipher.py index e0d8540..faa4dd4 100644 --- a/lib/devbase/env/cipher.py +++ b/lib/devbase/env/cipher.py @@ -105,15 +105,30 @@ def _resolve_identity(path_spec: str): f"OpenSSH 秘密鍵の解釈に失敗しました ({path}): {e}" ) from e - if raw.strip().startswith(b'AGE-SECRET-KEY-1'): - try: - text = raw.decode('utf-8').strip() - except UnicodeDecodeError as e: - raise CipherError(f"age 秘密鍵が UTF-8 でデコードできません ({path}): {e}") from e - try: - return pyrage.x25519.Identity.from_str(text) - except Exception as e: - raise CipherError(f"age 秘密鍵の解釈に失敗しました ({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 に任せて受け付ける。 diff --git a/tests/cli/test_env_import.py b/tests/cli/test_env_import.py index 6f063b8..bf27b60 100644 --- a/tests/cli/test_env_import.py +++ b/tests/cli/test_env_import.py @@ -943,3 +943,66 @@ def test_env_import_comment_only_existing_replace_reports_op_replace( 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=(), + ) diff --git a/tests/env/test_cipher.py b/tests/env/test_cipher.py index 2027a02..ebaabee 100644 --- a/tests/env/test_cipher.py +++ b/tests/env/test_cipher.py @@ -176,3 +176,23 @@ def test_default_identity_paths_includes_ed25519(): 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" From 8f8f5c3eda0fcfb4d0ed9596a1f21c7501b8cae8 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sun, 24 May 2026 02:23:38 +0900 Subject: [PATCH 07/16] =?UTF-8?q?fix(env):=20PR=20#13=20round2=20gemini=20?= =?UTF-8?q?=E6=8C=87=E6=91=98=E5=AF=BE=E5=BF=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - io_export._validate_options: DEST='-' (stdout) と --passphrase-stdin の 排他チェックを削除。stdin (passphrase) と stdout (bundle) は別ストリーム のため衝突しない (例: `echo pass | devbase env export - --passphrase-stdin > out`) - io_common.read_passphrase: stdin から読んだ行末を rstrip('\n') から rstrip('\r\n') に変更。Windows/WSL 由来 CRLF パイプで末尾 \r が残ると age 復号が無音で失敗するため。 - tests: 排他テストを併用許可テストに置換 + CRLF rstrip 回帰テスト追加 Refs: https://github.com/devbasex/devbase/pull/13#pullrequestreview-4351189608 --- lib/devbase/env/io_common.py | 4 +++- lib/devbase/env/io_export.py | 9 ++++----- tests/cli/test_env_export.py | 38 ++++++++++++++++++++++++++++++++---- 3 files changed, 41 insertions(+), 10 deletions(-) diff --git a/lib/devbase/env/io_common.py b/lib/devbase/env/io_common.py index f18995a..a46b894 100644 --- a/lib/devbase/env/io_common.py +++ b/lib/devbase/env/io_common.py @@ -46,7 +46,9 @@ def read_passphrase( line = sys.stdin.readline() if not line: raise error_class("stdin からパスフレーズを読み取れませんでした") - return line.rstrip('\n') + # CRLF (Windows/WSL からのパイプ) を考慮して \r も剥がす。 + # パスフレーズ末尾に \r が残ると複合化が一致せず原因不明の失敗になる。 + return line.rstrip('\r\n') return None diff --git a/lib/devbase/env/io_export.py b/lib/devbase/env/io_export.py index bed85b2..976e834 100644 --- a/lib/devbase/env/io_export.py +++ b/lib/devbase/env/io_export.py @@ -75,11 +75,10 @@ def _sensitive_keys(entries: Sequence[_bundle.BundleEntry]) -> List[str]: def _validate_options(opts: ExportOptions) -> None: - if opts.passphrase_stdin and opts.dest == '-': - raise ExportError( - "DEST='-' (stdout) と --passphrase-stdin は併用できません " - "(stdin/stdout が衝突します)" - ) + # 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 は併用できません") diff --git a/tests/cli/test_env_export.py b/tests/cli/test_env_export.py index dd44b47..000e959 100644 --- a/tests/cli/test_env_export.py +++ b/tests/cli/test_env_export.py @@ -3,7 +3,6 @@ from __future__ import annotations import io -import os from pathlib import Path import pyrage @@ -82,9 +81,27 @@ def test_export_force_unencrypted_writes_plaintext_tar_gz(fake_root, tmp_path, c assert dest.stat().st_mode & 0o777 == 0o600 -def test_export_rejects_stdout_with_passphrase_stdin(fake_root): - with pytest.raises(ExportError, match="DEST='-'"): - export(fake_root, ExportOptions(dest="-", passphrase_stdin=True)) +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): @@ -130,6 +147,19 @@ def fail_getpass(*args, **kwargs): 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("") From cf002090ee48dd8b07f9143f4fb88b9ed1abc260 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sun, 24 May 2026 02:33:31 +0900 Subject: [PATCH 08/16] =?UTF-8?q?fix(env):=20PR=20#13=20round3=20codex=20?= =?UTF-8?q?=E6=8C=87=E6=91=98=E5=AF=BE=E5=BF=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - _import_merge.py の project 名正規表現を厳格化し、`./..`/隠しディレクトリを 弾く (path traversal: `env/projects/./.env` が `$DEVBASE_ROOT/projects/.env` に解決される問題への対策)。 - 上記の回帰テストを `tests/cli/test_env_import.py` に追加。 - zsh/bash 補完に `--unsafe-allow-unencrypted-bucket` (env export) を追加し CLI 定義と同期。 --- etc/_devbase | 3 +- etc/devbase-completion.bash | 2 +- lib/devbase/env/_import_merge.py | 10 ++++++- tests/cli/test_env_import.py | 48 ++++++++++++++++++++++++++++++++ 4 files changed, 60 insertions(+), 3 deletions(-) diff --git a/etc/_devbase b/etc/_devbase index b7944dd..cb3a5cb 100644 --- a/etc/_devbase +++ b/etc/_devbase @@ -162,7 +162,8 @@ _devbase() { '*--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]' + '--force-unencrypted[Write as plaintext tar.gz]' \ + '--unsafe-allow-unencrypted-bucket[Allow uploading unencrypted tar.gz to S3 (off by default)]' ;; import) _arguments \ diff --git a/etc/devbase-completion.bash b/etc/devbase-completion.bash index 81f6431..fdba9df 100644 --- a/etc/devbase-completion.bash +++ b/etc/devbase-completion.bash @@ -83,7 +83,7 @@ _devbase_completions() { ;; export) if [[ "$cur" == -* ]]; then - COMPREPLY=($(compgen -W "--include-project --exclude-project --no-global --no-metadata --recipient --passphrase-env --passphrase-stdin --force-unencrypted" -- "$cur")) + 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) diff --git a/lib/devbase/env/_import_merge.py b/lib/devbase/env/_import_merge.py index 8d1ad89..32fad33 100644 --- a/lib/devbase/env/_import_merge.py +++ b/lib/devbase/env/_import_merge.py @@ -23,7 +23,15 @@ logger = get_logger(__name__) -_PROJECT_ENV_RE = re.compile(r'^env/projects/([^/]+)/\.env$') +# project 名は通常のディレクトリ名のみ許容する。 +# - 先頭文字: 英数字 / `_` (`.` を許可すると `env/projects/./.env` が +# `$DEVBASE_ROOT/projects/.env` に正規化され、グローバル .env を上書きする +# path traversal 系の問題になる — PR #13 codex round 3 指摘) +# - 2文字目以降: 英数字 / `_` / `-` / `.` +# - `.` / `..` のような特殊セグメント、空文字、`/` を含む値は弾く +# bundle._validate_manifest や tar 展開側 (`..` のみ拒否) では塞ぎきれないため、 +# arcname を path に解決する側で project 名を制限する。 +_PROJECT_ENV_RE = re.compile(r'^env/projects/([A-Za-z0-9_][A-Za-z0-9_.\-]*)/\.env$') # import_bundle が許容する --merge モード一覧。CLI の choices と一致させる。 MERGE_MODES: Tuple[str, ...] = ('keep-existing', 'prefer-incoming') diff --git a/tests/cli/test_env_import.py b/tests/cli/test_env_import.py index bf27b60..3c8a0af 100644 --- a/tests/cli/test_env_import.py +++ b/tests/cli/test_env_import.py @@ -1006,3 +1006,51 @@ def test_env_import_filter_members_rejects_unknown_arcname(): 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' From e7b146422cda3395d742d596dd7211c94556d005 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sun, 24 May 2026 02:43:16 +0900 Subject: [PATCH 09/16] =?UTF-8?q?fix(env):=20PR=20#13=20round4=20gemini=20?= =?UTF-8?q?=E6=8C=87=E6=91=98=E5=AF=BE=E5=BF=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - cipher.py: `@PATH` ファイルに複数行の鍵が含まれる場合は明示的に CipherError を投げる。team_keys.txt のような複数公開鍵列挙を 暗黙に「最初の 1 人」だけ扱う挙動は、チーム運用で暗号化バンドル が壊れる原因になるため誤運用を防ぐ - io_common.py: `resolve_identity_specs` で `~/.ssh/id_ed25519` と `~/.ssh/id_rsa` の両方が存在する場合、両方を返すように変更。 `pyrage.decrypt` は複数 identity を受け付け、バンドルに合致する 鍵だけが使われる。これにより RSA で暗号化されたバンドルを ed25519 鍵だけで開けず失敗する問題を解消 - resolve_recipient_specs は意図的に最初の 1 つを返す挙動を維持 (どの鍵で暗号化するか一意に決める必要があるため) - 複数鍵ファイル拒否と複数 identity 復号の回帰テストを追加 --- lib/devbase/env/cipher.py | 11 +++- lib/devbase/env/io_common.py | 13 ++-- tests/env/test_cipher.py | 19 ++++++ tests/env/test_io_common.py | 122 +++++++++++++++++++++++++++++++++++ 4 files changed, 160 insertions(+), 5 deletions(-) create mode 100644 tests/env/test_io_common.py diff --git a/lib/devbase/env/cipher.py b/lib/devbase/env/cipher.py index faa4dd4..e3c3f81 100644 --- a/lib/devbase/env/cipher.py +++ b/lib/devbase/env/cipher.py @@ -50,13 +50,22 @@ def _resolve_recipient(spec: str, _depth: int = 0): 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'): diff --git a/lib/devbase/env/io_common.py b/lib/devbase/env/io_common.py index a46b894..a0b27da 100644 --- a/lib/devbase/env/io_common.py +++ b/lib/devbase/env/io_common.py @@ -70,16 +70,21 @@ def resolve_recipient_specs(specs: Sequence[str]) -> List[str]: def resolve_identity_specs(specs: Sequence[str]) -> List[str]: """identity 指定の解決。 - 明示指定があればそのまま返す。空なら ``~/.ssh/id_ed25519`` → ``id_rsa`` の - 順で存在する秘密鍵を探し、最初に見つかったものを返す。 + 明示指定があればそのまま返す。空なら ``~/.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) - return [str(path)] - return [] + found.append(str(path)) + return found def write_secure_bytes(path: Path, data: bytes, *, mode: int = 0o600) -> None: diff --git a/tests/env/test_cipher.py b/tests/env/test_cipher.py index ebaabee..65f1ce3 100644 --- a/tests/env/test_cipher.py +++ b/tests/env/test_cipher.py @@ -126,6 +126,25 @@ def test_resolve_recipient_at_path_rejects_only_comments(tmp_path): 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" 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" From 3c5297d1421001f51a71c9ddd7beff4fead3ba1d Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sun, 24 May 2026 02:56:37 +0900 Subject: [PATCH 10/16] =?UTF-8?q?fix(env):=20PR=20#13=20round5=20codex/gem?= =?UTF-8?q?ini=20=E6=8C=87=E6=91=98=E5=AF=BE=E5=BF=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - bundle.is_valid_project_name() を導入し、export 側 (make_entries_from_disk) でも import 側 (_PROJECT_ENV_RE) と同じ project 名 validator を適用する。空白 / 先頭 `.` / `-` 等を含む ディレクトリは警告のみで skip し、round-trip 不能な bundle が 作られるのを防ぐ (codex round 5 指摘)。 - _import_merge._PROJECT_ENV_RE を bundle._VALID_PROJECT_NAME_RE から組み立てるよう変更し、import / export 両側の validator を 契約レベルで同期させる。 - S3Backend._verify_bucket_encryption で NoSuchBucket / 認証・接続 系エラー (code が取れないケース) は --unsafe-allow-unencrypted-bucket の有無に関わらず即 StorageError を投げる。続行しても put_object が 同じエラーで再失敗するだけのため、早期にエラーを返してユーザの トラブルシューティングを助ける (gemini round 5 指摘)。 - 回帰テスト追加: - test_is_valid_project_name / test_make_entries_from_disk_skips_invalid_project_names - test_make_entries_from_disk_invalid_name_explicitly_included_is_still_skipped - test_make_entries_from_disk_validator_matches_import_side (契約同期) - test_s3_backend_rejects_no_such_bucket_even_with_unsafe_flag - test_s3_backend_rejects_auth_or_network_error_without_aws_code Co-Authored-By: Claude Opus 4.7 (1M context) --- lib/devbase/env/_import_merge.py | 9 ++- lib/devbase/env/bundle.py | 37 +++++++++++ lib/devbase/env/storage.py | 24 +++++++ tests/env/test_bundle.py | 110 +++++++++++++++++++++++++++++++ tests/env/test_storage.py | 68 +++++++++++++++++++ 5 files changed, 247 insertions(+), 1 deletion(-) diff --git a/lib/devbase/env/_import_merge.py b/lib/devbase/env/_import_merge.py index 32fad33..0fa019f 100644 --- a/lib/devbase/env/_import_merge.py +++ b/lib/devbase/env/_import_merge.py @@ -19,6 +19,7 @@ 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__) @@ -31,7 +32,13 @@ # - `.` / `..` のような特殊セグメント、空文字、`/` を含む値は弾く # bundle._validate_manifest や tar 展開側 (`..` のみ拒否) では塞ぎきれないため、 # arcname を path に解決する側で project 名を制限する。 -_PROJECT_ENV_RE = re.compile(r'^env/projects/([A-Za-z0-9_][A-Za-z0-9_.\-]*)/\.env$') +# +# 名前部分の 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') diff --git a/lib/devbase/env/bundle.py b/lib/devbase/env/bundle.py index d9b9399..e957096 100644 --- a/lib/devbase/env/bundle.py +++ b/lib/devbase/env/bundle.py @@ -5,6 +5,7 @@ import gzip import hashlib import io +import re import tarfile from dataclasses import dataclass from datetime import datetime, timezone @@ -13,15 +14,33 @@ 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): """バンドル構築・展開エラー""" @@ -245,6 +264,24 @@ def make_entries_from_disk(devbase_root, 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( diff --git a/lib/devbase/env/storage.py b/lib/devbase/env/storage.py index ffb36c5..549e654 100644 --- a/lib/devbase/env/storage.py +++ b/lib/devbase/env/storage.py @@ -190,12 +190,36 @@ def _verify_bucket_encryption(self, client, bucket: str) -> None: 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 = ( diff --git a/tests/env/test_bundle.py b/tests/env/test_bundle.py index e6458e6..5e44663 100644 --- a/tests/env/test_bundle.py +++ b/tests/env/test_bundle.py @@ -299,6 +299,116 @@ def test_make_entries_from_disk_ignores_directory_named_env(tmp_path): 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 diff --git a/tests/env/test_storage.py b/tests/env/test_storage.py index 0f32e3e..1d5eb9f 100644 --- a/tests/env/test_storage.py +++ b/tests/env/test_storage.py @@ -448,6 +448,74 @@ def test_s3_backend_read_wraps_unknown_error(): 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( From 74c9432b07e23191179cebdd5d3d85c170eb55ef Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sun, 24 May 2026 03:40:54 +0900 Subject: [PATCH 11/16] =?UTF-8?q?chore:=20=E4=B8=8D=E8=A6=81=E3=81=AA=20mi?= =?UTF-8?q?grate=5Fai=5Fto=5Fhome.sh=20=E3=82=92=E5=89=8A=E9=99=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.7 (1M context) --- bin/migrate_ai_to_home.sh | 77 --------------------------------------- 1 file changed, 77 deletions(-) delete mode 100755 bin/migrate_ai_to_home.sh 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" From b1e85d6b47c00b9a5ed5cdeb4a1b1fdc385008fc Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sun, 24 May 2026 03:23:38 +0900 Subject: [PATCH 12/16] =?UTF-8?q?fix(env):=20recipient=20=E3=81=A8=20passp?= =?UTF-8?q?hrase=20=E5=90=8C=E6=99=82=E6=8C=87=E5=AE=9A=E3=82=92=E6=8B=92?= =?UTF-8?q?=E5=90=A6=20+=20docs=20=E8=A1=A8=E7=8F=BE=E4=BF=AE=E6=AD=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - io_export._encrypt_payload で passphrase ありかつ opts.recipients 指定時に ExportError を送出。従来は recipients=[] に上書きされて cipher.encrypt 側の同時指定チェックを silently バイパスし、ユーザ が明示した --recipient が無視されていた (gemini round 6 指摘)。 - docs/user/env-export-import.md L201 の制約説明を修正。round 2 で export 側の DEST='-' x --passphrase-stdin 排他チェックを撤廃した ため、SOURCE='-' (import) のみ併用不可、export は併用可能であること を明記 (codex round 6 指摘)。 - recipient + passphrase 併用拒否の回帰テストを追加。 Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/user/env-export-import.md | 2 +- lib/devbase/env/io_export.py | 7 +++++++ tests/cli/test_env_export.py | 19 +++++++++++++++++++ 3 files changed, 27 insertions(+), 1 deletion(-) diff --git a/docs/user/env-export-import.md b/docs/user/env-export-import.md index 3b363b7..6f515d5 100644 --- a/docs/user/env-export-import.md +++ b/docs/user/env-export-import.md @@ -198,7 +198,7 @@ devbase env export - --force-unencrypted | gpg --encrypt -r alice@example.com > gpg --decrypt bundle.gpg | devbase env import - ``` -> **制約**: `DEST='-'` / `SOURCE='-'` と `--passphrase-stdin` は **併用不可** (stdin/stdout が衝突するため明示的にエラーにします)。 +> **制約**: `SOURCE='-'` と `--passphrase-stdin` は **併用不可** (どちらも stdin を要求するため衝突します。import 側のみエラーになります)。`DEST='-'` (export) は stdin (passphrase) と stdout (bundle) で別ストリームを使うため `--passphrase-stdin` と併用できます。 ## `devbase env export` リファレンス diff --git a/lib/devbase/env/io_export.py b/lib/devbase/env/io_export.py index 976e834..403a121 100644 --- a/lib/devbase/env/io_export.py +++ b/lib/devbase/env/io_export.py @@ -86,6 +86,13 @@ def _validate_options(opts: ExportOptions) -> None: def _encrypt_payload(tar_blob: bytes, opts: ExportOptions) -> bytes: """``opts`` の鍵指定に従って tar.gz を暗号化する。鍵が無ければ既定鍵を試す""" passphrase = _read_passphrase(opts) + if passphrase is not None and opts.recipients: + # 明示指定の --recipient と --passphrase-* を黙って捨てると意図と異なる + # 暗号化が行われるため、ここで明示的にエラーにする + # (cipher.encrypt 側にも同等チェックがあるが、recipients=[] に上書きする前に弾く) + raise ExportError( + "--recipient と --passphrase-env/--passphrase-stdin は併用できません" + ) recipients = ( [] if passphrase is not None else _io_common.resolve_recipient_specs(opts.recipients) diff --git a/tests/cli/test_env_export.py b/tests/cli/test_env_export.py index 000e959..1a0fc9a 100644 --- a/tests/cli/test_env_export.py +++ b/tests/cli/test_env_export.py @@ -110,6 +110,25 @@ def test_export_rejects_both_passphrase_env_and_stdin(fake_root): 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") From 23dedafbc46e32efd7ae9a180f06e6aed842e848 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sun, 24 May 2026 04:12:27 +0900 Subject: [PATCH 13/16] =?UTF-8?q?fix(env):=20PR=20#22=20round1=20codex/gem?= =?UTF-8?q?ini=20=E6=8C=87=E6=91=98=E5=AF=BE=E5=BF=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - bundle.py: manifest version チェックを厳密一致 (!=) に変更し、 0 や負数の未知スキーマを拒否 (codex major 指摘) - cli.py: import --identity の help を "(all existing ones)" に修正 (codex minor 指摘、resolve_identity_specs は全鍵を返す) - io_import.py: --identity と --passphrase-* の同時指定を _validate_options で拒否 (gemini major 指摘) - テスト追加: version=0/-1 の拒否、identity+passphrase 排他チェック Co-Authored-By: Claude Opus 4.7 (1M context) --- lib/devbase/cli.py | 2 +- lib/devbase/env/bundle.py | 4 ++-- lib/devbase/env/io_import.py | 4 ++++ tests/cli/test_env_import.py | 12 ++++++++++++ tests/env/test_bundle.py | 9 +++++++-- 5 files changed, 26 insertions(+), 5 deletions(-) diff --git a/lib/devbase/cli.py b/lib/devbase/cli.py index e2fd4be..1e20f38 100644 --- a/lib/devbase/cli.py +++ b/lib/devbase/cli.py @@ -179,7 +179,7 @@ def _add_env_parser(subparsers): metavar='FILE', dest='identities', help=("age / OpenSSH private key file (repeatable). " "Default: ~/.ssh/id_ed25519, then ~/.ssh/id_rsa " - "(first existing one)")) + "(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', diff --git a/lib/devbase/env/bundle.py b/lib/devbase/env/bundle.py index e957096..33fdd4b 100644 --- a/lib/devbase/env/bundle.py +++ b/lib/devbase/env/bundle.py @@ -164,10 +164,10 @@ def _validate_manifest(manifest: Dict, members: Dict[str, bytes]) -> None: version = manifest.get('version') if not isinstance(version, int): raise BundleError("manifest.version が不正です") - if version > SUPPORTED_MANIFEST_VERSION: + if version != SUPPORTED_MANIFEST_VERSION: raise BundleError( f"manifest.version={version} はこの devbase ではサポートされていません " - f"(対応最大={SUPPORTED_MANIFEST_VERSION})。devbase 本体を更新してください" + f"(対応={SUPPORTED_MANIFEST_VERSION})。devbase 本体を更新してください" ) files = manifest.get('files') or [] diff --git a/lib/devbase/env/io_import.py b/lib/devbase/env/io_import.py index 6107e1f..616b9b7 100644 --- a/lib/devbase/env/io_import.py +++ b/lib/devbase/env/io_import.py @@ -85,6 +85,10 @@ def _validate_options(opts: ImportOptions) -> None: ) 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: diff --git a/tests/cli/test_env_import.py b/tests/cli/test_env_import.py index 3c8a0af..d650a1c 100644 --- a/tests/cli/test_env_import.py +++ b/tests/cli/test_env_import.py @@ -206,6 +206,18 @@ def test_import_rejects_both_passphrase_env_and_stdin(dest_root): 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") diff --git a/tests/env/test_bundle.py b/tests/env/test_bundle.py index 5e44663..d70bf2c 100644 --- a/tests/env/test_bundle.py +++ b/tests/env/test_bundle.py @@ -49,7 +49,12 @@ def test_unpack_rejects_corrupted_sha256(): bundle.unpack(out.getvalue()) -def test_unpack_rejects_unknown_version(): +@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) @@ -62,7 +67,7 @@ def test_unpack_rejects_unknown_version(): data = tin.extractfile(info).read() if info.name == bundle.MANIFEST_NAME: m = yaml.safe_load(data) - m["version"] = bundle.SUPPORTED_MANIFEST_VERSION + 1 + m["version"] = bad_version data = yaml.safe_dump(m).encode("utf-8") info.size = len(data) tout.addfile(info, io.BytesIO(data)) From b81aae450153422b7b4a3b091d704d610b293925 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sun, 24 May 2026 04:22:34 +0900 Subject: [PATCH 14/16] =?UTF-8?q?fix(env):=20export=20=E3=82=AA=E3=83=97?= =?UTF-8?q?=E3=82=B7=E3=83=A7=E3=83=B3=E6=8E=92=E4=BB=96=E3=83=81=E3=82=A7?= =?UTF-8?q?=E3=83=83=E3=82=AF=E3=82=92=20=5Fvalidate=5Foptions=20=E3=81=AB?= =?UTF-8?q?=E9=9B=86=E7=B4=84=20(fail-fast)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - --recipient と --passphrase-* の排他チェックを _encrypt_payload から _validate_options へ移動 (ディスク I/O 前に弾く) - --force-unencrypted と鍵指定の排他チェックを export() から _validate_options へ移動 - io_import._validate_options と対称的な構造に統一 - _validate_options 直接テスト 5 件追加 PR #22 round2 gemini [major / 堅牢性] 指摘対応 Co-Authored-By: Claude Opus 4.7 (1M context) --- lib/devbase/env/io_export.py | 25 ++++++++-------- tests/cli/test_env_export.py | 55 +++++++++++++++++++++++++++++++++++- 2 files changed, 68 insertions(+), 12 deletions(-) diff --git a/lib/devbase/env/io_export.py b/lib/devbase/env/io_export.py index 403a121..d286c82 100644 --- a/lib/devbase/env/io_export.py +++ b/lib/devbase/env/io_export.py @@ -81,18 +81,23 @@ def _validate_options(opts: ExportOptions) -> None: # (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) - if passphrase is not None and opts.recipients: - # 明示指定の --recipient と --passphrase-* を黙って捨てると意図と異なる - # 暗号化が行われるため、ここで明示的にエラーにする - # (cipher.encrypt 側にも同等チェックがあるが、recipients=[] に上書きする前に弾く) - raise ExportError( - "--recipient と --passphrase-env/--passphrase-stdin は併用できません" - ) + # NOTE: --recipient と --passphrase-* の排他チェックは _validate_options で + # fail-fast 済み。cipher.encrypt 側にも防御的チェックがある。 recipients = ( [] if passphrase is not None else _io_common.resolve_recipient_specs(opts.recipients) @@ -148,10 +153,8 @@ def export(devbase_root: Path, opts: ExportOptions) -> int: logger.debug("tar.gz サイズ: %d bytes", len(tar_blob)) if opts.force_unencrypted: - if opts.recipients or opts.passphrase_env or opts.passphrase_stdin: - raise ExportError( - "--force-unencrypted は recipient / passphrase と併用できません" - ) + # NOTE: --force-unencrypted と鍵指定の排他チェックは _validate_options で + # fail-fast 済み。ここでは平文出力の警告のみ。 _warn_if_plaintext_sensitive(entries) payload = tar_blob else: diff --git a/tests/cli/test_env_export.py b/tests/cli/test_env_export.py index 1a0fc9a..d89184d 100644 --- a/tests/cli/test_env_export.py +++ b/tests/cli/test_env_export.py @@ -9,7 +9,9 @@ import pytest from devbase.env import bundle, cipher -from devbase.env.io_export import ExportOptions, ExportError, _read_passphrase, export +from devbase.env.io_export import ( + ExportOptions, ExportError, _read_passphrase, _validate_options, export, +) @pytest.fixture @@ -239,3 +241,54 @@ def test_export_uses_default_recipient_if_present(fake_root, tmp_path, monkeypat 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, + )) From a751323995fec2505d2e27d8a1779b3d7f2a622f Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sun, 24 May 2026 04:33:03 +0900 Subject: [PATCH 15/16] =?UTF-8?q?fix(env):=20backup=20GC=20=E3=81=AB=20dbe?= =?UTF-8?q?nv-=20prefix=20=E3=81=A7=E5=AE=89=E5=85=A8=E5=BC=81=20+=20expor?= =?UTF-8?q?t=20=E6=97=A2=E5=AE=9A=E5=90=8D=E3=82=92=20microsecond=20?= =?UTF-8?q?=E7=B2=BE=E5=BA=A6=E3=81=AB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #22 round3 codex 指摘対応: 1. [major] _import_atomic.py: backup ディレクトリ名に `dbenv-` prefix を付与し、 --backup-dir 親に無関係なタイムスタンプ形式ディレクトリがあっても GC で rmtree しないようにした。旧フォーマット (prefix なし) は後方互換で GC 対象。 2. [minor] io_export.py: 既定出力名を秒精度から microsecond 精度に変更し、 同一秒の複数 export による無言上書きを防止。加えて既定名使用時に同名 ファイルが既に存在する場合は ExportError で失敗させる。 テスト追加: - test_gc_backups_ignores_bare_timestamp_dirs_from_other_tools - test_default_dest_includes_microsecond - test_export_default_dest_rejects_existing_file Co-Authored-By: Claude Opus 4.7 (1M context) --- lib/devbase/env/_import_atomic.py | 28 +++++++++++++---------- lib/devbase/env/io_export.py | 11 ++++++++- tests/cli/test_env_export.py | 32 +++++++++++++++++++++++++- tests/cli/test_env_import.py | 38 ++++++++++++++++++++++++++++--- 4 files changed, 92 insertions(+), 17 deletions(-) diff --git a/lib/devbase/env/_import_atomic.py b/lib/devbase/env/_import_atomic.py index 9c788d5..bf271ba 100644 --- a/lib/devbase/env/_import_atomic.py +++ b/lib/devbase/env/_import_atomic.py @@ -29,14 +29,18 @@ logger = get_logger(__name__) -# _make_backup_dir が生成するタイムスタンプ形式のみを GC 対象にする。 -# 以下のいずれかにマッチするディレクトリのみ削除する: -# YYYYMMDD-HHMMSS (旧フォーマット, 後方互換) -# YYYYMMDD-HHMMSS-NNNNNN (microsecond 付き) -# YYYYMMDD-HHMMSS-NNNNNN-NN (同一マイクロ秒内の連番付き) -# これ以外のディレクトリは devbase が作ったものではないので削除しない -# (--backup-dir 親に無関係なディレクトリがあっても安全)。 -_BACKUP_DIR_NAME_RE = re.compile(r'^\d{8}-\d{6}(?:-\d{6}(?:-\d+)?)?$') +# 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): @@ -55,7 +59,7 @@ def make_backup_dir(devbase_root: Path, backup_dir: Optional[str]) -> Path: else devbase_root / 'backups' / 'env-import') base.mkdir(parents=True, exist_ok=True) - stem = datetime.now().strftime('%Y%m%d-%H%M%S-%f') # microsecond まで + 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) @@ -184,9 +188,9 @@ def cleanup_tmps(tmps) -> None: def gc_backups(backup_dir: Path, keep_last: int) -> None: """``backup_dir`` の親ディレクトリで古い backup を ``keep_last`` 個まで残して GC する。 - devbase 生成のタイムスタンプ形式 (``YYYYMMDD-HHMMSS[-NNNNNN[-NN]]``) に - マッチするディレクトリのみが GC 対象。``--backup-dir`` 親に無関係な - ファイル / ディレクトリがあっても、それらは触らない。 + ``dbenv-`` prefix 付きの devbase 生成ディレクトリ、または旧フォーマットの + タイムスタンプ形式 (``YYYYMMDD-HHMMSS[-NNNNNN[-NN]]``) にマッチする + ディレクトリのみが GC 対象。``--backup-dir`` 親に無関係なディレクトリは触らない。 """ if keep_last <= 0: return diff --git a/lib/devbase/env/io_export.py b/lib/devbase/env/io_export.py index d286c82..49fec29 100644 --- a/lib/devbase/env/io_export.py +++ b/lib/devbase/env/io_export.py @@ -45,7 +45,8 @@ class ExportOptions: def _default_dest(force_unencrypted: bool) -> str: - ts = datetime.now().strftime('%Y%m%d-%H%M%S') + # 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}' @@ -162,6 +163,14 @@ def export(devbase_root: Path, opts: ExportOptions) -> int: logger.debug("暗号化後サイズ: %d bytes", len(payload)) dest = opts.dest or _default_dest(opts.force_unencrypted) + # 既定名 (opts.dest 未指定) かつローカルパスの場合、既存ファイルの上書きを拒否する + # (microsecond 精度でも理論上は衝突しうるため防御的にチェック) + if opts.dest is None 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( diff --git a/tests/cli/test_env_export.py b/tests/cli/test_env_export.py index d89184d..e5311c4 100644 --- a/tests/cli/test_env_export.py +++ b/tests/cli/test_env_export.py @@ -10,7 +10,8 @@ from devbase.env import bundle, cipher from devbase.env.io_export import ( - ExportOptions, ExportError, _read_passphrase, _validate_options, export, + ExportOptions, ExportError, _default_dest, _read_passphrase, + _validate_options, export, ) @@ -292,3 +293,32 @@ def test_validate_rejects_force_unencrypted_with_passphrase_stdin(): 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}"], + )) diff --git a/tests/cli/test_env_import.py b/tests/cli/test_env_import.py index d650a1c..5926a58 100644 --- a/tests/cli/test_env_import.py +++ b/tests/cli/test_env_import.py @@ -345,8 +345,9 @@ def test_import_keep_last_gc_removes_old_backups(fake_root, dest_root, age_keys, remaining = sorted(p.name for p in backup_root.iterdir()) assert len(remaining) == 3 - # 最新 3 個に絞られる: 既存の 20260101-000003, 000004, 加えて新規 backup - assert remaining[-1].startswith('20') + # 最新 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): @@ -476,7 +477,7 @@ def test_gc_backups_only_removes_timestamp_dirs(fake_root, dest_root, age_keys, # 関係ないファイル unrelated_file = custom_backup_root / "readme.txt" unrelated_file.write_text("must not be deleted") - # devbase 命名の古い backup を keep_last 超に置く + # devbase 命名 (旧フォーマット) の古い backup を keep_last 超に置く for i in range(5): (custom_backup_root / f"20240101-00000{i}").mkdir() @@ -497,6 +498,37 @@ def test_gc_backups_only_removes_timestamp_dirs(fake_root, dest_root, age_keys, 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") From db8f0a5e31f11c2ee4d2a0c5db88a48ca6cac2f4 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sun, 24 May 2026 04:42:08 +0900 Subject: [PATCH 16/16] =?UTF-8?q?fix(env):=20opts.dest=20=E7=A9=BA?= =?UTF-8?q?=E6=96=87=E5=AD=97=E3=81=A7=E6=97=A2=E5=AE=9A=E5=90=8D=E3=82=AC?= =?UTF-8?q?=E3=83=BC=E3=83=89=E3=81=8C=E3=83=90=E3=82=A4=E3=83=91=E3=82=B9?= =?UTF-8?q?=E3=81=95=E3=82=8C=E3=82=8B=E5=95=8F=E9=A1=8C=E3=82=92=E4=BF=AE?= =?UTF-8?q?=E6=AD=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `opts.dest` が `""` (空文字) の場合、`opts.dest or _default_dest(...)` で 既定名に置換されるが、`if opts.dest is None` チェックが `False` となり 同名ファイル存在時の ExportError ガードがバイパスされていた。 `if not opts.dest` に修正し、空文字も None と同等に扱うようにした。 テスト `test_export_empty_dest_rejects_existing_file` を追加。 PR #22 round4 gemini 指摘対応 Co-Authored-By: Claude Opus 4.7 (1M context) --- lib/devbase/env/io_export.py | 2 +- tests/cli/test_env_export.py | 21 +++++++++++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/lib/devbase/env/io_export.py b/lib/devbase/env/io_export.py index 49fec29..be18c51 100644 --- a/lib/devbase/env/io_export.py +++ b/lib/devbase/env/io_export.py @@ -165,7 +165,7 @@ def export(devbase_root: Path, opts: ExportOptions) -> int: dest = opts.dest or _default_dest(opts.force_unencrypted) # 既定名 (opts.dest 未指定) かつローカルパスの場合、既存ファイルの上書きを拒否する # (microsecond 精度でも理論上は衝突しうるため防御的にチェック) - if opts.dest is None and not _storage.is_s3(dest) and not _storage.is_stdio(dest): + if not opts.dest and not _storage.is_s3(dest) and not _storage.is_stdio(dest): if Path(dest).exists(): raise ExportError( f"既定出力先 {dest} が既に存在します。" diff --git a/tests/cli/test_env_export.py b/tests/cli/test_env_export.py index e5311c4..ac47db2 100644 --- a/tests/cli/test_env_export.py +++ b/tests/cli/test_env_export.py @@ -322,3 +322,24 @@ def test_export_default_dest_rejects_existing_file( 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}"], + ))