]> git.ipfire.org Git - thirdparty/rsync.git/commitdiff
testsuite: cross-directory/temp/backup/dest coverage at depth
authorAndrew Tridgell <andrew@tridgell.net>
Sat, 23 May 2026 21:47:10 +0000 (07:47 +1000)
committerAndrew Tridgell <andrew@tridgell.net>
Sun, 24 May 2026 02:31:52 +0000 (12:31 +1000)
Fill the highest-restructure-risk gap: options that do two-directory / rename /
outside-tree work, asserted at >=3 levels deep with the aux tree kept outside
the main tree, and asserting the option's specific property rather than just
tree equality (which the ported tests already cover).

  alt-dest-deep  --link-dest hardlinks unchanged files (same inode), --copy-dest
                 copies (never links), --compare-dest omits unchanged files;
                 ref tree outside both src and dest.
  temp-dir       cross-dir temp->final rename at depth; temp dir left clean; a
                 missing --temp-dir fails (so the option is proven consulted).
  partial        --partial keeps the partial in the dest file; relative
                 --partial-dir stages per-directory at depth (pre-seed +
                 interrupt/resume); absolute --partial-dir writes the partial
                 outside the tree.
  inplace        --inplace keeps the destination inode across a delta update;
                 the default temp+rename path replaces it.
  append         --append completes truncated files tail-only; --append-verify
                 repairs a corrupted prefix (protocol >= 30).
  backup-deep    --suffix saves <name>S beside the new file; --backup-dir
                 relocates old files to a parallel deep tree outside the dest
                 and captures deletions under --delete.

All green on master and under --protocol=29/30.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
testsuite/alt-dest-deep_test.py [new file with mode: 0644]
testsuite/append_test.py [new file with mode: 0644]
testsuite/backup-deep_test.py [new file with mode: 0644]
testsuite/delay-updates-deep_test.py [new file with mode: 0644]
testsuite/inplace_test.py [new file with mode: 0644]
testsuite/partial_test.py [new file with mode: 0644]
testsuite/temp-dir_test.py [new file with mode: 0644]

