Skip to content

Commit 55000f7

Browse files
committed
windows: implement st_birthtime restoration, fixes #8730
1 parent e43800b commit 55000f7

File tree

11 files changed

+228
-55
lines changed

11 files changed

+228
-55
lines changed

pyproject.toml

Lines changed: 19 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -93,21 +93,6 @@ skip-magic-trailing-comma = true
9393
line-length = 120
9494
target-version = "py310"
9595

96-
# Exclude a variety of commonly ignored directories.
97-
exclude = [
98-
".cache",
99-
".eggs",
100-
".git",
101-
".git-rewrite",
102-
".idea",
103-
".mypy_cache",
104-
".ruff_cache",
105-
".tox",
106-
"build",
107-
"dist",
108-
]
109-
110-
[tool.ruff.lint]
11196
# Enable pycodestyle (`E`) and Pyflakes (`F`) codes by default.
11297
select = ["E", "F"]
11398

@@ -119,20 +104,35 @@ select = ["E", "F"]
119104
# F811 redef of unused var
120105

121106
# borg code style guidelines:
122-
ignore = ["F405", "E402"]
107+
# Ignoring E203 due to https://github.com/PyCQA/pycodestyle/issues/373.
108+
ignore = ["E203", "F405", "E402"]
123109

124110
# Allow autofix for all enabled rules (when `--fix` is provided).
125-
fixable = ["ALL"]
111+
fixable = ["A", "B", "C", "D", "E", "F", "G", "I", "N", "Q", "S", "T", "W", "ANN", "ARG", "BLE", "COM", "DJ", "DTZ", "EM", "ERA", "EXE", "FBT", "ICN", "INP", "ISC", "NPY", "PD", "PGH", "PIE", "PL", "PT", "PTH", "PYI", "RET", "RSE", "RUF", "SIM", "SLF", "TCH", "TID", "TRY", "UP", "YTT"]
126112
unfixable = []
127113

114+
# Exclude a variety of commonly ignored directories.
115+
exclude = [
116+
".cache",
117+
".eggs",
118+
".git",
119+
".git-rewrite",
120+
".idea",
121+
".mypy_cache",
122+
".ruff_cache",
123+
".tox",
124+
"build",
125+
"dist",
126+
]
127+
128128
# Allow unused variables when underscore-prefixed.
129129
dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$"
130130

131131
# Code style violation exceptions:
132132
# please note that the values are adjusted so that they do not cause failures
133133
# with existing code. if you want to change them, you should first fix all
134134
# ruff failures that appear with your change.
135-
[tool.ruff.lint.per-file-ignores]
135+
[tool.ruff.per-file-ignores]
136136
"scripts/make.py" = ["E501"]
137137
"src/borg/archive.py" = ["E501"]
138138
"src/borg/archiver/help_cmd.py" = ["E501"]
@@ -142,7 +142,7 @@ dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$"
142142
"src/borg/testsuite/archiver/disk_full_test.py" = ["F811"]
143143
"src/borg/testsuite/archiver/return_codes_test.py" = ["F811"]
144144
"src/borg/testsuite/benchmark_test.py" = ["F811"]
145-
"src/borg/testsuite/platform/platform_test.py" = ["F811"]
145+
"src/borg/testsuite/platform_test.py" = ["F811"]
146146

147147
[tool.pytest.ini_options]
148148
markers = []

