Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 33 additions & 1 deletion src/borg/archive.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@
from .item import Item, ArchiveItem, ItemDiff
from .platform import acl_get, acl_set, set_flags, get_flags, set_times, swidth
from .hashindex import ChunkIndex, ChunkIndexEntry
from .repository import Repository, PackReader
from .repository import Repository, PackReader, remove_missing_pack_entries
from .repoobj import RepoObj, object_validator

# macOS: SF_DATALESS marks dataless placeholder files (e.g. cloud files not materialized locally).
Expand Down Expand Up @@ -2356,6 +2356,9 @@ def check(
# could not be noticed afterwards. So everything read here is read at the "repair" place, which
# re-certifies chunkid == id_hash(content) by default, see BORG_ASSERT_ID.
self.repo_objs.set_assert_id_place("repair")
else:
# --repair builds the index from the packs present in packs/, so it references no missing pack.
self.remove_missing_packs()
if verify_data:
self.verify_data()
self.manifest = Manifest.load(repository, key=self.key)
Expand Down Expand Up @@ -2391,6 +2394,35 @@ def make_key(self, repository):
"""Return the key of repository, see key_factory."""
return key_factory(repository)

def remove_missing_packs(self):
Comment thread
ThomasWaldmann marked this conversation as resolved.
"""Remove the entries of the chunks stored in missing packs from self.chunks and log these packs as errors.

A missing pack is a pack the index references, but that is absent from packs/. The archives check then
reports the objects stored in it as missing. The stored index is not changed.
"""
present_names = {info.name for info in self.repository.store_list("packs")}
# F_PENDING marks a chunk whose pack location is unresolved: its pack_id is a placeholder.
referenced_pack_ids = {
entry.pack_id for _, entry in self.chunks.iteritems() if not (entry.flags & ChunkIndex.F_PENDING)
}
# store.info() confirms that each pack the listing lacks is missing.
missing_pack_ids = sorted(
pack_id
for pack_id in referenced_pack_ids
if bin_to_hex(pack_id) not in present_names
and not self.repository.store.info("packs/" + bin_to_hex(pack_id)).exists
)
if not missing_pack_ids:
return
removed = remove_missing_pack_entries(self.chunks, missing_pack_ids)
self.error_found = True
# one id per line (the list can be long).
logger.error(f"{len(missing_pack_ids)} pack(s) referenced by the index are missing:")
for pack_id in missing_pack_ids:
logger.error(f"Missing pack: {bin_to_hex(pack_id)}")
logger.error(f"The {removed} chunk(s) stored in these packs are lost.")
logger.error('Run "borg check --repair" to remove their entries from the repository index.')

def verify_data(self):
logger.info("Starting cryptographic data integrity verification...")
chunks_count = len(self.chunks)
Expand Down
7 changes: 5 additions & 2 deletions src/borg/archiver/check_cmd.py
Original file line number Diff line number Diff line change
Expand Up @@ -176,8 +176,11 @@ def build_parser_check(self, subparsers, common_parser, mid_common_parser):
archive data (requires ``--verify-data``). This includes ensuring that the
repository manifest exists, the archive metadata chunk is present, and that
all chunks referencing files (items) in the archive exist. This requires
reading archive and file metadata, but not data. To scan for archives whose
entries were lost from the archive directory, pass ``--find-lost-archives``.
reading archive and file metadata, but not data. Without ``--repair``, this step
first reports the packs the chunk index references, but that are missing from
the repository; the objects stored in them are then reported as missing. To scan
for archives whose entries were lost from the archive directory, pass
``--find-lost-archives``.
It has to look at the metadata of every object in the repository (only for the
archive metadata objects it finds that way, it also reads the object data), so
it is very time-consuming for big repositories.
Expand Down
57 changes: 57 additions & 0 deletions src/borg/testsuite/archiver/check_cmd_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -1848,6 +1848,63 @@ def test_repair_repository_only_removes_missing_pack_entries(archivers, request)
cmd(archiver, "check", exit_code=0)


@pytest.mark.parametrize("lost", ["archive-metadata", "item-ptrs", "item-metadata", "file-content"])
@pytest.mark.parametrize(
"options", [[], ["--find-lost-archives"], ["--verify-data"]], ids=["plain", "find-lost-archives", "verify-data"]
)
def test_archives_only_reports_a_missing_pack(archivers, request, monkeypatch, lost, options):
archiver = request.getfixturevalue(archivers)
monkeypatch.setenv("BORG_PACK_MAX_COUNT", "1") # a pack per object: removing a pack removes one object
check_cmd_setup(archiver)
archive, repository = open_archive(archiver.repository_path, "archive1")
with repository:
if lost == "archive-metadata":
chunk_id = archive.id
message = f"Archive metadata block {bin_to_hex(chunk_id)} is missing!"
elif lost == "item-ptrs":
chunk_id = archive.metadata.item_ptrs[0]
message = f"Archive archive1: item pointers chunk 0 {bin_to_hex(chunk_id)} is missing!"
elif lost == "item-metadata":
chunk_id = archive.item_ids[0]
message = f"item metadata chunk missing [chunk: 000000_{bin_to_hex(chunk_id)}]"
else:
item = next(item for item in archive.iter_items() if item.path.endswith(src_file))
chunk_id = item.chunks[-1].id
message = f"Missing chunk detected: {bin_to_hex(chunk_id)}"
pack_hex = bin_to_hex(repository.chunks[chunk_id].pack_id)
repository.store_delete("packs/" + pack_hex)
output = cmd(archiver, "check", "--archives-only", *options, exit_code=1)
assert f"Missing pack: {pack_hex}" in output
assert message in output
# the stored index still references the pack.
output = cmd(archiver, "check", "--repository-only", exit_code=1)
assert f"Missing pack: {pack_hex}" in output
# the repository check removes the entries of the pack before the archives check runs.
output = cmd(archiver, "check", exit_code=1)
assert output.count(f"Missing pack: {pack_hex}") == 1
assert message in output
cmd(archiver, "check", "--archives-only", "--repair", exit_code=0)
# the repair drops the archives and items whose metadata is lost; lost file content stays reported.
cmd(archiver, "check", exit_code=1 if lost == "file-content" else 0)


def test_remove_missing_packs_confirms_a_missing_pack(archivers, request, monkeypatch):
"""A pack the packs/ listing lacks, but that store.info() finds, is not missing."""
archiver = request.getfixturevalue(archivers)
check_cmd_setup(archiver)
with open_repository(archiver) as repository:
checker = _archive_checker(repository)
pack_hex = bin_to_hex(next(repository.chunks.iter_packs())[0])
listing = [info for info in repository.store_list("packs") if info.name != pack_hex]
monkeypatch.setattr(repository, "store_list", lambda name, **kwargs: listing)
chunks_count = len(repository.chunks)

checker.remove_missing_packs()

assert not checker.error_found
assert len(repository.chunks) == chunks_count


def test_items_with_unknown_keys_are_kept(archivers, request):
# items with keys this borg version does not know (e.g. written by a newer borg) are not an error:
# check warns about them once per archive (rc stays 0) and --repair writes them back unchanged.
Expand Down
Loading