Skip to content

Commit cc0d2c0

Browse files
committed
check --repair: resync past corrupt object headers when rebuilding the chunks index, #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.
1 parent b675495 commit cc0d2c0

7 files changed

Lines changed: 344 additions & 29 deletions

File tree

‎docs/internals/packs.rst‎

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -89,10 +89,30 @@ A reader locates the next blob by advancing::
8989

9090
next_blob_offset = current_blob_offset + REPOOBJ_HEADER_SIZE + meta_size + data_size
9191

92-
The per-blob magic limits the blast radius of corrupted length fields: if
93-
``meta_size`` or ``data_size`` is damaged, the scanner loses at most one blob.
94-
Once it finds the next ``OBJ_MAGIC`` sequence it resumes. Other corruption
95-
(payload bit flips) is caught by AEAD on that blob without losing position.
92+
``iter_headers()`` checks every header it walks: it must have ``OBJ_MAGIC``, a
93+
supported version, and sizes that keep the blob inside the pack. A header that
94+
fails these checks means a corrupt pack, and ``IntegrityError`` is raised.
95+
96+
The per-blob magic limits the blast radius of corrupted length fields. The
97+
repair walk (``iter_headers(validate=...)``, used when ``borg check --repair``
98+
rebuilds the chunks index from the packs) scans forward for the next blob and
99+
resumes there, so the blobs after the damaged part of the pack are still found.
100+
101+
``OBJ_MAGIC`` occurs inside the payloads as well, and in ``none`` and
102+
``authenticated`` mode the payloads are user content stored as it is, so a
103+
backed up file can contain something shaped like a blob. The scan therefore
104+
accepts a candidate only if it parses. For the AEAD keys it reads the header and
105+
the encrypted metadata, a few hundred bytes: decrypting the metadata
106+
authenticates it together with the header's magic, version and chunk_id, which
107+
are its AAD (additional authenticated data: authenticated with the ciphertext,
108+
but not encrypted). The other keys authenticate by ``chunk_id == id_hash(content)``
109+
(``KeyBase.id_check_is_authentication``), which needs the blob's data, so for
110+
those the scan reads the whole blob. The key is needed either way; a repair that
111+
cannot read the manifest walks without scanning.
112+
113+
``data_size`` is not part of that AAD, so accepting a candidate authenticates
114+
its chunk id, and its size only as far as the blob fits into the pack. Bit flips
115+
in the data are caught when the blob is read, on that blob alone.
96116

97117
Blobs follow one another contiguously with no padding::
98118

‎src/borg/archive.py‎

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1862,6 +1862,32 @@ def __next__(self):
18621862
return next(self._unpacker)
18631863

18641864

