Preserve symlinks and old-snapshot immutability (#9)

* Preserve symlinks and old-snapshot immutability

Two real bugs found while implementing this item, both in fs.py:

- copy_direntry() checked entry.is_dir() (default follow_symlinks=True)
  before entry.is_symlink(). A symlink pointing at a directory was
  misclassified as a directory and an empty real directory was created
  in its place, silently destroying the symlink and losing its target
  entirely. Fixed by checking is_symlink() first, and is_dir() with
  follow_symlinks=False.
- rsync()'s permission/ownership-sync branch called os.chmod/os.chown
  on dst_entry.path unconditionally. Two problems: (1) for a symlink
  whose target already matched, this had no follow_symlinks=False
  guard and could rewrite an external symlink target's permissions;
  (2) for a regular file, that path may still be hardlinked to the
  same file in the previous, completed snapshot (hardlink_dir() links
  unchanged files in at the start of every backup) - mutating it in
  place silently corrupted every older snapshot sharing the inode.
  Fixed by skipping symlinks entirely (nothing to sync once their
  target matches) and adding _break_shared_hardlink(), a copy-on-write
  step that gives a file its own inode before any in-place metadata
  change.

Also fixed the dir-mtime-restore loop's is_dir() call (same
default-follow_symlinks issue, lower severity since its own operations
were already lstat-based/no-follow).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* Address Copilot review: preserve ownership/timestamps when breaking hardlinks, fix test fd leak

- _break_shared_hardlink() now explicitly restores the original file's
  uid/gid and atime/mtime on the new private copy before it replaces
  the shared one. copy_file() only carries over mode at create time, so
  without this a caller that only meant to update permissions would
  silently also reset ownership/mtime as a side effect of the
  copy-on-write break itself. Also cleans up the temp file on failure
  and uses a less collision-prone temp name.
- Fixed a file descriptor leak in the new hardlink-immutability test
  (bare open(...).read() without a context manager)
- Strengthened the same test to assert mtime survives the break

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

---------

Co-authored-by: Maxim Shulga <maxim.shulga@alludo.com>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
authored and GitHub committed 2026-08-03 23:08:24 +00:00
1 parent fd613edad0
commit cc61aed10c
4 files changed
+173 -9

No files matched your search

+69 -5
View File
@@ -8,6 +8,7 @@ import logging
import os
import subprocess
import sys
import uuid
from typing import Iterable, Tuple, Union
_lg = logging.getLogger(__name__)
@@ -243,16 +244,59 @@ def copy_file(src, dst):
pass
def _break_shared_hardlink(path: str):
"""
Give `path` a private inode if it currently shares one with another
file (nlink > 1), so a subsequent in-place metadata change (chmod,
chown, utime, flags) can never mutate that other file. Files copied
into a staging directory via hardlink_dir() start out hardlinked to
the previous snapshot; this is the copy-on-write step that keeps
that previous, completed snapshot immutable.
The break itself must be metadata-neutral: copy_file() only carries
over the mode at file-creation time, not ownership or timestamps, so
the new private copy is explicitly restored to the original's full
stat before it replaces `path` - otherwise a caller that only meant
to update permissions would silently also reset ownership/mtime as a
side effect of this implementation detail.
"""
st = os.lstat(path)
if st.st_nlink <= 1:
return
tmp_path = "%s.cow-%d-%s" % (path, os.getpid(), uuid.uuid4().hex[:8])
try:
copy_file(path, tmp_path)
try:
os.chown(tmp_path, st.st_uid, st.st_gid)
except PermissionError:
_lg.debug("Cannot preserve ownership while breaking hardlink "
"(not root): %s", path)
os.chmod(tmp_path, st.st_mode)
os.utime(tmp_path, (st.st_atime, st.st_mtime))
os.replace(tmp_path, path)
except OSError:
try:
os.unlink(tmp_path)
except OSError:
pass
raise
def copy_direntry(entry: Union[os.DirEntry, PseudoDirEntry], dst_path):
""" Non-recursive DirEntry (file/dir/symlink) copy. """
src_stat = entry.stat(follow_symlinks=False)
if entry.is_dir():
os.mkdir(dst_path)
elif entry.is_symlink():
# Classify with lstat semantics and check symlink-ness first: is_dir()
# (and is_file()) default to follow_symlinks=True, so a symlink
# pointing at a directory would otherwise be misclassified as a
# directory here and get an empty directory created in its place,
# silently losing the symlink and its target.
if entry.is_symlink():
link_target = os.readlink(entry.path)
os.symlink(link_target, dst_path)
elif entry.is_dir(follow_symlinks=False):
os.mkdir(dst_path)
else:
copy_file(entry.path, dst_path)
@@ -416,6 +460,26 @@ def rsync(src_dir,
yield rel_path, Actions.ERROR, str(exc)
continue
# Symlinks reaching here already have a matching target (handled
# above), and applying chmod/chown to a symlink path without
# follow_symlinks=False support would silently modify whatever
# the symlink points to - including a target outside the backup
# entirely. There's nothing meaningful left to sync for them.
if src_entry.is_symlink():
continue
# A regular file reaching here is unchanged in content, but may
# still be hardlinked to the same file in the previous, supposedly
# immutable snapshot (hardlink_dir() links unchanged files in at
# the start of every backup to save space). Break that link
# before mutating permissions/ownership in place, or the change
# would silently apply to every older snapshot sharing the inode.
if src_entry.is_file(follow_symlinks=False):
if src_stat.st_mode != dst_stat.st_mode or (
src_stat.st_uid != dst_stat.st_uid
or src_stat.st_gid != dst_stat.st_gid):
_break_shared_hardlink(dst_entry.path)
# update permissions and ownership
if src_stat.st_mode != dst_stat.st_mode:
_lg.debug("Rsync, updating permissions: %s", rel_path)
@@ -443,7 +507,7 @@ def rsync(src_dir,
# restore dir mtimes in dst, updated by updating files
for src_entry in scantree(src_root_abs, dir_first=True):
if not src_entry.is_dir():
if not src_entry.is_dir(follow_symlinks=False):
continue
rel_path = src_entry.path[len(src_root_abs) + 1:]
dst_path = os.path.join(dst_root_abs, rel_path)