Skip to content

Commit 2f50ad0

Browse files
committed
windows: implement st_birthtime restoration, fixes #8730
1 parent 079aebb commit 2f50ad0

File tree

6 files changed

+177
-31
lines changed

6 files changed

+177
-31
lines changed

src/borg/archive.py

Lines changed: 3 additions & 1 deletion
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
@@ -1007,6 +1007,8 @@ def restore_attrs(self, path, item, symlink=False, fd=None):
10071007
pass
10081008
else: # win32
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: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44
Public APIs are documented in platform.base.
55
"""
66

7+
import ctypes
8+
from ctypes import wintypes
79
from types import ModuleType
810

911
from ..platformflags import is_win32, is_linux, is_freebsd, is_netbsd, is_darwin, is_cygwin, is_haiku
@@ -77,6 +79,53 @@
7779
from . import windows_ug as platform_ug
7880

7981

82+
def set_birthtime(path, birthtime_ns):
83+
"""
84+
Set creation time (birthtime) on *path* to *birthtime_ns*.
85+
"""
86+
if is_win32:
87+
# Windows API Constants
88+
FILE_WRITE_ATTRIBUTES = 0x0100
89+
FILE_SHARE_READ = 0x00000001
90+
FILE_SHARE_WRITE = 0x00000002
91+
FILE_SHARE_DELETE = 0x00000004
92+
OPEN_EXISTING = 3
93+
FILE_FLAG_BACKUP_SEMANTICS = 0x02000000
94+
95+
class FILETIME(ctypes.Structure):
96+
_fields_ = [("dwLowDateTime", wintypes.DWORD), ("dwHighDateTime", wintypes.DWORD)]
97+
98+
# Convert ns to Windows FILETIME
99+
# Units: 100-nanosecond intervals
100+
# Epoch: Jan 1, 1601
101+
unix_epoch_in_100ns = 116444736000000000
102+
intervals = (birthtime_ns // 100) + unix_epoch_in_100ns
103+
104+
ft = FILETIME()
105+
ft.dwLowDateTime = intervals & 0xFFFFFFFF
106+
ft.dwHighDateTime = intervals >> 32
107+
108+
handle = ctypes.windll.kernel32.CreateFileW(
109+
str(path),
110+
FILE_WRITE_ATTRIBUTES,
111+
FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE,
112+
None,
113+
OPEN_EXISTING,
114+
FILE_FLAG_BACKUP_SEMANTICS,
115+
None,
116+
)
117+
118+
if handle == -1:
119+
return
120+
121+
try:
122+
# SetFileTime(handle, lpCreationTime, lpLastAccessTime, lpLastWriteTime)
123+
ctypes.windll.kernel32.SetFileTime(handle, ctypes.byref(ft), None, None)
124+
finally:
125+
ctypes.windll.kernel32.CloseHandle(handle)
126+
# No-op on other platforms (POSIX handles birthtime via os.utime if supported)
127+
128+
80129
def get_birthtime_ns(st, path, fd=None):
81130
if hasattr(st, "st_birthtime_ns"):
82131
# Added in Python 3.12, but not always available.

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/create_cmd_test.py

Lines changed: 10 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,22 @@ 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+
platform.set_birthtime("input/file1", birthtime_ns)
217+
os.utime("input/file1", ns=(atime_ns, mtime_ns))
217218
cmd(archiver, "repo-create", RK_ENCRYPTION)
218219
cmd(archiver, "create", "test", "input", "--nobirthtime")
219220
with changedir("output"):
220221
cmd(archiver, "extract", "test")
221222
sti = os.stat("input/file1")
222223
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)
224+
assert same_ts_ns(sti.st_birthtime * 1e9, birthtime_ns)
225+
if sys.platform == "win32":
226+
assert not same_ts_ns(sto.st_birthtime * 1e9, birthtime_ns)
227+
else:
228+
assert same_ts_ns(sto.st_birthtime * 1e9, mtime_ns)
225229
assert same_ts_ns(sti.st_mtime_ns, sto.st_mtime_ns)
226-
assert same_ts_ns(sto.st_mtime_ns, mtime * 1e9)
230+
assert same_ts_ns(sto.st_mtime_ns, mtime_ns)
227231

228232

229233
def test_create_stdin(archivers, request):
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import os
2+
import pytest
3+
from ...platform import set_birthtime, get_birthtime_ns
4+
from ...platformflags import is_win32
5+
from . import cmd, generate_archiver_tests, changedir
6+
7+
8+
def pytest_generate_tests(metafunc):
9+
generate_archiver_tests(metafunc, kinds="local")
10+
11+
12+
@pytest.mark.skipif(not is_win32, reason="Windows only test")
13+
def test_birthtime_restore(archivers, request):
14+
archiver = request.getfixturevalue(archivers)
15+
cmd(archiver, "repo-create", "--encryption=none")
16+
17+
# Create a file in input directory
18+
input_file = os.path.join(archiver.input_path, "test_file")
19+
if not os.path.exists(archiver.input_path):
20+
os.makedirs(archiver.input_path)
21+
with open(input_file, "w") as f:
22+
f.write("data")
23+
24+
st = os.stat(input_file)
25+
original_birthtime = get_birthtime_ns(st, input_file)
26+
27+
# Set an old birthtime (10 years ago)
28+
# 10 years * 365 days * 24 hours * 3600 seconds * 10^9 ns/s
29+
old_birthtime_ns = original_birthtime - 10 * 365 * 24 * 3600 * 10**9
30+
# Ensure it's 100ns aligned (Windows precision)
31+
old_birthtime_ns = (old_birthtime_ns // 100) * 100
32+
set_birthtime(input_file, old_birthtime_ns)
33+
34+
# Verify it was set correctly initially
35+
st_verify = os.stat(input_file)
36+
assert get_birthtime_ns(st_verify, input_file) == old_birthtime_ns
37+
38+
# Archive it
39+
cmd(archiver, "create", "test", "input")
40+
41+
# Extract it to a different location
42+
if not os.path.exists("output"):
43+
os.makedirs("output")
44+
with changedir("output"):
45+
cmd(archiver, "extract", "test")
46+
47+
# Check restored birthtime
48+
restored_file = os.path.join("output", "input", "test_file")
49+
st_restored = os.stat(restored_file)
50+
restored_birthtime = get_birthtime_ns(st_restored, restored_file)
51+
52+
assert restored_birthtime == old_birthtime_ns
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import os
2+
import pytest
3+
from ...platformflags import is_win32
4+
from ...platform import set_birthtime, get_birthtime_ns
5+
6+
7+
@pytest.mark.skipif(not is_win32, reason="Windows only test")
8+
def test_set_birthtime(tmpdir):
9+
test_file = str(tmpdir.join("test_birthtime.txt"))
10+
with open(test_file, "w") as f:
11+
f.write("content")
12+
13+
st = os.stat(test_file)
14+
original_birthtime = get_birthtime_ns(st, test_file)
15+
assert original_birthtime is not None
16+
17+
# Set a new birthtime (e.g., 1 hour ago)
18+
# We use a value that is clearly different from 'now'
19+
new_birthtime_ns = original_birthtime - 3600 * 10**9
20+
21+
set_birthtime(test_file, new_birthtime_ns)
22+
23+
st_new = os.stat(test_file)
24+
restored_birthtime = get_birthtime_ns(st_new, test_file)
25+
26+
# Windows FILETIME has 100ns precision.
27+
# Our set_birthtime implementation handles this.
28+
# We check if it matches (allowing for the 100ns granularity if needed,
29+
# but here we subtracted exactly 1 hour which is a multiple of 100ns)
30+
assert restored_birthtime == new_birthtime_ns

0 commit comments

Comments
 (0)