Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions src/borg/archive.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@
from .helpers import HardLinkManager
from .helpers import ChunkIteratorFileWrapper, open_item
from .helpers import Error, IntegrityError, set_ec
from .platform import uid2user, user2uid, gid2group, group2gid, get_birthtime_ns
from .platform import uid2user, user2uid, gid2group, group2gid, get_birthtime_ns, set_birthtime
from .helpers import parse_timestamp, archive_ts_now
from .helpers import OutputTimestamp, format_timedelta, format_file_size, file_status, FileSize
from .helpers import safe_encode, make_path_safe, remove_surrogates, text_to_json, join_cmd, remove_dotdot_prefixes
Expand Down Expand Up @@ -1005,8 +1005,10 @@ def restore_attrs(self, path, item, symlink=False, fd=None):
set_flags(path, item.bsdflags, fd=fd)
except OSError:
pass
else: # win32
else: # pragma: win32 only
# set timestamps rather late
if "birthtime" in item:
set_birthtime(path, item.birthtime)
mtime = item.mtime
atime = item.atime if "atime" in item else mtime
try:
Expand Down
6 changes: 6 additions & 0 deletions src/borg/platform/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
from .linux import listxattr, getxattr, setxattr
from .linux import acl_get, acl_set
from .linux import set_flags, get_flags
from .base import set_birthtime
from .linux import SyncFile
from .posix import process_alive, local_pid_alive
from .posix import get_errno
Expand All @@ -31,6 +32,7 @@
from .freebsd import acl_get, acl_set
from .freebsd import set_flags
from .base import get_flags
from .base import set_birthtime
from .base import SyncFile
from .posix import process_alive, local_pid_alive
from .posix import get_errno
Expand All @@ -40,6 +42,7 @@
from .netbsd import listxattr, getxattr, setxattr
from .base import acl_get, acl_set
from .base import set_flags, get_flags
from .base import set_birthtime
from .base import SyncFile
from .posix import process_alive, local_pid_alive
from .posix import get_errno
Expand All @@ -51,6 +54,7 @@
from .darwin import is_darwin_feature_64_bit_inode, _get_birthtime_ns
from .darwin import set_flags
from .base import get_flags
from .base import set_birthtime
from .base import SyncFile
from .posix import process_alive, local_pid_alive
from .posix import get_errno
Expand All @@ -61,6 +65,7 @@
from .base import listxattr, getxattr, setxattr
from .base import acl_get, acl_set
from .base import set_flags, get_flags
from .base import set_birthtime
from .base import SyncFile
from .posix import process_alive, local_pid_alive
from .posix import get_errno
Expand All @@ -71,6 +76,7 @@
from .base import listxattr, getxattr, setxattr
from .base import acl_get, acl_set
from .base import set_flags, get_flags
from .windows import set_birthtime # type: ignore[no-redef]
from .base import SyncFile
from .windows import process_alive, local_pid_alive
from .windows import getosusername
Expand Down
7 changes: 7 additions & 0 deletions src/borg/platform/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,13 @@ def setxattr(path, name, value, *, follow_symlinks=False):
"""


def set_birthtime(path, birthtime_ns):
"""
Set creation time (birthtime) on *path* to *birthtime_ns*.
"""
raise NotImplementedError("set_birthtime is not supported on this platform")


def acl_get(path, item, st, numeric_ids=False, fd=None):
"""
Save ACL entries.
Expand Down
48 changes: 48 additions & 0 deletions src/borg/platform/windows.pyx
Original file line number Diff line number Diff line change
Expand Up @@ -37,3 +37,51 @@ def process_alive(host, pid, thread):
def local_pid_alive(pid):
"""Return whether *pid* is alive."""
raise NotImplementedError


def set_birthtime(path, birthtime_ns):
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is there also a way to do this with an open file descriptor?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

a lot of borg file functions take a file descriptor and a path. if the (open) file descriptor is provided, the function uses that and if not, it falls back to using the path.

"""
Set creation time (birthtime) on *path* to *birthtime_ns*.
"""
import ctypes
from ctypes import wintypes

# Windows API Constants
FILE_WRITE_ATTRIBUTES = 0x0100
FILE_SHARE_READ = 0x00000001
FILE_SHARE_WRITE = 0x00000002
FILE_SHARE_DELETE = 0x00000004
OPEN_EXISTING = 3
FILE_FLAG_BACKUP_SEMANTICS = 0x02000000

class FILETIME(ctypes.Structure):
_fields_ = [("dwLowDateTime", wintypes.DWORD), ("dwHighDateTime", wintypes.DWORD)]

