Skip to content

Commit a751323

Browse files
takemi-ohamaclaude
andcommitted
fix(env): backup GC に dbenv- prefix で安全弁 + export 既定名を microsecond 精度に
PR #22 round3 codex 指摘対応: 1. [major] _import_atomic.py: backup ディレクトリ名に `dbenv-` prefix を付与し、 --backup-dir 親に無関係なタイムスタンプ形式ディレクトリがあっても GC で rmtree しないようにした。旧フォーマット (prefix なし) は後方互換で GC 対象。 2. [minor] io_export.py: 既定出力名を秒精度から microsecond 精度に変更し、 同一秒の複数 export による無言上書きを防止。加えて既定名使用時に同名 ファイルが既に存在する場合は ExportError で失敗させる。 テスト追加: - test_gc_backups_ignores_bare_timestamp_dirs_from_other_tools - test_default_dest_includes_microsecond - test_export_default_dest_rejects_existing_file Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent b81aae4 commit a751323

4 files changed

Lines changed: 92 additions & 17 deletions

File tree

lib/devbase/env/_import_atomic.py

Lines changed: 16 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -29,14 +29,18 @@
2929

3030
logger = get_logger(__name__)
3131

32-
# _make_backup_dir が生成するタイムスタンプ形式のみを GC 対象にする。
33-
# 以下のいずれかにマッチするディレクトリのみ削除する:
34-
# YYYYMMDD-HHMMSS (旧フォーマット, 後方互換)
35-
# YYYYMMDD-HHMMSS-NNNNNN (microsecond 付き)
36-
# YYYYMMDD-HHMMSS-NNNNNN-NN (同一マイクロ秒内の連番付き)
37-
# これ以外のディレクトリは devbase が作ったものではないので削除しない
38-
# (--backup-dir 親に無関係なディレクトリがあっても安全)。
39-
_BACKUP_DIR_NAME_RE = re.compile(r'^\d{8}-\d{6}(?:-\d{6}(?:-\d+)?)?$')
32+
# make_backup_dir が生成するディレクトリ名形式のみを GC 対象にする。
33+
# prefix ``dbenv-`` を付けることで、--backup-dir 親に無関係なタイムスタンプ
34+
# ディレクトリ (他ツールの backup 等) が存在しても誤って rmtree しない。
35+
# dbenv-YYYYMMDD-HHMMSS-NNNNNN (microsecond 付き)
36+
# dbenv-YYYYMMDD-HHMMSS-NNNNNN-NN (同一マイクロ秒内の連番付き)
37+
# (旧フォーマット ``YYYYMMDD-HHMMSS`` は prefix 無しだが後方互換のため残す)
38+
_BACKUP_DIR_PREFIX = 'dbenv-'
39+
_BACKUP_DIR_NAME_RE = re.compile(
40+
r'^dbenv-\d{8}-\d{6}-\d{6}(?:-\d+)?$' # 新フォーマット (prefix 付き)
41+
r'|'
42+
r'^\d{8}-\d{6}(?:-\d{6}(?:-\d+)?)?$' # 旧フォーマット (後方互換)
43+
)
4044

4145

4246
class AtomicError(DevbaseError):
@@ -55,7 +59,7 @@ def make_backup_dir(devbase_root: Path, backup_dir: Optional[str]) -> Path:
5559
else devbase_root / 'backups' / 'env-import')
5660
base.mkdir(parents=True, exist_ok=True)
5761

