diff --git a/scripts/odysseus-backup b/scripts/odysseus-backup index b0f312074..9709ed6b5 100755 --- a/scripts/odysseus-backup +++ b/scripts/odysseus-backup @@ -133,18 +133,32 @@ def cmd_list(args): emit([], args) return entries = [] - for p in sorted(_BACKUP_DIR.iterdir(), key=lambda x: x.stat().st_mtime, reverse=True): - if not p.is_file(): - continue - entries.append({ - "path": str(p), - "name": p.name, - "bytes": p.stat().st_size, - "modified": datetime.fromtimestamp(p.stat().st_mtime).isoformat(), - }) + for p in _BACKUP_DIR.iterdir(): + entry = _backup_entry(p) + if entry is not None: + entries.append(entry) + entries.sort(key=lambda entry: entry["_mtime"], reverse=True) + for entry in entries: + entry.pop("_mtime", None) emit(entries, args) +def _backup_entry(p): + try: + if not p.is_file(): + return None + st = p.stat() + except OSError: + return None + return { + "path": str(p), + "name": p.name, + "bytes": st.st_size, + "modified": datetime.fromtimestamp(st.st_mtime).isoformat(), + "_mtime": st.st_mtime, + } + + def cmd_verify(args): """Open the tarball read-only and walk its members — confirms integrity without extracting anything.""" diff --git a/tests/test_backup_cli_security.py b/tests/test_backup_cli_security.py index 23baa44cb..89d1dcddf 100644 --- a/tests/test_backup_cli_security.py +++ b/tests/test_backup_cli_security.py @@ -25,6 +25,46 @@ def _verify_args(path: Path): return SimpleNamespace(path=str(path), pretty=False) +def test_backup_entry_skips_files_that_disappear(): + backup = _load_backup_cli() + + class Vanished: + name = "gone.tar.gz" + + def is_file(self): + return True + + def stat(self): + raise FileNotFoundError("gone") + + def __str__(self): + return "backups/gone.tar.gz" + + assert backup._backup_entry(Vanished()) is None + + +def test_backup_list_sorts_by_captured_mtime(monkeypatch): + backup = _load_backup_cli() + first = SimpleNamespace(name="older.tar.gz") + second = SimpleNamespace(name="newer.tar.gz") + monkeypatch.setattr(backup, "_BACKUP_DIR", SimpleNamespace( + is_dir=lambda: True, + iterdir=lambda: [first, second], + )) + monkeypatch.setattr(backup, "_backup_entry", lambda p: { + "name": p.name, + "modified": "2026-10-25T01:45:00" if p is first else "2026-10-25T01:15:00", + "_mtime": 100 if p is first else 200, + }) + seen = [] + monkeypatch.setattr(backup, "emit", lambda payload, args: seen.append(payload)) + + backup.cmd_list(SimpleNamespace(pretty=False)) + + assert [entry["name"] for entry in seen[0]] == ["newer.tar.gz", "older.tar.gz"] + assert all("_mtime" not in entry for entry in seen[0]) + + def test_snapshot_rejects_output_inside_data_dir(tmp_path, monkeypatch): backup = _load_backup_cli() repo = tmp_path / "repo"