From a71ff1007100eeafd06db1ac095a57fe1c6f1bbd Mon Sep 17 00:00:00 2001 From: JoaoMarcos44 Date: Sat, 26 Sep 2026 21:14:49 -0300 Subject: [PATCH] test(backup): cover failed zip member cleanup (cherry picked from commit 857b969c57cd1c7f50b4d5c3d41800e2bc24e4d0) --- hermes_cli/backup.py | 1 + tests/hermes_cli/test_backup.py | 81 +++++++++++++++++++++++++++++++++ 2 files changed, 82 insertions(+) diff --git a/hermes_cli/backup.py b/hermes_cli/backup.py index 36cab03a49..7bc1594204 100644 --- a/hermes_cli/backup.py +++ b/hermes_cli/backup.py @@ -468,6 +468,7 @@ def _write_zip_file(zf: zipfile.ZipFile, path: Path, arcname: str) -> None: _discard_failed_zip_members(zf, filelist_len) raise + def _zip_sqlite_snapshot(zf: zipfile.ZipFile, abs_path: Path, rel_path: Path, out_path: Path) -> Optional[int]: """Add a WAL-safe snapshot of *abs_path* to *zf*; return its byte size, or None on failure. diff --git a/tests/hermes_cli/test_backup.py b/tests/hermes_cli/test_backup.py index 47c00dc16d..8313e58f87 100644 --- a/tests/hermes_cli/test_backup.py +++ b/tests/hermes_cli/test_backup.py @@ -1778,6 +1778,87 @@ class TestQuickSnapshotProjectsKanban: assert rows == [("w1", "ship")] +def _fail_zip_write_after(monkeypatch, member: str, byte_count: int = 120_000) -> None: + """Make ZipFile.write finalize a large partial member, then surface a source-read failure.""" + real_write = zipfile.ZipFile.write + + def flaky_write(self, filename, arcname=None, compress_type=None, compresslevel=None): + if str(arcname) == member: + with Path(filename).open("rb") as src, self.open(str(arcname), "w") as dst: + dst.write(src.read(byte_count)) + raise OSError(5, "simulated source read failure") + return real_write(self, filename, arcname, compress_type, compresslevel) + + monkeypatch.setattr(zipfile.ZipFile, "write", flaky_write) + + +class TestFailedZipMemberRecovery: + def test_automatic_backup_omits_crc_valid_partial_member(self, tmp_path, monkeypatch): + hermes_home = tmp_path / ".hermes" + hermes_home.mkdir() + (hermes_home / "config.yaml").write_text("model: test\n") + (hermes_home / "flaky.bin").write_bytes(os.urandom(300_000)) + archive = tmp_path / "automatic.zip" + _fail_zip_write_after(monkeypatch, "flaky.bin") + + from hermes_cli.backup import _write_full_zip_backup + + assert _write_full_zip_backup(archive, hermes_home) is None + assert archive.exists() + # The failed payload is deliberately left orphaned before the central directory. + # Keeping >64 KiB here proves the archive remains readable without rewinding start_dir. + assert archive.stat().st_size > 65_535 + with zipfile.ZipFile(archive) as zf: + assert "flaky.bin" not in zf.namelist() + assert zf.read("config.yaml") == b"model: test\n" + assert zf.testzip() is None + + def test_failed_duplicate_restores_previous_central_directory_entry(self, tmp_path, monkeypatch): + source = tmp_path / "source.bin" + source.write_bytes(os.urandom(300_000)) + archive = tmp_path / "duplicate.zip" + _fail_zip_write_after(monkeypatch, "dup.bin") + + import hermes_cli.backup as backup_mod + + with zipfile.ZipFile(archive, "w", zipfile.ZIP_DEFLATED) as zf: + zf.writestr("dup.bin", b"complete-before-failure") + with pytest.raises(OSError, match="simulated source read failure"): + backup_mod._write_zip_file(zf, source, "dup.bin") + zf.writestr("after.bin", b"after") + + assert archive.stat().st_size > 65_535 + with zipfile.ZipFile(archive) as zf: + assert zf.namelist().count("dup.bin") == 1 + assert zf.read("dup.bin") == b"complete-before-failure" + assert zf.read("after.bin") == b"after" + assert zf.testzip() is None + + def test_incomplete_pre_update_backup_does_not_rotate_last_complete(self, tmp_path, monkeypatch): + hermes_home = tmp_path / ".hermes" + hermes_home.mkdir() + (hermes_home / "config.yaml").write_text("model: test\n") + + from hermes_cli.backup import create_pre_update_backup + + good = create_pre_update_backup(hermes_home=hermes_home, keep=1) + assert good is not None and good.exists() + _advance_backup_clock() + + (hermes_home / "flaky.bin").write_bytes(os.urandom(300_000)) + _fail_zip_write_after(monkeypatch, "flaky.bin") + incomplete = create_pre_update_backup(hermes_home=hermes_home, keep=1) + + assert incomplete is None + assert good.exists(), "an incomplete generation rotated out the last complete backup" + backups = sorted((hermes_home / "backups").glob("pre-update-*.zip")) + assert len(backups) == 2 + salvage = next(path for path in backups if path != good) + with zipfile.ZipFile(salvage) as zf: + assert "flaky.bin" not in zf.namelist() + assert zf.read("config.yaml") == b"model: test\n" + assert zf.testzip() is None + class TestPreUpdateBackup: """Tests for create_pre_update_backup — the auto-backup ``hermes update`` runs before touching anything."""