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

+74
View File
@@ -635,3 +635,77 @@ class TestRsyncExt:
assert results[2][0] == "config.json"
assert results[3][0] == "existing.txt"
assert results[4][0] == "old/"
class TestRsyncHardlinkImmutability:
"""
Regression tests: rsync() is run against a staging directory that
was populated by hardlinking in the previous, completed snapshot
(curateipsum.backup.hardlink_dir). Any in-place metadata change made
while updating staging must never mutate that previous snapshot's
files via the shared inode.
"""
def test_permission_change_does_not_mutate_shared_hardlink(
self, common_fs_dirs, tmp_path
):
src_dir, dst_dir = common_fs_dirs
# Independent source file with new content/mtime but the SAME
# eventual permissions the old snapshot file already has - only
# the fallback "update permissions and ownership" branch should
# fire, not a full rewrite (which would already be safe via
# rm+recreate).
src_fpath = create_file(str(src_dir))
os.chmod(src_fpath, 0o600)
# Simulate staging (dst_dir) having been hardlinked in from the
# previous, completed snapshot: a separate directory tree outside
# dst_dir, sharing an inode with the staging file, with content
# and mtime matching src (so only permissions differ) but the old
# mode.
dst_fpath = os.path.join(str(dst_dir),
relpath(src_fpath, str(src_dir), str(dst_dir)))
prior_snapshot_fpath = str(tmp_path / "prior_snapshot")
with open(src_fpath) as src_f:
src_content = src_f.read()
with open(prior_snapshot_fpath, "w") as f:
f.write(src_content)
os.chmod(prior_snapshot_fpath, 0o644)
src_stat = os.lstat(src_fpath)
os.utime(prior_snapshot_fpath, (src_stat.st_atime, src_stat.st_mtime))
os.link(prior_snapshot_fpath, dst_fpath)
prior_mode_before = os.lstat(prior_snapshot_fpath).st_mode
dst_mtime_before = os.lstat(dst_fpath).st_mtime
assert os.lstat(dst_fpath).st_ino == os.lstat(
prior_snapshot_fpath).st_ino # sanity: genuinely shared
all(fs.rsync(str(src_dir), str(dst_dir)))
assert os.lstat(dst_fpath).st_mode == os.lstat(src_fpath).st_mode
assert os.lstat(prior_snapshot_fpath).st_mode == prior_mode_before
# dst must now have its own inode, no longer shared
assert os.lstat(dst_fpath).st_ino != os.lstat(
prior_snapshot_fpath).st_ino
# breaking the hardlink must not, as a side effect, reset mtime:
# rsync only intended to change permissions here (content/mtime
# already matched), and uid/gid are identical for this test user
# so the chown branch never fires to "accidentally" fix ownership
assert os.lstat(dst_fpath).st_mtime == dst_mtime_before
def test_symlink_metadata_never_touches_external_target(
self, common_fs_dirs, tmp_path
):
src_dir, dst_dir = common_fs_dirs
external_target = tmp_path / "external_target"
external_target.mkdir()
(external_target / "canary").write_text("do-not-touch")
external_mode_before = os.lstat(str(external_target)).st_mode
link_name = "a_symlink"
os.symlink(str(external_target), os.path.join(str(src_dir), link_name))
os.symlink(str(external_target), os.path.join(str(dst_dir), link_name))
all(fs.rsync(str(src_dir), str(dst_dir)))
assert os.lstat(str(external_target)).st_mode == external_mode_before
assert (external_target / "canary").read_text() == "do-not-touch"