Add an explicit one-filesystem policy to backups (#18)

* Add an explicit one-filesystem policy to backups

scantree() gains an opt-in one_filesystem flag: when a subdirectory's
device differs from the walk root's (a mount point), its contents are
excluded from the walk. The mount-point directory itself is still
yielded, so it appears as an empty directory in the backup rather than
vanishing silently - matching rsync's --one-file-system behavior.

Wired into rsync()'s two source-tree walks so backups never silently
pull in content from a bind mount or other filesystem mounted under
the source tree.

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

* Fix one-filesystem policy: symlinked root and external rsync parity

- scantree() computed the walk root's device with os.lstat(), but
  scandir() follows the root path if it's a symlink. A symlinked root
  would compute the symlink's own device instead of the target
  directory's, making every real subdirectory look cross-device and
  get its contents wrongly excluded. Use os.stat() for the root only.
- rsync_ext() (the external-rsync backend) didn't pass
  --one-file-system, so backups using external_rsync=True still
  crossed mount points despite the pure-Python path now refusing to.

Addresses GitHub Copilot review feedback on PR #18.

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-06 03:38:16 +00:00
1 parent e3c8acf954
commit 91b7bb133c
4 files changed
+109 -5

No files matched your search

+28 -4
View File
@@ -181,6 +181,7 @@ def rsync_ext(src, dst, dry_run=False) -> Iterable[Tuple[str, Actions, str]]:
# rsync_args.append("--inplace")
rsync_args.append("--whole-file")
rsync_args.append("--human-readable")
rsync_args.append("--one-file-system")
rsync_args.append("--delete-during")
rsync_args.append("--itemize-changes")
rsync_args.append(f"{src}/")
@@ -219,17 +220,38 @@ def rsync_ext(src, dst, dry_run=False) -> Iterable[Tuple[str, Actions, str]]:
process.wait()
def scantree(path, dir_first=True) -> Iterable[os.DirEntry]:
def scantree(path, dir_first=True,
one_filesystem=False, _root_dev=None) -> Iterable[os.DirEntry]:
"""
Recursively yield DirEntry objects (dir/file/symlink) for given directory.
:param one_filesystem: if True, don't descend into a subdirectory that
lives on a different device than `path` (a mount point). The
directory itself is still yielded, so it appears as an empty
directory in the walk - only its contents are excluded. Matches
rsync's --one-file-system behavior.
"""
if one_filesystem and _root_dev is None:
# os.stat(), not os.lstat(): scandir() follows `path` if it's a
# symlink, so the root device must be the resolved directory's,
# not the symlink's own.
_root_dev = os.stat(path).st_dev
entry: os.DirEntry
with os.scandir(path) as scan_it:
for entry in scan_it:
if entry.is_dir(follow_symlinks=False):
if (one_filesystem
and os.lstat(entry.path).st_dev != _root_dev):
_lg.warning(
"Scantree, excluding mount point contents: %s",
entry.path)
yield entry
continue
if dir_first:
yield entry
yield from scantree(entry.path, dir_first)
yield from scantree(entry.path, dir_first,
one_filesystem, _root_dev)
if not dir_first:
yield entry
else:
@@ -445,7 +467,8 @@ def rsync(src_dir,
# Create source map {rel_path: dir_entry}
src_files_map = {
ent.path[len(src_root_abs) + 1:]: ent for ent in scantree(src_root_abs)
ent.path[len(src_root_abs) + 1:]: ent
for ent in scantree(src_root_abs, one_filesystem=True)
}
# process dst tree
@@ -613,7 +636,8 @@ def rsync(src_dir,
yield rel_path, Actions.ERROR, str(exc)
# restore dir mtimes in dst, updated by updating files
for src_entry in scantree(src_root_abs, dir_first=True):
for src_entry in scantree(src_root_abs, dir_first=True,
one_filesystem=True):
if not src_entry.is_dir(follow_symlinks=False):
continue
rel_path = src_entry.path[len(src_root_abs) + 1:]