--- /dev/null
+#!/usr/bin/env python3
+"""Coverage of -A (acls) at depth.
+
+acls_test.py exercises a shallow tree; this companion sets a distinctive POSIX
+ACL on a file AND a directory at every level of a >=3-deep tree and checks that
+-A reproduces them (the ACL is applied per entry, on a name whose parent chain
+the resolver restructure rewrites).
+"""
+
+import os
+import shutil
+import subprocess
+
+from rsyncfns import (
+ FROMDIR, TODIR,
+ make_tree, rmtree, run_rsync, test_fail, test_skipped,
+ walk_dirs, walk_files,
+)
+
+vv = run_rsync('-VV', check=True, capture_output=True).stdout
+if '"ACLs": true' not in vv:
+ test_skipped("rsync built without ACL support")
+if not (shutil.which('setfacl') and shutil.which('getfacl')):
+ test_skipped("setfacl/getfacl not available")
+
+src = FROMDIR
+rmtree(src)
+rmtree(TODIR)
+make_tree(src, depth=3)
+
+entries = [p.relative_to(src) for p in (walk_dirs(src) + walk_files(src))]
+entries.sort()
+
+# Grant uid 0 an explicit r-x ACL on every entry (valid for a non-root owner to
+# set on its own files; needs no extra accounts).
+for rel in entries:
+ r = subprocess.run(['setfacl', '-m', 'u:0:r-x', str(src / rel)])
+ if r.returncode != 0:
+ test_skipped("filesystem does not support setting ACLs")
+
+
+def getfacl(path):
+ # Strip the comment header (# file:/# owner:/# group:) so the comparison
+ # is path-independent; this getfacl doesn't accept the GNU -c flag.
+ out = subprocess.check_output(['getfacl', str(path)], text=True,
+ stderr=subprocess.DEVNULL)
+ return ''.join(ln for ln in out.splitlines(keepends=True)
+ if not ln.startswith('#'))
+
+
+run_rsync('-aA', f'{src}/', f'{TODIR}/')
+
+for rel in entries:
+ want = getfacl(src / rel)
+ got = getfacl(TODIR / rel)
+ # getfacl renders uid 0 as "root"; accept either spelling.
+ if 'user:root:r-x' not in got and 'user:0:r-x' not in got:
+ test_fail(f"-A did not reproduce the named-user ACL on {rel}:\n{got}")
+ if want != got:
+ test_fail(f"-A: ACL of {rel} differs\n--- source ---\n{want}"
+ f"--- dest ---\n{got}")
+
+print("acls-depth: -A reproduced a POSIX ACL on every entry at depth")
--- /dev/null
+#!/usr/bin/env python3
+"""Coverage of -p (perms), -t (times) and --chmod at depth.
+
+Each attribute is set distinctively on a file AND a directory at every level of
+a >=3-deep tree, then checked per entry after the transfer -- the metadata is
+applied as a single-component operation on an entry whose parent chain the
+resolver restructure rewrites, so it must be verified deep, not just at the
+root.
+"""
+
+import os
+
+from rsyncfns import (
+ FROMDIR, TODIR,
+ assert_mode, assert_mtime_close, make_tree, rmtree, run_rsync, test_fail,
+ walk_dirs, walk_files,
+)
+
+src = FROMDIR
+FILE_MODE = 0o640
+DIR_MODE = 0o750
+BASE_MTIME = 1_400_000_000 # a fixed, clearly-old timestamp
+
+
+def seed():
+ rmtree(src)
+ rmtree(TODIR)
+ make_tree(src, depth=3)
+ for i, f in enumerate(walk_files(src)):
+ os.chmod(f, FILE_MODE)
+ os.utime(f, (BASE_MTIME + i * 100, BASE_MTIME + i * 100))
+ for d in walk_dirs(src):
+ os.chmod(d, DIR_MODE)
+
+
+# --- -p preserves exact file and directory modes at every level -------------
+seed()
+run_rsync('-rlpt', f'{src}/', f'{TODIR}/')
+for f in walk_files(src):
+ assert_mode(TODIR / f.relative_to(src), FILE_MODE, label=f'-p file {f.name}')
+for d in walk_dirs(src):
+ assert_mode(TODIR / d.relative_to(src), DIR_MODE, label=f'-p dir {d.name}')
+
+# --- -t preserves file mtimes at every level --------------------------------
+for f in walk_files(src):
+ rel = f.relative_to(src)
+ assert_mtime_close(TODIR / rel, f.stat().st_mtime, label=f'-t {rel}')
+
+# --- --chmod rewrites modes at every level ----------------------------------
+seed()
+run_rsync('-a', '--chmod=D710,F600', f'{src}/', f'{TODIR}/')
+for f in walk_files(src):
+ assert_mode(TODIR / f.relative_to(src), 0o600, label=f'--chmod file {f.name}')
+for d in walk_dirs(src):
+ assert_mode(TODIR / d.relative_to(src), 0o710, label=f'--chmod dir {d.name}')
+
+print("metadata-depth: -p / -t / --chmod verified per entry at depth")
--- /dev/null
+#!/usr/bin/env python3
+"""Coverage of -O (--omit-dir-times) and -J (--omit-link-times) at depth.
+
+-O preserves file mtimes but leaves directory mtimes alone; -J does the same
+for symlinks. Verify the distinction deep in the tree: the preserved entries
+match the source, the omitted ones do not.
+"""
+
+import os
+
+from rsyncfns import (
+ FROMDIR, TODIR,
+ assert_mtime_close, make_tree, rmtree, run_rsync, test_fail,
+ walk_dirs, walk_files,
+)
+
+src = FROMDIR
+OLD = 1_400_000_000
+
+
+def seed():
+ rmtree(src)
+ rmtree(TODIR)
+ make_tree(src, depth=3)
+ for p in list(walk_files(src)) + list(walk_dirs(src)):
+ os.utime(p, (OLD, OLD))
+
+
+# --- -O: file mtimes preserved, directory mtimes omitted --------------------
+seed()
+run_rsync('-rlt', '-O', f'{src}/', f'{TODIR}/')
+for f in walk_files(src):
+ assert_mtime_close(TODIR / f.relative_to(src), OLD, label=f'-O file {f.name}')
+omitted = False
+for d in walk_dirs(src):
+ if abs(os.stat(TODIR / d.relative_to(src)).st_mtime - OLD) > 1:
+ omitted = True # at least one dir keeps a fresh (now) mtime
+if not omitted:
+ test_fail("-O did not omit directory times -- every dir mtime matched the "
+ "source")
+
+# --- -J: symlink mtime omitted (where the platform records symlink mtimes) --
+seed()
+deep = os.path.join('d1', 'd2', 'd3')
+os.symlink('f3', src / deep / 'sl')
+try:
+ os.utime(src / deep / 'sl', (OLD, OLD), follow_symlinks=False)
+except (NotImplementedError, OSError):
+ print("omit-times: -J check skipped (no symlink-mtime support here)")
+else:
+ run_rsync('-rlt', '-J', f'{src}/', f'{TODIR}/')
+ dst = TODIR / deep / 'sl'
+ if not os.path.islink(dst):
+ test_fail("-J test: symlink was not copied")
+ if abs(os.lstat(dst).st_mtime - OLD) <= 1:
+ test_fail("-J did not omit the symlink mtime")
+
+print("omit-times: -O omits dir times, -J omits link times (at depth)")
--- /dev/null
+#!/usr/bin/env python3
+"""Coverage of --groupmap / --chown / --usermap / -o at depth.
+
+Remapping is applied to a file AND a directory at every level. As root we can
+remap to an arbitrary uid/gid (root may always chown/chgrp), so the uid side is
+covered too. As a normal user we can still remap the group to a secondary group
+we belong to; the uid side then needs root and is skipped.
+"""
+
+import os
+
+from rsyncfns import (
+ FROMDIR, TODIR,
+ get_rootuid, get_testuid, make_tree, rmtree, rsync_getgroups, run_rsync,
+ test_fail, test_skipped, walk_dirs, walk_files,
+)
+
+src = FROMDIR
+is_root = get_testuid() == get_rootuid()
+prim = os.getgid()
+
+
+def seed():
+ rmtree(src)
+ rmtree(TODIR)
+ make_tree(src, depth=3)
+ entries = [p.relative_to(src) for p in (walk_dirs(src) + walk_files(src))]
+ # Normalise the source group so a prim->target remap is observable.
+ for rel in entries:
+ if os.stat(src / rel).st_gid != prim:
+ os.chown(src / rel, -1, prim)
+ return entries
+
+
+def assert_all(entries, *, gid=None, uid=None, label=''):
+ for rel in entries:
+ st = os.stat(TODIR / rel)
+ if gid is not None and st.st_gid != gid:
+ test_fail(f"{label}: group of {rel} is {st.st_gid}, expected {gid}")
+ if uid is not None and st.st_uid != uid:
+ test_fail(f"{label}: owner of {rel} is {st.st_uid}, expected {uid}")
+
+
+if is_root:
+ # Root may assign any numeric id (it need not exist); pick targets that
+ # differ from the source's ids so the remap is observable.
+ target_gid = 1 if prim == 0 else 0
+ target_uid = 1 if get_testuid() == 0 else 0
+
+ entries = seed()
+ run_rsync('-a', f'--groupmap={prim}:{target_gid}', f'{src}/', f'{TODIR}/')
+ assert_all(entries, gid=target_gid, label='--groupmap (root)')
+
+ entries = seed()
+ run_rsync('-a', f'--chown=:{target_gid}', f'{src}/', f'{TODIR}/')
+ assert_all(entries, gid=target_gid, label='--chown group (root)')
+
+ entries = seed()
+ run_rsync('-a', f'--usermap=*:{target_uid}', f'{src}/', f'{TODIR}/')
+ assert_all(entries, uid=target_uid, label='--usermap (root)')
+
+ entries = seed()
+ run_rsync('-a', f'--chown={target_uid}:{target_gid}', f'{src}/', f'{TODIR}/')
+ assert_all(entries, uid=target_uid, gid=target_gid,
+ label='--chown user:group (root)')
+ print("ownership-depth: --groupmap/--chown/--usermap verified at depth (root)")
+else:
+ groups = [int(g) for g in rsync_getgroups()]
+ secs = [g for g in groups if g != prim]
+ if not secs:
+ test_skipped("non-root with no secondary group to remap to")
+ sec = secs[0]
+
+ entries = seed()
+ run_rsync('-a', f'--groupmap={prim}:{sec}', f'{src}/', f'{TODIR}/')
+ assert_all(entries, gid=sec, label='--groupmap')
+
+ entries = seed()
+ run_rsync('-a', f'--chown=:{sec}', f'{src}/', f'{TODIR}/')
+ assert_all(entries, gid=sec, label='--chown group')
+ print("ownership-depth: --groupmap/--chown group remap verified at depth "
+ "(-o/--usermap user remap needs root -- skipped)")
--- /dev/null
+#!/usr/bin/env python3
+"""Coverage of -S (--sparse) at depth.
+
+A file with a large hole, several directory levels deep, should arrive sparse
+(its allocated blocks much smaller than its apparent size) and byte-identical
+when copied with -S.
+
+We do NOT assert the converse (that a plain copy fills the hole): whether a
+zero-run is stored as a hole when -S is absent is the filesystem's choice, not
+rsync's -- ZFS/APFS transparently sparsify zero blocks, so a --no-sparse copy
+can legitimately stay sparse there.
+"""
+
+import os
+
+from rsyncfns import (
+ FROMDIR, TODIR,
+ assert_same, makepath, rmtree, run_rsync, test_fail, test_skipped,
+)
+
+src = FROMDIR
+deep = os.path.join('d1', 'd2', 'd3', 'holey')
+SIZE = 4 * 1024 * 1024
+
+
+def make_sparse(path):
+ with open(path, 'wb') as f:
+ f.write(b'head')
+ f.seek(SIZE - 4)
+ f.write(b'tail')
+
+
+def allocated(path):
+ return os.stat(path).st_blocks * 512
+
+
+rmtree(src)
+rmtree(TODIR)
+makepath(src / 'd1' / 'd2' / 'd3')
+make_sparse(src / deep)
+
+# Confirm the source filesystem actually made a sparse file; otherwise the
+# whole premise (and any dest comparison) is meaningless here.
+if allocated(src / deep) >= SIZE:
+ test_skipped("source filesystem did not create a sparse file")
+
+# --- with -S the hole is preserved at the destination -----------------------
+run_rsync('-a', '-S', f'{src}/', f'{TODIR}/')
+assert_same(TODIR / deep, src / deep, label='-S content')
+if allocated(TODIR / deep) >= SIZE:
+ test_fail(f"-S did not preserve the hole at depth "
+ f"(allocated {allocated(TODIR / deep)} for a {SIZE}-byte file)")
+
+# --- a plain copy reproduces the content too (allocation is FS-defined) ------
+rmtree(TODIR)
+run_rsync('-a', '--no-sparse', f'{src}/', f'{TODIR}/')
+assert_same(TODIR / deep, src / deep, label='no-sparse content')
+
+print("sparse: -S preserves a deep hole; content correct with and without it")
--- /dev/null
+#!/usr/bin/env python3
+"""Coverage of -X (xattrs) at depth.
+
+xattrs_test.py exercises a shallow tree; this companion sets a distinctive user
+xattr on a file AND a directory at every level of a >=3-deep tree and checks
+that -X reproduces them all (the xattr is applied per entry, on a name whose
+parent chain the resolver restructure rewrites).
+"""
+
+import os
+import sys
+
+from rsyncfns import (
+ FROMDIR, TODIR,
+ make_tree, rmtree, run_rsync, test_fail, test_skipped,
+ walk_dirs, walk_files, xattr_dump, xattr_set, xattrs_supported,
+)
+
+if not xattrs_supported():
+ test_skipped("rsync built without xattr support (or no xattr tooling here)")
+
+src = FROMDIR
+rmtree(src)
+rmtree(TODIR)
+make_tree(src, depth=3)
+
+entries = [p.relative_to(src) for p in (walk_dirs(src) + walk_files(src))]
+entries.sort()
+
+os.chdir(src)
+try:
+ for i, rel in enumerate(entries):
+ xattr_set('depth', f'value-{i}-{rel}', str(rel))
+except OSError as e:
+ test_skipped(f"unable to set an xattr on this filesystem: {e}")
+
+want = xattr_dump(*[str(r) for r in entries])
+
+run_rsync('-aX', '-f-x_system.*', '-f-x_security.*', '--super',
+ f'{src}/', f'{TODIR}/')
+
+os.chdir(TODIR)
+got = xattr_dump(*[str(r) for r in entries])
+
+if got != want:
+ from difflib import unified_diff
+ sys.stdout.write(''.join(unified_diff(
+ want.splitlines(keepends=True), got.splitlines(keepends=True),
+ fromfile='source', tofile='dest')))
+ test_fail("xattrs differ between source and destination at depth")
+
+print("xattrs-depth: -X reproduced a user xattr on every entry at depth")