testsuite: add COVERAGE.md matrix and -u/--force coverage

COVERAGE.md is the living checklist mapping every CLI option (~142) and daemon
parameter (~54) to its test(s), with depth / cross-dir status and remaining
gaps, so the path-resolution restructure can see exactly what is guarded.

update_test.py closes two of the documented gaps: -u/--update (keep a newer
destination, update an older one) and --force (replace a non-empty destination
directory with a file), both at depth.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Andrew Tridgell
2026-05-24 08:14:39 +10:00
parent 1d5b5ab83a
commit edf298ace5
2 changed files with 268 additions and 0 deletions

206
testsuite/COVERAGE.md Normal file
View File

@@ -0,0 +1,206 @@
# rsync option / daemon-parameter test coverage matrix
Living checklist for the test-coverage effort that precedes the path-handling
restructure of rsync's path resolution. The restructure rewrites parent-directory
resolution for essentially every option, so the goal here is a regression net
that exercises each option **at directory depth** (≥3 levels) and, where the
option spans trees, **across directory boundaries**, asserting the *specific
property* the option controls — not just `dest == src`.
How to read the columns:
* **test(s)** — the `testsuite/*_test.py` that exercise the option. Tests added
by this effort are marked `*new*`.
* **depth** — Y = asserted on entries ≥3 levels deep; `~` = exercised only at/near
the tree root; `n/a` = not a path-resolution option.
* **x-dir** — Y = exercised with the relevant aux tree (temp/backup/dest/partial)
**outside** the main tree; `—` = not a cross-directory option.
* **gap** — what is still missing.
Status legend: ✓ property asserted · `~` shallow / by an existing ported test ·
✗ no coverage.
---
## Command-line options
### Recursion / structure
| option | test(s) | depth | x-dir | notes / gap |
|---|---|---|---|---|
| -a, --archive | (all) | Y | — | ✓ ubiquitous |
| -r, --recursive | hands, delete-deep*new* | Y | — | ✓ |
| -R, --relative | relative, relative-implied*new* | Y | — | ✓ implied-dir attrs at depth |
| --no-implied-dirs | relative-implied*new* | Y | — | ✓ (proto 30+; proto 29 rejects multi-component path) |
| --inc-recursive / --no-inc-recursive | hardlinks | Y | — | `~` exercised, not isolated |
| -d, --dirs | dirs*new* | Y | — | ✓ no-recurse top layer |
| --old-dirs / --old-d | — | — | — | ✗ |
| -m, --prune-empty-dirs | prune-empty-dirs*new* | Y | — | ✓ incl. filter-emptied chains |
### Links
| option | test(s) | depth | x-dir | notes / gap |
|---|---|---|---|---|
| -l, --links | links*new*, symlink-ignore | Y | — | ✓ |
| -L, --copy-links | links*new* | Y | — | ✓ deref file+dir |
| -k, --copy-dirlinks | links*new* | Y | — | ✓ follow dir-symlink |
| -K, --keep-dirlinks | symlink-dirlink-basis | Y | — | ✓ #715; skips on no-RESOLVE_BENEATH / --disable-openat2 |
| -H, --hard-links | hardlinks, hardlinks-deep*new* | Y | Y | ✓ cross-directory hardlink |
| --copy-unsafe-links | unsafe-links | `~` | — | `~` |
| --safe-links | safe-links | `~` | — | `~` |
| --munge-links | (daemon-munge*new* covers the daemon param) | — | — | `~` client option not isolated; local mode is a near no-op |
### Metadata / permissions / ownership
| option | test(s) | depth | x-dir | notes / gap |
|---|---|---|---|---|
| -p, --perms | metadata-depth*new* | Y | — | ✓ exact modes per entry |
| -E, --executability | executability | `~` | — | `~` |
| --chmod | metadata-depth*new*, chmod-option | Y | — | ✓ |
| -A, --acls | acls, acls-depth*new* | Y | — | ✓ |
| -X, --xattrs | xattrs, xattrs-depth*new* | Y | — | ✓ |
| -t, --times | metadata-depth*new* | Y | — | ✓ |
| -U, --atimes | atimes | `~` | — | `~` (same set path as -t, covered deep) |
| --open-noatime | open-noatime | `~` | — | `~` |
| -N, --crtimes | crtimes | `~` | — | `~` (skips without crtimes support) |
| -O, --omit-dir-times | omit-times*new* | Y | — | ✓ |
| -J, --omit-link-times | omit-times*new* | Y | — | ✓ |
| -o, --owner | chown, ownership-depth*new* | Y | — | ✓ uid map root-gated |
| -g, --group | chgrp, ownership-depth*new* | Y | — | ✓ group remap non-root |
| --super / --fake-super | chown, chown-fake | `~` | — | `~` |
| --numeric-ids | — | — | — | ✗ client; daemon `numeric ids` also ✗ |
| --usermap / --groupmap | ownership-depth*new* | Y | — | ✓ groupmap non-root; usermap root-gated |
| --chown | ownership-depth*new* | Y | — | ✓ group half |
| -D / --devices / --specials | devices, devices-fake | `~` | — | `~` root/device-gated |
| --copy-devices / --write-devices | — | — | — | ✗ device-gated |
| -S, --sparse | sparse*new* | Y | — | ✓ hole preserved at depth |
### Delta / temp / backup / dest (highest restructure risk)
| option | test(s) | depth | x-dir | notes / gap |
|---|---|---|---|---|
| -T, --temp-dir | temp-dir*new*, chmod-temp-dir | Y | Y | ✓ cross-dir rename |
| --partial | partial*new* | Y | — | ✓ partial kept in dest file |
| --partial-dir | partial*new*, symlink-dirlink-basis | Y | Y | ✓ relative (in-tree) + absolute (outside). Absolute **delta** resume is broken on master — asserts only the cross-dir write |
| --delay-updates | delay-updates, delay-updates-deep*new* | Y | — | ✓ per-dir staging |
| --inplace | inplace*new*, alt-dest | Y | — | ✓ inode preserved |
| --append / --append-verify | append*new* | Y | — | ✓ verify split is proto 30+ |
| -b, --backup / --backup-dir / --suffix | backup, backup-deep*new* | Y | Y | ✓ |
| --compare-dest / --copy-dest / --link-dest | alt-dest, alt-dest-deep*new* | Y | Y | ✓ link=hardlink, copy=copy, compare=skip |
| -y, --fuzzy | fuzzy | `~` | — | `~` |
| -u, --update | update*new* | Y | — | ✓ keeps newer dest, updates older |
| -W, --whole-file | (used widely; --no-whole-file ubiquitous) | n/a | — | `~` |
| --mkpath | mkpath | `~` | — | `~` |
| -x, --one-file-system | — | — | — | ✗ (needs a mount boundary) |
| --preallocate / --fsync | — | — | — | ✗ |
| -B, --block-size | — | — | — | ✗ |
| --max-alloc | — | — | — | ✗ |
### Filtering
| option | test(s) | depth | x-dir | notes / gap |
|---|---|---|---|---|
| -f, --filter / -F | filter-depth*new*, merge | Y | — | ✓ deep per-dir merge |
| --exclude / --include | filter-depth*new*, exclude, exclude-lsh | Y | — | ✓ |
| --exclude-from / --include-from | files-from-depth*new* | Y | — | ✓ |
| -C, --cvs-exclude | cvs-exclude*new* | Y | — | ✓ incl. deep .cvsignore |
| --files-from | files-from-depth*new* | Y | — | ✓ |
| -0, --from0 | files-from-depth*new* | Y | — | ✓ |
| --max-size / --min-size | size-filter*new* | Y | — | ✓ |
| --existing / --ignore-existing | delete-deep*new* | Y | — | ✓ |
| --ignore-missing-args / --delete-missing-args | — | — | — | ✗ |
### Deletion
| option | test(s) | depth | x-dir | notes / gap |
|---|---|---|---|---|
| --delete / --del | delete, delete-deep*new* | Y | — | ✓ deep subtree |
| --delete-before/during/delay/after | delete-deep*new* | Y | — | ✓ all four agree |
| --delete-excluded | delete | `~` | — | `~` |
| --max-delete | delete-deep*new* | Y | — | ✓ caps deletions |
| --remove-source-files | delete | `~` | — | `~` |
| --force | update*new* | Y | — | ✓ replaces a non-empty dir with a file |
| --ignore-errors | — | — | — | ✗ (client; daemon `ignore errors` also ✗) |
### Comparison / checksum / compression
| option | test(s) | depth | x-dir | notes / gap |
|---|---|---|---|---|
| -c, --checksum | compare*new* | Y | — | ✓ catches stealth change |
| -I, --ignore-times | compare*new* | Y | — | ✓ |
| --size-only | compare*new* | Y | — | ✓ |
| -@, --modify-window | compare*new* | Y | — | ✓ |
| --checksum-choice / --checksum-seed | compress-options*new* | Y | — | ✓ every advertised algo |
| -z, --compress | daemon-gzip-{up,down}load, daemon-refuse-compress | `~` | — | `~` |
| --compress-choice / --compress-level / --skip-compress | compress-options*new* | Y | — | ✓ |
### Output / reporting (path-irrelevant — checked for output shape)
| option | test(s) | notes / gap |
|---|---|---|
| -i, --itemize-changes | output-options*new*, itemize | ✓ |
| -n, --dry-run | output-options*new* | ✓ |
| --stats | output-options*new* | ✓ |
| --out-format | output-options*new* | ✓ |
| --list-only | output-options*new* | ✓ |
| -q, --quiet | output-options*new* | ✓ |
| --progress / -P | output-options*new* | ✓ (--progress) |
| -h, --human-readable / -8, --8-bit-output | output-options*new* | ✓ smoke |
| --version / --help | output-options*new* | ✓ |
| --info / --debug / --stderr / --no-motd / --outbuf | — | ✗ |
| -M, --remote-option / --log-file / --log-file-format | — | ✗ (daemon `log file` covered) |
### Batch / connection / misc
| option | test(s) | notes / gap |
|---|---|---|
| --write-batch / --only-write-batch / --read-batch | batch-mode | `~` |
| -e, --rsh / --rsync-path | ssh-basic, many | `~` |
| --protocol | check29 / check30 (whole suite) | ✓ |
| --address / --port | daemon tests under --use-tcp | `~` |
| --password-file | daemon-auth*new* | ✓ |
| --early-input / daemon `early exec` | — | ✗ |
| --sockopts / --blocking-io / --timeout / --contimeout | — | ✗ |
| -4/-6, --ipv4/--ipv6 | — | ✗ |
| --stop-after / --stop-at | — | ✗ |
| --bwlimit | partial*new* (used, not asserted) | `~` |
| --copy-as | — | ✗ root-gated |
| --iconv | — | ✗ |
| -s/--secluded-args, --old-args, --trust-sender | (default arg-protection exercised) | `~` |
---
## Daemon (rsyncd.conf) parameters
| parameter | test(s) | notes / gap |
|---|---|---|
| path | daemon-access*new*, all daemon tests | ✓ incl. deep sub-path |
| read only | daemon-access*new*, daemon | ✓ |
| write only | daemon-access*new* | ✓ |
| list | daemon-access*new*, daemon | ✓ hidden-but-usable |
| use chroot | sender-flist-symlink-leak, daemon-chroot-acl | `~` (no=most tests; yes needs root) |
| munge symlinks | daemon-munge*new* | ✓ /rsyncd-munged/ add+strip |
| exclude / include | daemon-filter*new*, daemon | ✓ exclude |
| filter / exclude from / include from | — | ✗ (exclude covers the mechanism) |
| incoming chmod | daemon-filter*new*, chmod-option | ✓ |
| outgoing chmod | daemon-filter*new* | ✓ |
| auth users / secrets file | daemon-auth*new* | ✓ accept/reject/unauth |
| strict modes | daemon-auth*new* | ✓ rejects world-readable secrets |
| refuse options | daemon-refuse*new*, daemon-refuse-compress | ✓ named/wildcard/allow-list |
| pre-xfer exec / post-xfer exec | daemon-exec*new* | ✓ env + abort |
| early exec | — | ✗ (needs --early-input) |
| hosts allow / hosts deny | daemon (allow), daemon-chroot-acl (deny) | `~` (needs --use-tcp for real peer) |
| reverse lookup / forward lookup | daemon-chroot-acl | `~` reverse only |
| log file / transfer logging / log format | daemon | `~` set, not asserted |
| max verbosity | daemon | `~` |
| comment | daemon, daemon-access*new* | ✓ |
| numeric ids | — | ✗ (hard to observe non-root) |
| fake super | chown-fake (client side) | ✗ as daemon param |
| timeout / max connections / lock file | — | ✗ (need --use-tcp + concurrency) |
| temp dir / open noatime / ignore errors / ignore nonreadable | — | ✗ |
| charset / name converter / dont compress | — | ✗ |
| uid / gid / daemon uid / daemon gid / daemon chroot | build_rsyncd_conf (uid/gid when root), daemon-chroot-acl | `~` root-gated |
| motd file / pid file / port / address / socket options / listen backlog / proxy protocol / syslog facility / syslog tag | — | ✗ (server-startup/connection params) |
---
## Known gaps worth a future pass
* Connection/timeout params (`--timeout`, `--contimeout`, daemon `timeout`,
`max connections`) need a real socket + concurrency (run under `--use-tcp`).
* Root-only behaviours (`-o`/`--usermap` uid remap, real devices, `use chroot
= yes`, daemon uid/gid) skip as non-root; run the suite as root to cover.
* `--ignore-errors`, `-x/--one-file-system`, `--numeric-ids` have no dedicated
test yet (lower restructure risk).
* Absolute `--partial-dir` + delta resume is broken on master; the test asserts
only the cross-directory write there and completes with `--whole-file`.

