diff --git a/src/borg/archive.py b/src/borg/archive.py index 1e14df1b7..49035b0f7 100644 --- a/src/borg/archive.py +++ b/src/borg/archive.py @@ -1227,7 +1227,8 @@ class MetadataCollector: attrs["mtime"] = safe_ns(st.st_mtime_ns) if not self.noatime: attrs["atime"] = safe_ns(st.st_atime_ns) - if not self.noctime: + if not self.noctime and not is_win32: + # win32: st_ctime is the file creation time, that is archived as birthtime, see #8730. attrs["ctime"] = safe_ns(st.st_ctime_ns) if not self.nobirthtime: birthtime_ns = get_birthtime_ns(st, path, fd=fd) diff --git a/src/borg/platform/__init__.py b/src/borg/platform/__init__.py index a3fcd0ae0..cdfbd6bb8 100644 --- a/src/borg/platform/__init__.py +++ b/src/borg/platform/__init__.py @@ -96,6 +96,9 @@ def get_birthtime_ns(st, path, fd=None): if hasattr(st, "st_birthtime_ns"): # Added in Python 3.12, but not always available. return st.st_birthtime_ns + elif is_win32: + # Python < 3.12 on win32: st_ctime is the file creation time, see #8730. + return st.st_ctime_ns elif is_darwin and is_darwin_feature_64_bit_inode: return _get_birthtime_ns(fd or path, follow_symlinks=False) elif hasattr(st, "st_birthtime"): diff --git a/src/borg/testsuite/__init__.py b/src/borg/testsuite/__init__.py index 633929144..bb432ebc1 100644 --- a/src/borg/testsuite/__init__.py +++ b/src/borg/testsuite/__init__.py @@ -22,6 +22,9 @@ except: # noqa from ..fuse_impl import llfuse, has_any_fuse, has_llfuse, has_pyfuse3, has_mfusepy, ENOATTR # NOQA from .. import platform + +# import these directly: the borg.testsuite.platform subpackage shadows the platform name above. +from ..platform import get_birthtime_ns, set_times from ..platformflags import is_win32, is_darwin # Does this version of llfuse support ns precision? @@ -47,6 +50,9 @@ else: if sys.platform.startswith("netbsd"): st_mtime_ns_round = -4 # 10us - strange: only >1 microsecond resolution here? +if is_win32: + st_mtime_ns_round = -2 # 100ns - the resolution of a FILETIME, see platform.windows.set_times + def same_ts_ns(ts_ns1, ts_ns2): """Compare two timestamps (both in nanoseconds) to determine whether they are (roughly) equal.""" @@ -207,16 +213,19 @@ def are_fifos_supported(): @functools.lru_cache def is_utime_fully_supported(): + # note: platform.set_times is used rather than os.utime, because os.utime can not set the + # timestamps of a symlink itself on win32 (it raises NotImplementedError there), see #8730. with unopened_tempfile() as filepath: - # Some filesystems (such as SSHFS) don't support utime on symlinks + # Some filesystems (such as SSHFS) don't support setting the timestamps of symlinks if are_symlinks_supported(): os.symlink("something", filepath) else: open(filepath, "w").close() try: - os.utime(filepath, (1000, 2000), follow_symlinks=False) + atime_ns, mtime_ns = 1000 * 10**9, 2000 * 10**9 + set_times(filepath, atime_ns=atime_ns, mtime_ns=mtime_ns, follow_symlinks=False) new_stats = os.stat(filepath, follow_symlinks=False) - if new_stats.st_atime == 1000 and new_stats.st_mtime == 2000: + if new_stats.st_atime_ns == atime_ns and new_stats.st_mtime_ns == mtime_ns: return True except OSError: pass @@ -227,20 +236,23 @@ def is_utime_fully_supported(): @functools.lru_cache def is_birthtime_fully_supported(): - if not hasattr(os.stat_result, "st_birthtime"): - return False + # note: platform.set_times is used rather than the "os.utime twice" trick, because that only + # works on the BSDs and on macOS - on win32, the birthtime needs SetFileTime, see #8730. with unopened_tempfile() as filepath: - # Some filesystems (such as SSHFS) don't support utime on symlinks + # Some filesystems (such as SSHFS) don't support setting the timestamps of symlinks if are_symlinks_supported(): os.symlink("something", filepath) else: open(filepath, "w").close() try: - birthtime, mtime, atime = 946598400, 946684800, 946771200 - os.utime(filepath, (atime, birthtime), follow_symlinks=False) - os.utime(filepath, (atime, mtime), follow_symlinks=False) + birthtime_ns, mtime_ns, atime_ns = 946598400 * 10**9, 946684800 * 10**9, 946771200 * 10**9 + set_times(filepath, atime_ns=atime_ns, mtime_ns=mtime_ns, birthtime_ns=birthtime_ns, follow_symlinks=False) new_stats = os.stat(filepath, follow_symlinks=False) - if new_stats.st_birthtime == birthtime and new_stats.st_mtime == mtime and new_stats.st_atime == atime: + if ( + get_birthtime_ns(new_stats, filepath) == birthtime_ns + and new_stats.st_mtime_ns == mtime_ns + and new_stats.st_atime_ns == atime_ns + ): return True except OSError: pass diff --git a/src/borg/testsuite/archiver/create_cmd_test.py b/src/borg/testsuite/archiver/create_cmd_test.py index 6f1d0f235..c97c541a9 100644 --- a/src/borg/testsuite/archiver/create_cmd_test.py +++ b/src/borg/testsuite/archiver/create_cmd_test.py @@ -252,19 +252,24 @@ def test_unix_socket(archivers, request, monkeypatch): def test_nobirthtime(archivers, request): archiver = request.getfixturevalue(archivers) create_test_files(archiver.input_path) - birthtime, mtime, atime = 946598400, 946684800, 946771200 - os.utime("input/file1", (atime, birthtime)) - os.utime("input/file1", (atime, mtime)) + birthtime_ns, mtime_ns, atime_ns = 946598400 * 10**9, 946684800 * 10**9, 946771200 * 10**9 + platform.set_times("input/file1", atime_ns=atime_ns, mtime_ns=mtime_ns, birthtime_ns=birthtime_ns) cmd(archiver, "repo-create", RK_ENCRYPTION) cmd(archiver, "create", "test", "input", "--nobirthtime") with changedir("output"): cmd(archiver, "extract", "test") sti = os.stat("input/file1") sto = os.stat("output/input/file1") - assert same_ts_ns(sti.st_birthtime * 1e9, birthtime * 1e9) - assert same_ts_ns(sto.st_birthtime * 1e9, mtime * 1e9) + assert same_ts_ns(platform.get_birthtime_ns(sti, "input/file1"), birthtime_ns) + birthtime_out = platform.get_birthtime_ns(sto, "output/input/file1") + if is_win32: + # win32 keeps the creation time the extracted file got when it was created. + assert not same_ts_ns(birthtime_out, birthtime_ns) + else: + # posix: setting an mtime older than the birthtime pulls the birthtime back to it. + assert same_ts_ns(birthtime_out, mtime_ns) assert same_ts_ns(sti.st_mtime_ns, sto.st_mtime_ns) - assert same_ts_ns(sto.st_mtime_ns, mtime * 10**9) + assert same_ts_ns(sto.st_mtime_ns, mtime_ns) def test_create_stdin(archivers, request): diff --git a/src/borg/testsuite/archiver/diff_cmd_test.py b/src/borg/testsuite/archiver/diff_cmd_test.py index a2d486fcc..d787cec19 100644 --- a/src/borg/testsuite/archiver/diff_cmd_test.py +++ b/src/borg/testsuite/archiver/diff_cmd_test.py @@ -175,7 +175,7 @@ def test_basic_functionality(archivers, request): unexpected = {"type": "modified", "added": 0, "removed": 0} assert unexpected not in get_changes("input/file_touched", joutput) if not content_only: - # on win32, ctime is the file creation time and does not change. + # on win32, no ctime is archived, see #8730. expected = {"mtime"} if is_win32 else {"mtime", "ctime"} assert expected.issubset({c["type"] for c in get_changes("input/file_touched", joutput)}) else: @@ -274,18 +274,20 @@ def test_time_diffs(archivers, request): cmd(archiver, "create", "archive2", "input") output = cmd(archiver, "diff", "archive1", "archive2", "--format", "'{mtime}{ctime} {path}{NL}'") assert "mtime" in output - assert "ctime" in output # Should show up on Windows as well since it is a new file. + if is_win32: + assert "ctime" not in output # win32: no ctime is archived, see #8730 + else: + assert "ctime" in output granularity_sleep() os.chmod("input/test_file", 0o777) cmd(archiver, "create", "archive3", "input") output = cmd(archiver, "diff", "archive2", "archive3", "--format", "'{mtime}{ctime} {path}{NL}'") assert "mtime" not in output - # Checking platform because ctime should not be shown on Windows since it wasn't recreated. - if not is_win32: - assert "ctime" in output + if is_win32: + assert "ctime" not in output # win32: no ctime is archived, see #8730 else: - assert "ctime" not in output + assert "ctime" in output def test_sort_by_option(archivers, request): diff --git a/src/borg/testsuite/archiver/extract_cmd_test.py b/src/borg/testsuite/archiver/extract_cmd_test.py index 7e5aeeebf..ab424f28a 100644 --- a/src/borg/testsuite/archiver/extract_cmd_test.py +++ b/src/borg/testsuite/archiver/extract_cmd_test.py @@ -310,6 +310,7 @@ def test_atime(archivers, request): assert same_ts_ns(sto.st_atime_ns, atime * 10**9) +@pytest.mark.skipif(is_win32, reason="no os.pread and no O_NOATIME on win32") @pytest.mark.skipif(not is_utime_fully_supported(), reason="cannot properly setup and execute test without utime") def test_atime_open_updates_atime(archiver): # simulate a platform where already the open() updates the atime (no O_NOATIME support, @@ -356,19 +357,20 @@ def test_atime_open_updates_atime(archiver): def test_birthtime(archivers, request): archiver = request.getfixturevalue(archivers) create_test_files(archiver.input_path) - birthtime, mtime, atime = 946598400, 946684800, 946771200 - os.utime("input/file1", (atime, birthtime)) - os.utime("input/file1", (atime, mtime)) + birthtime_ns, mtime_ns, atime_ns = 946598400 * 10**9, 946684800 * 10**9, 946771200 * 10**9 + platform.set_times("input/file1", atime_ns=atime_ns, mtime_ns=mtime_ns, birthtime_ns=birthtime_ns) cmd(archiver, "repo-create", RK_ENCRYPTION) cmd(archiver, "create", "test", "input") with changedir("output"): cmd(archiver, "extract", "test") sti = os.stat("input/file1") sto = os.stat("output/input/file1") - assert same_ts_ns(sti.st_birthtime * 1e9, sto.st_birthtime * 1e9) - assert same_ts_ns(sto.st_birthtime * 1e9, birthtime * 1e9) + birthtime_in = get_birthtime_ns(sti, "input/file1") + birthtime_out = get_birthtime_ns(sto, "output/input/file1") + assert same_ts_ns(birthtime_in, birthtime_out) + assert same_ts_ns(birthtime_out, birthtime_ns) assert same_ts_ns(sti.st_mtime_ns, sto.st_mtime_ns) - assert same_ts_ns(sto.st_mtime_ns, mtime * 10**9) + assert same_ts_ns(sto.st_mtime_ns, mtime_ns) @pytest.mark.skipif(not is_win32, reason="Windows-only test")