diff --git a/testsuite/alt-dest-deep_test.py b/testsuite/alt-dest-deep_test.py
new file mode 100644 (file)
index 0000000..1054948
--- /dev/null
@@ -0,0 +1,85 @@
+#!/usr/bin/env python3
+"""Property-level coverage of --link-dest / --copy-dest / --compare-dest at
+depth and across directory boundaries.
+
+The existing alt-dest_test.py is a faithful port that checks tree equality;
+this companion asserts the *distinguishing* property of each option, at every
+level of a >=3-deep tree, with the alternate-destination tree placed OUTSIDE
+both the source and destination trees (a sibling, not a parent/child):
+
+  --link-dest    unchanged files are HARD-LINKED to the reference (same inode);
+                 a changed file is transferred fresh (not linked).
+  --copy-dest    unchanged files are COPIED from the reference (never linked);
+                 dest is complete and byte-identical to the source.
+  --compare-dest unchanged files are NEITHER transferred NOR created in dest;
+                 only a changed/new file lands in dest.
+
+These options drive the two-dirfd / outside-tree path handling the resolver
+restructure rewrites, so they must be guarded at depth.
+"""
+
+import os
+
+from rsyncfns import (
+    FROMDIR, SCRATCHDIR, TODIR,
+    assert_exists, assert_hardlinked, assert_not_exists, assert_not_hardlinked,
+    assert_same, make_tree, rmtree, run_rsync, walk_files,
+)
+
+src = FROMDIR
+ref = SCRATCHDIR / 'altref'   # sibling of from/ and to/ -- outside both trees
+
+rmtree(src)
+rmtree(ref)
+rmtree(TODIR)
+
+# A >=3-deep source: f0 at the root, then d1/f1, d1/d2/f2, d1/d2/d3/f3.
+make_tree(src, depth=3, data=True)
+
+# Reference tree == an exact copy of the source, so every file is "identical"
+# for the alt-dest comparison.
+run_rsync('-a', f'{src}/', f'{ref}/')
+
+# Now make the deepest file differ so it must be transferred in every mode.
+changed = os.path.join('d1', 'd2', 'd3', 'f3')
+with open(src / changed, 'ab') as f:
+    f.write(b'a changed deep tail\n')
+
+rels = [p.relative_to(src) for p in walk_files(src)]
+assert os.path.join('d1', 'd2', 'd3', 'f3') in [str(r) for r in rels]
+
+
+def run_to(opt):
+    rmtree(TODIR)
+    run_rsync('-a', f'--{opt}={ref}', f'{src}/', f'{TODIR}/')
+
+
+# --- --link-dest: unchanged -> hardlink to ref; changed -> fresh copy -------
+run_to('link-dest')
+for rel in rels:
+    d, r = TODIR / rel, ref / rel
+    if str(rel) == changed:
+        assert_not_hardlinked(d, r, label=f'link-dest changed {rel}')
+        assert_same(d, src / rel, label=f'link-dest changed {rel}')
+    else:
+        assert_hardlinked(d, r, label=f'link-dest unchanged {rel}')
+
+# --- --copy-dest: every file copied (never linked), dest complete -----------
+run_to('copy-dest')
+for rel in rels:
+    d, r = TODIR / rel, ref / rel
+    assert_exists(d, label=f'copy-dest {rel}')
+    assert_same(d, src / rel, label=f'copy-dest {rel}')
+    assert_not_hardlinked(d, r, label=f'copy-dest {rel}')
+
+# --- --compare-dest: unchanged absent from dest; only the changed file lands -
+run_to('compare-dest')
+for rel in rels:
+    d = TODIR / rel
+    if str(rel) == changed:
+        assert_exists(d, label=f'compare-dest changed {rel}')
+        assert_same(d, src / rel, label=f'compare-dest changed {rel}')
+    else:
+        assert_not_exists(d, label=f'compare-dest unchanged {rel}')
+
+print("alt-dest-deep: link-dest/copy-dest/compare-dest verified at depth")
diff --git a/testsuite/append_test.py b/testsuite/append_test.py
new file mode 100644 (file)
index 0000000..46c7833
--- /dev/null
@@ -0,0 +1,73 @@
+#!/usr/bin/env python3
+"""Coverage of --append and --append-verify at depth.
+
+--append assumes each destination file is a prefix of the (longer) source and
+transfers only the bytes past the existing size; it does NOT re-check the data
+already present, and it never touches a file that is already the same size or
+larger. --append-verify works the same way but folds the existing data into the
+whole-file checksum, so a transfer whose result fails verification is re-sent
+with a normal --inplace pass. Exercise both on files >=3 levels deep.
+"""
+
+import os
+
+from rsyncfns import (
+    FROMDIR, TODIR,
+    assert_same, forced_protocol, make_tree, rmtree, run_rsync, test_fail,
+    walk_files,
+)
+
+src = FROMDIR
+deep = os.path.join('d1', 'd2', 'd3', 'f3')
+
+
+def seed_source():
+    rmtree(src)
+    make_tree(src, depth=3, data=True, data_size=8192)
+    return [p.relative_to(src) for p in walk_files(src)]
+
+
+def dest_prefix(rels, *, corrupt=False, frac=0.5):
+    """Build a destination holding the first `frac` of each source file (a
+    valid prefix), optionally corrupting the deep file's leading bytes."""
+    rmtree(TODIR)
+    for rel in rels:
+        dst = TODIR / rel
+        dst.parent.mkdir(parents=True, exist_ok=True)
+        full = (src / rel).read_bytes()
+        dst.write_bytes(full[: int(len(full) * frac)])
+    if corrupt:
+        p = TODIR / deep
+        bad = bytearray(p.read_bytes())
+        bad[0:64] = b'\x00' * 64
+        p.write_bytes(bytes(bad))
+
+
+# --- --append completes truncated destinations at every level ---------------
+rels = seed_source()
+dest_prefix(rels)
+run_rsync('-a', '--append', f'{src}/', f'{TODIR}/')
+for rel in rels:
+    assert_same(TODIR / rel, src / rel, label=f'append {rel}')
+
+# The split between non-verifying --append and verifying --append-verify only
+# exists at protocol >= 30; at protocol 29 plain --append still verifies, so
+# skip the distinguishing sub-cases there.
+proto = forced_protocol()
+if proto is not None and proto < 30:
+    print(f"append: protocol {proto} -- skipping the --append/--append-verify "
+          "split (verifying-append behaviour predates the protocol-30 split)")
+else:
+    # plain --append trusts a corrupted prefix (leaves it wrong)
+    dest_prefix(rels, corrupt=True)
+    run_rsync('-a', '--append', f'{src}/', f'{TODIR}/')
+    if (TODIR / deep).read_bytes() == (src / deep).read_bytes():
+        test_fail("plain --append unexpectedly repaired a corrupted prefix "
+                  "(it should append only and trust the existing data)")
+
+    # --append-verify detects the bad prefix and re-sends the whole file
+    dest_prefix(rels, corrupt=True)
+    run_rsync('-a', '--append-verify', f'{src}/', f'{TODIR}/')
+    assert_same(TODIR / deep, src / deep, label='append-verify deep')
+
+print("append: tail-only completion at depth; append-verify repairs prefix")
diff --git a/testsuite/backup-deep_test.py b/testsuite/backup-deep_test.py
new file mode 100644 (file)
index 0000000..f2f754c
--- /dev/null
@@ -0,0 +1,86 @@
+#!/usr/bin/env python3
+"""Property-level coverage of --backup / --suffix / --backup-dir at depth.
+
+backup_test.py is the ported regression test (2 levels, no custom suffix); this
+companion checks the concrete outcome of each backup mode at >=3 levels and,
+for --backup-dir, with the backup tree placed OUTSIDE the destination (a
+sibling) so the old file is renamed across directories into a parallel deep
+path -- the cross-directory operation the resolver restructure rewrites.
+
+Asserts, at every level of the tree:
+  * --backup --suffix=S  saves the overwritten file as <name>S beside the new
+    one (old content in the backup, new content in place);
+  * --backup --backup-dir=DIR  relocates the old file to DIR/<relpath>,
+    preserving the deep structure, while the destination gets the new content;
+  * --backup-dir together with --delete  routes a deletion into the backup
+    tree instead of losing it.
+"""
+
+import os
+
+from rsyncfns import (
+    FROMDIR, SCRATCHDIR, TODIR,
+    assert_not_exists, assert_same, make_tree, rmtree, run_rsync, test_fail,
+    walk_files,
+)
+
+src = FROMDIR
+bak = SCRATCHDIR / 'backups'   # sibling of from/ and to/ -- outside both trees
+
+
+def seed():
+    """Fresh v1 source, a matching destination, and the old (v1) bytes; then
+    mutate the source to v2 so the next sync overwrites every file."""
+    rmtree(src)
+    rmtree(TODIR)
+    rmtree(bak)
+    make_tree(src, depth=3, data=True, data_size=4096)
+    rels = [p.relative_to(src) for p in walk_files(src)]
+    run_rsync('-a', f'{src}/', f'{TODIR}/')
+    old = {rel: (src / rel).read_bytes() for rel in rels}
+    for rel in rels:                       # v1 -> v2
+        with open(src / rel, 'ab') as f:
+            f.write(b'\nversion-2 tail\n')
+    return rels, old
+
+
+# --- --backup --suffix=.old (same directory) --------------------------------
+rels, old = seed()
+run_rsync('-a', '-b', '--suffix=.old', '--no-whole-file',
+          f'{src}/', f'{TODIR}/')
+for rel in rels:
+    assert_same(TODIR / rel, src / rel, label=f'suffix new {rel}')
+    backup = (TODIR / rel)
+    backup = backup.with_name(backup.name + '.old')
+    if not backup.is_file():
+        test_fail(f"--suffix backup missing for {rel}: {backup}")
+    if backup.read_bytes() != old[rel]:
+        test_fail(f"--suffix backup of {rel} does not hold the old content")
+
+# --- --backup-dir at depth, outside the dest tree (cross-dir) ---------------
+rels, old = seed()
+run_rsync('-a', '-b', f'--backup-dir={bak}', '--no-whole-file',
+          f'{src}/', f'{TODIR}/')
+for rel in rels:
+    assert_same(TODIR / rel, src / rel, label=f'backup-dir new {rel}')
+    saved = bak / rel
+    if not saved.is_file():
+        test_fail(f"--backup-dir did not preserve deep path for {rel}: {saved}")
+    if saved.read_bytes() != old[rel]:
+        test_fail(f"--backup-dir copy of {rel} does not hold the old content")
+
+# --- --backup-dir captures a deletion under --delete ------------------------
+rels, old = seed()
+# Add a deep file to the destination that is absent from the source.
+extra = os.path.join('d1', 'd2', 'd3', 'goner')
+(TODIR / extra).write_bytes(b'about to be deleted\n')
+run_rsync('-a', '--delete', '-b', f'--backup-dir={bak}', '--no-whole-file',
+          f'{src}/', f'{TODIR}/')
+assert_not_exists(TODIR / extra, label='deleted file removed from dest')
+saved = bak / extra
+if not saved.is_file():
+    test_fail(f"--backup-dir did not capture the deletion of {extra}")
+if saved.read_bytes() != b'about to be deleted\n':
+    test_fail("captured deletion has the wrong content")
+
+print("backup-deep: suffix / backup-dir / delete-capture verified at depth")
diff --git a/testsuite/delay-updates-deep_test.py b/testsuite/delay-updates-deep_test.py
new file mode 100644 (file)
index 0000000..bd359fc
--- /dev/null
@@ -0,0 +1,62 @@
+#!/usr/bin/env python3
+"""Property-level coverage of --delay-updates at depth.
+
+--delay-updates writes each updated file into a per-directory staging dir
+(.~tmp~) during the transfer and only renames them into place at the very end,
+so an interrupted run never leaves a half-written file visible. The staging dir
+sits inside each destination directory, so the staging write and the
+end-of-run rename are parent-directory operations the resolver restructure
+rewrites; the ported delay-updates_test.py only exercises the tree root, so
+this companion drives a >=3-deep tree.
+
+Asserts, at every level of the tree:
+  * a --delay-updates transfer reproduces the source and leaves no .~tmp~
+    staging directory behind;
+  * a stale file pre-planted in a deep .~tmp~ staging dir is overwritten
+    cleanly and the staging dir is removed.
+"""
+
+import os
+
+from rsyncfns import (
+    FROMDIR, TODIR,
+    assert_same, make_tree, rmtree, run_rsync, test_fail, walk_dirs,
+    walk_files,
+)
+
+src = FROMDIR
+deepdir = os.path.join('d1', 'd2', 'd3')
+
+
+def no_staging_left():
+    leftover = [p for p in walk_dirs(TODIR) if p.name == '.~tmp~']
+    if leftover:
+        test_fail(f"--delay-updates left staging dirs behind: {leftover}")
+
+
+# --- initial --delay-updates over a deep tree -------------------------------
+rmtree(src)
+rmtree(TODIR)
+make_tree(src, depth=3, data=True, data_size=4096)
+rels = [p.relative_to(src) for p in walk_files(src)]
+
+run_rsync('-a', '--delay-updates', f'{src}/', f'{TODIR}/')
+for rel in rels:
+    assert_same(TODIR / rel, src / rel, label=f'delay-updates initial {rel}')
+no_staging_left()
+
+# --- update every file, with a stale staging file planted deep --------------
+for rel in rels:
+    with open(src / rel, 'ab') as f:
+        f.write(b'\nupdated content\n')
+
+stage = TODIR / deepdir / '.~tmp~'
+stage.mkdir(parents=True, exist_ok=True)
+(stage / 'f3').write_bytes(b'stale staged junk\n')   # must be overwritten
+
+run_rsync('-a', '--delay-updates', f'{src}/', f'{TODIR}/')
+for rel in rels:
+    assert_same(TODIR / rel, src / rel, label=f'delay-updates update {rel}')
+no_staging_left()
+
+print("delay-updates-deep: staging + clean overwrite verified at depth")
diff --git a/testsuite/inplace_test.py b/testsuite/inplace_test.py
new file mode 100644 (file)
index 0000000..e0c2079
--- /dev/null
@@ -0,0 +1,71 @@
+#!/usr/bin/env python3
+"""Coverage of --inplace at depth.
+
+--inplace updates the destination file directly instead of writing a temp copy
+and renaming it over the original, so across a delta update the destination
+keeps the SAME inode. Without --inplace the receiver creates a fresh temp file
+and renames it, giving the destination a NEW inode. Both behaviours hinge on
+how the receiver resolves the destination directory and (for the default mode)
+performs the temp->final rename, which the path restructure rewrites; verify
+them on a file >=3 levels deep.
+"""
+
+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=200000)
+
+
+def inode(path):
+    return os.stat(path).st_ino
+
+
+def modify_deep():
+    # Rewrite a chunk in the middle so it is a genuine delta, not just a tail
+    # append. Bump mtime by a clear margin (the whole test runs inside one
+    # second, so a "now" touch would collide with the destination's mtime and
+    # the size-unchanged file would be skipped by the quick check).
+    p = src / deep
+    data = bytearray(p.read_bytes())
+    data[1000:1100] = bytes((b ^ 0xFF) for b in data[1000:1100])
+    p.write_bytes(bytes(data))
+    st = os.stat(p)
+    os.utime(p, (st.st_atime, st.st_mtime + 100))
+
+
+# --- --inplace keeps the destination inode across a delta update ------------
+seed()
+run_rsync('-a', f'{src}/', f'{TODIR}/')
+ino_before = inode(TODIR / deep)
+
+modify_deep()
+run_rsync('-a', '--inplace', '--no-whole-file', f'{src}/', f'{TODIR}/')
+assert_same(TODIR / deep, src / deep, label='inplace content')
+if inode(TODIR / deep) != ino_before:
+    test_fail("--inplace changed the destination inode at depth "
+              f"({ino_before} -> {inode(TODIR / deep)})")
+
+# --- control: the default (temp+rename) path replaces the inode -------------
+seed()
+run_rsync('-a', f'{src}/', f'{TODIR}/')
+ino_before = inode(TODIR / deep)
+
+modify_deep()
+run_rsync('-a', '--no-whole-file', f'{src}/', f'{TODIR}/')
+assert_same(TODIR / deep, src / deep, label='default content')
+if inode(TODIR / deep) == ino_before:
+    test_fail("default (non-inplace) delta update unexpectedly kept the "
+              "destination inode at depth -- temp+rename did not run")
+
+print("inplace: same-inode update at depth verified; default replaces inode")
diff --git a/testsuite/partial_test.py b/testsuite/partial_test.py
new file mode 100644 (file)
index 0000000..97b6f69
--- /dev/null
@@ -0,0 +1,130 @@
+#!/usr/bin/env python3
+"""Coverage of --partial / --partial-dir at depth and across directory
+boundaries.
+
+--partial keeps a partially transferred file so a later run can resume it.
+--partial-dir=DIR keeps the partial in DIR instead of the destination file:
+a RELATIVE dir is created in (and removed from) each destination file's own
+directory; an ABSOLUTE dir is a reserved location that holds partials by
+basename. All of this is parent- and cross-directory path resolution -- what
+the resolver restructure rewrites -- so exercise it on a file several levels
+deep, with the absolute partial-dir kept OUTSIDE the destination tree.
+
+Note: a *delta* resume from an absolute partial-dir currently fails whole-file
+verification on master (it re-puts the partial and never converges). This test
+therefore only asserts the cross-directory WRITE of the partial for that case
+and completes it with --whole-file, which is the clearly-correct baseline.
+"""
+
+import os
+import signal
+import subprocess
+import time
+
+from rsyncfns import (
+    FROMDIR, SCRATCHDIR, TODIR,
+    assert_same, make_data_file, makepath, rmtree, rsync_argv, run_rsync,
+    test_fail,
+)
+
+src = FROMDIR
+deepdir = os.path.join('d1', 'd2', 'd3')
+deep = os.path.join(deepdir, 'f3')
+FULL = 12_000_000
+
+
+def seed_big():
+    rmtree(src)
+    rmtree(TODIR)
+    makepath(src / deepdir)
+    make_data_file(src / deep, FULL)
+
+
+def is_prefix(partial) -> bool:
+    pb = partial.read_bytes()
+    return 0 < len(pb) < FULL and (src / deep).read_bytes()[:len(pb)] == pb
+
+
+def interrupt_transfer(extra_args, partial_path):
+    """Start a deliberately slow transfer, SIGTERM it once the receiver's
+    in-progress temp (.f3.XXXXXX) has some data, and wait for `partial_path`
+    (where this mode keeps the partial) to materialise.
+
+    The bandwidth limit is low so the multi-second transfer cannot finish
+    before we interrupt it -- important under a loaded parallel run (-j16),
+    where the polling loop can lag by seconds. We then poll for the partial,
+    since rsync moves it into place from its exit_cleanup handler."""
+    proc = subprocess.Popen(
+        rsync_argv('-a', '--no-whole-file', '--bwlimit=400', *extra_args,
+                   f'{src}/', f'{TODIR}/'),
+        stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
+    tdir = TODIR / deepdir
+    caught = False
+    deadline = time.monotonic() + 30
+    while time.monotonic() < deadline:
+        if proc.poll() is not None:
+            break                       # exited before we caught it
+        if tdir.is_dir():
+            temps = [p for p in tdir.glob('.f3.*')
+                     if p.is_file() and p.stat().st_size > 0]
+            if temps:
+                caught = True
+                break
+        time.sleep(0.02)
+    proc.send_signal(signal.SIGTERM)
+    proc.wait()
+    if not caught:
+        test_fail("never caught an in-progress temp (transfer finished too "
+                  "fast to interrupt)")
+    # rsync moves the partial into place from exit_cleanup; give it a moment.
+    pdeadline = time.monotonic() + 5
+    while time.monotonic() < pdeadline:
+        if partial_path.is_file() and partial_path.stat().st_size > 0:
+            return
+        time.sleep(0.05)
+
+
+# --- 1. --partial (no dir): partial kept in the dest file itself, at depth --
+seed_big()
+interrupt_transfer(['--partial'], TODIR / deep)
+if not (TODIR / deep).is_file() or not is_prefix(TODIR / deep):
+    test_fail("--partial did not leave a valid partial in the dest file")
+run_rsync('-a', '--partial', '--no-whole-file', f'{src}/', f'{TODIR}/')
+assert_same(TODIR / deep, src / deep, label='--partial resume')
+
+# --- 2. relative --partial-dir at depth: deterministic clean pre-seed -------
+rmtree(src)
+rmtree(TODIR)
+makepath(src / deepdir, TODIR / deepdir / '.rsync-partial')
+make_data_file(src / deep, 1_000_000)
+full = (src / deep).read_bytes()
+(TODIR / deepdir / '.rsync-partial' / 'f3').write_bytes(full[:400_000])
+run_rsync('-a', '--partial-dir=.rsync-partial', '--no-whole-file',
+          f'{src}/', f'{TODIR}/')
+assert_same(TODIR / deep, src / deep, label='rel partial-dir preseed')
+if (TODIR / deepdir / '.rsync-partial').exists():
+    test_fail("relative --partial-dir not removed after the partial was used")
+
+# --- 3. relative --partial-dir at depth: interrupt then resume -------------
+seed_big()
+part = TODIR / deepdir / '.rsync-partial' / 'f3'
+interrupt_transfer(['--partial-dir=.rsync-partial'], part)
+if not part.is_file() or not is_prefix(part):
+    test_fail("relative --partial-dir did not keep a valid partial at depth")
+run_rsync('-a', '--partial-dir=.rsync-partial', '--no-whole-file',
+          f'{src}/', f'{TODIR}/')
+assert_same(TODIR / deep, src / deep, label='rel partial-dir resume')
+
+# --- 4. absolute --partial-dir OUTSIDE the tree (cross-dir): interrupt -----
+ext = SCRATCHDIR / 'partials'      # sibling of from/ and to/ -- outside both
+rmtree(ext)
+ext.mkdir()
+seed_big()
+interrupt_transfer([f'--partial-dir={ext}'], ext / 'f3')
+if not (ext / 'f3').is_file() or not is_prefix(ext / 'f3'):
+    test_fail("absolute --partial-dir did not write the partial to the "
+              "outside-tree dir")
+run_rsync('-a', f'--partial-dir={ext}', '--whole-file', f'{src}/', f'{TODIR}/')
+assert_same(TODIR / deep, src / deep, label='abs partial-dir resume')
+
+print("partial: --partial + relative/absolute --partial-dir verified at depth")
diff --git a/testsuite/temp-dir_test.py b/testsuite/temp-dir_test.py
new file mode 100644 (file)
index 0000000..63c37ea
--- /dev/null
@@ -0,0 +1,61 @@
+#!/usr/bin/env python3
+"""Coverage of --temp-dir (-T) at depth and across directory boundaries.
+
+--temp-dir tells the receiver to create its scratch/temp copies in DIR rather
+than in the destination directory, then rename the finished file into place.
+That rename crosses from the temp directory to a destination directory several
+levels deep -- exactly the two-directory operation the path-resolution
+restructure rewrites -- so it must be guarded at depth with the temp dir kept
+OUTSIDE the destination tree.
+
+Asserts:
+  * a transfer with --temp-dir pointing outside the dest tree reproduces the
+    source byte-for-byte at every level;
+  * no temp/scratch files are left behind in the temp dir or the dest tree;
+  * a non-existent --temp-dir makes the receiver fail (so we know the option
+    is actually consulted, not silently ignored).
+"""
+
+import os
+
+from rsyncfns import (
+    FROMDIR, SCRATCHDIR, TODIR,
+    assert_same, make_tree, rmtree, run_rsync, test_fail, walk_files,
+)
+
+src = FROMDIR
+tmp = SCRATCHDIR / 'scratch-temp'   # sibling of from/ and to/ -- outside both
+rmtree(src)
+rmtree(TODIR)
+rmtree(tmp)
+tmp.mkdir()
+
+make_tree(src, depth=3, data=True)
+rels = [p.relative_to(src) for p in walk_files(src)]
+
+# Transfer with the temp dir outside the destination tree.
+run_rsync('-a', f'--temp-dir={tmp}', f'{src}/', f'{TODIR}/')
+
+for rel in rels:
+    assert_same(TODIR / rel, src / rel, label=f'temp-dir {rel}')
+
+# The temp dir must be clean afterwards (every scratch file renamed away).
+leftover = sorted(p for p in tmp.rglob('*'))
+if leftover:
+    test_fail(f"--temp-dir left scratch files behind: {leftover}")
+
+# No stray rsync temp files (.name.XXXXXX) anywhere in the dest tree.
+strays = [p for p in TODIR.rglob('.*') if p.is_file()]
+if strays:
+    test_fail(f"dest tree contains stray temp files: {strays}")
+
+# Negative: a missing temp dir must cause a receiver failure, proving the
+# option is honoured rather than ignored.
+rmtree(TODIR)
+proc = run_rsync('-a', f'--temp-dir={SCRATCHDIR}/does-not-exist',
+                 f'{src}/', f'{TODIR}/', check=False)
+if proc.returncode == 0:
+    test_fail("--temp-dir pointing at a missing directory unexpectedly "
+              "succeeded")
+
+print("temp-dir: cross-dir rename at depth verified; missing temp dir fails")