src/borg/archive.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@
3434
from .helpers import HardLinkManager
3535
from .helpers import ChunkIteratorFileWrapper, open_item
3636
from .helpers import Error, IntegrityError, set_ec
37-
from .platform import uid2user, user2uid, gid2group, group2gid, get_birthtime_ns
37+
from .platform import uid2user, user2uid, gid2group, group2gid, get_birthtime_ns, set_birthtime
3838
from .helpers import parse_timestamp, archive_ts_now
3939
from .helpers import OutputTimestamp, format_timedelta, format_file_size, file_status, FileSize
4040
from .helpers import safe_encode, make_path_safe, remove_surrogates, text_to_json, join_cmd, remove_dotdot_prefixes
@@ -1005,8 +1005,10 @@ def restore_attrs(self, path, item, symlink=False, fd=None):
10051005
set_flags(path, item.bsdflags, fd=fd)
10061006
except OSError:
10071007
pass
1008-
else: # win32
1008+
else: # pragma: win32 only
10091009
# set timestamps rather late
1010+
if "birthtime" in item:
1011+
set_birthtime(path, item.birthtime)
10101012
mtime = item.mtime
10111013
atime = item.atime if "atime" in item else mtime
10121014
try:

src/borg/platform/__init__.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
from .linux import listxattr, getxattr, setxattr
2222
from .linux import acl_get, acl_set
2323
from .linux import set_flags, get_flags
24+
from .base import set_birthtime
2425
from .linux import SyncFile
2526
from .posix import process_alive, local_pid_alive
2627
from .posix import get_errno
@@ -31,6 +32,7 @@
3132
from .freebsd import acl_get, acl_set
3233
from .freebsd import set_flags
3334
from .base import get_flags
35+
from .base import set_birthtime
3436
from .base import SyncFile
3537
from .posix import process_alive, local_pid_alive
3638
from .posix import get_errno
@@ -40,6 +42,7 @@
4042
from .netbsd import listxattr, getxattr, setxattr
4143
from .base import acl_get, acl_set
4244
from .base import set_flags, get_flags
45+
from .base import set_birthtime
4346
from .base import SyncFile
4447
from .posix import process_alive, local_pid_alive
4548
from .posix import get_errno
@@ -51,6 +54,7 @@
5154
from .darwin import is_darwin_feature_64_bit_inode, _get_birthtime_ns
5255
from .darwin import set_flags
5356
from .base import get_flags
57+
from .base import set_birthtime
5458
from .base import SyncFile
5559
from .posix import process_alive, local_pid_alive
5660
from .posix import get_errno
@@ -61,6 +65,7 @@
6165
from .base import listxattr, getxattr, setxattr
6266
from .base import acl_get, acl_set
6367
from .base import set_flags, get_flags
68+
from .base import set_birthtime
6469
from .base import SyncFile
6570
from .posix import process_alive, local_pid_alive
6671
from .posix import get_errno
@@ -71,6 +76,7 @@
7176
from .base import listxattr, getxattr, setxattr
7277
from .base import acl_get, acl_set
7378
from .base import set_flags, get_flags
79+
from .windows import set_birthtime # type: ignore[no-redef]
7480
from .base import SyncFile
7581
from .windows import process_alive, local_pid_alive
7682
from .windows import getosusername

src/borg/platform/base.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,13 @@ def setxattr(path, name, value, *, follow_symlinks=False):
6868
"""
6969

7070

71+
def set_birthtime(path, birthtime_ns):
72+
"""
73+
Set creation time (birthtime) on *path* to *birthtime_ns*.
74+
"""
75+
raise NotImplementedError("set_birthtime is not supported on this platform")
76+
77+
7178
def acl_get(path, item, st, numeric_ids=False, fd=None):
7279
"""
7380
Save ACL entries.