62
testsuite/update_test.py Normal file
View File

@@ -0,0 +1,62 @@
#!/usr/bin/env python3
"""Coverage of -u (--update) and --force at depth.
-u skips any destination file that is newer than the source. --force lets rsync
delete a non-empty destination directory when it must be replaced by a
non-directory. Both decide a per-entry action on a name whose parent chain the
resolver restructure rewrites, so check them several levels deep.
"""
import os
from rsyncfns import (
FROMDIR, TODIR,
assert_same, make_tree, makepath, rmtree, run_rsync, test_fail,
)
src = FROMDIR
deep = os.path.join('d1', 'd2', 'd3', 'f3')
# --- -u keeps a newer destination file, updates an older one ----------------
rmtree(src)
rmtree(TODIR)
make_tree(src, depth=3)
run_rsync('-a', f'{src}/', f'{TODIR}/')
# Make the deep source newer in content, but the DEST copy newer in time.
(src / deep).write_text("new source content\n")
keep = "destination is newer - keep me\n"
(TODIR / deep).write_text(keep)
st = os.stat(src / deep)
os.utime(TODIR / deep, (st.st_atime, st.st_mtime + 100)) # dest mtime newer
run_rsync('-a', '-u', f'{src}/', f'{TODIR}/')
if (TODIR / deep).read_text() != keep:
test_fail("-u overwrote a destination file that was newer than the source")
# An older destination file IS updated under -u.
os.utime(TODIR / deep, (st.st_atime, st.st_mtime - 100)) # dest mtime older
run_rsync('-a', '-u', f'{src}/', f'{TODIR}/')
assert_same(TODIR / deep, src / deep, label='-u updated an older dest file')
# --- --force replaces a non-empty dest directory with a file at depth -------
rmtree(src)
rmtree(TODIR)
makepath(src / 'd1' / 'd2' / 'd3')
(src / deep).write_text("now a regular file\n") # src: d1/d2/d3/f3 = file
makepath(TODIR / 'd1' / 'd2' / 'd3' / 'f3') # dest: f3 = non-empty dir
(TODIR / 'd1' / 'd2' / 'd3' / 'f3' / 'occupant').write_text("blocker\n")
# Without --force the non-empty directory can't be replaced.
proc = run_rsync('-a', f'{src}/', f'{TODIR}/', check=False)
if proc.returncode == 0 and (TODIR / deep).is_file():
test_fail("a non-empty directory was replaced by a file without --force")
# With --force the directory is removed and the file takes its place.
run_rsync('-a', '--force', f'{src}/', f'{TODIR}/')
if not (TODIR / deep).is_file():
test_fail("--force did not replace the directory with the file at depth")
assert_same(TODIR / deep, src / deep, label='--force replacement content')
print("update: -u keeps newer dest / updates older; --force replaces a dir at depth")