From 5e5b1569b7af3d0695b2228017937bbd02421ccb Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Thu, 21 May 2026 10:31:38 +0900 Subject: [PATCH 01/11] =?UTF-8?q?chore(PLAN03-1-export-local):=20Draft=20P?= =?UTF-8?q?R=20=E4=BD=9C=E6=88=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From c3de981d7cd2d704ddbdf201fe0a4f4094331c5f Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Thu, 21 May 2026 10:42:03 +0900 Subject: [PATCH 02/11] =?UTF-8?q?feat(env):=20devbase=20env=20export=20?= =?UTF-8?q?=E3=82=92=E8=BF=BD=E5=8A=A0=20(PLAN03-1=20PR1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- lib/devbase/cli.py | 32 +++++- lib/devbase/commands/env.py | 19 ++++ lib/devbase/env/bundle.py | 203 +++++++++++++++++++++++++++++++++++ lib/devbase/env/cipher.py | 148 +++++++++++++++++++++++++ lib/devbase/env/io_export.py | 164 ++++++++++++++++++++++++++++ lib/devbase/env/storage.py | 78 ++++++++++++++ pyproject.toml | 10 ++ tests/__init__.py | 0 tests/cli/__init__.py | 0 tests/cli/test_env_export.py | 140 ++++++++++++++++++++++++ tests/env/__init__.py | 0 tests/env/test_bundle.py | 112 +++++++++++++++++++ tests/env/test_cipher.py | 62 +++++++++++ tests/env/test_storage.py | 59 ++++++++++ uv.lock | 164 +++++++++++++++++++++++++++- 15 files changed, 1189 insertions(+), 2 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/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/lib/devbase/cli.py b/lib/devbase/cli.py index 3201679..511fd22 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'], + ('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'], } @@ -109,6 +109,36 @@ 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_rsa.pub if present")) + 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""" 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..ca3921b --- /dev/null +++ b/lib/devbase/env/bundle.py @@ -0,0 +1,203 @@ +"""env export/import バンドル (tar.gz + manifest.yml) の構築・展開""" + +from __future__ import annotations + +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() + # mtime=0 で再現性を確保 + with tarfile.open(fileobj=buf, mode='w:gz', 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 + + members: Dict[str, bytes] = {} + 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}") + f = tf.extractfile(info) + if f is None: + continue + members[info.name] = f.read() + + 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: + 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 [] + for entry in files: + path = entry.get('path') + expected = entry.get('sha256') + if path not in members: + raise BundleError(f"manifest に記載されたファイルが見つかりません: {path}") + actual = _sha256(members[path]) + if expected and expected != actual: + raise BundleError( + f"sha256 が一致しません (path={path}, expected={expected[:12]}..., " + f"actual={actual[:12]}...)" + ) + + +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' + if global_env.exists(): + 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.exists(): + 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.exists(): + 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..b0fa71e --- /dev/null +++ b/lib/devbase/env/cipher.py @@ -0,0 +1,148 @@ +"""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): + """暗号化・復号エラー""" + + +def _resolve_recipient(spec: str): + """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('@'): + path = Path(spec[1:]).expanduser() + if not path.exists(): + raise CipherError(f"recipient ファイルが見つかりません: {path}") + return _resolve_recipient(path.read_text(encoding='utf-8').strip()) + + 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}") + + raw = path.read_bytes() + text = raw.decode('utf-8', errors='ignore').strip() + + if text.startswith('AGE-SECRET-KEY-1'): + try: + return pyrage.x25519.Identity.from_str(text) + except Exception as e: + raise CipherError(f"age 秘密鍵の解釈に失敗しました ({path}): {e}") from e + + 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 省略時に試す既定の公開鍵パス候補""" + return [Path.home() / '.ssh' / 'id_rsa.pub'] + + +def default_identity_paths() -> List[Path]: + """identity 省略時に試す既定の秘密鍵パス候補""" + return [Path.home() / '.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..bbf1eca --- /dev/null +++ b/lib/devbase/env/io_export.py @@ -0,0 +1,164 @@ +"""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_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 + 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_rsa.pub があれば --recipient 省略時の既定として使用されます" + ) + 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..7334725 --- /dev/null +++ b/lib/devbase/env/storage.py @@ -0,0 +1,78 @@ +"""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: ... + + +class LocalBackend: + """ローカルファイルシステム""" + + def write_bytes(self, dest: str, data: bytes) -> None: + path = Path(dest).expanduser() + if path.parent and not path.parent.exists(): + path.parent.mkdir(parents=True, exist_ok=True) + # 0600 で書き出すため open(..., 'wb') 後に chmod する + with open(path, 'wb') as f: + f.write(data) + try: + os.chmod(path, 0o600) + except OSError: + # Windows 等で chmod が無効でも書き込み自体は完了させる + pass + + def read_bytes(self, source: str) -> bytes: + path = Path(source).expanduser() + if not path.exists(): + raise StorageError(f"ファイルが見つかりません: {path}") + return path.read_bytes() + + +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 で対応予定)" + ) + + 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..d00abea --- /dev/null +++ b/tests/cli/test_env_export.py @@ -0,0 +1,140 @@ +"""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, 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_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/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..c4812f6 --- /dev/null +++ b/tests/env/test_bundle.py @@ -0,0 +1,112 @@ +"""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()) diff --git a/tests/env/test_cipher.py b/tests/env/test_cipher.py new file mode 100644 index 0000000..db61ae9 --- /dev/null +++ b/tests/env/test_cipher.py @@ -0,0 +1,62 @@ +"""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"]) diff --git a/tests/env/test_storage.py b/tests/env/test_storage.py new file mode 100644 index 0000000..7738d33 --- /dev/null +++ b/tests/env/test_storage.py @@ -0,0 +1,59 @@ +"""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") + + +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" 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 f406c8c9f64917193417b5219c9eaeb7587c8c2d Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Thu, 21 May 2026 16:29:52 +0900 Subject: [PATCH 03/11] =?UTF-8?q?fix(env):=20=E3=83=AC=E3=83=93=E3=83=A5?= =?UTF-8?q?=E3=83=BC=E6=8C=87=E6=91=98=E3=81=AE=E4=BF=AE=E6=AD=A3=20(stora?= =?UTF-8?q?ge/bundle/cipher)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- lib/devbase/env/bundle.py | 8 ++++++++ lib/devbase/env/cipher.py | 7 +++++-- lib/devbase/env/storage.py | 14 ++++++++++++-- 3 files changed, 25 insertions(+), 4 deletions(-) diff --git a/lib/devbase/env/bundle.py b/lib/devbase/env/bundle.py index ca3921b..b0d8a17 100644 --- a/lib/devbase/env/bundle.py +++ b/lib/devbase/env/bundle.py @@ -130,9 +130,17 @@ def _validate_manifest(manifest: Dict, members: Dict[str, bytes]) -> None: ) files = manifest.get('files') or [] + if not isinstance(files, list): + raise BundleError("manifest.files が list ではありません") 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 expected is not None and not isinstance(expected, str): + raise BundleError(f"manifest.files の sha256 が不正です (path={path}): {expected!r}") if path not in members: raise BundleError(f"manifest に記載されたファイルが見つかりません: {path}") actual = _sha256(members[path]) diff --git a/lib/devbase/env/cipher.py b/lib/devbase/env/cipher.py index b0fa71e..99b57ac 100644 --- a/lib/devbase/env/cipher.py +++ b/lib/devbase/env/cipher.py @@ -64,9 +64,12 @@ def _resolve_identity(path_spec: str): raise CipherError(f"identity ファイルが見つかりません: {path}") raw = path.read_bytes() - text = raw.decode('utf-8', errors='ignore').strip() - if text.startswith('AGE-SECRET-KEY-1'): + 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: diff --git a/lib/devbase/env/storage.py b/lib/devbase/env/storage.py index 7334725..5c41bf2 100644 --- a/lib/devbase/env/storage.py +++ b/lib/devbase/env/storage.py @@ -20,11 +20,21 @@ 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 を /tmp/x として扱う + 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 = Path(dest).expanduser() + path = _to_local_path(dest) if path.parent and not path.parent.exists(): path.parent.mkdir(parents=True, exist_ok=True) # 0600 で書き出すため open(..., 'wb') 後に chmod する @@ -37,7 +47,7 @@ def write_bytes(self, dest: str, data: bytes) -> None: pass def read_bytes(self, source: str) -> bytes: - path = Path(source).expanduser() + path = _to_local_path(source) if not path.exists(): raise StorageError(f"ファイルが見つかりません: {path}") return path.read_bytes() From 8baca0c114530f9641ac26b379e7546124dc555c Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Thu, 21 May 2026 16:36:20 +0900 Subject: [PATCH 04/11] =?UTF-8?q?fix(env):=20round=202=20=E3=83=AC?= =?UTF-8?q?=E3=83=93=E3=83=A5=E3=83=BC=E6=8C=87=E6=91=98=E3=81=AE=E4=BF=AE?= =?UTF-8?q?=E6=AD=A3=20(=E5=A0=85=E7=89=A2=E6=80=A7=20+=20test=20=E8=BF=BD?= =?UTF-8?q?=E5=8A=A0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- lib/devbase/env/bundle.py | 2 + lib/devbase/env/cipher.py | 14 +++++-- lib/devbase/env/storage.py | 9 ++++- tests/env/test_bundle.py | 78 ++++++++++++++++++++++++++++++++++++++ tests/env/test_cipher.py | 11 ++++++ tests/env/test_storage.py | 21 ++++++++++ 6 files changed, 131 insertions(+), 4 deletions(-) diff --git a/lib/devbase/env/bundle.py b/lib/devbase/env/bundle.py index b0d8a17..1ec3489 100644 --- a/lib/devbase/env/bundle.py +++ b/lib/devbase/env/bundle.py @@ -101,6 +101,8 @@ def unpack(blob: bytes) -> Tuple[Dict, Dict[str, bytes]]: 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 diff --git a/lib/devbase/env/cipher.py b/lib/devbase/env/cipher.py index 99b57ac..dd2c225 100644 --- a/lib/devbase/env/cipher.py +++ b/lib/devbase/env/cipher.py @@ -14,24 +14,32 @@ class CipherError(DevbaseError): """暗号化・復号エラー""" -def _resolve_recipient(spec: str): +_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' -> ファイル参照 (中身を再帰的に解釈) + '@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}") - return _resolve_recipient(path.read_text(encoding='utf-8').strip()) + return _resolve_recipient(path.read_text(encoding='utf-8').strip(), _depth + 1) if spec.startswith('age1'): try: diff --git a/lib/devbase/env/storage.py b/lib/devbase/env/storage.py index 5c41bf2..29c0dc8 100644 --- a/lib/devbase/env/storage.py +++ b/lib/devbase/env/storage.py @@ -24,7 +24,14 @@ 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 を /tmp/x として扱う + # 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() diff --git a/tests/env/test_bundle.py b/tests/env/test_bundle.py index c4812f6..dc79db4 100644 --- a/tests/env/test_bundle.py +++ b/tests/env/test_bundle.py @@ -110,3 +110,81 @@ def test_unpack_rejects_traversal_paths(): 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_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()) diff --git a/tests/env/test_cipher.py b/tests/env/test_cipher.py index db61ae9..ee822fd 100644 --- a/tests/env/test_cipher.py +++ b/tests/env/test_cipher.py @@ -60,3 +60,14 @@ def test_recipient_at_file_reference(tmp_path, x25519_keypair): 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}"]) diff --git a/tests/env/test_storage.py b/tests/env/test_storage.py index 7738d33..62f8800 100644 --- a/tests/env/test_storage.py +++ b/tests/env/test_storage.py @@ -48,6 +48,27 @@ def test_resolve_rejects_unknown_scheme(): storage.resolve("ftp://host/x") +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() From cbb98cab5d47212786257bd67c45a761bb25afd1 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Thu, 21 May 2026 16:45:36 +0900 Subject: [PATCH 05/11] =?UTF-8?q?fix(env):=20sha256=20=E5=BF=85=E9=A0=88?= =?UTF-8?q?=E5=8C=96=E3=81=A8=20ed25519=20=E3=83=87=E3=83=95=E3=82=A9?= =?UTF-8?q?=E3=83=AB=E3=83=88=E9=8D=B5=E5=AF=BE=E5=BF=9C=20(round=203)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- lib/devbase/env/bundle.py | 12 ++++++++--- lib/devbase/env/cipher.py | 16 ++++++++++---- tests/env/test_bundle.py | 44 +++++++++++++++++++++++++++++++++++++++ tests/env/test_cipher.py | 19 +++++++++++++++++ 4 files changed, 84 insertions(+), 7 deletions(-) diff --git a/lib/devbase/env/bundle.py b/lib/devbase/env/bundle.py index 1ec3489..181ac68 100644 --- a/lib/devbase/env/bundle.py +++ b/lib/devbase/env/bundle.py @@ -141,12 +141,18 @@ def _validate_manifest(manifest: Dict, members: Dict[str, bytes]) -> None: expected = entry.get('sha256') if not isinstance(path, str) or not path: raise BundleError(f"manifest.files の path が不正です: {path!r}") - if expected is not None and not isinstance(expected, str): - raise BundleError(f"manifest.files の sha256 が不正です (path={path}): {expected!r}") + 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 and expected != actual: + if expected != actual: raise BundleError( f"sha256 が一致しません (path={path}, expected={expected[:12]}..., " f"actual={actual[:12]}...)" diff --git a/lib/devbase/env/cipher.py b/lib/devbase/env/cipher.py index dd2c225..6cec229 100644 --- a/lib/devbase/env/cipher.py +++ b/lib/devbase/env/cipher.py @@ -150,10 +150,18 @@ def decrypt(data: bytes, def default_recipient_paths() -> List[Path]: - """recipient 省略時に試す既定の公開鍵パス候補""" - return [Path.home() / '.ssh' / 'id_rsa.pub'] + """recipient 省略時に試す既定の公開鍵パス候補 + + ed25519 を優先し、次に rsa を試す。 + """ + ssh = Path.home() / '.ssh' + return [ssh / 'id_ed25519.pub', ssh / 'id_rsa.pub'] def default_identity_paths() -> List[Path]: - """identity 省略時に試す既定の秘密鍵パス候補""" - return [Path.home() / '.ssh' / 'id_rsa'] + """identity 省略時に試す既定の秘密鍵パス候補 + + ed25519 を優先し、次に rsa を試す。 + """ + ssh = Path.home() / '.ssh' + return [ssh / 'id_ed25519', ssh / 'id_rsa'] diff --git a/tests/env/test_bundle.py b/tests/env/test_bundle.py index dc79db4..03fe6d2 100644 --- a/tests/env/test_bundle.py +++ b/tests/env/test_bundle.py @@ -168,6 +168,50 @@ def test_unpack_rejects_invalid_sha256_field(): 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() diff --git a/tests/env/test_cipher.py b/tests/env/test_cipher.py index ee822fd..43f0f33 100644 --- a/tests/env/test_cipher.py +++ b/tests/env/test_cipher.py @@ -71,3 +71,22 @@ def test_recipient_at_file_reference_depth_limit(tmp_path): b.write_text(f"@{a}\n") with pytest.raises(cipher.CipherError, match="深すぎ|循環"): cipher.encrypt(b"x", recipients=[f"@{a}"]) + + +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") From 2c09ae71f603a2805d989e5353e59ceb8f061e5e Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Thu, 21 May 2026 16:52:05 +0900 Subject: [PATCH 06/11] =?UTF-8?q?fix(env):=20round=204=20=E3=83=AC?= =?UTF-8?q?=E3=83=93=E3=83=A5=E3=83=BC=E6=8C=87=E6=91=98=E3=81=AE=E4=BF=AE?= =?UTF-8?q?=E6=AD=A3=20(=E7=95=B0=E5=B8=B8=E7=B3=BB=E3=81=AE=E5=A0=85?= =?UTF-8?q?=E7=89=A2=E5=8C=96)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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)。 --- lib/devbase/env/bundle.py | 5 +++++ lib/devbase/env/cipher.py | 8 +++++++- lib/devbase/env/storage.py | 6 ++++++ tests/env/test_bundle.py | 13 +++++++++++++ tests/env/test_cipher.py | 9 +++++++++ tests/env/test_storage.py | 11 +++++++++++ 6 files changed, 51 insertions(+), 1 deletion(-) diff --git a/lib/devbase/env/bundle.py b/lib/devbase/env/bundle.py index 181ac68..6420da8 100644 --- a/lib/devbase/env/bundle.py +++ b/lib/devbase/env/bundle.py @@ -122,6 +122,11 @@ def unpack(blob: bytes) -> Tuple[Dict, Dict[str, bytes]]: 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 が不正です") diff --git a/lib/devbase/env/cipher.py b/lib/devbase/env/cipher.py index 6cec229..2df1628 100644 --- a/lib/devbase/env/cipher.py +++ b/lib/devbase/env/cipher.py @@ -39,7 +39,13 @@ def _resolve_recipient(spec: str, _depth: int = 0): path = Path(spec[1:]).expanduser() if not path.exists(): raise CipherError(f"recipient ファイルが見つかりません: {path}") - return _resolve_recipient(path.read_text(encoding='utf-8').strip(), _depth + 1) + try: + content = path.read_text(encoding='utf-8') + except UnicodeDecodeError as e: + raise CipherError( + f"recipient ファイルの UTF-8 デコードに失敗しました: {path}: {e}" + ) from e + return _resolve_recipient(content.strip(), _depth + 1) if spec.startswith('age1'): try: diff --git a/lib/devbase/env/storage.py b/lib/devbase/env/storage.py index 29c0dc8..ba515ba 100644 --- a/lib/devbase/env/storage.py +++ b/lib/devbase/env/storage.py @@ -88,6 +88,12 @@ def resolve(uri: str) -> StorageBackend: "(後続 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}://") diff --git a/tests/env/test_bundle.py b/tests/env/test_bundle.py index 03fe6d2..7bf3eff 100644 --- a/tests/env/test_bundle.py +++ b/tests/env/test_bundle.py @@ -232,3 +232,16 @@ def test_unpack_rejects_duplicate_tar_entries(): 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()) diff --git a/tests/env/test_cipher.py b/tests/env/test_cipher.py index 43f0f33..5a6daad 100644 --- a/tests/env/test_cipher.py +++ b/tests/env/test_cipher.py @@ -73,6 +73,15 @@ def test_recipient_at_file_reference_depth_limit(tmp_path): 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_default_recipient_paths_includes_ed25519(): """ed25519 公開鍵が rsa より先に試される""" paths = cipher.default_recipient_paths() diff --git a/tests/env/test_storage.py b/tests/env/test_storage.py index 62f8800..0910bed 100644 --- a/tests/env/test_storage.py +++ b/tests/env/test_storage.py @@ -48,6 +48,17 @@ def test_resolve_rejects_unknown_scheme(): 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" From 776cb8d6582e81a9a9a45a40c6af4c156a813119 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Thu, 21 May 2026 17:07:36 +0900 Subject: [PATCH 07/11] =?UTF-8?q?fix(env):=20=5Fresolve=5Fidentity=20?= =?UTF-8?q?=E3=81=AE=20OSError=20=E3=82=92=20CipherError=20=E3=81=AB?= =?UTF-8?q?=E5=8C=85=E3=82=80=20(round=205)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- lib/devbase/env/cipher.py | 5 ++++- tests/env/test_cipher.py | 19 +++++++++++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/lib/devbase/env/cipher.py b/lib/devbase/env/cipher.py index 2df1628..01d6cc8 100644 --- a/lib/devbase/env/cipher.py +++ b/lib/devbase/env/cipher.py @@ -77,7 +77,10 @@ def _resolve_identity(path_spec: str): if not path.exists(): raise CipherError(f"identity ファイルが見つかりません: {path}") - raw = path.read_bytes() + try: + raw = path.read_bytes() + except OSError as e: + raise CipherError(f"identity ファイルの読み込みに失敗しました ({path}): {e}") from e if raw.strip().startswith(b'AGE-SECRET-KEY-1'): try: diff --git a/tests/env/test_cipher.py b/tests/env/test_cipher.py index 5a6daad..ae01748 100644 --- a/tests/env/test_cipher.py +++ b/tests/env/test_cipher.py @@ -82,6 +82,25 @@ def test_recipient_at_file_reference_rejects_non_utf8(tmp_path): 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_default_recipient_paths_includes_ed25519(): """ed25519 公開鍵が rsa より先に試される""" paths = cipher.default_recipient_paths() From d357e86cfd8b1674e77e850e094557bd435b70f9 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Thu, 21 May 2026 17:19:08 +0900 Subject: [PATCH 08/11] =?UTF-8?q?fix(env):=20round=206=20=E3=83=AC?= =?UTF-8?q?=E3=83=93=E3=83=A5=E3=83=BC=E6=8C=87=E6=91=98=E3=81=AE=E4=BF=AE?= =?UTF-8?q?=E6=AD=A3=20(=E6=B1=BA=E5=AE=9A=E6=80=A7=20+=20=E5=AE=8C?= =?UTF-8?q?=E5=85=A8=E6=80=A7=20+=20=E5=A0=85=E7=89=A2=E6=80=A7)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 を追加 --- lib/devbase/env/bundle.py | 29 ++++++++++++++++++++++----- lib/devbase/env/cipher.py | 17 ++++++++++++++++ tests/env/test_bundle.py | 41 +++++++++++++++++++++++++++++++++++++++ tests/env/test_cipher.py | 33 +++++++++++++++++++++++++++++++ 4 files changed, 115 insertions(+), 5 deletions(-) diff --git a/lib/devbase/env/bundle.py b/lib/devbase/env/bundle.py index 6420da8..7ac1757 100644 --- a/lib/devbase/env/bundle.py +++ b/lib/devbase/env/bundle.py @@ -2,6 +2,7 @@ from __future__ import annotations +import gzip import hashlib import io import tarfile @@ -67,11 +68,16 @@ def pack(entries: Sequence[BundleEntry], allow_unicode=True).encode('utf-8') buf = io.BytesIO() - # mtime=0 で再現性を確保 - with tarfile.open(fileobj=buf, mode='w:gz', format=tarfile.PAX_FORMAT) as tf: - _add_member(tf, MANIFEST_NAME, manifest_bytes) - for entry in entries: - _add_member(tf, entry.arcname, entry.data) + # 再現性を確保: + # - 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() @@ -139,6 +145,8 @@ def _validate_manifest(manifest: Dict, members: Dict[str, bytes]) -> None: 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__}") @@ -162,6 +170,17 @@ def _validate_manifest(manifest: Dict, members: Dict[str, bytes]) -> None: 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, diff --git a/lib/devbase/env/cipher.py b/lib/devbase/env/cipher.py index 01d6cc8..d9b7ae8 100644 --- a/lib/devbase/env/cipher.py +++ b/lib/devbase/env/cipher.py @@ -45,6 +45,10 @@ def _resolve_recipient(spec: str, _depth: int = 0): raise CipherError( f"recipient ファイルの UTF-8 デコードに失敗しました: {path}: {e}" ) from e + except OSError as e: + raise CipherError( + f"recipient ファイルの読み込みに失敗しました ({path}): {e}" + ) from e return _resolve_recipient(content.strip(), _depth + 1) if spec.startswith('age1'): @@ -82,6 +86,17 @@ def _resolve_identity(path_spec: str): 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() @@ -92,6 +107,8 @@ def _resolve_identity(path_spec: str): 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: diff --git a/tests/env/test_bundle.py b/tests/env/test_bundle.py index 7bf3eff..52e807a 100644 --- a/tests/env/test_bundle.py +++ b/tests/env/test_bundle.py @@ -245,3 +245,44 @@ def test_unpack_rejects_non_mapping_manifest(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_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 index ae01748..c09e0b4 100644 --- a/tests/env/test_cipher.py +++ b/tests/env/test_cipher.py @@ -101,6 +101,39 @@ def fake_read_bytes(self): cipher.decrypt(b"x", identities=[str(id_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() From c07b1a7620ba48a7c95e94c69f77e733a2097222 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Thu, 21 May 2026 17:21:01 +0900 Subject: [PATCH 09/11] =?UTF-8?q?fix(env):=20@PATH=20=E5=8F=82=E7=85=A7?= =?UTF-8?q?=E3=83=95=E3=82=A1=E3=82=A4=E3=83=AB=E3=81=AE=E3=82=B3=E3=83=A1?= =?UTF-8?q?=E3=83=B3=E3=83=88=E3=83=BB=E7=A9=BA=E8=A1=8C=E3=82=92=E3=82=B9?= =?UTF-8?q?=E3=82=AD=E3=83=83=E3=83=97=E3=81=99=E3=82=8B=20(round=206=20?= =?UTF-8?q?=E8=BF=BD=E5=8A=A0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit recipient ファイルにコメント (# 始まり) や空行が混在していても扱えるよう、 有効な最初の行のみを採用する。テストも追加。 --- lib/devbase/env/cipher.py | 10 +++++++++- tests/env/test_cipher.py | 25 +++++++++++++++++++++++++ 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/lib/devbase/env/cipher.py b/lib/devbase/env/cipher.py index d9b7ae8..e0d8540 100644 --- a/lib/devbase/env/cipher.py +++ b/lib/devbase/env/cipher.py @@ -49,7 +49,15 @@ def _resolve_recipient(spec: str, _depth: int = 0): raise CipherError( f"recipient ファイルの読み込みに失敗しました ({path}): {e}" ) from e - return _resolve_recipient(content.strip(), _depth + 1) + # ファイル中に複数行 / コメント / 空行が混在していても扱えるよう、 + # 空行と '#' で始まるコメント行を除いた最初の有効行を採用する。 + 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: diff --git a/tests/env/test_cipher.py b/tests/env/test_cipher.py index c09e0b4..2027a02 100644 --- a/tests/env/test_cipher.py +++ b/tests/env/test_cipher.py @@ -101,6 +101,31 @@ def fake_read_bytes(self): 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" From 521c28d05946f1fa3bf0338540450c1b8b8a35c3 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Thu, 21 May 2026 19:48:39 +0900 Subject: [PATCH 10/11] =?UTF-8?q?fix(env):=20round=201=20=E3=83=AC?= =?UTF-8?q?=E3=83=93=E3=83=A5=E3=83=BC=E6=8C=87=E6=91=98=E3=81=AE=E4=BF=AE?= =?UTF-8?q?=E6=AD=A3=20(TOCTOU=20+=20BundleError=20=E7=B5=B1=E4=B8=80=20+?= =?UTF-8?q?=20prefix=20=E4=BA=92=E6=8F=9B=20+=20completion)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- etc/_devbase | 13 +++++++ etc/devbase-completion.bash | 7 +++- lib/devbase/cli.py | 28 ++++++++++++--- lib/devbase/env/bundle.py | 43 ++++++++++++++-------- lib/devbase/env/storage.py | 47 ++++++++++++++++++------ tests/cli/test_prefix_resolution.py | 50 ++++++++++++++++++++++++++ tests/env/test_bundle.py | 37 +++++++++++++++++++ tests/env/test_storage.py | 56 +++++++++++++++++++++++++++++ 8 files changed, 250 insertions(+), 31 deletions(-) create mode 100644 tests/cli/test_prefix_resolution.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 511fd22..a9088dc 100644 --- a/lib/devbase/cli.py +++ b/lib/devbase/cli.py @@ -41,6 +41,15 @@ ('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.""" @@ -280,14 +289,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(): @@ -303,7 +320,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/env/bundle.py b/lib/devbase/env/bundle.py index 7ac1757..d9b9399 100644 --- a/lib/devbase/env/bundle.py +++ b/lib/devbase/env/bundle.py @@ -99,20 +99,29 @@ def unpack(blob: bytes) -> Tuple[Dict, Dict[str, bytes]]: 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] = {} - 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() + 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: @@ -154,6 +163,9 @@ def _validate_manifest(manifest: Dict, members: Dict[str, bytes]) -> None: 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() ): @@ -204,7 +216,8 @@ def make_entries_from_disk(devbase_root, if include_global: global_env = devbase_root / '.env' - if global_env.exists(): + # is_file() でディレクトリ等を除外し、IsADirectoryError 等の例外を防ぐ + if global_env.is_file(): entries.append(BundleEntry( arcname='env/global.env', origin='$DEVBASE_ROOT/.env', @@ -213,7 +226,7 @@ def make_entries_from_disk(devbase_root, if include_metadata: sources_yml = devbase_root / '.env.sources.yml' - if sources_yml.exists(): + if sources_yml.is_file(): entries.append(BundleEntry( arcname='env/sources.yml', origin='$DEVBASE_ROOT/.env.sources.yml', @@ -233,7 +246,7 @@ def make_entries_from_disk(devbase_root, if included is not None and name not in included: continue env_path = proj_dir / '.env' - if env_path.exists(): + if env_path.is_file(): entries.append(BundleEntry( arcname=f'env/projects/{name}/.env', origin=f'$DEVBASE_ROOT/projects/{name}/.env', diff --git a/lib/devbase/env/storage.py b/lib/devbase/env/storage.py index ba515ba..114d0b4 100644 --- a/lib/devbase/env/storage.py +++ b/lib/devbase/env/storage.py @@ -42,22 +42,49 @@ class LocalBackend: def write_bytes(self, dest: str, data: bytes) -> None: path = _to_local_path(dest) - if path.parent and not path.parent.exists(): - path.parent.mkdir(parents=True, exist_ok=True) - # 0600 で書き出すため open(..., 'wb') 後に chmod する - with open(path, 'wb') as f: - f.write(data) try: - os.chmod(path, 0o600) - except OSError: - # Windows 等で chmod が無効でも書き込み自体は完了させる - pass + 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}") - return path.read_bytes() + try: + return path.read_bytes() + except OSError as e: + raise StorageError(f"読み込みに失敗しました ({path}): {e}") from e class StdioBackend: 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/test_bundle.py b/tests/env/test_bundle.py index 52e807a..e6458e6 100644 --- a/tests/env/test_bundle.py +++ b/tests/env/test_bundle.py @@ -262,6 +262,43 @@ def test_pack_is_deterministic(): 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 diff --git a/tests/env/test_storage.py b/tests/env/test_storage.py index 0910bed..385e5b0 100644 --- a/tests/env/test_storage.py +++ b/tests/env/test_storage.py @@ -89,3 +89,59 @@ class FakeStdout: 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)) From 01b2208bd119e9288a87c06321a39aa770eb9394 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Thu, 21 May 2026 20:08:13 +0900 Subject: [PATCH 11/11] =?UTF-8?q?fix(env):=20round=202=20advisory=20?= =?UTF-8?q?=E3=83=AC=E3=83=93=E3=83=A5=E3=83=BC=E6=8C=87=E6=91=98=E3=81=AE?= =?UTF-8?q?=E4=BF=AE=E6=AD=A3=20(docstring=20/=20help=20/=20stdin=20prompt?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- lib/devbase/cli.py | 3 ++- lib/devbase/env/io_export.py | 13 +++++++++++-- tests/cli/test_env_export.py | 25 ++++++++++++++++++++++++- 3 files changed, 37 insertions(+), 4 deletions(-) diff --git a/lib/devbase/cli.py b/lib/devbase/cli.py index a9088dc..1da2160 100644 --- a/lib/devbase/cli.py +++ b/lib/devbase/cli.py @@ -139,7 +139,8 @@ def _add_env_parser(subparsers): help=("age / OpenSSH public key (repeatable). " "Formats: 'age1...', 'ssh-ed25519 AAAA...', 'ssh-rsa AAAA...', " "'@PATH' for file reference. " - "Default: ~/.ssh/id_rsa.pub if present")) + "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', diff --git a/lib/devbase/env/io_export.py b/lib/devbase/env/io_export.py index bbf1eca..2da2c2c 100644 --- a/lib/devbase/env/io_export.py +++ b/lib/devbase/env/io_export.py @@ -46,7 +46,11 @@ def _default_dest(force_unencrypted: bool) -> str: def _resolve_recipients(specs: Sequence[str]) -> List[str]: - """recipient 指定の解決。空なら既定鍵 (~/.ssh/id_rsa.pub) を試みる""" + """recipient 指定の解決。 + + 空なら既定鍵を優先順 (``~/.ssh/id_ed25519.pub`` → ``~/.ssh/id_rsa.pub``) で + 探索し、最初に見つかったものを利用する。 + """ if specs: return list(specs) for path in _cipher.default_recipient_paths(): @@ -66,6 +70,10 @@ def _read_passphrase(opts: ExportOptions) -> Optional[str]: 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 からパスフレーズを読み取れませんでした") @@ -148,7 +156,8 @@ def export(devbase_root: Path, opts: ExportOptions) -> int: " --passphrase-env VAR 環境変数からパスフレーズ取得\n" " --passphrase-stdin stdin の最初の行をパスフレーズとして使用\n" " --force-unencrypted 平文 tar.gz として書き出す (機密キー検知時は警告)\n" - " ~/.ssh/id_rsa.pub があれば --recipient 省略時の既定として使用されます" + " ~/.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)) diff --git a/tests/cli/test_env_export.py b/tests/cli/test_env_export.py index d00abea..18b89fe 100644 --- a/tests/cli/test_env_export.py +++ b/tests/cli/test_env_export.py @@ -10,7 +10,7 @@ import pytest from devbase.env import bundle, cipher -from devbase.env.io_export import ExportOptions, ExportError, export +from devbase.env.io_export import ExportOptions, ExportError, _read_passphrase, export @pytest.fixture @@ -93,6 +93,29 @@ 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") + 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")