Repository navigation
Add manifest validation to prevent data corruption - #17
Conversation
There was a problem hiding this comment.
Reviewed the full diff against vulntester/backup.py and its only caller (vulntester/__main__.py:175), plus the existing restore coverage in tests/test_privacy.py. The direction is right and the restore-loop restructuring is correct (no double counting — the inner except Exception handles restore failures, the outer except TypeError only covers the BackupEntry(**entry_data) construction). Two things should be fixed before merge.
1. backup.py:177 — entries is never validated, so malformed manifests still escape the new ManifestError contract.
Line 171 validates that the root is an object, but data.get("entries", []) is used unchecked:
- If
entriesis a number,for entry_data in ...raises a rawTypeError: int object is not iterableout ofrestore(), bypassingManifestErrorentirely and crashingvulntester --rollbackwith a traceback. - If
entriesis an object or a string, the result is worse than a crash: iteration yields dict keys / individual characters, each one fails theisinstance(entry_data, dict)check on line 178, and the function returns(0, N). A structurally broken manifest is then reported as "Rollback complete: 0 restored, N failed" — indistinguishable from a real partial rollback failure over N genuine entries.
Suggested fix, hoisting the lookup:
entries = data.get("entries", [])
if not isinstance(entries, list):
raise ManifestError("Manifest entries must be a JSON array")2. backup.py:152 — the write path still corrupts the manifest it is meant to protect.
The PR title is "prevent data corruption", but open(self._manifest_path, "w") truncates the existing manifest before anything is written. If json.dump fails partway (disk full, caught on line 154; serialization error, caught on line 156) the previously valid manifest is already destroyed, and every backup recorded before this call becomes un-rollbackable — not just the current entry. The new ManifestError reports the failure but does not prevent the loss, so the write half of this PR is detection only. Writing to a sibling temp file and then os.replace()-ing it over the manifest would actually deliver the stated intent.
Smaller points
__main__.py:175:restore()now raisesManifestErrorand the only caller does not catch it, so a missing or unreadable manifest still exits with a stack trace rather than the clean early failure the description promises. Worth catching it there and returning a non-zero exit with the message.backup.py:79-81and143-145: theseexcept ManifestErrorblocks are behaviourally dead — theexcept Exceptionimmediately below returns the same value, so only the log wording differs. Both paths also leave the failed entry appended toself.manifest.entries(lines 75 and 139), so a later successful save silently persists an entry whose backup was reported as failed. Pre-existing, but squarely within this PR scope.backup.py:154and164:IOErroris an alias ofOSErroron Python 3, so the tuple is redundant.- No tests added.
tests/test_privacy.py:589-622already exercisesrestore(), so the new branches (unreadable file, malformed JSON, non-object root, non-object entry, missing fields) are cheap to cover and would have caught point 1.
- restore() now rejects a manifest whose "entries" field is not a JSON array, instead of letting a raw TypeError escape (int/other) or silently misreporting a dict's keys as failed entries. - _save_manifest() writes to a sibling .tmp file and os.replace()s it into place, so a write/serialize failure can no longer truncate and destroy a previously valid manifest. - main()'s rollback path now catches ManifestError and exits cleanly instead of letting it propagate as a stack trace. - Dropped the redundant IOError branch (an alias of OSError on Python 3). - Added regression tests covering malformed entries, non-object roots, unreadable/invalid manifests, and write-failure corruption.
|
Verified both main points against the code and both held up; fixed them plus the uncaught-exception point: Fixed
Verified each new test fails on the pre-fix code and passes after the fix (checked by reverting Not changed
Checks
|
There was a problem hiding this comment.
Re-reviewed the full diff; all three substantive items from my previous review are now fixed. backup.py:180-182 hoists and type-checks entries so a non-list no longer escapes as a raw TypeError or gets mis-counted as N genuine restore failures; _save_manifest (backup.py:151-163) now writes to a sibling manifest.tmp and os.replace-es it into place, so a mid-write OSError/serialization failure leaves the previous manifest intact (same directory, so the replace is atomic); and __main__.py:175-179 catches ManifestError and exits 1 with a message instead of a traceback. I also checked that the restructured restore loop does not double-count — the inner except Exception covers restore failures and the outer except TypeError only covers BackupEntry(**entry_data) construction — that restore() is the only public entry point affected and has no other callers, and that the five new tests plus the write-failure test match the branches they claim to cover. Scope is limited to backup.py, its one caller, and tests, with no unrelated changes.
Two non-blocking notes for a follow-up: a manifest with invalid UTF-8 raises UnicodeDecodeError (a ValueError, not an OSError) and still bypasses the ManifestError contract at backup.py:167-175; and the except ManifestError branches at backup.py:80 and 144 remain behaviourally identical to the except Exception below them, with the failed entry still left in self.manifest.entries so a later successful save persists it.
What
Add validation and error handling to manifest.json read/write operations to catch malformed, missing, or inaccessible files early.
Why
Silent manifest corruption could lead to incomplete or incorrect rollback behavior, potentially leaving the system in an inconsistent state.