]> git.ipfire.org Git - thirdparty/rsync.git/commitdiff
testsuite: metadata preservation coverage at depth
authorAndrew Tridgell <andrew@tridgell.net>
Sat, 23 May 2026 21:54:28 +0000 (07:54 +1000)
committerAndrew Tridgell <andrew@tridgell.net>
Sun, 24 May 2026 02:31:52 +0000 (12:31 +1000)
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) <noreply@anthropic.com>
testsuite/acls-depth_test.py [new file with mode: 0644]
testsuite/metadata-depth_test.py [new file with mode: 0644]
testsuite/omit-times_test.py [new file with mode: 0644]
testsuite/ownership-depth_test.py [new file with mode: 0644]
testsuite/sparse_test.py [new file with mode: 0644]
testsuite/xattrs-depth_test.py [new file with mode: 0644]

diff --git a/testsuite/acls-depth_test.py b/testsuite/acls-depth_test.py
new file mode 100644 (file)
index 0000000..308be76
--- /dev/null
@@ -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 (file)
index 0000000..f8ba7a0
--- /dev/null
@@ -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 (file)
index 0000000..a532c85
--- /dev/null
@@ -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 (file)
index 0000000..6facae2
--- /dev/null
@@ -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 (file)
index 0000000..898ed8c
--- /dev/null
@@ -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 (file)
index 0000000..c090286
--- /dev/null
@@ -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")