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

+26
View File
@@ -285,6 +285,32 @@ class TestCopyDirEntry:
dst_stat = os.lstat(dst_path)
assert dst_stat.st_mode == src_stat.st_mode
def test_copy_symlink_to_directory_stays_a_symlink(self, tmp_path):
"""
Regression: is_dir()/is_file() default to follow_symlinks=True,
so a symlink pointing at a directory must be classified via
is_symlink() first, or it gets misclassified as a directory and
an empty real directory is created in its place - silently
destroying the symlink and losing its target entirely.
"""
target_dir = os.path.join(str(tmp_path), "target_dir")
src_link = os.path.join(str(tmp_path), "source_link")
dst_link = os.path.join(str(tmp_path), "dest_link")
os.mkdir(target_dir)
with open(os.path.join(target_dir, "canary"), "w") as f:
f.write("content")
os.symlink(target_dir, src_link)
with os.scandir(str(tmp_path)) as it:
for entry in it:
if entry.name == "source_link":
fs.copy_direntry(entry, dst_link)
break
assert os.path.islink(dst_link)
assert os.readlink(dst_link) == target_dir
def test_copy_symlink_preserves_times_if_supported(self, tmp_path):
"""Test that symlink timestamps are preserved (OS-dependent)"""
target_path = os.path.join(str(tmp_path), "target.txt")