From: Andrew Tridgell Date: Sat, 23 May 2026 21:54:28 +0000 (+1000) Subject: testsuite: metadata preservation coverage at depth X-Git-Url: http://git.ipfire.org/gitweb/index.cgi?a=commitdiff_plain;h=273b9f265f1b0fb16e003139246157126205fe83;p=thirdparty%2Frsync.git testsuite: metadata preservation coverage at depth Set each attribute distinctively on a file AND a directory at every level of a >=3-deep tree and verify it per entry after transfer (metadata is applied as a single-component op on an entry whose parent chain the resolver restructure rewrites): metadata-depth -p preserves exact file/dir modes; -t preserves file mtimes; --chmod=D710,F600 rewrites them. omit-times -O omits directory times (files still preserved); -J omits symlink times. sparse -S preserves a deep file's hole (allocated << size); --no-sparse fills it. xattrs-depth -X reproduces a user xattr on every entry (gated on xattr support). acls-depth -A reproduces a POSIX ACL on every entry (gated on ACL support + setfacl/getfacl). ownership-depth --groupmap and --chown=:GROUP remap the group of every entry (non-root, to a secondary group); -o/--usermap gated on root. All green on master and under --protocol=29/30. Co-Authored-By: Claude Opus 4.7 (1M context) --- diff --git a/testsuite/acls-depth_test.py b/testsuite/acls-depth_test.py new file mode 100644 index 00000000..308be765 --- /dev/null +++ b/testsuite/acls-depth_test.py @@ -0,0 +1,63 @@ +#!/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") diff --git a/testsuite/metadata-depth_test.py b/testsuite/metadata-depth_test.py new file mode 100644 index 00000000..f8ba7a07 --- /dev/null +++ b/testsuite/metadata-depth_test.py @@ -0,0 +1,57 @@ +#!/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") diff --git a/testsuite/omit-times_test.py b/testsuite/omit-times_test.py new file mode 100644 index 00000000..a532c85f --- /dev/null +++ b/testsuite/omit-times_test.py @@ -0,0 +1,58 @@ +#!/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)") diff --git a/testsuite/ownership-depth_test.py b/testsuite/ownership-depth_test.py new file mode 100644 index 00000000..6facae25 --- /dev/null +++ b/testsuite/ownership-depth_test.py @@ -0,0 +1,82 @@ +#!/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)") diff --git a/testsuite/sparse_test.py b/testsuite/sparse_test.py new file mode 100644 index 00000000..898ed8c9 --- /dev/null +++ b/testsuite/sparse_test.py @@ -0,0 +1,59 @@ +#!/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") diff --git a/testsuite/xattrs-depth_test.py b/testsuite/xattrs-depth_test.py new file mode 100644 index 00000000..c090286c --- /dev/null +++ b/testsuite/xattrs-depth_test.py @@ -0,0 +1,52 @@ +#!/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")