From: Andrew Tridgell Date: Sat, 23 May 2026 21:51:19 +0000 (+1000) Subject: testsuite: structure / recursion / link coverage at depth X-Git-Url: http://git.ipfire.org/cgi-bin/gitweb.cgi?a=commitdiff_plain;h=0d546ee3b4cbc58d1c2d919e4a9733d27b8fff10;p=thirdparty%2Frsync.git testsuite: structure / recursion / link coverage at depth Cover the structure and link options at >=3 levels and across directories, asserting each option's specific effect: links -l keeps a symlink, -L dereferences it, -k follows a directory symlink -- all on a symlink several levels deep. dirs -d copies the top layer (file + empty dir) without recursing. prune-empty-dirs -m drops empty chains and chains emptied by an exclude, keeps populated ones. hardlinks-deep -H preserves a hard link whose names live in different directories at depth; without -H they become separate inodes. delete-deep --delete removes a deep extraneous file/subtree; the four delete-timing variants agree; --max-delete caps deletions; --existing / --ignore-existing select/skip correctly. relative-implied -R mirrors an implied directory's mode at depth; --no-implied-dirs does not (proto 30+). Green on master and under --protocol=29/30 (the --no-implied-dirs sub-case is gated to protocol >= 30, where multi-component sender paths are accepted). Co-Authored-By: Claude Opus 4.7 (1M context) --- diff --git a/testsuite/delete-deep_test.py b/testsuite/delete-deep_test.py new file mode 100644 index 00000000..3525c2e3 --- /dev/null +++ b/testsuite/delete-deep_test.py @@ -0,0 +1,85 @@ +#!/usr/bin/env python3 +"""Coverage of the --delete family at depth, plus --max-delete, --existing and +--ignore-existing. + +delete_test.py covers --del dry-run output, --remove-source-files and per-dir +protect filters; this companion asserts the concrete outcome of deletion deep +in the tree (the subtree walk the resolver restructure touches) and the +controls that bound or invert it. +""" + +import os + +from rsyncfns import ( + FROMDIR, TODIR, + assert_not_exists, assert_same, make_tree, makepath, rmtree, run_rsync, + test_fail, walk_files, +) + +src = FROMDIR + + +def seed_src(): + rmtree(src) + make_tree(src, depth=3) + return [p.relative_to(src) for p in walk_files(src)] + + +def fresh_dest(): + rmtree(TODIR) + run_rsync('-a', f'{src}/', f'{TODIR}/') + + +# --- --delete removes a deep extraneous file and subtree -------------------- +rels = seed_src() +fresh_dest() +makepath(TODIR / 'd1' / 'd2' / 'extra') +(TODIR / 'd1' / 'd2' / 'extra' / 'junk').write_text("x\n") +(TODIR / 'd1' / 'd2' / 'orphan').write_text("y\n") +run_rsync('-a', '--delete', f'{src}/', f'{TODIR}/') +assert_not_exists(TODIR / 'd1' / 'd2' / 'extra', label='--delete deep dir') +assert_not_exists(TODIR / 'd1' / 'd2' / 'orphan', label='--delete deep file') +for rel in rels: + assert_same(TODIR / rel, src / rel, label=f'--delete kept {rel}') + +# --- every delete-timing variant yields the same deep deletion -------------- +for variant in ('--delete-before', '--delete-during', '--delete-delay', + '--delete-after'): + fresh_dest() + (TODIR / 'd1' / 'd2' / 'gone.txt').write_text("z\n") + run_rsync('-a', variant, f'{src}/', f'{TODIR}/') + assert_not_exists(TODIR / 'd1' / 'd2' / 'gone.txt', label=f'{variant} deep') + +# --- --max-delete caps the number of deletions ------------------------------ +fresh_dest() +for i in range(5): + (TODIR / f'extra{i}').write_text("e\n") +run_rsync('-a', '--delete', '--max-delete=2', f'{src}/', f'{TODIR}/', + check=False) # rsync exits 25 when the limit is hit +remaining = list(TODIR.glob('extra*')) +if len(remaining) != 3: + test_fail(f"--max-delete=2 should leave 3 of 5 extras, found " + f"{len(remaining)}: {remaining}") + +# --- --existing only updates files already present (creates nothing) --------- +seed_src() +rmtree(TODIR) +makepath(TODIR / 'd1') +(TODIR / 'd1' / 'f1').write_text("old\n") +run_rsync('-a', '--existing', f'{src}/', f'{TODIR}/') +assert_same(TODIR / 'd1' / 'f1', src / 'd1' / 'f1', + label='--existing updated existing deep file') +assert_not_exists(TODIR / 'f0', label='--existing did not create new top file') +assert_not_exists(TODIR / 'd1' / 'd2', label='--existing did not create new dir') + +# --- --ignore-existing skips present files, creates the rest ----------------- +seed_src() +rmtree(TODIR) +makepath(TODIR / 'd1') +(TODIR / 'd1' / 'f1').write_text("KEEP THIS\n") +run_rsync('-a', '--ignore-existing', f'{src}/', f'{TODIR}/') +if (TODIR / 'd1' / 'f1').read_text() != "KEEP THIS\n": + test_fail("--ignore-existing overwrote an existing deep file") +assert_same(TODIR / 'f0', src / 'f0', label='--ignore-existing created new file') + +print("delete-deep: delete family, max-delete, existing/ignore-existing at depth") diff --git a/testsuite/dirs_test.py b/testsuite/dirs_test.py new file mode 100644 index 00000000..3772734a --- /dev/null +++ b/testsuite/dirs_test.py @@ -0,0 +1,33 @@ +#!/usr/bin/env python3 +"""Coverage of -d / --dirs: transfer directories without recursing into them. + +-d copies the entries directly named (or directly inside a trailing-slash +source): regular files at the top level are copied, and a top-level directory +is created as an empty directory -- its contents are NOT transferred. Verify +that on a tree that is several levels deep, only the top layer materialises. +""" + +from rsyncfns import ( + FROMDIR, TODIR, + assert_same, make_tree, rmtree, run_rsync, test_fail, +) + +src = FROMDIR +rmtree(src) +rmtree(TODIR) +make_tree(src, depth=3) # f0 at root; d1/{f1, d2/{f2, d3/f3}} + +run_rsync('-d', f'{src}/', f'{TODIR}/') + +# The top-level file is copied. +assert_same(TODIR / 'f0', src / 'f0', label='-d top-level file') +# The top-level directory is created... +if not (TODIR / 'd1').is_dir(): + test_fail("-d did not create the top-level directory") +# ...but NOT recursed into. +if (TODIR / 'd1' / 'f1').exists(): + test_fail("-d recursed into a directory (f1 should not exist)") +if list((TODIR / 'd1').iterdir()): + test_fail("-d populated the directory; it should be empty") + +print("dirs: -d copies the top layer without recursing") diff --git a/testsuite/hardlinks-deep_test.py b/testsuite/hardlinks-deep_test.py new file mode 100644 index 00000000..def0aa26 --- /dev/null +++ b/testsuite/hardlinks-deep_test.py @@ -0,0 +1,36 @@ +#!/usr/bin/env python3 +"""Coverage of -H across directory boundaries. + +hardlinks_test.py exercises -H on sibling files at the tree root; this +companion checks that -H preserves a hard link whose two names live in +DIFFERENT directories several levels deep (the cross-directory case the +resolver restructure touches), and that without -H the names become +independent inodes. +""" + +from rsyncfns import ( + FROMDIR, TODIR, + assert_hardlinked, assert_not_hardlinked, makepath, rmtree, run_rsync, +) +import os + +src = FROMDIR +a = os.path.join('a', 'aa', 'orig') +b = os.path.join('b', 'bb', 'hardlink') + +rmtree(src) +rmtree(TODIR) +makepath(src / 'a' / 'aa', src / 'b' / 'bb') +(src / a).write_text("shared content across directories\n") +os.link(src / a, src / b) # one inode, two names in different dirs + +# -H preserves the cross-directory hard link. +run_rsync('-aH', f'{src}/', f'{TODIR}/') +assert_hardlinked(TODIR / a, TODIR / b, label='-H cross-dir hardlink') + +# Without -H the two names are copied as independent files. +rmtree(TODIR) +run_rsync('-a', f'{src}/', f'{TODIR}/') +assert_not_hardlinked(TODIR / a, TODIR / b, label='no -H => separate inodes') + +print("hardlinks-deep: -H preserves a cross-directory hard link at depth") diff --git a/testsuite/links_test.py b/testsuite/links_test.py new file mode 100644 index 00000000..f7b412de --- /dev/null +++ b/testsuite/links_test.py @@ -0,0 +1,65 @@ +#!/usr/bin/env python3 +"""Coverage of symlink-handling options -l / -L / -k at depth. + + -l (--links) copy a symlink as a symlink (target preserved); + -L (--copy-links) replace a symlink with the file/dir it points to; + -k (--copy-dirlinks) treat a symlink-to-directory as the real directory. + +(-K --keep-dirlinks -- the receiver-side follow of an in-tree directory +symlink, issue #715 -- is covered by symlink-dirlink-basis_test.py and needs +the secure resolver; here we cover the portable source-side options, all on a +symlink that lives several directory levels deep.) +""" + +import os + +from rsyncfns import ( + FROMDIR, TODIR, + assert_is_symlink, assert_same, make_tree, rmtree, run_rsync, test_fail, +) + +src = FROMDIR +deepdir = os.path.join('d1', 'd2', 'd3') + + +def seed(): + rmtree(src) + rmtree(TODIR) + make_tree(src, depth=3) + os.symlink('f3', src / deepdir / 'sl') # file symlink, deep + (src / deepdir / 'realdir').mkdir() + (src / deepdir / 'realdir' / 'inside').write_text("inside\n") + os.symlink('realdir', src / deepdir / 'dirlink') # dir symlink, deep + + +# --- -l: symlinks preserved as symlinks at depth ---------------------------- +seed() +run_rsync('-rl', f'{src}/', f'{TODIR}/') +assert_is_symlink(TODIR / deepdir / 'sl', target='f3', label='-l file symlink') +assert_is_symlink(TODIR / deepdir / 'dirlink', target='realdir', + label='-l dir symlink') + +# --- -L: symlinks dereferenced into their referents at depth ----------------- +seed() +run_rsync('-rL', f'{src}/', f'{TODIR}/') +if os.path.islink(TODIR / deepdir / 'sl'): + test_fail("-L left a file symlink at depth instead of dereferencing it") +assert_same(TODIR / deepdir / 'sl', src / deepdir / 'f3', label='-L deref file') +if os.path.islink(TODIR / deepdir / 'dirlink'): + test_fail("-L left a dir symlink at depth instead of dereferencing it") +assert_same(TODIR / deepdir / 'dirlink' / 'inside', + src / deepdir / 'realdir' / 'inside', label='-L deref dir') + +# --- -k: only the dir-symlink is followed; the file symlink stays a symlink -- +seed() +run_rsync('-rlk', f'{src}/', f'{TODIR}/') +if os.path.islink(TODIR / deepdir / 'dirlink'): + test_fail("-k left the dir symlink as a symlink") +if not (TODIR / deepdir / 'dirlink').is_dir(): + test_fail("-k did not turn the dir symlink into a real directory") +assert_same(TODIR / deepdir / 'dirlink' / 'inside', + src / deepdir / 'realdir' / 'inside', label='-k dir contents') +assert_is_symlink(TODIR / deepdir / 'sl', target='f3', + label='-k keeps the file symlink') + +print("links: -l preserves, -L dereferences, -k follows dir-symlinks (at depth)") diff --git a/testsuite/prune-empty-dirs_test.py b/testsuite/prune-empty-dirs_test.py new file mode 100644 index 00000000..c53adfbd --- /dev/null +++ b/testsuite/prune-empty-dirs_test.py @@ -0,0 +1,45 @@ +#!/usr/bin/env python3 +"""Coverage of -m / --prune-empty-dirs at depth. + +--prune-empty-dirs drops directory chains that would end up empty at the +destination -- both chains that are empty in the source and chains that become +empty because a filter excluded their only files. Populated chains are kept. +""" + +from rsyncfns import ( + FROMDIR, TODIR, + assert_not_exists, assert_same, makepath, rmtree, run_rsync, +) + +src = FROMDIR + + +def reseed(): + rmtree(src) + rmtree(TODIR) + + +# --- a deep empty chain is pruned; a deep populated chain is kept ------------ +reseed() +makepath(src / 'empty' / 'e1' / 'e2', src / 'full' / 'd1' / 'd2') +(src / 'full' / 'd1' / 'd2' / 'file').write_text("data\n") + +run_rsync('-a', '-m', f'{src}/', f'{TODIR}/') +assert_not_exists(TODIR / 'empty', label='-m pruned an empty chain') +assert_same(TODIR / 'full' / 'd1' / 'd2' / 'file', + src / 'full' / 'd1' / 'd2' / 'file', label='-m kept populated chain') + +# --- a chain emptied by an exclude filter is also pruned -------------------- +reseed() +makepath(src / 'mixed' / 'sub', src / 'onlylogs' / 'sub') +(src / 'mixed' / 'sub' / 'keep.txt').write_text("k\n") +(src / 'mixed' / 'sub' / 'drop.log').write_text("d\n") +(src / 'onlylogs' / 'sub' / 'a.log').write_text("a\n") + +run_rsync('-a', '-m', '--exclude=*.log', f'{src}/', f'{TODIR}/') +assert_same(TODIR / 'mixed' / 'sub' / 'keep.txt', + src / 'mixed' / 'sub' / 'keep.txt', label='-m kept non-empty dir') +assert_not_exists(TODIR / 'onlylogs', + label='-m pruned a dir emptied by an exclude') + +print("prune-empty-dirs: empty and filter-emptied chains pruned, populated kept") diff --git a/testsuite/relative-implied_test.py b/testsuite/relative-implied_test.py new file mode 100644 index 00000000..167d79df --- /dev/null +++ b/testsuite/relative-implied_test.py @@ -0,0 +1,53 @@ +#!/usr/bin/env python3 +"""Coverage of -R implied directories and --no-implied-dirs at depth. + +With -R the directories implied by a source path are recreated at the +destination AND have their attributes mirrored from the source. With +--no-implied-dirs those implied directories are still created as needed but get +default attributes instead of the source's. Verify the distinction on an +implied directory carrying a non-default mode, several levels deep. +""" + +import os +import stat + +from rsyncfns import ( + SCRATCHDIR, TODIR, + assert_mode, assert_same, forced_protocol, makepath, rmtree, run_rsync, + test_fail, +) + +base = SCRATCHDIR / 'rbase' +rmtree(base) +rmtree(TODIR) +makepath(base / 'a' / 'b' / 'c') +os.chmod(base / 'a' / 'b', 0o750) # distinctive mode on an implied dir +(base / 'a' / 'b' / 'c' / 'file').write_text("data\n") + +os.chdir(base / 'a') + +# -R: implied dirs b and c are recreated with the source's attributes. +run_rsync('-aR', 'b/c/file', f'{TODIR}/') +assert_mode(TODIR / 'b', 0o750, label='-R mirrors implied-dir mode') +assert_same(TODIR / 'b' / 'c' / 'file', base / 'a' / 'b' / 'c' / 'file', + label='-R deep file') + +# --no-implied-dirs: implied dir b is created with default (not source) attrs. +# At protocol 29 the generator rejects a multi-component path that has no +# transmitted directory entries ("invalid path from sender"), so this half is +# protocol-30+. +proto = forced_protocol() +if proto is not None and proto < 30: + print(f"relative-implied: protocol {proto} -- skipping --no-implied-dirs " + "(the multi-component path is rejected by the proto-29 generator)") +else: + rmtree(TODIR) + run_rsync('-aR', '--no-implied-dirs', 'b/c/file', f'{TODIR}/') + m = stat.S_IMODE(os.stat(TODIR / 'b').st_mode) + if m == 0o750: + test_fail("--no-implied-dirs unexpectedly mirrored the source mode " + "0750 onto the implied directory") + assert_same(TODIR / 'b' / 'c' / 'file', base / 'a' / 'b' / 'c' / 'file', + label='--no-implied-dirs deep file') + +print("relative-implied: -R mirrors implied-dir attrs; --no-implied-dirs does not")