src/borg/platform/windows.pyx

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,3 +37,51 @@ def process_alive(host, pid, thread):
3737
def local_pid_alive(pid):
3838
"""Return whether *pid* is alive."""
3939
raise NotImplementedError
40+
41+
42+
def set_birthtime(path, birthtime_ns):
43+
"""
44+
Set creation time (birthtime) on *path* to *birthtime_ns*.
45+
"""
46+
import ctypes
47+
from ctypes import wintypes
48+
49+
# Windows API Constants
50+
FILE_WRITE_ATTRIBUTES = 0x0100
51+
FILE_SHARE_READ = 0x00000001
52+
FILE_SHARE_WRITE = 0x00000002
53+
FILE_SHARE_DELETE = 0x00000004
54+
OPEN_EXISTING = 3
55+
FILE_FLAG_BACKUP_SEMANTICS = 0x02000000
56+
57+
class FILETIME(ctypes.Structure):
58+
_fields_ = [("dwLowDateTime", wintypes.DWORD), ("dwHighDateTime", wintypes.DWORD)]
59+
60+
# Convert ns to Windows FILETIME
61+
# Units: 100-nanosecond intervals
62+
# Epoch: Jan 1, 1601
63+
unix_epoch_in_100ns = 116444736000000000
64+
intervals = (birthtime_ns // 100) + unix_epoch_in_100ns
65+
66+
ft = FILETIME()
67+
ft.dwLowDateTime = intervals & 0xFFFFFFFF
68+
ft.dwHighDateTime = intervals >> 32
69+
70+
handle = ctypes.windll.kernel32.CreateFileW(
71+
str(path),
72+
FILE_WRITE_ATTRIBUTES,
73+
FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE,
74+
None,
75+
OPEN_EXISTING,
76+
FILE_FLAG_BACKUP_SEMANTICS,
77+
None,
78+
)
79+
80+
if handle == -1:
81+
return
82+
83+
try:
84+
# SetFileTime(handle, lpCreationTime, lpLastAccessTime, lpLastWriteTime)
85+
ctypes.windll.kernel32.SetFileTime(handle, ctypes.byref(ft), None, None)
86+
finally:
87+
ctypes.windll.kernel32.CloseHandle(handle)

src/borg/testsuite/__init__.py

Lines changed: 33 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@
2121
raises = None
2222

2323
from ..fuse_impl import llfuse, has_any_fuse, has_llfuse, has_pyfuse3, has_mfusepy, ENOATTR # NOQA
24-
from .. import platform
24+
from borg import platform as borg_platform
2525
from ..platformflags import is_win32, is_darwin
2626

2727
# Does this version of llfuse support ns precision?
@@ -32,7 +32,7 @@
3232
has_lchflags = hasattr(os, "lchflags") or sys.platform.startswith("linux")
3333
try:
3434
with tempfile.NamedTemporaryFile() as file:
35-
platform.set_flags(file.name, stat.UF_NODUMP)
35+
borg_platform.set_flags(file.name, stat.UF_NODUMP)
3636
except OSError:
3737
has_lchflags = False
3838

@@ -185,42 +185,51 @@ def are_fifos_supported():
185185
def is_utime_fully_supported():
186186
with unopened_tempfile() as filepath:
187187
# Some filesystems (such as SSHFS) don't support utime on symlinks
188-
if are_symlinks_supported():
188+
if are_symlinks_supported() and not is_win32:
189189
os.symlink("something", filepath)
190+
try:
191+
os.utime(filepath, (1000, 2000), follow_symlinks=False)
192+
new_stats = os.stat(filepath, follow_symlinks=False)
193+
if new_stats.st_atime == 1000 and new_stats.st_mtime == 2000:
194+
return True
195+
except OSError:
196+
pass
197+
except NotImplementedError:
198+
pass
190199
else:
191200
open(filepath, "w").close()
192-
try:
193-
os.utime(filepath, (1000, 2000), follow_symlinks=False)
194-
new_stats = os.stat(filepath, follow_symlinks=False)
195-
if new_stats.st_atime == 1000 and new_stats.st_mtime == 2000:
196-
return True
197-
except OSError:
198-
pass
199-
except NotImplementedError:
200-
pass
201-
return False
201+
try:
202+
os.utime(filepath, (1000, 2000))
203+
new_stats = os.stat(filepath)
204+
if new_stats.st_atime == 1000 and new_stats.st_mtime == 2000:
205+
return True
206+
except OSError:
207+
pass
208+
return False
202209

203210

204211
@functools.lru_cache
205212
def is_birthtime_fully_supported():
206-
if not hasattr(os.stat_result, "st_birthtime"):
207-
return False
208213
with unopened_tempfile() as filepath:
209214
# Some filesystems (such as SSHFS) don't support utime on symlinks
210-
if are_symlinks_supported():
215+
if are_symlinks_supported() and not is_win32:
211216
os.symlink("something", filepath)
212217
else:
213218
open(filepath, "w").close()
214219
try:
215-
birthtime, mtime, atime = 946598400, 946684800, 946771200
216-
os.utime(filepath, (atime, birthtime), follow_symlinks=False)
217-
os.utime(filepath, (atime, mtime), follow_symlinks=False)
218-
new_stats = os.stat(filepath, follow_symlinks=False)
219-
if new_stats.st_birthtime == birthtime and new_stats.st_mtime == mtime and new_stats.st_atime == atime:
220+
birthtime_ns, mtime_ns, atime_ns = 946598400 * 10**9, 946684800 * 10**9, 946771200 * 10**9
221+
borg_platform.set_birthtime(filepath, birthtime_ns)
222+
os.utime(filepath, ns=(atime_ns, mtime_ns))
223+
new_stats = os.stat(filepath)
224+
bt = borg_platform.get_birthtime_ns(new_stats, filepath)
225+
if (
226+
bt is not None
227+
and same_ts_ns(bt, birthtime_ns)
228+
and same_ts_ns(new_stats.st_mtime_ns, mtime_ns)
229+
and same_ts_ns(new_stats.st_atime_ns, atime_ns)
230+
):
220231
return True
221-
except OSError:
222-
pass
223-
except NotImplementedError:
232+
except (OSError, NotImplementedError, AttributeError):
224233
pass
225234
return False
226235

src/borg/testsuite/archiver/__init__.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -396,8 +396,12 @@ def _assert_dirs_equal_cmp(diff, ignore_flags=False, ignore_xattrs=False, ignore
396396
# If utime is not fully supported, Borg cannot set mtime.
397397
# Therefore, we should not test it in that case.
398398
if is_utime_fully_supported():
399+
if is_win32 and stat.S_ISLNK(s1.st_mode):
400+
# Windows often fails to restore symlink mtime correctly or we can't set it.
401+
# Skip mtime check for symlinks on Windows.
402+
pass
399403
# Older versions of llfuse do not support ns precision properly
400-
if ignore_ns:
404+
elif ignore_ns:
401405
d1.append(int(s1.st_mtime_ns / 1e9))
402406
d2.append(int(s2.st_mtime_ns / 1e9))
403407
elif fuse and not have_fuse_mtime_ns:
@@ -409,6 +413,12 @@ def _assert_dirs_equal_cmp(diff, ignore_flags=False, ignore_xattrs=False, ignore
409413
if not ignore_xattrs:
410414
d1.append(filter_xattrs(get_all(path1, follow_symlinks=False)))
411415
d2.append(filter_xattrs(get_all(path2, follow_symlinks=False)))
416+
if is_win32 and is_utime_fully_supported():
417+
# Check timestamps with 10ms tolerance due to precision differences
418+
mtime_idx = -2 if not ignore_xattrs else -1
419+
# If within tolerance, synchronize them for the assertion
420+
if abs(d1[mtime_idx] - d2[mtime_idx]) < 10_000_000:
421+
d2[mtime_idx] = d1[mtime_idx]
412422
assert d1 == d2
413423
for sub_diff in diff.subdirs.values():
414424
_assert_dirs_equal_cmp(sub_diff, ignore_flags=ignore_flags, ignore_xattrs=ignore_xattrs, ignore_ns=ignore_ns)

src/borg/testsuite/archiver/create_cmd_test.py

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import shutil
66
import socket
77
import stat
8+
import sys
89
import subprocess
910

1011
import pytest
@@ -211,19 +212,23 @@ def test_unix_socket(archivers, request, monkeypatch):
211212
def test_nobirthtime(archivers, request):
212213
archiver = request.getfixturevalue(archivers)
213214
create_test_files(archiver.input_path)
214-
birthtime, mtime, atime = 946598400, 946684800, 946771200
215-
os.utime("input/file1", (atime, birthtime))
216-
os.utime("input/file1", (atime, mtime))
215+
birthtime_ns, mtime_ns, atime_ns = 946598400 * 10**9, 946684800 * 10**9, 946771200 * 10**9
216+
if sys.platform == "win32":
217+
platform.set_birthtime("input/file1", birthtime_ns)
218+
os.utime("input/file1", ns=(atime_ns, mtime_ns))
217219
cmd(archiver, "repo-create", RK_ENCRYPTION)
218220
cmd(archiver, "create", "test", "input", "--nobirthtime")
219221
with changedir("output"):
220222
cmd(archiver, "extract", "test")
221223
sti = os.stat("input/file1")
222224
sto = os.stat("output/input/file1")
223-
assert same_ts_ns(sti.st_birthtime * 1e9, birthtime * 1e9)
224-
assert same_ts_ns(sto.st_birthtime * 1e9, mtime * 1e9)
225+
assert same_ts_ns(sti.st_birthtime * 1e9, birthtime_ns)
226+
if sys.platform == "win32":
227+
assert not same_ts_ns(sto.st_birthtime * 1e9, birthtime_ns)
228+
else:
229+
assert same_ts_ns(sto.st_birthtime * 1e9, mtime_ns)
225230
assert same_ts_ns(sti.st_mtime_ns, sto.st_mtime_ns)
226-
assert same_ts_ns(sto.st_mtime_ns, mtime * 1e9)
231+
assert same_ts_ns(sto.st_mtime_ns, mtime_ns)
227232

228233

229234
def test_create_stdin(archivers, request):

src/borg/testsuite/archiver/extract_cmd_test.py

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
from ...helpers import flags_noatime, flags_normal
1616
from .. import changedir, same_ts_ns, granularity_sleep
1717
from .. import are_symlinks_supported, are_hardlinks_supported, is_utime_fully_supported, is_birthtime_fully_supported
18-
from ...platform import get_birthtime_ns
18+
from ...platform import get_birthtime_ns, set_birthtime # noqa: F401
1919
from ...platformflags import is_darwin, is_freebsd, is_win32
2020
from . import (
2121
RK_ENCRYPTION,
@@ -168,7 +168,7 @@ def test_birthtime(archivers, request):
168168
archiver = request.getfixturevalue(archivers)
169169
create_test_files(archiver.input_path)
170170
birthtime, mtime, atime = 946598400, 946684800, 946771200
171-
os.utime("input/file1", (atime, birthtime))
171+
set_birthtime("input/file1", birthtime * 1_000_000_000) # noqa: F821
172172
os.utime("input/file1", (atime, mtime))
173173
cmd(archiver, "repo-create", RK_ENCRYPTION)
174174
cmd(archiver, "create", "test", "input")
@@ -177,7 +177,11 @@ def test_birthtime(archivers, request):
177177
sti = os.stat("input/file1")
178178
sto = os.stat("output/input/file1")
179179
assert same_ts_ns(sti.st_birthtime * 1e9, sto.st_birthtime * 1e9)
180-
assert same_ts_ns(sto.st_birthtime * 1e9, birthtime * 1e9)
180+
if is_win32:
181+
# allow for small differences (e.g. 10ms)
182+
assert abs(sto.st_birthtime * 1e9 - birthtime * 1e9) < 10_000_000
183+
else:
184+
assert same_ts_ns(sto.st_birthtime * 1e9, birthtime * 1e9)
181185
assert same_ts_ns(sti.st_mtime_ns, sto.st_mtime_ns)
182186
assert same_ts_ns(sto.st_mtime_ns, mtime * 1e9)
183187

0 commit comments

Comments
 (0)