From 0a31ddc8639fc8689c2a515bad21e932bc0c71f4 Mon Sep 17 00:00:00 2001 From: Thomas Waldmann Date: Tue, 1 Sep 2026 09:09:54 +0200 Subject: [PATCH] fix files cache rebuild from an archive without ctime _build_files_cache read item.ctime unconditionally, but an archive item does not necessarily have a ctime: - borg create --noctime omits it, - on Windows it is never archived (st_ctime is the file creation time there and gets archived as birthtime, see #8730), - very old archives only have mtime, as the comment in stat_attrs() says. Rebuilding the files cache from such an archive crashed: AttributeError: attribute ctime not found That is reachable whenever the local files cache is missing while a previous archive exists, i.e. after the cache directory was lost, or on a fresh machine - always on Windows, and with --noctime everywhere. Both timestamps are read with item.get() now. A timestamp the archive does not have is cached as 0, which can not compare equal to the timestamp seen in the file system, so the file is considered changed and gets chunked again if that timestamp is part of the files cache mode. The tracking of the newest ctime/mtime skips missing values instead of comparing them. --- src/borg/cache.py | 34 +++++++++++-------- .../testsuite/archiver/create_cmd_test.py | 22 ++++++++++++ 2 files changed, 41 insertions(+), 15 deletions(-) diff --git a/src/borg/cache.py b/src/borg/cache.py index 0e4241a99c..475b0ad882 100644 --- a/src/borg/cache.py +++ b/src/borg/cache.py @@ -314,26 +314,30 @@ def _build_files_cache(self): # only put regular files' infos into the files cache: if stat.S_ISREG(item.mode): path_hash = self.key.id_hash(safe_encode(item.path)) + # an item does not necessarily have all of these timestamps: --noctime omits ctime, + # on Windows ctime is never archived (it is the file creation time there, see #8730) + # and very old archives only have mtime. + ctime_ns = item.get("ctime") + mtime_ns = item.get("mtime") # keep track of the key(s) for the most recent timestamp(s): - ctime_ns = item.ctime - if self._newest_cmtime is None or ctime_ns > self._newest_cmtime: - self._newest_cmtime = ctime_ns - self._newest_path_hashes = {path_hash} - elif ctime_ns == self._newest_cmtime: - self._newest_path_hashes.add(path_hash) - mtime_ns = item.mtime - if self._newest_cmtime is None or mtime_ns > self._newest_cmtime: - self._newest_cmtime = mtime_ns - self._newest_path_hashes = {path_hash} - elif mtime_ns == self._newest_cmtime: - self._newest_path_hashes.add(path_hash) - # add the file to the in-memory files cache + for timestamp_ns in (ctime_ns, mtime_ns): + if timestamp_ns is None: + continue + if self._newest_cmtime is None or timestamp_ns > self._newest_cmtime: + self._newest_cmtime = timestamp_ns + self._newest_path_hashes = {path_hash} + elif timestamp_ns == self._newest_cmtime: + self._newest_path_hashes.add(path_hash) + # Add the file to the in-memory files cache. A timestamp the archive does not have + # is cached as 0, so it can not compare equal to the timestamp seen in the file + # system: if that timestamp is part of the files cache mode, the file is considered + # changed and gets chunked again, which is the safe outcome. entry = FileCacheEntry( age=0, inode=item.get("inode", 0), size=item.size, - ctime=int_to_timestamp(ctime_ns), - mtime=int_to_timestamp(mtime_ns), + ctime=int_to_timestamp(0 if ctime_ns is None else ctime_ns), + mtime=int_to_timestamp(0 if mtime_ns is None else mtime_ns), chunks=item.chunks, digests=item.get("digests"), ) diff --git a/src/borg/testsuite/archiver/create_cmd_test.py b/src/borg/testsuite/archiver/create_cmd_test.py index 6750177cea..3b44556d63 100644 --- a/src/borg/testsuite/archiver/create_cmd_test.py +++ b/src/borg/testsuite/archiver/create_cmd_test.py @@ -7,6 +7,7 @@ import socket import stat import subprocess +from pathlib import Path import pytest from blake3 import blake3 @@ -2035,3 +2036,24 @@ def no_close_time_write(self, now, force=False, clear=False): with changedir("output"): cmd(archiver, "extract", "test") assert_dirs_equal("input", "output/input") + + +def _remove_files_cache(archiver, archive_name): + """Remove the local files cache of an archive series, forcing a rebuild from the repository.""" + from ...cache import files_cache_name + from ...helpers import get_cache_dir + + repo_id = json.loads(cmd(archiver, "repo-info", "--json"))["repository"]["id"] + cache_file = Path(get_cache_dir(repo_id, create=False)) / files_cache_name(archive_name) + cache_file.unlink() + + +def test_files_cache_rebuild_without_ctime(archivers, request): + """Rebuilding from an archive that has no ctime must work - --noctime, and always on Windows.""" + archiver = request.getfixturevalue(archivers) + create_regular_file(archiver.input_path, "file1", size=1024 * 80) + cmd(archiver, "repo-create", RK_ENCRYPTION) + cmd(archiver, "create", "--noctime", "home", "input") + _remove_files_cache(archiver, "home") + output = cmd(archiver, "create", "--noctime", "--debug", "home", "input") + assert "Building files cache from" in output