diff --git a/src/borg/archive.py b/src/borg/archive.py index 23dcc7ed57..b43ea97739 100644 --- a/src/borg/archive.py +++ b/src/borg/archive.py @@ -1901,11 +1901,24 @@ class ArchiveChecker: def __init__(self): self.error_found = False + self.problems_found = 0 + self.repairs_done = 0 self.key = None # True once repair drops a defect chunk or writes a new one, i.e. once the chunks index no # longer matches the packs. self.chunks_modified = False + def _note_problem(self, *, repaired): + """Record a problem found during the check. + + Callers must always pass repaired= explicitly: True only when the problem was + actually fixed (not merely detected, and not when --repair only discarded data). + """ + self.error_found = True + self.problems_found += 1 + if repaired and self.repair: + self.repairs_done += 1 + def check( self, repository, @@ -1966,17 +1979,19 @@ def check( repository.get_manifest() except NoManifestError: logger.error("Repository manifest is missing.") - self.error_found = True rebuild_manifest = True else: try: self.manifest = Manifest.load(repository, (Manifest.Operation.CHECK,), key=self.key) except IntegrityErrorBase as exc: logger.error("Repository manifest is corrupted: %s", exc) - self.error_found = True rebuild_manifest = True if rebuild_manifest: + # Rebuild first; only then can we say whether the problem was repaired. + # Without --repair, rebuild_manifest() only builds an in-memory manifest for the + # rest of this check run; finish() writes it only when self.repair is set. self.manifest = self.rebuild_manifest() + self._note_problem(repaired=self.repair) # On Ctrl-C, skip any scan not yet started; a scan already running stops at its own boundary. if find_lost_archives and not sig_int: self.rebuild_archives_directory() @@ -2000,7 +2015,15 @@ def check( logger.info("Archive consistency check interrupted, no problems found so far.") raise Error("Got Ctrl-C / SIGINT.") if self.error_found: - logger.error("Archive consistency check complete, problems found.") + if self.repair: + # Always report the repair count in --repair mode, including 0 repaired. + logger.error( + "Archive consistency check complete, %d problem(s) found, %d repaired.", + self.problems_found, + self.repairs_done, + ) + else: + logger.error("Archive consistency check complete, %d problem(s) found.", self.problems_found) else: logger.info("Archive consistency check complete, no problems found.") return self.repair or not self.error_found @@ -2059,11 +2082,14 @@ def verify_data(self): try: encrypted_data = self.repository.get(chunk_id) except (Repository.ObjectNotFound, IntegrityErrorBase) as err: - self.error_found = True errors += 1 logger.error("chunk %s: %s", bin_to_hex(chunk_id), err) if isinstance(err, IntegrityErrorBase): defect_chunks.append(chunk_id) + if not self.repair: + self._note_problem(repaired=False) + else: + self._note_problem(repaired=False) else: try: # we must decompress, so it'll call assert_id() in there. @@ -2073,10 +2099,11 @@ def verify_data(self): chunk_id, encrypted_data, decompress=True, ro_type=ROBJ_DONTCARE, assert_id_place="verify_data" ) except IntegrityErrorBase as integrity_error: - self.error_found = True errors += 1 logger.error("chunk %s, integrity error: %s", bin_to_hex(chunk_id), integrity_error) defect_chunks.append(chunk_id) + if not self.repair: + self._note_problem(repaired=False) pi.finish() if defect_chunks: if self.repair: @@ -2105,6 +2132,8 @@ def verify_data(self): self.chunks_modified = True # drop it from our own index too, so rebuild_archives reports the file it belongs to. del self.chunks[defect_chunk] + # Removing a defect chunk is not a repair: referenced data is still lost. + self._note_problem(repaired=False) else: logger.warning("chunk %s not deleted, did not consistently fail.", bin_to_hex(defect_chunk)) else: @@ -2169,7 +2198,7 @@ def valid_archive(obj): meta = self.repo_objs.parse_meta(chunk_id, cdata, ro_type=ROBJ_DONTCARE) except IntegrityErrorBase as exc: logger.error("Skipping corrupted chunk: %s", exc) - self.error_found = True + self._note_problem(repaired=False) continue if meta["type"] != ROBJ_ARCHIVE_META: continue @@ -2179,7 +2208,7 @@ def valid_archive(obj): meta, data = self.repo_objs.parse(chunk_id, cdata, ro_type=ROBJ_DONTCARE) except IntegrityErrorBase as exc: logger.error("Skipping corrupted chunk: %s", exc) - self.error_found = True + self._note_problem(repaired=False) continue if meta["type"] != ROBJ_ARCHIVE_META: continue # should never happen @@ -2200,12 +2229,13 @@ def valid_archive(obj): f"We already have a soft-deleted archives directory entry for {name} {archive_id_hex}." ) else: - self.error_found = True if self.repair: logger.warning(f"Creating archives directory entry for {name} {archive_id_hex}.") self.manifest.archives.create(name, archive_id, archive.time) + self._note_problem(repaired=True) else: logger.warning(f"Would create archives directory entry for {name} {archive_id_hex}.") + self._note_problem(repaired=False) pi.finish() if sig_int: @@ -2273,7 +2303,7 @@ def verify_file_chunks(archive_name, item): ) ) record_missing_chunk(archive_name, item.path, chunk_id, size) - self.error_found = True + self._note_problem(repaired=False) offset += size if "size" in item: item_size = item.size @@ -2325,7 +2355,7 @@ def missing_chunk_detector(chunk_id): def report(msg, chunk_id, chunk_no): cid = bin_to_hex(chunk_id) msg += " [chunk: %06d_%s]" % (chunk_no, cid) # see "debug dump-archive-items" - self.error_found = True + self._note_problem(repaired=False) logger.error(msg) def list_keys_safe(keys): @@ -2424,24 +2454,26 @@ def valid_item(obj): logger.info(f"Analyzing archive {formatted} ({i + 1}/{num_archives})") if archive_id not in self.chunks: logger.error(f"Archive metadata block {archive_id_hex} is missing!") - self.error_found = True if self.repair: logger.error(f"Deleting broken archive {info.name} {archive_id_hex}.") self.manifest.archives.delete_by_id(archive_id) + self._note_problem(repaired=True) else: logger.error(f"Would delete broken archive {info.name} {archive_id_hex}.") + self._note_problem(repaired=False) continue cdata = self.repository.get(archive_id) try: _, data = self.repo_objs.parse(archive_id, cdata, ro_type=ROBJ_ARCHIVE_META) except IntegrityErrorBase as integrity_error: logger.error(f"Archive metadata block {archive_id_hex} is corrupted: {integrity_error}") - self.error_found = True if self.repair: logger.error(f"Deleting broken archive {info.name} {archive_id_hex}.") self.manifest.archives.delete_by_id(archive_id) + self._note_problem(repaired=True) else: logger.error(f"Would delete broken archive {info.name} {archive_id_hex}.") + self._note_problem(repaired=False) continue archive = self.key.unpack_archive(data) archive = ArchiveItem(internal_dict=archive) diff --git a/src/borg/testsuite/archiver/check_cmd_test.py b/src/borg/testsuite/archiver/check_cmd_test.py index 34a9ee5cb3..c8d60a7995 100644 --- a/src/borg/testsuite/archiver/check_cmd_test.py +++ b/src/borg/testsuite/archiver/check_cmd_test.py @@ -558,6 +558,8 @@ def test_corrupted_manifest(archivers, request): output = cmd(archiver, "check", "-v", "--repair", exit_code=0) assert "archive1" in output assert "archive2" in output + assert "problem(s) found" in output + assert "repaired" in output cmd(archiver, "check", exit_code=0) @@ -778,6 +780,8 @@ def test_verify_data(archivers, request, init_args): assert "The following chunks are missing in the repository:" in output assert bin_to_hex(chunk.id) in output assert src_file in output + assert "problem(s) found" in output + assert "repaired" in output # run with --verify-data again, it will notice the missing chunk. output = cmd(archiver, "check", "--archives-only", "--verify-data", exit_code=1) @@ -869,6 +873,8 @@ def test_corrupted_file_chunk(archivers, request, init_args): assert "The following chunks are missing in the repository:" in output assert bin_to_hex(chunk.id) in output assert src_file in output + assert "problem(s) found" in output + assert "repaired" in output # run normal check again cmd(archiver, "check", "--repository-only", exit_code=0)