Repository navigation
Conversation
`vhir restore` hashes every restored file against the backup manifest, but the result was purely cosmetic: it printed "Restored case INC-2026-001 to ..." and "Files: 41 verified, 1 MISMATCH", removed the .restore-in-progress marker, printed the "vhir case activate" banner and exited 0. A corrupted or tampered backup therefore restored "successfully" — a wrapper script or an operator reading the exit code proceeds, and the bad case is now the live case, indistinguishable from a clean restore. Worse, because the marker was gone, re-running restore to recover hit "Case directory already exists". The fix keeps the marker in place when any file mismatches or is missing, so the directory stays flagged as a partial restore and the existing .restore-in-progress cleanup path handles the retry, then exits 1 before the activation banner. This matches the sibling checks in the same repo: `_verify_backup` returns False on any mismatch and `cmd_backup` exits 1 on it, and `cmd_verify_evidence` exits 2 when evidence is MODIFIED. The loop now also names the offending files, the same way `_verify_backup` does, so the non-zero exit is actionable. tests/test_backup_restore.py covers a tampered backup file, a file absent from the backup, an intact backup, and the repair-and-retry cycle that the retained marker is what makes possible. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…-manifest-mismatch
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
BLUF
vhir restoreprintedRestored case INC-2026-001 to ..., told the operator tovhir case activateit, and exited 0 while reporting1 MISMATCHon the same screen. Any wrapper script or operator gating on the exit code proceeds, and the bad case becomes the live case.cmd_restorecounted mismatches but never acted on them — nosys.exit, and the.restore-in-progressmarker was unlinked unconditionally, unlike_verify_backup(returns False) andcmd_verify_evidence(exits 2).vhir restore— disaster recovery, legal preservation, and archival retrieval. Because the marker was removed, re-running restore to recover was also blocked by "Case directory already exists", so there was no clean retry path.sys.exit(1)before the activation banner. No signature, format, or dependency changes.tests/test_backup_restore.py(tamper, missing file, intact backup, repair-and-retry); 3 of them fail withDID NOT RAISE SystemExiton unmodified source. Suite goes 428 → 432 green on Python 3.10 and 3.12; ruff check and format clean.main@b2324ac(2026-10-05): the bug is still present — this PR's new tests, applied tomainwithout the fix, still fail (3 failed, 1 passed). The branch now hasmainmerged in.Priority: HIGH
The defect
cmd_restorecopies every file listed in the backup manifest into the live case directory, then re-hashes each one against the manifest. It counted the failures — and then ignored them:mismatch_countandmissing_countare used for nothing but a print. The function then falls through to the OpenSearch/ledger summary and the closing banner, and returns — exit status 0.Here is the real transcript from a backup whose
findings.jsonwas altered after the manifest was written (captured from the new test against unmodified source):It says "Restored case", it instructs the operator to activate the case, and
$?is0.Why it is wrong
This is an integrity check that does not check. In a forensic tool the whole point of carrying SHA-256 hashes in the manifest is that a restore either reproduces the case byte-for-byte or it fails loudly. Three specific consequences:
No machine-readable signal. Automation around
vhir restorecannot distinguish a clean restore from a corrupted one. The only evidence is a substring on stdout.The tampered case becomes the live case. Once restored and activated it is indistinguishable from a clean restore — the provenance question "did this case come back intact?" is unanswerable after the fact.
No recovery path.
restore_marker.unlink()ran unconditionally, so.restore-in-progresswas gone. Re-runningvhir restorethen hit the guard atbackup.py:867:The marker exists precisely so an interrupted restore can be detected and
shutil.rmtree'd on the next run (backup.py:845-863) — removing it after a failed verification threw away the one mechanism that makes retry possible.Consistency with sibling code
Every other integrity check in this repo fails closed:
_verify_backup(backup.py:383-444) printsMISMATCH: {rel_path}per file andreturn Falseon any mismatch or missing file;cmd_backup(backup.py:26-34) turns that intosys.exit(1). Sovhir backup --verify <path>on the same tampered backup correctly exits 1 — whilevhir restore <path>on it exits 0.cmd_verify_evidence(evidence.py:477-479) printsALERT: Evidence files have been modified since registration.andsys.exit(2).cmd_restorewas the outlier.The fix
Confined to the tail of
cmd_restore:Three properties, in order of importance:
vhir case activatebanner — the tool no longer instructs the operator to activate a case that failed verification.vhir restorecan rmtree it and retry. This is what makes repair-and-retry work.MISSING: {rel}/MISMATCH: {rel}) exactly as_verify_backupdoes, so the non-zero exit is actionable.A deliberate non-change: the finding suggested also calling
_verify_backup(backup_path)as a pre-flight. That was not added — it hashes every file a second time and catches strictly less than the post-copy loop, which also detects copy-time corruption and files the copy loop silently skipped. See the note below on what remains out of scope.Known limitation, unchanged by this PR
The verification ledger copy, password-hash copies and OpenSearch snapshot restore all run before the verify loop, so they are not rolled back by the new
sys.exit(1). A retry after a failed restore will hitError: Verification ledger already existsunless run with--skip-ledger. Fixing that means moving the verify block up to run immediately after the file copy — a larger change to restore's ordering, kept separate so this one stays cherry-pickable.Testing
New file
tests/test_backup_restore.py, 4 tests drivingcmd_restoreend-to-end against a synthesised backup intmp_path:test_tampered_file_exits_nonzeroSystemExit(1), marker retainedtest_missing_file_exits_nonzeroif src.exists()silently skips it) →SystemExit(1), marker retainedtest_intact_backup_restorestest_retry_after_repair_succeedsAgainst unmodified source, three of the four fail for exactly the reported reason:
With the fix:
4 passed. Full suite432 passedon Python 3.10 and 3.12 (428 baseline + 4).ruff check .→ All checks passed;ruff format --check .→ 64 files already formatted.CI status
The checks on this PR will show
action_required: GitHub gates workflow runs on pull requests fromfirst-time fork contributors until a maintainer approves them. They need an Approve and run click.
The CI job was reproduced locally after merging
main@b2324ac(761 tests onmain), using the same steps as.github/workflows/ci.yml:pip install -e ".[dev]" && pytest tests/— Python 3.10pip install -e ".[dev]" && pytest tests/— Python 3.11pip install -e ".[dev]" && pytest tests/— Python 3.12ruff check .+ruff format --check .(latest ruff, unpinned as in CI)🤖 Generated with Claude Code