* 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>