diff --git a/src/borg/archive.py b/src/borg/archive.py index 541e69e2d2..c9bf9a865c 100644 --- a/src/borg/archive.py +++ b/src/borg/archive.py @@ -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). @@ -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) @@ -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): + """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) diff --git a/src/borg/archiver/check_cmd.py b/src/borg/archiver/check_cmd.py index 50364adf45..a0184de36e 100644 --- a/src/borg/archiver/check_cmd.py +++ b/src/borg/archiver/check_cmd.py @@ -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. diff --git a/src/borg/testsuite/archiver/check_cmd_test.py b/src/borg/testsuite/archiver/check_cmd_test.py index 90a03f873f..1e6360f38c 100644 --- a/src/borg/testsuite/archiver/check_cmd_test.py +++ b/src/borg/testsuite/archiver/check_cmd_test.py @@ -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.