From: Andrew Tridgell Date: Sat, 23 May 2026 22:01:52 +0000 (+1000) Subject: testsuite: output, comparison and algorithm-selection option coverage X-Git-Url: http://git.ipfire.org/gitweb.cgi?a=commitdiff_plain;h=e57c7f5d877904608454dbb61a302ceb977b1571;p=thirdparty%2Frsync.git testsuite: output, comparison and algorithm-selection option coverage Breadth pass for options not yet exercised: output-options output shape of --version/--help/-i/-n/--stats/ --out-format/--list-only/-q/--progress/-h/-8 (these control output, not path handling, so they're checked for shape). compare -c and -I catch a stealth change (same size+mtime, new content) deep in the tree; --size-only skips a same-size change; --modify-window absorbs a 1s mtime difference. compress-options --compress-choice for every advertised compressor, --compress-level, --skip-compress, --checksum-choice for every advertised checksum, and --checksum-seed -- each a clean byte-identical transfer at depth. Green on master and under --protocol=29/30. Co-Authored-By: Claude Opus 4.7 (1M context) --- diff --git a/testsuite/compare_test.py b/testsuite/compare_test.py new file mode 100644 index 00000000..8279c9ff --- /dev/null +++ b/testsuite/compare_test.py @@ -0,0 +1,90 @@ +#!/usr/bin/env python3 +"""Coverage of the comparison/skip options at depth: -c, -I, --size-only, +--modify-window. + +These decide WHETHER a file is transferred. Each is checked on a file several +levels deep using a "stealth change" (same size, same mtime, different content) +that the default quick check deliberately skips. +""" + +import os + +from rsyncfns import ( + FROMDIR, TODIR, + assert_same, make_tree, rmtree, run_rsync, test_fail, +) + +src = FROMDIR +deep = os.path.join('d1', 'd2', 'd3', 'f3') + + +def seed(): + rmtree(src) + rmtree(TODIR) + make_tree(src, depth=3, data=True, data_size=4096) + run_rsync('-a', f'{src}/', f'{TODIR}/') # dest == src + + +def stealth_change(): + """Change the deep source file's content but restore the destination's + size+mtime, so the quick check sees them as equal.""" + st = os.stat(TODIR / deep) + data = bytearray((src / deep).read_bytes()) + data[0] ^= 0xFF # same length, new content + (src / deep).write_bytes(bytes(data)) + os.utime(src / deep, (st.st_atime, st.st_mtime)) + + +# --- the default quick check skips a stealth change; -c and -I catch it ------ +seed() +stealth_change() +run_rsync('-a', f'{src}/', f'{TODIR}/') +if (TODIR / deep).read_bytes() == (src / deep).read_bytes(): + test_fail("default quick check unexpectedly transferred a same-size, " + "same-mtime change (test setup is wrong)") + +run_rsync('-a', '-c', f'{src}/', f'{TODIR}/') +assert_same(TODIR / deep, src / deep, label='-c caught stealth change') + +seed() +stealth_change() +run_rsync('-a', '-I', f'{src}/', f'{TODIR}/') +assert_same(TODIR / deep, src / deep, label='-I caught stealth change') + +# --- --size-only skips a same-size change even when the mtime differs -------- +def samesize_newmtime(): + data = bytearray((src / deep).read_bytes()) + data[0] ^= 0xFF # same size, new content + (src / deep).write_bytes(bytes(data)) + st = os.stat(src / deep) + os.utime(src / deep, (st.st_atime, st.st_mtime + 100)) # mtime differs + + +seed() +samesize_newmtime() +run_rsync('-a', '--size-only', f'{src}/', f'{TODIR}/') +if (TODIR / deep).read_bytes() == (src / deep).read_bytes(): + test_fail("--size-only transferred a same-size file (should have skipped)") + +# Contrast on a fresh tree: the default DOES transfer it (mtime differs). +# (Re-seed because --size-only above updated the dest mtime to match.) +seed() +samesize_newmtime() +run_rsync('-a', f'{src}/', f'{TODIR}/') +assert_same(TODIR / deep, src / deep, label='default caught mtime change') + +# --- --modify-window absorbs a small mtime difference ----------------------- +# Both runs are dry-run (-ain): a real run would update the dest mtime to match +# the source, leaving the --modify-window run nothing to absorb (vacuous). +seed() +st = os.stat(TODIR / deep) +os.utime(src / deep, (st.st_atime, st.st_mtime + 1)) # 1s newer, same content +p = run_rsync('-ain', f'{src}/', f'{TODIR}/', capture_output=True) +if 'f3' not in p.stdout: + test_fail("a 1s mtime change was not itemized without --modify-window") +p = run_rsync('-ain', '--modify-window=2', f'{src}/', f'{TODIR}/', + capture_output=True) +if 'f3' in p.stdout: + test_fail("--modify-window=2 did not absorb a 1s mtime difference") + +print("compare: -c / -I / --size-only / --modify-window verified at depth") diff --git a/testsuite/compress-options_test.py b/testsuite/compress-options_test.py new file mode 100644 index 00000000..74e08771 --- /dev/null +++ b/testsuite/compress-options_test.py @@ -0,0 +1,65 @@ +#!/usr/bin/env python3 +"""Breadth coverage of the algorithm-selection options at depth: +--compress-choice / --compress-level / --skip-compress and +--checksum-choice / --checksum-seed. + +Compression and checksum choice don't change the result, so each available +algorithm is exercised for a clean, byte-identical transfer of a >=3-deep tree +(proving the option parses, negotiates and doesn't corrupt data). +""" + +import json + +from rsyncfns import ( + FROMDIR, TODIR, + assert_same, make_tree, rmtree, run_rsync, walk_files, +) + +src = FROMDIR +vv = json.loads(run_rsync('-VV', check=True, capture_output=True).stdout) +compressors = [a for a in vv.get('compress_list', []) if a != 'none'] +checksums = [a for a in vv.get('checksum_list', []) if a != 'none'] + + +def fresh(): + rmtree(src) + rmtree(TODIR) + make_tree(src, depth=3, data=True, data_size=4096) + return [p.relative_to(src) for p in walk_files(src)] + + +def verify(rels, label): + for rel in rels: + assert_same(TODIR / rel, src / rel, label=f'{label} {rel}') + + +# --- --compress-choice for every advertised compressor ---------------------- +for algo in compressors: + rels = fresh() + run_rsync('-az', f'--compress-choice={algo}', f'{src}/', f'{TODIR}/') + verify(rels, f'--compress-choice={algo}') + +# --- --compress-level ------------------------------------------------------- +rels = fresh() +run_rsync('-az', '--compress-level=9', f'{src}/', f'{TODIR}/') +verify(rels, '--compress-level=9') + +# --- --skip-compress (the file must still arrive intact) -------------------- +rels = fresh() +(src / 'd1' / 'd2' / 'x.gz').write_bytes(b'\x1f\x8b' + b'pseudo gzip body ' * 64) +run_rsync('-az', '--skip-compress=gz', f'{src}/', f'{TODIR}/') +assert_same(TODIR / 'd1' / 'd2' / 'x.gz', src / 'd1' / 'd2' / 'x.gz', + label='--skip-compress gz') + +# --- --checksum-choice for every advertised checksum ------------------------ +for algo in checksums: + rels = fresh() + run_rsync('-a', '-c', f'--checksum-choice={algo}', f'{src}/', f'{TODIR}/') + verify(rels, f'--checksum-choice={algo}') + +# --- --checksum-seed -------------------------------------------------------- +rels = fresh() +run_rsync('-a', '-c', '--checksum-seed=12345', f'{src}/', f'{TODIR}/') +verify(rels, '--checksum-seed') + +print("compress-options: compress/checksum algorithm selection verified at depth") diff --git a/testsuite/output-options_test.py b/testsuite/output-options_test.py new file mode 100644 index 00000000..3a836e1c --- /dev/null +++ b/testsuite/output-options_test.py @@ -0,0 +1,90 @@ +#!/usr/bin/env python3 +"""Breadth coverage of the output / reporting options. + +These options control rsync's OUTPUT, not its path handling, so they are +checked for the documented output shape rather than at depth: + --version, --help, --itemize-changes (-i), --dry-run (-n), --stats, + --out-format, --list-only, --quiet (-q), --progress, -h, -8. +""" + +import subprocess + +from rsyncfns import ( + FROMDIR, TODIR, + assert_not_exists, make_tree, rmtree, rsync_argv, test_fail, +) + +src = FROMDIR + + +def out(*args): + return subprocess.run(rsync_argv(*args), capture_output=True, text=True) + + +# --- --version / --help ----------------------------------------------------- +p = out('--version') +if p.returncode != 0 or 'protocol version' not in p.stdout: + test_fail(f"--version output unexpected:\n{p.stdout}") +p = out('--help') +help_txt = p.stdout + p.stderr +if 'rsync' not in help_txt or 'Usage' not in help_txt: + test_fail("--help did not print usage") + +rmtree(src) +rmtree(TODIR) +make_tree(src, depth=2) + +# --- --itemize-changes: a new file shows the create itemization ------------- +p = out('-ai', f'{src}/', f'{TODIR}/') +if '>f+++++++++' not in p.stdout: + test_fail(f"--itemize-changes missing create line:\n{p.stdout}") + +# --- --dry-run lists but does not create ------------------------------------ +rmtree(TODIR) +p = out('-ain', f'{src}/', f'{TODIR}/') +if '>f+++++++++' not in p.stdout: + test_fail("--dry-run itemize output missing") +assert_not_exists(TODIR / 'f0', label='--dry-run created a file') + +# --- --stats prints the summary block --------------------------------------- +rmtree(TODIR) +p = out('-a', '--stats', f'{src}/', f'{TODIR}/') +if 'Number of files:' not in p.stdout or 'Total file size:' not in p.stdout: + test_fail(f"--stats output missing expected lines:\n{p.stdout}") + +# --- --out-format=%n emits bare filenames ----------------------------------- +rmtree(TODIR) +p = out('-a', '--out-format=%n', f'{src}/', f'{TODIR}/') +if 'f0' not in p.stdout: + test_fail(f"--out-format=%n did not emit filenames:\n{p.stdout}") + +# --- --list-only lists the source without copying --------------------------- +# Pass a destination too: without --list-only this transfer would populate +# TODIR, so the assert_not_exists below actually proves the "without copying" +# property rather than being vacuously true for a destination-less command. +rmtree(TODIR) +p = out('--list-only', '-r', f'{src}/', f'{TODIR}/') +if 'f0' not in p.stdout: + test_fail(f"--list-only did not list files:\n{p.stdout}") +assert_not_exists(TODIR / 'f0', label='--list-only copied a file') + +# --- --quiet suppresses normal stdout --------------------------------------- +rmtree(TODIR) +p = out('-a', '-q', f'{src}/', f'{TODIR}/') +if p.stdout.strip() != '': + test_fail(f"--quiet produced stdout: {p.stdout!r}") + +# --- --progress shows a percentage ------------------------------------------ +rmtree(TODIR) +p = out('-a', '--progress', f'{src}/', f'{TODIR}/') +if '100%' not in p.stdout: + test_fail(f"--progress did not show a percentage:\n{p.stdout}") + +# --- -h / -8 do not break a transfer ---------------------------------------- +rmtree(TODIR) +p = out('-a', '-h', '-8', '--stats', f'{src}/', f'{TODIR}/') +if p.returncode != 0: + test_fail(f"-h/-8 broke the transfer:\n{p.stderr}") + +print("output-options: version/help/-i/-n/--stats/--out-format/--list-only/" + "-q/--progress/-h/-8 verified")