check --repair: resync past corrupt object headers when rebuilding the chunks index - #10094
check --repair: resync past corrupt object headers when rebuilding the chunks index#10094mr-raj12 wants to merge 8 commits into
Conversation
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## master #10094 +/- ##
==========================================
+ Coverage 87.18% 87.50% +0.31%
==========================================
Files 102 103 +1
Lines 18372 18607 +235
Branches 2824 2857 +33
==========================================
+ Hits 16018 16282 +264
+ Misses 1645 1626 -19
+ Partials 709 699 -10 ☔ View full report in Codecov by Harness. |
|
Does #10092 (comment) apply here too?
The consequence is in Either is fine by me, just say which. |
a6533ae to
1877f65
Compare
1877f65 to
cc0d2c0
Compare
6d67228 to
ff0f7a0
Compare
|
Answered by #10095: every mode now covers the object header by the metadata slot's AAD, so The Rebased on master, the |
|
Reviewed this with a focus on the resync logic — nice work overall. One gap: A corrupted size field that stays within the pack still loses intact objects. The tests cover a broken magic and a Repro (3-object pack, obj1's obj1 = bytearray(fchunk(b"A" * 100, meta=b"m1", chunk_id=H(1)))
obj2 = fchunk(b"B" * 100, meta=b"m2", chunk_id=H(2))
obj3 = fchunk(b"C" * 100, meta=b"m3", chunk_id=H(3))
obj1[45:49] = struct.pack("<I", 100 + len(obj2) + 60)
pack = bytes(obj1) + obj2 + obj3
list(PackReader(pack_contents=pack).iter_headers(validate=accept_all))
# pack <no id>: invalid object header at offset 362 and none after it, skipping the remaining 91 bytes.
# [(H(1), 0, 362)] <- obj2 and obj3 both lost, although they are intactScanning from just past the last accepted header instead finds obj2 at its correct offset (151). Suggestion: on resync, scan from just after the last accepted object's header rather than from the misaligned offset (or equivalently, only trust a jump once the header at its landing point checks out — lookahead by one). Trade-offs to weigh: it re-reads up to one object's payload per resync, and the index can end up with the bogus wrong-size entry overlapping the recovered ones — harmless, since reading it fails either way (the chunk is unrecoverable without its size field), but worth a code comment. Either way, a test for the within-pack shape would be good: it behaves qualitatively differently from the past-pack-end shape the tests already cover. |
ff0f7a0 to
6f1e186
Compare
|
Fixed as suggested: the scan now starts just past the last accepted header, with a test for the within-pack shape, the wrong-size entry is dropped rather than emitted, since check_pack_objects treats overlapping index entries as index corruption and compact would abort on that pack. |
6f1e186 to
ab1762f
Compare
|
By Claude: Re-reviewed at ab1762f — the third commit fixes the shape I reported (a But while probing the neighbouring shapes I found that the look-back heuristic does not cover the class it is meant for, and one case got worse — sorry, that one follows from my own suggestion. Three-object pack, 100-byte payloads,
B keeps an entry that can not be read back (and It does not have to be a heuristic, though: the sizes are authenticated, just not in the header. That suggests a simpler and stronger walk: validate every header the repair walk accepts, not only resync candidates — read
I prototyped it on top of ab1762f to make sure it holds up: repository.py gets shorter than it is now, all PR tests pass, and I added tests with real Two small things from last time still apply: |
ab1762f to
c3b9ec0
Compare
…e chunks index, borgbackup#8476 When check --repair rebuilds the chunks index from the packs, a corrupt object header now makes iter_headers resync rather than raise: it takes a validate function and scans forward for the next object, in 1 MiB windows that overlap by one header so a header on a window boundary is still found. Repository-only checks pass no validate and keep raising IntegrityError on a corrupt header. OBJ_MAGIC also occurs inside payloads, so a candidate is accepted only when it authenticates. For AEAD keys, decrypting the metadata authenticates it against the header's magic, version and chunk_id, so the walk confirms a chunk id from a few hundred bytes. Keys that authenticate by chunk_id == id_hash(content) (id_check_is_authentication) read the whole object and parse() at the "repair" id place; validate.needs_data selects between the two. Authentication needs the key, so check --repair makes it before the rebuild with manifest_only=True. A repair that cannot read the manifest has no key and walks without resyncing.
…one, borgbackup#8476 Every key mode covers the object header by the metadata slot's AAD, so parse_meta confirms a candidate and validate.needs_data is gone.
…orgbackup#8476 A meta_size or data_size corrupted to a value that keeps the object inside the pack leaves the header parseable, so a walk that only checks the header follows a wrong offset and loses the intact objects after it. The walk now validates every header it accepts, not only the candidates the resync scan finds: it reads the metadata slot along with the header and checks it. The slot's tag covers the header's magic, version and chunk id and the slot itself, so a corrupted meta_size fails it. data_size, the one header field outside the tag, must equal csize - the data slot's payload size, recorded in the tagged metadata - plus the key's fixed envelope overhead. A header that fails makes the walk scan forward for the next object that validates and resume there. The object with the failed header is dropped.
c3b9ec0 to
90fecfc
Compare
|
review by claude fable 5 Head reviewed: Commit structure (git range-diff old→new)
Full-PR diffstat: 7 files, +546/−38 (was +406/−31). Commit 3 — the adoptionContent-wise this is the posted patch: the walk validates every header it accepts (reading the metadata slot along with it), a failed header triggers a resync from
Commit 4 — distinct failure reportingValidation failures now report as Commit 5 — named problems + bounded reads
Behavior change, documentedWithout a validator, Body / docs
TestsAll tests from the patch kept (validator name updated), plus improvements:
Verification performed
Remaining, all non-blocking
VerdictApprove. The adoption is faithful, the additions are genuine improvements (bounded reads, precise diagnostics, stronger tests), the one deviation is sound, and the docs/body are accurate about what is and is not covered. Ready for maintainer review/merge. |
|
@mr-raj12 Some small stuff, we are getting closer to merging. :-) Is IRC working again for you? |
Fixing and updating reviews soon, got in with a new username now it reconnects frequently but works somehow , checking that as well |
|
|
@mr-raj12 You can find my mobile phone number on my company home page and use it with signal messengar. |

Follow-up to #10083 (header validation, #8476).
When
borg check --repairrebuilds the chunks index from the packs, a corrupt object header no longer aborts the whole pack.iter_headers(validate=...)validates every header it walks and, when one fails, scans for the next object that validates and resumes there, so the objects after the damaged region are still indexed.Without a validate function (repository-only check, routine rebuild) a header that does not parse still raises IntegrityError.
Details:
_find_headersearches for OBJ_MAGIC a window at a time, the windows overlapping by one header so a header on a boundary is still found. OBJ_MAGIC also occurs inside payloads, so a candidate is accepted only when its header parses, describes an object of at most MAX_DATA_SIZE bytes that fits into the pack, and validate confirms it. It returns the header it accepted, so the walk resumes at that object without reading and validating it a second time.object_validatorbuilds validate from the key: it parses the candidate's metadata slot, whose tag covers the slot itself and the chunk id, and at object version0x02the header's magic and version as well. At version0x01those two are not in the tag: a wrong magic fails the explicit magic check, and a wrong version fails because the version selects the AAD the slot is parsed with.data_size, the one header field outside the tag at either version, must equalcsize- the data slot's payload size, recorded in the tagged metadata - plus the key's fixedPAYLOAD_OVERHEAD. So the whole header is confirmed from a few hundred bytes.meta_sizeordata_sizecorrupted to a value that keeps the object inside the pack is therefore caught at that header, rather than at the misaligned offset it points to, and the intact objects in between are kept. The object with the failed header is dropped: its id, its extent or its metadata is wrong, so it can not be read back.--repairdoes before the rebuild, withmanifest_only=True(self.chunksis only built afterwards). A repair that cannot read the manifest has no key, warns and walks without resyncing.ArchiveChecker.finish()rebuilds the index from the packs once more when repair changed them (borg2 check: implement repository repair (index and pack rebuild) #10026), so it passes the same validator._parse_headernames which check a header failed, so the raise and the resync warning reportno object header,unsupported object version N,object extends past end of fileorobject of N bytes exceeds the maximum of M. The first and third are the messages master reports for those cases.MAX_VALIDATED_META_SIZE(64 KiB) is rejected without being fetched, reporting its size rather than a failed authentication. A real slot is a few dozen bytes, so this only bounds what a corruptmeta_sizecan make the walk or the scan read._find_headerand_get_objectalready do.borg checkruns overpacks/, also after a repair has rebuilt the index from it. Rewriting such a pack is repository-level repair, borg2 check: implement repository repair (index and pack rebuild) #10026.Repository.checkrebuilds a corrupt index from the packs without a validator: validating needs the key, which aRepositorydoes not have. It is reached only withpack_errors == 0, and a pack damaged in the store fails the sha256 check before that, so what still reaches it is a pack matching its name whose object header is bad anyway. Thereiter_headersraises, borg2 check: implement repository repair (index and pack rebuild) #10026.Behaviour change without a validator:
iter_headers()now also raises on an unsupported object version and on an object larger than MAX_DATA_SIZE. master checked the magic and the pack bound only.In the
none-*modes the metadata tag is an unkeyed checksum, so the walk accepts any well-formed blob, including one a backed up file contains. Theauthenticated-*modes accept a blob written with the key the repository uses: their tag is deterministic and binds a blob to its chunk id alone, so a blob copied verbatim out of a repository sharing that key validates at any offset in any pack. Backing up such a repository puts its blobs into the payloads, as long as compression leaves them as they are.A blob that arrives this way reads back as itself, so indexing it adds a chunk nothing references. Its
data_sizedescribes the blob as it was written, though, and chunking cuts a payload where the content dictates: a blob whose header and metadata slot fall inside the payload and whose data is cut off still validates, and the extent it claims covers the blobs that follow it, which the walk then skips. Bytes crafted to pass an unkeyed checksum claim an extent the same way. The scan reaches a payload only after the blob owning it failed to validate, so a corrupt header is what makes any of this reachable.Known cost: the validating walk reads 1 KiB per object where the plain walk reads a 49-byte header, so a pack of objects smaller than that is read several times over. Left as a TODO - a window buffer, like the one
_find_headerscans with, would read each byte once, but reading a window to skip a multi-megabyte object costs more than a short read per header.Not covered: a resync drops an object, and
ArchiveCheckerdoes not count that as an error, so a repair that drops an object no archive references still ends with "no problems found".Merges cleanly with master. Tested in repository_test, cache_test and check_cmd_test.