# Convert ns to Windows FILETIME
# Units: 100-nanosecond intervals
# Epoch: Jan 1, 1601
unix_epoch_in_100ns = 116444736000000000
intervals = (birthtime_ns // 100) + unix_epoch_in_100ns

ft = FILETIME()
ft.dwLowDateTime = intervals & 0xFFFFFFFF
ft.dwHighDateTime = intervals >> 32

handle = ctypes.windll.kernel32.CreateFileW(
str(path),
FILE_WRITE_ATTRIBUTES,
FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE,
None,
OPEN_EXISTING,
FILE_FLAG_BACKUP_SEMANTICS,
None,
)

if handle == -1:
return

try:
# SetFileTime(handle, lpCreationTime, lpLastAccessTime, lpLastWriteTime)
ctypes.windll.kernel32.SetFileTime(handle, ctypes.byref(ft), None, None)
finally:
ctypes.windll.kernel32.CloseHandle(handle)
57 changes: 33 additions & 24 deletions src/borg/testsuite/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
raises = None

from ..fuse_impl import llfuse, has_any_fuse, has_llfuse, has_pyfuse3, has_mfusepy, ENOATTR # NOQA
from .. import platform
from borg import platform as borg_platform
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

don't do absolute imports.

from ..platformflags import is_win32, is_darwin

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

Expand Down Expand Up @@ -185,42 +185,51 @@ def are_fifos_supported():
def is_utime_fully_supported():
with unopened_tempfile() as filepath:
# Some filesystems (such as SSHFS) don't support utime on symlinks
if are_symlinks_supported():
if are_symlinks_supported() and not is_win32:
os.symlink("something", filepath)
try:
os.utime(filepath, (1000, 2000), follow_symlinks=False)
new_stats = os.stat(filepath, follow_symlinks=False)
if new_stats.st_atime == 1000 and new_stats.st_mtime == 2000:
return True
except OSError:
pass
except NotImplementedError:
pass
else:
open(filepath, "w").close()
try:
os.utime(filepath, (1000, 2000), follow_symlinks=False)
new_stats = os.stat(filepath, follow_symlinks=False)
if new_stats.st_atime == 1000 and new_stats.st_mtime == 2000:
return True
except OSError:
pass
except NotImplementedError:
pass
return False
try:
os.utime(filepath, (1000, 2000))
new_stats = os.stat(filepath)
if new_stats.st_atime == 1000 and new_stats.st_mtime == 2000:
return True
except OSError:
pass
return False


@functools.lru_cache
def is_birthtime_fully_supported():
if not hasattr(os.stat_result, "st_birthtime"):
return False
with unopened_tempfile() as filepath:
# Some filesystems (such as SSHFS) don't support utime on symlinks
if are_symlinks_supported():
if are_symlinks_supported() and not is_win32:
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)
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:
birthtime_ns, mtime_ns, atime_ns = 946598400 * 10**9, 946684800 * 10**9, 946771200 * 10**9
borg_platform.set_birthtime(filepath, birthtime_ns)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

don't kill it for other platforms.

os.utime(filepath, ns=(atime_ns, mtime_ns))
new_stats = os.stat(filepath)
bt = borg_platform.get_birthtime_ns(new_stats, filepath)
if (
bt is not None
and same_ts_ns(bt, birthtime_ns)
and same_ts_ns(new_stats.st_mtime_ns, mtime_ns)
and same_ts_ns(new_stats.st_atime_ns, atime_ns)
):
return True
except OSError:
pass
except NotImplementedError:
except (OSError, NotImplementedError, AttributeError):
pass
return False

