From 80993778f0b181c912632252aef25d6d63c3d2a6 Mon Sep 17 00:00:00 2001 From: Craig Jennings Date: Thu, 2 Jul 2026 05:58:03 -0400 Subject: fix(inbox-send): never overwrite on filename collision Two sends in the same minute whose text starts with the same phrase derived identical filenames, and the second silently replaced the first. A message was lost this way in the wild. An existing target now gets a -2/-3 stem suffix, extension preserved, on both the text and file paths. Four red-first tests reproduce the loss with a fixed timestamp so the same-minute case is deterministic. --- .ai/scripts/inbox-send.py | 21 +++++- .ai/scripts/tests/test_inbox_send.py | 75 ++++++++++++++++++++++ claude-templates/.ai/scripts/inbox-send.py | 21 +++++- .../.ai/scripts/tests/test_inbox_send.py | 75 ++++++++++++++++++++++ 4 files changed, 188 insertions(+), 4 deletions(-) diff --git a/.ai/scripts/inbox-send.py b/.ai/scripts/inbox-send.py index 1362a1f..1ebb636 100755 --- a/.ai/scripts/inbox-send.py +++ b/.ai/scripts/inbox-send.py @@ -177,6 +177,23 @@ def build_text_org(message: str, source_name: str, timestamp: str) -> str: ) +def uniquify(dest: Path) -> Path: + """Return dest, or dest with a -2/-3/... stem suffix when it already exists. + + Two sends in the same minute whose text starts with the same phrase + derive identical filenames, and the second silently overwrote the + first (a message was lost this way, 2026-07-02). Never overwrite. + """ + if not dest.exists(): + return dest + n = 2 + while True: + candidate = dest.with_name(f"{dest.stem}-{n}{dest.suffix}") + if not candidate.exists(): + return candidate + n += 1 + + def send_text( target_inbox: Path, message: str, @@ -191,7 +208,7 @@ def send_text( if not slug: raise ValueError(f"could not derive a slug from text: {message!r}") filename = f"{now.strftime(TS_FILENAME_FMT)}-from-{source_name}-{slug}.org" - dest = target_inbox / filename + dest = uniquify(target_inbox / filename) dest.write_text(build_text_org(message, source_name, now.strftime(TS_DOC_FMT))) return dest @@ -211,7 +228,7 @@ def send_file( raise ValueError(f"could not derive a slug from file: {src_path}") ext = src_path.suffix filename = f"{now.strftime(TS_FILENAME_FMT)}-from-{source_name}-{slug}{ext}" - dest = target_inbox / filename + dest = uniquify(target_inbox / filename) shutil.copy2(src_path, dest) return dest diff --git a/.ai/scripts/tests/test_inbox_send.py b/.ai/scripts/tests/test_inbox_send.py index cb60e63..f75d7a1 100644 --- a/.ai/scripts/tests/test_inbox_send.py +++ b/.ai/scripts/tests/test_inbox_send.py @@ -401,3 +401,78 @@ class TestInboxSendErrors: assert result.returncode != 0 files = list((tmp_path / "projects" / "target" / "inbox").iterdir()) assert files == [] + + +# ---------------------------------------------------------------------- +# Filename collisions (two sends deriving the same name must not overwrite) +# ---------------------------------------------------------------------- + +def _load_module(): + import importlib.util + spec = importlib.util.spec_from_file_location("inbox_send", SCRIPT) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + return mod + + +class TestFilenameCollisions: + """Two sends in the same minute with the same leading phrase derived + identical filenames and the second silently overwrote the first + (a message was lost this way, 2026-07-02).""" + + def test_send_text_same_minute_same_phrase_keeps_both(self, tmp_path): + from datetime import datetime + mod = _load_module() + inbox = tmp_path / "inbox" + inbox.mkdir() + now = datetime(2026, 7, 2, 5, 42, 0) + prefix = "identical leading phrase long enough to fill the whole slug budget entirely" + first = mod.send_text(inbox, prefix + " tail one", "archsetup", None, now) + second = mod.send_text(inbox, prefix + " tail two", "archsetup", None, now) + assert first != second + assert first.exists() and second.exists() + assert first.name != second.name + assert "tail one" in first.read_text() + assert "tail two" in second.read_text() + + def test_send_text_collision_suffix_increments(self, tmp_path): + from datetime import datetime + mod = _load_module() + inbox = tmp_path / "inbox" + inbox.mkdir() + now = datetime(2026, 7, 2, 5, 42, 0) + paths = [mod.send_text(inbox, "same lead phrase differs later A", "src", "fixed-slug", now) + for _ in range(3)] + names = [p.name for p in paths] + assert names[0].endswith("fixed-slug.org") + assert names[1].endswith("fixed-slug-2.org") + assert names[2].endswith("fixed-slug-3.org") + + def test_send_file_collision_preserves_extension(self, tmp_path): + from datetime import datetime + mod = _load_module() + inbox = tmp_path / "inbox" + inbox.mkdir() + src = tmp_path / "note.org" + src.write_text("body one") + now = datetime(2026, 7, 2, 5, 42, 0) + first = mod.send_file(inbox, src, "src", None, now) + src.write_text("body two") + second = mod.send_file(inbox, src, "src", None, now) + assert second.name.endswith("note-2.org") + assert first.read_text() == "body one" + assert second.read_text() == "body two" + + def test_cli_two_rapid_sends_lose_nothing(self, project_root, run_script, tmp_path): + project_root("sender") + target = project_root("receiver") + roots = [tmp_path / "projects"] + prefix = "identical leading phrase long enough to fill the whole slug budget entirely" + run_script(["receiver", "--text", prefix + " message one"], + cwd=tmp_path / "projects" / "sender", roots=roots) + run_script(["receiver", "--text", prefix + " message two"], + cwd=tmp_path / "projects" / "sender", roots=roots) + files = list((target / "inbox").iterdir()) + assert len(files) == 2 + bodies = "".join(f.read_text() for f in files) + assert "message one" in bodies and "message two" in bodies diff --git a/claude-templates/.ai/scripts/inbox-send.py b/claude-templates/.ai/scripts/inbox-send.py index 1362a1f..1ebb636 100755 --- a/claude-templates/.ai/scripts/inbox-send.py +++ b/claude-templates/.ai/scripts/inbox-send.py @@ -177,6 +177,23 @@ def build_text_org(message: str, source_name: str, timestamp: str) -> str: ) +def uniquify(dest: Path) -> Path: + """Return dest, or dest with a -2/-3/... stem suffix when it already exists. + + Two sends in the same minute whose text starts with the same phrase + derive identical filenames, and the second silently overwrote the + first (a message was lost this way, 2026-07-02). Never overwrite. + """ + if not dest.exists(): + return dest + n = 2 + while True: + candidate = dest.with_name(f"{dest.stem}-{n}{dest.suffix}") + if not candidate.exists(): + return candidate + n += 1 + + def send_text( target_inbox: Path, message: str, @@ -191,7 +208,7 @@ def send_text( if not slug: raise ValueError(f"could not derive a slug from text: {message!r}") filename = f"{now.strftime(TS_FILENAME_FMT)}-from-{source_name}-{slug}.org" - dest = target_inbox / filename + dest = uniquify(target_inbox / filename) dest.write_text(build_text_org(message, source_name, now.strftime(TS_DOC_FMT))) return dest @@ -211,7 +228,7 @@ def send_file( raise ValueError(f"could not derive a slug from file: {src_path}") ext = src_path.suffix filename = f"{now.strftime(TS_FILENAME_FMT)}-from-{source_name}-{slug}{ext}" - dest = target_inbox / filename + dest = uniquify(target_inbox / filename) shutil.copy2(src_path, dest) return dest diff --git a/claude-templates/.ai/scripts/tests/test_inbox_send.py b/claude-templates/.ai/scripts/tests/test_inbox_send.py index cb60e63..f75d7a1 100644 --- a/claude-templates/.ai/scripts/tests/test_inbox_send.py +++ b/claude-templates/.ai/scripts/tests/test_inbox_send.py @@ -401,3 +401,78 @@ class TestInboxSendErrors: assert result.returncode != 0 files = list((tmp_path / "projects" / "target" / "inbox").iterdir()) assert files == [] + + +# ---------------------------------------------------------------------- +# Filename collisions (two sends deriving the same name must not overwrite) +# ---------------------------------------------------------------------- + +def _load_module(): + import importlib.util + spec = importlib.util.spec_from_file_location("inbox_send", SCRIPT) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + return mod + + +class TestFilenameCollisions: + """Two sends in the same minute with the same leading phrase derived + identical filenames and the second silently overwrote the first + (a message was lost this way, 2026-07-02).""" + + def test_send_text_same_minute_same_phrase_keeps_both(self, tmp_path): + from datetime import datetime + mod = _load_module() + inbox = tmp_path / "inbox" + inbox.mkdir() + now = datetime(2026, 7, 2, 5, 42, 0) + prefix = "identical leading phrase long enough to fill the whole slug budget entirely" + first = mod.send_text(inbox, prefix + " tail one", "archsetup", None, now) + second = mod.send_text(inbox, prefix + " tail two", "archsetup", None, now) + assert first != second + assert first.exists() and second.exists() + assert first.name != second.name + assert "tail one" in first.read_text() + assert "tail two" in second.read_text() + + def test_send_text_collision_suffix_increments(self, tmp_path): + from datetime import datetime + mod = _load_module() + inbox = tmp_path / "inbox" + inbox.mkdir() + now = datetime(2026, 7, 2, 5, 42, 0) + paths = [mod.send_text(inbox, "same lead phrase differs later A", "src", "fixed-slug", now) + for _ in range(3)] + names = [p.name for p in paths] + assert names[0].endswith("fixed-slug.org") + assert names[1].endswith("fixed-slug-2.org") + assert names[2].endswith("fixed-slug-3.org") + + def test_send_file_collision_preserves_extension(self, tmp_path): + from datetime import datetime + mod = _load_module() + inbox = tmp_path / "inbox" + inbox.mkdir() + src = tmp_path / "note.org" + src.write_text("body one") + now = datetime(2026, 7, 2, 5, 42, 0) + first = mod.send_file(inbox, src, "src", None, now) + src.write_text("body two") + second = mod.send_file(inbox, src, "src", None, now) + assert second.name.endswith("note-2.org") + assert first.read_text() == "body one" + assert second.read_text() == "body two" + + def test_cli_two_rapid_sends_lose_nothing(self, project_root, run_script, tmp_path): + project_root("sender") + target = project_root("receiver") + roots = [tmp_path / "projects"] + prefix = "identical leading phrase long enough to fill the whole slug budget entirely" + run_script(["receiver", "--text", prefix + " message one"], + cwd=tmp_path / "projects" / "sender", roots=roots) + run_script(["receiver", "--text", prefix + " message two"], + cwd=tmp_path / "projects" / "sender", roots=roots) + files = list((target / "inbox").iterdir()) + assert len(files) == 2 + bodies = "".join(f.read_text() for f in files) + assert "message one" in bodies and "message two" in bodies -- cgit v1.2.3