58-
stem = datetime.now().strftime('%Y%m%d-%H%M%S-%f') # microsecond まで
62+
stem = _BACKUP_DIR_PREFIX + datetime.now().strftime('%Y%m%d-%H%M%S-%f')
5963
primary = base / stem
6064
if not primary.exists():
6165
primary.mkdir(parents=True)
@@ -184,9 +188,9 @@ def cleanup_tmps(tmps) -> None:
184188
def gc_backups(backup_dir: Path, keep_last: int) -> None:
185189
"""``backup_dir`` の親ディレクトリで古い backup を ``keep_last`` 個まで残して GC する。
186190
187-
devbase 生成のタイムスタンプ形式 (``YYYYMMDD-HHMMSS[-NNNNNN[-NN]]``) に
188-
マッチするディレクトリのみが GC 対象。``--backup-dir`` 親に無関係な
189-
ファイル / ディレクトリがあっても、それらは触らない
191+
``dbenv-`` prefix 付きの devbase 生成ディレクトリ、または旧フォーマットの
192+
タイムスタンプ形式 (``YYYYMMDD-HHMMSS[-NNNNNN[-NN]]``) にマッチする
193+
ディレクトリのみが GC 対象。``--backup-dir`` 親に無関係なディレクトリは触らない
190194
"""
191195
if keep_last <= 0:
192196
return

lib/devbase/env/io_export.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,8 @@ class ExportOptions:
4545

4646

4747
def _default_dest(force_unencrypted: bool) -> str:
48-
ts = datetime.now().strftime('%Y%m%d-%H%M%S')
48+
# microsecond まで含めて衝突を回避する (PR #22 codex round 3 指摘)
49+
ts = datetime.now().strftime('%Y%m%d-%H%M%S-%f')
4950
suffix = '.dbenv.tar.gz' if force_unencrypted else '.dbenv'
5051
return f'./devbase-env-{ts}{suffix}'
5152

@@ -162,6 +163,14 @@ def export(devbase_root: Path, opts: ExportOptions) -> int:
162163
logger.debug("暗号化後サイズ: %d bytes", len(payload))
163164

164165
dest = opts.dest or _default_dest(opts.force_unencrypted)
166+
# 既定名 (opts.dest 未指定) かつローカルパスの場合、既存ファイルの上書きを拒否する
167+
# (microsecond 精度でも理論上は衝突しうるため防御的にチェック)
168+
if opts.dest is None and not _storage.is_s3(dest) and not _storage.is_stdio(dest):
169+
if Path(dest).exists():
170+
raise ExportError(
171+
f"既定出力先 {dest} が既に存在します。"
172+
"出力先を明示的に指定するか、既存ファイルを移動してください"
173+
)
165174
# S3 など backend 固有のオプションを渡したい場合は s3_options を組み立てる。
166175
# それ以外 (local/stdio) では未使用なので無害。
167176
s3_options = (_storage.S3Options.from_env(

tests/cli/test_env_export.py

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,8 @@
1010

1111
from devbase.env import bundle, cipher
1212
from devbase.env.io_export import (
13-
ExportOptions, ExportError, _read_passphrase, _validate_options, export,
13+
ExportOptions, ExportError, _default_dest, _read_passphrase,
14+
_validate_options, export,
1415
)
1516

1617

@@ -292,3 +293,32 @@ def test_validate_rejects_force_unencrypted_with_passphrase_stdin():
292293
force_unencrypted=True,
293294
passphrase_stdin=True,
294295
))
296+
297+
298+
# --- default dest 衝突回避 (PR #22 codex round 3 指摘) ---
299+
300+
301+
def test_default_dest_includes_microsecond():
302+
"""既定出力名が microsecond 精度を含むこと"""
303+
name = _default_dest(force_unencrypted=False)
304+
# ./devbase-env-YYYYMMDD-HHMMSS-ffffff.dbenv
305+
import re
306+
assert re.match(r'^\./devbase-env-\d{8}-\d{6}-\d{6}\.dbenv$', name), name
307+
308+
309+
def test_export_default_dest_rejects_existing_file(
310+
fake_root, age_keys, tmp_path, monkeypatch):
311+
"""既定出力先に同名ファイルが既に存在する場合は ExportError を上げる"""
312+
pub_file, _ = age_keys
313+
# _default_dest を固定して衝突を再現する
314+
fixed_name = "./devbase-env-20240101-120000-000000.dbenv"
315+
monkeypatch.setattr("devbase.env.io_export._default_dest", lambda fu: fixed_name)
316+
# 既存ファイルを作成
317+
existing = tmp_path / "devbase-env-20240101-120000-000000.dbenv"
318+
existing.write_bytes(b"old data")
319+
monkeypatch.chdir(tmp_path)
320+
321+
with pytest.raises(ExportError, match="既に存在します"):
322+
export(fake_root, ExportOptions(
323+
recipients=[f"@{pub_file}"],
324+
))

