From 1db5ca736e45ce32e5dbf501148bc993388b6548 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sun, 24 May 2026 08:08:48 +0900 Subject: [PATCH 1/2] =?UTF-8?q?fix(env):=20PLAN03-2=20export=20dest=20?= =?UTF-8?q?=E6=9C=AB=E5=B0=BE=20`/`=20=E3=81=AE=E3=83=95=E3=82=A1=E3=82=A4?= =?UTF-8?q?=E3=83=AB=E5=90=8D=E8=87=AA=E5=8B=95=E8=A3=9C=E5=AE=8C=20(#24)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `devbase env export s3://bucket/prefix/` のように末尾 `/` の dest を指定したとき、 ファイル名部分が空のオブジェクトが作られていた問題を修正する。 `aws s3 cp` 互換でディレクトリ的 dest に既定ファイル名 (`devbase-env-.dbenv`) を自動補完する。 - S3 URI 末尾 `/`: prefix にファイル名を append - ローカル既存ディレクトリ / 末尾 `/`: ディレクトリ配下にファイルを生成 - それ以外 (フルキー / 通常ファイルパス / stdio `-`) は従来通り Co-Authored-By: Claude Opus 4.7 (1M context) --- lib/devbase/env/io_export.py | 34 +++++++++++++ tests/cli/test_env_export.py | 96 ++++++++++++++++++++++++++++++++++++ 2 files changed, 130 insertions(+) diff --git a/lib/devbase/env/io_export.py b/lib/devbase/env/io_export.py index be18c51..ab72453 100644 --- a/lib/devbase/env/io_export.py +++ b/lib/devbase/env/io_export.py @@ -3,6 +3,7 @@ from __future__ import annotations import getpass # noqa: F401 (tests monkey-patch devbase.env.io_export.getpass) +import os import re from dataclasses import dataclass, field from datetime import datetime @@ -51,6 +52,31 @@ def _default_dest(force_unencrypted: bool) -> str: return f'./devbase-env-{ts}{suffix}' +def _default_filename(force_unencrypted: bool) -> str: + """`_default_dest` の `./` prefix を除いたファイル名部分のみを返す。 + dest がディレクトリ的なときに append する用途。""" + return _default_dest(force_unencrypted).removeprefix('./') + + +def _complete_dir_dest(dest: str, force_unencrypted: bool) -> str: + """dest が「ディレクトリ的」なら既定ファイル名を補完する (`aws s3 cp` 互換、#24)。 + + - S3 URI で末尾が `/`: `s3://bucket/prefix/` → `s3://bucket/prefix/` + - ローカルで既存ディレクトリ: `/tmp/out/` (または末尾 `/` なし) → `/tmp/out/` + - それ以外 (フルキー / 通常ファイルパス / stdio `-`) はそのまま返す。 + """ + if _storage.is_stdio(dest): + return dest + name = _default_filename(force_unencrypted) + if _storage.is_s3(dest): + return dest + name if dest.endswith('/') else dest + # ローカル: 既存ディレクトリか末尾 `/` ならディレクトリ扱い + p = Path(dest) + if dest.endswith('/') or dest.endswith(os.sep) or p.is_dir(): + return str(p / name) + return dest + + def _read_passphrase(opts: ExportOptions) -> Optional[str]: """既存テストとの互換のために残している thin wrapper。 実体は :mod:`devbase.env.io_common.read_passphrase`。""" @@ -163,6 +189,14 @@ def export(devbase_root: Path, opts: ExportOptions) -> int: logger.debug("暗号化後サイズ: %d bytes", len(payload)) dest = opts.dest or _default_dest(opts.force_unencrypted) + # dest が「ディレクトリ的」なら `aws s3 cp` 互換でファイル名を自動補完する (#24)。 + # 末尾 `/` の S3 URI で空キーオブジェクトが作られる事故と、ローカル既存 + # ディレクトリへの OSError fail-fast の両方を救う。 + if opts.dest: + completed = _complete_dir_dest(dest, opts.force_unencrypted) + if completed != dest: + logger.info("dest がディレクトリ的なためファイル名を補完: %s", completed) + dest = completed # 既定名 (opts.dest 未指定) かつローカルパスの場合、既存ファイルの上書きを拒否する # (microsecond 精度でも理論上は衝突しうるため防御的にチェック) if not opts.dest and not _storage.is_s3(dest) and not _storage.is_stdio(dest): diff --git a/tests/cli/test_env_export.py b/tests/cli/test_env_export.py index ac47db2..ace46c1 100644 --- a/tests/cli/test_env_export.py +++ b/tests/cli/test_env_export.py @@ -343,3 +343,99 @@ def test_export_empty_dest_rejects_existing_file( dest="", # 空文字 — None と同様に既定名ガードが効くこと recipients=[f"@{pub_file}"], )) + + +# --- dest 末尾 `/` のファイル名自動補完 (#24, PLAN03-2) --- + + +def test_complete_dir_dest_s3_trailing_slash(monkeypatch): + """S3 URI が末尾 `/` のときは既定ファイル名を append する (`aws s3 cp` 互換)""" + from devbase.env.io_export import _complete_dir_dest + monkeypatch.setattr( + "devbase.env.io_export._default_filename", + lambda fu: "devbase-env-FIXED.dbenv", + ) + assert _complete_dir_dest("s3://bucket/prefix/", False) == \ + "s3://bucket/prefix/devbase-env-FIXED.dbenv" + + +def test_complete_dir_dest_s3_full_key_unchanged(monkeypatch): + """S3 フルキー (末尾 `/` なし) はそのまま返す (回帰防止)""" + from devbase.env.io_export import _complete_dir_dest + monkeypatch.setattr( + "devbase.env.io_export._default_filename", + lambda fu: "devbase-env-FIXED.dbenv", + ) + assert _complete_dir_dest("s3://bucket/prefix/foo.dbenv", False) == \ + "s3://bucket/prefix/foo.dbenv" + + +def test_complete_dir_dest_local_existing_dir(tmp_path, monkeypatch): + """ローカル既存ディレクトリのときも補完する""" + from devbase.env.io_export import _complete_dir_dest + monkeypatch.setattr( + "devbase.env.io_export._default_filename", + lambda fu: "devbase-env-FIXED.dbenv", + ) + d = tmp_path / "outdir" + d.mkdir() + result = _complete_dir_dest(str(d), False) + assert result == str(d / "devbase-env-FIXED.dbenv") + + +def test_complete_dir_dest_local_trailing_slash(tmp_path, monkeypatch): + """末尾 `/` のローカルパスは (ディレクトリが存在しなくても) 補完する""" + from devbase.env.io_export import _complete_dir_dest + monkeypatch.setattr( + "devbase.env.io_export._default_filename", + lambda fu: "devbase-env-FIXED.dbenv", + ) + target = str(tmp_path / "nodir") + "/" + assert _complete_dir_dest(target, False).endswith("/nodir/devbase-env-FIXED.dbenv") + + +def test_complete_dir_dest_local_normal_file_unchanged(tmp_path, monkeypatch): + """通常のファイルパスは補完しない""" + from devbase.env.io_export import _complete_dir_dest + monkeypatch.setattr( + "devbase.env.io_export._default_filename", + lambda fu: "devbase-env-FIXED.dbenv", + ) + target = str(tmp_path / "out.dbenv") + assert _complete_dir_dest(target, False) == target + + +def test_complete_dir_dest_stdio_unchanged(): + """stdio (`-`) は補完しない""" + from devbase.env.io_export import _complete_dir_dest + assert _complete_dir_dest("-", False) == "-" + + +def test_complete_dir_dest_plaintext_suffix(monkeypatch): + """force_unencrypted=True のときは `.dbenv.tar.gz` で補完される""" + from devbase.env.io_export import _complete_dir_dest, _default_filename + name = _default_filename(True) + assert name.endswith(".dbenv.tar.gz") + result = _complete_dir_dest("s3://bucket/prefix/", True) + assert result.endswith(".dbenv.tar.gz") + assert result.startswith("s3://bucket/prefix/") + + +def test_export_local_dir_completes_filename(fake_root, age_keys, tmp_path): + """end-to-end: ローカル既存ディレクトリへの export でファイル名が補完される""" + pub_file, id_file = age_keys + outdir = tmp_path / "outdir" + outdir.mkdir() + rc = export(fake_root, ExportOptions( + dest=str(outdir), + recipients=[f"@{pub_file}"], + )) + assert rc == 0 + # 補完されたファイルが 1 つだけ生成されている + files = list(outdir.iterdir()) + assert len(files) == 1 + assert files[0].name.startswith("devbase-env-") + assert files[0].name.endswith(".dbenv") + # 内容を復号できる + decrypted = cipher.decrypt(files[0].read_bytes(), identities=[str(id_file)]) + assert decrypted From 0fe875068ccaebc06ca86bc3f4eb032039f1985d Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sun, 24 May 2026 11:29:01 +0900 Subject: [PATCH 2/2] =?UTF-8?q?fix(env):=20PR=20#25=20cross-review=20round?= =?UTF-8?q?=201=20gemini=20=E6=8C=87=E6=91=98=E3=81=AE=E4=BF=AE=E6=AD=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `_generate_default_filename` を新設し `_default_dest` / `_default_filename` の結合を解消 (major / 設計) - `_complete_dir_dest` の `endswith` をタプル形式に統一 (minor / 可読性) - `test_complete_dir_dest_local_trailing_slash` を `os.sep` 完全パス比較 (`==`) に変更 (minor / テスト) --- lib/devbase/env/io_export.py | 18 ++++++++++++------ tests/cli/test_env_export.py | 7 +++++-- 2 files changed, 17 insertions(+), 8 deletions(-) diff --git a/lib/devbase/env/io_export.py b/lib/devbase/env/io_export.py index ab72453..ca6f4a4 100644 --- a/lib/devbase/env/io_export.py +++ b/lib/devbase/env/io_export.py @@ -45,17 +45,23 @@ class ExportOptions: unsafe_allow_unencrypted_bucket: bool = False -def _default_dest(force_unencrypted: bool) -> str: +def _generate_default_filename(force_unencrypted: bool) -> str: + """既定ファイル名 (prefix なし) を生成する共通ヘルパー。 + `_default_dest` / `_default_filename` の両方から呼ぶことで結合度を下げる + (PR #25 cross-review round 1 gemini 指摘)。""" # microsecond まで含めて衝突を回避する (PR #22 codex round 3 指摘) ts = datetime.now().strftime('%Y%m%d-%H%M%S-%f') suffix = '.dbenv.tar.gz' if force_unencrypted else '.dbenv' - return f'./devbase-env-{ts}{suffix}' + return f'devbase-env-{ts}{suffix}' + + +def _default_dest(force_unencrypted: bool) -> str: + return f'./{_generate_default_filename(force_unencrypted)}' def _default_filename(force_unencrypted: bool) -> str: - """`_default_dest` の `./` prefix を除いたファイル名部分のみを返す。 - dest がディレクトリ的なときに append する用途。""" - return _default_dest(force_unencrypted).removeprefix('./') + """既定ファイル名 (prefix なし) を返す。dest がディレクトリ的なときに append する用途。""" + return _generate_default_filename(force_unencrypted) def _complete_dir_dest(dest: str, force_unencrypted: bool) -> str: @@ -72,7 +78,7 @@ def _complete_dir_dest(dest: str, force_unencrypted: bool) -> str: return dest + name if dest.endswith('/') else dest # ローカル: 既存ディレクトリか末尾 `/` ならディレクトリ扱い p = Path(dest) - if dest.endswith('/') or dest.endswith(os.sep) or p.is_dir(): + if dest.endswith(('/', os.sep)) or p.is_dir(): return str(p / name) return dest diff --git a/tests/cli/test_env_export.py b/tests/cli/test_env_export.py index ace46c1..bbff4b5 100644 --- a/tests/cli/test_env_export.py +++ b/tests/cli/test_env_export.py @@ -3,6 +3,7 @@ from __future__ import annotations import io +import os from pathlib import Path import pyrage @@ -390,8 +391,10 @@ def test_complete_dir_dest_local_trailing_slash(tmp_path, monkeypatch): "devbase.env.io_export._default_filename", lambda fu: "devbase-env-FIXED.dbenv", ) - target = str(tmp_path / "nodir") + "/" - assert _complete_dir_dest(target, False).endswith("/nodir/devbase-env-FIXED.dbenv") + target_dir = tmp_path / "nodir" + target_path_with_slash = str(target_dir) + os.sep + expected_path = str(target_dir / "devbase-env-FIXED.dbenv") + assert _complete_dir_dest(target_path_with_slash, False) == expected_path def test_complete_dir_dest_local_normal_file_unchanged(tmp_path, monkeypatch):