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