tests/cli/test_env_import.py

Lines changed: 35 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -345,8 +345,9 @@ def test_import_keep_last_gc_removes_old_backups(fake_root, dest_root, age_keys,
345345

346346
remaining = sorted(p.name for p in backup_root.iterdir())
347347
assert len(remaining) == 3
348-
# 最新 3 個に絞られる: 既存の 20260101-000003, 000004, 加えて新規 backup
349-
assert remaining[-1].startswith('20')
348+
# 最新 3 個に絞られる: 既存の旧フォーマット 2 個 + dbenv- prefix 付き新規 backup
349+
# 新規 backup は dbenv- prefix 付き (ソート順で末尾)
350+
assert remaining[-1].startswith('dbenv-')
350351

351352

352353
def test_import_include_project_filter(fake_root, dest_root, age_keys, tmp_path):
@@ -476,7 +477,7 @@ def test_gc_backups_only_removes_timestamp_dirs(fake_root, dest_root, age_keys,
476477
# 関係ないファイル
477478
unrelated_file = custom_backup_root / "readme.txt"
478479
unrelated_file.write_text("must not be deleted")
479-
# devbase 命名の古い backup を keep_last 超に置く
480+
# devbase 命名 (旧フォーマット) の古い backup を keep_last 超に置く
480481
for i in range(5):
481482
(custom_backup_root / f"20240101-00000{i}").mkdir()
482483

@@ -497,6 +498,37 @@ def test_gc_backups_only_removes_timestamp_dirs(fake_root, dest_root, age_keys,
497498
assert len(timestamp_dirs) == 3
498499

499500

501+
def test_gc_backups_ignores_bare_timestamp_dirs_from_other_tools(
502+
fake_root, dest_root, age_keys, tmp_path):
503+
"""--backup-dir 親にタイムスタンプ形式だが prefix 無しの無関係ディレクトリがあっても
504+
旧フォーマット (後方互換) 以外は GC 対象にならない。新たに作られる backup は
505+
dbenv- prefix 付きになる"""
506+
_, id_file = age_keys
507+
bundle_path = _export_bundle(fake_root, age_keys, tmp_path)
508+
509+
custom_backup_root = tmp_path / "shared-backups"
510+
custom_backup_root.mkdir()
511+
# 他ツールが作ったタイムスタンプ風ディレクトリ (prefix 無し・microsecond 付き形式に
512+
# 一致しないパターン: 例えば "backup-20240101-120000" は regex に引っかからない)
513+
other_tool_dir = custom_backup_root / "backup-20240101-120000"
514+
other_tool_dir.mkdir()
515+
(other_tool_dir / "data.db").write_text("important")
516+
517+
rc = import_bundle(dest_root, ImportOptions(
518+
source=str(bundle_path), identities=[str(id_file)],
519+
backup_dir=str(custom_backup_root), keep_last=1))
520+
assert rc == 0
521+
522+
# 他ツールのディレクトリは無傷
523+
assert other_tool_dir.exists()
524+
assert (other_tool_dir / "data.db").read_text() == "important"
525+
526+
# 新しい backup は dbenv- prefix 付き
527+
new_backups = [p for p in custom_backup_root.iterdir()
528+
if p.is_dir() and p.name.startswith("dbenv-")]
529+
assert len(new_backups) == 1
530+
531+
500532
def test_import_passphrase_env_roundtrip(fake_root, dest_root, tmp_path, monkeypatch):
501533
dest = tmp_path / "out.dbenv"
502534
monkeypatch.setenv("DEVBASE_TEST_PASS", "s3cr3t")

0 commit comments

Comments
 (0)