diff --git a/etc/_devbase b/etc/_devbase index 9df7250..b7944dd 100644 --- a/etc/_devbase +++ b/etc/_devbase @@ -73,6 +73,8 @@ _devbase() { 'delete:Delete a variable' 'edit:Open .env in editor' 'project:Setup project-specific variables' + 'export:Export .env files as an encrypted bundle (age)' + 'import:Import .env bundle (age decrypt + merge)' ) plugin_subcommands=( @@ -150,6 +152,36 @@ _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]' + ;; + import) + _arguments \ + '1:source:_files' \ + '--merge[Merge strategy]:mode:(keep-existing prefer-incoming)' \ + '--replace-keys[Replace only these keys (comma-separated)]:keys:' \ + '--replace[Replace existing files entirely]' \ + '--dry-run[Preview changes without writing]' \ + '*--identity[age / OpenSSH private key file (repeatable)]:file:_files' \ + '--passphrase-env[Read passphrase from env var]:var:' \ + '--passphrase-stdin[Read passphrase from stdin]' \ + '*--include-project[Limit to specified project (repeatable)]:name:' \ + '*--exclude-project[Exclude project (repeatable)]:name:' \ + '--no-global[Do not import $DEVBASE_ROOT/.env]' \ + '--no-metadata[Do not import $DEVBASE_ROOT/.env.sources.yml]' \ + '--merge-metadata[Add only new sources entries from bundle]' \ + '--backup-dir[Override backup directory]:dir:_files -/' \ + '--keep-last[Keep only the last N backup directories]:n:' + ;; *) _describe -t env-commands 'env command' env_subcommands ;; diff --git a/etc/devbase-completion.bash b/etc/devbase-completion.bash index cc62a91..81f6431 100644 --- a/etc/devbase-completion.bash +++ b/etc/devbase-completion.bash @@ -12,7 +12,7 @@ _devbase_completions() { local commands="init status shell-rc container ct env plugin pl snapshot ss up down login build ps help" local container_subcommands="up down ps login logs scale build" - local env_subcommands="init sync list set get delete edit project" + local env_subcommands="init sync list set get delete edit project export import" local plugin_subcommands="list install uninstall update info sync repo" local repo_subcommands="add remove list refresh" local snapshot_subcommands="create list restore copy delete rotate" @@ -81,6 +81,16 @@ _devbase_completions() { COMPREPLY=($(compgen -W "--project -p" -- "$cur")) fi ;; + export) + if [[ "$cur" == -* ]]; then + COMPREPLY=($(compgen -W "--include-project --exclude-project --no-global --no-metadata --recipient --passphrase-env --passphrase-stdin --force-unencrypted" -- "$cur")) + fi + ;; + import) + if [[ "$cur" == -* ]]; then + COMPREPLY=($(compgen -W "--merge --replace-keys --replace --dry-run --identity --passphrase-env --passphrase-stdin --include-project --exclude-project --no-global --no-metadata --merge-metadata --backup-dir --keep-last" -- "$cur")) + fi + ;; esac fi # plugin subcommand arguments diff --git a/lib/devbase/cli.py b/lib/devbase/cli.py index 3201679..d519db4 100644 --- a/lib/devbase/cli.py +++ b/lib/devbase/cli.py @@ -36,11 +36,23 @@ # Subcommand map for prefix resolution: {(aliases...): [subcmds]} SUBCMD_MAP = { ('container', 'ct'): ['up', 'down', 'ps', 'login', 'logs', 'scale', 'build'], - ('env',): ['init', 'sync', 'list', 'set', 'get', 'delete', 'edit', 'project'], + ('env',): ['init', 'sync', 'list', 'set', 'get', 'delete', 'edit', 'project', 'export', 'import'], ('plugin', 'pl'): ['list', 'install', 'uninstall', 'update', 'info', 'sync', 'repo'], ('snapshot', 'ss'): ['create', 'list', 'restore', 'copy', 'delete', 'rotate'], } +# 後方互換: prefix が複数候補にマッチする場合に、特定の入力を特定のサブコマンドに +# 優先的に解決させる。例えば `devbase env e` は従来 `edit` のみに解決されていたが、 +# `export` 追加後は ambiguous になるため、既存ショートカットを維持するために維持先を明示する。 +SUBCMD_PREFIX_PREFERENCES = { + ('env',): { + 'e': 'edit', + # `import` 追加で `i` が `init` / `import` の両方にマッチして ambiguous に + # なるため、既存ショートカット (`devbase env i` → `init`) を維持する。 + 'i': 'init', + }, +} + def _require_devbase_root() -> Path: """Get DEVBASE_ROOT from environment, exiting if not set.""" @@ -109,6 +121,86 @@ def _add_env_parser(subparsers): env_sub.add_parser('edit', help='Open .env in editor') env_sub.add_parser('project', help='Setup project-specific variables') + env_export = env_sub.add_parser( + 'export', + help='Export .env files as an encrypted bundle (age)', + ) + env_export.add_argument('dest', nargs='?', default=None, + help="Output path (default: ./devbase-env-.dbenv, '-' for stdout)") + env_export.add_argument('--include-project', action='append', default=None, + metavar='NAME', dest='include_projects', + help='Limit to specified project (repeatable)') + env_export.add_argument('--exclude-project', action='append', default=[], + metavar='NAME', dest='exclude_projects', + help='Exclude project (repeatable)') + env_export.add_argument('--no-global', action='store_true', + help='Exclude $DEVBASE_ROOT/.env') + env_export.add_argument('--no-metadata', action='store_true', + help='Exclude $DEVBASE_ROOT/.env.sources.yml') + env_export.add_argument('--recipient', action='append', default=[], + metavar='KEY', dest='recipients', + help=("age / OpenSSH public key (repeatable). " + "Formats: 'age1...', 'ssh-ed25519 AAAA...', 'ssh-rsa AAAA...', " + "'@PATH' for file reference. " + "Default: ~/.ssh/id_ed25519.pub, then ~/.ssh/id_rsa.pub " + "(first existing one)")) + env_export.add_argument('--passphrase-env', metavar='VAR', default=None, + help='Read passphrase from environment variable VAR') + env_export.add_argument('--passphrase-stdin', action='store_true', + help='Read passphrase from the first line of stdin') + env_export.add_argument('--force-unencrypted', action='store_true', + help='Write as plaintext tar.gz (rejected by default; ' + 'warns when sensitive keys are detected)') + + env_import = env_sub.add_parser( + 'import', + help='Import .env files from a bundle (age-encrypted or plaintext tar.gz)', + ) + env_import.add_argument('source', + help="Bundle path or '-' for stdin") + env_import.add_argument('--merge', choices=['keep-existing', 'prefer-incoming'], + default='keep-existing', + help=("Key-level merge mode. keep-existing (default) keeps " + "existing keys and adds new ones; prefer-incoming " + "overwrites with bundle values")) + env_import.add_argument('--replace-keys', metavar='KEYS', default='', + help=("Comma-separated keys to force-overwrite from bundle " + "(other keys behave like keep-existing). " + "Cannot be combined with --replace")) + env_import.add_argument('--replace', action='store_true', + help='Replace each target .env file wholesale (backup is taken)') + env_import.add_argument('--dry-run', action='store_true', + help='Show planned diff without writing') + env_import.add_argument('--identity', action='append', default=[], + metavar='FILE', dest='identities', + help=("age / OpenSSH private key file (repeatable). " + "Default: ~/.ssh/id_ed25519, then ~/.ssh/id_rsa " + "(first existing one)")) + env_import.add_argument('--passphrase-env', metavar='VAR', default=None, + help='Read passphrase from environment variable VAR') + env_import.add_argument('--passphrase-stdin', action='store_true', + help='Read passphrase from the first line of stdin') + env_import.add_argument('--include-project', action='append', default=None, + metavar='NAME', dest='include_projects', + help='Limit to specified project (repeatable)') + env_import.add_argument('--exclude-project', action='append', default=[], + metavar='NAME', dest='exclude_projects', + help='Exclude project (repeatable)') + env_import.add_argument('--no-global', action='store_true', + help='Do not import $DEVBASE_ROOT/.env') + env_import.add_argument('--no-metadata', action='store_true', + help='Do not import $DEVBASE_ROOT/.env.sources.yml ' + '(default behavior is reference-only copy; this fully ignores it)') + env_import.add_argument('--merge-metadata', action='store_true', + help='Merge new source entries into existing .env.sources.yml ' + '(machine-specific fields are preserved as-is from bundle; ' + 'run `devbase env sync` after import to refresh)') + env_import.add_argument('--backup-dir', metavar='DIR', default=None, + help='Override backup directory ' + '(default: $DEVBASE_ROOT/backups/env-import/)') + env_import.add_argument('--keep-last', type=int, default=10, metavar='N', + help='Keep only the last N backup directories (default: 10, 0 to disable)') + def _add_plugin_parser(subparsers): """Plugin group parser""" @@ -250,14 +342,22 @@ def _create_parser(): return parser -def _resolve_prefix(input_cmd, candidates): +def _resolve_prefix(input_cmd, candidates, preferences=None): """Resolve an abbreviated command to its full name via unique prefix matching. - Returns the full command name if exactly one candidate matches, - otherwise returns the input as-is (ambiguous or no match). + Returns the full command name if exactly one candidate matches. + If ambiguous, falls back to `preferences[input_cmd]` (if provided) to keep + backward compatibility with previously-unique abbreviations. + Otherwise returns the input as-is. """ matches = [c for c in candidates if c.startswith(input_cmd)] - return matches[0] if len(matches) == 1 else input_cmd + if len(matches) == 1: + return matches[0] + if preferences and input_cmd in preferences: + preferred = preferences[input_cmd] + if preferred in matches: + return preferred + return input_cmd def _expand_argv(): @@ -273,7 +373,8 @@ def _expand_argv(): cmd = sys.argv[1] for aliases, subcmds in SUBCMD_MAP.items(): if cmd in aliases: - sys.argv[2] = _resolve_prefix(sys.argv[2], subcmds) + preferences = SUBCMD_PREFIX_PREFERENCES.get(aliases) + sys.argv[2] = _resolve_prefix(sys.argv[2], subcmds, preferences) break # plugin repo sub-subcommand diff --git a/lib/devbase/commands/env.py b/lib/devbase/commands/env.py index 87eba3e..3b43598 100644 --- a/lib/devbase/commands/env.py +++ b/lib/devbase/commands/env.py @@ -33,6 +33,8 @@ def cmd_env(devbase_root: Path, args) -> int: 'delete': lambda: cmd_env_delete(devbase_root, getattr(args, 'key', '')), 'edit': lambda: cmd_env_edit(devbase_root), 'project': lambda: cmd_env_project(devbase_root), + 'export': lambda: cmd_env_export(devbase_root, args), + 'import': lambda: cmd_env_import(devbase_root, args), } handler = handlers.get(subcmd) @@ -382,6 +384,51 @@ 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 cmd_env_import(devbase_root: Path, args) -> int: + """devbase env import""" + from devbase.env.io_import import ImportOptions, import_bundle + + replace_keys_arg = getattr(args, 'replace_keys', '') or '' + replace_keys = [k.strip() for k in replace_keys_arg.split(',') if k.strip()] + + opts = ImportOptions( + source=getattr(args, 'source'), + merge=getattr(args, 'merge', 'keep-existing'), + replace_keys=replace_keys, + replace=getattr(args, 'replace', False), + dry_run=getattr(args, 'dry_run', False), + identities=list(getattr(args, 'identities', []) or []), + passphrase_env=getattr(args, 'passphrase_env', None), + passphrase_stdin=getattr(args, 'passphrase_stdin', False), + include_projects=getattr(args, 'include_projects', None), + exclude_projects=list(getattr(args, 'exclude_projects', []) or []), + include_global=not getattr(args, 'no_global', False), + include_metadata=not getattr(args, 'no_metadata', False), + merge_metadata=getattr(args, 'merge_metadata', False), + backup_dir=getattr(args, 'backup_dir', None), + keep_last=getattr(args, 'keep_last', 10), + ) + return import_bundle(devbase_root, opts) + + def _update_source_metadata(devbase_root: Path, env_file: EnvFile) -> None: """ソースメタデータを更新する""" sources = SourcesManager(devbase_root) diff --git a/lib/devbase/env/bundle.py b/lib/devbase/env/bundle.py new file mode 100644 index 0000000..d9b9399 --- /dev/null +++ b/lib/devbase/env/bundle.py @@ -0,0 +1,256 @@ +"""env export/import バンドル (tar.gz + manifest.yml) の構築・展開""" + +from __future__ import annotations + +import gzip +import hashlib +import io +import tarfile +from dataclasses import dataclass +from datetime import datetime, timezone +from typing import Dict, List, Optional, Sequence, Tuple + +import yaml + +from devbase.errors import DevbaseError + +try: + from devbase import __version__ as _DEVBASE_VERSION +except ImportError: + _DEVBASE_VERSION = "unknown" + +MANIFEST_NAME = "manifest.yml" +SUPPORTED_MANIFEST_VERSION = 1 + + +class BundleError(DevbaseError): + """バンドル構築・展開エラー""" + + +@dataclass(frozen=True) +class BundleEntry: + """バンドル内ファイル 1 件""" + arcname: str # tar 内パス (例: 'env/global.env') + origin: str # 元ファイルの DEVBASE_ROOT 相対表記 (例: '$DEVBASE_ROOT/.env') + data: bytes + + +def _sha256(data: bytes) -> str: + return hashlib.sha256(data).hexdigest() + + +def _local_now_iso() -> str: + return datetime.now(timezone.utc).astimezone().isoformat(timespec='seconds') + + +def build_manifest(entries: Sequence[BundleEntry], + devbase_version: str = _DEVBASE_VERSION, + created_at: Optional[str] = None) -> Dict: + """manifest.yml の dict 表現を生成する""" + return { + 'version': SUPPORTED_MANIFEST_VERSION, + 'created_at': created_at or _local_now_iso(), + 'devbase_version': devbase_version, + 'files': [ + {'path': e.arcname, 'sha256': _sha256(e.data), 'origin': e.origin} + for e in entries + ], + } + + +def pack(entries: Sequence[BundleEntry], + devbase_version: str = _DEVBASE_VERSION, + created_at: Optional[str] = None) -> bytes: + """エントリ群を manifest.yml 付きの tar.gz バイト列にまとめる""" + manifest = build_manifest(entries, devbase_version=devbase_version, + created_at=created_at) + manifest_bytes = yaml.safe_dump(manifest, sort_keys=False, + allow_unicode=True).encode('utf-8') + + buf = io.BytesIO() + # 再現性を確保: + # - tarfile の mode='w:gz' は gzip ヘッダに現在時刻を埋め込むため出力が + # 非決定的になる。gzip.GzipFile を mtime=0 で明示的に作成し、その上に + # tarfile を mode='w' で書き出すことで完全に決定的なバイト列にする。 + # - PAX_FORMAT を指定して各エントリの mtime=0 等のメタも安定させる。 + with gzip.GzipFile(fileobj=buf, mode='wb', mtime=0) as gz: + with tarfile.open(fileobj=gz, mode='w', format=tarfile.PAX_FORMAT) as tf: + _add_member(tf, MANIFEST_NAME, manifest_bytes) + for entry in entries: + _add_member(tf, entry.arcname, entry.data) + return buf.getvalue() + + +def _add_member(tf: tarfile.TarFile, arcname: str, data: bytes) -> None: + info = tarfile.TarInfo(name=arcname) + info.size = len(data) + info.mtime = 0 + info.mode = 0o600 + tf.addfile(info, io.BytesIO(data)) + + +def unpack(blob: bytes) -> Tuple[Dict, Dict[str, bytes]]: + """tar.gz バイト列から (manifest, {arcname: bytes}) を取り出す + + sha256 / version の検証も行う。 + """ + buf = io.BytesIO(blob) + try: + tf = tarfile.open(fileobj=buf, mode='r:gz') + except tarfile.TarError as e: + raise BundleError(f"tar.gz の読み込みに失敗しました: {e}") from e + except OSError as e: + raise BundleError(f"tar.gz の読み込みに失敗しました: {e}") from e + + members: Dict[str, bytes] = {} + try: + with tf: + for info in tf.getmembers(): + if not info.isfile(): + continue + if info.name.startswith('/') or '..' in info.name.split('/'): + raise BundleError(f"不正なパスを含んでいます: {info.name}") + if info.name in members: + raise BundleError(f"重複エントリを検出しました: {info.name}") + f = tf.extractfile(info) + if f is None: + continue + members[info.name] = f.read() + except BundleError: + raise + except tarfile.TarError as e: + raise BundleError(f"tar の展開に失敗しました: {e}") from e + except OSError as e: + raise BundleError(f"tar の展開に失敗しました: {e}") from e + + manifest_bytes = members.pop(MANIFEST_NAME, None) + if manifest_bytes is None: + raise BundleError(f"{MANIFEST_NAME} がバンドルに含まれていません") + + try: + manifest = yaml.safe_load(manifest_bytes) or {} + except yaml.YAMLError as e: + raise BundleError(f"{MANIFEST_NAME} のパースに失敗しました: {e}") from e + + _validate_manifest(manifest, members) + return manifest, members + + +def _validate_manifest(manifest: Dict, members: Dict[str, bytes]) -> None: + if not isinstance(manifest, dict): + raise BundleError( + f"{MANIFEST_NAME} の top-level が mapping ではありません " + f"(type={type(manifest).__name__})" + ) + version = manifest.get('version') + if not isinstance(version, int): + raise BundleError("manifest.version が不正です") + if version > SUPPORTED_MANIFEST_VERSION: + raise BundleError( + f"manifest.version={version} はこの devbase ではサポートされていません " + f"(対応最大={SUPPORTED_MANIFEST_VERSION})。devbase 本体を更新してください" + ) + + files = manifest.get('files') or [] + if not isinstance(files, list): + raise BundleError("manifest.files が list ではありません") + + manifest_paths: set = set() + for entry in files: + if not isinstance(entry, dict): + raise BundleError(f"manifest.files の要素が dict ではありません: {type(entry).__name__}") + path = entry.get('path') + expected = entry.get('sha256') + if not isinstance(path, str) or not path: + raise BundleError(f"manifest.files の path が不正です: {path!r}") + if path in manifest_paths: + # 重複 path は origin/metadata の解釈が曖昧になるため拒否する + raise BundleError(f"manifest.files に同じ path が重複しています: {path}") + if not isinstance(expected, str) or len(expected) != 64 or not all( + c in '0123456789abcdef' for c in expected.lower() + ): + raise BundleError( + f"manifest.files の sha256 が不正です (path={path}): " + f"64文字の16進文字列が必要です" + ) + expected = expected.lower() + if path not in members: + raise BundleError(f"manifest に記載されたファイルが見つかりません: {path}") + actual = _sha256(members[path]) + if expected != actual: + raise BundleError( + f"sha256 が一致しません (path={path}, expected={expected[:12]}..., " + f"actual={actual[:12]}...)" + ) + manifest_paths.add(path) + + # tar 内のファイルセットと manifest のファイルセットの完全一致を検証する。 + # manifest に記載のないファイルが tar に混入していても検知できるようにする + # (バンドル内未知ファイルの混入はセキュリティ・整合性リスクのため拒否)。 + unknown = sorted(set(members) - manifest_paths) + if unknown: + raise BundleError( + "manifest に記載のないファイルがバンドルに含まれています: " + + ", ".join(unknown) + ) + + +def make_entries_from_disk(devbase_root, + include_global: bool = True, + include_metadata: bool = True, + include_projects: Optional[Sequence[str]] = None, + exclude_projects: Sequence[str] = ()) -> List[BundleEntry]: + """DEVBASE_ROOT 配下から export 対象を収集して BundleEntry のリストを返す + + Args: + devbase_root: Path + include_global: True なら $DEVBASE_ROOT/.env を含める + include_metadata: True なら $DEVBASE_ROOT/.env.sources.yml を含める + include_projects: 指定があればこのプロジェクト名のみを対象 + exclude_projects: 除外するプロジェクト名 + """ + from pathlib import Path + + devbase_root = Path(devbase_root) + entries: List[BundleEntry] = [] + + if include_global: + global_env = devbase_root / '.env' + # is_file() でディレクトリ等を除外し、IsADirectoryError 等の例外を防ぐ + if global_env.is_file(): + entries.append(BundleEntry( + arcname='env/global.env', + origin='$DEVBASE_ROOT/.env', + data=global_env.read_bytes(), + )) + + if include_metadata: + sources_yml = devbase_root / '.env.sources.yml' + if sources_yml.is_file(): + entries.append(BundleEntry( + arcname='env/sources.yml', + origin='$DEVBASE_ROOT/.env.sources.yml', + data=sources_yml.read_bytes(), + )) + + projects_dir = devbase_root / 'projects' + if projects_dir.is_dir(): + excluded = set(exclude_projects) + included = set(include_projects) if include_projects else None + + candidates = sorted(p for p in projects_dir.iterdir() if p.is_dir()) + for proj_dir in candidates: + name = proj_dir.name + if name in excluded: + continue + if included is not None and name not in included: + continue + env_path = proj_dir / '.env' + if env_path.is_file(): + entries.append(BundleEntry( + arcname=f'env/projects/{name}/.env', + origin=f'$DEVBASE_ROOT/projects/{name}/.env', + data=env_path.read_bytes(), + )) + + return entries diff --git a/lib/devbase/env/cipher.py b/lib/devbase/env/cipher.py new file mode 100644 index 0000000..e0d8540 --- /dev/null +++ b/lib/devbase/env/cipher.py @@ -0,0 +1,201 @@ +"""age (pyrage) を用いた env バンドルの暗号化・復号""" + +from __future__ import annotations + +from pathlib import Path +from typing import List, Optional, Sequence + +import pyrage + +from devbase.errors import DevbaseError + + +class CipherError(DevbaseError): + """暗号化・復号エラー""" + + +_MAX_RECIPIENT_REF_DEPTH = 5 + + +def _resolve_recipient(spec: str, _depth: int = 0): + """recipient 仕様文字列を pyrage Recipient に解決する + + 形式: + 'age1...' -> X25519 公開鍵 + 'ssh-ed25519 AAAA...' -> OpenSSH ed25519 公開鍵 + 'ssh-rsa AAAA...' -> OpenSSH RSA 公開鍵 + '@PATH' -> ファイル参照 (中身を再帰的に解釈, 深さ上限あり) + """ + spec = spec.strip() + if not spec: + raise CipherError("recipient が空です") + + if spec.startswith('@'): + if _depth >= _MAX_RECIPIENT_REF_DEPTH: + raise CipherError( + f"recipient の @PATH 参照が深すぎます (上限={_MAX_RECIPIENT_REF_DEPTH})。" + "循環参照の可能性があります" + ) + path = Path(spec[1:]).expanduser() + if not path.exists(): + raise CipherError(f"recipient ファイルが見つかりません: {path}") + try: + content = path.read_text(encoding='utf-8') + except UnicodeDecodeError as e: + raise CipherError( + f"recipient ファイルの UTF-8 デコードに失敗しました: {path}: {e}" + ) from e + except OSError as e: + raise CipherError( + f"recipient ファイルの読み込みに失敗しました ({path}): {e}" + ) from e + # ファイル中に複数行 / コメント / 空行が混在していても扱えるよう、 + # 空行と '#' で始まるコメント行を除いた最初の有効行を採用する。 + valid = [ + line.strip() for line in content.splitlines() + if line.strip() and not line.strip().startswith('#') + ] + if not valid: + raise CipherError(f"recipient ファイルに有効な行がありません: {path}") + return _resolve_recipient(valid[0], _depth + 1) + + if spec.startswith('age1'): + try: + return pyrage.x25519.Recipient.from_str(spec) + except Exception as e: + raise CipherError(f"age 公開鍵の解釈に失敗しました: {e}") from e + + if spec.startswith('ssh-ed25519 ') or spec.startswith('ssh-rsa '): + try: + return pyrage.ssh.Recipient.from_str(spec) + except Exception as e: + raise CipherError(f"OpenSSH 公開鍵の解釈に失敗しました: {e}") from e + + if spec.startswith('ssh-'): + raise CipherError( + f"age は ssh-ed25519 / ssh-rsa のみ対応です (入力: {spec.split()[0]})。" + "ssh-ecdsa / ssh-dss などは `age-keygen` で age 専用鍵を生成してください" + ) + + raise CipherError( + f"recipient の形式を判別できません: {spec[:32]!r}... " + "(対応形式: age1... / ssh-ed25519 ... / ssh-rsa ... / @PATH)" + ) + + +def _resolve_identity(path_spec: str): + """秘密鍵ファイルパスを pyrage Identity に解決する""" + path = Path(path_spec).expanduser() + if not path.exists(): + raise CipherError(f"identity ファイルが見つかりません: {path}") + + try: + raw = path.read_bytes() + except OSError as e: + raise CipherError(f"identity ファイルの読み込みに失敗しました ({path}): {e}") from e + + # OpenSSH 秘密鍵は PEM 風の決まったヘッダを持つため、age 鍵より先に + # ヘッダで判別する。これにより鍵形式判別が明示的になり、将来の鍵形式 + # 追加時にも分岐を増やすだけで済む。 + if b'-----BEGIN OPENSSH PRIVATE KEY-----' in raw: + try: + return pyrage.ssh.Identity.from_buffer(raw) + except Exception as e: + raise CipherError( + f"OpenSSH 秘密鍵の解釈に失敗しました ({path}): {e}" + ) from e + + if raw.strip().startswith(b'AGE-SECRET-KEY-1'): + try: + text = raw.decode('utf-8').strip() + except UnicodeDecodeError as e: + raise CipherError(f"age 秘密鍵が UTF-8 でデコードできません ({path}): {e}") from e + try: + return pyrage.x25519.Identity.from_str(text) + except Exception as e: + raise CipherError(f"age 秘密鍵の解釈に失敗しました ({path}): {e}") from e + + # ヘッダから判別できなかった場合のフォールバック。OpenSSH 互換の他形式 + # (rsa 以外の PEM など) を pyrage に任せて受け付ける。 + try: + return pyrage.ssh.Identity.from_buffer(raw) + except Exception as e: + raise CipherError( + f"秘密鍵の解釈に失敗しました ({path}): {e}\n" + "対応形式: AGE-SECRET-KEY-1... / OpenSSH (ed25519, rsa)" + ) from e + + +def encrypt(data: bytes, + recipients: Sequence[str] = (), + passphrase: Optional[str] = None) -> bytes: + """data を age で暗号化する + + recipients と passphrase のどちらか一方のみ指定する。両方指定はエラー。 + """ + if passphrase and recipients: + raise CipherError("recipient と passphrase は同時に指定できません") + + if passphrase is not None: + if not passphrase: + raise CipherError("passphrase が空です") + try: + return pyrage.passphrase.encrypt(data, passphrase) + except Exception as e: + raise CipherError(f"passphrase 暗号化に失敗しました: {e}") from e + + if not recipients: + raise CipherError("recipient または passphrase を指定してください") + + resolved = [_resolve_recipient(r) for r in recipients] + try: + return pyrage.encrypt(data, resolved) + except Exception as e: + raise CipherError(f"recipient 暗号化に失敗しました: {e}") from e + + +def decrypt(data: bytes, + identities: Sequence[str] = (), + passphrase: Optional[str] = None) -> bytes: + """age 暗号化済みデータを復号する""" + if passphrase and identities: + raise CipherError("identity と passphrase は同時に指定できません") + + if passphrase is not None: + if not passphrase: + raise CipherError("passphrase が空です") + try: + return pyrage.passphrase.decrypt(data, passphrase) + except Exception as e: + raise CipherError( + "passphrase 復号に失敗しました (パスフレーズが誤っている可能性があります)" + ) from e + + if not identities: + raise CipherError("identity または passphrase を指定してください") + + resolved = [_resolve_identity(p) for p in identities] + try: + return pyrage.decrypt(data, resolved) + except Exception as e: + raise CipherError( + "復号に失敗しました (identity が一致しない / バンドルが破損している可能性があります)" + ) from e + + +def default_recipient_paths() -> List[Path]: + """recipient 省略時に試す既定の公開鍵パス候補 + + ed25519 を優先し、次に rsa を試す。 + """ + ssh = Path.home() / '.ssh' + return [ssh / 'id_ed25519.pub', ssh / 'id_rsa.pub'] + + +def default_identity_paths() -> List[Path]: + """identity 省略時に試す既定の秘密鍵パス候補 + + ed25519 を優先し、次に rsa を試す。 + """ + ssh = Path.home() / '.ssh' + return [ssh / 'id_ed25519', ssh / 'id_rsa'] diff --git a/lib/devbase/env/io_export.py b/lib/devbase/env/io_export.py new file mode 100644 index 0000000..101ceb2 --- /dev/null +++ b/lib/devbase/env/io_export.py @@ -0,0 +1,177 @@ +"""devbase env export の高レベル実装""" + +from __future__ import annotations + +import getpass +import os +import re +import sys +from dataclasses import dataclass, field +from datetime import datetime +from pathlib import Path +from typing import List, Optional, Sequence + +from devbase.errors import DevbaseError +from devbase.log import get_logger + +from devbase.env import bundle as _bundle +from devbase.env import cipher as _cipher +from devbase.env import storage as _storage + +logger = get_logger(__name__) + +# 機密情報の検出パターン (平文出力時の警告用) +_SENSITIVE_PATTERNS = ('KEY', 'SECRET', 'TOKEN', 'PASSWORD', 'CREDENTIALS', 'BASE64') + + +class ExportError(DevbaseError): + """export エラー""" + + +@dataclass +class ExportOptions: + dest: Optional[str] = None + include_global: bool = True + include_metadata: bool = True + include_projects: Optional[List[str]] = None + exclude_projects: List[str] = field(default_factory=list) + recipients: List[str] = field(default_factory=list) + passphrase_env: Optional[str] = None + passphrase_stdin: bool = False + force_unencrypted: bool = False + + +def _default_dest(force_unencrypted: bool) -> str: + ts = datetime.now().strftime('%Y%m%d-%H%M%S') + suffix = '.dbenv.tar.gz' if force_unencrypted else '.dbenv' + return f'./devbase-env-{ts}{suffix}' + + +def _resolve_recipients(specs: Sequence[str]) -> List[str]: + """recipient 指定の解決。 + + 空なら既定鍵を優先順 (``~/.ssh/id_ed25519.pub`` → ``~/.ssh/id_rsa.pub``) で + 探索し、最初に見つかったものを利用する。 + """ + if specs: + return list(specs) + for path in _cipher.default_recipient_paths(): + if path.exists(): + logger.info("recipient 既定鍵を使用: %s", path) + return [f'@{path}'] + return [] + + +def _read_passphrase(opts: ExportOptions) -> Optional[str]: + if opts.passphrase_env: + value = os.environ.get(opts.passphrase_env) + if not value: + raise ExportError( + f"環境変数 {opts.passphrase_env} が空または未設定です" + ) + return value + if opts.passphrase_stdin: + # tty で対話実行している場合は getpass.getpass でエコー抑止 + # (パイプ入力時は echo の概念がないので従来どおり stdin.readline で読む)。 + if sys.stdin.isatty(): + try: + return getpass.getpass("passphrase: ", stream=sys.stderr) + except EOFError as e: + raise ExportError("stdin からパスフレーズを読み取れませんでした") from e + line = sys.stdin.readline() + if not line: + raise ExportError("stdin からパスフレーズを読み取れませんでした") + return line.rstrip('\n') + return None + + +def _has_sensitive_keys(entries) -> List[str]: + """env 形式のテキストから機密キーを抽出する (平文出力時の警告用)""" + hits = set() + key_re = re.compile(r'^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=', re.MULTILINE) + for entry in entries: + if not entry.arcname.endswith('.env'): + continue + try: + text = entry.data.decode('utf-8', errors='ignore') + except Exception: + continue + for key in key_re.findall(text): + upper = key.upper() + if any(p in upper for p in _SENSITIVE_PATTERNS): + hits.add(key) + return sorted(hits) + + +def export(devbase_root: Path, opts: ExportOptions) -> int: + """export 本体。CLI ハンドラから呼ばれる""" + # 引数組み合わせの早期検証 + if opts.passphrase_stdin and opts.dest == '-': + raise ExportError( + "DEST='-' (stdout) と --passphrase-stdin は併用できません " + "(stdin/stdout が衝突します)" + ) + if opts.passphrase_env and opts.passphrase_stdin: + raise ExportError("--passphrase-env と --passphrase-stdin は併用できません") + + entries = _bundle.make_entries_from_disk( + devbase_root, + include_global=opts.include_global, + include_metadata=opts.include_metadata, + include_projects=opts.include_projects, + exclude_projects=opts.exclude_projects, + ) + if not entries: + raise ExportError( + "export 対象のファイルがありません " + "(--no-global / --exclude-project の指定や DEVBASE_ROOT を確認してください)" + ) + + logger.info("export 対象 %d 件:", len(entries)) + for entry in entries: + logger.info(" - %s (%d bytes) <- %s", + entry.arcname, len(entry.data), entry.origin) + + tar_blob = _bundle.pack(entries) + logger.debug("tar.gz サイズ: %d bytes", len(tar_blob)) + + if opts.force_unencrypted: + if opts.recipients or opts.passphrase_env or opts.passphrase_stdin: + raise ExportError( + "--force-unencrypted は recipient / passphrase と併用できません" + ) + sensitive = _has_sensitive_keys(entries) + if sensitive: + logger.warning( + "平文 export に機密キーが含まれます: %s", + ', '.join(sensitive[:10]) + (' ...' if len(sensitive) > 10 else '') + ) + logger.warning( + "ファイルパーミッションは 0600 で書き出されますが、保管・転送時の暗号化を強く推奨します" + ) + payload = tar_blob + else: + passphrase = _read_passphrase(opts) + recipients = _resolve_recipients(opts.recipients) if passphrase is None else [] + if not recipients and not passphrase: + raise ExportError( + "暗号化キーが指定されていません。次のいずれかを指定してください:\n" + " --recipient KEY age / OpenSSH 公開鍵\n" + " --passphrase-env VAR 環境変数からパスフレーズ取得\n" + " --passphrase-stdin stdin の最初の行をパスフレーズとして使用\n" + " --force-unencrypted 平文 tar.gz として書き出す (機密キー検知時は警告)\n" + " ~/.ssh/id_ed25519.pub または ~/.ssh/id_rsa.pub があれば " + "--recipient 省略時の既定として使用されます (ed25519 優先)" + ) + payload = _cipher.encrypt(tar_blob, recipients=recipients, passphrase=passphrase) + logger.debug("暗号化後サイズ: %d bytes", len(payload)) + + dest = opts.dest or _default_dest(opts.force_unencrypted) + backend = _storage.resolve(dest) + backend.write_bytes(dest, payload) + + if _storage.is_stdio(dest): + logger.info("export 完了 (stdout, %d bytes)", len(payload)) + else: + logger.info("export 完了: %s (%d bytes)", dest, len(payload)) + return 0 diff --git a/lib/devbase/env/io_import.py b/lib/devbase/env/io_import.py new file mode 100644 index 0000000..5f025b1 --- /dev/null +++ b/lib/devbase/env/io_import.py @@ -0,0 +1,711 @@ +"""devbase env import の高レベル実装 + +責務: + - SOURCE (file / stdio) の読み込み + - age 復号 (バンドルが暗号化されていれば) + - tar.gz バンドルの展開と sha256 / manifest version の検証 (bundle.unpack) + - --merge / --replace-keys / --replace のセマンティクスで .env 群を更新 + - .env.sources.yml は既定で上書きせず参照用コピーのみ (--merge-metadata で + 新規 source のみ追加) + - 2 フェーズ書き出し (prepare → commit) で部分適用を最小化 + - --backup-dir / --keep-last N で backup を GC + - --dry-run で差分プレビュー +""" + +from __future__ import annotations + +import getpass +import os +import re +import shutil +import sys +from dataclasses import dataclass, field +from datetime import datetime +from pathlib import Path +from typing import Dict, List, Optional, Sequence, Tuple + +import yaml + +from devbase.errors import DevbaseError +from devbase.log import get_logger + +from devbase.env import bundle as _bundle +from devbase.env import cipher as _cipher +from devbase.env import storage as _storage +from devbase.env.store import EnvEntry, EnvFile + +logger = get_logger(__name__) + +# gzip magic. tar.gz バンドルは先頭 2 byte が 0x1f 0x8b。age 暗号化済みは +# テキストヘッダ "age-encryption.org/v1\n" で始まるため magic で識別できる。 +_GZIP_MAGIC = b'\x1f\x8b' + +_MERGE_MODES = ('keep-existing', 'prefer-incoming') + +# _make_backup_dir が生成するタイムスタンプ形式のみを GC 対象にする。 +# 以下のいずれかにマッチするディレクトリのみ削除する: +# YYYYMMDD-HHMMSS (旧フォーマット, 後方互換) +# YYYYMMDD-HHMMSS-NNNNNN (microsecond 付き) +# YYYYMMDD-HHMMSS-NNNNNN-NN (同一マイクロ秒内の連番付き) +# これ以外のディレクトリは devbase が作ったものではないので削除しない +# (--backup-dir 親に無関係なディレクトリがあっても安全)。 +_BACKUP_DIR_NAME_RE = re.compile(r'^\d{8}-\d{6}(?:-\d{6}(?:-\d+)?)?$') + + +class ImportError(DevbaseError): + """import エラー""" + + +@dataclass +class ImportOptions: + source: str + merge: str = 'keep-existing' + replace_keys: List[str] = field(default_factory=list) + replace: bool = False + dry_run: bool = False + identities: List[str] = field(default_factory=list) + passphrase_env: Optional[str] = None + passphrase_stdin: bool = False + include_projects: Optional[List[str]] = None + exclude_projects: List[str] = field(default_factory=list) + include_global: bool = True + include_metadata: bool = True + merge_metadata: bool = False + backup_dir: Optional[str] = None + keep_last: int = 10 + + +@dataclass +class _Plan: + """1 ファイル分の書き出し計画""" + target: Path + arcname: str + new_bytes: bytes + # 差分サマリ (dry-run / ログ用) + added_keys: List[str] = field(default_factory=list) + overwritten_keys: List[str] = field(default_factory=list) + skipped_keys: List[str] = field(default_factory=list) + # ファイル単位の操作種別 + op: str = 'merge' # 'merge' | 'replace' | 'create' | 'sources-merge' + + +def _read_passphrase(opts: ImportOptions) -> Optional[str]: + if opts.passphrase_env: + value = os.environ.get(opts.passphrase_env) + if not value: + raise ImportError(f"環境変数 {opts.passphrase_env} が空または未設定です") + return value + if opts.passphrase_stdin: + if sys.stdin.isatty(): + try: + return getpass.getpass("passphrase: ", stream=sys.stderr) + except EOFError as e: + raise ImportError("stdin からパスフレーズを読み取れませんでした") from e + line = sys.stdin.readline() + if not line: + raise ImportError("stdin からパスフレーズを読み取れませんでした") + return line.rstrip('\n') + return None + + +def _resolve_identities(specs: Sequence[str]) -> List[str]: + if specs: + return list(specs) + for path in _cipher.default_identity_paths(): + if path.exists(): + logger.info("identity 既定鍵を使用: %s", path) + return [str(path)] + return [] + + +def _decrypt_if_needed(blob: bytes, opts: ImportOptions) -> bytes: + """先頭バイトで暗号化済みかを判定して必要なら復号する""" + if blob[:2] == _GZIP_MAGIC: + # 平文 tar.gz。鍵指定があっても無視せず警告にとどめる + if opts.identities or opts.passphrase_env or opts.passphrase_stdin: + logger.warning( + "バンドルは平文ですが identity / passphrase が指定されています " + "(使用されません)" + ) + return blob + + passphrase = _read_passphrase(opts) + if passphrase is not None: + return _cipher.decrypt(blob, passphrase=passphrase) + + identities = _resolve_identities(opts.identities) + if not identities: + raise ImportError( + "バンドルは暗号化されていますが復号キーが指定されていません。\n" + " --identity FILE age / OpenSSH 秘密鍵ファイル\n" + " --passphrase-env VAR 環境変数からパスフレーズ取得\n" + " --passphrase-stdin stdin の最初の行をパスフレーズとして使用\n" + " ~/.ssh/id_ed25519 または ~/.ssh/id_rsa があれば " + "--identity 省略時の既定として使用されます (ed25519 優先)" + ) + return _cipher.decrypt(blob, identities=identities) + + +def _filter_members(members: Dict[str, bytes], + opts: ImportOptions) -> Dict[str, bytes]: + """include/exclude 指定で展開済みメンバーを絞り込む""" + included = set(opts.include_projects) if opts.include_projects else None + excluded = set(opts.exclude_projects) + result: Dict[str, bytes] = {} + + proj_re = re.compile(r'^env/projects/([^/]+)/\.env$') + + for arcname, data in members.items(): + if arcname == 'env/global.env': + if not opts.include_global: + continue + result[arcname] = data + continue + if arcname == 'env/sources.yml': + if not opts.include_metadata: + continue + result[arcname] = data + continue + m = proj_re.match(arcname) + if m: + name = m.group(1) + if name in excluded: + continue + if included is not None and name not in included: + continue + result[arcname] = data + continue + # 他の形式は manifest 検証で拒否されているはずだが念のため + logger.debug("未対応の arcname を無視します: %s", arcname) + return result + + +def _target_for(arcname: str, devbase_root: Path) -> Path: + if arcname == 'env/global.env': + return devbase_root / '.env' + if arcname == 'env/sources.yml': + return devbase_root / '.env.sources.yml' + m = re.match(r'^env/projects/([^/]+)/\.env$', arcname) + if m: + return devbase_root / 'projects' / m.group(1) / '.env' + raise ImportError(f"未対応のバンドルエントリ: {arcname}") + + +def _merge_into_existing_bytes(existing_bytes: bytes, + merged: Dict[str, str]) -> bytes: + """既存 ``.env`` のコメント / 空行 / キー順を保持したまま、 + ``merged`` の内容で値を上書きしてバイト列化する。 + + ``merged`` のうち既存ファイルに無いキーは末尾に追加する。 + 既存ファイルにあって ``merged`` から削除されているキーは、entries からも除外して + 出力する (現状の merge ロジック上、削除されるケースは無いが安全側で対応)。 + + PR #15 gemini 指摘: ``EnvFile.dump_bytes`` で再シリアライズすると ``key=value`` + だけの出力になりコメント・空行が失われる。これを避けるため entries ベースで + 再構成する。 + """ + entries = EnvFile.parse_entries(existing_bytes) + seen: set = set() + out_entries: List[EnvEntry] = [] + for e in entries: + if e.kind == 'kv' and e.key is not None: + if e.key in merged: + # 値を merge 後のものに差し替え (key 順 / コメントは保持) + out_entries.append(EnvEntry( + kind='kv', raw=e.raw, key=e.key, value=merged[e.key] + )) + seen.add(e.key) + # merged から除外されているキーは entries からも落とす + else: + out_entries.append(e) + # 既存に無かった新規キーは末尾に append (定常的な key 順を維持するため sorted) + for key in sorted(k for k in merged if k not in seen): + out_entries.append(EnvEntry( + kind='kv', raw='', key=key, value=merged[key] + )) + return EnvFile.dump_entries_bytes(out_entries) + + +def _build_merge_plan( + target: Path, + arcname: str, + incoming_bytes: bytes, + existing_bytes: bytes, + target_exists: bool, + merged: Dict[str, str], + added: List[str], + overwritten: List[str], + skipped: List[str], +) -> _Plan: + """merge 系 (replace_keys / keep-existing / prefer-incoming) 共通の _Plan 生成。 + + - 新規作成時 (``target_exists`` が False) は ``incoming_bytes`` をそのまま採用して + 二重エスケープを回避 (PR #15 codex 指摘) + - 既存ファイルへの merge 時は ``_merge_into_existing_bytes`` でコメント / 空行 / + キー順を保持したまま値を差し替える (PR #15 gemini 指摘)。 + ``existing`` (key=value dict) の空判定で create/merge を決めると、コメント / + 空行のみで構成された既存 .env が ``incoming_bytes`` で上書きされてコメントが + 失われるため、ファイル実体の有無で判定する (PR #15 round5 指摘)。 + """ + new_bytes = (_merge_into_existing_bytes(existing_bytes, merged) + if target_exists else incoming_bytes) + return _Plan( + target=target, + arcname=arcname, + new_bytes=new_bytes, + added_keys=sorted(added), + overwritten_keys=sorted(overwritten), + skipped_keys=sorted(skipped), + op='merge' if target_exists else 'create', + ) + + +def _plan_env_merge(target: Path, incoming_bytes: bytes, + opts: ImportOptions, arcname: str) -> _Plan: + """1 つの .env に対する merge / replace 計画を作る + + 既存ファイルが無い (= create) ケースでは、バンドル側の ``incoming_bytes`` を + そのまま採用する。``EnvFile.dump_bytes`` で再シリアライズすると、export 側で + 既に escape された値を parse_bytes 経由でも完全に round-trip できる前提が + 崩れた瞬間に二重エスケープが発生するためである (PR #15 codex 指摘)。 + + 既存ファイルが存在する merge 経路では ``_merge_into_existing_bytes`` で + 既存のコメント / 空行 / キー順を保持したまま値だけ差し替える + (PR #15 gemini 指摘)。 + + 各分岐で重複していた ``new_bytes`` 生成と ``_Plan`` 構築は + ``_build_merge_plan`` に括り出している (PR #15 gemini round4 指摘)。 + """ + incoming = EnvFile.parse_bytes(incoming_bytes) + existing: Dict[str, str] = {} + existing_bytes: bytes = b'' + target_exists = target.exists() + if target_exists: + existing_bytes = target.read_bytes() + existing = EnvFile.parse_bytes(existing_bytes) + + if opts.replace: + added = sorted(set(incoming) - set(existing)) + overwritten = sorted(k for k in incoming if k in existing and incoming[k] != existing[k]) + # replace は バンドル側の値で完全に置き換える (merge 経路と別系統) + # op 判定は ``existing`` (key=value dict) ではなくファイル実体の有無で行う: + # コメントのみの既存 .env を 'create' と誤判定しないため (PR #15 round5 指摘)。 + return _Plan( + target=target, + arcname=arcname, + new_bytes=incoming_bytes, + added_keys=added, + overwritten_keys=overwritten, + skipped_keys=[], + op='replace' if target_exists else 'create', + ) + + merged: Dict[str, str] = dict(existing) + added: List[str] = [] + overwritten: List[str] = [] + skipped: List[str] = [] + + if opts.replace_keys: + replace_set = set(opts.replace_keys) + for key, value in incoming.items(): + if key in replace_set: + if key in existing: + if existing[key] != value: + overwritten.append(key) + merged[key] = value + else: + added.append(key) + merged[key] = value + else: + # --replace-keys 指定外のキーは keep-existing 相当: + # 既存にあれば残し、無ければ新規追加 (skipped は overwrite を + # 抑止した = 上書きしなかったキーのみ)。 + if key in existing: + if existing[key] != value: + skipped.append(key) + else: + added.append(key) + merged[key] = value + elif opts.merge == 'keep-existing': + for key, value in incoming.items(): + if key in existing: + skipped.append(key) + else: + merged[key] = value + added.append(key) + elif opts.merge == 'prefer-incoming': + for key, value in incoming.items(): + if key in existing: + if existing[key] != value: + overwritten.append(key) + merged[key] = value + else: + merged[key] = value + added.append(key) + else: + raise ImportError(f"不明な --merge モード: {opts.merge!r}") + + return _build_merge_plan( + target=target, + arcname=arcname, + incoming_bytes=incoming_bytes, + existing_bytes=existing_bytes, + target_exists=target_exists, + merged=merged, + added=added, + overwritten=overwritten, + skipped=skipped, + ) + + +def _plan_sources(target: Path, incoming_bytes: bytes, + opts: ImportOptions) -> Optional[_Plan]: + """.env.sources.yml の取り扱い計画 + + 既定: 上書きせず None を返す (backup_dir に参照用コピーのみ書く)。 + --merge-metadata: 新規 source エントリのみ追加した内容で更新する。 + """ + if not opts.merge_metadata: + # 上書きしないので _Plan は返さない。参照用 copy は run() 側で処理。 + return None + + try: + incoming = yaml.safe_load(incoming_bytes) or {} + except yaml.YAMLError as e: + raise ImportError(f"バンドルの sources.yml が壊れています: {e}") from e + if not isinstance(incoming, dict): + raise ImportError("バンドルの sources.yml が dict ではありません") + incoming_sources = incoming.get('sources') or {} + if not isinstance(incoming_sources, dict): + raise ImportError("バンドルの sources.yml の sources が dict ではありません") + + existing: Dict = {} + if target.exists(): + try: + existing = yaml.safe_load(target.read_bytes()) or {} + except yaml.YAMLError as e: + raise ImportError( + f"既存の {target.name} のパースに失敗しました: {e}" + ) from e + if not isinstance(existing, dict): + existing = {} + existing.setdefault('sources', {}) + if not isinstance(existing['sources'], dict): + existing['sources'] = {} + + added: List[str] = [] + merged_sources = dict(existing['sources']) + for name, entry in incoming_sources.items(): + if name in merged_sources: + continue + merged_sources[name] = entry + added.append(name) + + if not added: + return None # 変化なし + + existing['sources'] = merged_sources + new_bytes = yaml.safe_dump( + existing, default_flow_style=False, allow_unicode=True + ).encode('utf-8') + return _Plan( + target=target, + arcname='env/sources.yml', + new_bytes=new_bytes, + added_keys=sorted(added), + overwritten_keys=[], + skipped_keys=[], + op='sources-merge', + ) + + +def _make_backup_dir(devbase_root: Path, opts: ImportOptions) -> Path: + """バックアップディレクトリを作成する。 + + 秒精度のみだと同一秒に 2 回 import を走らせたときに同じディレクトリを再利用して + 前回バックアップを上書きしてしまうため、microsecond + 連番を付与して衝突を回避する + (PR #15 codex 指摘)。 + """ + if opts.backup_dir: + base = Path(opts.backup_dir).expanduser() + else: + base = devbase_root / 'backups' / 'env-import' + base.mkdir(parents=True, exist_ok=True) + + now = datetime.now() + stem = now.strftime('%Y%m%d-%H%M%S-%f') # microsecond まで + path = base / stem + if not path.exists(): + path.mkdir(parents=True) + return path + # 同一マイクロ秒に複数回走った場合の安全弁: 連番を付与 + for n in range(1, 1000): + candidate = base / f'{stem}-{n:02d}' + if not candidate.exists(): + candidate.mkdir(parents=True) + return candidate + raise ImportError( + f"backup ディレクトリの衝突回避に失敗しました (base={base}, stem={stem})" + ) + + +def _backup_existing(plans: Sequence[_Plan], sources_copy: Optional[Tuple[Path, bytes]], + backup_dir: Path, devbase_root: Path) -> None: + """phase 1 前に既存ファイルの内容を backup_dir にコピーする""" + for plan in plans: + if not plan.target.exists(): + continue + try: + relative = plan.target.relative_to(devbase_root) + except ValueError: + relative = Path(plan.target.name) + dst = backup_dir / relative + dst.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(plan.target, dst) + + # バンドルに含まれていた sources.yml の参照用コピー (上書きしないケース) + if sources_copy is not None: + target, data = sources_copy + dst = backup_dir / 'sources.yml.imported' + dst.parent.mkdir(parents=True, exist_ok=True) + dst.write_bytes(data) + try: + os.chmod(dst, 0o600) + except OSError: + pass + + +def _write_atomic(plan: _Plan) -> Path: + """phase 1: 新内容を .import.tmp として書き出す (0600)""" + tmp = plan.target.with_suffix(plan.target.suffix + '.import.tmp') + tmp.parent.mkdir(parents=True, exist_ok=True) + if tmp.exists(): + # 過去の失敗の残骸を掃除 + try: + tmp.unlink() + except OSError: + pass + flags = os.O_WRONLY | os.O_CREAT | os.O_TRUNC + fd = os.open(tmp, flags, 0o600) + try: + with os.fdopen(fd, 'wb') as f: + f.write(plan.new_bytes) + except BaseException: + try: + os.close(fd) + except OSError: + pass + raise + try: + os.chmod(tmp, 0o600) + except OSError: + pass + return tmp + + +def _commit(plans_and_tmps: List[Tuple[_Plan, Path]], backup_dir: Path, + devbase_root: Path) -> List[Path]: + """phase 2: tmp → target に rename。 + + 途中失敗時は best-effort で rollback したうえで、まだ rename されていない + 残りの ``.import.tmp`` ファイルもクリーンアップする (PR #15 gemini 指摘)。 + """ + committed: List[Tuple[_Plan, Path]] = [] + remaining_tmps = [tmp for _, tmp in plans_and_tmps] + try: + for idx, (plan, tmp) in enumerate(plans_and_tmps): + os.replace(tmp, plan.target) + try: + os.chmod(plan.target, 0o600) + except OSError: + pass + committed.append((plan, plan.target)) + # rename 済みの tmp は残らないが、リストから除外して後続 cleanup を簡潔に + remaining_tmps[idx] = None # type: ignore[call-overload] + except OSError as e: + logger.error("commit フェーズで失敗しました: %s", e) + try: + _rollback(committed, backup_dir, devbase_root) + finally: + # rename 前で残っている .import.tmp を後始末 + _cleanup_tmps([t for t in remaining_tmps if t is not None]) + raise ImportError(f"commit フェーズで失敗しました: {e}") from e + return [t for _, t in committed] + + +def _rollback(committed: Sequence[Tuple[_Plan, Path]], backup_dir: Path, + devbase_root: Path) -> None: + """best-effort ロールバック: + - 既存上書き (backup あり) → backup から復元 + - backup が無いケース → 元ファイルが存在しなかった (= 新規作成) と + みなして unlink し、元の「不在」状態に戻す。``op='create'`` だけでなく + ``op='sources-merge'`` で sources.yml を新規作成したケースもここで + unlink する (PR #15 gemini 指摘)。 + + ``_backup_existing`` は target が存在した場合のみ backup を作る。よって + 「backup が無い」事実は「元ファイルが存在しなかった」ことを示している。 + """ + for plan, target in committed: + try: + relative = target.relative_to(devbase_root) + except ValueError: + relative = Path(target.name) + src = backup_dir / relative + if src.exists(): + try: + shutil.copy2(src, target) + logger.warning("rollback: %s を %s から復元しました", target, src) + except OSError as e: + logger.error("rollback 失敗: %s -> %s: %s", src, target, e) + else: + # 元ファイル不在 → 新規作成された target を unlink して元の状態に戻す + try: + target.unlink() + logger.warning("rollback: 新規作成された %s を削除しました", target) + except FileNotFoundError: + pass + except OSError as e: + logger.error("rollback unlink 失敗: %s: %s", target, e) + + +def _cleanup_tmps(tmps: Sequence[Path]) -> None: + for tmp in tmps: + try: + if tmp.exists(): + tmp.unlink() + except OSError: + pass + + +def _gc_backups(backup_dir: Path, keep_last: int) -> None: + """backup_dir の親ディレクトリ内の古い backup を keep_last 個まで残して GC する。 + + 安全性のため、削除対象は devbase が生成するタイムスタンプ形式 + (YYYYMMDD-HHMMSS) のディレクトリに限定する。--backup-dir で指定された + 親ディレクトリに無関係なファイル/ディレクトリがあっても、それらは触らない。 + """ + if keep_last <= 0: + return + parent = backup_dir.parent + if not parent.is_dir(): + return + siblings = sorted( + (p for p in parent.iterdir() + if p.is_dir() and _BACKUP_DIR_NAME_RE.match(p.name)), + key=lambda p: p.name, + ) + if len(siblings) <= keep_last: + return + to_remove = siblings[:-keep_last] + for d in to_remove: + try: + shutil.rmtree(d) + logger.info("古い backup を削除しました: %s", d) + except OSError as e: + logger.warning("backup 削除に失敗 (%s): %s", d, e) + + +def _log_plans(plans: Sequence[_Plan], dry_run: bool) -> None: + prefix = "[dry-run] " if dry_run else "" + for plan in plans: + logger.info( + "%s%s: %s (+%d add / ~%d overwrite / -%d skip)", + prefix, plan.op, plan.target, + len(plan.added_keys), len(plan.overwritten_keys), len(plan.skipped_keys), + ) + if plan.added_keys: + logger.info(" added: %s", ", ".join(plan.added_keys)) + if plan.overwritten_keys: + logger.info(" overwrite: %s", ", ".join(plan.overwritten_keys)) + if plan.skipped_keys: + logger.info(" skip (existing kept): %s", ", ".join(plan.skipped_keys)) + + +def import_bundle(devbase_root: Path, opts: ImportOptions) -> int: + """import 本体。CLI ハンドラから呼ばれる""" + # 引数の早期検証 + if opts.merge not in _MERGE_MODES: + raise ImportError( + f"--merge の値が不正です: {opts.merge!r} (許可: {', '.join(_MERGE_MODES)})" + ) + if opts.replace and opts.replace_keys: + raise ImportError("--replace と --replace-keys は併用できません") + if opts.passphrase_stdin and opts.source == '-': + raise ImportError( + "SOURCE='-' (stdin) と --passphrase-stdin は併用できません " + "(stdin が衝突します)" + ) + if opts.passphrase_env and opts.passphrase_stdin: + raise ImportError("--passphrase-env と --passphrase-stdin は併用できません") + + # SOURCE 読み込み + backend = _storage.resolve(opts.source) + blob = backend.read_bytes(opts.source) + logger.debug("読み込みサイズ: %d bytes", len(blob)) + + # 復号 (必要なら) + 展開 + manifest 検証 (sha256 / version) + tar_blob = _decrypt_if_needed(blob, opts) + manifest, members = _bundle.unpack(tar_blob) + logger.info("バンドル version=%s, 生成=%s, devbase=%s", + manifest.get('version'), manifest.get('created_at'), + manifest.get('devbase_version')) + + filtered = _filter_members(members, opts) + if not filtered: + raise ImportError( + "import 対象がありません " + "(--no-global / --include-project の指定とバンドル内容を確認してください)" + ) + + # 計画作成 + plans: List[_Plan] = [] + sources_reference: Optional[Tuple[Path, bytes]] = None + for arcname, data in sorted(filtered.items()): + target = _target_for(arcname, devbase_root) + if arcname == 'env/sources.yml': + plan = _plan_sources(target, data, opts) + if plan is not None: + plans.append(plan) + else: + # 既定動作: 上書きしないので参照用 copy のみバックアップする + sources_reference = (target, data) + else: + plans.append(_plan_env_merge(target, data, opts, arcname)) + + _log_plans(plans, opts.dry_run) + if sources_reference is not None and not opts.merge_metadata: + logger.info( + "%ssources.yml は上書きしません (--merge-metadata 指定時のみ更新, " + "参照用コピーを backup ディレクトリに残します)", + "[dry-run] " if opts.dry_run else "", + ) + + if opts.dry_run: + logger.info("[dry-run] 書き込みは行いません") + return 0 + + if not plans and sources_reference is None: + logger.info("変更はありません") + return 0 + + # backup → phase 1 (tmp 書き出し) → phase 2 (rename) + backup_dir = _make_backup_dir(devbase_root, opts) + logger.info("backup ディレクトリ: %s", backup_dir) + _backup_existing(plans, sources_reference, backup_dir, devbase_root) + + tmps: List[Path] = [] + plans_and_tmps: List[Tuple[_Plan, Path]] = [] + try: + for plan in plans: + tmp = _write_atomic(plan) + tmps.append(tmp) + plans_and_tmps.append((plan, tmp)) + except Exception: + _cleanup_tmps(tmps) + raise + + _commit(plans_and_tmps, backup_dir, devbase_root) + logger.info("import 完了: %d ファイル更新", len(plans)) + + _gc_backups(backup_dir, opts.keep_last) + return 0 diff --git a/lib/devbase/env/storage.py b/lib/devbase/env/storage.py new file mode 100644 index 0000000..114d0b4 --- /dev/null +++ b/lib/devbase/env/storage.py @@ -0,0 +1,128 @@ +"""env バンドルの入出力先 (local / stdio / 将来 s3, gcs) を抽象化する""" + +from __future__ import annotations + +import os +import sys +from pathlib import Path +from typing import Protocol +from urllib.parse import urlparse + +from devbase.errors import DevbaseError + + +class StorageError(DevbaseError): + """ストレージ操作エラー""" + + +class StorageBackend(Protocol): + def write_bytes(self, dest: str, data: bytes) -> None: ... + def read_bytes(self, source: str) -> bytes: ... + + +def _to_local_path(uri: str) -> Path: + """ローカルパス文字列または file:// URI を Path に正規化する""" + parsed = urlparse(uri) + if parsed.scheme.lower() == 'file': + # file:///tmp/x や file://localhost/tmp/x のみ許容 + # file://other-host/tmp/x はホスト情報が脱落するので拒否 + netloc = (parsed.netloc or '').lower() + if netloc not in ('', 'localhost'): + raise StorageError( + f"file:// URI のホスト指定はサポートされていません " + f"(netloc={parsed.netloc!r}, 許可: '' / 'localhost')" + ) + from urllib.request import url2pathname + return Path(url2pathname(parsed.path)).expanduser() + return Path(uri).expanduser() + + +class LocalBackend: + """ローカルファイルシステム""" + + def write_bytes(self, dest: str, data: bytes) -> None: + path = _to_local_path(dest) + try: + if path.parent and not path.parent.exists(): + path.parent.mkdir(parents=True, exist_ok=True) + # TOCTOU 回避: open(..., 'wb') 後に chmod すると、umask が緩い環境では + # 一瞬 0644 等で平文 export が露出する。 + # os.open に mode=0o600 を渡し、O_CREAT|O_TRUNC|O_WRONLY で作成時点 + # から 0600 を強制する。既存ファイルも書き込み前に chmod で権限を絞る。 + if path.exists(): + try: + os.chmod(path, 0o600) + except OSError: + # Windows 等で chmod が無効でも処理を続行 + pass + flags = os.O_WRONLY | os.O_CREAT | os.O_TRUNC + fd = os.open(path, flags, 0o600) + try: + with os.fdopen(fd, 'wb') as f: + f.write(data) + except BaseException: + # fdopen 失敗時は fd を明示的に閉じる (fdopen 成功時は with が close) + try: + os.close(fd) + except OSError: + pass + raise + # mode 引数が無視される環境 (Windows 等) でも後追いで chmod を試みる + try: + os.chmod(path, 0o600) + except OSError: + pass + except StorageError: + raise + except OSError as e: + raise StorageError(f"書き込みに失敗しました ({path}): {e}") from e + + def read_bytes(self, source: str) -> bytes: + path = _to_local_path(source) + if not path.exists(): + raise StorageError(f"ファイルが見つかりません: {path}") + try: + return path.read_bytes() + except OSError as e: + raise StorageError(f"読み込みに失敗しました ({path}): {e}") from e + + +class StdioBackend: + """`-` 指定での stdin/stdout 入出力 (パイプ運用向け)""" + + def write_bytes(self, dest: str, data: bytes) -> None: + sys.stdout.buffer.write(data) + sys.stdout.buffer.flush() + + def read_bytes(self, source: str) -> bytes: + return sys.stdin.buffer.read() + + +def resolve(uri: str) -> StorageBackend: + """URI スキームから対応する backend を返す""" + if uri == '-': + return StdioBackend() + + parsed = urlparse(uri) + scheme = parsed.scheme.lower() + + if scheme in ('', 'file'): + return LocalBackend() + + if scheme in ('s3', 'gs'): + raise StorageError( + f"スキーム '{scheme}://' は本 PR では未実装です " + "(後続 PR で対応予定)" + ) + + # Windows のドライブレター付きパス (例: C:\path, d:/path) は + # urlparse が scheme='c' / 'd' と誤認するため、1 文字アルファベットで + # かつ `://` を伴わないものは LocalBackend にフォールバックする + if len(scheme) == 1 and scheme.isalpha() and '://' not in uri: + return LocalBackend() + + raise StorageError(f"未対応のスキームです: {scheme}://") + + +def is_stdio(uri: str) -> bool: + return uri == '-' diff --git a/lib/devbase/env/store.py b/lib/devbase/env/store.py index 057fe0e..13eb6b3 100644 --- a/lib/devbase/env/store.py +++ b/lib/devbase/env/store.py @@ -1,9 +1,13 @@ """Environment variable file store""" +from __future__ import annotations + import os +import re import shutil +from dataclasses import dataclass from pathlib import Path -from typing import Optional, Dict, Union +from typing import Optional, Dict, Union, List from devbase.log import get_logger @@ -49,6 +53,52 @@ def collect_key(env_file, key, *, auto_value=None, prompt=None, mask_after=10): return False +@dataclass +class EnvEntry: + """``.env`` ファイルの 1 行を表すトークン。 + + - ``kind='kv'`` のとき ``key`` / ``value`` が有効 (``raw`` は元の行全体) + - ``kind='comment'`` / ``kind='blank'`` のとき ``raw`` のみ有効 + + コメント・空行を保持してマージ出力するために使う (PR #15 gemini 指摘)。 + """ + kind: str # 'kv' | 'comment' | 'blank' + raw: str = '' + key: Optional[str] = None + value: Optional[str] = None + + +# ``EnvFile.dump_bytes`` / :meth:`EnvFile.save` で値を quote する閾値となる文字集合。 +# シェル ``source`` 時に展開・解釈されうる metachar をすべて含める。``$`` を含む値も +# ``\$`` にエスケープして出力するため、ここで quoting 対象として捕捉する +# (PR #15 gemini 指摘)。 +_NEEDS_QUOTE_CHARS = (' ', '"', "'", '$', '`', '\\', '<', '>', '|', '&', ';', + '(', ')', '#') + + +def _escape_double_quoted(value: str) -> str: + """``"..."`` 内で安全な escape を施す。 + + - ``\\`` → ``\\\\`` + - ``"`` → ``\\"`` + - ``\n`` → ``\\n`` (改行をリテラル化) + - ``$`` → ``\\$`` (``.env`` を ``source`` した際の変数展開を抑止) + """ + return (value.replace('\\', '\\\\') + .replace('"', '\\"') + .replace('\n', '\\n') + .replace('$', '\\$')) + + +# ``_unescape_double_quoted`` 用の逆引きテーブル。``re.sub(r'\\.', ...)`` で +# マッチした 2 文字を 1 文字に置換する。未知エスケープ (例: ``\x``) は +# バックスラッシュごとそのまま残す必要があるため、``dict.get`` の default に +# マッチ文字列自身を返す。末尾単独 ``\`` は ``\\.`` のドットが 2 文字目を +# 要求するため自然にマッチせず、そのまま保持される。 +_DOUBLE_QUOTE_UNESCAPES = {'\\\\': '\\', '\\"': '"', '\\n': '\n', '\\$': '$'} +_DOUBLE_QUOTE_UNESCAPE_RE = re.compile(r'\\.') + + class EnvFile: """ .envファイルの読み書き・バックアップ・バリデーションを管理する。 @@ -59,48 +109,114 @@ def __init__(self, file_path: Union[str, Path]): self._data: Dict[str, str] = {} self._loaded = False + @staticmethod + def parse_bytes(data: bytes) -> Dict[str, str]: + """bytes 列を load と同じ規則でパースして dict を返す (ファイル不要) + + ``save`` / :meth:`EnvFile.dump_bytes` の inverse として振る舞うため、 + ダブルクオート内の ``\\\\`` / ``\\"`` / ``\\n`` / ``\\$`` を unescape する。 + formatter と round-trip 整合性が取れていないと、parse → format で + 二重エスケープが発生する (PR #15 codex 指摘)。 + """ + result: Dict[str, str] = {} + for entry in EnvFile.parse_entries(data): + if entry.kind == 'kv' and entry.key is not None: + result[entry.key] = entry.value or '' + return result + + @staticmethod + def parse_entries(data: bytes) -> List[EnvEntry]: + """``.env`` の各行をトークン化して返す。 + + コメント (``#`` 始まり) と空行は ``EnvEntry(kind='comment'|'blank', raw=...)`` + として保持される。これにより merge 出力時に元のコメント/空白構造を残せる + (PR #15 gemini 指摘)。 + """ + entries: List[EnvEntry] = [] + for raw_line in data.decode('utf-8').splitlines(): + stripped = raw_line.strip() + if not stripped: + entries.append(EnvEntry(kind='blank', raw=raw_line)) + continue + if stripped.startswith('#'): + entries.append(EnvEntry(kind='comment', raw=raw_line)) + continue + if '=' not in stripped: + # ``key=value`` 形式でない行は (滅多に無いが) 原文保持する + entries.append(EnvEntry(kind='comment', raw=raw_line)) + continue + key, _, value = stripped.partition('=') + key = key.strip() + value = value.strip() + if len(value) >= 2 and value[0] == value[-1] and value[0] in ('"', "'"): + quote = value[0] + value = value[1:-1] + if quote == '"': + value = EnvFile._unescape_double_quoted(value) + entries.append(EnvEntry(kind='kv', raw=raw_line, key=key, value=value)) + return entries + + @staticmethod + def _unescape_double_quoted(s: str) -> str: + """``save`` が double-quote 値に対して施した escape を 1 パスで戻す。 + + 単純な逐次 ``replace`` は ``"\\\\n"`` (リテラル ``\\\\`` + ``n``) と + ``"\\n"`` (改行) の区別が付かないため、``\\`` を一括で捉える + ``re.sub`` + 逆引き辞書で処理する。 + """ + return _DOUBLE_QUOTE_UNESCAPE_RE.sub( + lambda m: _DOUBLE_QUOTE_UNESCAPES.get(m.group(0), m.group(0)), s + ) + + @staticmethod + def _format_kv_line(key: str, value: str) -> str: + """1 つの ``key=value`` を ``.env`` 行 (末尾 ``\\n`` 含む) にフォーマットする""" + needs_quote = ( + '\n' in value + or any(c in value for c in _NEEDS_QUOTE_CHARS) + ) + if needs_quote: + return f'{key}="{_escape_double_quoted(value)}"\n' + return f'{key}={value}\n' + + @staticmethod + def dump_bytes(data: Dict[str, str]) -> bytes: + """``save`` と同一フォーマットで dict をバイト列化する (ファイル不要)。 + + ``io_import`` 側でも merge 結果を bytes として持つ必要があるため、 + フォーマット規則を 1 箇所 (このメソッド) に集約する (PR #15 gemini 指摘)。 + """ + lines = [EnvFile._format_kv_line(k, data[k]) for k in sorted(data)] + return ''.join(lines).encode('utf-8') + + @staticmethod + def dump_entries_bytes(entries: List[EnvEntry]) -> bytes: + """``parse_entries`` で得た entries を ``.env`` バイト列に戻す。 + + ``kv`` エントリは現在の ``value`` を ``dump_bytes`` と同じ規則で再フォーマット + する。``comment`` / ``blank`` は ``raw`` をそのまま保持して出力する。 + """ + lines: List[str] = [] + for e in entries: + if e.kind == 'kv' and e.key is not None: + lines.append(EnvFile._format_kv_line(e.key, e.value or '')) + else: + lines.append(e.raw + '\n') + return ''.join(lines).encode('utf-8') + def load(self) -> Dict[str, str]: """ファイルを読み込みkey=valueをパースする""" - self._data = {} - if not self.file_path.exists(): - self._loaded = True - return self._data - - with open(self.file_path, 'r', encoding='utf-8') as f: - for line in f: - line = line.strip() - - if not line or line.startswith('#'): - continue - - if '=' not in line: - continue - - key, _, value = line.partition('=') - key = key.strip() - value = value.strip() - - if value and value[0] == value[-1] and value[0] in ('"', "'"): - value = value[1:-1] - - self._data[key] = value - + self._data = {} + else: + self._data = self.parse_bytes(self.file_path.read_bytes()) self._loaded = True return self._data def save(self) -> None: """現在のデータを.envファイルに保存する""" self.file_path.parent.mkdir(parents=True, exist_ok=True) - - with open(self.file_path, 'w', encoding='utf-8') as f: - for key, value in sorted(self._data.items()): - if '\n' in value or any(c in value for c in (' ', '"', "'", '$', '`', '\\', '<', '>', '|', '&', ';', '(', ')', '#')): - value = value.replace('\\', '\\\\').replace('"', '\\"').replace('\n', '\\n') - f.write(f'{key}="{value}"\n') - else: - f.write(f'{key}={value}\n') - + self.file_path.write_bytes(self.dump_bytes(self._data)) os.chmod(self.file_path, 0o600) def backup(self) -> Optional[Path]: diff --git a/pyproject.toml b/pyproject.toml index e7aefd3..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..dd44b47 --- /dev/null +++ b/tests/cli/test_env_export.py @@ -0,0 +1,192 @@ +"""devbase env export の統合テスト (擬似 DEVBASE_ROOT)""" + +from __future__ import annotations + +import io +import os +from pathlib import Path + +import pyrage +import pytest + +from devbase.env import bundle, cipher +from devbase.env.io_export import ExportOptions, ExportError, _read_passphrase, export + + +@pytest.fixture +def fake_root(tmp_path): + root = tmp_path / "devbase-root" + (root / "projects" / "alpha").mkdir(parents=True) + (root / "projects" / "beta").mkdir(parents=True) + (root / ".env").write_text("AWS_CONFIG_BASE64=AAAA\nGLOBAL=1\n") + (root / ".env.sources.yml").write_text("sources: {}\n") + (root / "projects" / "alpha" / ".env").write_text("ALPHA_API_KEY=xyz\n") + (root / "projects" / "beta" / ".env").write_text("BETA_DB_PASSWORD=p\n") + return root + + +@pytest.fixture +def age_keys(tmp_path): + identity = pyrage.x25519.Identity.generate() + pub_file = tmp_path / "age.pub" + pub_file.write_text(str(identity.to_public()) + "\n") + id_file = tmp_path / "age.key" + id_file.write_text(str(identity)) + return pub_file, id_file + + +def test_export_local_with_recipient_roundtrips(fake_root, age_keys, tmp_path): + pub_file, id_file = age_keys + dest = tmp_path / "out.dbenv" + + rc = export(fake_root, ExportOptions( + dest=str(dest), + recipients=[f"@{pub_file}"], + )) + assert rc == 0 + assert dest.exists() + assert dest.stat().st_mode & 0o777 == 0o600 + + decrypted = cipher.decrypt(dest.read_bytes(), identities=[str(id_file)]) + manifest, members = bundle.unpack(decrypted) + + assert {e["path"] for e in manifest["files"]} == { + "env/global.env", + "env/sources.yml", + "env/projects/alpha/.env", + "env/projects/beta/.env", + } + assert members["env/global.env"] == b"AWS_CONFIG_BASE64=AAAA\nGLOBAL=1\n" + assert members["env/projects/alpha/.env"] == b"ALPHA_API_KEY=xyz\n" + + +def test_export_rejects_unencrypted_by_default(fake_root, tmp_path, monkeypatch): + monkeypatch.setattr(Path, "home", classmethod(lambda cls: tmp_path / "no-ssh")) + dest = tmp_path / "out.dbenv" + + with pytest.raises(ExportError, match="暗号化キー"): + export(fake_root, ExportOptions(dest=str(dest))) + + +def test_export_force_unencrypted_writes_plaintext_tar_gz(fake_root, tmp_path, caplog): + dest = tmp_path / "out.dbenv.tar.gz" + caplog.set_level("WARNING") + rc = export(fake_root, ExportOptions(dest=str(dest), force_unencrypted=True)) + assert rc == 0 + + # 機密キーが検知されて警告が出ること + assert any("機密キー" in r.message for r in caplog.records) + + manifest, members = bundle.unpack(dest.read_bytes()) + assert "env/global.env" in members + assert dest.stat().st_mode & 0o777 == 0o600 + + +def test_export_rejects_stdout_with_passphrase_stdin(fake_root): + with pytest.raises(ExportError, match="DEST='-'"): + export(fake_root, ExportOptions(dest="-", passphrase_stdin=True)) + + +def test_export_rejects_both_passphrase_env_and_stdin(fake_root): + with pytest.raises(ExportError, match="--passphrase-env"): + export(fake_root, ExportOptions( + dest="/dev/null", passphrase_env="X", passphrase_stdin=True)) + + +def test_read_passphrase_uses_getpass_on_tty(monkeypatch): + """tty 入力時は getpass.getpass を使い stdin.readline は呼ばない (エコー抑止)""" + fake_stdin = io.StringIO("should-not-be-read\n") + monkeypatch.setattr(fake_stdin, "isatty", lambda: True, raising=False) + monkeypatch.setattr("sys.stdin", fake_stdin) + + calls = {} + + def fake_getpass(prompt='', stream=None): + calls['prompt'] = prompt + calls['stream'] = stream + return "hunter2" + + monkeypatch.setattr("devbase.env.io_export.getpass.getpass", fake_getpass) + + pw = _read_passphrase(ExportOptions(passphrase_stdin=True)) + assert pw == "hunter2" + assert calls['prompt'] == "passphrase: " + assert fake_stdin.read() == "should-not-be-read\n" # stdin は消費されていない + + +def test_read_passphrase_falls_back_to_stdin_on_pipe(monkeypatch, capsys): + """パイプ (非 tty) 入力時は getpass を使わず stdin.readline で読む""" + fake_stdin = io.StringIO("hunter2\n") + monkeypatch.setattr(fake_stdin, "isatty", lambda: False, raising=False) + monkeypatch.setattr("sys.stdin", fake_stdin) + + def fail_getpass(*args, **kwargs): + raise AssertionError("getpass.getpass should not be called for piped stdin") + + monkeypatch.setattr("devbase.env.io_export.getpass.getpass", fail_getpass) + + pw = _read_passphrase(ExportOptions(passphrase_stdin=True)) + assert pw == "hunter2" + assert "passphrase" not in capsys.readouterr().err + + +def test_read_passphrase_tty_eof_raises_export_error(monkeypatch): + """tty で getpass が EOFError を投げた場合は ExportError に変換される""" + fake_stdin = io.StringIO("") + monkeypatch.setattr(fake_stdin, "isatty", lambda: True, raising=False) + monkeypatch.setattr("sys.stdin", fake_stdin) + + def raise_eof(*args, **kwargs): + raise EOFError() + + monkeypatch.setattr("devbase.env.io_export.getpass.getpass", raise_eof) + + with pytest.raises(ExportError, match="パスフレーズを読み取れません"): + _read_passphrase(ExportOptions(passphrase_stdin=True)) + + +def test_export_with_passphrase_env(fake_root, tmp_path, monkeypatch): + dest = tmp_path / "out.dbenv" + monkeypatch.setenv("DEVBASE_TEST_PASS", "s3cr3t") + rc = export(fake_root, ExportOptions( + dest=str(dest), passphrase_env="DEVBASE_TEST_PASS")) + assert rc == 0 + decrypted = cipher.decrypt(dest.read_bytes(), passphrase="s3cr3t") + bundle.unpack(decrypted) + + +def test_export_include_exclude_projects(fake_root, age_keys, tmp_path): + pub_file, id_file = age_keys + dest = tmp_path / "out.dbenv" + export(fake_root, ExportOptions( + dest=str(dest), + recipients=[f"@{pub_file}"], + include_projects=["alpha"], + )) + decrypted = cipher.decrypt(dest.read_bytes(), identities=[str(id_file)]) + _, members = bundle.unpack(decrypted) + assert "env/projects/alpha/.env" in members + assert "env/projects/beta/.env" not in members + + +def test_export_stdout_with_recipient(fake_root, age_keys, capsysbinary): + pub_file, id_file = age_keys + rc = export(fake_root, ExportOptions(dest="-", recipients=[f"@{pub_file}"])) + assert rc == 0 + out = capsysbinary.readouterr().out + decrypted = cipher.decrypt(out, identities=[str(id_file)]) + bundle.unpack(decrypted) + + +def test_export_uses_default_recipient_if_present(fake_root, tmp_path, monkeypatch, age_keys): + pub_file, id_file = age_keys + fake_home = tmp_path / "fake-home" + (fake_home / ".ssh").mkdir(parents=True) + (fake_home / ".ssh" / "id_rsa.pub").write_text(pub_file.read_text()) + monkeypatch.setattr(Path, "home", classmethod(lambda cls: fake_home)) + + dest = tmp_path / "out.dbenv" + rc = export(fake_root, ExportOptions(dest=str(dest))) + assert rc == 0 + decrypted = cipher.decrypt(dest.read_bytes(), identities=[str(id_file)]) + bundle.unpack(decrypted) diff --git a/tests/cli/test_env_import.py b/tests/cli/test_env_import.py new file mode 100644 index 0000000..f509895 --- /dev/null +++ b/tests/cli/test_env_import.py @@ -0,0 +1,941 @@ +"""devbase env import の統合テスト (擬似 DEVBASE_ROOT で round-trip / merge / replace / dry-run)""" + +from __future__ import annotations + +import io +import os +from pathlib import Path +from typing import Tuple + +import pyrage +import pytest + +from devbase.env import bundle, cipher +from devbase.env.io_export import ExportOptions, export +from devbase.env.io_import import ( + ImportError as ImportBundleError, + ImportOptions, + _read_passphrase, + import_bundle, +) + + +@pytest.fixture +def fake_root(tmp_path): + """export 用の擬似 DEVBASE_ROOT (PR1 と同じ構造)""" + root = tmp_path / "src-root" + (root / "projects" / "alpha").mkdir(parents=True) + (root / "projects" / "beta").mkdir(parents=True) + (root / ".env").write_text("AWS_CONFIG_BASE64=AAAA\nGLOBAL=1\n") + (root / ".env.sources.yml").write_text( + "sources:\n aws:\n type: tar_base64\n hash: deadbeef\n" + ) + (root / "projects" / "alpha" / ".env").write_text("ALPHA_API_KEY=xyz\n") + (root / "projects" / "beta" / ".env").write_text("BETA_DB_PASSWORD=p\n") + return root + + +@pytest.fixture +def dest_root(tmp_path): + """import 先の擬似 DEVBASE_ROOT (空 or 既存ファイルあり)""" + root = tmp_path / "dst-root" + (root / "projects" / "alpha").mkdir(parents=True) + (root / "projects" / "beta").mkdir(parents=True) + return root + + +@pytest.fixture +def age_keys(tmp_path): + identity = pyrage.x25519.Identity.generate() + pub_file = tmp_path / "age.pub" + pub_file.write_text(str(identity.to_public()) + "\n") + id_file = tmp_path / "age.key" + id_file.write_text(str(identity)) + return pub_file, id_file + + +def _export_bundle(fake_root: Path, age_keys: Tuple[Path, Path], + tmp_path: Path) -> Path: + pub_file, _ = age_keys + dest = tmp_path / "out.dbenv" + rc = export(fake_root, ExportOptions( + dest=str(dest), recipients=[f"@{pub_file}"])) + assert rc == 0 + return dest + + +def test_import_roundtrip_creates_files_with_0600(fake_root, dest_root, age_keys, tmp_path): + _, id_file = age_keys + bundle_path = _export_bundle(fake_root, age_keys, tmp_path) + + rc = import_bundle(dest_root, ImportOptions( + source=str(bundle_path), identities=[str(id_file)])) + assert rc == 0 + + # global と各 project の .env が復元されている + assert (dest_root / ".env").read_text() == "AWS_CONFIG_BASE64=AAAA\nGLOBAL=1\n" + assert (dest_root / "projects" / "alpha" / ".env").read_text() == "ALPHA_API_KEY=xyz\n" + assert (dest_root / "projects" / "beta" / ".env").read_text() == "BETA_DB_PASSWORD=p\n" + + # パーミッションが 0600 + assert (dest_root / ".env").stat().st_mode & 0o777 == 0o600 + assert (dest_root / "projects" / "alpha" / ".env").stat().st_mode & 0o777 == 0o600 + + # sources.yml は既定では上書きしないので存在しない + assert not (dest_root / ".env.sources.yml").exists() + # backup ディレクトリに参照用 sources.yml.imported が残る + backup_root = dest_root / "backups" / "env-import" + assert backup_root.is_dir() + sub = next(backup_root.iterdir()) + assert (sub / "sources.yml.imported").exists() + + +def test_import_dry_run_does_not_modify(fake_root, dest_root, age_keys, tmp_path): + _, id_file = age_keys + bundle_path = _export_bundle(fake_root, age_keys, tmp_path) + + # 既存ファイルを置く + (dest_root / ".env").write_text("EXISTING=keep\n") + os.chmod(dest_root / ".env", 0o600) + + rc = import_bundle(dest_root, ImportOptions( + source=str(bundle_path), identities=[str(id_file)], dry_run=True)) + assert rc == 0 + + # 元の .env は変更されていない + assert (dest_root / ".env").read_text() == "EXISTING=keep\n" + # backup も作られない + assert not (dest_root / "backups").exists() + + +def test_import_keep_existing_only_adds_new_keys(fake_root, dest_root, age_keys, tmp_path): + _, id_file = age_keys + bundle_path = _export_bundle(fake_root, age_keys, tmp_path) + + (dest_root / ".env").write_text("AWS_CONFIG_BASE64=OLD\nKEEP=this\n") + os.chmod(dest_root / ".env", 0o600) + + rc = import_bundle(dest_root, ImportOptions( + source=str(bundle_path), identities=[str(id_file)])) + assert rc == 0 + + text = (dest_root / ".env").read_text() + # 既存の AWS_CONFIG_BASE64 は OLD のまま (keep-existing) + assert "AWS_CONFIG_BASE64=OLD" in text + # 新規キー GLOBAL=1 は追加される + assert "GLOBAL=1" in text + # 既存キー KEEP は残る + assert "KEEP=this" in text + + +def test_import_prefer_incoming_overwrites_existing(fake_root, dest_root, age_keys, tmp_path): + _, id_file = age_keys + bundle_path = _export_bundle(fake_root, age_keys, tmp_path) + + (dest_root / ".env").write_text("AWS_CONFIG_BASE64=OLD\nKEEP=this\n") + os.chmod(dest_root / ".env", 0o600) + + rc = import_bundle(dest_root, ImportOptions( + source=str(bundle_path), identities=[str(id_file)], + merge='prefer-incoming')) + assert rc == 0 + + text = (dest_root / ".env").read_text() + # バンドル側で上書きされる + assert "AWS_CONFIG_BASE64=AAAA" in text + # incoming に無い既存キーは残る + assert "KEEP=this" in text + + +def test_import_replace_keys_only_overwrites_specified(fake_root, dest_root, age_keys, tmp_path): + _, id_file = age_keys + bundle_path = _export_bundle(fake_root, age_keys, tmp_path) + + (dest_root / ".env").write_text("AWS_CONFIG_BASE64=OLD\nGLOBAL=KEEP\n") + os.chmod(dest_root / ".env", 0o600) + + rc = import_bundle(dest_root, ImportOptions( + source=str(bundle_path), identities=[str(id_file)], + replace_keys=['AWS_CONFIG_BASE64'])) + assert rc == 0 + + text = (dest_root / ".env").read_text() + assert "AWS_CONFIG_BASE64=AAAA" in text # 上書きされる + assert "GLOBAL=KEEP" in text # 指定外なので keep + + +def test_import_replace_takes_backup_and_replaces(fake_root, dest_root, age_keys, tmp_path): + _, id_file = age_keys + bundle_path = _export_bundle(fake_root, age_keys, tmp_path) + + (dest_root / ".env").write_text("OLD=value\n") + os.chmod(dest_root / ".env", 0o600) + + rc = import_bundle(dest_root, ImportOptions( + source=str(bundle_path), identities=[str(id_file)], replace=True)) + assert rc == 0 + + text = (dest_root / ".env").read_text() + assert "AWS_CONFIG_BASE64=AAAA" in text + assert "GLOBAL=1" in text + assert "OLD=value" not in text # 完全に置き換わる + + backup_root = dest_root / "backups" / "env-import" + sub = next(backup_root.iterdir()) + assert (sub / ".env").read_text() == "OLD=value\n" + + +def test_import_rejects_replace_with_replace_keys(fake_root, dest_root, age_keys, tmp_path): + _, id_file = age_keys + bundle_path = _export_bundle(fake_root, age_keys, tmp_path) + with pytest.raises(ImportBundleError, match="--replace と --replace-keys"): + import_bundle(dest_root, ImportOptions( + source=str(bundle_path), identities=[str(id_file)], + replace=True, replace_keys=['A'])) + + +def test_import_rejects_stdin_with_passphrase_stdin(dest_root): + with pytest.raises(ImportBundleError, match="SOURCE='-'"): + import_bundle(dest_root, ImportOptions( + source='-', passphrase_stdin=True)) + + +def test_import_rejects_both_passphrase_env_and_stdin(dest_root): + with pytest.raises(ImportBundleError, match="--passphrase-env"): + import_bundle(dest_root, ImportOptions( + source='/dev/null', passphrase_env='X', passphrase_stdin=True)) + + +def test_read_passphrase_uses_getpass_on_tty(monkeypatch): + """tty 入力時は getpass.getpass を使い stdin.readline は呼ばない (エコー抑止)""" + fake_stdin = io.StringIO("should-not-be-read\n") + monkeypatch.setattr(fake_stdin, "isatty", lambda: True, raising=False) + monkeypatch.setattr("sys.stdin", fake_stdin) + + calls = {} + + def fake_getpass(prompt='', stream=None): + calls['prompt'] = prompt + calls['stream'] = stream + return "hunter2" + + monkeypatch.setattr("devbase.env.io_import.getpass.getpass", fake_getpass) + + pw = _read_passphrase(ImportOptions(source='/dev/null', passphrase_stdin=True)) + assert pw == "hunter2" + assert calls['prompt'] == "passphrase: " + assert fake_stdin.read() == "should-not-be-read\n" # stdin は消費されていない + + +def test_read_passphrase_falls_back_to_stdin_on_pipe(monkeypatch, capsys): + """パイプ (非 tty) 入力時は getpass を使わず stdin.readline で読む""" + fake_stdin = io.StringIO("piped-pass\n") + monkeypatch.setattr(fake_stdin, "isatty", lambda: False, raising=False) + monkeypatch.setattr("sys.stdin", fake_stdin) + + def fail_getpass(*args, **kwargs): + raise AssertionError("getpass.getpass should not be called for piped stdin") + + monkeypatch.setattr("devbase.env.io_import.getpass.getpass", fail_getpass) + + pw = _read_passphrase(ImportOptions(source='/dev/null', passphrase_stdin=True)) + assert pw == "piped-pass" + assert "passphrase" not in capsys.readouterr().err + + +def test_read_passphrase_tty_eof_raises_import_error(monkeypatch): + """tty で getpass が EOFError を投げた場合は ImportError に変換される""" + fake_stdin = io.StringIO("") + monkeypatch.setattr(fake_stdin, "isatty", lambda: True, raising=False) + monkeypatch.setattr("sys.stdin", fake_stdin) + + def raise_eof(*args, **kwargs): + raise EOFError() + + monkeypatch.setattr("devbase.env.io_import.getpass.getpass", raise_eof) + + with pytest.raises(ImportBundleError, match="パスフレーズを読み取れません"): + _read_passphrase(ImportOptions(source='/dev/null', passphrase_stdin=True)) + + +def test_import_rejects_unknown_manifest_version(fake_root, dest_root, age_keys, tmp_path): + """manifest.version が SUPPORTED_MANIFEST_VERSION より大きいバンドルは拒否される""" + import gzip + import io as _io + import tarfile + import yaml + + pub_file, id_file = age_keys + # 通常 export + bundle_path = _export_bundle(fake_root, age_keys, tmp_path) + # 復号して中身を書き換えてから age で再暗号化する + plain = cipher.decrypt(bundle_path.read_bytes(), identities=[str(id_file)]) + + # tar.gz を再構築して manifest.version=999 にする + buf_in = _io.BytesIO(plain) + tin = tarfile.open(fileobj=buf_in, mode='r:gz') + out = _io.BytesIO() + with gzip.GzipFile(fileobj=out, mode='wb', mtime=0) as gz: + with tarfile.open(fileobj=gz, mode='w', format=tarfile.PAX_FORMAT) as tout: + for info in tin.getmembers(): + data = tin.extractfile(info).read() + if info.name == bundle.MANIFEST_NAME: + manifest = yaml.safe_load(data) + manifest['version'] = 999 + data = yaml.safe_dump(manifest, sort_keys=False).encode('utf-8') + ti = tarfile.TarInfo(name=info.name) + ti.size = len(data) + ti.mtime = 0 + ti.mode = 0o600 + tout.addfile(ti, _io.BytesIO(data)) + tin.close() + + bad_plain = out.getvalue() + bad = pyrage.encrypt(bad_plain, + [pyrage.ssh.Recipient.from_str(pub_file.read_text().strip())] + if pub_file.read_text().strip().startswith('ssh-') + else [pyrage.x25519.Recipient.from_str(pub_file.read_text().strip())]) + bad_path = tmp_path / "bad.dbenv" + bad_path.write_bytes(bad) + + with pytest.raises(bundle.BundleError, match="サポートされていません"): + import_bundle(dest_root, ImportOptions( + source=str(bad_path), identities=[str(id_file)])) + + +def test_import_preserves_lf_line_endings(fake_root, dest_root, age_keys, tmp_path): + _, id_file = age_keys + # CRLF を排除した想定: export → import で LF が保持されること + (fake_root / ".env").write_text("A=1\nB=2\n") + bundle_path = _export_bundle(fake_root, age_keys, tmp_path) + + rc = import_bundle(dest_root, ImportOptions( + source=str(bundle_path), identities=[str(id_file)])) + assert rc == 0 + raw = (dest_root / ".env").read_bytes() + assert b'\r' not in raw + assert raw.endswith(b'\n') + + +def test_import_keep_last_gc_removes_old_backups(fake_root, dest_root, age_keys, tmp_path): + _, id_file = age_keys + bundle_path = _export_bundle(fake_root, age_keys, tmp_path) + + backup_root = dest_root / "backups" / "env-import" + # 既存の古い backup を 5 個事前作成する (タイムスタンプ命名規則に合わせる) + backup_root.mkdir(parents=True) + for i in range(5): + (backup_root / f"20260101-00000{i}").mkdir() + + rc = import_bundle(dest_root, ImportOptions( + source=str(bundle_path), identities=[str(id_file)], keep_last=3)) + assert rc == 0 + + remaining = sorted(p.name for p in backup_root.iterdir()) + assert len(remaining) == 3 + # 最新 3 個に絞られる: 既存の 20260101-000003, 000004, 加えて新規 backup + assert remaining[-1].startswith('20') + + +def test_import_include_project_filter(fake_root, dest_root, age_keys, tmp_path): + _, id_file = age_keys + bundle_path = _export_bundle(fake_root, age_keys, tmp_path) + + rc = import_bundle(dest_root, ImportOptions( + source=str(bundle_path), identities=[str(id_file)], + include_projects=['alpha'])) + assert rc == 0 + assert (dest_root / "projects" / "alpha" / ".env").exists() + assert not (dest_root / "projects" / "beta" / ".env").exists() + + +def test_import_plaintext_bundle(fake_root, dest_root, tmp_path): + """--force-unencrypted で出力した平文 tar.gz もそのまま import できる""" + dest = tmp_path / "out.dbenv.tar.gz" + rc = export(fake_root, ExportOptions(dest=str(dest), force_unencrypted=True)) + assert rc == 0 + + rc = import_bundle(dest_root, ImportOptions(source=str(dest))) + assert rc == 0 + assert (dest_root / ".env").exists() + + +def test_import_merge_metadata_adds_only_new_sources(fake_root, dest_root, age_keys, tmp_path): + _, id_file = age_keys + # 既存 sources.yml を用意 (aws のみ。bundle 側も aws を持つ) + (dest_root / ".env.sources.yml").write_text( + "sources:\n aws:\n type: tar_base64\n hash: existinghash\n" + ) + bundle_path = _export_bundle(fake_root, age_keys, tmp_path) + + rc = import_bundle(dest_root, ImportOptions( + source=str(bundle_path), identities=[str(id_file)], + merge_metadata=True)) + assert rc == 0 + + import yaml as _yaml + data = _yaml.safe_load((dest_root / ".env.sources.yml").read_text()) + # 既存 aws は維持される (hash=existinghash のまま) + assert data['sources']['aws']['hash'] == 'existinghash' + + +def test_import_no_metadata_skips_sources_yml(fake_root, dest_root, age_keys, tmp_path): + _, id_file = age_keys + bundle_path = _export_bundle(fake_root, age_keys, tmp_path) + + rc = import_bundle(dest_root, ImportOptions( + source=str(bundle_path), identities=[str(id_file)], + include_metadata=False)) + assert rc == 0 + # 参照用コピーも作られない (filter で除外されるため) + backup_root = dest_root / "backups" / "env-import" + sub = next(backup_root.iterdir()) + assert not (sub / "sources.yml.imported").exists() + + +def test_import_replace_keys_adds_unspecified_new_keys(fake_root, dest_root, age_keys, tmp_path): + """--replace-keys 指定外でも、既存ファイルに無い incoming キーは追加される + (CLI help 'other keys behave like keep-existing' に整合)""" + _, id_file = age_keys + bundle_path = _export_bundle(fake_root, age_keys, tmp_path) + + # 既存は AWS_CONFIG_BASE64 のみ。incoming は AWS_CONFIG_BASE64 + GLOBAL=1 + (dest_root / ".env").write_text("AWS_CONFIG_BASE64=OLD\n") + os.chmod(dest_root / ".env", 0o600) + + rc = import_bundle(dest_root, ImportOptions( + source=str(bundle_path), identities=[str(id_file)], + replace_keys=['AWS_CONFIG_BASE64'])) + assert rc == 0 + + text = (dest_root / ".env").read_text() + assert "AWS_CONFIG_BASE64=AAAA" in text # 指定キーは上書き + assert "GLOBAL=1" in text # 指定外でも既存に無い新規キーは追加される (keep-existing 相当) + + +def test_rollback_unlinks_newly_created_files_on_commit_failure( + fake_root, dest_root, age_keys, tmp_path, monkeypatch): + """commit フェーズ途中失敗時、元ファイル不在で新規作成された target は unlink され、 + 部分適用状態が残らないこと""" + from devbase.env import io_import as _io_import + + _, id_file = age_keys + bundle_path = _export_bundle(fake_root, age_keys, tmp_path) + + # dest には元ファイルが一切無い (= 全 plan op='create') + assert not (dest_root / ".env").exists() + assert not (dest_root / "projects" / "alpha" / ".env").exists() + + # 2 つ目以降の os.replace で失敗させる + original_replace = os.replace + call_count = {'n': 0} + + def failing_replace(src, dst): + call_count['n'] += 1 + if call_count['n'] >= 2: + raise OSError("simulated commit failure") + return original_replace(src, dst) + + monkeypatch.setattr(_io_import.os, 'replace', failing_replace) + + with pytest.raises(_io_import.ImportError, match="commit フェーズで失敗"): + import_bundle(dest_root, ImportOptions( + source=str(bundle_path), identities=[str(id_file)])) + + # rollback で新規作成 (op='create') の .env は削除されていること + assert not (dest_root / ".env").exists() + # まだ commit されていない target ももちろん存在しない + assert not (dest_root / "projects" / "beta" / ".env").exists() + + +def test_gc_backups_only_removes_timestamp_dirs(fake_root, dest_root, age_keys, tmp_path): + """--backup-dir 指定時でも、devbase が作った timestamp 形式以外のディレクトリは + GC で削除されない""" + _, id_file = age_keys + bundle_path = _export_bundle(fake_root, age_keys, tmp_path) + + custom_backup_root = tmp_path / "user-backups" + custom_backup_root.mkdir() + # 関係ないディレクトリ + unrelated = custom_backup_root / "important-user-data" + unrelated.mkdir() + (unrelated / "keep.txt").write_text("must not be deleted") + # 関係ないファイル + unrelated_file = custom_backup_root / "readme.txt" + unrelated_file.write_text("must not be deleted") + # devbase 命名の古い backup を keep_last 超に置く + for i in range(5): + (custom_backup_root / f"20240101-00000{i}").mkdir() + + rc = import_bundle(dest_root, ImportOptions( + source=str(bundle_path), identities=[str(id_file)], + backup_dir=str(custom_backup_root), keep_last=3)) + assert rc == 0 + + # 無関係なディレクトリ/ファイルは残る + assert unrelated.exists() + assert (unrelated / "keep.txt").exists() + assert unrelated_file.exists() + # timestamp 形式は keep_last=3 まで絞られる (新規 backup 含む) + timestamp_dirs = sorted( + p.name for p in custom_backup_root.iterdir() + if p.is_dir() and p.name not in ('important-user-data',) + ) + assert len(timestamp_dirs) == 3 + + +def test_import_passphrase_env_roundtrip(fake_root, dest_root, tmp_path, monkeypatch): + dest = tmp_path / "out.dbenv" + monkeypatch.setenv("DEVBASE_TEST_PASS", "s3cr3t") + rc = export(fake_root, ExportOptions( + dest=str(dest), passphrase_env="DEVBASE_TEST_PASS")) + assert rc == 0 + + rc = import_bundle(dest_root, ImportOptions( + source=str(dest), passphrase_env="DEVBASE_TEST_PASS")) + assert rc == 0 + assert (dest_root / ".env").exists() + + +def test_import_preserves_escaped_values_no_double_escape( + dest_root, age_keys, tmp_path): + """値に backslash / quote / newline / spaces が含まれていても + export → import で二重エスケープされないことを保証する (PR #15 codex 指摘)""" + _, id_file = age_keys + pub_file, _ = age_keys + + # 特殊文字を含む .env を持つ source root を構築 + src_root = tmp_path / "esc-src" + (src_root / "projects" / "alpha").mkdir(parents=True) + raw_env = ( + 'BACKSLASH="a\\\\b"\n' # 値: a\b (3 chars) + 'QUOTE_IN_VALUE="he said \\"hi\\""\n' # 値: he said "hi" + 'WITH_NEWLINE="line1\\nline2"\n' # 値: line1line2 + 'WITH_SPACE="value with space"\n' # 値: value with space + 'PLAIN=simple\n' # 値: simple + ) + (src_root / ".env").write_text(raw_env) + (src_root / "projects" / "alpha" / ".env").write_text( + 'ALPHA_BACK="a\\\\b"\n' + ) + + bundle_path = tmp_path / "esc.dbenv" + rc = export(src_root, ExportOptions( + dest=str(bundle_path), recipients=[f"@{pub_file}"])) + assert rc == 0 + + # 新規作成 (dest 側に既存ファイル無し) + rc = import_bundle(dest_root, ImportOptions( + source=str(bundle_path), identities=[str(id_file)])) + assert rc == 0 + + # 新規作成時は incoming_bytes をそのまま使うので元バイト列と一致する + assert (dest_root / ".env").read_text() == raw_env + + # EnvFile から読んだ際に escape が正しく解釈されること (parse_bytes round-trip) + from devbase.env.store import EnvFile + parsed = EnvFile.parse_bytes((dest_root / ".env").read_bytes()) + assert parsed['BACKSLASH'] == 'a\\b' + assert parsed['QUOTE_IN_VALUE'] == 'he said "hi"' + assert parsed['WITH_NEWLINE'] == 'line1\nline2' + assert parsed['WITH_SPACE'] == 'value with space' + assert parsed['PLAIN'] == 'simple' + + +def test_import_merge_round_trips_escaped_values( + dest_root, age_keys, tmp_path): + """既存ファイルがあって merge する場合でも、parse → format の round-trip で + 値が壊れない (二重エスケープしない)""" + _, id_file = age_keys + pub_file, _ = age_keys + + src_root = tmp_path / "esc-src2" + (src_root / "projects" / "alpha").mkdir(parents=True) + (src_root / ".env").write_text('NEW_BACK="a\\\\b"\n') + + bundle_path = tmp_path / "esc2.dbenv" + rc = export(src_root, ExportOptions( + dest=str(bundle_path), recipients=[f"@{pub_file}"])) + assert rc == 0 + + # dest に既存ファイルを置く (merge 経路に入る) + (dest_root / ".env").write_text('EXISTING="x\\\\y"\n') + os.chmod(dest_root / ".env", 0o600) + + rc = import_bundle(dest_root, ImportOptions( + source=str(bundle_path), identities=[str(id_file)], + merge='prefer-incoming')) + assert rc == 0 + + from devbase.env.store import EnvFile + parsed = EnvFile.parse_bytes((dest_root / ".env").read_bytes()) + # 二重エスケープされていないので、parse 後の値は元の 3 文字 "a\\b" + assert parsed['NEW_BACK'] == 'a\\b' + assert parsed['EXISTING'] == 'x\\y' + + +def test_rollback_unlinks_newly_created_sources_yml( + fake_root, dest_root, age_keys, tmp_path, monkeypatch): + """sources.yml を --merge-metadata で新規作成中に commit 失敗すると、 + ロールバックで sources.yml が削除されること (PR #15 gemini 指摘)""" + from devbase.env import io_import as _io_import + + _, id_file = age_keys + bundle_path = _export_bundle(fake_root, age_keys, tmp_path) + + # dest には sources.yml が無い状態。--merge-metadata で新規作成パスに入る + assert not (dest_root / ".env.sources.yml").exists() + + # commit 中に sources.yml の rename だけ失敗させる (最後のファイル) + original_replace = os.replace + + def failing_replace(src, dst): + if str(dst).endswith('.env.sources.yml'): + raise OSError("simulated commit failure on sources.yml") + return original_replace(src, dst) + + monkeypatch.setattr(_io_import.os, 'replace', failing_replace) + + with pytest.raises(_io_import.ImportError, match="commit"): + import_bundle(dest_root, ImportOptions( + source=str(bundle_path), identities=[str(id_file)], + merge_metadata=True)) + + # sources.yml はもともと存在しなかったので、ロールバックで unlink されているはず + assert not (dest_root / ".env.sources.yml").exists() + + +def test_commit_failure_cleans_remaining_import_tmp_files( + fake_root, dest_root, age_keys, tmp_path, monkeypatch): + """_commit 失敗時に、まだ rename されていない .import.tmp ファイルが残らないこと + (PR #15 gemini 指摘)""" + from devbase.env import io_import as _io_import + + _, id_file = age_keys + bundle_path = _export_bundle(fake_root, age_keys, tmp_path) + + original_replace = os.replace + call_count = {'n': 0} + + def failing_replace(src, dst): + call_count['n'] += 1 + if call_count['n'] >= 2: + raise OSError("simulated commit failure") + return original_replace(src, dst) + + monkeypatch.setattr(_io_import.os, 'replace', failing_replace) + + with pytest.raises(_io_import.ImportError, match="commit"): + import_bundle(dest_root, ImportOptions( + source=str(bundle_path), identities=[str(id_file)])) + + # 残骸の .import.tmp ファイルが無いこと + leftover = list(dest_root.rglob('*.import.tmp')) + assert leftover == [], f"残骸の tmp が残っている: {leftover}" + + +def test_backup_dir_collision_avoidance(fake_root, dest_root, age_keys, tmp_path): + """同じプロセス内で連続して import を実行しても、backup ディレクトリ名が衝突せず + 前回バックアップを上書きしないこと (PR #15 codex 指摘)""" + _, id_file = age_keys + bundle_path = _export_bundle(fake_root, age_keys, tmp_path) + + # 1 回目 + rc = import_bundle(dest_root, ImportOptions( + source=str(bundle_path), identities=[str(id_file)])) + assert rc == 0 + # 2 回目 (同一プロセス内, おそらく同一秒) + rc = import_bundle(dest_root, ImportOptions( + source=str(bundle_path), identities=[str(id_file)])) + assert rc == 0 + + backup_root = dest_root / "backups" / "env-import" + subdirs = sorted(p.name for p in backup_root.iterdir() if p.is_dir()) + # 2 つの異なる backup ディレクトリが残っていること + assert len(subdirs) == 2, f"backup が衝突して 1 つになっている: {subdirs}" + + +def test_envfile_parse_bytes_round_trip_with_escapes(): + """``EnvFile.parse_bytes`` が ``save`` が施す escape を正しく逆変換すること + (PR #15 codex 指摘の double-escape 回避テスト)""" + from devbase.env.store import EnvFile + + # 直接 EnvFile.save と同じ規則で encode したものを parse_bytes で復元 + raw = ( + 'BACKSLASH="a\\\\b"\n' # a\b + 'QUOTED="he said \\"hi\\""\n' # he said "hi" + 'NL="x\\ny"\n' # xy + 'PLAIN=simple\n' + 'EMPTY=""\n' # empty string with quotes + ) + parsed = EnvFile.parse_bytes(raw.encode('utf-8')) + assert parsed['BACKSLASH'] == 'a\\b' + assert parsed['QUOTED'] == 'he said "hi"' + assert parsed['NL'] == 'x\ny' + assert parsed['PLAIN'] == 'simple' + assert parsed['EMPTY'] == '' + + # 「リテラル ``\\n``」(2 文字: backslash + 'n') を含む値も区別できること + # save は ``a\\nb`` (3 chars) を ``"a\\\\nb"`` に変換するので、これを parse_bytes + # に通せば元の 3 文字に戻る + raw2 = 'LITERAL="a\\\\nb"\n' + parsed2 = EnvFile.parse_bytes(raw2.encode('utf-8')) + assert parsed2['LITERAL'] == 'a\\nb' # backslash + 'n' + 'b' (3 chars) + + +def test_envfile_dollar_escape_round_trip(): + """``$`` を含む値は ``\\$`` にエスケープされ、``source`` 時に変数展開されない + (PR #15 gemini 指摘)""" + from devbase.env.store import EnvFile + + # dump → parse の round-trip で値が保たれる + data = { + 'DOLLAR': '$HOME', # 単純な変数展開を含む + 'PRICE': 'cost is $100', # 値内の $ + 'ESCAPED_LIKE': 'a\\$b', # backslash + $ の組み合わせ + 'PLAIN_NUM': '12345', # quote 不要なケース + } + dumped = EnvFile.dump_bytes(data) + text = dumped.decode('utf-8') + # $ が裸 (バックスラッシュ無し) で出力されていないこと + # ($ の直前は必ず \ である or 行の終端 / 別の \\) + for line in text.splitlines(): + if '=' in line and '"' in line: + # ダブルクオート内に裸の $ があるかチェック + _, _, val = line.partition('=') + # 値部分の $ をすべて検査: 直前の文字が \\ であること + for idx, ch in enumerate(val): + if ch == '$': + assert idx > 0 and val[idx - 1] == '\\', ( + f"unescaped $ in dump: {line!r}" + ) + parsed = EnvFile.parse_bytes(dumped) + assert parsed == data + + +def test_env_import_merge_preserves_comments_and_blanks( + fake_root, dest_root, age_keys, tmp_path): + """merge 経路で既存 ``.env`` のコメントと空行が保持されること (PR #15 gemini 指摘)""" + _, id_file = age_keys + pub_file, _ = age_keys + + # incoming bundle: AWS_CONFIG_BASE64=AAAA + GLOBAL=1 + src_root = tmp_path / "comment-src" + src_root.mkdir() + (src_root / ".env").write_text("AWS_CONFIG_BASE64=AAAA\nGLOBAL=1\n") + bundle_path = tmp_path / "comment.dbenv" + rc = export(src_root, ExportOptions( + dest=str(bundle_path), recipients=[f"@{pub_file}"])) + assert rc == 0 + + # 既存 dest .env にコメント・空行・既存キーを配置 + existing_text = ( + "# Top-level header comment\n" + "\n" + "# AWS section\n" + "AWS_CONFIG_BASE64=OLD\n" + "\n" + "# user-managed key\n" + "KEEP=this\n" + ) + (dest_root / ".env").write_text(existing_text) + os.chmod(dest_root / ".env", 0o600) + + rc = import_bundle(dest_root, ImportOptions( + source=str(bundle_path), identities=[str(id_file)], + merge='prefer-incoming')) + assert rc == 0 + + out = (dest_root / ".env").read_text() + # コメント・空行が保持されている + assert "# Top-level header comment" in out + assert "# AWS section" in out + assert "# user-managed key" in out + # 既存値は prefer-incoming で AAAA に書き換わる + assert "AWS_CONFIG_BASE64=AAAA" in out + # 既存にしか無かった KEEP は維持 + assert "KEEP=this" in out + # incoming にしか無かった GLOBAL は末尾に追加 + assert "GLOBAL=1" in out + # 空行も最低 1 つ残っている + assert "\n\n" in out + + +def test_env_import_keep_existing_preserves_comments( + fake_root, dest_root, age_keys, tmp_path): + """keep-existing 経路でもコメントが保持されること""" + _, id_file = age_keys + pub_file, _ = age_keys + + src_root = tmp_path / "keep-src" + src_root.mkdir() + (src_root / ".env").write_text("INCOMING_KEY=incoming\n") + bundle_path = tmp_path / "keep.dbenv" + rc = export(src_root, ExportOptions( + dest=str(bundle_path), recipients=[f"@{pub_file}"])) + assert rc == 0 + + (dest_root / ".env").write_text( + "# This comment must survive\nKEEP_ME=v\n" + ) + os.chmod(dest_root / ".env", 0o600) + + rc = import_bundle(dest_root, ImportOptions( + source=str(bundle_path), identities=[str(id_file)])) + assert rc == 0 + + out = (dest_root / ".env").read_text() + assert "# This comment must survive" in out + assert "KEEP_ME=v" in out + assert "INCOMING_KEY=incoming" in out + + +def test_env_import_dollar_value_is_escaped_after_merge( + fake_root, dest_root, age_keys, tmp_path): + """``$`` を含む値が merge 後の ``.env`` 上でエスケープされていること + (シェルで source した時の変数展開を防ぐ / PR #15 gemini 指摘)""" + _, id_file = age_keys + pub_file, _ = age_keys + + src_root = tmp_path / "dollar-src" + src_root.mkdir() + # 値に $ を含む。export 側 (EnvFile.save 形式) で書き出される + (src_root / ".env").write_text('PRICE="cost is \\$100"\n') + bundle_path = tmp_path / "dollar.dbenv" + rc = export(src_root, ExportOptions( + dest=str(bundle_path), recipients=[f"@{pub_file}"])) + assert rc == 0 + + # 既存 dest .env (merge 経路に入る) + (dest_root / ".env").write_text("EXISTING=keep\n") + os.chmod(dest_root / ".env", 0o600) + + rc = import_bundle(dest_root, ImportOptions( + source=str(bundle_path), identities=[str(id_file)], + merge='prefer-incoming')) + assert rc == 0 + + raw_text = (dest_root / ".env").read_text() + # 裸の $ が現れていないこと (\ の直後でなければ NG) + for line in raw_text.splitlines(): + if 'PRICE' not in line: + continue + # 値の中の $ は必ず \\ の直後 + _, _, val = line.partition('=') + for idx, ch in enumerate(val): + if ch == '$': + assert idx > 0 and val[idx - 1] == '\\', ( + f"unescaped $ in merged .env: {line!r}" + ) + + from devbase.env.store import EnvFile + parsed = EnvFile.parse_bytes((dest_root / ".env").read_bytes()) + assert parsed['PRICE'] == 'cost is $100' + assert parsed['EXISTING'] == 'keep' + + +# --- PR #15 round5: コメント / 空行のみの既存 .env が create 扱いされて +# 上書きされないこと (`existing` dict が空でも target.exists() で merge に入る)。 + +def _setup_comment_only_dest(dest_root: Path) -> str: + """key=value を含まずコメント / 空行のみで構成された既存 .env を作る""" + text = ( + "# user-managed header (no kv yet)\n" + "\n" + "# section: aws\n" + "\n" + ) + (dest_root / ".env").write_text(text) + os.chmod(dest_root / ".env", 0o600) + return text + + +def _build_simple_bundle(tmp_path: Path, pub_file: Path, + name: str = "comment-only") -> Path: + src_root = tmp_path / f"{name}-src" + src_root.mkdir() + (src_root / ".env").write_text("INCOMING_KEY=incoming\n") + bundle_path = tmp_path / f"{name}.dbenv" + rc = export(src_root, ExportOptions( + dest=str(bundle_path), recipients=[f"@{pub_file}"])) + assert rc == 0 + return bundle_path + + +@pytest.mark.parametrize("merge_mode", ["prefer-incoming", "keep-existing"]) +def test_env_import_comment_only_existing_preserves_comments_on_merge( + fake_root, dest_root, age_keys, tmp_path, merge_mode): + """コメント / 空行のみの既存 .env が ``existing`` 辞書の空判定で create 扱いに + なって上書きされ、ヘッダコメントが消失しないこと (PR #15 round5 指摘)""" + _, id_file = age_keys + pub_file, _ = age_keys + bundle_path = _build_simple_bundle(tmp_path, pub_file, "comment-merge") + + _setup_comment_only_dest(dest_root) + + rc = import_bundle(dest_root, ImportOptions( + source=str(bundle_path), identities=[str(id_file)], + merge=merge_mode)) + assert rc == 0 + + out = (dest_root / ".env").read_text() + assert "# user-managed header (no kv yet)" in out + assert "# section: aws" in out + assert "INCOMING_KEY=incoming" in out + + +def test_env_import_comment_only_existing_preserves_comments_on_replace_keys( + fake_root, dest_root, age_keys, tmp_path): + """--replace-keys 経路でも、コメントのみの既存 .env が create 扱いされず + コメントが保持されること (PR #15 round5 指摘)""" + _, id_file = age_keys + pub_file, _ = age_keys + bundle_path = _build_simple_bundle(tmp_path, pub_file, "comment-rk") + + _setup_comment_only_dest(dest_root) + + rc = import_bundle(dest_root, ImportOptions( + source=str(bundle_path), identities=[str(id_file)], + replace_keys=["INCOMING_KEY"])) + assert rc == 0 + + out = (dest_root / ".env").read_text() + assert "# user-managed header (no kv yet)" in out + assert "# section: aws" in out + assert "INCOMING_KEY=incoming" in out + + +def test_env_import_comment_only_existing_replace_reports_op_replace( + fake_root, dest_root, age_keys, tmp_path, caplog): + """--replace 経路では incoming で完全上書きするが、その op は 'create' では + なく 'replace' として報告されること (PR #15 round5 指摘)。 + + --replace の意味論として既存内容は捨てるため、コメント保持は要件ではないが、 + ログ上 ``create`` と表示されるとロールバック挙動など他の経路 (= 新規作成は + backup を取らない) と判別できなくなるため、op 表記の正確性を確認する。 + """ + import logging + _, id_file = age_keys + pub_file, _ = age_keys + bundle_path = _build_simple_bundle(tmp_path, pub_file, "comment-replace") + + _setup_comment_only_dest(dest_root) + + with caplog.at_level(logging.INFO, logger="devbase.env.io_import"): + rc = import_bundle(dest_root, ImportOptions( + source=str(bundle_path), identities=[str(id_file)], + replace=True)) + assert rc == 0 + + # 'replace: ' のような行が出ているはず。少なくとも 'create:' 表記で + # 出力されていないことを確認 (op_replace の正しさ)。 + log_text = "\n".join(r.message for r in caplog.records) + assert "replace: " in log_text, log_text + # backup には元のコメントのみの .env が記録されている (存在判定が正しければ + # _backup_existing が target を見つけてコピーするため) + backup_root = dest_root / "backups" / "env-import" + assert backup_root.is_dir() + snapshots = [p for p in backup_root.iterdir() if p.is_dir()] + assert len(snapshots) >= 1 + backed = (snapshots[0] / ".env").read_text() + assert "# user-managed header (no kv yet)" in backed diff --git a/tests/cli/test_prefix_resolution.py b/tests/cli/test_prefix_resolution.py new file mode 100644 index 0000000..06f849a --- /dev/null +++ b/tests/cli/test_prefix_resolution.py @@ -0,0 +1,71 @@ +"""sys.argv の prefix 解決 (`devbase env e` → `edit` 等) の後方互換テスト""" + +from __future__ import annotations + +import sys + +from devbase import cli + + +def test_resolve_prefix_unique_match(): + assert cli._resolve_prefix("ed", ["edit", "export"]) == "edit" + assert cli._resolve_prefix("ex", ["edit", "export"]) == "export" + + +def test_resolve_prefix_ambiguous_returns_input(): + # `e` は edit / export の両方にマッチするため、デフォルトでは入力をそのまま返す + assert cli._resolve_prefix("e", ["edit", "export"]) == "e" + + +def test_resolve_prefix_falls_back_to_preference_when_ambiguous(): + """ambiguous な prefix に対し preference があれば fallback で解決する""" + candidates = ["edit", "export"] + preferences = {"e": "edit"} + assert cli._resolve_prefix("e", candidates, preferences) == "edit" + + +def test_resolve_prefix_ignores_preference_when_target_not_in_candidates(): + """preference の指す値が candidates にない場合は無視される""" + candidates = ["edit", "export"] + preferences = {"e": "explode"} + assert cli._resolve_prefix("e", candidates, preferences) == "e" + + +def test_expand_argv_env_e_resolves_to_edit(monkeypatch): + """`devbase env e` は引き続き `devbase env edit` に解決される (後方互換)""" + monkeypatch.setattr(sys, "argv", ["devbase", "env", "e"]) + cli._expand_argv() + assert sys.argv == ["devbase", "env", "edit"] + + +def test_expand_argv_env_ed_resolves_to_edit(monkeypatch): + monkeypatch.setattr(sys, "argv", ["devbase", "env", "ed"]) + cli._expand_argv() + assert sys.argv == ["devbase", "env", "edit"] + + +def test_expand_argv_env_ex_resolves_to_export(monkeypatch): + monkeypatch.setattr(sys, "argv", ["devbase", "env", "ex"]) + cli._expand_argv() + assert sys.argv == ["devbase", "env", "export"] + + +def test_expand_argv_env_i_resolves_to_init(monkeypatch): + """`devbase env i` は `import` 追加後も `init` に解決される (後方互換 / PR #15 round5)""" + monkeypatch.setattr(sys, "argv", ["devbase", "env", "i"]) + cli._expand_argv() + assert sys.argv == ["devbase", "env", "init"] + + +def test_expand_argv_env_im_resolves_to_import(monkeypatch): + """`devbase env im` は唯一の候補なので `import` に解決される""" + monkeypatch.setattr(sys, "argv", ["devbase", "env", "im"]) + cli._expand_argv() + assert sys.argv == ["devbase", "env", "import"] + + +def test_expand_argv_env_in_resolves_to_init(monkeypatch): + """`devbase env in` も唯一の候補 (`init`) に解決される""" + monkeypatch.setattr(sys, "argv", ["devbase", "env", "in"]) + cli._expand_argv() + assert sys.argv == ["devbase", "env", "init"] diff --git a/tests/env/__init__.py b/tests/env/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/env/test_bundle.py b/tests/env/test_bundle.py new file mode 100644 index 0000000..e6458e6 --- /dev/null +++ b/tests/env/test_bundle.py @@ -0,0 +1,325 @@ +"""bundle.py: tar.gz パック/アンパックと manifest 検証""" + +from __future__ import annotations + +import pytest + +from devbase.env import bundle + + +def _entry(arcname: str, data: bytes, origin: str = "") -> bundle.BundleEntry: + return bundle.BundleEntry(arcname=arcname, origin=origin or arcname, data=data) + + +def test_pack_unpack_roundtrip_preserves_contents(): + entries = [ + _entry("env/global.env", b"FOO=bar\nBAZ=qux\n"), + _entry("env/projects/p1/.env", b"API_KEY=abc\n"), + ] + blob = bundle.pack(entries, devbase_version="test") + manifest, members = bundle.unpack(blob) + + assert manifest["version"] == bundle.SUPPORTED_MANIFEST_VERSION + assert manifest["devbase_version"] == "test" + assert {e["path"] for e in manifest["files"]} == {e.arcname for e in entries} + assert members["env/global.env"] == b"FOO=bar\nBAZ=qux\n" + assert members["env/projects/p1/.env"] == b"API_KEY=abc\n" + + +def test_unpack_rejects_corrupted_sha256(): + entries = [_entry("env/global.env", b"FOO=bar\n")] + blob = bundle.pack(entries) + + # 同じ tar に対し manifest の sha256 を意図的に壊した tar を作る + import io, tarfile, yaml + src = io.BytesIO(blob) + out = io.BytesIO() + with tarfile.open(fileobj=src, mode="r:gz") as tin, \ + tarfile.open(fileobj=out, mode="w:gz") as tout: + for info in tin.getmembers(): + data = tin.extractfile(info).read() + if info.name == bundle.MANIFEST_NAME: + m = yaml.safe_load(data) + m["files"][0]["sha256"] = "0" * 64 + data = yaml.safe_dump(m).encode("utf-8") + info.size = len(data) + tout.addfile(info, io.BytesIO(data)) + + with pytest.raises(bundle.BundleError, match="sha256"): + bundle.unpack(out.getvalue()) + + +def test_unpack_rejects_unknown_version(): + entries = [_entry("env/global.env", b"FOO=bar\n")] + blob = bundle.pack(entries) + + import io, tarfile, yaml + src = io.BytesIO(blob) + out = io.BytesIO() + with tarfile.open(fileobj=src, mode="r:gz") as tin, \ + tarfile.open(fileobj=out, mode="w:gz") as tout: + for info in tin.getmembers(): + data = tin.extractfile(info).read() + if info.name == bundle.MANIFEST_NAME: + m = yaml.safe_load(data) + m["version"] = bundle.SUPPORTED_MANIFEST_VERSION + 1 + data = yaml.safe_dump(m).encode("utf-8") + info.size = len(data) + tout.addfile(info, io.BytesIO(data)) + + with pytest.raises(bundle.BundleError, match="version"): + bundle.unpack(out.getvalue()) + + +def test_make_entries_from_disk(tmp_path): + root = tmp_path + (root / ".env").write_text("GLOBAL=1\n") + (root / ".env.sources.yml").write_text("sources: {}\n") + proj_a = root / "projects" / "a" + proj_a.mkdir(parents=True) + (proj_a / ".env").write_text("A=1\n") + proj_b = root / "projects" / "b" + proj_b.mkdir(parents=True) + (proj_b / ".env").write_text("B=1\n") + + entries = bundle.make_entries_from_disk(root) + arcnames = {e.arcname for e in entries} + assert arcnames == { + "env/global.env", + "env/sources.yml", + "env/projects/a/.env", + "env/projects/b/.env", + } + + only_a = bundle.make_entries_from_disk(root, include_projects=["a"], + include_metadata=False) + assert {e.arcname for e in only_a} == {"env/global.env", "env/projects/a/.env"} + + no_global = bundle.make_entries_from_disk(root, include_global=False, + exclude_projects=["b"]) + assert "env/global.env" not in {e.arcname for e in no_global} + assert "env/projects/b/.env" not in {e.arcname for e in no_global} + + +def test_unpack_rejects_traversal_paths(): + import io, tarfile + out = io.BytesIO() + with tarfile.open(fileobj=out, mode="w:gz") as tout: + info = tarfile.TarInfo(name="../escape.txt") + info.size = 3 + tout.addfile(info, io.BytesIO(b"BAD")) + with pytest.raises(bundle.BundleError, match="不正なパス"): + bundle.unpack(out.getvalue()) + + +def _rewrite_manifest(blob: bytes, new_manifest_obj) -> bytes: + """blob 内の manifest.yml を new_manifest_obj に置き換えた tar.gz を返す""" + import io, tarfile, yaml + src = io.BytesIO(blob) + out = io.BytesIO() + with tarfile.open(fileobj=src, mode="r:gz") as tin, \ + tarfile.open(fileobj=out, mode="w:gz") as tout: + for info in tin.getmembers(): + data = tin.extractfile(info).read() + if info.name == bundle.MANIFEST_NAME: + data = yaml.safe_dump(new_manifest_obj).encode("utf-8") + info.size = len(data) + tout.addfile(info, io.BytesIO(data)) + return out.getvalue() + + +def test_unpack_rejects_files_not_list(): + blob = bundle.pack([_entry("env/global.env", b"FOO=bar\n")]) + bad = _rewrite_manifest(blob, { + "version": bundle.SUPPORTED_MANIFEST_VERSION, + "files": "not-a-list", + }) + with pytest.raises(bundle.BundleError, match="files が list"): + bundle.unpack(bad) + + +def test_unpack_rejects_files_entry_not_dict(): + blob = bundle.pack([_entry("env/global.env", b"FOO=bar\n")]) + bad = _rewrite_manifest(blob, { + "version": bundle.SUPPORTED_MANIFEST_VERSION, + "files": ["not-a-dict"], + }) + with pytest.raises(bundle.BundleError, match="dict ではありません"): + bundle.unpack(bad) + + +def test_unpack_rejects_invalid_path_field(): + blob = bundle.pack([_entry("env/global.env", b"FOO=bar\n")]) + bad = _rewrite_manifest(blob, { + "version": bundle.SUPPORTED_MANIFEST_VERSION, + "files": [{"path": 123, "sha256": "x" * 64}], + }) + with pytest.raises(bundle.BundleError, match="path が不正"): + bundle.unpack(bad) + + +def test_unpack_rejects_invalid_sha256_field(): + blob = bundle.pack([_entry("env/global.env", b"FOO=bar\n")]) + bad = _rewrite_manifest(blob, { + "version": bundle.SUPPORTED_MANIFEST_VERSION, + "files": [{"path": "env/global.env", "sha256": 12345}], + }) + with pytest.raises(bundle.BundleError, match="sha256 が不正"): + bundle.unpack(bad) + + +def test_unpack_rejects_missing_sha256_field(): + """sha256 が欠落 (None) している manifest は BundleError""" + blob = bundle.pack([_entry("env/global.env", b"FOO=bar\n")]) + bad = _rewrite_manifest(blob, { + "version": bundle.SUPPORTED_MANIFEST_VERSION, + "files": [{"path": "env/global.env"}], # sha256 欠落 + }) + with pytest.raises(bundle.BundleError, match="sha256 が不正"): + bundle.unpack(bad) + + +def test_unpack_rejects_sha256_none(): + """sha256 が明示的に None でも BundleError (完全性チェック迂回防止)""" + blob = bundle.pack([_entry("env/global.env", b"FOO=bar\n")]) + bad = _rewrite_manifest(blob, { + "version": bundle.SUPPORTED_MANIFEST_VERSION, + "files": [{"path": "env/global.env", "sha256": None}], + }) + with pytest.raises(bundle.BundleError, match="sha256 が不正"): + bundle.unpack(bad) + + +def test_unpack_rejects_sha256_wrong_length(): + """sha256 が 64 文字でない場合は BundleError""" + blob = bundle.pack([_entry("env/global.env", b"FOO=bar\n")]) + bad = _rewrite_manifest(blob, { + "version": bundle.SUPPORTED_MANIFEST_VERSION, + "files": [{"path": "env/global.env", "sha256": "abc123"}], + }) + with pytest.raises(bundle.BundleError, match="sha256 が不正"): + bundle.unpack(bad) + + +def test_unpack_rejects_sha256_non_hex(): + """sha256 が 64 文字でも 16 進でないなら BundleError""" + blob = bundle.pack([_entry("env/global.env", b"FOO=bar\n")]) + bad = _rewrite_manifest(blob, { + "version": bundle.SUPPORTED_MANIFEST_VERSION, + "files": [{"path": "env/global.env", "sha256": "z" * 64}], + }) + with pytest.raises(bundle.BundleError, match="sha256 が不正"): + bundle.unpack(bad) + + +def test_unpack_rejects_duplicate_tar_entries(): + import io, tarfile, yaml + out = io.BytesIO() + manifest = { + "version": bundle.SUPPORTED_MANIFEST_VERSION, + "files": [{"path": "env/global.env", + "sha256": bundle._sha256(b"FOO=bar\n")}], + } + manifest_bytes = yaml.safe_dump(manifest).encode("utf-8") + with tarfile.open(fileobj=out, mode="w:gz") as tout: + m = tarfile.TarInfo(name=bundle.MANIFEST_NAME) + m.size = len(manifest_bytes) + tout.addfile(m, io.BytesIO(manifest_bytes)) + # 同名エントリを 2 回追加 + for payload in (b"FOO=bar\n", b"FOO=other\n"): + info = tarfile.TarInfo(name="env/global.env") + info.size = len(payload) + tout.addfile(info, io.BytesIO(payload)) + with pytest.raises(bundle.BundleError, match="重複エントリ"): + bundle.unpack(out.getvalue()) + + +@pytest.mark.parametrize("payload", [b"- a\n- b\n", b"just a string\n", b"42\n"]) +def test_unpack_rejects_non_mapping_manifest(payload): + """manifest.yaml の top-level が dict でない場合 BundleError""" + import io, tarfile + out = io.BytesIO() + with tarfile.open(fileobj=out, mode="w:gz") as tout: + m = tarfile.TarInfo(name=bundle.MANIFEST_NAME) + m.size = len(payload) + tout.addfile(m, io.BytesIO(payload)) + with pytest.raises(bundle.BundleError, match="mapping ではありません"): + bundle.unpack(out.getvalue()) + + +def test_pack_is_deterministic(): + """同一入力に対し pack() の出力バイト列が完全に一致 (gzip mtime=0 が効いている)""" + entries = [ + _entry("env/global.env", b"FOO=bar\n"), + _entry("env/projects/p1/.env", b"X=1\n"), + ] + blob1 = bundle.pack(entries, devbase_version="test", + created_at="2024-01-01T00:00:00+00:00") + blob2 = bundle.pack(entries, devbase_version="test", + created_at="2024-01-01T00:00:00+00:00") + assert blob1 == blob2 + # gzip マジックで始まる + assert blob1[:2] == b"\x1f\x8b" + + +def test_unpack_rejects_duplicate_manifest_paths(): + """manifest.files に同じ path が複数回現れたら BundleError""" + blob = bundle.pack([_entry("env/global.env", b"FOO=bar\n")]) + bad = _rewrite_manifest(blob, { + "version": bundle.SUPPORTED_MANIFEST_VERSION, + "files": [ + {"path": "env/global.env", + "sha256": bundle._sha256(b"FOO=bar\n")}, + {"path": "env/global.env", + "sha256": bundle._sha256(b"FOO=bar\n")}, + ], + }) + with pytest.raises(bundle.BundleError, match="path が重複"): + bundle.unpack(bad) + + +def test_unpack_rejects_broken_tar_with_bundle_error(): + """壊れた tar.gz は BundleError として送出される (tarfile.TarError を漏らさない)""" + # gzip ヘッダだけ正しいが中身が壊れているバイト列 + broken = b"\x1f\x8b\x08\x00" + b"\x00" * 32 + with pytest.raises(bundle.BundleError): + bundle.unpack(broken) + + +def test_make_entries_from_disk_ignores_directory_named_env(tmp_path): + """対象パスがディレクトリの場合は is_file() で除外され、例外にならない""" + root = tmp_path + # .env がディレクトリだったケース + (root / ".env").mkdir() + # 通常の sources.yml + (root / ".env.sources.yml").write_text("sources: {}\n") + entries = bundle.make_entries_from_disk(root) + arcnames = {e.arcname for e in entries} + assert "env/global.env" not in arcnames + assert "env/sources.yml" in arcnames + + +def test_unpack_rejects_unknown_tar_entries(): + """manifest に記載のないファイルが tar に紛れ込んでいたら BundleError""" + import io, tarfile, yaml + out = io.BytesIO() + manifest = { + "version": bundle.SUPPORTED_MANIFEST_VERSION, + "files": [{"path": "env/global.env", + "sha256": bundle._sha256(b"FOO=bar\n")}], + } + manifest_bytes = yaml.safe_dump(manifest).encode("utf-8") + with tarfile.open(fileobj=out, mode="w:gz") as tout: + m = tarfile.TarInfo(name=bundle.MANIFEST_NAME) + m.size = len(manifest_bytes) + tout.addfile(m, io.BytesIO(manifest_bytes)) + # manifest に記載があるファイル + legit = tarfile.TarInfo(name="env/global.env") + legit.size = len(b"FOO=bar\n") + tout.addfile(legit, io.BytesIO(b"FOO=bar\n")) + # manifest に記載のないファイル + stowaway = tarfile.TarInfo(name="env/stowaway.env") + stowaway.size = len(b"EVIL=1\n") + tout.addfile(stowaway, io.BytesIO(b"EVIL=1\n")) + with pytest.raises(bundle.BundleError, match="manifest に記載のないファイル"): + bundle.unpack(out.getvalue()) diff --git a/tests/env/test_cipher.py b/tests/env/test_cipher.py new file mode 100644 index 0000000..2027a02 --- /dev/null +++ b/tests/env/test_cipher.py @@ -0,0 +1,178 @@ +"""cipher.py: age 暗号化のラウンドトリップとエラー検出""" + +from __future__ import annotations + +import pyrage +import pytest + +from devbase.env import cipher + + +@pytest.fixture +def x25519_keypair(): + identity = pyrage.x25519.Identity.generate() + return str(identity.to_public()), str(identity) + + +def test_recipient_roundtrip_with_x25519(tmp_path, x25519_keypair): + pub, priv_str = x25519_keypair + id_path = tmp_path / "age_identity.key" + id_path.write_text(priv_str) + + blob = cipher.encrypt(b"hello", recipients=[pub]) + assert blob != b"hello" + assert cipher.decrypt(blob, identities=[str(id_path)]) == b"hello" + + +def test_passphrase_roundtrip(): + blob = cipher.encrypt(b"secret payload", passphrase="correct horse") + assert cipher.decrypt(blob, passphrase="correct horse") == b"secret payload" + + +def test_passphrase_wrong_raises_cipher_error(): + blob = cipher.encrypt(b"x", passphrase="right") + with pytest.raises(cipher.CipherError): + cipher.decrypt(blob, passphrase="wrong") + + +def test_encrypt_requires_recipient_or_passphrase(): + with pytest.raises(cipher.CipherError): + cipher.encrypt(b"x") + + +def test_encrypt_rejects_both_recipient_and_passphrase(x25519_keypair): + pub, _ = x25519_keypair + with pytest.raises(cipher.CipherError): + cipher.encrypt(b"x", recipients=[pub], passphrase="p") + + +def test_recipient_at_file_reference(tmp_path, x25519_keypair): + pub, priv_str = x25519_keypair + pub_file = tmp_path / "age.pub" + pub_file.write_text(pub + "\n") + id_file = tmp_path / "age.key" + id_file.write_text(priv_str) + + blob = cipher.encrypt(b"data", recipients=[f"@{pub_file}"]) + assert cipher.decrypt(blob, identities=[str(id_file)]) == b"data" + + +def test_recipient_rejects_unsupported_ssh_type(): + with pytest.raises(cipher.CipherError, match="ssh-ecdsa|ssh-"): + cipher.encrypt(b"x", recipients=["ssh-ecdsa AAAA dummy"]) + + +def test_recipient_at_file_reference_depth_limit(tmp_path): + """@PATH の循環参照で RecursionError ではなく CipherError を返す""" + # 互いを参照する 2 ファイル + a = tmp_path / "a.txt" + b = tmp_path / "b.txt" + a.write_text(f"@{b}\n") + b.write_text(f"@{a}\n") + with pytest.raises(cipher.CipherError, match="深すぎ|循環"): + cipher.encrypt(b"x", recipients=[f"@{a}"]) + + +def test_recipient_at_file_reference_rejects_non_utf8(tmp_path): + """@PATH ファイルが UTF-8 でない場合 CipherError に包んで送出""" + bad = tmp_path / "bad.pub" + # 0x80 は UTF-8 として不正な開始バイト + bad.write_bytes(b"\x80\x81\x82\n") + with pytest.raises(cipher.CipherError, match="UTF-8 デコード"): + cipher.encrypt(b"x", recipients=[f"@{bad}"]) + + +def test_resolve_identity_wraps_oserror(tmp_path, monkeypatch): + """identity ファイルの read_bytes が OSError を投げた場合 CipherError に包んで送出""" + id_path = tmp_path / "identity.key" + id_path.write_text("dummy") + + from pathlib import Path as _Path + + original_read_bytes = _Path.read_bytes + + def fake_read_bytes(self): + if self == id_path: + raise OSError("simulated I/O error") + return original_read_bytes(self) + + monkeypatch.setattr(_Path, "read_bytes", fake_read_bytes) + with pytest.raises(cipher.CipherError, match="読み込みに失敗"): + cipher.decrypt(b"x", identities=[str(id_path)]) + + +def test_resolve_recipient_at_path_skips_comments_and_blank_lines(tmp_path, x25519_keypair): + """@PATH ファイル中のコメント行 / 空行をスキップして最初の有効な recipient を採用""" + pub_path = tmp_path / "rcpt.pub" + pub_path.write_text( + "# this is a comment\n" + "\n" + f"{x25519_keypair[0]}\n" + "# trailing comment\n" + ) + ciphertext = cipher.encrypt(b"hello", recipients=[f"@{pub_path}"]) + # 復号できれば有効な recipient として解釈されている + id_path = tmp_path / "id.key" + id_path.write_text(x25519_keypair[1]) + plain = cipher.decrypt(ciphertext, identities=[str(id_path)]) + assert plain == b"hello" + + +def test_resolve_recipient_at_path_rejects_only_comments(tmp_path): + """@PATH ファイルがコメント・空行のみだと CipherError""" + pub_path = tmp_path / "empty.pub" + pub_path.write_text("# only comments\n\n# nothing else\n") + with pytest.raises(cipher.CipherError, match="有効な行がありません"): + cipher.encrypt(b"x", recipients=[f"@{pub_path}"]) + + +def test_resolve_recipient_at_path_wraps_oserror(tmp_path, monkeypatch): + """@PATH の read_text が OSError を投げた場合 CipherError に包んで送出""" + rcpt_path = tmp_path / "rcpt.pub" + rcpt_path.write_text("dummy") + + from pathlib import Path as _Path + + original_read_text = _Path.read_text + + def fake_read_text(self, *args, **kwargs): + if self == rcpt_path: + raise PermissionError("simulated permission denied") + return original_read_text(self, *args, **kwargs) + + monkeypatch.setattr(_Path, "read_text", fake_read_text) + with pytest.raises(cipher.CipherError, match="読み込みに失敗"): + cipher.encrypt(b"x", recipients=[f"@{rcpt_path}"]) + + +def test_resolve_identity_prefers_openssh_header(tmp_path): + """OpenSSH ヘッダで始まる秘密鍵は age 鍵判定より先に SSH として処理される""" + # 中身は不正でも、OpenSSH ヘッダで判別された後 pyrage 側エラーになる + # ことを確認 (= age 経路ではなく SSH 経路に入った証拠) + id_path = tmp_path / "id.key" + id_path.write_bytes( + b"-----BEGIN OPENSSH PRIVATE KEY-----\n" + b"not-a-valid-key\n" + b"-----END OPENSSH PRIVATE KEY-----\n" + ) + with pytest.raises(cipher.CipherError, match="OpenSSH 秘密鍵の解釈"): + cipher.decrypt(b"x", identities=[str(id_path)]) + + +def test_default_recipient_paths_includes_ed25519(): + """ed25519 公開鍵が rsa より先に試される""" + paths = cipher.default_recipient_paths() + names = [p.name for p in paths] + assert "id_ed25519.pub" in names + assert "id_rsa.pub" in names + # ed25519 を rsa より先に優先 + assert names.index("id_ed25519.pub") < names.index("id_rsa.pub") + + +def test_default_identity_paths_includes_ed25519(): + """ed25519 秘密鍵が rsa より先に試される""" + paths = cipher.default_identity_paths() + names = [p.name for p in paths] + assert "id_ed25519" in names + assert "id_rsa" in names + assert names.index("id_ed25519") < names.index("id_rsa") diff --git a/tests/env/test_storage.py b/tests/env/test_storage.py new file mode 100644 index 0000000..385e5b0 --- /dev/null +++ b/tests/env/test_storage.py @@ -0,0 +1,147 @@ +"""storage.py: Local / Stdio backend + resolve()""" + +from __future__ import annotations + +import io +import sys + +import pytest + +from devbase.env import storage + + +def test_local_backend_roundtrip(tmp_path): + backend = storage.LocalBackend() + dest = tmp_path / "out" / "bundle.bin" + backend.write_bytes(str(dest), b"abc") + + assert backend.read_bytes(str(dest)) == b"abc" + assert dest.stat().st_mode & 0o777 == 0o600 + + +def test_local_backend_missing_file_raises(tmp_path): + backend = storage.LocalBackend() + with pytest.raises(storage.StorageError): + backend.read_bytes(str(tmp_path / "no-such")) + + +def test_resolve_local_for_plain_path(): + assert isinstance(storage.resolve("/tmp/foo"), storage.LocalBackend) + assert isinstance(storage.resolve("relative/path"), storage.LocalBackend) + assert isinstance(storage.resolve("file:///tmp/foo"), storage.LocalBackend) + + +def test_resolve_stdio_for_dash(): + assert isinstance(storage.resolve("-"), storage.StdioBackend) + assert storage.is_stdio("-") + assert not storage.is_stdio("/tmp/foo") + + +def test_resolve_rejects_unimplemented_schemes(): + for uri in ("s3://bucket/key", "gs://bucket/object"): + with pytest.raises(storage.StorageError, match="未実装"): + storage.resolve(uri) + + +def test_resolve_rejects_unknown_scheme(): + with pytest.raises(storage.StorageError, match="未対応"): + storage.resolve("ftp://host/x") + + +@pytest.mark.parametrize("uri", [ + r"C:\Users\foo\bundle.tar.gz", + r"c:\tmp\out.bin", + "D:/data/out.bin", +]) +def test_resolve_windows_drive_letter_falls_back_to_local(uri): + """Windows のドライブレター付きパスは urlparse が scheme と誤認するが + LocalBackend にフォールバックされる""" + assert isinstance(storage.resolve(uri), storage.LocalBackend) + + +def test_local_backend_file_uri_roundtrip(tmp_path): + backend = storage.LocalBackend() + dest = tmp_path / "via-uri.bin" + uri = f"file://{dest}" + backend.write_bytes(uri, b"xyz") + assert dest.read_bytes() == b"xyz" + assert backend.read_bytes(uri) == b"xyz" + + # localhost も許容 + uri_localhost = f"file://localhost{dest}" + assert backend.read_bytes(uri_localhost) == b"xyz" + + +def test_local_backend_file_uri_rejects_remote_host(tmp_path): + backend = storage.LocalBackend() + with pytest.raises(storage.StorageError, match="ホスト指定"): + backend.read_bytes("file://other-host/tmp/x") + with pytest.raises(storage.StorageError, match="ホスト指定"): + backend.write_bytes("file://other-host/tmp/x", b"data") + + +def test_stdio_backend_writes_to_stdout(monkeypatch): + buf = io.BytesIO() + + class FakeStdout: + buffer = buf + + monkeypatch.setattr(sys, "stdout", FakeStdout()) + storage.StdioBackend().write_bytes("-", b"hello") + assert buf.getvalue() == b"hello" + + +def test_local_backend_write_creates_with_0600_no_toctou(tmp_path, monkeypatch): + """`os.open` の mode 引数 (0o600) が確実に渡され、umask に依存せず作成時点から + 0600 になることを検証する""" + backend = storage.LocalBackend() + dest = tmp_path / "secure.bin" + + captured = {} + real_os_open = storage.os.open + + def spy_open(path, flags, mode=0o777): + captured['mode'] = mode + captured['flags'] = flags + return real_os_open(path, flags, mode) + + monkeypatch.setattr(storage.os, "open", spy_open) + backend.write_bytes(str(dest), b"secret") + assert captured['mode'] == 0o600 + # O_CREAT|O_TRUNC|O_WRONLY が含まれていること + import os as _os + assert captured['flags'] & _os.O_CREAT + assert captured['flags'] & _os.O_TRUNC + assert dest.stat().st_mode & 0o777 == 0o600 + + +def test_local_backend_overwrite_existing_file_keeps_0600(tmp_path): + """既存ファイル (0644) に上書きしても 0600 まで権限を絞れる""" + backend = storage.LocalBackend() + dest = tmp_path / "exists.bin" + dest.write_bytes(b"old") + dest.chmod(0o644) + + backend.write_bytes(str(dest), b"new") + assert dest.read_bytes() == b"new" + assert dest.stat().st_mode & 0o777 == 0o600 + + +def test_local_backend_write_wraps_oserror_as_storage_error(tmp_path): + """書き込み時の OSError は StorageError にラップされる""" + backend = storage.LocalBackend() + # 書き込み不可能なパス (存在しないルートを起点) — mkdir も失敗する状況を作る + # FileExistsError をテストするため、parent をファイルにして mkdir を阻む + blocker = tmp_path / "blocker" + blocker.write_bytes(b"x") + dest = blocker / "child" / "out.bin" + with pytest.raises(storage.StorageError): + backend.write_bytes(str(dest), b"data") + + +def test_local_backend_read_wraps_oserror_as_storage_error(tmp_path): + """read 時の OSError (例: ディレクトリを read) は StorageError にラップされる""" + backend = storage.LocalBackend() + # ディレクトリを read_bytes すると IsADirectoryError + with pytest.raises(storage.StorageError): + backend.read_bytes(str(tmp_path)) diff --git a/uv.lock b/uv.lock index 28d2ff2..49f3aa8 100644 --- a/uv.lock +++ b/uv.lock @@ -2,16 +2,115 @@ version = 1 revision = 2 requires-python = ">=3.10" +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + [[package]] name = "devbase" version = "2.2.0" source = { virtual = "." } dependencies = [ + { name = "pyrage" }, { name = "pyyaml" }, ] +[package.dev-dependencies] +dev = [ + { name = "pytest" }, +] + [package.metadata] -requires-dist = [{ name = "pyyaml", specifier = ">=6.0" }] +requires-dist = [ + { name = "pyrage", specifier = ">=1.2" }, + { name = "pyyaml", specifier = ">=6.0" }, +] + +[package.metadata.requires-dev] +dev = [{ name = "pytest", specifier = ">=8.0" }] + +[[package]] +name = "exceptiongroup" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "packaging" +version = "26.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, +] + +[[package]] +name = "pyrage" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/e8/918161376594d69b294e920bd6444f1d9997e6e6dd2aca18e15f1ef72463/pyrage-1.3.0.tar.gz", hash = "sha256:b283a2e3d688cbf68c707f57d93fdab3304ff57c7e2e6b710c0b4bc9096ad9da", size = 30120, upload-time = "2025-06-14T01:28:04.108Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f7/6e/3095678ee12f0401e1de17f4d6993783b20a4b807daf69e23b170724e5f4/pyrage-1.3.0-cp39-abi3-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:907901ada8d63d674cc9005889150846c7349ef587ee8bf5e9278b79c54b4679", size = 1563258, upload-time = "2025-06-14T01:27:57.886Z" }, + { url = "https://files.pythonhosted.org/packages/3b/e7/f515fbc972a5d83e9fa82d1c23a16f733f4dd6c2c6ae33d9054ca04a8d92/pyrage-1.3.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ea452cb9c9c47083a96b309467dea5614d12530e1de4b6585f10aa04d3d19d1c", size = 785930, upload-time = "2025-06-14T01:27:59.755Z" }, + { url = "https://files.pythonhosted.org/packages/38/f3/e91bf604fd40c42c60e8f95075cddb0b85d0bdf452f736b533b1bad550e0/pyrage-1.3.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ab066b22925c5a0ec5fead2e21e4586b21d5da730055c7e46caa978bd99de936", size = 847692, upload-time = "2025-06-14T01:28:01.042Z" }, + { url = "https://files.pythonhosted.org/packages/88/59/15fd1945b02e6f93eff5a2ff352e67f85f51bf543769484f9bd960868c19/pyrage-1.3.0-cp39-abi3-win_amd64.whl", hash = "sha256:3be314a9746809c2710bfd144a6acf0c54a40f43e306857b9778a9d871ad97b3", size = 767566, upload-time = "2025-06-14T01:28:02.597Z" }, +] + +[[package]] +name = "pytest" +version = "9.0.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" }, +] [[package]] name = "pyyaml" @@ -76,3 +175,66 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, ] + +[[package]] +name = "tomli" +version = "2.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/11/db3d5885d8528263d8adc260bb2d28ebf1270b96e98f0e0268d32b8d9900/tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30", size = 154704, upload-time = "2026-03-25T20:21:10.473Z" }, + { url = "https://files.pythonhosted.org/packages/6d/f7/675db52c7e46064a9aa928885a9b20f4124ecb9bc2e1ce74c9106648d202/tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a", size = 149454, upload-time = "2026-03-25T20:21:12.036Z" }, + { url = "https://files.pythonhosted.org/packages/61/71/81c50943cf953efa35bce7646caab3cf457a7d8c030b27cfb40d7235f9ee/tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076", size = 237561, upload-time = "2026-03-25T20:21:13.098Z" }, + { url = "https://files.pythonhosted.org/packages/48/c1/f41d9cb618acccca7df82aaf682f9b49013c9397212cb9f53219e3abac37/tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9", size = 243824, upload-time = "2026-03-25T20:21:14.569Z" }, + { url = "https://files.pythonhosted.org/packages/22/e4/5a816ecdd1f8ca51fb756ef684b90f2780afc52fc67f987e3c61d800a46d/tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c", size = 242227, upload-time = "2026-03-25T20:21:15.712Z" }, + { url = "https://files.pythonhosted.org/packages/6b/49/2b2a0ef529aa6eec245d25f0c703e020a73955ad7edf73e7f54ddc608aa5/tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc", size = 247859, upload-time = "2026-03-25T20:21:17.001Z" }, + { url = "https://files.pythonhosted.org/packages/83/bd/6c1a630eaca337e1e78c5903104f831bda934c426f9231429396ce3c3467/tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049", size = 97204, upload-time = "2026-03-25T20:21:18.079Z" }, + { url = "https://files.pythonhosted.org/packages/42/59/71461df1a885647e10b6bb7802d0b8e66480c61f3f43079e0dcd315b3954/tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e", size = 108084, upload-time = "2026-03-25T20:21:18.978Z" }, + { url = "https://files.pythonhosted.org/packages/b8/83/dceca96142499c069475b790e7913b1044c1a4337e700751f48ed723f883/tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece", size = 95285, upload-time = "2026-03-25T20:21:20.309Z" }, + { url = "https://files.pythonhosted.org/packages/c1/ba/42f134a3fe2b370f555f44b1d72feebb94debcab01676bf918d0cb70e9aa/tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a", size = 155924, upload-time = "2026-03-25T20:21:21.626Z" }, + { url = "https://files.pythonhosted.org/packages/dc/c7/62d7a17c26487ade21c5422b646110f2162f1fcc95980ef7f63e73c68f14/tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085", size = 150018, upload-time = "2026-03-25T20:21:23.002Z" }, + { url = "https://files.pythonhosted.org/packages/5c/05/79d13d7c15f13bdef410bdd49a6485b1c37d28968314eabee452c22a7fda/tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9", size = 244948, upload-time = "2026-03-25T20:21:24.04Z" }, + { url = "https://files.pythonhosted.org/packages/10/90/d62ce007a1c80d0b2c93e02cab211224756240884751b94ca72df8a875ca/tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5", size = 253341, upload-time = "2026-03-25T20:21:25.177Z" }, + { url = "https://files.pythonhosted.org/packages/1a/7e/caf6496d60152ad4ed09282c1885cca4eea150bfd007da84aea07bcc0a3e/tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585", size = 248159, upload-time = "2026-03-25T20:21:26.364Z" }, + { url = "https://files.pythonhosted.org/packages/99/e7/c6f69c3120de34bbd882c6fba7975f3d7a746e9218e56ab46a1bc4b42552/tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1", size = 253290, upload-time = "2026-03-25T20:21:27.46Z" }, + { url = "https://files.pythonhosted.org/packages/d6/2f/4a3c322f22c5c66c4b836ec58211641a4067364f5dcdd7b974b4c5da300c/tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917", size = 98141, upload-time = "2026-03-25T20:21:28.492Z" }, + { url = "https://files.pythonhosted.org/packages/24/22/4daacd05391b92c55759d55eaee21e1dfaea86ce5c571f10083360adf534/tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9", size = 108847, upload-time = "2026-03-25T20:21:29.386Z" }, + { url = "https://files.pythonhosted.org/packages/68/fd/70e768887666ddd9e9f5d85129e84910f2db2796f9096aa02b721a53098d/tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257", size = 95088, upload-time = "2026-03-25T20:21:30.677Z" }, + { url = "https://files.pythonhosted.org/packages/07/06/b823a7e818c756d9a7123ba2cda7d07bc2dd32835648d1a7b7b7a05d848d/tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54", size = 155866, upload-time = "2026-03-25T20:21:31.65Z" }, + { url = "https://files.pythonhosted.org/packages/14/6f/12645cf7f08e1a20c7eb8c297c6f11d31c1b50f316a7e7e1e1de6e2e7b7e/tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a", size = 149887, upload-time = "2026-03-25T20:21:33.028Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e0/90637574e5e7212c09099c67ad349b04ec4d6020324539297b634a0192b0/tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897", size = 243704, upload-time = "2026-03-25T20:21:34.51Z" }, + { url = "https://files.pythonhosted.org/packages/10/8f/d3ddb16c5a4befdf31a23307f72828686ab2096f068eaf56631e136c1fdd/tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f", size = 251628, upload-time = "2026-03-25T20:21:36.012Z" }, + { url = "https://files.pythonhosted.org/packages/e3/f1/dbeeb9116715abee2485bf0a12d07a8f31af94d71608c171c45f64c0469d/tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d", size = 247180, upload-time = "2026-03-25T20:21:37.136Z" }, + { url = "https://files.pythonhosted.org/packages/d3/74/16336ffd19ed4da28a70959f92f506233bd7cfc2332b20bdb01591e8b1d1/tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5", size = 251674, upload-time = "2026-03-25T20:21:38.298Z" }, + { url = "https://files.pythonhosted.org/packages/16/f9/229fa3434c590ddf6c0aa9af64d3af4b752540686cace29e6281e3458469/tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd", size = 97976, upload-time = "2026-03-25T20:21:39.316Z" }, + { url = "https://files.pythonhosted.org/packages/6a/1e/71dfd96bcc1c775420cb8befe7a9d35f2e5b1309798f009dca17b7708c1e/tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36", size = 108755, upload-time = "2026-03-25T20:21:40.248Z" }, + { url = "https://files.pythonhosted.org/packages/83/7a/d34f422a021d62420b78f5c538e5b102f62bea616d1d75a13f0a88acb04a/tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd", size = 95265, upload-time = "2026-03-25T20:21:41.219Z" }, + { url = "https://files.pythonhosted.org/packages/3c/fb/9a5c8d27dbab540869f7c1f8eb0abb3244189ce780ba9cd73f3770662072/tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf", size = 155726, upload-time = "2026-03-25T20:21:42.23Z" }, + { url = "https://files.pythonhosted.org/packages/62/05/d2f816630cc771ad836af54f5001f47a6f611d2d39535364f148b6a92d6b/tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac", size = 149859, upload-time = "2026-03-25T20:21:43.386Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/66341bdb858ad9bd0ceab5a86f90eddab127cf8b046418009f2125630ecb/tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662", size = 244713, upload-time = "2026-03-25T20:21:44.474Z" }, + { url = "https://files.pythonhosted.org/packages/df/6d/c5fad00d82b3c7a3ab6189bd4b10e60466f22cfe8a08a9394185c8a8111c/tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853", size = 252084, upload-time = "2026-03-25T20:21:45.62Z" }, + { url = "https://files.pythonhosted.org/packages/00/71/3a69e86f3eafe8c7a59d008d245888051005bd657760e96d5fbfb0b740c2/tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15", size = 247973, upload-time = "2026-03-25T20:21:46.937Z" }, + { url = "https://files.pythonhosted.org/packages/67/50/361e986652847fec4bd5e4a0208752fbe64689c603c7ae5ea7cb16b1c0ca/tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba", size = 256223, upload-time = "2026-03-25T20:21:48.467Z" }, + { url = "https://files.pythonhosted.org/packages/8c/9a/b4173689a9203472e5467217e0154b00e260621caa227b6fa01feab16998/tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6", size = 98973, upload-time = "2026-03-25T20:21:49.526Z" }, + { url = "https://files.pythonhosted.org/packages/14/58/640ac93bf230cd27d002462c9af0d837779f8773bc03dee06b5835208214/tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7", size = 109082, upload-time = "2026-03-25T20:21:50.506Z" }, + { url = "https://files.pythonhosted.org/packages/d5/2f/702d5e05b227401c1068f0d386d79a589bb12bf64c3d2c72ce0631e3bc49/tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232", size = 96490, upload-time = "2026-03-25T20:21:51.474Z" }, + { url = "https://files.pythonhosted.org/packages/45/4b/b877b05c8ba62927d9865dd980e34a755de541eb65fffba52b4cc495d4d2/tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4", size = 164263, upload-time = "2026-03-25T20:21:52.543Z" }, + { url = "https://files.pythonhosted.org/packages/24/79/6ab420d37a270b89f7195dec5448f79400d9e9c1826df982f3f8e97b24fd/tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c", size = 160736, upload-time = "2026-03-25T20:21:53.674Z" }, + { url = "https://files.pythonhosted.org/packages/02/e0/3630057d8eb170310785723ed5adcdfb7d50cb7e6455f85ba8a3deed642b/tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d", size = 270717, upload-time = "2026-03-25T20:21:55.129Z" }, + { url = "https://files.pythonhosted.org/packages/7a/b4/1613716072e544d1a7891f548d8f9ec6ce2faf42ca65acae01d76ea06bb0/tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41", size = 278461, upload-time = "2026-03-25T20:21:56.228Z" }, + { url = "https://files.pythonhosted.org/packages/05/38/30f541baf6a3f6df77b3df16b01ba319221389e2da59427e221ef417ac0c/tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c", size = 274855, upload-time = "2026-03-25T20:21:57.653Z" }, + { url = "https://files.pythonhosted.org/packages/77/a3/ec9dd4fd2c38e98de34223b995a3b34813e6bdadf86c75314c928350ed14/tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f", size = 283144, upload-time = "2026-03-25T20:21:59.089Z" }, + { url = "https://files.pythonhosted.org/packages/ef/be/605a6261cac79fba2ec0c9827e986e00323a1945700969b8ee0b30d85453/tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8", size = 108683, upload-time = "2026-03-25T20:22:00.214Z" }, + { url = "https://files.pythonhosted.org/packages/12/64/da524626d3b9cc40c168a13da8335fe1c51be12c0a63685cc6db7308daae/tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26", size = 121196, upload-time = "2026-03-25T20:22:01.169Z" }, + { url = "https://files.pythonhosted.org/packages/5a/cd/e80b62269fc78fc36c9af5a6b89c835baa8af28ff5ad28c7028d60860320/tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396", size = 100393, upload-time = "2026-03-25T20:22:02.137Z" }, + { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +]