Skip to content

Commit 1db5ca7

Browse files
takemi-ohamaclaude
andcommitted
fix(env): PLAN03-2 export dest 末尾 / のファイル名自動補完 (#24)
`devbase env export s3://bucket/prefix/` のように末尾 `/` の dest を指定したとき、 ファイル名部分が空のオブジェクトが作られていた問題を修正する。 `aws s3 cp` 互換でディレクトリ的 dest に既定ファイル名 (`devbase-env-<TS>.dbenv`) を自動補完する。 - S3 URI 末尾 `/`: prefix にファイル名を append - ローカル既存ディレクトリ / 末尾 `/`: ディレクトリ配下にファイルを生成 - それ以外 (フルキー / 通常ファイルパス / stdio `-`) は従来通り Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 83942e4 commit 1db5ca7

2 files changed

Lines changed: 130 additions & 0 deletions

File tree

lib/devbase/env/io_export.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from __future__ import annotations
44

55
import getpass # noqa: F401 (tests monkey-patch devbase.env.io_export.getpass)
6+
import os
67
import re
78
from dataclasses import dataclass, field
89
from datetime import datetime
@@ -51,6 +52,31 @@ def _default_dest(force_unencrypted: bool) -> str:
5152
return f'./devbase-env-{ts}{suffix}'
5253

5354

55+
def _default_filename(force_unencrypted: bool) -> str:
56+
"""`_default_dest` の `./` prefix を除いたファイル名部分のみを返す。
57+
dest がディレクトリ的なときに append する用途。"""
58+
return _default_dest(force_unencrypted).removeprefix('./')
59+
60+
61+
def _complete_dir_dest(dest: str, force_unencrypted: bool) -> str:
62+
"""dest が「ディレクトリ的」なら既定ファイル名を補完する (`aws s3 cp` 互換、#24)。
63+
64+
- S3 URI で末尾が `/`: `s3://bucket/prefix/` → `s3://bucket/prefix/<default>`
65+
- ローカルで既存ディレクトリ: `/tmp/out/` (または末尾 `/` なし) → `/tmp/out/<default>`
66+
- それ以外 (フルキー / 通常ファイルパス / stdio `-`) はそのまま返す。
67+
"""
68+
if _storage.is_stdio(dest):
69+
return dest
70+
name = _default_filename(force_unencrypted)
71+
if _storage.is_s3(dest):
72+
return dest + name if dest.endswith('/') else dest
73+
# ローカル: 既存ディレクトリか末尾 `/` ならディレクトリ扱い
74+
p = Path(dest)
75+
if dest.endswith('/') or dest.endswith(os.sep) or p.is_dir():
76+
return str(p / name)
77+
return dest
78+
79+
5480
def _read_passphrase(opts: ExportOptions) -> Optional[str]:
5581
"""既存テストとの互換のために残している thin wrapper。
5682
実体は :mod:`devbase.env.io_common.read_passphrase`。"""
@@ -163,6 +189,14 @@ def export(devbase_root: Path, opts: ExportOptions) -> int:
163189
logger.debug("暗号化後サイズ: %d bytes", len(payload))
164190

165191
dest = opts.dest or _default_dest(opts.force_unencrypted)
192+
# dest が「ディレクトリ的」なら `aws s3 cp` 互換でファイル名を自動補完する (#24)。
193+
# 末尾 `/` の S3 URI で空キーオブジェクトが作られる事故と、ローカル既存
194+
# ディレクトリへの OSError fail-fast の両方を救う。
195+
if opts.dest:
196+
completed = _complete_dir_dest(dest, opts.force_unencrypted)
197+
if completed != dest:
198+
logger.info("dest がディレクトリ的なためファイル名を補完: %s", completed)
199+
dest = completed
166200
# 既定名 (opts.dest 未指定) かつローカルパスの場合、既存ファイルの上書きを拒否する
167201
# (microsecond 精度でも理論上は衝突しうるため防御的にチェック)
168202
if not opts.dest and not _storage.is_s3(dest) and not _storage.is_stdio(dest):

tests/cli/test_env_export.py

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -343,3 +343,99 @@ def test_export_empty_dest_rejects_existing_file(
343343
dest="", # 空文字 — None と同様に既定名ガードが効くこと
344344
recipients=[f"@{pub_file}"],
345345
))
346+
347+
348+
# --- dest 末尾 `/` のファイル名自動補完 (#24, PLAN03-2) ---
349+
350+
351+
def test_complete_dir_dest_s3_trailing_slash(monkeypatch):
352+
"""S3 URI が末尾 `/` のときは既定ファイル名を append する (`aws s3 cp` 互換)"""
353+
from devbase.env.io_export import _complete_dir_dest
354+
monkeypatch.setattr(
355+
"devbase.env.io_export._default_filename",
356+
lambda fu: "devbase-env-FIXED.dbenv",
357+
)
358+
assert _complete_dir_dest("s3://bucket/prefix/", False) == \
359+
"s3://bucket/prefix/devbase-env-FIXED.dbenv"
360+
361+
362+
def test_complete_dir_dest_s3_full_key_unchanged(monkeypatch):
363+
"""S3 フルキー (末尾 `/` なし) はそのまま返す (回帰防止)"""
364+
from devbase.env.io_export import _complete_dir_dest
365+
monkeypatch.setattr(
366+
"devbase.env.io_export._default_filename",
367+
lambda fu: "devbase-env-FIXED.dbenv",
368+
)
369+
assert _complete_dir_dest("s3://bucket/prefix/foo.dbenv", False) == \
370+
"s3://bucket/prefix/foo.dbenv"
371+
372+
373+
def test_complete_dir_dest_local_existing_dir(tmp_path, monkeypatch):
374+
"""ローカル既存ディレクトリのときも補完する"""
375+
from devbase.env.io_export import _complete_dir_dest
376+
monkeypatch.setattr(
377+
"devbase.env.io_export._default_filename",
378+
lambda fu: "devbase-env-FIXED.dbenv",
379+
)
380+
d = tmp_path / "outdir"
381+
d.mkdir()
382+
result = _complete_dir_dest(str(d), False)
383+
assert result == str(d / "devbase-env-FIXED.dbenv")
384+
385+
386+
def test_complete_dir_dest_local_trailing_slash(tmp_path, monkeypatch):
387+
"""末尾 `/` のローカルパスは (ディレクトリが存在しなくても) 補完する"""
388+
from devbase.env.io_export import _complete_dir_dest
389+
monkeypatch.setattr(
390+
"devbase.env.io_export._default_filename",
391+
lambda fu: "devbase-env-FIXED.dbenv",
392+
)
393+
target = str(tmp_path / "nodir") + "/"
394+
assert _complete_dir_dest(target, False).endswith("/nodir/devbase-env-FIXED.dbenv")
395+
396+
397+
def test_complete_dir_dest_local_normal_file_unchanged(tmp_path, monkeypatch):
398+
"""通常のファイルパスは補完しない"""
399+
from devbase.env.io_export import _complete_dir_dest
400+
monkeypatch.setattr(
401+
"devbase.env.io_export._default_filename",
402+
lambda fu: "devbase-env-FIXED.dbenv",
403+
)
404+
target = str(tmp_path / "out.dbenv")
405+
assert _complete_dir_dest(target, False) == target
406+
407+
408+
def test_complete_dir_dest_stdio_unchanged():
409+
"""stdio (`-`) は補完しない"""
410+
from devbase.env.io_export import _complete_dir_dest
411+
assert _complete_dir_dest("-", False) == "-"
412+
413+
414+
def test_complete_dir_dest_plaintext_suffix(monkeypatch):
415+
"""force_unencrypted=True のときは `.dbenv.tar.gz` で補完される"""
416+
from devbase.env.io_export import _complete_dir_dest, _default_filename
417+
name = _default_filename(True)
418+
assert name.endswith(".dbenv.tar.gz")
419+
result = _complete_dir_dest("s3://bucket/prefix/", True)
420+
assert result.endswith(".dbenv.tar.gz")
421+
assert result.startswith("s3://bucket/prefix/")
422+
423+
424+
def test_export_local_dir_completes_filename(fake_root, age_keys, tmp_path):
425+
"""end-to-end: ローカル既存ディレクトリへの export でファイル名が補完される"""
426+
pub_file, id_file = age_keys
427+
outdir = tmp_path / "outdir"
428+
outdir.mkdir()
429+
rc = export(fake_root, ExportOptions(
430+
dest=str(outdir),
431+
recipients=[f"@{pub_file}"],
432+
))
433+
assert rc == 0
434+
# 補完されたファイルが 1 つだけ生成されている
435+
files = list(outdir.iterdir())
436+
assert len(files) == 1
437+
assert files[0].name.startswith("devbase-env-")
438+
assert files[0].name.endswith(".dbenv")
439+
# 内容を復号できる
440+
decrypted = cipher.decrypt(files[0].read_bytes(), identities=[str(id_file)])
441+
assert decrypted

0 commit comments

Comments
 (0)