Expand Down
12 changes: 11 additions & 1 deletion src/borg/testsuite/archiver/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -396,8 +396,12 @@ def _assert_dirs_equal_cmp(diff, ignore_flags=False, ignore_xattrs=False, ignore
# If utime is not fully supported, Borg cannot set mtime.
# Therefore, we should not test it in that case.
if is_utime_fully_supported():
if is_win32 and stat.S_ISLNK(s1.st_mode):
# Windows often fails to restore symlink mtime correctly or we can't set it.
# Skip mtime check for symlinks on Windows.
pass
# Older versions of llfuse do not support ns precision properly
if ignore_ns:
elif ignore_ns:
d1.append(int(s1.st_mtime_ns / 1e9))
d2.append(int(s2.st_mtime_ns / 1e9))
elif fuse and not have_fuse_mtime_ns:
Expand All @@ -409,6 +413,12 @@ def _assert_dirs_equal_cmp(diff, ignore_flags=False, ignore_xattrs=False, ignore
if not ignore_xattrs:
d1.append(filter_xattrs(get_all(path1, follow_symlinks=False)))
d2.append(filter_xattrs(get_all(path2, follow_symlinks=False)))
if is_win32 and is_utime_fully_supported():
# Check timestamps with 10ms tolerance due to precision differences
mtime_idx = -2 if not ignore_xattrs else -1
# If within tolerance, synchronize them for the assertion
if abs(d1[mtime_idx] - d2[mtime_idx]) < 10_000_000:
d2[mtime_idx] = d1[mtime_idx]
assert d1 == d2
for sub_diff in diff.subdirs.values():
_assert_dirs_equal_cmp(sub_diff, ignore_flags=ignore_flags, ignore_xattrs=ignore_xattrs, ignore_ns=ignore_ns)
Expand Down
17 changes: 11 additions & 6 deletions src/borg/testsuite/archiver/create_cmd_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import shutil
import socket
import stat
import sys
import subprocess

import pytest
Expand Down Expand Up @@ -211,19 +212,23 @@ 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
if sys.platform == "win32":
platform.set_birthtime("input/file1", birthtime_ns)
Comment on lines +216 to +217
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is_win32

also: don't kill it for other platforms.

os.utime("input/file1", ns=(atime_ns, mtime_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(sti.st_birthtime * 1e9, birthtime_ns)
if sys.platform == "win32":
assert not same_ts_ns(sto.st_birthtime * 1e9, birthtime_ns)
else:
assert same_ts_ns(sto.st_birthtime * 1e9, mtime_ns)
assert same_ts_ns(sti.st_mtime_ns, sto.st_mtime_ns)
assert same_ts_ns(sto.st_mtime_ns, mtime * 1e9)
assert same_ts_ns(sto.st_mtime_ns, mtime_ns)


def test_create_stdin(archivers, request):
Expand Down
10 changes: 7 additions & 3 deletions src/borg/testsuite/archiver/extract_cmd_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
from ...helpers import flags_noatime, flags_normal
from .. import changedir, same_ts_ns, granularity_sleep
from .. import are_symlinks_supported, are_hardlinks_supported, is_utime_fully_supported, is_birthtime_fully_supported
from ...platform import get_birthtime_ns
from ...platform import get_birthtime_ns, set_birthtime # noqa: F401
from ...platformflags import is_darwin, is_freebsd, is_win32
from . import (
RK_ENCRYPTION,
Expand Down Expand Up @@ -168,7 +168,7 @@ 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))
set_birthtime("input/file1", birthtime * 1_000_000_000) # noqa: F821
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

don't kill it for other platforms.

os.utime("input/file1", (atime, mtime))
cmd(archiver, "repo-create", RK_ENCRYPTION)
cmd(archiver, "create", "test", "input")
Expand All @@ -177,7 +177,11 @@ def test_birthtime(archivers, request):
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)
if is_win32:
# allow for small differences (e.g. 10ms)
assert abs(sto.st_birthtime * 1e9 - birthtime * 1e9) < 10_000_000
Comment on lines +181 to +182
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if we generally have to expect up to 10ms variance on windows, this could be moved into same_ts_ns.

else:
assert same_ts_ns(sto.st_birthtime * 1e9, birthtime * 1e9)
assert same_ts_ns(sti.st_mtime_ns, sto.st_mtime_ns)
assert same_ts_ns(sto.st_mtime_ns, mtime * 1e9)

Expand Down
52 changes: 52 additions & 0 deletions src/borg/testsuite/archiver/win32_birthtime_e2e_test.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import os
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

in the end, this should be in the tests of the extract command.

import pytest
from ...platform import set_birthtime, get_birthtime_ns
from ...platformflags import is_win32
from . import cmd, generate_archiver_tests, changedir


def pytest_generate_tests(metafunc):
generate_archiver_tests(metafunc, kinds="local")


@pytest.mark.skipif(not is_win32, reason="Windows only test")
def test_birthtime_restore(archivers, request):
archiver = request.getfixturevalue(archivers)
cmd(archiver, "repo-create", "--encryption=none")

# Create a file in input directory
input_file = os.path.join(archiver.input_path, "test_file")
if not os.path.exists(archiver.input_path):
os.makedirs(archiver.input_path)
with open(input_file, "w") as f:
f.write("data")

st = os.stat(input_file)
original_birthtime = get_birthtime_ns(st, input_file)

# Set an old birthtime (10 years ago)
# 10 years * 365 days * 24 hours * 3600 seconds * 10^9 ns/s
old_birthtime_ns = original_birthtime - 10 * 365 * 24 * 3600 * 10**9
# Ensure it's 100ns aligned (Windows precision)
old_birthtime_ns = (old_birthtime_ns // 100) * 100
set_birthtime(input_file, old_birthtime_ns)

# Verify it was set correctly initially
st_verify = os.stat(input_file)
assert get_birthtime_ns(st_verify, input_file) == old_birthtime_ns

# Archive it
cmd(archiver, "create", "test", "input")

# Extract it to a different location
if not os.path.exists("output"):
os.makedirs("output")
Comment on lines +42 to +43
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

don't we always have the output dir?

also: check makedirs parameters.

with changedir("output"):
cmd(archiver, "extract", "test")

# Check restored birthtime
restored_file = os.path.join("output", "input", "test_file")
st_restored = os.stat(restored_file)
restored_birthtime = get_birthtime_ns(st_restored, restored_file)

assert restored_birthtime == old_birthtime_ns
Loading