1865+
def resync_validator(repo_objs):
1866+
"""Return validate(chunk_id, obj): True if obj is the repo object with id chunk_id.
1867+
1868+
obj holds an object's header and encrypted metadata, plus its encrypted data when
1869+
validate.needs_data is set. For most keys, decrypting the metadata authenticates it against the
1870+
header (magic, version, chunk_id), so the metadata alone decides. Keys that authenticate by
1871+
chunk_id == id_hash(content) (id_check_is_authentication) need the data; for them
1872+
validate.needs_data is set and parse() checks that id at the "repair" id place.
1873+
"""
1874+
needs_data = repo_objs.key.id_check_is_authentication
1875+
1876+
def validate(chunk_id, obj):
1877+
try:
1878+
if needs_data:
1879+
repo_objs.parse(chunk_id, obj, ro_type=ROBJ_DONTCARE, assert_id_place="repair")
1880+
else:
1881+
repo_objs.parse_meta(chunk_id, obj, ro_type=ROBJ_DONTCARE)
1882+
except Exception:
1883+
# authentication, id check, msgpack or decompression can each raise on non-object bytes.
1884+
return False
1885+
return True
1886+
1887+
validate.needs_data = needs_data
1888+
return validate
1889+
1890+
18651891
class ArchiveChecker:
18661892
# Bound how many missing file chunks rebuild_archives buffers for its end-of-run report,
18671893
# so checking a badly damaged repo with very many missing chunks can not exhaust memory.
@@ -1913,7 +1939,18 @@ def check(
19131939
# so we do not rebuild it from the packs (reading every pack is far too slow for a routine check).
19141940
# --repair does rebuild from the packs (slow_rebuild=repair), working from the real packs so it
19151941
# can detect and fix archives that reference chunks whose pack has gone missing.
1916-
self.chunks = build_chunkindex_from_repo(self.repository, slow_rebuild=repair, write_immediately=False)
1942+
# Under --repair, validate lets the rebuild resync past a corrupt object header (see resync_validator).
1943+
# It authenticates objects with the key, so make the key first; manifest_only=True makes make_key use
1944+
# the manifest, not self.chunks, which is still unset here.
1945+
if self.key is None:
1946+
try:
1947+
self.key = self.make_key(repository, manifest_only=True)
1948+
except IntegrityError as err:
1949+
logger.warning(f"{err}. Packs with a corrupt object header can not be repaired.")
1950+
validate = resync_validator(RepoObj(self.key)) if repair and self.key is not None else None
1951+
self.chunks = build_chunkindex_from_repo(
1952+
self.repository, slow_rebuild=repair, validate=validate, write_immediately=False
1953+
)
19171954
if self.key is None:
19181955
self.key = self.make_key(repository)
19191956
self.repo_objs = RepoObj(self.key)

‎src/borg/cache.py‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -839,7 +839,7 @@ def repack_chunkindex(repository):
839839

840840

841841
def build_chunkindex_from_repo(
842-
repository, *, slow_rebuild=False, write_immediately=False, init_flags=ChunkIndex.F_USED
842+
repository, *, slow_rebuild=False, validate=None, write_immediately=False, init_flags=ChunkIndex.F_USED
843843
):
844844
# first, try to build a fresh, mostly complete chunk index from centrally stored index fragments:
845845
if not slow_rebuild:
@@ -906,7 +906,8 @@ def build_chunkindex_from_repo(
906906
# PackReader uses the store directly, so refresh the lock here; a full rebuild can be slow.
907907
repository._lock_refresh()
908908
pack_id = hex_to_bin(info.name)
909-
for chunk_id, obj_offset, obj_size in PackReader(repository.store, pack_id).iter_headers():
909+
# validate makes iter_headers resync past a corrupt object header and index the objects after it.
910+
for chunk_id, obj_offset, obj_size in PackReader(repository.store, pack_id).iter_headers(validate=validate):
910911
num_chunks += 1
911912
chunks[chunk_id] = ChunkIndexEntry(
912913
flags=init_flags, size=0, pack_id=pack_id, obj_offset=obj_offset, obj_size=obj_size

‎src/borg/repository.py‎

Lines changed: 82 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -30,14 +30,17 @@
3030
from .storelocking import Lock
3131
from .logger import create_logger
3232
from .manifest import NoManifestError
33-
from .repoobj import RepoObj, OBJ_MAGIC
33+
from .repoobj import RepoObj, OBJ_MAGIC, SUPPORTED_OBJ_VERSIONS
3434
from .crypto.key import is_keyfile
3535

3636
logger = create_logger(__name__)
3737

3838
# an object name is its sha256 as 64 lowercase hex digits.
3939
_valid_object_name = re.compile(r"[0-9a-f]{64}").fullmatch
4040

41+
# how much of a pack PackReader reads at once when searching for the next object header.
42+
RESYNC_WINDOW_SIZE = 1024 * 1024
43+
4144

4245
def repo_lister(repository, *, limit=None):
4346
marker = None
@@ -362,24 +365,73 @@ def read(self, offset, size):
362365
return self.store.load(self.key, offset=offset, size=size)
363366

364367
def size(self):
365-
"""Return the pack size in bytes; for a store-backed pack this is one metadata lookup."""
368+
"""Return the pack size in bytes (a store metadata lookup, unless the pack is in memory)."""
366369
if self.pack_contents is not None:
367370
return len(self.pack_contents)
368371
return self.store.info(self.key).size
369372

370-
def iter_headers(self):
373+
@staticmethod
374+
def _parse_header(hdr_data, offset, pack_size):
375+
"""Return the ObjHeader in hdr_data if it is a valid header at offset, None otherwise.
376+
377+
Valid means: OBJ_MAGIC, a supported version, and an object that fits into the pack.
378+
"""
379+
hdr = RepoObj.ObjHeader(*RepoObj.obj_header.unpack(hdr_data))
380+
if hdr.magic != OBJ_MAGIC or hdr.version not in SUPPORTED_OBJ_VERSIONS:
381+
return None
382+
if offset + RepoObj.obj_header.size + hdr.meta_size + hdr.data_size > pack_size:
383+
return None
384+
return hdr
385+
386+
def _find_header(self, offset, pack_size, validate):
387+
"""Scan forward from offset for the next object validate accepts, return its offset or None.
388+
389+
A pack has no framing besides the object headers, so this searches for OBJ_MAGIC. That byte
390+
sequence also occurs inside payloads, so a candidate is accepted only when its header parses
391+
and validate confirms it.
392+
"""
393+
hdr_size = RepoObj.obj_header.size
394+
while offset + hdr_size <= pack_size:
395+
# a window at a time, so the scan costs one store request per RESYNC_WINDOW_SIZE bytes.
396+
buf = bytes(self.read(offset, min(RESYNC_WINDOW_SIZE, pack_size - offset)))
397+
if len(buf) < hdr_size:
398+
break
399+
pos = 0
400+
while True:
401+
pos = buf.find(OBJ_MAGIC, pos)
402+
if pos < 0 or pos + hdr_size > len(buf):
403+
break # not in this window, or a header overlapping its end: the next window has it
404+
hdr = self._parse_header(buf[pos : pos + hdr_size], offset + pos, pack_size)
405+
if hdr is not None:
406+
obj_size = hdr_size + hdr.meta_size + hdr.data_size
407+
# an object is at most MAX_DATA_SIZE bytes (Repository.put), so a larger candidate is a
408+
# false match on OBJ_MAGIC in a payload.
409+
if obj_size <= MAX_DATA_SIZE:
410+
size = obj_size if validate.needs_data else hdr_size + hdr.meta_size
411+
end = pos + size
412+
# the window holds these bytes, unless the candidate crosses its end.
413+
obj = buf[pos:end] if end <= len(buf) else self.read(offset + pos, size)
414+
if validate(hdr.chunk_id, obj):
415+
return offset + pos
416+
pos += 1
417+
# step by the window less one header, so a magic straddling the boundary is still found.
418+
offset += max(len(buf) - (hdr_size - 1), 1)
419+
return None
420+
421+
def iter_headers(self, validate=None):
371422
"""Yield (chunk_id, offset, size) for each object by walking the fixed object headers.
372423
373-
Only the headers are read, not the payloads, so locating every object costs one short
374-
range read per object (or just a slice, when the pack is already in memory), plus one
375-
store metadata lookup for the pack size.
424+
The walk reads a header per object: one short range read each (or a slice, for a pack in
425+
memory), plus one store metadata lookup for the pack size.
426+
427+
A header must have OBJ_MAGIC, a supported version and describe an object that fits into
428+
the pack, otherwise the pack is corrupt and IntegrityError is raised. A read shorter than
429+
a header ends the walk: that is the end of the pack.
376430
377-
Each full header must have OBJ_MAGIC and describe an object that fits into the pack,
378-
otherwise the pack is corrupt and IntegrityError is raised. Ending the walk instead
379-
would be worse than raising: the chunks index rebuilt from these headers would just be
380-
missing the rest of the pack, and borg check --repair would then "fix" the archives by
381-
dropping chunks that are there.
382-
A trailing partial header is the clean end of the pack, not corruption.
431+
validate(chunk_id, obj): returns whether obj is a repo object with id chunk_id, where obj is
432+
its header and metadata, plus its data when validate.needs_data is set. When validate is
433+
given, a corrupt header makes the walk resync: it scans for the next object validate accepts
434+
(see _find_header), continues there, and logs the skipped bytes.
383435
"""
384436
pack_hex = bin_to_hex(self.pack_id) if self.pack_id is not None else "<no id>"
385437
pack_size = self.size()
@@ -389,17 +441,26 @@ def iter_headers(self):
389441
hdr_data = self.read(offset, hdr_size)
390442
if len(hdr_data) < hdr_size:
391443
break # clean EOF, or trailing partial bytes
392-
hdr = RepoObj.ObjHeader(*RepoObj.obj_header.unpack(hdr_data))
393-
if hdr.magic != OBJ_MAGIC:
394-
raise IntegrityError(
395-
f'pack {pack_hex}: no object header at offset {offset} (pack corruption), run "borg check"'
444+
hdr = self._parse_header(hdr_data, offset, pack_size)
445+
if hdr is None:
446+
if validate is None:
447+
raise IntegrityError(
448+
f'pack {pack_hex}: invalid object header at offset {offset} (pack corruption), run "borg check"'
449+
)
450+
next_offset = self._find_header(offset + 1, pack_size, validate)
451+
if next_offset is None:
452+
logger.warning(
453+
f"pack {pack_hex}: invalid object header at offset {offset} and none after it, "
454+
f"skipping the remaining {pack_size - offset} bytes."
455+
)
456+
break
457+
logger.warning(
458+
f"pack {pack_hex}: invalid object header at offset {offset}, "
459+
f"skipping {next_offset - offset} bytes to the next one."
396460
)
461+
offset = next_offset
462+
continue
397463
obj_size = hdr_size + hdr.meta_size + hdr.data_size
398-
if offset + obj_size > pack_size:
399-
raise IntegrityError(
400-
f"pack {pack_hex}: object extends past end of file at offset {offset} "
401-
f'(pack corruption), run "borg check"'
402-
)
403464
yield hdr.chunk_id, offset, obj_size
404465
offset += obj_size
405466

‎src/borg/testsuite/archiver/check_cmd_test.py‎

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -688,6 +688,34 @@ def test_extra_chunks(archivers, request):
688688
cmd(archiver, "check", "-v", exit_code=0) # check does not deal with orphans anymore
689689

690690

691+
def test_repair_resyncs_pack_with_corrupt_object_header(archivers, request):
692+
"""--repair rebuilds the index from a pack whose object header is damaged.
693+
694+
A damaged header makes the walk lose the object boundaries, so the rebuild scans for the next
695+
object that authenticates and carries on there. That needs the key, which --repair makes before
696+
the rebuild. Repairing the pack itself is a separate step, see #10026.
697+
"""
698+
archiver = request.getfixturevalue(archivers)
699+
if archiver.get_kind() != "local":
700+
pytest.skip("inspects the store directly")
701+
check_cmd_setup(archiver)
702+
cmd(archiver, "check", exit_code=0)
703+
704+
with Repository(archiver.repository_location, exclusive=True) as repository:
705+
# damage the header of the second object of a pack that holds more than two.
706+
by_pack = {}
707+
for chunk_id, entry in repository.chunks.items():
708+
by_pack.setdefault(entry.pack_id, []).append((entry.obj_offset, chunk_id))
709+
pack_id, objs = next((p, sorted(o)) for p, o in by_pack.items() if len(o) > 2)
710+
damaged_offset, _ = objs[1]
711+
key = "packs/" + bin_to_hex(pack_id)
712+
repository.store_store(key, corrupt(repository.store_load(key), damaged_offset))
713+
714+
output = cmd(archiver, "check", "--repair", "--debug", exit_code=0)
715+
assert f"invalid object header at offset {damaged_offset}" in output
716+
assert "bytes to the next one" in output # the rebuild resumed at the next object
717+
718+
691719
def test_repair_finish_flushes_pack_writer(archivers, request):
692720
"""finish() stores chunks re-added during --repair before it drops the index (#10055).
693721

‎src/borg/testsuite/cache_test.py‎

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,8 @@
2525
)
2626
from ..hashindex import ChunkIndex, ChunkIndexEntry
2727
from ..crypto.key import AESOCBKey
28-
from ..helpers import safe_ns
28+
from ..helpers import bin_to_hex, safe_ns
29+
from ..helpers import IntegrityError
2930
from ..helpers.msgpack import int_to_timestamp
3031
from ..manifest import Manifest
3132
from ..repository import Repository
@@ -475,6 +476,26 @@ def test_close_consolidates_fragments_across_sessions(tmp_path, monkeypatch):
475476
assert cid in index
476477

477478

479+
def test_build_chunkindex_repair_resyncs_after_corrupt_header(tmp_path):
480+
"""A corrupt object header fails the rebuild, but with repair=True the rest of the pack is indexed."""
481+
from .repository_test import accept_all, fchunk
482+
483+
obj1 = bytearray(fchunk(b"first", chunk_id=H(90)))
484+
obj2 = fchunk(b"second", chunk_id=H(91))
485+
obj1[0] ^= 0xFF # break the magic of the first object's header
486+
pack_id = H(92)
487+
with Repository(os.fspath(tmp_path / "repository"), exclusive=True, create=True) as repository:
488+
repository.store_store("packs/" + bin_to_hex(pack_id), bytes(obj1) + obj2)
489+
with pytest.raises(IntegrityError):
490+
build_chunkindex_from_repo(repository, slow_rebuild=True)
491+
# accept_all: accepts every candidate, so this exercises the plumbing only.
492+
index = build_chunkindex_from_repo(repository, slow_rebuild=True, validate=accept_all)
493+
assert H(91) in index # found by resyncing past the damaged header
494+
assert H(90) not in index # its header is gone, so the object can not be indexed
495+
assert index[H(91)].pack_id == pack_id
496+
assert index[H(91)].obj_offset == len(obj1)
497+
498+
478499
def test_repack_leaves_sealed_untouched_and_reconstructs(tmp_path, monkeypatch):
479500
"""Sealed (>= MIN) fragments survive a repack; build_chunkindex_from_repo reconstructs the index."""
480501
monkeypatch.setattr(cache_mod, "CHUNKINDEX_FRAGMENT_ENTRIES_MIN", 1000)

0 commit comments

Comments
 (0)