"""Feature tests: incremental sync, bandwidth limiting, dry run, metadata, filters.""" import filecmp import os import random import shutil import socket import stat import subprocess import sys import time import pytest sys.path.insert(0, os.path.dirname(__file__)) from common import ( PROJECT_ROOT, BUILD_DIR, TEST_DATA_DIR, run_client, CountingProxy, generate_test_files, verify_transfer, clean_dir, make_result, get_dest_received_dir, CLIENT_CMD, SERVER_CMD, ServerManager, _find_free_port, _wait_for_port, _wait_proc, ) SOURCE_DIR = os.path.join(TEST_DATA_DIR, "feature_source") DEST_DIR = os.path.join(TEST_DATA_DIR, "feature_dest") DEVICE_SOURCE = os.path.join(TEST_DATA_DIR, "device_source") DEVICE_DEST = os.path.join(TEST_DATA_DIR, "device_dest") def _start_captured_server(prefix=None, extra_args=None): """Start a plain-TCP server with captured stdout/stderr for one test. Returns (proc, port). The caller owns `proc` and must terminate it via `_wait_proc` so a server that ignores SIGTERM is killed instead of leaving a zombie or raising TimeoutExpired. The shared session server discards its output, so tests that lock in a receiver-side warning need their own. The server's SIGTERM handler exits via `_exit`, which does not flush stdio, so `stdbuf -oL` keeps stdout line-buffered and the warning observable.""" port = _find_free_port() cmd = ["stdbuf", "-oL"] + (prefix or []) + SERVER_CMD + ["-p", str(port), "--allow-unauthenticated"] if extra_args: cmd += extra_args proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) _wait_for_port(port) return proc, port def _stop_captured_server(proc): """Terminate a captured server and return its (stdout, stderr) text.""" proc.terminate() _wait_proc(proc) return proc.communicate() class TestDeviceSpecial: """Phase 4: --devices / --specials / -D / --copy-devices / --write-devices. Device node CREATION (mknod) is privileged (CAP_MKNOD); CI runs non-root, so only the FIFO path (mkfifo, unprivileged) is asserted unconditionally. The real-device-created assertions are guarded to run only as root. Everything else must simply succeed / skip without aborting. """ def _setup(self): clean_dir(DEVICE_SOURCE) clean_dir(DEVICE_DEST) with open(os.path.join(DEVICE_SOURCE, "plain.txt"), "wb") as f: f.write(b"regular content\n") def test_specials_recreates_fifo(self, shared_server): self._setup() os.mkfifo(os.path.join(DEVICE_SOURCE, "pipe.fifo")) result, _ = run_client(DEVICE_SOURCE, DEVICE_DEST, flags=["--specials"], port=shared_server.port) assert result.returncode == 0, f"Exit {result.returncode}: {result.stderr[:200]}" received = get_dest_received_dir(DEVICE_DEST, DEVICE_SOURCE) fifo = os.path.join(received, "pipe.fifo") assert os.path.exists(fifo) and stat.S_ISFIFO(os.stat(fifo).st_mode), ( "source FIFO was not recreated as a FIFO on the destination" ) # The regular file alongside it still transferred normally. with open(os.path.join(received, "plain.txt")) as f: assert f.read() == "regular content\n" def test_D_implies_devices_and_specials_fifo(self, shared_server): """-D implies --devices --specials; a FIFO is preserved without a crash even though no device mknod is attempted on the (non-root) receiver.""" self._setup() os.mkfifo(os.path.join(DEVICE_SOURCE, "pipe.fifo")) result, _ = run_client(DEVICE_SOURCE, DEVICE_DEST, flags=["-D"], port=shared_server.port) assert result.returncode == 0, f"Exit {result.returncode}: {result.stderr[:200]}" received = get_dest_received_dir(DEVICE_DEST, DEVICE_SOURCE) assert stat.S_ISFIFO(os.stat(os.path.join(received, "pipe.fifo")).st_mode) @pytest.mark.ci def test_specials_recreates_socket(self, shared_server): """--specials recreates a unix-domain socket with mknod(S_IFSOCK), which Linux permits unprivileged; the adjacent regular file still transfers.""" self._setup() sock_path = os.path.join(DEVICE_SOURCE, "source.sock") s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) try: s.bind(sock_path) result, _ = run_client(DEVICE_SOURCE, DEVICE_DEST, flags=["--specials"], port=shared_server.port) finally: s.close() assert result.returncode == 0, f"Exit {result.returncode}: {result.stderr[:200]}" received = get_dest_received_dir(DEVICE_DEST, DEVICE_SOURCE) with open(os.path.join(received, "plain.txt")) as f: assert f.read() == "regular content\n" dest_sock = os.path.join(received, "source.sock") assert os.path.lexists(dest_sock), "socket source was not recreated" assert stat.S_ISSOCK(os.lstat(dest_sock).st_mode), ( "socket source must be recreated as a socket node" ) @pytest.mark.ci def test_special_default_skips_non_regular(self, shared_server): """Without --specials, rsync skips a FIFO/socket as a non-regular file; FastSync must skip it (never copy it as an empty regular file).""" self._setup() os.mkfifo(os.path.join(DEVICE_SOURCE, "skip.fifo")) sock_path = os.path.join(DEVICE_SOURCE, "skip.sock") s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) try: s.bind(sock_path) result, _ = run_client(DEVICE_SOURCE, DEVICE_DEST, flags=[], port=shared_server.port) finally: s.close() assert result.returncode == 0, f"Exit {result.returncode}: {result.stderr[:200]}" received = get_dest_received_dir(DEVICE_DEST, DEVICE_SOURCE) assert not os.path.lexists(os.path.join(received, "skip.fifo")) assert not os.path.lexists(os.path.join(received, "skip.sock")) with open(os.path.join(received, "plain.txt")) as f: assert f.read() == "regular content\n" @pytest.mark.ci @pytest.mark.parametrize("flags", [["--copy-devices"], ["--copy-devices", "--sendfile"]]) def test_copy_devices_skips_fifo_without_specials(self, shared_server, flags): """rsync's --copy-devices applies to device nodes only; a FIFO/socket is a non-regular entry and is skipped unless --specials is also given. In particular it must never hang in the sendfile open().""" self._setup() os.mkfifo(os.path.join(DEVICE_SOURCE, "device_copy.fifo")) result, dur = run_client(DEVICE_SOURCE, DEVICE_DEST, flags=flags, port=shared_server.port) assert result.returncode == 0, f"Exit {result.returncode}: {result.stderr[:200]}" received = get_dest_received_dir(DEVICE_DEST, DEVICE_SOURCE) assert not os.path.lexists(os.path.join(received, "device_copy.fifo")), ( "a FIFO under --copy-devices alone must be skipped, not materialized" ) assert dur < 60, f"{' '.join(flags)} hung on a FIFO source" def test_write_devices_non_crash(self, shared_server): """--write-devices writes into an existing device only; when the destination holds no device node the entry is skipped safely and the run still succeeds (never aborts).""" self._setup() # Destination already holds a regular file at the source FIFO's path: # the receiver must not clobber it and must not crash. os.mkfifo(os.path.join(DEVICE_SOURCE, "target.fifo")) result, _ = run_client(DEVICE_SOURCE, DEVICE_DEST, flags=["--write-devices"], port=shared_server.port) assert result.returncode == 0, f"Exit {result.returncode}: {result.stderr[:200]}" @pytest.mark.ci def test_write_devices_regular_file_target_skipped(self, shared_server): """--write-devices only ever writes into an existing char/block node: a pre-existing REGULAR file at the destination path is left byte-identical (not clobbered) and the run still succeeds.""" self._setup() received = get_dest_received_dir(DEVICE_DEST, DEVICE_SOURCE) os.makedirs(received, exist_ok=True) target = os.path.join(received, "plain.txt") with open(target, "wb") as f: f.write(b"pre-existing local content\n") result, _ = run_client(DEVICE_SOURCE, DEVICE_DEST, flags=["--write-devices"], port=shared_server.port) assert result.returncode == 0, f"Exit {result.returncode}: {result.stderr[:200]}" with open(target, "rb") as f: assert f.read() == b"pre-existing local content\n", ( "write-devices clobbered a non-device destination" ) @pytest.mark.setpriv def test_devices_nonroot_receiver_skips_safely(self): """A receiver without CAP_MKNOD must skip a device entry with a warning and never abort. A root runner drops the receiver (server) to nobody via setpriv; on a non-root runner (or without setpriv) the test skips.""" if os.geteuid() != 0 or shutil.which("setpriv") is None: pytest.skip("requires root + setpriv to run the receiver unprivileged") self._setup() os.mknod(os.path.join(DEVICE_SOURCE, "chardev"), stat.S_IFCHR | 0o666, os.makedev(1, 3)) # The unprivileged receiver must be able to create the destination tree. os.makedirs(DEVICE_DEST, exist_ok=True) os.chmod(DEVICE_DEST, 0o777) server, port = _start_captured_server( prefix=["setpriv", "--reuid=65534", "--regid=65534", "--clear-groups"]) try: result, _ = run_client(DEVICE_SOURCE, DEVICE_DEST, flags=["--devices"], port=port) finally: out, err = _stop_captured_server(server) assert result.returncode == 0, f"Exit {result.returncode}: {result.stderr[:300]}" received = get_dest_received_dir(DEVICE_DEST, DEVICE_SOURCE) with open(os.path.join(received, "plain.txt")) as f: assert f.read() == "regular content\n" assert not os.path.lexists(os.path.join(received, "chardev")), ( "a receiver without CAP_MKNOD must skip the device node, not create it" ) assert ("cannot create device node" in (out + err) or "device-node creation is not permitted" in (out + err)), ( f"receiver did not log the documented device skip: out={out!r} err={err!r}" ) @pytest.mark.skipif(os.geteuid() != 0, reason="requires root to create device nodes") def test_devices_recreates_real_char_device(self, shared_server): """Root-only: a source char device node is recreated on the destination with the same type and rdev (privilege-gated mknod path).""" self._setup() src_dev = os.path.join(DEVICE_SOURCE, "realdev") os.mknod(src_dev, stat.S_IFCHR | 0o666, os.makedev(1, 3)) result, _ = run_client(DEVICE_SOURCE, DEVICE_DEST, flags=["--devices"], port=shared_server.port) assert result.returncode == 0, f"Exit {result.returncode}: {result.stderr[:200]}" received = get_dest_received_dir(DEVICE_DEST, DEVICE_SOURCE) st = os.lstat(os.path.join(received, "realdev")) assert stat.S_ISCHR(st.st_mode) assert os.major(st.st_rdev) == 1 and os.minor(st.st_rdev) == 3 @pytest.mark.skipif(os.geteuid() != 0, reason="requires root to create device nodes") def test_copy_devices_copies_device_as_regular(self, shared_server): """Root-only: --copy-devices copies a device's content into an ordinary regular file instead of recreating the node. /dev/null (1,3) has size 0, so the result is a 0-byte REGULAR file.""" self._setup() src_dev = os.path.join(DEVICE_SOURCE, "copieddev") os.mknod(src_dev, stat.S_IFCHR | 0o666, os.makedev(1, 3)) result, _ = run_client(DEVICE_SOURCE, DEVICE_DEST, flags=["-a", "--copy-devices"], port=shared_server.port) assert result.returncode == 0, f"Exit {result.returncode}: {result.stderr[:200]}" received = get_dest_received_dir(DEVICE_DEST, DEVICE_SOURCE) st = os.lstat(os.path.join(received, "copieddev")) assert stat.S_ISREG(st.st_mode), ( f"--copy-devices must produce a regular file, got mode {oct(st.st_mode)}" ) assert st.st_size == 0 def test_m_remove_source_files_keeps_recreated_fifo(self, shared_server): """--threads --remove-source-files --specials: a recreated FIFO must NOT be acknowledged as a removable source (its outcome must not shift the per-file status stream, which would break the run and mis-remove the adjacent regular file). The regular file is removed; the FIFO stays.""" self._setup() os.mkfifo(os.path.join(DEVICE_SOURCE, "pipe.fifo")) result, _ = run_client(DEVICE_SOURCE, DEVICE_DEST, flags=["--threads", "--remove-source-files", "--specials"], port=shared_server.port) assert result.returncode == 0, ( f"Exit {result.returncode}: {result.stderr[:300]}" ) assert not os.path.exists(os.path.join(DEVICE_SOURCE, "plain.txt")), ( "regular source file should have been removed" ) assert os.path.exists(os.path.join(DEVICE_SOURCE, "pipe.fifo")), ( "recreated FIFO source must never be removed" ) @pytest.mark.skipif(os.geteuid() != 0, reason="requires root to create device nodes") def test_m_remove_source_files_keeps_recreated_device(self, shared_server): """Root-only: --threads --remove-source-files --devices must not remove a source device node the receiver recreated (mirrors the single-threaded behavior; the special is never acknowledged as a removable source).""" self._setup() src_dev = os.path.join(DEVICE_SOURCE, "realdev") os.mknod(src_dev, stat.S_IFCHR | 0o666, os.makedev(1, 3)) result, _ = run_client(DEVICE_SOURCE, DEVICE_DEST, flags=["--threads", "--remove-source-files", "--devices"], port=shared_server.port) assert result.returncode == 0, ( f"Exit {result.returncode}: {result.stderr[:300]}" ) assert not os.path.exists(os.path.join(DEVICE_SOURCE, "plain.txt")), ( "regular source file should have been removed" ) assert os.path.exists(src_dev) and stat.S_ISCHR(os.lstat(src_dev).st_mode), ( "recreated device source must never be removed" ) def test_write_devices_fifo_target_skips_not_hangs(self, shared_server): """--write-devices must never block on a pre-existing FIFO at the destination mirror: opening with O_NONBLOCK fails with ENXIO and the entry is skipped (the FIFO is left untouched and the run succeeds).""" self._setup() # Pre-plant a FIFO at the destination mirror of the source file's path. received = get_dest_received_dir(DEVICE_DEST, DEVICE_SOURCE) os.makedirs(received, exist_ok=True) target = os.path.join(received, "plain.txt") os.mkfifo(target) result, dur = run_client(DEVICE_SOURCE, DEVICE_DEST, flags=["--write-devices"], port=shared_server.port) assert result.returncode == 0, f"Exit {result.returncode}: {result.stderr[:200]}" assert stat.S_ISFIFO(os.lstat(target).st_mode), "FIFO target was clobbered" assert dur < 60, "write-devices hung on a FIFO target" def test_special_confined_to_receive_root(self, shared_server): """A special node is created only under the receive root; nothing is ever materialized outside it (the receiver is confined to its authorized root).""" self._setup() os.mkfifo(os.path.join(DEVICE_SOURCE, "confined.fifo")) result, _ = run_client(DEVICE_SOURCE, DEVICE_DEST, flags=["--specials"], port=shared_server.port) assert result.returncode == 0, f"Exit {result.returncode}: {result.stderr[:200]}" # The only new FIFO is under the receive tree; its sibling watchers # confirm the confined dest layout (no stray node at the source root). source_fifo_escaped = os.path.join(DEVICE_DEST, "confined.fifo") assert not os.path.lexists(source_fifo_escaped), "special escaped the receive root" received = get_dest_received_dir(DEVICE_DEST, DEVICE_SOURCE) assert stat.S_ISFIFO(os.stat(os.path.join(received, "confined.fifo")).st_mode) @pytest.fixture(scope="module", autouse=True) def setup_test_data(): generate_test_files(SOURCE_DIR, full=False) clean_dir(DEST_DIR) yield # Remove only this module's own dirs. Under pytest-xdist the whole # (worker-keyed) TEST_DATA_DIR is shared with concurrently-interleaved # modules, so never rmtree it here. shutil.rmtree(SOURCE_DIR, ignore_errors=True) shutil.rmtree(DEST_DIR, ignore_errors=True) class TestDryRun: def test_trust_sender_transfer_completes(self, shared_server): """--trust-sender is a receiver-local policy (never sent to the peer). A transfer run with it must still complete and produce byte-identical results: the receiver keeps its low-level root confinement, so a normal trusted transfer is unchanged.""" clean_dir(DEST_DIR) received = get_dest_received_dir(DEST_DIR, SOURCE_DIR) result, _ = run_client(SOURCE_DIR, DEST_DIR, flags=["--trust-sender"], port=shared_server.port) assert result.returncode == 0, f"Exit {result.returncode}: {result.stderr[:200]}" mismatches, missing = verify_transfer(SOURCE_DIR, received) assert not missing, f"Missing files: {missing[:5]}" assert not mismatches, f"Mismatched files: {mismatches[:5]}" def test_human_readable_dry_run(self): result, dur = run_client(SOURCE_DIR, DEST_DIR, flags=["-h", "--dry-run"]) assert result.returncode == 0, f"Exit {result.returncode}: {result.stderr[:100]}" assert "Total:" in result.stdout assert any(unit in result.stdout for unit in ("K", "M", "G")) def test_dry_run(self): clean_dir(DEST_DIR) result, dur = run_client( SOURCE_DIR, DEST_DIR, flags=["-n"], ) assert result.returncode == 0, f"Exit {result.returncode}: {result.stderr[:100]}" assert "Dry run:" in result.stdout, f"No dry run output: {result.stdout[:200]}" def test_quiet_suppresses_dry_run_output(self): result, dur = run_client( SOURCE_DIR, DEST_DIR, flags=["-q", "-n", "--progress", "--stats"], ) assert result.returncode == 0, f"Exit {result.returncode}: {result.stderr[:100]}" assert result.stdout == "" assert result.stderr == "" def test_quiet_preserves_errors(self): result, dur = run_client( SOURCE_DIR, DEST_DIR, flags=["--quiet", "--server-port", "1"], ) assert result.returncode != 0 assert result.stderr != "" @pytest.mark.parametrize("flags", [["-q", "-v"], ["-v", "-q"]]) def test_quiet_successful_transfer_and_verbose_order(self, shared_server, flags): clean_dir(DEST_DIR) result, dur = run_client( SOURCE_DIR, DEST_DIR, flags=flags, port=shared_server.port, ) assert result.returncode == 0, f"Exit {result.returncode}: {result.stderr[:100]}" assert result.stdout == "" assert result.stderr == "" received = get_dest_received_dir(DEST_DIR, SOURCE_DIR) mismatches, missing = verify_transfer(SOURCE_DIR, received) assert not missing, f"Missing: {missing}" assert not mismatches, f"Mismatch: {mismatches}" def _snapshot_xattrs(path): """Return a stable, comparable tuple of (name, value) xattr pairs. Returns None when the platform/filesystem does not expose xattrs so both snapshots agree on "unavailable" instead of one being treated as changed.""" try: names = os.listxattr(path, follow_symlinks=False) except (AttributeError, OSError): return None if not names: return () pairs = [] for name in sorted(names): try: value = os.getxattr(path, name, follow_symlinks=False) except OSError: value = None pairs.append((name, value)) return tuple(pairs) def _snapshot_tree(root): """Return a structural snapshot of a directory tree. Every entry (including directories) is recorded as (inode, mtime_ns, mode, xattrs, kind-specific payload) so a dry-run that touched a mode, inode, mtime, xattr, or content is observable. Regular files carry their size+bytes, symlinks their target, and special entries (FIFO/socket/device) their size only -- opening a special file could block. Returns an empty dict for a missing root so "nothing was created" is also observable.""" snapshot = {} if not os.path.exists(root): return snapshot for dirpath, dirnames, filenames in os.walk(root): for name in list(dirnames) + filenames: path = os.path.join(dirpath, name) rel = os.path.relpath(path, root) st = os.lstat(path) entry = [st.st_ino, st.st_mtime_ns, stat.S_IMODE(st.st_mode), _snapshot_xattrs(path)] if stat.S_ISLNK(st.st_mode): entry.append(("symlink", os.readlink(path))) elif stat.S_ISREG(st.st_mode): with open(path, "rb") as fh: data = fh.read() entry += [st.st_size, data] else: entry.append(st.st_size) snapshot[rel] = tuple(entry) return snapshot class TestRemoteDryRun: """Server-contacting --dry-run (protocol 2.21.0): contacts the receiver, reports what WOULD transfer/skip based on receiver state, and mutates nothing on either side.""" def _seed(self, source): clean_dir(source) os.makedirs(os.path.join(source, "nested"), exist_ok=True) with open(os.path.join(source, "keep.txt"), "wb") as f: f.write(b"unchanged content\n") with open(os.path.join(source, "changed.txt"), "wb") as f: f.write(b"original content\n") with open(os.path.join(source, "nested", "deep.txt"), "wb") as f: f.write(b"deep file\n") @pytest.mark.ci def test_remote_dry_run_reports_changes_and_mutates_nothing(self, shared_server): source = os.path.join(TEST_DATA_DIR, "remote_dry_src") dest = os.path.join(TEST_DATA_DIR, "remote_dry_dst") self._seed(source) clean_dir(dest) # Populate the destination with a real transfer that preserves mtimes # (--preserve), then make exactly one file differ (content+size) and add # a brand-new file. result, _ = run_client(source, dest, flags=["--preserve"], port=shared_server.port) assert result.returncode == 0, f"seed transfer failed: {result.stderr[:200]}" received = get_dest_received_dir(dest, source) with open(os.path.join(source, "changed.txt"), "wb") as f: f.write(b"a much longer replacement payload\n") with open(os.path.join(source, "added.txt"), "wb") as f: f.write(b"newly added\n") before = _snapshot_tree(received) # --checksum must NOT read destination contents in a dry-run (B3), so # the up-to-date decision is metadata-only. The --preserve seed made # keep.txt and deep.txt size+mtime-identical; the dry-run must also # transmit metadata (--preserve) for that metadata to be comparable. result, _ = run_client(source, dest, flags=["--dry-run", "--checksum", "--preserve"], port=shared_server.port) assert result.returncode == 0, f"remote dry-run failed: {result.stderr[:300]}" assert "Dry run:" in result.stdout, result.stdout[:200] assert "changed.txt" in result.stdout, result.stdout assert "added.txt" in result.stdout, result.stdout assert "keep.txt" not in result.stdout, ( f"up-to-date file must not be reported as would-transfer: {result.stdout}" ) assert "deep.txt" not in result.stdout, result.stdout assert _snapshot_tree(received) == before, "remote dry-run mutated the destination" @pytest.mark.ci def test_remote_dry_run_checksum_does_not_read_destination(self, shared_server): """B3: --dry-run --checksum against a read-only module must not read the destination file's content (a 1-bit hash oracle). A same-size/same-content file whose mtime differs is therefore reported as would-transfer because the metadata-only decision is inconclusive, instead of being hashed and silently skipped.""" source = os.path.join(TEST_DATA_DIR, "remote_dry_oracle_src") dest = os.path.join(TEST_DATA_DIR, "remote_dry_oracle_dst") self._seed(source) clean_dir(dest) result, _ = run_client(source, dest, flags=["--preserve"], port=shared_server.port) assert result.returncode == 0, result.stderr[:200] received = get_dest_received_dir(dest, source) target = os.path.join(received, "keep.txt") # Identical size and content, but a deliberately different mtime. os.utime(target, (1000000000, 1000000000)) before = _snapshot_tree(received) result, _ = run_client(source, dest, flags=["--dry-run", "--checksum", "--preserve"], port=shared_server.port) assert result.returncode == 0, result.stderr[:300] assert "keep.txt" in result.stdout, ( f"dry-run --checksum must not read the destination to prove equality: {result.stdout}" ) assert _snapshot_tree(received) == before, "dry-run mutated the destination" @pytest.mark.ci def test_remote_dry_run_into_empty_dest_creates_nothing(self, shared_server): source = os.path.join(TEST_DATA_DIR, "remote_dry_empty_src") dest = os.path.join(TEST_DATA_DIR, "remote_dry_empty_dst") self._seed(source) clean_dir(dest) received = get_dest_received_dir(dest, source) assert not os.path.exists(received) result, _ = run_client(source, dest, flags=["--dry-run"], port=shared_server.port) assert result.returncode == 0, f"exit {result.returncode}: {result.stderr[:300]}" assert "keep.txt" in result.stdout assert "changed.txt" in result.stdout assert "deep.txt" in result.stdout # Nowhere may the receiver have created the destination mirror. assert not os.path.exists(received), "dry-run created directories on the receiver" assert _snapshot_tree(received) == {} @pytest.mark.ci def test_remote_dry_run_mkpath_does_not_create_root(self, shared_server): """A wire dry_run cannot make --mkpath create anything, and it cannot relax the precondition either: a nonexistent root is rejected (a real run without the created root is impossible in dry-run) while nothing is created.""" source = os.path.join(TEST_DATA_DIR, "remote_dry_mk_src") dest = os.path.join(TEST_DATA_DIR, "remote_dry_mk_dst") self._seed(source) shutil.rmtree(dest, ignore_errors=True) assert not os.path.exists(dest) result, _ = run_client(source, dest, flags=["--dry-run", "--mkpath"], port=shared_server.port) assert result.returncode != 0, "dry-run --mkpath accepted a nonexistent receive root" assert not os.path.exists(dest), "dry-run --mkpath created the destination root" @pytest.mark.ci def test_remote_dry_run_with_delete_does_not_delete(self, shared_server): source = os.path.join(TEST_DATA_DIR, "remote_dry_del_src") dest = os.path.join(TEST_DATA_DIR, "remote_dry_del_dst") self._seed(source) clean_dir(dest) result, _ = run_client(source, dest, port=shared_server.port) assert result.returncode == 0, result.stderr[:200] received = get_dest_received_dir(dest, source) extra = os.path.join(received, "extra.txt") with open(extra, "wb") as f: f.write(b"must survive a dry-run delete\n") before = _snapshot_tree(received) for flags in (["--dry-run", "--delete"], ["--dry-run", "--delete-after"]): result, _ = run_client(source, dest, flags=flags, port=shared_server.port) assert result.returncode == 0, f"{flags}: {result.stderr[:300]}" assert os.path.exists(extra), f"{flags} deleted an extra in dry-run" assert _snapshot_tree(received) == before, f"{flags} mutated the destination" @pytest.mark.skipif(shutil.which("rsync") is None, reason="rsync not installed") def test_dry_run_delete_lines_match_rsync(self): """`-n --delete` lists exactly the destination extras rsync would remove. Covers the three cases that a real run protects: the file being updated (in the keep set), a filter-excluded source entry (protected prefix), and a --max-size-pruned source entry (always-protected prefix). Track 4a adds a fourth: a destination-only entry matching the exclude rule is re-derived on the receiver and also protected, so only the genuine destination-only `extra.txt` appears. """ source = os.path.join(TEST_DATA_DIR, "dryrep_src") rdst = os.path.join(TEST_DATA_DIR, "dryrep_rdst") fdst = os.path.join(TEST_DATA_DIR, "dryrep_fdst") clean_dir(source) clean_dir(rdst) clean_dir(fdst) for name, data in (("a.txt", b"new content\n"), ("keep.log", b"log\n"), ("big.bin", b"B" * 2000)): with open(os.path.join(source, name), "wb") as fh: fh.write(data) os.utime(os.path.join(source, "a.txt"), (1_700_000_000, 1_700_000_000)) received = get_dest_received_dir(fdst, source) os.makedirs(received, exist_ok=True) for root in (rdst, received): for name, data in (("a.txt", b"old\n"), ("keep.log", b"log\n"), ("big.bin", b"B" * 2000), ("extra.txt", b"extra\n"), ("stray.log", b"dest only\n")): with open(os.path.join(root, name), "wb") as fh: fh.write(data) os.utime(os.path.join(root, name), (1_500_000_000, 1_500_000_000)) flags = ["-a", "-n", "-i", "--delete", "--exclude=*.log", "--max-size=1000"] r = subprocess.run(["rsync", "-an", "-i", "--delete", "--exclude=*.log", "--max-size=1000", source + "/", rdst + "/"], capture_output=True, text=True, env=dict(os.environ, LC_ALL="C")) assert r.returncode == 0, r.stderr rsync_del = sorted(l for l in r.stdout.splitlines() if l.startswith("*deleting")) assert rsync_del == ["*deleting extra.txt"], f"unexpected rsync set: {rsync_del}" with ServerManager() as server: server.start(extra_args=["--allow-delete"]) result, _ = run_client(source, fdst, flags=flags, port=server.port) assert result.returncode == 0, (result.stderr or result.stdout)[:300] fs_del = sorted(l for l in (result.stdout or "").splitlines() if l.startswith("*deleting")) assert fs_del == rsync_del, f"rsync={rsync_del}\nfastsync={fs_del}" assert os.path.exists(os.path.join(received, "stray.log")), \ "destination-only exclude match must be protected in the dry-run report" @pytest.mark.ci def test_remote_dry_run_quiet_is_silent(self, shared_server): source = os.path.join(TEST_DATA_DIR, "remote_dry_quiet_src") dest = os.path.join(TEST_DATA_DIR, "remote_dry_quiet_dst") self._seed(source) clean_dir(dest) result, _ = run_client(source, dest, flags=["-q", "--dry-run"], port=shared_server.port) assert result.returncode == 0, result.stderr[:300] assert result.stdout == "" assert result.stderr == "" @pytest.mark.ci def test_remote_dry_run_threaded_routes_to_server(self, shared_server): source = os.path.join(TEST_DATA_DIR, "remote_dry_mt_src") dest = os.path.join(TEST_DATA_DIR, "remote_dry_mt_dst") self._seed(source) clean_dir(dest) result, _ = run_client(source, dest, flags=["--dry-run", "--threads"], port=shared_server.port) assert result.returncode == 0, result.stderr[:300] assert "changed.txt" in result.stdout assert _snapshot_tree(get_dest_received_dir(dest, source)) == {} @pytest.mark.ci def test_normal_transfer_unaffected_by_dry_run(self, shared_server): """A real transfer after dry-run still installs the changes.""" source = os.path.join(TEST_DATA_DIR, "remote_dry_normal_src") dest = os.path.join(TEST_DATA_DIR, "remote_dry_normal_dst") self._seed(source) clean_dir(dest) run_client(source, dest, port=shared_server.port) received = get_dest_received_dir(dest, source) with open(os.path.join(source, "changed.txt"), "wb") as f: f.write(b"updated payload for the real transfer\n") run_client(source, dest, flags=["--dry-run"], port=shared_server.port) result, _ = run_client(source, dest, port=shared_server.port) assert result.returncode == 0, result.stderr[:200] with open(os.path.join(received, "changed.txt"), "rb") as f: assert f.read() == b"updated payload for the real transfer\n" @pytest.mark.ci def test_remote_dry_run_delay_updates_mutates_nothing(self, shared_server): """--delay-updates stages under the receive root; a dry-run must neither create that staging tree nor publish anything (mode/inode/mtime intact).""" source = os.path.join(TEST_DATA_DIR, "remote_dry_delay_src") dest = os.path.join(TEST_DATA_DIR, "remote_dry_delay_dst") self._seed(source) clean_dir(dest) result, _ = run_client(source, dest, flags=["--delay-updates"], port=shared_server.port) assert result.returncode == 0, result.stderr[:200] with open(os.path.join(source, "changed.txt"), "wb") as f: f.write(b"changed for delay-updates dry-run\n") before = _snapshot_tree(dest) result, _ = run_client(source, dest, flags=["--dry-run", "--delay-updates"], port=shared_server.port) assert result.returncode == 0, result.stderr[:300] assert "changed.txt" in result.stdout, result.stdout assert _snapshot_tree(dest) == before, "delay-updates dry-run mutated the destination" @pytest.mark.ci def test_remote_dry_run_backup_mutates_nothing(self, shared_server): """--backup would rename the old file aside; a dry-run must not.""" source = os.path.join(TEST_DATA_DIR, "remote_dry_backup_src") dest = os.path.join(TEST_DATA_DIR, "remote_dry_backup_dst") self._seed(source) clean_dir(dest) result, _ = run_client(source, dest, port=shared_server.port) assert result.returncode == 0, result.stderr[:200] with open(os.path.join(source, "changed.txt"), "wb") as f: f.write(b"changed for backup dry-run\n") before = _snapshot_tree(dest) result, _ = run_client(source, dest, flags=["--dry-run", "--backup"], port=shared_server.port) assert result.returncode == 0, result.stderr[:300] assert "changed.txt" in result.stdout, result.stdout assert _snapshot_tree(dest) == before, "--backup dry-run mutated the destination" @pytest.mark.ci def test_remote_dry_run_symlink_mutates_nothing(self, shared_server): source = os.path.join(TEST_DATA_DIR, "remote_dry_symlink_src") dest = os.path.join(TEST_DATA_DIR, "remote_dry_symlink_dst") self._seed(source) os.symlink("changed.txt", os.path.join(source, "link")) clean_dir(dest) result, _ = run_client(source, dest, flags=["-a"], port=shared_server.port) assert result.returncode == 0, result.stderr[:200] received = get_dest_received_dir(dest, source) assert os.path.islink(os.path.join(received, "link")) # Re-point the source link so the entry is genuinely stale, then prove a # dry-run leaves the destination link target, inode, and mtime untouched. os.unlink(os.path.join(source, "link")) os.symlink("keep.txt", os.path.join(source, "link")) before = _snapshot_tree(dest) result, _ = run_client(source, dest, flags=["-a", "--dry-run"], port=shared_server.port) assert result.returncode == 0, result.stderr[:300] assert _snapshot_tree(dest) == before, "symlink dry-run mutated the destination" assert os.readlink(os.path.join(received, "link")) == "changed.txt" @pytest.mark.ci def test_remote_dry_run_hardlink_mutates_nothing(self, shared_server): source = os.path.join(TEST_DATA_DIR, "remote_dry_hardlink_src") dest = os.path.join(TEST_DATA_DIR, "remote_dry_hardlink_dst") clean_dir(source) clean_dir(dest) with open(os.path.join(source, "h1.txt"), "wb") as f: f.write(b"hardlinked payload\n") os.link(os.path.join(source, "h1.txt"), os.path.join(source, "h2.txt")) result, _ = run_client(source, dest, flags=["-H"], port=shared_server.port) assert result.returncode == 0, result.stderr[:200] received = get_dest_received_dir(dest, source) assert os.stat(os.path.join(received, "h1.txt")).st_ino == \ os.stat(os.path.join(received, "h2.txt")).st_ino # Change the shared inode; both names are now stale in the destination. with open(os.path.join(source, "h1.txt"), "wb") as f: f.write(b"changed hardlinked payload\n") before = _snapshot_tree(dest) result, _ = run_client(source, dest, flags=["-H", "--dry-run"], port=shared_server.port) assert result.returncode == 0, result.stderr[:300] assert _snapshot_tree(dest) == before, "hardlink dry-run mutated the destination" @pytest.mark.ci def test_remote_dry_run_fifo_special_mutates_nothing(self, shared_server): source = os.path.join(TEST_DATA_DIR, "remote_dry_fifo_src") dest = os.path.join(TEST_DATA_DIR, "remote_dry_fifo_dst") clean_dir(source) clean_dir(dest) with open(os.path.join(source, "plain.txt"), "wb") as f: f.write(b"plain\n") os.mkfifo(os.path.join(source, "existing.fifo")) result, _ = run_client(source, dest, flags=["--specials"], port=shared_server.port) assert result.returncode == 0, result.stderr[:200] received = get_dest_received_dir(dest, source) assert stat.S_ISFIFO(os.lstat(os.path.join(received, "existing.fifo")).st_mode) os.mkfifo(os.path.join(source, "new.fifo")) before = _snapshot_tree(dest) result, _ = run_client(source, dest, flags=["--specials", "--dry-run"], port=shared_server.port) assert result.returncode == 0, result.stderr[:300] assert not os.path.exists(os.path.join(received, "new.fifo")), \ "dry-run created a FIFO on the receiver" assert _snapshot_tree(dest) == before, "special-node dry-run mutated the destination" @pytest.mark.ci def test_read_batch_with_dry_run_is_refused(self, shared_server): """A dry-run of a local batch apply is meaningless (and must not become a mutation escape hatch): the CLI rejects the combination up front.""" source = os.path.join(TEST_DATA_DIR, "remote_dry_batch_src") dest = os.path.join(TEST_DATA_DIR, "remote_dry_batch_dst") self._seed(source) clean_dir(dest) result, _ = run_client(source, dest, flags=["--read-batch=/nonexistent.batch", "--dry-run"], port=shared_server.port) assert result.returncode != 0, "read-batch + dry-run was accepted" combined = (result.stderr or "") + (result.stdout or "") assert "cannot be combined" in combined or "--dry-run" in combined, combined[:300] @pytest.mark.ci def test_remote_dry_run_bad_root_fails_like_real_run(self, shared_server): """A wire dry_run must not relax the destination-root precondition: a missing or non-directory root that fails a real run fails a dry-run too, and the dry-run must not create/replace anything.""" source = os.path.join(TEST_DATA_DIR, "remote_dry_badroot_src") self._seed(source) missing = os.path.join(TEST_DATA_DIR, "remote_dry_badroot_missing") shutil.rmtree(missing, ignore_errors=True) real, _ = run_client(source, missing, port=shared_server.port) assert real.returncode != 0, "real run accepted a missing receive root" assert not os.path.exists(missing), "real run created the missing root" dry, _ = run_client(source, missing, flags=["--dry-run"], port=shared_server.port) assert dry.returncode != 0, "dry-run accepted a missing receive root a real run rejects" assert not os.path.exists(missing), "dry-run created the missing receive root" fileroot = os.path.join(TEST_DATA_DIR, "remote_dry_badroot_file") shutil.rmtree(fileroot, ignore_errors=True) with open(fileroot, "wb") as f: f.write(b"i am a regular file, not a directory\n") real, _ = run_client(source, fileroot, port=shared_server.port) assert real.returncode != 0, "real run accepted a regular-file receive root" dry, _ = run_client(source, fileroot, flags=["--dry-run"], port=shared_server.port) assert dry.returncode != 0, "dry-run accepted a regular-file receive root a real run rejects" with open(fileroot, "rb") as f: assert f.read() == b"i am a regular file, not a directory\n", \ "dry-run clobbered a regular-file receive root" class TestRemoveSourceFiles: def test_removes_only_transferred_regular_files(self, shared_server): source = os.path.join(TEST_DATA_DIR, "remove_source") dest = os.path.join(TEST_DATA_DIR, "remove_dest") clean_dir(source) clean_dir(dest) with open(os.path.join(source, "one.txt"), "wb") as f: f.write(b"one") with open(os.path.join(source, "two.txt"), "wb") as f: f.write(b"two") os.makedirs(os.path.join(source, "directory")) os.symlink("one.txt", os.path.join(source, "link.txt")) result, _ = run_client(source, dest, flags=["--remove-source-files", "--threads"], port=shared_server.port) assert result.returncode == 0, f"Remove-source sync failed: {result.stderr[:200]}" assert not os.path.exists(os.path.join(source, "one.txt")) assert not os.path.exists(os.path.join(source, "two.txt")) assert os.path.isdir(os.path.join(source, "directory")) assert os.path.islink(os.path.join(source, "link.txt")) def test_single_threaded_removes_transferred_file(self, shared_server): source = os.path.join(TEST_DATA_DIR, "remove_single_source") dest = os.path.join(TEST_DATA_DIR, "remove_single_dest") clean_dir(source) clean_dir(dest) source_file = os.path.join(source, "file.txt") with open(source_file, "wb") as f: f.write(b"single threaded") result, _ = run_client(source, dest, flags=["--remove-source-files"], port=shared_server.port) assert result.returncode == 0 assert not os.path.exists(source_file) def test_dry_run_preserves_source_files(self): source = os.path.join(TEST_DATA_DIR, "remove_dry_source") dest = os.path.join(TEST_DATA_DIR, "remove_dry_dest") clean_dir(source) clean_dir(dest) source_file = os.path.join(source, "file.txt") with open(source_file, "wb") as f: f.write(b"keep") result, _ = run_client(source, dest, flags=["--remove-source-files", "--dry-run"]) assert result.returncode == 0 assert os.path.isfile(source_file) def test_failed_connection_preserves_source_files(self): source = os.path.join(TEST_DATA_DIR, "remove_failed_source") dest = os.path.join(TEST_DATA_DIR, "remove_failed_dest") clean_dir(source) clean_dir(dest) source_file = os.path.join(source, "file.txt") with open(source_file, "wb") as f: f.write(b"keep after failure") result, _ = run_client(source, dest, flags=["--remove-source-files"], port=1) assert result.returncode != 0 assert os.path.isfile(source_file) def test_incremental_skip_preserves_source_file(self, shared_server): source = os.path.join(TEST_DATA_DIR, "remove_skipped_source") dest = os.path.join(TEST_DATA_DIR, "remove_skipped_dest") clean_dir(source) clean_dir(dest) source_file = os.path.join(source, "file.txt") with open(source_file, "wb") as f: f.write(b"keep after skip") # The seed run preserves timestamps (--preserve) so the destination copy has the # source's exact mtime; otherwise the incremental skip would depend on # both writes landing in the same whole second (a race). result, _ = run_client(source, dest, flags=["--preserve"], port=shared_server.port) assert result.returncode == 0 result, _ = run_client(source, dest, flags=["--remove-source-files", "--incremental"], port=shared_server.port) assert result.returncode == 0 assert os.path.isfile(source_file) class TestArchiveMode: @pytest.mark.ci def test_archive_mode(self, shared_server): clean_dir(DEST_DIR) result, dur = run_client( SOURCE_DIR, DEST_DIR, flags=["-a"], port=shared_server.port, ) if result.returncode != 0: pytest.fail(f"Exit {result.returncode}: {(result.stderr or result.stdout)[:200]}") received = get_dest_received_dir(DEST_DIR, SOURCE_DIR) mismatches, missing = verify_transfer(SOURCE_DIR, received) assert not missing, f"Missing: {missing}" assert not mismatches, f"Mismatch: {mismatches}" def test_archive_with_negated_links(self, shared_server): # --archive implies links + metadata + devices + specials. Devices/specials # force metadata transmission (recreating a node needs the metadata mode), so # the post-parse layer keeps use_metadata on even under --no-preserve; only the # independently-negatable --no-links actually takes effect here. clean_dir(DEST_DIR) result, dur = run_client( SOURCE_DIR, DEST_DIR, flags=["--archive", "--no-links"], port=shared_server.port, ) if result.returncode != 0: pytest.fail(f"Exit {result.returncode}: {(result.stderr or result.stdout)[:200]}") received = get_dest_received_dir(DEST_DIR, SOURCE_DIR) mismatches, missing = verify_transfer(SOURCE_DIR, received) assert not missing, f"Missing: {missing}" assert not mismatches, f"Mismatch: {mismatches}" class TestExecutability: @pytest.mark.ci def test_preserves_only_executable_bits(self, shared_server): source = os.path.join(TEST_DATA_DIR, "executability_source") dest = os.path.join(TEST_DATA_DIR, "executability_dest") clean_dir(source) clean_dir(dest) source_file = os.path.join(source, "tool.sh") with open(source_file, "w") as f: f.write("#!/bin/sh\necho test\n") os.chmod(source_file, 0o751) result, _ = run_client(source, dest, flags=["-E"], port=shared_server.port) assert result.returncode == 0, f"Executability sync failed: {result.stderr[:200]}" received_file = os.path.join(get_dest_received_dir(dest, source), "tool.sh") received_mode = os.stat(received_file).st_mode # rsync -E on a fresh destination: the base is source & ~umask, then the # execute bits are derived from that base's read bits. For a source of # 0751 this is exactly source & ~umask (owner rwx, group r-x, other --x # under the usual 022 umask => group/other bits 0o051, not 0o011). current_umask = os.umask(0) os.umask(current_umask) expected_mode = 0o751 & ~current_umask assert received_mode & 0o777 == expected_mode, ( f"expected mode {oct(expected_mode)}, got {oct(received_mode & 0o777)}" ) class TestChmod: @pytest.mark.ci def test_chmod_applies_to_transferred_files(self, shared_server): clean_dir(DEST_DIR) source_file = os.path.join(SOURCE_DIR, "small.txt") os.chmod(source_file, 0o777) result, dur = run_client( SOURCE_DIR, DEST_DIR, flags=["--chmod=u=rw,go=r"], port=shared_server.port, ) if result.returncode != 0: pytest.fail(f"Exit {result.returncode}: {(result.stderr or result.stdout)[:200]}") received = get_dest_received_dir(DEST_DIR, SOURCE_DIR) assert (os.stat(os.path.join(received, "small.txt")).st_mode & 0o777) == 0o644 @pytest.mark.ci def test_chmod_does_not_imply_perms(self, shared_server): """rsync's --chmod only tweaks the mode used for a NEW destination; it does not imply -p, so a pre-existing destination keeps its own mode.""" source = os.path.join(TEST_DATA_DIR, "chmod_nop_src") dest = os.path.join(TEST_DATA_DIR, "chmod_nop_dst") clean_dir(source) clean_dir(dest) src_file = os.path.join(source, "f.txt") with open(src_file, "wb") as fh: fh.write(b"one\n") os.chmod(src_file, 0o644) result, _ = run_client(source, dest, flags=["-p"], port=shared_server.port) assert result.returncode == 0, f"seed failed: {(result.stderr or '')[:200]}" dst_file = os.path.join(get_dest_received_dir(dest, source), "f.txt") os.chmod(dst_file, 0o600) with open(src_file, "wb") as fh: fh.write(b"two, changed content\n") result, _ = run_client(source, dest, flags=["--chmod=go+w"], port=shared_server.port) assert result.returncode == 0, \ f"--chmod failed: {(result.stderr or result.stdout)[:300]}" got = stat.S_IMODE(os.stat(dst_file).st_mode) assert got == 0o600, \ f"--chmod must not imply -p; existing dest mode changed to {oct(got)}" @pytest.mark.ci def test_chmod_go_w_with_perms(self, shared_server): source = os.path.join(TEST_DATA_DIR, "chmod_gow_src") dest = os.path.join(TEST_DATA_DIR, "chmod_gow_dst") clean_dir(source) clean_dir(dest) src_file = os.path.join(source, "f.txt") with open(src_file, "wb") as fh: fh.write(b"x\n") os.chmod(src_file, 0o644) result, _ = run_client(source, dest, flags=["-p", "--chmod=go+w"], port=shared_server.port) assert result.returncode == 0, \ f"-p --chmod=go+w failed: {(result.stderr or result.stdout)[:300]}" got = stat.S_IMODE(os.stat( os.path.join(get_dest_received_dir(dest, source), "f.txt")).st_mode) assert got == 0o666, f"--chmod=go+w must grant group/other write, got {oct(got)}" @pytest.mark.ci def test_chmod_repeated_options_accumulate(self, shared_server): source = os.path.join(TEST_DATA_DIR, "chmod_append_src") dest = os.path.join(TEST_DATA_DIR, "chmod_append_dst") clean_dir(source) clean_dir(dest) src_file = os.path.join(source, "f.txt") with open(src_file, "wb") as fh: fh.write(b"x\n") os.chmod(src_file, 0o644) result, _ = run_client(source, dest, flags=["-p", "--chmod=a+r", "--chmod=a-w"], port=shared_server.port) assert result.returncode == 0, \ f"append --chmod failed: {(result.stderr or result.stdout)[:300]}" got = stat.S_IMODE(os.stat( os.path.join(get_dest_received_dir(dest, source), "f.txt")).st_mode) assert got == 0o444, f"repeated --chmod must accumulate, got {oct(got)}" @pytest.mark.ci @pytest.mark.skipif(shutil.which("rsync") is None, reason="rsync not installed") def test_chmod_matches_rsync(self, shared_server): """Differential --chmod verification against rsync 3.4.1 for D/F/X selectors, no-/with--p new files, special bits, and append semantics.""" cases = [ ("go_w_no_p", ["--chmod=go+w"], {}, {"f.txt": (b"x", 0o644)}, ["f.txt"]), ("go_w_p", ["-p", "--chmod=go+w"], {}, {"f.txt": (b"x", 0o644)}, ["f.txt"]), ("world_writable_p", ["-p"], {}, {"f.txt": (b"x", 0o666)}, ["f.txt"]), ("setgid_sticky_dirs_p", ["-p"], {"sg": 0o2755, "st": 0o1777}, {"sg/a.txt": (b"x", 0o644), "st/b.txt": (b"x", 0o644)}, ["sg", "st"]), ("special_file_p", ["-p"], {}, {"s": (b"x", 0o6755)}, ["s"]), ("archive_special_file", ["-a"], {}, {"s": (b"x", 0o6755)}, ["s"]), ("archive_setgid_dir", ["-a"], {"d": 0o2755}, {"d/a.txt": (b"x", 0o644)}, ["d"]), ("dfx_p", ["-p", "--chmod=Dg+s,Fo-w,+X"], {"d": 0o700}, {"d/inner.txt": (b"x", 0o644), "f.txt": (b"x", 0o644)}, ["d", "f.txt"]), ("x_selector_p", ["-p", "--chmod=a+X"], {"d": 0o600}, {"d/inner.txt": (b"x", 0o644), "exe": (b"x", 0o755), "noexe": (b"x", 0o644)}, ["d", "exe", "noexe"]), ("append_p", ["-p", "--chmod=a+r", "--chmod=a-w"], {}, {"f.txt": (b"x", 0o644)}, ["f.txt"]), ] for name, flags, dirs, files, check in cases: source = os.path.join(TEST_DATA_DIR, f"chmod_diff_{name}_src") fdest = os.path.join(TEST_DATA_DIR, f"chmod_diff_{name}_fs") rdest = os.path.join(TEST_DATA_DIR, f"chmod_diff_{name}_rsync") clean_dir(source) clean_dir(fdest) clean_dir(rdest) for rel, mode in dirs.items(): path = os.path.join(source, rel) os.makedirs(path, exist_ok=True) os.chmod(path, mode) for rel, (content, mode) in files.items(): path = os.path.join(source, rel) os.makedirs(os.path.dirname(path), exist_ok=True) with open(path, "wb") as fh: fh.write(content) os.chmod(path, mode) rsync_result = subprocess.run( ["rsync", "-r"] + flags + [source + "/", rdest + "/"], text=True, capture_output=True) assert rsync_result.returncode == 0, \ f"rsync {name} failed: {rsync_result.stderr[:300]}" result, _ = run_client(source, fdest, flags=flags, port=shared_server.port) assert result.returncode == 0, \ f"FastSync {name} failed: {(result.stderr or result.stdout)[:300]}" fs_root = get_dest_received_dir(fdest, source) for rel in check: rsync_mode = stat.S_IMODE(os.lstat(os.path.join(rdest, rel)).st_mode) fs_mode = stat.S_IMODE(os.lstat(os.path.join(fs_root, rel)).st_mode) assert fs_mode == rsync_mode, ( f"{name}: mode mismatch for {rel}: " f"FastSync {oct(fs_mode)} != rsync {oct(rsync_mode)}") class TestPreallocate: """--preallocate allocates the destination file space up front; the final destination content must be byte-identical to a normal run.""" def test_preallocate_transfer_succeeds(self, shared_server): source = os.path.join(TEST_DATA_DIR, "prealloc_source") dest = os.path.join(TEST_DATA_DIR, "prealloc_dest") clean_dir(source) clean_dir(dest) payload = os.urandom(2 * 1024 * 1024 + 137) with open(os.path.join(source, "data.bin"), "wb") as f: f.write(payload) with open(os.path.join(source, "small.txt"), "wb") as f: f.write(b"hello\n") result, _ = run_client(source, dest, flags=["--preallocate"], port=shared_server.port) assert result.returncode == 0, \ f"--preallocate failed: {(result.stderr or result.stdout)[:400]}" received_dir = os.path.join(dest, os.path.abspath(source).lstrip(os.sep)) data_path = os.path.join(received_dir, "data.bin") assert os.path.isfile(data_path), f"destination file not created: {data_path}" with open(data_path, "rb") as f: assert f.read() == payload, "destination content mismatch" small_path = os.path.join(received_dir, "small.txt") with open(small_path, "rb") as f: assert f.read() == b"hello\n", "small file content mismatch" def test_preallocate_combines_with_partial(self, shared_server): source = os.path.join(TEST_DATA_DIR, "prealloc_partial_src") dest = os.path.join(TEST_DATA_DIR, "prealloc_partial_dst") clean_dir(source) clean_dir(dest) with open(os.path.join(source, "f.txt"), "wb") as f: f.write(b"partial + preallocate\n") result, _ = run_client(source, dest, flags=["--preallocate", "--partial"], port=shared_server.port) assert result.returncode == 0, \ f"--preallocate --partial failed: {(result.stderr or result.stdout)[:400]}" received_dir = os.path.join(dest, os.path.abspath(source).lstrip(os.sep)) with open(os.path.join(received_dir, "f.txt"), "rb") as f: assert f.read() == b"partial + preallocate\n" class TestCompressionChoice: @pytest.mark.ci def test_zstd_choice_compresses(self, shared_server): clean_dir(DEST_DIR) result, _ = run_client(SOURCE_DIR, DEST_DIR, flags=["--zc", "zstd"], port=shared_server.port) assert result.returncode == 0, f"zstd sync failed: {(result.stderr or result.stdout)[:200]}" received = get_dest_received_dir(DEST_DIR, SOURCE_DIR) mismatches, missing = verify_transfer(SOURCE_DIR, received) assert not missing, f"Missing: {missing}" assert not mismatches, f"Mismatch: {mismatches}" def test_none_choice_disables_compression(self, shared_server): clean_dir(DEST_DIR) result, _ = run_client(SOURCE_DIR, DEST_DIR, flags=["-z", "--compress-choice", "none"], port=shared_server.port) assert result.returncode == 0, f"none sync failed: {(result.stderr or result.stdout)[:200]}" received = get_dest_received_dir(DEST_DIR, SOURCE_DIR) mismatches, missing = verify_transfer(SOURCE_DIR, received) assert not missing, f"Missing: {missing}" assert not mismatches, f"Mismatch: {mismatches}" class TestSkipCompress: @pytest.mark.ci def test_skip_compress_case_insensitive(self, shared_server): clean_dir(DEST_DIR) with open(os.path.join(SOURCE_DIR, "skip-case.TXT"), "wb") as f: f.write((b"skip compression case test\n" * 100)) result, _ = run_client( SOURCE_DIR, DEST_DIR, flags=["-z", "--skip-compress=.txt"], port=shared_server.port, ) assert result.returncode == 0, f"Skip-compress sync failed: {(result.stderr or result.stdout)[:200]}" received = get_dest_received_dir(DEST_DIR, SOURCE_DIR) with open(os.path.join(received, "skip-case.TXT"), "rb") as f: assert f.read() == b"skip compression case test\n" * 100 def test_skip_compress_empty_list(self, shared_server): clean_dir(DEST_DIR) result, _ = run_client( SOURCE_DIR, DEST_DIR, flags=["-z", "--skip-compress="], port=shared_server.port, ) assert result.returncode == 0, f"Empty skip-compress sync failed: {(result.stderr or result.stdout)[:200]}" received = get_dest_received_dir(DEST_DIR, SOURCE_DIR) mismatches, missing = verify_transfer(SOURCE_DIR, received) assert not missing, f"Missing: {missing}" assert not mismatches, f"Mismatch: {mismatches}" def test_skip_compress_incremental_full_fallback(self, shared_server): clean_dir(DEST_DIR) path = os.path.join(SOURCE_DIR, "incremental-skip.TXT") with open(path, "wb") as f: f.write(b"original skipped content\n") flags = ["-z", "--preserve", "--skip-compress=.txt"] result, _ = run_client(SOURCE_DIR, DEST_DIR, flags=flags, port=shared_server.port) assert result.returncode == 0, f"Initial sync failed: {(result.stderr or result.stdout)[:200]}" with open(path, "wb") as f: f.write(b"updated skipped content\n") result, _ = run_client( SOURCE_DIR, DEST_DIR, flags=flags + ["--incremental"], port=shared_server.port, ) assert result.returncode == 0, f"Incremental sync failed: {(result.stderr or result.stdout)[:200]}" received = get_dest_received_dir(DEST_DIR, SOURCE_DIR) with open(os.path.join(received, "incremental-skip.TXT"), "rb") as f: assert f.read() == b"updated skipped content\n" def test_skip_compress_rejects_chunk_serialization(self, shared_server): result, _ = run_client( SOURCE_DIR, DEST_DIR, flags=["-z", "--chunk-serialization", "--skip-compress=.txt"], port=shared_server.port, ) assert result.returncode != 0 assert "cannot be combined" in (result.stderr or result.stdout) class TestExclude: def test_exclude_single(self, shared_server): clean_dir(DEST_DIR) result, dur = run_client( SOURCE_DIR, DEST_DIR, flags=["--exclude", "small.txt"], port=shared_server.port, ) if result.returncode != 0: pytest.fail(f"Exit {result.returncode}: {(result.stderr or result.stdout)[:200]}") received = get_dest_received_dir(DEST_DIR, SOURCE_DIR) mismatches, missing = verify_transfer(SOURCE_DIR, received) assert "small.txt" in missing, "small.txt should be excluded but was transferred" other_missing = [m for m in missing if m != "small.txt"] assert not other_missing, f"Other files missing: {other_missing}" assert not mismatches, f"Mismatch: {mismatches}" def test_exclude_glob(self, shared_server): clean_dir(DEST_DIR) result, dur = run_client( SOURCE_DIR, DEST_DIR, flags=["--exclude", "*.txt"], port=shared_server.port, ) if result.returncode != 0: pytest.fail(f"Exit {result.returncode}: {(result.stderr or result.stdout)[:200]}") received = get_dest_received_dir(DEST_DIR, SOURCE_DIR) assert not os.path.exists(os.path.join(received, "small.txt")), "small.txt should be excluded" assert os.path.exists(os.path.join(received, "binary.bin")), "binary.bin should be present" class TestInclude: def test_include_single(self, shared_server): # rsync first-match-wins: an --include alone is NOT a whitelist, so the # selector must pair it with --exclude '*' (the common idiom). clean_dir(DEST_DIR) result, dur = run_client( SOURCE_DIR, DEST_DIR, flags=["--include", "binary.bin", "--exclude", "*"], port=shared_server.port, ) if result.returncode != 0: pytest.fail(f"Exit {result.returncode}: {(result.stderr or result.stdout)[:200]}") received = get_dest_received_dir(DEST_DIR, SOURCE_DIR) assert os.path.exists(os.path.join(received, "binary.bin")), "binary.bin should be included" assert not os.path.exists(os.path.join(received, "small.txt")), "small.txt should not be included" def test_include_glob(self, shared_server): clean_dir(DEST_DIR) result, dur = run_client( SOURCE_DIR, DEST_DIR, flags=["--include", "*.bin", "--exclude", "*"], port=shared_server.port, ) if result.returncode != 0: pytest.fail(f"Exit {result.returncode}: {(result.stderr or result.stdout)[:200]}") received = get_dest_received_dir(DEST_DIR, SOURCE_DIR) assert os.path.exists(os.path.join(received, "binary.bin")), "binary.bin should be included" assert not os.path.exists(os.path.join(received, "small.txt")), "small.txt should not be included" class TestSizeFilters: def test_max_size(self, shared_server): clean_dir(DEST_DIR) result, dur = run_client( SOURCE_DIR, DEST_DIR, flags=["--max-size", "100"], port=shared_server.port, ) if result.returncode != 0: pytest.fail(f"Exit {result.returncode}: {(result.stderr or result.stdout)[:200]}") received = get_dest_received_dir(DEST_DIR, SOURCE_DIR) assert os.path.exists(os.path.join(received, "small.txt")), "small.txt should be present" assert not os.path.exists(os.path.join(received, "medium.txt")), "medium.txt should be skipped" def test_min_size(self, shared_server): clean_dir(DEST_DIR) result, dur = run_client( SOURCE_DIR, DEST_DIR, flags=["--min-size", "1000"], port=shared_server.port, ) if result.returncode != 0: pytest.fail(f"Exit {result.returncode}: {(result.stderr or result.stdout)[:200]}") received = get_dest_received_dir(DEST_DIR, SOURCE_DIR) assert not os.path.exists(os.path.join(received, "small.txt")), "small.txt should be skipped" assert os.path.exists(os.path.join(received, "medium.txt")), "medium.txt should be present" class TestIncremental: @pytest.mark.ci def test_incremental_skips_unchanged(self, shared_server): clean_dir(DEST_DIR) result, dur = run_client( SOURCE_DIR, DEST_DIR, flags=["--preserve"], port=shared_server.port, ) assert result.returncode == 0, f"First sync failed: {result.stderr[:100]}" start = time.monotonic() result, dur = run_client( SOURCE_DIR, DEST_DIR, flags=["--preserve", "--incremental"], port=shared_server.port, ) incremental_time = time.monotonic() - start assert result.returncode == 0, f"Incremental sync failed: {(result.stderr or result.stdout)[:200]}" received = get_dest_received_dir(DEST_DIR, SOURCE_DIR) mismatches, missing = verify_transfer(SOURCE_DIR, received) assert not missing, f"Missing: {missing}" assert not mismatches, f"Mismatch: {mismatches}" @pytest.mark.ci def test_incremental_detects_changes(self, shared_server): clean_dir(DEST_DIR) result, _ = run_client( SOURCE_DIR, DEST_DIR, flags=["--preserve"], port=shared_server.port, ) assert result.returncode == 0 modified_file = os.path.join(SOURCE_DIR, "small.txt") with open(modified_file, "wb") as f: f.write(b"modified content for incremental test\n") result, dur = run_client( SOURCE_DIR, DEST_DIR, flags=["--preserve", "--incremental"], port=shared_server.port, ) assert result.returncode == 0 with open(modified_file, "wb") as f: f.write(b"hello world\n") received = get_dest_received_dir(DEST_DIR, SOURCE_DIR) received_file = os.path.join(received, "small.txt") assert os.path.exists(received_file), "Modified file should be present" with open(received_file, "rb") as f: content = f.read() assert b"modified content" in content, f"Modified content not transferred: {content[:50]}" def test_checksum_detects_same_size_and_mtime_change(self, shared_server): clean_dir(DEST_DIR) result, _ = run_client(SOURCE_DIR, DEST_DIR, flags=["--preserve"], port=shared_server.port) assert result.returncode == 0 received = get_dest_received_dir(DEST_DIR, SOURCE_DIR) source_file = os.path.join(SOURCE_DIR, "small.txt") received_file = os.path.join(received, "small.txt") source_stat = os.stat(source_file) with open(received_file, "wb") as f: f.write(b"different!\n") os.utime(received_file, (source_stat.st_atime, source_stat.st_mtime)) result, _ = run_client(SOURCE_DIR, DEST_DIR, flags=["--preserve", "--incremental", "--checksum"], port=shared_server.port) assert result.returncode == 0, f"Checksum sync failed: {result.stderr[:200]}" with open(received_file, "rb") as f: assert f.read() == b"hello world\n" def test_size_only_skips_same_size_with_different_mtime(self, shared_server): clean_dir(DEST_DIR) result, _ = run_client(SOURCE_DIR, DEST_DIR, flags=["--preserve"], port=shared_server.port) assert result.returncode == 0 received = get_dest_received_dir(DEST_DIR, SOURCE_DIR) received_file = os.path.join(received, "small.txt") with open(received_file, "wb") as f: f.write(b"different!!\n") os.utime(received_file, (time.time() - 3600, time.time() - 3600)) result, _ = run_client( SOURCE_DIR, DEST_DIR, flags=["--preserve", "--incremental", "--size-only"], port=shared_server.port, ) assert result.returncode == 0, f"Size-only sync failed: {result.stderr[:200]}" with open(received_file, "rb") as f: assert f.read() == b"different!!\n" def test_ignore_times_transfers_same_size_and_mtime(self, shared_server): clean_dir(DEST_DIR) result, _ = run_client(SOURCE_DIR, DEST_DIR, flags=["--preserve"], port=shared_server.port) assert result.returncode == 0 received = get_dest_received_dir(DEST_DIR, SOURCE_DIR) source_file = os.path.join(SOURCE_DIR, "small.txt") received_file = os.path.join(received, "small.txt") source_stat = os.stat(source_file) with open(received_file, "wb") as f: f.write(b"stale data!\n") os.utime(received_file, (source_stat.st_atime, source_stat.st_mtime)) result, _ = run_client(SOURCE_DIR, DEST_DIR, flags=["--preserve", "--incremental", "--ignore-times"], port=shared_server.port) assert result.returncode == 0, f"Ignore-times sync failed: {result.stderr[:200]}" with open(received_file, "rb") as f: assert f.read() == b"hello world\n" def test_modify_window_allows_subsecond_mtime_difference(self, shared_server): clean_dir(DEST_DIR) result, _ = run_client(SOURCE_DIR, DEST_DIR, flags=["--preserve"], port=shared_server.port) assert result.returncode == 0 received = get_dest_received_dir(DEST_DIR, SOURCE_DIR) source_file = os.path.join(SOURCE_DIR, "small.txt") received_file = os.path.join(received, "small.txt") source_stat = os.stat(source_file) with open(received_file, "wb") as f: f.write(b"modified!!!\n") os.utime(received_file, ns=(source_stat.st_atime_ns, source_stat.st_mtime_ns - 1500000000)) result, _ = run_client(SOURCE_DIR, DEST_DIR, flags=["--preserve", "--incremental", "--modify-window=2"], port=shared_server.port) assert result.returncode == 0, f"Modify-window sync failed: {result.stderr[:200]}" with open(received_file, "rb") as f: assert f.read() == b"modified!!!\n" def test_whole_file_disables_delta_and_keeps_compression(self, shared_server): clean_dir(DEST_DIR) result, _ = run_client(SOURCE_DIR, DEST_DIR, flags=["--preserve"], port=shared_server.port) assert result.returncode == 0 source_file = os.path.join(SOURCE_DIR, "medium.txt") with open(source_file, "wb") as f: f.write(b"whole-file replacement\n" * 5000) result, _ = run_client( SOURCE_DIR, DEST_DIR, flags=["--preserve", "--incremental", "--delta", "-W", "-z"], port=shared_server.port, ) assert result.returncode == 0, f"Whole-file sync failed: {(result.stderr or result.stdout)[:200]}" received = get_dest_received_dir(DEST_DIR, SOURCE_DIR) mismatches, missing = verify_transfer(SOURCE_DIR, received) assert not missing, f"Missing: {missing}" assert not mismatches, f"Mismatch: {mismatches}" class TestChecksumChoice: """--checksum-choice/--cc and --checksum-seed: the whole-file digest used by the --incremental/--checksum handshake is selectable and seedable. The receiver hashes the on-disk old file with the SAME algorithm+seed, so an unchanged file is skipped and a changed file (even with identical size and mtime) is transferred -- and the transfer always lands byte-exact. FastSync accepts xxh64 (default, seed-aware) and md5; names it does not implement are rejected, never silently ignored.""" def test_unsupported_algorithm_is_rejected(self, shared_server): result, _ = run_client( SOURCE_DIR, DEST_DIR, flags=["--checksum", "--checksum-choice=sha256"], port=shared_server.port, ) assert result.returncode != 0, "sha256 must be rejected, not silently ignored" @pytest.mark.ci def test_checksum_alone_skips_unchanged(self, shared_server): """-c alone (no explicit --incremental) must switch the quick-check to a content digest: an unchanged file whose mtime differs is skipped.""" source = os.path.join(TEST_DATA_DIR, "checksum_alone_src") dest = os.path.join(TEST_DATA_DIR, "checksum_alone_dst") clean_dir(source) clean_dir(dest) with open(os.path.join(source, "f.txt"), "wb") as fh: fh.write(b"same content\n") result, _ = run_client(source, dest, port=shared_server.port) assert result.returncode == 0, result.stderr[:200] received = os.path.join(get_dest_received_dir(dest, source), "f.txt") assert os.path.exists(received) # Make the destination mtime differ without changing the bytes. bumped = os.stat(received).st_mtime + 100 os.utime(received, (bumped, bumped)) result, _ = run_client(source, dest, flags=["-c"], port=shared_server.port) assert result.returncode == 0, result.stderr[:200] # A skip leaves our bumped mtime in place; a transfer would rewrite it. assert os.stat(received).st_mtime == pytest.approx(bumped), \ "-c did not skip an unchanged file" # A same-size, same-mtime content change is still detected. with open(received, "wb") as fh: fh.write(b"DIFF content\n") os.utime(received, (bumped, bumped)) result, _ = run_client(source, dest, flags=["-c"], port=shared_server.port) assert result.returncode == 0, result.stderr[:200] with open(received, "rb") as fh: assert fh.read() == b"same content\n" @pytest.mark.ci @pytest.mark.parametrize("algo", ["xxh128", "xxh3", "xxh64", "md5", "md4", "sha1"]) def test_checksum_choice_all_algorithms_transfer(self, shared_server, algo): """Every rsync 3.4.1 checksum algorithm is accepted and transfers byte-exactly. 'none' is covered separately (it needs no digest).""" clean_dir(DEST_DIR) flags = ["--preserve", "--incremental", "--checksum", f"--checksum-choice={algo}"] result, _ = run_client(SOURCE_DIR, DEST_DIR, flags=flags, port=shared_server.port) assert result.returncode == 0, \ f"checksum-choice={algo} failed: {(result.stderr or result.stdout)[:300]}" received = get_dest_received_dir(DEST_DIR, SOURCE_DIR) mismatches, missing = verify_transfer(SOURCE_DIR, received) assert not missing, f"Missing: {missing}" assert not mismatches, f"Mismatch: {mismatches}" @pytest.mark.ci def test_checksum_choice_two_name_form(self, shared_server): """The rsync 'TRANSFER,PRE-TRANSFER' form is accepted; FastSync uses the second (pre-transfer) algorithm for its whole-file digest.""" clean_dir(DEST_DIR) flags = ["--preserve", "--incremental", "--checksum", "--cc=md4,sha1"] result, _ = run_client(SOURCE_DIR, DEST_DIR, flags=flags, port=shared_server.port) assert result.returncode == 0, \ f"two-name --cc=md4,sha1 failed: {(result.stderr or result.stdout)[:300]}" received = get_dest_received_dir(DEST_DIR, SOURCE_DIR) mismatches, missing = verify_transfer(SOURCE_DIR, received) assert not missing and not mismatches, f"missing={missing} mismatches={mismatches}" @pytest.mark.ci def test_checksum_choice_none_accepted_without_checksum(self, shared_server): clean_dir(DEST_DIR) result, _ = run_client(SOURCE_DIR, DEST_DIR, flags=["--preserve", "--incremental", "--cc=none"], port=shared_server.port) assert result.returncode == 0, \ f"--cc=none failed: {(result.stderr or result.stdout)[:300]}" received = get_dest_received_dir(DEST_DIR, SOURCE_DIR) mismatches, missing = verify_transfer(SOURCE_DIR, received) assert not missing and not mismatches, f"missing={missing} mismatches={mismatches}" @pytest.mark.ci def test_checksum_choice_none_rejected_with_checksum(self, shared_server): """rsync rejects 'none' as the pre-transfer checksum with --checksum and exits 4; mirror both the rejection and the exit code.""" for choice in ("none", "md5,none"): result, _ = run_client(SOURCE_DIR, DEST_DIR, flags=["--checksum", f"--cc={choice}"], port=shared_server.port) assert result.returncode == 4, \ f"--cc={choice} --checksum must exit 4, got {result.returncode}: " \ f"{(result.stderr or result.stdout)[:200]}" @pytest.mark.ci def test_checksum_choice_unknown_rejected_exit_4(self, shared_server): for bad in ("sha256", "bogus", "md5,", "md4,md5,sha1"): result, _ = run_client(SOURCE_DIR, DEST_DIR, flags=[f"--checksum-choice={bad}"], port=shared_server.port) assert result.returncode == 4, \ f"--checksum-choice={bad} must exit 4, got {result.returncode}" @pytest.mark.ci @pytest.mark.parametrize("algo", ["zstd", "lz4", "zlib", "zlibx"]) def test_compress_choice_all_algorithms_transfer(self, shared_server, algo): """Every rsync 3.4.1 compression codec is accepted and transfers byte-exactly through its own codec.""" clean_dir(DEST_DIR) flags = ["-z", f"--compress-choice={algo}"] result, _ = run_client(SOURCE_DIR, DEST_DIR, flags=flags, port=shared_server.port) assert result.returncode == 0, \ f"--compress-choice={algo} failed: {(result.stderr or result.stdout)[:300]}" received = get_dest_received_dir(DEST_DIR, SOURCE_DIR) mismatches, missing = verify_transfer(SOURCE_DIR, received) assert not missing, f"Missing: {missing}" assert not mismatches, f"Mismatch: {mismatches}" @pytest.mark.ci def test_compress_choice_unknown_rejected_exit_4(self, shared_server): for bad in ("bogus", "zstd,lz4", ""): result, _ = run_client(SOURCE_DIR, DEST_DIR, flags=[f"--compress-choice={bad}"], port=shared_server.port) assert result.returncode == 4, \ f"--compress-choice={bad} must exit 4, got {result.returncode}" @pytest.mark.ci def test_compress_choice_none_disables_compression(self, shared_server): clean_dir(DEST_DIR) result, _ = run_client(SOURCE_DIR, DEST_DIR, flags=["-z", "--compress-choice=none"], port=shared_server.port) assert result.returncode == 0, \ f"--compress-choice=none failed: {(result.stderr or result.stdout)[:300]}" received = get_dest_received_dir(DEST_DIR, SOURCE_DIR) mismatches, missing = verify_transfer(SOURCE_DIR, received) assert not missing and not mismatches, f"missing={missing} mismatches={mismatches}" @pytest.mark.ci def test_compress_choice_auto_transfers(self, shared_server): """--compress-choice=auto is normalized to zstd client-side, so the receiver never rejects the transfer (#4).""" clean_dir(DEST_DIR) flags = ["-z", "--compress-choice=auto"] result, _ = run_client(SOURCE_DIR, DEST_DIR, flags=flags, port=shared_server.port) assert result.returncode == 0, \ f"--compress-choice=auto sync failed: {(result.stderr or result.stdout)[:300]}" received = get_dest_received_dir(DEST_DIR, SOURCE_DIR) mismatches, missing = verify_transfer(SOURCE_DIR, received) assert not missing, f"Missing: {missing}" assert not mismatches, f"Mismatch: {mismatches}" @pytest.mark.parametrize("algo", ["xxh64", "xxh3", "xxh128", "md5"]) @pytest.mark.parametrize("mt", [False, True]) def test_unchanged_skipped_and_bytes_preserved(self, shared_server, algo, mt): clean_dir(DEST_DIR) result, _ = run_client(SOURCE_DIR, DEST_DIR, flags=["--preserve"], port=shared_server.port) assert result.returncode == 0, f"seed sync failed: {result.stderr[:200]}" flags = (["--preserve", "--incremental", "--checksum", f"--checksum-choice={algo}"] + (["--threads"] if mt else [])) result, _ = run_client(SOURCE_DIR, DEST_DIR, flags=flags, port=shared_server.port) assert result.returncode == 0, f"checksum {algo} run failed: {result.stderr[:200]}" received = get_dest_received_dir(DEST_DIR, SOURCE_DIR) mismatches, missing = verify_transfer(SOURCE_DIR, received) assert not missing, f"Missing: {missing}" assert not mismatches, f"Mismatch: {mismatches}" # A changed source file with the SAME size and mtime must still be # detected (and re-transferred byte-exactly) because the whole-file digest # differs -- the explicit reason --checksum exists. This exercises the # sender/receiver digest agreement for a non-default algorithm. @pytest.mark.parametrize("algo", ["xxh64", "xxh3", "xxh128", "md5"]) @pytest.mark.parametrize("mt", [False, True]) def test_changed_same_size_mtime_redetected(self, shared_server, algo, mt): clean_dir(DEST_DIR) result, _ = run_client(SOURCE_DIR, DEST_DIR, flags=["--preserve"], port=shared_server.port) assert result.returncode == 0 received = get_dest_received_dir(DEST_DIR, SOURCE_DIR) source_file = os.path.join(SOURCE_DIR, "small.txt") # "hello world\n" (12 bytes) received_file = os.path.join(received, "small.txt") source_stat = os.stat(source_file) with open(received_file, "wb") as f: f.write(b"DDDDDDDDDDDD") # same size, different content os.utime(received_file, (source_stat.st_atime, source_stat.st_mtime)) flags = (["--preserve", "--incremental", "--checksum", f"--checksum-choice={algo}"] + (["--threads"] if mt else [])) result, _ = run_client(SOURCE_DIR, DEST_DIR, flags=flags, port=shared_server.port) assert result.returncode == 0, f"checksum {algo} redetect failed: {result.stderr[:200]}" with open(received_file, "rb") as f: assert f.read() == b"hello world\n" @pytest.mark.parametrize("algo", ["xxh64", "md5"]) def test_unchanged_run_transfers_almost_no_data(self, shared_server, algo): # A fully-unchanged --checksum run skips every file: only the config + a # small handshake travels, not the payloads. Proxy byte counts are not # available for -m (multithreaded connections), so single-thread only. clean_dir(DEST_DIR) result, _ = run_client(SOURCE_DIR, DEST_DIR, flags=["--preserve"], port=shared_server.port) assert result.returncode == 0 flags = ["--preserve", "--incremental", "--checksum", f"--checksum-choice={algo}"] proxy = CountingProxy(shared_server.port) cmd = (CLIENT_CMD + ["--source-dir", SOURCE_DIR, "--dest-dir", DEST_DIR, "--save-to-disk", "--server-port", str(proxy.port)] + flags) result = proxy.run(cmd) assert result.returncode == 0, f"checksum {algo} skip run failed: {result.stderr[:200]}" assert proxy.client_to_server < 100000, \ f"unchanged --checksum run sent {proxy.client_to_server} bytes; expected a skip" @pytest.mark.parametrize("mt", [False, True]) def test_seed_is_deterministic_and_preserves_content(self, shared_server, mt): clean_dir(DEST_DIR) flags = ["--preserve", "--incremental", "--checksum", "--checksum-choice=xxh64", "--checksum-seed=987654"] + (["--threads"] if mt else []) first, _ = run_client(SOURCE_DIR, DEST_DIR, flags=flags, port=shared_server.port) assert first.returncode == 0, f"seeded run failed: {first.stderr[:200]}" # A second run with the SAME seed and unchanged content skips everything # deterministically (same digests both sides). second, _ = run_client(SOURCE_DIR, DEST_DIR, flags=flags, port=shared_server.port) assert second.returncode == 0, f"deterministic rerun failed: {second.stderr[:200]}" received = get_dest_received_dir(DEST_DIR, SOURCE_DIR) mismatches, missing = verify_transfer(SOURCE_DIR, received) assert not missing and not mismatches, f"missing={missing} mismatches={mismatches}" # A changed file with the same size and mtime is still caught and fixed # (a non-zero seed does not weaken the comparison). source_file = os.path.join(SOURCE_DIR, "medium.txt") received_file = os.path.join(received, "medium.txt") source_stat = os.stat(source_file) with open(received_file, "wb") as f: f.write(b"z" * os.path.getsize(source_file)) os.utime(received_file, (source_stat.st_atime, source_stat.st_mtime)) third, _ = run_client(SOURCE_DIR, DEST_DIR, flags=flags, port=shared_server.port) assert third.returncode == 0, f"seeded redetect failed: {third.stderr[:200]}" with open(received_file, "rb") as f: assert f.read() == open(source_file, "rb").read() # --checksum-seed also feeds the delta path's per-block strong checksum on # both ends (receiver signature and sender window hash use the same seed), # so a seeded delta transfer still lands byte-exact. @pytest.mark.parametrize("mt", [False, True]) def test_seed_delta_block_hash_transfers_byte_exact(self, shared_server, mt): source = os.path.join(TEST_DATA_DIR, f"ccseed_{'m' if mt else 's'}_src") dest = os.path.join(TEST_DATA_DIR, f"ccseed_{'m' if mt else 's'}_dst") clean_dir(source) clean_dir(dest) big = os.path.join(source, "big.bin") with open(big, "wb") as f: f.write(bytes(range(256)) * 200) # 51200 bytes > delta 16K floor result, _ = run_client(source, dest, flags=["--preserve"], port=shared_server.port) assert result.returncode == 0, f"seed delta seed failed: {result.stderr[:200]}" # Edit a region so the receiver must match a changed block with the seed. with open(big, "r+b") as f: f.seek(1000) f.write(b"\x00" * 64) # Force an mtime mismatch: the incremental quick-check skips files whose # stored mtime second equals the source's, which can collide when the # edit and the prior sync share a second. Setting an old dest mtime # guarantees the delta path is exercised deterministically. os.utime(os.path.join(get_dest_received_dir(dest, source), "big.bin"), (0, 0)) flags = (["--preserve", "--incremental", "--delta", "--checksum-seed=314159"] + (["--threads"] if mt else [])) result, _ = run_client(source, dest, flags=flags, port=shared_server.port) assert result.returncode == 0, f"seed delta run failed: {result.stderr[:200]}" received = get_dest_received_dir(dest, source) assert _read_file(os.path.join(received, "big.bin")) == _read_file(big), \ "seeded delta transfer is not byte-exact" class TestUpdate: @pytest.mark.ci def test_update_skips_older_destination_and_allows_equal_or_newer_source(self, shared_server): clean_dir(DEST_DIR) result, _ = run_client(SOURCE_DIR, DEST_DIR, flags=["-u"], port=shared_server.port) assert result.returncode == 0 received = get_dest_received_dir(DEST_DIR, SOURCE_DIR) source_file = os.path.join(SOURCE_DIR, "small.txt") received_file = os.path.join(received, "small.txt") source_stat = os.stat(source_file) with open(received_file, "wb") as f: f.write(b"newer destination\n") os.utime(received_file, ns=(source_stat.st_atime_ns, source_stat.st_mtime_ns + 10_000_000_000)) result, _ = run_client(SOURCE_DIR, DEST_DIR, flags=["-u"], port=shared_server.port) assert result.returncode == 0 with open(received_file, "rb") as f: assert f.read() == b"newer destination\n" os.utime(received_file, ns=(source_stat.st_atime_ns, source_stat.st_mtime_ns)) result, _ = run_client(SOURCE_DIR, DEST_DIR, flags=["-u"], port=shared_server.port) assert result.returncode == 0 with open(received_file, "rb") as f: assert f.read() == b"hello world\n" def test_update_skips_unreadable_newer_destination(self, shared_server): clean_dir(DEST_DIR) result, _ = run_client(SOURCE_DIR, DEST_DIR, flags=["-u"], port=shared_server.port) assert result.returncode == 0 received_file = os.path.join(get_dest_received_dir(DEST_DIR, SOURCE_DIR), "small.txt") source_stat = os.stat(os.path.join(SOURCE_DIR, "small.txt")) with open(received_file, "wb") as f: f.write(b"protected destination\n") os.utime(received_file, ns=(source_stat.st_atime_ns, source_stat.st_mtime_ns + 10_000_000_000)) original_mode = os.stat(received_file).st_mode try: os.chmod(received_file, 0) result, _ = run_client(SOURCE_DIR, DEST_DIR, flags=["-u"], port=shared_server.port) assert result.returncode == 0 os.chmod(received_file, original_mode) with open(received_file, "rb") as f: assert f.read() == b"protected destination\n" finally: os.chmod(received_file, original_mode) with open(received_file, "wb") as f: f.write(b"older destination\n") os.utime(received_file, ns=(source_stat.st_atime_ns, source_stat.st_mtime_ns - 10_000_000_000)) result, _ = run_client(SOURCE_DIR, DEST_DIR, flags=["-u"], port=shared_server.port) assert result.returncode == 0 with open(received_file, "rb") as f: assert f.read() == b"hello world\n" class TestExisting: @pytest.mark.ci def test_existing_updates_existing_and_skips_new(self, shared_server): clean_dir(DEST_DIR) result, _ = run_client(SOURCE_DIR, DEST_DIR, flags=["--preserve"], port=shared_server.port) assert result.returncode == 0, f"Initial sync failed: {(result.stderr or result.stdout)[:200]}" received = get_dest_received_dir(DEST_DIR, SOURCE_DIR) source_file = os.path.join(SOURCE_DIR, "small.txt") new_source_file = os.path.join(SOURCE_DIR, "new-existing-test.txt") with open(source_file, "wb") as f: f.write(b"updated existing content\n") with open(new_source_file, "wb") as f: f.write(b"this file must not be created\n") try: result, _ = run_client(SOURCE_DIR, DEST_DIR, flags=["--preserve", "--existing"], port=shared_server.port) assert result.returncode == 0, f"--existing sync failed: {(result.stderr or result.stdout)[:200]}" with open(os.path.join(received, "small.txt"), "rb") as f: assert f.read() == b"updated existing content\n" assert not os.path.exists(os.path.join(received, "new-existing-test.txt")) finally: os.unlink(new_source_file) with open(source_file, "wb") as f: f.write(b"hello world\n") class TestIgnoreExisting: def test_ignore_existing_preserves_existing_and_transfers_new(self, shared_server): clean_dir(DEST_DIR) result, _ = run_client(SOURCE_DIR, DEST_DIR, port=shared_server.port) assert result.returncode == 0 received = get_dest_received_dir(DEST_DIR, SOURCE_DIR) existing_file = os.path.join(received, "small.txt") with open(existing_file, "wb") as f: f.write(b"destination content\n") new_source = os.path.join(SOURCE_DIR, "new.txt") try: with open(new_source, "wb") as f: f.write(b"new file\n") result, _ = run_client(SOURCE_DIR, DEST_DIR, flags=["--ignore-existing"], port=shared_server.port) assert result.returncode == 0, f"Sync failed: {(result.stderr or result.stdout)[:200]}" with open(existing_file, "rb") as f: assert f.read() == b"destination content\n" with open(os.path.join(received, "new.txt"), "rb") as f: assert f.read() == b"new file\n" finally: if os.path.lexists(new_source): os.unlink(new_source) class TestIgnoreExisting: def test_ignore_existing_preserves_existing_and_transfers_new(self, shared_server): clean_dir(DEST_DIR) result, _ = run_client(SOURCE_DIR, DEST_DIR, port=shared_server.port) assert result.returncode == 0 received = get_dest_received_dir(DEST_DIR, SOURCE_DIR) existing_file = os.path.join(received, "small.txt") with open(existing_file, "wb") as f: f.write(b"destination content\n") new_source = os.path.join(SOURCE_DIR, "new.txt") try: with open(new_source, "wb") as f: f.write(b"new file\n") result, _ = run_client(SOURCE_DIR, DEST_DIR, flags=["--ignore-existing"], port=shared_server.port) assert result.returncode == 0, f"Sync failed: {(result.stderr or result.stdout)[:200]}" with open(existing_file, "rb") as f: assert f.read() == b"destination content\n" with open(os.path.join(received, "new.txt"), "rb") as f: assert f.read() == b"new file\n" finally: if os.path.lexists(new_source): os.unlink(new_source) class TestDelete: @pytest.mark.ci def test_delete_removes_extra_files(self, shared_server): clean_dir(DEST_DIR) result, _ = run_client( SOURCE_DIR, DEST_DIR, flags=["--preserve"], port=shared_server.port, ) assert result.returncode == 0 received = get_dest_received_dir(DEST_DIR, SOURCE_DIR) extra_file = os.path.join(received, "extra_file.txt") extra_dir = os.path.join(received, "extra_dir") with open(extra_file, "w") as f: f.write("should be deleted") os.makedirs(extra_dir, exist_ok=True) with open(os.path.join(extra_dir, "nested.txt"), "w") as f: f.write("nested extra") result, dur = run_client( SOURCE_DIR, DEST_DIR, flags=["--preserve", "--delete"], port=shared_server.port, ) assert result.returncode == 0, f"Delete sync failed: {(result.stderr or result.stdout)[:200]}" # The default server policy intentionally refuses client-requested # deletion unless it is started with --allow-delete. assert os.path.exists(extra_file), "unauthorized delete removed an extra file" assert os.path.exists(extra_dir), "unauthorized delete removed an extra directory" mismatches, missing = verify_transfer(SOURCE_DIR, received) assert not missing, f"Missing: {missing}" assert not mismatches, f"Mismatch: {mismatches}" @pytest.mark.ci def test_force_cannot_replace_directory_without_allow_delete(self): """C2: --force is deletion authority (an incoming file may recursively remove a non-empty destination directory tree). A server started without --allow-delete must clear it, so the operator's delete policy cannot be bypassed with --force.""" source = os.path.join(TEST_DATA_DIR, "force_src") dest = os.path.join(TEST_DATA_DIR, "force_dst") clean_dir(source) clean_dir(dest) with open(os.path.join(source, "blocker"), "wb") as f: f.write(b"incoming file\n") received = get_dest_received_dir(dest, source) blocker = os.path.join(received, "blocker") os.makedirs(blocker) nested = os.path.join(blocker, "nested.txt") with open(nested, "w") as f: f.write("survivor") # Deliberately NO --allow-delete. server = ServerManager() server.start() try: run_client(source, dest, flags=["--force"], port=server.port) finally: server.stop() assert os.path.isdir(blocker), "unauthorized --force removed a destination directory" assert os.path.exists(nested), "unauthorized --force removed a nested file" def test_progress_output(self, shared_server): clean_dir(DEST_DIR) result, dur = run_client( SOURCE_DIR, DEST_DIR, flags=["--progress"], port=shared_server.port, ) assert result.returncode == 0, f"Exit {result.returncode}: {result.stderr[:100]}" output = result.stdout + result.stderr assert "sending incremental file list" in output, "--progress produced no rsync header" assert "(xfr#" in output, "--progress produced no per-file xfr block" def test_human_readable_stats(self, shared_server): clean_dir(DEST_DIR) result, dur = run_client( SOURCE_DIR, DEST_DIR, flags=["-h", "--stats"], port=shared_server.port, ) assert result.returncode == 0, f"Exit {result.returncode}: {result.stderr[:100]}" assert "Number of files:" in result.stdout assert "Total file size:" in result.stdout def test_human_readable_stats_multithreaded(self, shared_server): # The multithreaded sender shares the single-threaded --stats format, # including --human-readable and the rate suffix. clean_dir(DEST_DIR) result, dur = run_client( SOURCE_DIR, DEST_DIR, flags=["--threads", "-h", "--stats"], port=shared_server.port, ) assert result.returncode == 0, f"Exit {result.returncode}: {result.stderr[:100]}" assert "Number of files:" in result.stdout assert "bytes/sec" in result.stdout def test_human_readable_progress_multithreaded(self, shared_server): clean_dir(DEST_DIR) result, dur = run_client( SOURCE_DIR, DEST_DIR, flags=["--threads", "-h", "--progress"], port=shared_server.port, ) assert result.returncode == 0, f"Exit {result.returncode}: {result.stderr[:100]}" output = result.stdout + result.stderr assert "sending incremental file list" in output assert "(xfr#" in output class TestInfo: def test_info_copy_reports_transfers(self, shared_server): clean_dir(DEST_DIR) result, _ = run_client( SOURCE_DIR, DEST_DIR, flags=["--info=copy"], port=shared_server.port, ) assert result.returncode == 0, f"Info sync failed: {(result.stderr or result.stdout)[:200]}" output = result.stdout + result.stderr assert "[INFO]" in output and "Transferring" in output def test_info_stats_reports_multithreaded_transfer(self, shared_server): clean_dir(DEST_DIR) result, _ = run_client( SOURCE_DIR, DEST_DIR, flags=["--threads", "--info=stats"], port=shared_server.port, ) assert result.returncode == 0, f"Info stats sync failed: {(result.stderr or result.stdout)[:200]}" output = result.stdout + result.stderr assert "[INFO]" in output and "Transfer summary:" in output def test_info_rejects_unknown_flag(self): result, _ = run_client( SOURCE_DIR, DEST_DIR, flags=["--info=unknown"], ) assert result.returncode != 0 assert "unsupported --info flag" in result.stderr @pytest.mark.parametrize("flags", [ ["--info=none", "--verbose"], ["--verbose", "--info=none"], ]) def test_info_none_suppresses_verbose_info(self, shared_server, flags): clean_dir(DEST_DIR) result, _ = run_client(SOURCE_DIR, DEST_DIR, flags=flags, port=shared_server.port) assert result.returncode == 0, f"Info sync failed: {(result.stderr or result.stdout)[:200]}" output = result.stdout + result.stderr assert "[INFO]" not in output assert "Transferring" not in output assert "Transfer summary:" not in output class TestBandwidthLimit: def test_bwlimit_runs(self, shared_server): clean_dir(DEST_DIR) result, dur = run_client( SOURCE_DIR, DEST_DIR, flags=["--bwlimit", "10240"], port=shared_server.port, ) assert result.returncode == 0, f"Exit {result.returncode}: {(result.stderr or result.stdout)[:200]}" received = get_dest_received_dir(DEST_DIR, SOURCE_DIR) mismatches, missing = verify_transfer(SOURCE_DIR, received) assert not missing, f"Missing: {missing}" assert not mismatches, f"Mismatch: {mismatches}" def _read_file(path): with open(path, "rb") as fh: return fh.read() class TestRemoveSourceFilesSkips: """--remove-source-files must not delete sources the receiver skipped (rsync reference behavior).""" def test_existing_first_sync_keeps_new_source(self, shared_server): source = os.path.join(TEST_DATA_DIR, "remove_rsf_existing_src") dest = os.path.join(TEST_DATA_DIR, "remove_rsf_existing_dst") clean_dir(source) clean_dir(dest) with open(os.path.join(source, "only.txt"), "wb") as f: f.write(b"keep me") result, _ = run_client(source, dest, flags=["--remove-source-files", "--existing"], port=shared_server.port) assert result.returncode == 0, f"Sync failed: {result.stderr[:200]}" # The file exists only on the source side, so --existing makes the # receiver skip it; the source must therefore not be removed. assert os.path.isfile(os.path.join(source, "only.txt")) received = get_dest_received_dir(dest, source) assert not os.path.exists(os.path.join(received, "only.txt")) def test_ignore_existing_keeps_skipped_source(self, shared_server): source = os.path.join(TEST_DATA_DIR, "remove_rsf_ignore_src") dest = os.path.join(TEST_DATA_DIR, "remove_rsf_ignore_dst") clean_dir(source) clean_dir(dest) source_file = os.path.join(source, "file.txt") with open(source_file, "wb") as f: f.write(b"payload") result, _ = run_client(source, dest, port=shared_server.port) assert result.returncode == 0 result, _ = run_client(source, dest, flags=["--remove-source-files", "--ignore-existing"], port=shared_server.port) assert result.returncode == 0, f"Sync failed: {result.stderr[:200]}" # Destination already has the file, so the second run is a receiver # skip; the source file must survive. assert os.path.isfile(source_file) received = get_dest_received_dir(dest, source) assert _read_file(os.path.join(received, "file.txt")) == b"payload" def test_update_newer_destination_keeps_source(self, shared_server): source = os.path.join(TEST_DATA_DIR, "remove_rsf_update_src") dest = os.path.join(TEST_DATA_DIR, "remove_rsf_update_dst") clean_dir(source) clean_dir(dest) source_file = os.path.join(source, "file.txt") with open(source_file, "wb") as f: f.write(b"source payload") result, _ = run_client(source, dest, port=shared_server.port) assert result.returncode == 0 received = get_dest_received_dir(dest, source) received_file = os.path.join(received, "file.txt") with open(received_file, "wb") as f: f.write(b"newer destination payload") os.utime(received_file, ns=(time.time_ns() + 10**9, time.time_ns() + 10**9)) result, _ = run_client(source, dest, flags=["--remove-source-files", "--update"], port=shared_server.port) assert result.returncode == 0, f"Sync failed: {result.stderr[:200]}" # --update skips a destination that is newer than the source, so the # source must not be removed. assert os.path.isfile(source_file) assert _read_file(received_file) == b"newer destination payload" def test_multithreaded_ignore_existing_keeps_skipped_source(self, shared_server): """The multithreaded writer path must also report per-file outcomes so a --remove-source-files sender does not delete skipped sources.""" source = os.path.join(TEST_DATA_DIR, "remove_rsf_mt_ignore_src") dest = os.path.join(TEST_DATA_DIR, "remove_rsf_mt_ignore_dst") clean_dir(source) clean_dir(dest) source_file = os.path.join(source, "file.txt") with open(source_file, "wb") as f: f.write(b"payload") result, _ = run_client(source, dest, port=shared_server.port) assert result.returncode == 0 result, _ = run_client(source, dest, flags=["--remove-source-files", "--ignore-existing", "--threads"], port=shared_server.port) assert result.returncode == 0, f"Sync failed: {result.stderr[:200]}" # Destination already has the file, so the receiver (writer thread) # skips it; the source must survive. assert os.path.isfile(source_file) received = get_dest_received_dir(dest, source) assert _read_file(os.path.join(received, "file.txt")) == b"payload" class TestBackup: def _sync(self, source, dest, flags, port): return run_client(source, dest, flags=flags, port=port) def test_plain_backup_keeps_previous_version(self, shared_server): source = os.path.join(TEST_DATA_DIR, "backup_src") dest = os.path.join(TEST_DATA_DIR, "backup_dst") clean_dir(source) clean_dir(dest) source_file = os.path.join(source, "f.txt") with open(source_file, "wb") as f: f.write(b"AAAA") result, _ = self._sync(source, dest, ["--backup"], shared_server.port) assert result.returncode == 0, f"Backup sync failed: {result.stderr[:200]}" with open(source_file, "wb") as f: f.write(b"BBBB") result, _ = self._sync(source, dest, ["--backup"], shared_server.port) assert result.returncode == 0, f"Backup sync failed: {result.stderr[:200]}" received = get_dest_received_dir(dest, source) assert _read_file(os.path.join(received, "f.txt")) == b"BBBB" # rsync default suffix "~" keeps the overwritten version. assert _read_file(os.path.join(received, "f.txt~")) == b"AAAA" def test_backup_custom_suffix(self, shared_server): source = os.path.join(TEST_DATA_DIR, "backup_suffix_src") dest = os.path.join(TEST_DATA_DIR, "backup_suffix_dst") clean_dir(source) clean_dir(dest) source_file = os.path.join(source, "f.txt") with open(source_file, "wb") as f: f.write(b"AAAA") flags = ["--backup", "--suffix", ".bak"] result, _ = self._sync(source, dest, flags, shared_server.port) assert result.returncode == 0, f"Backup sync failed: {result.stderr[:200]}" with open(source_file, "wb") as f: f.write(b"BBBB") result, _ = self._sync(source, dest, flags, shared_server.port) assert result.returncode == 0, f"Backup sync failed: {result.stderr[:200]}" received = get_dest_received_dir(dest, source) assert _read_file(os.path.join(received, "f.txt")) == b"BBBB" assert _read_file(os.path.join(received, "f.txt.bak")) == b"AAAA" def test_backup_dir_stores_backups_separately(self, shared_server): source = os.path.join(TEST_DATA_DIR, "backup_dir_src") dest = os.path.join(TEST_DATA_DIR, "backup_dir_dst") clean_dir(source) clean_dir(dest) source_file = os.path.join(source, "f.txt") with open(source_file, "wb") as f: f.write(b"AAAA") flags = ["--backup", "--backup-dir", "backups"] result, _ = self._sync(source, dest, flags, shared_server.port) assert result.returncode == 0, f"Backup sync failed: {result.stderr[:200]}" with open(source_file, "wb") as f: f.write(b"BBBB") result, _ = self._sync(source, dest, flags, shared_server.port) assert result.returncode == 0, f"Backup sync failed: {result.stderr[:200]}" received = get_dest_received_dir(dest, source) assert _read_file(os.path.join(received, "f.txt")) == b"BBBB" backup = os.path.join(dest, "backups", os.path.relpath(source_file, os.path.sep)) assert _read_file(backup) == b"AAAA" class TestPartialDir: def test_completed_transfer_installed_in_destination(self, shared_server): source = os.path.join(TEST_DATA_DIR, "partial_src") dest = os.path.join(TEST_DATA_DIR, "partial_dst") clean_dir(source) clean_dir(dest) source_file = os.path.join(source, "f.txt") with open(source_file, "wb") as f: f.write(b"partial payload") result, _ = run_client(source, dest, flags=["--partial", "--partial-dir", ".partial"], port=shared_server.port) assert result.returncode == 0, f"Partial sync failed: {result.stderr[:200]}" received = get_dest_received_dir(dest, source) assert _read_file(os.path.join(received, "f.txt")) == b"partial payload" # A completed transfer must not remain under the partial directory. partial = os.path.join(dest, ".partial", os.path.relpath(source_file, os.path.sep)) assert not os.path.exists(partial) class TestLargeFile: def test_transfer_100mb_file(self, shared_server): source = os.path.join(TEST_DATA_DIR, "large_src") dest = os.path.join(TEST_DATA_DIR, "large_dst") clean_dir(source) clean_dir(dest) source_file = os.path.join(source, "big.bin") chunk = os.urandom(1024 * 1024) with open(source_file, "wb") as f: for _ in range(100): f.write(chunk) result, _ = run_client(source, dest, port=shared_server.port) assert result.returncode == 0, f"Large-file sync failed: {result.stderr[:200]}" received = get_dest_received_dir(dest, source) assert filecmp.cmp(source_file, os.path.join(received, "big.bin"), shallow=False) class TestOneFileSystem: def _make_tree(self, source): clean_dir(source) os.makedirs(os.path.join(source, "nested", "deeper")) with open(os.path.join(source, "root.txt"), "wb") as f: f.write(b"root") with open(os.path.join(source, "nested", "inner.txt"), "wb") as f: f.write(b"inner") with open(os.path.join(source, "nested", "deeper", "deep.txt"), "wb") as f: f.write(b"deep") def _assert_full_tree_transferred(self, source, dest, port, flags): clean_dir(dest) result, _ = run_client(source, dest, flags=flags, port=port) assert result.returncode == 0, f"Sync failed: {result.stderr[:200]}" received = get_dest_received_dir(dest, source) mismatches, missing = verify_transfer(source, received) assert not missing, f"Missing: {missing}" assert not mismatches, f"Mismatch: {mismatches}" def test_x_transfer_matches_plain_over_single_filesystem(self, shared_server): source = os.path.join(TEST_DATA_DIR, "ofs_src") self._make_tree(source) self._assert_full_tree_transferred(source, os.path.join(TEST_DATA_DIR, "ofs_dst"), shared_server.port, ["-x"]) def test_x_multithreaded_transfer_matches_plain_over_single_filesystem(self, shared_server): source = os.path.join(TEST_DATA_DIR, "ofs_m_src") self._make_tree(source) self._assert_full_tree_transferred(source, os.path.join(TEST_DATA_DIR, "ofs_m_dst"), shared_server.port, ["--threads", "--one-file-system"]) def test_x_skips_other_device_mountpoint(self, shared_server): if os.geteuid() != 0 or shutil.which("mount") is None or shutil.which("umount") is None: pytest.skip("cross-device test requires root and mount(8)") source = os.path.join(TEST_DATA_DIR, "ofs_mnt_src") dest = os.path.join(TEST_DATA_DIR, "ofs_mnt_dst") dest_plain = os.path.join(TEST_DATA_DIR, "ofs_mnt_plain_dst") mountpoint = os.path.join(source, "external") clean_dir(source) clean_dir(dest) clean_dir(dest_plain) os.makedirs(mountpoint) os.makedirs(os.path.join(source, "nested")) with open(os.path.join(source, "root.txt"), "wb") as f: f.write(b"root") with open(os.path.join(source, "nested", "inner.txt"), "wb") as f: f.write(b"inner") mounted = False unmount_error = "" try: mount = subprocess.run(["mount", "-t", "tmpfs", "tmpfs", mountpoint], capture_output=True, text=True) if mount.returncode != 0: pytest.skip(f"cannot mount tmpfs: {mount.stderr.strip()}") mounted = True with open(os.path.join(mountpoint, "away.txt"), "wb") as f: f.write(b"cross device") result, _ = run_client(source, dest, flags=["-x"], port=shared_server.port) assert result.returncode == 0, f"-x sync failed: {result.stderr[:200]}" received = get_dest_received_dir(dest, source) assert os.path.isfile(os.path.join(received, "root.txt")) assert os.path.isfile(os.path.join(received, "nested", "inner.txt")) assert not os.path.exists(os.path.join(received, "external", "away.txt")), \ "-x must not cross into the mounted filesystem" result, _ = run_client(source, dest_plain, port=shared_server.port) assert result.returncode == 0, f"plain sync failed: {result.stderr[:200]}" received_plain = get_dest_received_dir(dest_plain, source) assert os.path.isfile(os.path.join(received_plain, "external", "away.txt")), \ "without -x the mounted subtree must be transferred" finally: if mounted: umount = subprocess.run(["umount", mountpoint], capture_output=True, text=True) if umount.returncode != 0: unmount_error = umount.stderr.strip() if unmount_error: pytest.fail(f"test mountpoint {mountpoint} still mounted after umount: {unmount_error}") def _walk_tmp_files(root): """Recursively list *.tmp* leftovers under root (empty if root missing).""" leftovers = [] if not os.path.isdir(root): return leftovers for base, _, files in os.walk(root): for name in files: if ".tmp." in name: leftovers.append(os.path.join(base, name)) return leftovers class TestTempDir: """--temp-dir=DIR puts the receiver's temporary working copies in a scratch directory below the destination root and atomically renames each completed file into its final destination. Files sharing a basename across directories exercise the flat scratch namespace.""" def _make_source(self, name): source = os.path.join(TEST_DATA_DIR, name) clean_dir(source) entries = { "top.txt": b"top level\n", "sub/file.txt": b"nested file\n" * 20, "other/file.txt": b"other nested file\n", "sub/deep.bin": bytes(range(256)) * 8, } for rel, content in entries.items(): full = os.path.join(source, rel) os.makedirs(os.path.dirname(full), exist_ok=True) with open(full, "wb") as fh: fh.write(content) return source def _assert_clean_scratch(self, scratch): assert os.path.isdir(scratch), f"scratch dir {scratch} was not created" leftovers = _walk_tmp_files(scratch) assert leftovers == [], f"leftover temp files in scratch dir: {leftovers}" @pytest.mark.parametrize("mt", [False, True]) def test_temp_dir_scratch(self, shared_server, mt): source = self._make_source("tempdir_src") dest = os.path.join(TEST_DATA_DIR, "tempdir_dst") clean_dir(dest) # rsync requires the temp dir to already exist (it is not created). os.makedirs(os.path.join(dest, "scratch"), exist_ok=True) flags = ["--temp-dir=scratch"] + (["--threads"] if mt else []) result, _ = run_client(source, dest, flags=flags, port=shared_server.port) assert result.returncode == 0, f"temp-dir sync failed: {result.stderr[:200]}" received = get_dest_received_dir(dest, source) mismatches, missing = verify_transfer(source, received) assert not missing, f"Missing: {missing}" assert not mismatches, f"Mismatch: {mismatches}" self._assert_clean_scratch(os.path.join(dest, "scratch")) @pytest.mark.skipif(shutil.which("rsync") is None, reason="rsync not installed") def test_relative_temp_dir_matches_rsync_absolute_rejected(self): """Differential: a relative --temp-dir is resolved under the destination by both (rsync 3.4.1 and FastSync), producing identical trees. An absolute --temp-dir is used verbatim by rsync standalone, but the receiver deliberately confines it to the receive root (security invariant), so FastSync rejects it without writing outside the root. """ source = self._make_source("tempdir_diff_src") rdst = os.path.join(TEST_DATA_DIR, "tempdir_diff_rdst") fdst = os.path.join(TEST_DATA_DIR, "tempdir_diff_fdst") clean_dir(rdst) clean_dir(fdst) os.makedirs(os.path.join(rdst, "scratch"), exist_ok=True) os.makedirs(os.path.join(fdst, "scratch"), exist_ok=True) r = subprocess.run(["rsync", "-a", "--temp-dir=scratch", source + "/", rdst + "/"], capture_output=True, text=True, env=dict(os.environ, LC_ALL="C")) assert r.returncode == 0, r.stderr with ServerManager() as server: server.start() result, _ = run_client(source, fdst, flags=["--temp-dir=scratch"], port=server.port) assert result.returncode == 0, (result.stderr or result.stdout)[:300] # rsync lays the source contents directly in rdst; FastSync mirrors the # absolute source path below fdst. Compare the mirrored content trees # (the scratch dir lives at each destination root). rtree = sorted(os.path.relpath(os.path.join(dp, n), rdst) for dp, dn, fn in os.walk(rdst) for n in dn + fn if os.path.join(dp, n) != os.path.join(rdst, "scratch")) mirror = get_dest_received_dir(fdst, source) ftree = sorted(os.path.relpath(os.path.join(dp, n), mirror) for dp, dn, fn in os.walk(mirror) for n in dn + fn) assert rtree == ftree, f"relative temp-dir tree mismatch: {rtree} != {ftree}" assert _walk_tmp_files(os.path.join(rdst, "scratch")) == [] assert _walk_tmp_files(os.path.join(fdst, "scratch")) == [] # Absolute temp dir: rsync accepts it; FastSync rejects it safely. abs_scratch = os.path.join(TEST_DATA_DIR, "tempdir_diff_abs") clean_dir(abs_scratch) rdst2 = os.path.join(TEST_DATA_DIR, "tempdir_diff_rdst2") clean_dir(rdst2) r2 = subprocess.run(["rsync", "-a", "--temp-dir=" + abs_scratch, source + "/", rdst2 + "/"], capture_output=True, text=True, env=dict(os.environ, LC_ALL="C")) assert r2.returncode == 0, r2.stderr fdst2 = os.path.join(TEST_DATA_DIR, "tempdir_diff_fdst2") clean_dir(fdst2) with ServerManager() as server: server.start() result2, _ = run_client(source, fdst2, flags=["--temp-dir", abs_scratch], port=server.port) assert result2.returncode != 0, "an absolute --temp-dir must be rejected (confined)" assert os.listdir(abs_scratch) == [], "receiver wrote into an unconfined temp dir" def test_default_behavior_has_no_scratch_dir(self, shared_server): source = self._make_source("tempdir_default_src") dest = os.path.join(TEST_DATA_DIR, "tempdir_default_dst") clean_dir(dest) result, _ = run_client(source, dest, port=shared_server.port) assert result.returncode == 0, f"Default sync failed: {result.stderr[:200]}" received = get_dest_received_dir(dest, source) mismatches, missing = verify_transfer(source, received) assert not missing, f"Missing: {missing}" assert not mismatches, f"Mismatch: {mismatches}" assert not os.path.exists(os.path.join(dest, "scratch")) def test_temp_dir_ignored_with_inplace(self, shared_server): """--inplace writes directly into the destination; --temp-dir must not redirect those writes into a scratch dir.""" source = self._make_source("tempdir_inplace_src") dest = os.path.join(TEST_DATA_DIR, "tempdir_inplace_dst") clean_dir(dest) result, _ = run_client(source, dest, flags=["--inplace", "--temp-dir=scratch"], port=shared_server.port) assert result.returncode == 0, f"inplace+temp-dir sync failed: {result.stderr[:200]}" received = get_dest_received_dir(dest, source) mismatches, missing = verify_transfer(source, received) assert not missing, f"Missing: {missing}" assert not mismatches, f"Mismatch: {mismatches}" assert not os.path.exists(os.path.join(dest, "scratch")), \ "--inplace wrote through the scratch dir" def test_temp_dir_ignored_with_partial_dir(self, shared_server): """--partial --partial-dir already stages in a separate directory; --temp-dir must not be used on top of it.""" source = self._make_source("tempdir_partial_src") dest = os.path.join(TEST_DATA_DIR, "tempdir_partial_dst") clean_dir(dest) result, _ = run_client(source, dest, flags=["--partial", "--partial-dir", ".partial", "--temp-dir=scratch"], port=shared_server.port) assert result.returncode == 0, f"partial+temp-dir sync failed: {result.stderr[:200]}" received = get_dest_received_dir(dest, source) mismatches, missing = verify_transfer(source, received) assert not missing, f"Missing: {missing}" assert not mismatches, f"Mismatch: {mismatches}" partial = os.path.join(dest, ".partial", os.path.relpath(os.path.join(source, "top.txt"), os.path.sep)) assert not os.path.exists(partial), "completed file remained under the partial dir" assert not os.path.exists(os.path.join(dest, "scratch")), \ "--partial-dir wrote through the scratch dir" def test_temp_dir_must_exist(self, shared_server): """rsync does not create the temp dir; a missing one is a clear error.""" source = self._make_source("tempdir_missing_src") dest = os.path.join(TEST_DATA_DIR, "tempdir_missing_dst") clean_dir(dest) missing_rel = os.path.join(dest, "no_such_scratch") assert not os.path.lexists(missing_rel) result, _ = run_client(source, dest, flags=["--temp-dir=no_such_scratch"], port=shared_server.port) assert result.returncode != 0, "a missing relative --temp-dir must fail" def test_temp_dir_absolute_rejected(self, shared_server): """The receiver confines --temp-dir to the destination root: an absolute (or `..`-escaping) value is rejected before any write, so a client can never make the receiver create scratch files in an arbitrary directory.""" source = self._make_source("tempdir_abs_src") dest = os.path.join(TEST_DATA_DIR, "tempdir_abs_dst") clean_dir(dest) scratch = os.path.join(TEST_DATA_DIR, "tempdir_abs_scratch") shutil.rmtree(scratch, ignore_errors=True) os.makedirs(scratch) result, _ = run_client(source, dest, flags=["--temp-dir", scratch], port=shared_server.port) assert result.returncode != 0, "an absolute --temp-dir must be rejected" assert os.listdir(scratch) == [], "receiver wrote into an unconfined temp dir" # A relative traversal is rejected for the same reason. result, _ = run_client(source, dest, flags=["--temp-dir=../escape_scratch"], port=shared_server.port) assert result.returncode != 0, "a `..` --temp-dir must be rejected" shutil.rmtree(scratch, ignore_errors=True) shutil.rmtree(os.path.join(TEST_DATA_DIR, "escape_scratch"), ignore_errors=True) class TestTimeoutAndAllocLimits: """#295: rsync defaults --timeout=0 (disabled), --contimeout=60, and --max-alloc=0 (no limit); 0 must be accepted for all three.""" def _seed(self, name): source = os.path.join(TEST_DATA_DIR, name) dest = os.path.join(TEST_DATA_DIR, name + "_dst") clean_dir(source) clean_dir(dest) with open(os.path.join(source, "f.txt"), "wb") as fh: fh.write(b"payload\n" * 100) return source, dest @pytest.mark.ci def test_timeout_zero_disables_and_transfers(self, shared_server): source, dest = self._seed("timeout_zero_src") result, _ = run_client(source, dest, flags=["--timeout=0", "--contimeout=0"], port=shared_server.port) assert result.returncode == 0, result.stderr[:200] received = get_dest_received_dir(dest, source) mismatches, missing = verify_transfer(source, received) assert not missing and not mismatches @pytest.mark.ci def test_no_timeout_forms(self, shared_server): source, dest = self._seed("timeout_no_src") result, _ = run_client(source, dest, flags=["--timeout=30", "--no-timeout", "--no-contimeout"], port=shared_server.port) assert result.returncode == 0, result.stderr[:200] @pytest.mark.ci def test_max_alloc_zero_means_no_limit(self, shared_server): source, dest = self._seed("max_alloc_zero_src") result, _ = run_client(source, dest, flags=["--max-alloc=0"], port=shared_server.port) assert result.returncode == 0, result.stderr[:200] received = get_dest_received_dir(dest, source) mismatches, missing = verify_transfer(source, received) assert not missing and not mismatches def test_temp_dir_cross_filesystem_fallback(self, shared_server): """A confined relative --temp-dir that resolves (via a symlink under the destination root) to another filesystem must fall back to a non-atomic copy instead of aborting (rsync parity). Skipped when no second filesystem is available.""" shm = "/dev/shm" if not os.path.isdir(shm): pytest.skip("/dev/shm not available") if os.stat(shm).st_dev == os.stat(TEST_DATA_DIR).st_dev: pytest.skip("/dev/shm is on the same filesystem as the test data") scratch = os.path.join(shm, f"fastsync_tmp_{os.getpid()}") shutil.rmtree(scratch, ignore_errors=True) os.makedirs(scratch) try: source, dest = self._seed("tempdir_xdev_src") # The receiver resolves a relative temp dir under the destination # root; a symlink there points the scratch at the second filesystem. link = os.path.join(dest, "xdev_scratch") os.symlink(scratch, link) result, _ = run_client(source, dest, flags=["--temp-dir", "xdev_scratch"], port=shared_server.port) assert result.returncode == 0, f"cross-fs temp-dir failed: {result.stderr[:300]}" received = get_dest_received_dir(dest, source) mismatches, missing = verify_transfer(source, received) assert not missing, f"Missing: {missing}" assert not mismatches, f"Mismatch: {mismatches}" assert os.listdir(scratch) == [], "temp files left behind in the cross-fs scratch" finally: shutil.rmtree(scratch, ignore_errors=True) class TestRemoteOptionTransport: """#296: -M/--remote-option is SSH-only; a daemon/TCP destination rejects it instead of silently ignoring it.""" @pytest.mark.ci def test_remote_option_rejected_for_tcp(self, shared_server): for flag in ("--remote-option=--allow-delete", "-M--allow-delete", "-M=--allow-delete"): result, _ = run_client(SOURCE_DIR, DEST_DIR, flags=[flag], port=shared_server.port) assert result.returncode != 0, f"{flag} must be rejected for a TCP destination" assert "remote-option" in (result.stderr + result.stdout), \ f"{flag}: error must name --remote-option" class TestTrustSenderServerPath: """--trust-sender is a receiver-local file-list validation policy. Symlink targets are stored verbatim like rsync (an absolute/`..` target is copied as a symlink by default); the sender-side --safe-links is what suppresses unsafe links. A client --trust-sender is never sent to the peer, so it cannot change how the receiving server stores links.""" def _make_source(self, name): source = os.path.join(TEST_DATA_DIR, name) clean_dir(source) with open(os.path.join(source, "file.txt"), "wb") as fh: fh.write(b"content\n") os.symlink("/etc/passwd", os.path.join(source, "escape_link")) return source def _run_with_server(self, extra_args, flags, tag): server = ServerManager() server.start(extra_args=extra_args) try: source = self._make_source(f"trust_sender_src_{tag}") dest = os.path.join(TEST_DATA_DIR, f"trust_sender_dst_{tag}") clean_dir(dest) result, _ = run_client(source, dest, flags=["-l"] + flags, port=server.port) link = os.path.join(get_dest_received_dir(dest, source), "escape_link") return result, link finally: server.stop() @pytest.mark.ci def test_escaping_symlink_stored_verbatim_by_default(self): result, link = self._run_with_server([], [], "default") assert result.returncode == 0, result.stderr[:200] assert os.path.islink(link), "rsync parity: -l stores the link verbatim" assert os.readlink(link) == "/etc/passwd" @pytest.mark.ci def test_client_trust_sender_does_not_change_server_storage(self): result, link = self._run_with_server([], ["--trust-sender"], "client") assert result.returncode == 0, result.stderr[:200] assert os.path.islink(link) and os.readlink(link) == "/etc/passwd", \ "a client --trust-sender must not change how the server stores links" @pytest.mark.ci def test_safe_links_skips_escaping_symlink(self): result, link = self._run_with_server([], ["--safe-links"], "safe") assert result.returncode == 0, result.stderr[:200] assert not os.path.lexists(link), "--safe-links must skip an unsafe target" def _source_files(): """All source paths (absolute) that a transfer would send right now.""" return [ os.path.join(root, name) for root, _dirs, names in os.walk(SOURCE_DIR) for name in names ] class TestListOnly: """--list-only prints every transfer candidate and changes nothing.""" def test_list_only_prints_each_file_and_does_not_transfer(self): clean_dir(DEST_DIR) result, _ = run_client(SOURCE_DIR, DEST_DIR, flags=["--list-only"]) assert result.returncode == 0, f"list-only failed: {result.stderr[:200]}" for full_path in _source_files(): rel = os.path.relpath(full_path, SOURCE_DIR) assert rel in result.stdout, f"list-only omitted {rel}" received = get_dest_received_dir(DEST_DIR, SOURCE_DIR) assert not os.path.exists(received), "list-only wrote to the destination" def test_list_only_with_dry_run_does_not_error(self): clean_dir(DEST_DIR) result, _ = run_client(SOURCE_DIR, DEST_DIR, flags=["--list-only", "--dry-run"]) assert result.returncode == 0, f"list-only -n failed: {result.stderr[:200]}" received = get_dest_received_dir(DEST_DIR, SOURCE_DIR) assert not os.path.exists(received) def test_list_only_multithreaded(self): clean_dir(DEST_DIR) result, _ = run_client(SOURCE_DIR, DEST_DIR, flags=["--list-only", "--threads"]) assert result.returncode == 0, f"list-only -m failed: {result.stderr[:200]}" for full_path in _source_files(): rel = os.path.relpath(full_path, SOURCE_DIR) assert rel in result.stdout, f"list-only -m omitted {rel}" received = get_dest_received_dir(DEST_DIR, SOURCE_DIR) assert not os.path.exists(received), "list-only -m wrote to the destination" class TestItemizeChanges: """-i/--itemize-changes prints rsync-style lines only for files sent.""" def test_first_run_prints_sent_lines(self, shared_server): clean_dir(DEST_DIR) result, _ = run_client(SOURCE_DIR, DEST_DIR, flags=["--preserve", "-i"], port=shared_server.port) assert result.returncode == 0, f"itemize sync failed: {result.stderr[:200]}" sent_lines = {">f+++++++++ " + os.path.relpath(p, SOURCE_DIR) for p in _source_files()} assert sent_lines <= set(result.stdout.splitlines()), ( f"missing itemize lines; got {result.stdout[:500]}" ) def test_incremental_second_run_prints_no_line_for_unchanged(self, shared_server): clean_dir(DEST_DIR) result, _ = run_client(SOURCE_DIR, DEST_DIR, flags=["--preserve"], port=shared_server.port) assert result.returncode == 0, f"seed sync failed: {result.stderr[:200]}" result, _ = run_client(SOURCE_DIR, DEST_DIR, flags=["--preserve", "-i", "--incremental"], port=shared_server.port) assert result.returncode == 0, f"incremental itemize failed: {result.stderr[:200]}" itemized = [line for line in result.stdout.splitlines() if line and line[0] in ">.f+++++++++ " + os.path.relpath(p, SOURCE_DIR) for p in _source_files()} assert sent_lines <= set(result.stdout.splitlines()), ( f"missing itemize lines in -m mode; got {result.stdout[:500]}" ) def test_dry_run_with_itemize_does_not_error(self): clean_dir(DEST_DIR) result, _ = run_client(SOURCE_DIR, DEST_DIR, flags=["-i", "--dry-run"]) assert result.returncode == 0, f"dry-run -i failed: {result.stderr[:200]}" def test_changed_file_on_second_incremental_run_prints_exactly_one_line(self, shared_server): """A changed file itemizes exactly once on an incremental rerun while unchanged files print nothing (no double emission).""" source = os.path.join(TEST_DATA_DIR, "itemize_change_src") dest = os.path.join(TEST_DATA_DIR, "itemize_change_dst") clean_dir(source) clean_dir(dest) changed = os.path.join(source, "changed.txt") untouched = os.path.join(source, "untouched.txt") with open(changed, "wb") as fh: fh.write(b"original\n") with open(untouched, "wb") as fh: fh.write(b"stable\n") result, _ = run_client(source, dest, flags=["--preserve"], port=shared_server.port) assert result.returncode == 0, f"seed sync failed: {result.stderr[:200]}" with open(changed, "wb") as fh: fh.write(b"edited payload\n") result, _ = run_client(source, dest, flags=["--preserve", "-i", "--incremental"], port=shared_server.port) assert result.returncode == 0, f"incremental itemize failed: {result.stderr[:200]}" itemized = [line for line in result.stdout.splitlines() if line.startswith(">f")] # The content and mtime both changed, so the itemize compares the # destination snapshot: size and time columns are set. assert itemized == [">f.st...... changed.txt"], ( f"expected exactly one itemize line for {changed}, got {itemized}" ) received = get_dest_received_dir(dest, source) assert _read_file(os.path.join(received, "changed.txt")) == b"edited payload\n" assert _read_file(os.path.join(received, "untouched.txt")) == b"stable\n" class TestOutFormat: """--out-format prints a line per transferred file using the template.""" def test_out_format_path_and_size(self, shared_server): clean_dir(DEST_DIR) result, _ = run_client(SOURCE_DIR, DEST_DIR, flags=["--out-format=%f %l"], port=shared_server.port) assert result.returncode == 0, f"out-format sync failed: {result.stderr[:200]}" # %f is rsync's long display path: the source argument normalized # (leading '/' stripped) joined to the transfer-relative name. prefix = SOURCE_DIR.lstrip(os.sep) expected = {f"{os.path.join(prefix, os.path.relpath(p, SOURCE_DIR))} {os.path.getsize(p)}" for p in _source_files()} got = set(result.stdout.splitlines()) assert expected <= got, f"out-format lines missing: expected {len(expected)} got {len(got)}" def test_out_format_multithreaded_matches_single(self, shared_server): clean_dir(DEST_DIR) result, _ = run_client(SOURCE_DIR, DEST_DIR, flags=["--out-format=%f %l", "--threads"], port=shared_server.port) assert result.returncode == 0, f"out-format -m sync failed: {result.stderr[:200]}" prefix = SOURCE_DIR.lstrip(os.sep) expected = {f"{os.path.join(prefix, os.path.relpath(p, SOURCE_DIR))} {os.path.getsize(p)}" for p in _source_files()} got = set(result.stdout.splitlines()) assert expected <= got, f"out-format -m lines missing: {result.stdout[:500]}" class TestLogFileFormat: """--log-file plus --log-file-format writes per-file lines to the log.""" def test_log_file_format_writes_transferred_files(self, shared_server): clean_dir(DEST_DIR) log_path = os.path.join(TEST_DATA_DIR, "itemize_transfer.log") if os.path.exists(log_path): os.unlink(log_path) result, _ = run_client( SOURCE_DIR, DEST_DIR, flags=["--log-file", log_path, "--log-file-format=%f %l"], port=shared_server.port, ) assert result.returncode == 0, f"log-file sync failed: {result.stderr[:200]}" assert os.path.exists(log_path), "--log-file created no log" with open(log_path, encoding="utf-8", errors="replace") as fh: content = fh.read() prefix = SOURCE_DIR.lstrip(os.sep) expected = {f"{os.path.join(prefix, os.path.relpath(p, SOURCE_DIR))} {os.path.getsize(p)}" for p in _source_files()} for line in expected: assert line in content, f"log file missing {line!r}" def test_log_file_format_multithreaded_writes_transferred_files(self, shared_server): source = os.path.join(TEST_DATA_DIR, "itemize_log_mt_src") dest = os.path.join(TEST_DATA_DIR, "itemize_log_mt_dst") clean_dir(source) clean_dir(dest) files = {"a.txt": b"alpha\n", "b.txt": b"beta\n"} for rel, data in files.items(): with open(os.path.join(source, rel), "wb") as fh: fh.write(data) log_path = os.path.join(TEST_DATA_DIR, "itemize_mt.log") if os.path.exists(log_path): os.unlink(log_path) result, _ = run_client( source, dest, flags=["--log-file", log_path, "--log-file-format=%f %l", "--threads"], port=shared_server.port, ) assert result.returncode == 0, f"log-file --threads sync failed: {result.stderr[:200]}" assert os.path.exists(log_path), "--log-file created no log" with open(log_path, encoding="utf-8", errors="replace") as fh: content = fh.read() prefix = os.path.abspath(source).lstrip(os.sep) expected = {f"{os.path.join(prefix, rel)} {len(data)}" for rel, data in files.items()} for line in expected: assert line in content, f"log file (--threads) missing {line!r}" class TestDelayUpdates: """--delay-updates stages every updated file under a private 0700 staging directory inside the receive root and atomically publishes all of them only after the whole transfer succeeds.""" STAGING = ".fastsync-stage" def _make_source(self, name): source = os.path.join(TEST_DATA_DIR, name) clean_dir(source) entries = { "top.txt": b"top level\n", "sub/deep.txt": b"deeply nested file\n", "sub/another.txt": b"another nested file\n" * 20, "binary.bin": bytes(range(256)) * 4, } for rel, content in entries.items(): full = os.path.join(source, rel) os.makedirs(os.path.dirname(full), exist_ok=True) with open(full, "wb") as fh: fh.write(content) return source @pytest.mark.parametrize("mt", [False, True]) def test_delay_updates_matches_plain_transfer(self, shared_server, mt): source = self._make_source("delay_match_src") plain_dest = os.path.join(TEST_DATA_DIR, "delay_match_plain_dst") delay_dest = os.path.join(TEST_DATA_DIR, "delay_match_delay_dst") clean_dir(plain_dest) clean_dir(delay_dest) result, _ = run_client(source, plain_dest, port=shared_server.port) assert result.returncode == 0, f"plain sync failed: {result.stderr[:200]}" flags = ["--delay-updates"] + (["--threads"] if mt else []) result, _ = run_client(source, delay_dest, flags=flags, port=shared_server.port) assert result.returncode == 0, f"delay-updates sync failed: {result.stderr[:200]}" plain_received = get_dest_received_dir(plain_dest, source) delay_received = get_dest_received_dir(delay_dest, source) mismatches, missing = verify_transfer(source, delay_received) assert not missing, f"Missing: {missing}" assert not mismatches, f"Mismatch: {mismatches}" for root, _dirs, files in os.walk(delay_received): for name in files: rel = os.path.relpath(os.path.join(root, name), delay_received) assert filecmp.cmp(os.path.join(plain_received, rel), os.path.join(delay_received, rel), shallow=False), rel assert not os.path.isdir(os.path.join(delay_dest, self.STAGING)), \ "staging directory left behind after a successful delayed transfer" @pytest.mark.skipif(shutil.which("rsync") is None, reason="rsync not installed") def test_delay_updates_staging_name_collision_residual(self): """Documented residual (RSYNC_COMPAT.md `--delay-updates` row): FastSync uses a fixed `.fastsync-stage` staging name and wipes a pre-existing tree of that name at the start of a delayed run (crash-leftover cleanup), even without `--delete`; rsync leaves a genuine destination entry of that name untouched. Pins the divergence that keeps the row Divergent.""" source = self._make_source("delay_collide_src") rdst = os.path.join(TEST_DATA_DIR, "delay_collide_rdst") fdst = os.path.join(TEST_DATA_DIR, "delay_collide_fdst") clean_dir(rdst) clean_dir(fdst) for root in (rdst, fdst): with open(os.path.join(root, "top.txt"), "wb") as fh: fh.write(b"old\n") stage = os.path.join(root, self.STAGING) os.makedirs(stage, exist_ok=True) with open(os.path.join(stage, "keepme.txt"), "wb") as fh: fh.write(b"genuine user data\n") r = subprocess.run(["rsync", "-a", "--delay-updates", source + "/", rdst + "/"], capture_output=True, text=True, env=dict(os.environ, LC_ALL="C")) assert r.returncode == 0, r.stderr assert os.path.exists(os.path.join(rdst, self.STAGING, "keepme.txt")), \ "rsync removed an unrelated destination entry named like the staging dir" with ServerManager() as server: server.start() result, _ = run_client(source, fdst, flags=["--delay-updates"], port=server.port) assert result.returncode == 0, (result.stderr or result.stdout)[:300] assert not os.path.exists(os.path.join(fdst, self.STAGING)), \ "FastSync did not wipe the reserved staging name (residual changed)" @pytest.mark.parametrize("mt", [False, True]) def test_delay_updates_incremental_rerun_no_leftovers(self, shared_server, mt): source = self._make_source("delay_rerun_src") dest = os.path.join(TEST_DATA_DIR, "delay_rerun_dst") clean_dir(dest) flags = ["--delay-updates", "--preserve", "--incremental"] + (["--threads"] if mt else []) result, _ = run_client(source, dest, flags=flags, port=shared_server.port) assert result.returncode == 0, f"first delayed sync failed: {result.stderr[:200]}" received = get_dest_received_dir(dest, source) mismatches, missing = verify_transfer(source, received) assert not missing and not mismatches assert not os.path.isdir(os.path.join(dest, self.STAGING)) result, _ = run_client(source, dest, flags=flags, port=shared_server.port) assert result.returncode == 0, f"second delayed sync failed: {result.stderr[:200]}" assert not os.path.isdir(os.path.join(dest, self.STAGING)), \ "fully-skipped delayed run left a staging directory" @pytest.mark.parametrize("mt", [False, True]) def test_remove_source_files_with_delay_updates(self, shared_server, mt): source = self._make_source("delay_rsf_src") dest = os.path.join(TEST_DATA_DIR, "delay_rsf_dst") clean_dir(dest) flags = ["--remove-source-files", "--delay-updates"] + (["--threads"] if mt else []) result, _ = run_client(source, dest, flags=flags, port=shared_server.port) assert result.returncode == 0, f"delayed remove-source sync failed: {result.stderr[:200]}" # Sources are removed only after the receiver published every file. for root, _dirs, files in os.walk(source): assert files == [], f"source files survived delayed remove-source-files: {files}" received = get_dest_received_dir(dest, source) assert os.path.isfile(os.path.join(received, "top.txt")) assert os.path.isfile(os.path.join(received, "sub", "deep.txt")) assert not os.path.isdir(os.path.join(dest, self.STAGING)) @pytest.mark.parametrize("mt", [False, True]) def test_delete_with_delay_updates(self, mt): """--delete runs before publication, so the delete walker must not treat the staging directory as a set of extras: a changed file must still be published after genuine extras are removed. Uses its own server started with --allow-delete (the shared session server refuses deletion).""" source = os.path.join(TEST_DATA_DIR, "delay_delete_src") dest = os.path.join(TEST_DATA_DIR, "delay_delete_dst") clean_dir(source) clean_dir(dest) with open(os.path.join(source, "f.txt"), "wb") as fh: fh.write(b"AAAA") with open(os.path.join(source, "extra.txt"), "wb") as fh: fh.write(b"seed extra") with ServerManager() as server: server.start(extra_args=["--allow-delete"]) result, _ = run_client(source, dest, port=server.port) assert result.returncode == 0, f"seed sync failed: {result.stderr[:200]}" received = get_dest_received_dir(dest, source) assert _read_file(os.path.join(received, "extra.txt")) == b"seed extra" # Second source state: f.txt changed, extra.txt removed from source. with open(os.path.join(source, "f.txt"), "wb") as fh: fh.write(b"BBBB") os.remove(os.path.join(source, "extra.txt")) flags = ["--delete", "--delay-updates"] + (["--threads"] if mt else []) result, _ = run_client(source, dest, flags=flags, port=server.port) assert result.returncode == 0, \ f"delete+delay-updates sync failed: {result.stderr[:200]}" assert _read_file(os.path.join(received, "f.txt")) == b"BBBB", \ "changed file was not published after deletion" assert not os.path.exists(os.path.join(received, "extra.txt")), \ "genuine extra file was not deleted" assert not os.path.isdir(os.path.join(dest, self.STAGING)) def test_delay_updates_rejects_reserved_backup_dir(self): """--backup-dir equal to the internal staging name must be rejected so an old backup can never be silently installed as the "new" file.""" source = self._make_source("delay_reserved_bak_src") for variant, suffix in (("bare", ""), ("slash", "/")): dest = os.path.join(TEST_DATA_DIR, f"delay_reserved_bak_{variant}_dst") clean_dir(dest) flags = ["--delay-updates", "--backup", "--backup-dir", ".fastsync-stage" + suffix] result, _ = run_client(source, dest, flags=flags, port=None) assert result.returncode != 0, \ f"reserved --backup-dir '{suffix}' was accepted" assert not os.path.isdir(os.path.join(dest, self.STAGING)), \ "staging directory created by a rejected run" @pytest.mark.parametrize("remove_source_files", [False, True]) @pytest.mark.parametrize("mt", [False, True]) def test_mid_publish_failure_keeps_published_no_rollback(self, shared_server, mt, remove_source_files): """A stage->publish rename failing part way through publication must fail the whole transfer, keep the already-published top-level file (no rollback), leave the not-yet-published nested file absent, and clean up the staging area. A regular file is planted where the final "sub" directory must be created, so the nested rename fails (mkdir over a file is impossible even for root) while the top-level file, which is always staged first, publishes. With --remove-source-files the sender must keep every source because no success/outcome frame is ever sent.""" source = os.path.join(TEST_DATA_DIR, "delay_mid_src") dest = os.path.join(TEST_DATA_DIR, "delay_mid_dst") clean_dir(source) clean_dir(dest) top_path = os.path.join(source, "top.txt") deep_path = os.path.join(source, "sub", "deep.txt") with open(top_path, "wb") as fh: fh.write(b"top payload\n") os.makedirs(os.path.dirname(deep_path)) with open(deep_path, "wb") as fh: fh.write(b"deep payload\n") received = get_dest_received_dir(dest, source) os.makedirs(received) with open(os.path.join(received, "sub"), "wb") as fh: fh.write(b"blocks the nested destination directory") flags = ["--delay-updates"] + (["--threads"] if mt else []) if remove_source_files: flags += ["--remove-source-files"] result, _ = run_client(source, dest, flags=flags, port=shared_server.port) assert result.returncode != 0, "blocked nested publish did not fail" # The top-level file was published before the nested rename failed and # is intentionally NOT rolled back. assert _read_file(os.path.join(received, "top.txt")) == b"top payload\n" # The nested file was never published. assert not os.path.lexists(os.path.join(received, "sub", "deep.txt")), \ "nested file appeared despite a failed publish" assert not os.path.isdir(os.path.join(dest, self.STAGING)), \ "staging leftovers after a failed mid-publish" # Sources survive: no success frame was sent, so a remove-source-files # sender must not delete anything. assert os.path.isfile(top_path) assert os.path.isfile(deep_path) @pytest.mark.parametrize("mt", [False, True]) def test_remove_source_files_keeps_receiver_skipped_source(self, shared_server, mt): """With --delay-updates + --ignore-existing a receiver-skipped source must survive (its outcome is sent only after publication) while a freshly delivered file is published and its source removed.""" source = os.path.join(TEST_DATA_DIR, "delay_rsf_skip_src") dest = os.path.join(TEST_DATA_DIR, "delay_rsf_skip_dst") clean_dir(source) clean_dir(dest) with open(os.path.join(source, "keep.txt"), "wb") as fh: fh.write(b"existing on dest") result, _ = run_client(source, dest, port=shared_server.port) assert result.returncode == 0, f"seed sync failed: {result.stderr[:200]}" with open(os.path.join(source, "keep.txt"), "wb") as fh: fh.write(b"changed on source") with open(os.path.join(source, "deliver.txt"), "wb") as fh: fh.write(b"new file") flags = ["--remove-source-files", "--ignore-existing", "--delay-updates"] + (["--threads"] if mt else []) result, _ = run_client(source, dest, flags=flags, port=shared_server.port) assert result.returncode == 0, f"delayed skip sync failed: {result.stderr[:200]}" # keep.txt already existed at the destination: receiver skip -> source stays. assert os.path.isfile(os.path.join(source, "keep.txt")), \ "receiver-skipped source was removed despite --ignore-existing" # deliver.txt was new: staged, published, and its source removed. assert not os.path.isfile(os.path.join(source, "deliver.txt")), \ "published source was not removed" received = get_dest_received_dir(dest, source) assert not os.path.isdir(os.path.join(dest, self.STAGING)) def test_delay_updates_rejects_inplace(self): source = self._make_source("delay_inplace_src") dest = os.path.join(TEST_DATA_DIR, "delay_inplace_dst") clean_dir(dest) result, _ = run_client(source, dest, flags=["--delay-updates", "--inplace"]) assert result.returncode != 0, "--inplace with --delay-updates was accepted" assert not os.path.isdir(os.path.join(dest, self.STAGING)) class TestFilesFrom: """--files-from transfers exactly the listed files; a listed directory transfers its whole subtree. The manifest (and thus --delete) derives from what was actually sent.""" def _make_relative_source(name): """A small tree used by the -R/--dirs/--no-implied-dirs tests.""" source = os.path.join(TEST_DATA_DIR, name) clean_dir(source) entries = { "top.txt": b"top\n", "a/b.txt": b"nested\n", "sub/x.txt": b"x\n", "sub/y.txt": b"y\n", "dir1/keep.txt": b"dir content\n", } for rel, content in entries.items(): full = os.path.join(source, rel) os.makedirs(os.path.dirname(full), exist_ok=True) with open(full, "wb") as fh: fh.write(content) return source def _write_rel_list(rel_text): path = os.path.join(TEST_DATA_DIR, "rel_list.txt") with open(path, "wb") as fh: fh.write(rel_text) return path class TestRelativeFilesFrom: """-R/--relative with --files-from keeps each listed entry's bare relative destination path below the destination root instead of mirroring the full source path. Without -R the layout is unchanged (full source mirror).""" @pytest.mark.parametrize("mt", [False, True]) def test_relative_files_from_keeps_relative_layout(self, shared_server, mt): source = _make_relative_source("rel_src") dest = os.path.join(TEST_DATA_DIR, "rel_dst") clean_dir(dest) lst = _write_rel_list(b"top.txt\nsub/x.txt\n") flags = ["--files-from", lst, "-R"] + (["--threads"] if mt else []) result, _ = run_client(source, dest, flags=flags, port=shared_server.port) assert result.returncode == 0, f"-R files-from sync failed: {result.stderr[:200]}" assert _read_file(os.path.join(dest, "sub", "x.txt")) == b"x\n", \ "listed file must land at /sub/x.txt" assert _read_file(os.path.join(dest, "top.txt")) == b"top\n", \ "top-level listed file must land at /top.txt" assert not os.path.exists(os.path.join(dest, "sub", "y.txt")) # The source-root mirror must not be reproduced under -R. assert not os.path.exists(get_dest_received_dir(dest, source)), \ "-R must not mirror the full source path" @pytest.mark.parametrize("mt", [False, True]) def test_relative_without_files_from_has_no_effect(self, shared_server, mt): """-R alone (no --files-from) must leave the normal full-source mirror layout untouched.""" source = _make_relative_source("rel_only_src") dest = os.path.join(TEST_DATA_DIR, "rel_only_dst") clean_dir(dest) flags = ["-R"] + (["--threads"] if mt else []) result, _ = run_client(source, dest, flags=flags, port=shared_server.port) assert result.returncode == 0, f"-R alone sync failed: {result.stderr[:200]}" received = get_dest_received_dir(dest, source) mismatches, missing = verify_transfer(source, received) assert not missing and not mismatches @pytest.mark.parametrize("mt", [False, True]) def test_without_relative_layout_unchanged(self, shared_server, mt): source = _make_relative_source("rel_noR_src") dest = os.path.join(TEST_DATA_DIR, "rel_noR_dst") clean_dir(dest) lst = _write_rel_list(b"sub/x.txt\n") flags = ["--files-from", lst] + (["--threads"] if mt else []) result, _ = run_client(source, dest, flags=flags, port=shared_server.port) assert result.returncode == 0, f"files-from sync failed: {result.stderr[:200]}" received = get_dest_received_dir(dest, source) assert _read_file(os.path.join(received, "sub", "x.txt")) == b"x\n", \ "without -R the full source mirror layout is preserved" assert not os.path.exists(os.path.join(dest, "sub")), \ "bare relative layout must not appear without -R" def test_relative_delete_manifest_stays_consistent(self): """--delete with --files-from is confined to the synchronized directories (rsync parity): listing a FILE does not make its parent a delete scope, but listing the DIRECTORY does.""" source = _make_relative_source("rel_del_src") dest = os.path.join(TEST_DATA_DIR, "rel_del_dst") clean_dir(dest) with ServerManager() as server: server.start(extra_args=["--allow-delete"]) lst = _write_rel_list(b"sub/x.txt\nsub/y.txt\n") result, _ = run_client(source, dest, flags=["--files-from", lst, "-R"], port=server.port) assert result.returncode == 0, f"seed -R sync failed: {result.stderr[:200]}" assert os.path.isfile(os.path.join(dest, "sub", "y.txt")) # A file-only listing leaves sub/ unsynchronized: y.txt survives. subset = _write_rel_list(b"sub/x.txt\n") result, _ = run_client(source, dest, flags=["--files-from", subset, "-R", "--delete"], port=server.port) assert result.returncode == 0, f"-R delete sync failed: {result.stderr[:200]}" assert os.path.isfile(os.path.join(dest, "sub", "x.txt")), "listed file was deleted" assert os.path.exists(os.path.join(dest, "sub", "y.txt")), \ "file-only --files-from made the parent a delete scope (rsync keeps it)" # Listing the directory synchronizes it: a source-removed y.txt is now # an in-scope extra and is deleted. os.unlink(os.path.join(source, "sub", "y.txt")) listed_dir = _write_rel_list(b"sub/\n") result, _ = run_client(source, dest, flags=["--files-from", listed_dir, "-R", "--delete"], port=server.port) assert result.returncode == 0, f"-R dir delete sync failed: {result.stderr[:200]}" assert os.path.isfile(os.path.join(dest, "sub", "x.txt")) assert not os.path.exists(os.path.join(dest, "sub", "y.txt")), \ "directory-listed --delete did not remove the in-scope extra" @pytest.mark.parametrize("mt", [False, True]) def test_relative_root_size_prune_protects_mirror_from_delete(self, mt): """#12: a root-level --max-size prune under -R + --files-from must record the bare relative wire path as its delete-protected prefix, so the size-pruned entry's destination mirror survives --delete (rsync parity).""" source = _make_relative_source("rel_rootsize_src") # Big enough that a 100-byte cap prunes only this entry. with open(os.path.join(source, "big.txt"), "wb") as fh: fh.write(b"b" * 1000) dest = os.path.join(TEST_DATA_DIR, "rel_rootsize_dst") clean_dir(dest) with ServerManager() as server: server.start(extra_args=["--allow-delete"]) # "." lists the whole tree, so the receive root is a delete scope # (a file-only list would leave the root out of scope, masking the # protected-prefix mismatch this test targets). lst = _write_rel_list(b".\n") result, _ = run_client(source, dest, flags=["--files-from", lst, "-R"] + (["--threads"] if mt else []), port=server.port) assert result.returncode == 0, f"seed -R sync failed: {result.stderr[:200]}" assert os.path.isfile(os.path.join(dest, "big.txt")) with open(os.path.join(dest, "unrelated.txt"), "w") as fh: fh.write("x") # --max-size=100 prunes only big.txt; its dest mirror is always protected. result, _ = run_client(source, dest, flags=["--files-from", lst, "-R", "--delete", "--max-size=100"] + (["--threads"] if mt else []), port=server.port) assert result.returncode == 0, f"-R size+delete sync failed: {result.stderr[:300]}" assert not os.path.exists(os.path.join(dest, "unrelated.txt")), "delete not active" assert os.path.isfile(os.path.join(dest, "big.txt")), \ "the size-pruned entry's mirror was wrongly deleted (protected prefix mismatch)" assert os.path.isfile(os.path.join(dest, "sub", "x.txt")) assert os.path.isfile(os.path.join(dest, "top.txt")) class TestMissingArgs: """--ignore-missing-args / --delete-missing-args: a --files-from entry that does not exist under the source is skipped instead of failing the run, and (delete-missing) its destination mirror is removed receiver-side. Following rsync, --delete-missing-args implies --ignore-missing-args but is independent of --delete: unrelated extras stay unless --delete is also given, and the missing-args deletion (an explicit user request) is never blocked by filter-exclusion protection.""" def _make_source(self, name): source = os.path.join(TEST_DATA_DIR, name) clean_dir(source) for rel, content in { "a.txt": b"a\n", "sub/b.txt": b"b\n", "keep.txt": b"keep\n", "prot/kept.txt": b"kept\n", }.items(): full = os.path.join(source, rel) os.makedirs(os.path.dirname(full), exist_ok=True) with open(full, "wb") as fh: fh.write(content) return source @pytest.mark.parametrize("mt", [False, True]) def test_missing_entry_is_hard_error_before_transfer(self, shared_server, mt): source = self._make_source("mg_default_src") dest = os.path.join(TEST_DATA_DIR, "mg_default_dst") clean_dir(dest) lst = _write_rel_list(b"a.txt\ngone.txt\nsub/b.txt\n") flags = ["--files-from", lst] + (["--threads"] if mt else []) result, _ = run_client(source, dest, flags=flags, port=shared_server.port) assert result.returncode != 0, "a listed-but-missing entry did not fail the run" assert "gone.txt" in (result.stderr or result.stdout) received = get_dest_received_dir(dest, source) assert not os.path.isfile(os.path.join(received, "a.txt")), \ "the transfer started despite the missing-entry hard error" @pytest.mark.parametrize("mt", [False, True]) def test_ignore_missing_args_transfers_the_rest(self, shared_server, mt): source = self._make_source("mg_ignore_src") dest = os.path.join(TEST_DATA_DIR, "mg_ignore_dst") clean_dir(dest) lst = _write_rel_list(b"a.txt\ngone.txt\nsub/b.txt\n") flags = ["--files-from", lst, "--ignore-missing-args"] + (["--threads"] if mt else []) result, _ = run_client(source, dest, flags=flags, port=shared_server.port) assert result.returncode == 0, f"ignore-missing-args sync failed: {result.stderr[:300]}" received = get_dest_received_dir(dest, source) assert _read_file(os.path.join(received, "a.txt")) == b"a\n" assert _read_file(os.path.join(received, "sub", "b.txt")) == b"b\n" assert not os.path.exists(os.path.join(received, "gone.txt")), \ "nothing was transferred for the missing entry" assert "--ignore-missing-args" in (result.stderr or result.stdout), \ "the skipped entry must be observable (not a silent no-op)" @pytest.mark.parametrize("mt", [False, True]) def test_all_missing_entries_succeed_transferring_nothing(self, shared_server, mt): source = self._make_source("mg_all_missing_src") dest = os.path.join(TEST_DATA_DIR, "mg_all_missing_dst") clean_dir(dest) lst = _write_rel_list(b"gone1.txt\ngone2.txt\n") flags = ["--files-from", lst, "--ignore-missing-args"] + (["--threads"] if mt else []) result, _ = run_client(source, dest, flags=flags, port=shared_server.port) assert result.returncode == 0, \ f"all-missing run should succeed (rsync parity): {result.stderr[:300]}" received = get_dest_received_dir(dest, source) assert not os.path.exists(os.path.join(received, "gone1.txt")) @pytest.mark.parametrize("mt", [False, True]) def test_empty_list_succeeds_transferring_nothing(self, shared_server, mt): """rsync 3.4.1 treats an empty --files-from list as "nothing to transfer" and exits 0 (verified with the real binary), so fastsync must too rather than reporting a hard error.""" source = self._make_source("mg_empty_src") dest = os.path.join(TEST_DATA_DIR, "mg_empty_dst") clean_dir(dest) lst = _write_rel_list(b"") flags = ["--files-from", lst, "--ignore-missing-args"] + (["--threads"] if mt else []) result, _ = run_client(source, dest, flags=flags, port=shared_server.port) assert result.returncode == 0, \ f"an empty --files-from list must succeed like rsync: {result.stderr[:300]}" received = get_dest_received_dir(dest, source) assert not os.path.exists(os.path.join(received, "a.txt")), \ "an empty --files-from list must transfer nothing" @pytest.mark.parametrize("mt", [False, True]) def test_delete_missing_removes_mirror_not_unrelated(self, mt): """-R layout: --delete-missing-args deletes exactly the missing entry's destination mirror (bare relative path) and leaves unrelated extras untouched; with --delete also present the unrelated extras go too.""" source = self._make_source("mg_del_src") dest = os.path.join(TEST_DATA_DIR, "mg_del_dst") clean_dir(dest) with ServerManager() as server: server.start(extra_args=["--allow-delete"]) seed = _write_rel_list(b"a.txt\nsub/b.txt\n") result, _ = run_client(source, dest, flags=["--files-from", seed, "-R"] + (["--threads"] if mt else []), port=server.port) assert result.returncode == 0, f"seed -R sync failed: {result.stderr[:200]}" assert os.path.isfile(os.path.join(dest, "a.txt")) assert os.path.isfile(os.path.join(dest, "sub", "b.txt")) # Plant the missing entry's destination mirror and an unrelated extra. with open(os.path.join(dest, "gone.txt"), "w") as fh: fh.write("stale mirror") with open(os.path.join(dest, "unrelated.txt"), "w") as fh: fh.write("unrelated") lst = _write_rel_list(b"a.txt\ngone.txt\nsub/b.txt\n") flags = ["--files-from", lst, "-R", "--delete-missing-args"] + (["--threads"] if mt else []) result, _ = run_client(source, dest, flags=flags, port=server.port) assert result.returncode == 0, f"delete-missing sync failed: {result.stderr[:300]}" assert not os.path.exists(os.path.join(dest, "gone.txt")), \ "the missing entry's destination mirror was not deleted" assert os.path.isfile(os.path.join(dest, "unrelated.txt")), \ "--delete-missing-args removed an unrelated extra (only --delete may)" assert os.path.isfile(os.path.join(dest, "a.txt")) assert os.path.isfile(os.path.join(dest, "sub", "b.txt")) # --delete is confined to synchronized directories: no listed # directory, so the root-level unrelated extra survives (rsync parity). lst2 = _write_rel_list(b"a.txt\ngone.txt\nsub/b.txt\n") flags2 = ["--files-from", lst2, "-R", "--delete-missing-args", "--delete"] + \ (["--threads"] if mt else []) result, _ = run_client(source, dest, flags=flags2, port=server.port) assert result.returncode == 0, f"delete-missing + delete sync failed: {result.stderr[:300]}" assert os.path.isfile(os.path.join(dest, "unrelated.txt")), \ "--delete under --files-from removed an extra outside a listed directory" assert not os.path.exists(os.path.join(dest, "gone.txt")) assert os.path.isfile(os.path.join(dest, "a.txt")) @pytest.mark.parametrize("mt", [False, True]) def test_delete_missing_mirror_outside_relative_layout(self, mt): """Without -R the missing entry's mirror mirrors the full source path below the destination root, exactly like a present sibling's.""" source = self._make_source("mg_del_nor_src") dest = os.path.join(TEST_DATA_DIR, "mg_del_nor_dst") clean_dir(dest) with ServerManager() as server: server.start(extra_args=["--allow-delete"]) # Full-tree seed places every current source file in the mirrored layout. result, _ = run_client(source, dest, flags=["--delete"], port=server.port) assert result.returncode == 0, f"seed sync failed: {result.stderr[:200]}" received = get_dest_received_dir(dest, source) assert os.path.isfile(os.path.join(received, "a.txt")) # Plant a stale mirror for an entry not (yet) on the source. with open(os.path.join(received, "gone.txt"), "w") as fh: fh.write("stale") lst = _write_rel_list(b"a.txt\ngone.txt\n") result, _ = run_client(source, dest, flags=["--files-from", lst, "--delete-missing-args"], port=server.port) assert result.returncode == 0, f"delete-missing no-R sync failed: {result.stderr[:300]}" assert not os.path.exists(os.path.join(received, "gone.txt")), \ "the full-source-mirror path of the missing entry was not deleted" assert os.path.isfile(os.path.join(received, "a.txt")) @pytest.mark.parametrize("mt", [False, True]) def test_delete_missing_args_respects_max_delete_budget(self, mt): """#290 (5): --delete-missing-args deletions draw from the same --max-delete budget as the ordinary extras walk: only the first N happen and the run exits 25 like rsync.""" source = self._make_source("mg_budget_src") dest = os.path.join(TEST_DATA_DIR, "mg_budget_dst") clean_dir(dest) with ServerManager() as server: server.start(extra_args=["--allow-delete"]) result, _ = run_client(source, dest, port=server.port) assert result.returncode == 0, f"seed failed: {result.stderr[:200]}" received = get_dest_received_dir(dest, source) for name in ("gone1.txt", "gone2.txt", "gone3.txt"): with open(os.path.join(received, name), "w") as fh: fh.write("stale") lst = _write_rel_list(b"a.txt\ngone1.txt\ngone2.txt\ngone3.txt\n") flags = ["--files-from", lst, "--delete-missing-args", "--max-delete=2"] + \ (["--threads"] if mt else []) result, _ = run_client(source, dest, flags=flags, port=server.port) assert result.returncode == 25, \ f"--delete-missing-args --max-delete=2 should exit 25: {result.stderr[:300]}" remaining = [n for n in ("gone1.txt", "gone2.txt", "gone3.txt") if os.path.exists(os.path.join(received, n))] assert len(remaining) == 1, \ f"missing-args deletions ignored the --max-delete budget: {remaining}" assert os.path.isfile(os.path.join(received, "a.txt")) @pytest.mark.parametrize("mt", [False, True]) def test_delete_missing_nonempty_dir_counts_each_entry_against_budget(self, mt): """A non-empty missing-arg directory with --force/--delete is removed entry-by-entry, each counting toward --max-delete (rsync parity): with a small cap the run stops after N files and leaves the rest in place.""" source = self._make_source("mg_dirbudget_src") dest = os.path.join(TEST_DATA_DIR, "mg_dirbudget_dst") clean_dir(dest) with ServerManager() as server: server.start(extra_args=["--allow-delete"]) result, _ = run_client(source, dest, port=server.port) assert result.returncode == 0, f"seed failed: {result.stderr[:200]}" received = get_dest_received_dir(dest, source) gone = os.path.join(received, "gone") os.makedirs(gone) for i in range(4): with open(os.path.join(gone, f"f{i}"), "w") as fh: fh.write("stale") lst = _write_rel_list(b"a.txt\ngone\n") flags = ["--files-from", lst, "--delete-missing-args", "--force", "--max-delete=2"] + (["--threads"] if mt else []) result, _ = run_client(source, dest, flags=flags, port=server.port) assert result.returncode == 25, \ f"non-empty missing-arg dir should cap at 2 and exit 25: {result.stderr[:300]}" assert os.path.isdir(gone), \ "the non-empty missing-arg directory should survive a capped run" remaining = len(os.listdir(gone)) assert remaining == 2, f"expected 2 entries left, found {remaining}" assert os.path.isfile(os.path.join(received, "a.txt")) @pytest.mark.parametrize("mt", [False, True]) def test_delete_missing_args_not_blocked_by_exclude_protection(self, mt): """A missing-arg mirror that sits under a filter-excluded directory is an explicit user request, so --delete-missing-args removes it even though an ordinary --delete honours the exclusion protection (rsync parity). Uses the non-relative layout: exclusion protection is only recorded there.""" source = self._make_source("mg_excl_src") dest = os.path.join(TEST_DATA_DIR, "mg_excl_dst") clean_dir(dest) with ServerManager() as server: server.start(extra_args=["--allow-delete"]) # Full-tree seed mirrors the whole source below the destination root. result, _ = run_client(source, dest, port=server.port) assert result.returncode == 0, f"seed failed: {result.stderr[:200]}" received = get_dest_received_dir(dest, source) assert os.path.isfile(os.path.join(received, "prot", "kept.txt")) # A stale mirror under the (now excluded) prot/ directory, plus an extra. with open(os.path.join(received, "prot", "gone.txt"), "w") as fh: fh.write("stale") with open(os.path.join(received, "extra.txt"), "w") as fh: fh.write("extra") lst = _write_rel_list(b"a.txt\nprot/gone.txt\n") flags = ["--files-from", lst, "--filter=- prot/", "--delete-missing-args", "--delete"] + (["--threads"] if mt else []) result, _ = run_client(source, dest, flags=flags, port=server.port) assert result.returncode == 0, f"delete-missing exclude sync failed: {result.stderr[:300]}" assert not os.path.exists(os.path.join(received, "prot", "gone.txt")), \ "the explicit missing-arg deletion was blocked by exclusion protection" assert os.path.isfile(os.path.join(received, "prot", "kept.txt")), \ "the excluded-but-present destination file must stay (default protection)" # A file-only --files-from listing synchronizes no directory, so the # root-level extra is outside the delete scope (rsync parity). assert os.path.isfile(os.path.join(received, "extra.txt")), \ "--delete under --files-from removed an extra outside a listed directory" assert os.path.isfile(os.path.join(received, "a.txt")) @pytest.mark.parametrize("mt", [False, True]) def test_delete_missing_args_with_delete_before(self, mt): """--delete-before (early delete timing) composes with --delete-missing-args: the exact-path deletions commit with the early manifest, before data, and --delete-before implies --delete (so unrelated extras go too).""" source = self._make_source("mg_early_src") dest = os.path.join(TEST_DATA_DIR, "mg_early_dst") clean_dir(dest) with open(os.path.join(dest, "gone.txt"), "w") as fh: fh.write("stale") with open(os.path.join(dest, "extra.txt"), "w") as fh: fh.write("extra") with ServerManager() as server: server.start(extra_args=["--allow-delete"]) lst = _write_rel_list(b"a.txt\ngone.txt\ngone2.txt\n") flags = ["--files-from", lst, "-R", "--delete-missing-args", "--delete-before"] + \ (["--threads"] if mt else []) result, _ = run_client(source, dest, flags=flags, port=server.port) assert result.returncode == 0, f"early delete-missing sync failed: {result.stderr[:300]}" assert not os.path.exists(os.path.join(dest, "gone.txt")), \ "early timing did not remove the missing-arg mirror" assert os.path.isfile(os.path.join(dest, "a.txt")), "a.txt was not transferred" # --delete-before implies --delete, but the extras walk is still # confined to synchronized directories: no listed directory here. assert os.path.isfile(os.path.join(dest, "extra.txt")), \ "--delete-before under --files-from removed an extra outside a listed directory" @pytest.mark.parametrize("mt", [False, True]) @pytest.mark.parametrize("relative", [False, True]) def test_delete_missing_deep_entry_with_absent_parent(self, mt, relative): """A missing entry whose destination mirror's parent directory does not exist is a no-op (nothing to delete), never a run failure: the exact-path deletions must not abort the --delete extras walk. Covers the -R bare-relative layout and the full source-mirror layout.""" source = self._make_source("mg_deep_src") # A listed directory gives the extras walk a synchronized scope to work # in, so the test can prove the absent-parent missing entry did not abort # it. os.makedirs(os.path.join(source, "scope")) with open(os.path.join(source, "scope", "keep.txt"), "w") as fh: fh.write("kept\n") dest = os.path.join(TEST_DATA_DIR, "mg_deep_dst") clean_dir(dest) rel_flags = ["-R"] if relative else [] with ServerManager() as server: server.start(extra_args=["--allow-delete"]) if relative: target_root = dest else: # Non-relative layout: seed a.txt so the receive-root mirror # tree exists (its sub/ sibling deliberately does not). seed = _write_rel_list(b"a.txt\n") result, _ = run_client(source, dest, flags=["--files-from", seed] + rel_flags, port=server.port) assert result.returncode == 0, f"seed sync failed: {result.stderr[:200]}" target_root = get_dest_received_dir(dest, source) assert os.path.isfile(os.path.join(target_root, "a.txt")) with open(os.path.join(target_root, "extra.txt"), "w") as fh: fh.write("extra") os.makedirs(os.path.join(target_root, "scope"), exist_ok=True) with open(os.path.join(target_root, "scope", "extra.txt"), "w") as fh: fh.write("extra") lst = _write_rel_list(b"a.txt\nscope/\nsub/gone.txt\n") flags = ["--files-from", lst, "--delete-missing-args", "--delete"] + rel_flags + \ (["--threads"] if mt else []) result, _ = run_client(source, dest, flags=flags, port=server.port) assert result.returncode == 0, \ f"deep missing-entry sync failed: {result.stderr[:300]}" assert _read_file(os.path.join(target_root, "a.txt")) == b"a\n" assert _read_file(os.path.join(target_root, "scope", "keep.txt")) == b"kept\n" assert not os.path.exists(os.path.join(target_root, "scope", "extra.txt")), \ "--delete extras walk was aborted by the absent-parent missing entry" # The root-level extra is outside every listed directory: it survives. assert os.path.exists(os.path.join(target_root, "extra.txt")) assert not os.path.exists(os.path.join(target_root, "sub")), \ "the absent parent directory of the missing entry was created" @pytest.mark.parametrize("mt", [False, True]) def test_dirs_missing_entry_skipped_in_scanner(self, shared_server, mt): """--dirs + --files-from: a listed-but-missing entry is skipped in the --dirs generator (which would otherwise hard-fail), transferring the rest of the list.""" source = self._make_source("mg_dirs_src") dest = os.path.join(TEST_DATA_DIR, "mg_dirs_dst") clean_dir(dest) lst = _write_rel_list(b"a.txt\ngone.txt\n") flags = ["--files-from", lst, "--dirs", "-R", "--ignore-missing-args"] + \ (["--threads"] if mt else []) result, _ = run_client(source, dest, flags=flags, port=shared_server.port) assert result.returncode == 0, f"--dirs ignore-missing sync failed: {result.stderr[:300]}" assert _read_file(os.path.join(dest, "a.txt")) == b"a\n", \ "the listed present file was not transferred" assert not os.path.exists(os.path.join(dest, "gone.txt")), \ "a directory/file was created for the missing --dirs entry" class TestNoImpliedDirs: """--no-implied-dirs (meaningful with -R) omits the source metadata of a listed path's implied parent directories but still creates those parents with default attributes, matching rsync 3.4.1.""" def _make(self): return _make_relative_source("noimplied_src") @pytest.mark.parametrize("mt", [False, True]) def test_implied_dir_created_with_default_attrs(self, shared_server, mt): source = self._make() dest = os.path.join(TEST_DATA_DIR, "noimplied_dst") clean_dir(dest) lst = _write_rel_list(b"a/b.txt\n") # "a" itself is not listed flags = ["--files-from", lst, "-R", "--no-implied-dirs"] + (["--threads"] if mt else []) result, _ = run_client(source, dest, flags=flags, port=shared_server.port) assert result.returncode == 0, \ f"implied parent directory was not created: {result.stderr[:200]}" assert os.path.isdir(os.path.join(dest, "a")), "implied parent 'a' was not created" assert _read_file(os.path.join(dest, "a", "b.txt")) == b"nested\n" @pytest.mark.parametrize("mt", [False, True]) def test_listed_dir_allows_file(self, shared_server, mt): source = self._make() dest = os.path.join(TEST_DATA_DIR, "noimplied_ok_dst") clean_dir(dest) lst = _write_rel_list(b"a\na/b.txt\n") flags = ["--files-from", lst, "-R", "--no-implied-dirs"] + (["--threads"] if mt else []) result, _ = run_client(source, dest, flags=flags, port=shared_server.port) assert result.returncode == 0, f"listed dir + file sync failed: {result.stderr[:200]}" assert _read_file(os.path.join(dest, "a", "b.txt")) == b"nested\n" @pytest.mark.parametrize("mt", [False, True]) def test_no_implied_dirs_without_relative_changes_nothing(self, shared_server, mt): source = self._make() dest = os.path.join(TEST_DATA_DIR, "noimplied_noR_dst") clean_dir(dest) lst = _write_rel_list(b"a/b.txt\n") flags = ["--files-from", lst, "--no-implied-dirs"] + (["--threads"] if mt else []) result, _ = run_client(source, dest, flags=flags, port=shared_server.port) assert result.returncode == 0, "--no-implied-dirs without -R changed behavior" received = get_dest_received_dir(dest, source) assert _read_file(os.path.join(received, "a", "b.txt")) == b"nested\n" class TestDirs: """-d/--dirs (and the --old-dirs/--old-d aliases) transfer directory entries without recursing into their contents.""" def _make(self): return _make_relative_source("dirs_src") def _assert_only_empty_mirror(self, dest, source): mirror = get_dest_received_dir(dest, source) assert os.path.isdir(mirror), "source-root mirror directory was not created" files = [] for root, _dirs, names in os.walk(mirror): files.extend(os.path.relpath(os.path.join(root, n), mirror) for n in names) assert files == [], f"--dirs descended into contents: {files}" @pytest.mark.parametrize("flag", ["--dirs", "-d", "--old-dirs", "--old-d"]) @pytest.mark.parametrize("mt", [False, True]) def test_dirs_transfers_empty_dir_only(self, shared_server, flag, mt): source = self._make() dest = os.path.join(TEST_DATA_DIR, "dirs_dst") clean_dir(dest) flags = [flag] + (["--threads"] if mt else []) result, _ = run_client(source, dest, flags=flags, port=shared_server.port) assert result.returncode == 0, f"{flag} sync failed: {result.stderr[:200]}" self._assert_only_empty_mirror(dest, source) @pytest.mark.parametrize("mt", [False, True]) def test_dirs_with_files_from(self, shared_server, mt): source = self._make() dest = os.path.join(TEST_DATA_DIR, "dirs_ff_dst") clean_dir(dest) # A listed directory is created empty; a listed file is transferred. lst = _write_rel_list(b"dir1\nsub/x.txt\n") flags = ["--files-from", lst, "--dirs", "-R"] + (["--threads"] if mt else []) result, _ = run_client(source, dest, flags=flags, port=shared_server.port) assert result.returncode == 0, f"dirs files-from sync failed: {result.stderr[:200]}" assert os.path.isdir(os.path.join(dest, "dir1")), "listed dir was not created" assert not os.path.exists(os.path.join(dest, "dir1", "keep.txt")), \ "--dirs must not descend into a listed directory" assert _read_file(os.path.join(dest, "sub", "x.txt")) == b"x\n", \ "listed file content was not transferred" assert not os.path.exists(os.path.join(dest, "sub", "y.txt")), \ "unlisted file appeared" @pytest.mark.parametrize("mt", [False, True]) def test_dirs_with_files_from_mirror_layout(self, shared_server, mt): """Without -R the dirs+files-from entries still mirror the source path.""" source = self._make() dest = os.path.join(TEST_DATA_DIR, "dirs_ff_noR_dst") clean_dir(dest) lst = _write_rel_list(b"dir1\n") flags = ["--files-from", lst, "--dirs"] + (["--threads"] if mt else []) result, _ = run_client(source, dest, flags=flags, port=shared_server.port) assert result.returncode == 0, f"dirs files-from no-R sync failed: {result.stderr[:200]}" received = get_dest_received_dir(dest, source) assert os.path.isdir(os.path.join(received, "dir1")), "mirrored dir entry not created" assert not os.path.exists(os.path.join(received, "dir1", "keep.txt")), \ "--dirs must not descend into a listed directory" assert not os.path.exists(os.path.join(received, "sub")), \ "unlisted subtree appeared" @pytest.mark.parametrize("mt", [False, True]) def test_dirs_chunk_serialization(self, shared_server, mt): """--dirs entries survive the chunk-serialization wire path (type marker round-trips); a listed dir lands empty and a listed file lands with content, with no protocol desync under -s -m.""" source = self._make() dest = os.path.join(TEST_DATA_DIR, "dirs_s_dst") clean_dir(dest) lst = _write_rel_list(b"dir1\nsub/x.txt\n") flags = ["--files-from", lst, "--dirs", "-R", "--chunk-serialization"] + (["--threads"] if mt else []) result, _ = run_client(source, dest, flags=flags, port=shared_server.port) assert result.returncode == 0, f"dirs -s sync failed: {result.stderr[:200]}" assert os.path.isdir(os.path.join(dest, "dir1")), "listed dir was not created" assert not os.path.exists(os.path.join(dest, "dir1", "keep.txt")), \ "--dirs must not descend into a listed directory" assert _read_file(os.path.join(dest, "sub", "x.txt")) == b"x\n", \ "listed file content was not transferred" def test_dirs_delete_keeps_transferred_empty_dir(self): """Directory entries appear in the delete manifest, so the empty dir a --dirs run just created is not pruned as an extra by --delete.""" source = self._make() dest = os.path.join(TEST_DATA_DIR, "dirs_del_dst") clean_dir(dest) extra = os.path.join(dest, "extra.txt") with open(extra, "wb") as fh: fh.write(b"delete me") with ServerManager() as server: server.start(extra_args=["--allow-delete"]) result, _ = run_client(source, dest, flags=["--dirs", "--delete"], port=server.port) assert result.returncode == 0, f"--dirs --delete sync failed: {result.stderr[:200]}" assert not os.path.exists(extra), "--delete did not remove the extra file" mirror = get_dest_received_dir(dest, source) assert os.path.isdir(mirror), "transferred empty dir was pruned as an extra" files = [] for root, _dirs, names in os.walk(mirror): files.extend(os.path.relpath(os.path.join(root, n), mirror) for n in names) assert files == [], f"--dirs descended into contents: {files}" def test_dirs_listed_dir_replaces_blocking_file(self, shared_server): """rsync parity: a listed directory replaces a regular file already at its destination path (rsync removes the non-directory and creates the directory).""" source = self._make() dest = os.path.join(TEST_DATA_DIR, "dirs_coll_dst") clean_dir(dest) blocker = os.path.join(dest, "dir1") with open(blocker, "wb") as fh: fh.write(b"blocking file") lst = _write_rel_list(b"dir1\n") result, _ = run_client(source, dest, flags=["--files-from", lst, "--dirs", "-R"], port=shared_server.port) assert result.returncode == 0, \ f"dir entry over an existing file failed: {(result.stderr or result.stdout)[:300]}" assert os.path.isdir(blocker) and not os.path.islink(blocker), \ "blocking regular file was not replaced by the incoming directory" class TestMkpath: """--mkpath tells the server to create the destination root directory (and missing leading components) when it does not exist yet; without it a missing destination root fails the transfer.""" @pytest.mark.parametrize("mt", [False, True]) def test_missing_root_fails_without_mkpath(self, mt): source = _make_relative_source("mkpath_fail_src") dest = os.path.join(TEST_DATA_DIR, "mkpath_missing_dst") shutil.rmtree(dest, ignore_errors=True) with ServerManager() as server: server.start() flags = ["--threads"] if mt else [] result, _ = run_client(source, dest, flags=flags, port=server.port) assert result.returncode != 0, "missing destination root did not fail without --mkpath" assert not os.path.exists(dest), "missing root was created without --mkpath" @pytest.mark.parametrize("mt", [False, True]) def test_mkpath_creates_missing_root(self, mt): source = _make_relative_source("mkpath_ok_src") dest = os.path.join(TEST_DATA_DIR, "deep", "mkpath_dst") shutil.rmtree(os.path.join(TEST_DATA_DIR, "deep"), ignore_errors=True) with ServerManager() as server: server.start() flags = ["--mkpath"] + (["--threads"] if mt else []) result, _ = run_client(source, dest, flags=flags, port=server.port) assert result.returncode == 0, f"--mkpath sync failed: {result.stderr[:200]}" received = get_dest_received_dir(dest, source) assert _read_file(os.path.join(received, "sub", "x.txt")) == b"x\n", \ "file not transferred into the --mkpath-created root" @pytest.mark.parametrize("mkpath", [False, True]) def test_existing_dest_with_trailing_slash(self, shared_server, mkpath): """A destination root written with a trailing slash must keep working: an existing root is accepted both with and without --mkpath.""" source = _make_relative_source("mkpath_trail_src") dest = os.path.join(TEST_DATA_DIR, "mkpath_trail_dst") clean_dir(dest) dest_slash = dest + "/" flags = ["--mkpath"] if mkpath else [] result, _ = run_client(source, dest_slash, flags=flags, port=shared_server.port) assert result.returncode == 0, \ f"trailing-slash dest sync (mkpath={mkpath}) failed: {result.stderr[:200]}" received = get_dest_received_dir(dest, source) assert _read_file(os.path.join(received, "sub", "x.txt")) == b"x\n", \ "file not transferred into the trailing-slash destination root" @pytest.mark.parametrize("mkpath", [False, True]) def test_dest_equal_authorized_root(self, mkpath): """A destination that is exactly the server's authorized root works without --mkpath, and with --mkpath creates no stray / nested directory.""" root = os.path.join(TEST_DATA_DIR, "mkpath_eq_root") clean_dir(root) source = _make_relative_source("mkpath_eq_src") with ServerManager() as server: server.start(extra_args=["--destination-root", root]) flags = ["--mkpath"] if mkpath else [] result, _ = run_client(source, root, flags=flags, port=server.port) assert result.returncode == 0, \ f"dest==authorized-root sync (mkpath={mkpath}) failed: {result.stderr[:200]}" received = get_dest_received_dir(root, source) assert _read_file(os.path.join(received, "sub", "x.txt")) == b"x\n", \ "file not transferred when the dest equals the authorized root" basename = os.path.basename(root.rstrip(os.sep)) assert not os.path.exists(os.path.join(root, basename)), \ "--mkpath created a spurious nested / directory" class TestFilters: """--filter/-C/-F rule layer: excludes prune, ordering is first-match-wins, the default with no matching rule is include, and legacy --exclude remains an independent layer.""" class TestDeleteTiming: """rsync deletion-timing family. --delete-before/--delete-during transmit the keep-set manifest BEFORE any file data (the receiver deletes extras and acks first); --delete/--delete-after/--delete-delay commit deletions only after the whole transfer succeeded. Every timing flag implies --delete.""" def _seed(self, tag): source = os.path.join(TEST_DATA_DIR, f"deltiming_{tag}_src") clean_dir(source) entries = { "top.txt": b"top level\n", "sub/deep.txt": b"deeply nested file\n", } for rel, content in entries.items(): full = os.path.join(source, rel) os.makedirs(os.path.dirname(full), exist_ok=True) with open(full, "wb") as fh: fh.write(content) return source @pytest.mark.parametrize("flag", ["--delete-before", "--delete-during", "--del", "--delete-after", "--delete-delay"]) @pytest.mark.parametrize("mt", [False, True]) @pytest.mark.ci def test_flag_removes_extras_on_success(self, flag, mt): """Every timing flag is accepted, implies --delete, and on a successful transfer removes the destination extras exactly like plain --delete.""" source = self._seed("ok") dest = os.path.join(TEST_DATA_DIR, "deltiming_ok_dst") clean_dir(dest) with ServerManager() as server: server.start(extra_args=["--allow-delete"]) result, _ = run_client(source, dest, port=server.port) assert result.returncode == 0, f"seed sync failed: {result.stderr[:200]}" received = get_dest_received_dir(dest, source) extra = os.path.join(received, "extra.txt") with open(extra, "wb") as fh: fh.write(b"should be deleted") flags = [flag] + (["--threads"] if mt else []) result, _ = run_client(source, dest, flags=flags, port=server.port) assert result.returncode == 0, \ f"{flag} sync failed: {(result.stderr or result.stdout)[:300]}" assert not os.path.exists(extra), f"{flag} did not remove the extra file" mismatches, missing = verify_transfer(source, received) assert not missing, f"{flag} missing files: {missing}" assert not mismatches, f"{flag} mismatched files: {mismatches}" @pytest.mark.parametrize("flag", ["--delete-before", "--delete-during", "--del"]) @pytest.mark.parametrize("mt", [False, True]) def test_early_flags_delete_before_data(self, flag, mt): """--delete-before/--delete-during remove extras (and a file blocking a destination directory) BEFORE data is applied, so a nested write that would fail while the blocker still exists succeeds.""" source = self._seed("early") dest = os.path.join(TEST_DATA_DIR, "deltiming_early_dst") clean_dir(dest) with ServerManager() as server: server.start(extra_args=["--allow-delete"]) result, _ = run_client(source, dest, port=server.port) assert result.returncode == 0, f"seed sync failed: {result.stderr[:200]}" received = get_dest_received_dir(dest, source) extra = os.path.join(received, "extra.txt") with open(extra, "wb") as fh: fh.write(b"extra file") blocker = os.path.join(received, "sub") shutil.rmtree(blocker) with open(blocker, "wb") as fh: fh.write(b"blocks the nested destination directory") flags = [flag] + (["--threads"] if mt else []) result, _ = run_client(source, dest, flags=flags, port=server.port) assert result.returncode == 0, \ f"{flag} (early delete) did not remove the blocker in time: " \ f"{(result.stderr or result.stdout)[:300]}" assert not os.path.exists(extra), f"{flag} did not delete the extra before data" assert _read_file(os.path.join(received, "sub", "deep.txt")) == b"deeply nested file\n", \ f"{flag}: nested file was not written after the early deletion" @pytest.mark.parametrize("flag", ["--delete", "--delete-after"]) @pytest.mark.parametrize("mt", [False, True]) def test_late_flags_commit_only_after_success(self, flag, mt): """Plain --delete/--delete-after defer deletion until the whole transfer succeeds: a mid-transfer write failure must leave every extra in place (commit-style safety). The -m receiver must also keep the extras: the deferred keep-set is committed by the server only after the disk-writer thread has finished, and a failing writer means the manifest is freed, never applied.""" source = self._seed("late") dest = os.path.join(TEST_DATA_DIR, "deltiming_late_dst") clean_dir(dest) with ServerManager() as server: server.start(extra_args=["--allow-delete"]) result, _ = run_client(source, dest, port=server.port) assert result.returncode == 0, f"seed sync failed: {result.stderr[:200]}" received = get_dest_received_dir(dest, source) extra = os.path.join(received, "extra.txt") with open(extra, "wb") as fh: fh.write(b"extra file") blocker = os.path.join(received, "sub") shutil.rmtree(blocker) with open(blocker, "wb") as fh: fh.write(b"blocks the nested destination directory") flags = [flag] + (["--threads"] if mt else []) result, _ = run_client(source, dest, flags=flags, port=server.port) assert result.returncode != 0, \ f"{flag} (mt={mt}) unexpectedly succeeded (deletion must be deferred)" assert os.path.exists(extra), \ f"{flag} (mt={mt}) removed an extra although the transfer failed" assert os.path.isfile(blocker), \ f"{flag} (mt={mt}) deleted the blocker although the transfer failed" @pytest.mark.parametrize("mt", [False, True]) def test_delete_delay_clears_type_conflict_like_rsync(self, mt): """rsync clears a destination file that blocks a source directory even when the deletion itself is deferred (--delete-delay); the type conflict is resolved immediately so the nested write succeeds. The transfer must therefore succeed and the unrelated extra must still be removed.""" source = self._seed("delayconflict") dest = os.path.join(TEST_DATA_DIR, "deltiming_delayconflict_dst") clean_dir(dest) with ServerManager() as server: server.start(extra_args=["--allow-delete"]) result, _ = run_client(source, dest, port=server.port) assert result.returncode == 0, f"seed sync failed: {result.stderr[:200]}" received = get_dest_received_dir(dest, source) extra = os.path.join(received, "extra.txt") with open(extra, "wb") as fh: fh.write(b"extra file") blocker = os.path.join(received, "sub") shutil.rmtree(blocker) with open(blocker, "wb") as fh: fh.write(b"blocks the nested destination directory") flags = ["--delete-delay"] + (["--threads"] if mt else []) result, _ = run_client(source, dest, flags=flags, port=server.port) assert result.returncode == 0, \ f"--delete-delay (mt={mt}) did not clear the type conflict: " \ f"{(result.stderr or result.stdout)[:300]}" assert os.path.isdir(blocker), "blocker file was not replaced by the source directory" assert _read_file(os.path.join(received, "sub", "deep.txt")) == b"deeply nested file\n" assert not os.path.exists(extra), "--delete-delay did not remove the extra" @pytest.mark.parametrize("flag", ["--delete-before", "--delete-during", "--delete-delay"]) def test_early_flag_respected_when_server_refuses_delete(self, flag, shared_server): """With an --allow-delete-less server the client's timing still completes (no deadlock on the pre-delete ack, no per-directory deletion) and simply never deletes, exactly like the plain server policy.""" source = self._seed("refused") dest = os.path.join(TEST_DATA_DIR, "deltiming_refused_dst") clean_dir(dest) result, _ = run_client(source, dest, port=shared_server.port) assert result.returncode == 0, f"seed sync failed: {result.stderr[:200]}" received = get_dest_received_dir(dest, source) extra = os.path.join(received, "extra.txt") with open(extra, "wb") as fh: fh.write(b"extra file") result, _ = run_client(source, dest, flags=[flag], port=shared_server.port) assert result.returncode == 0, \ f"{flag} against a refuse-delete server failed: {result.stderr[:300]}" assert os.path.exists(extra), "unauthorized delete removed an extra file" def _seed_delete_tree(tag, entries, dest): """Create a source tree and seed a full mirror at `dest`, returning (source, received_mirror).""" source = os.path.join(TEST_DATA_DIR, f"delpol_{tag}_src") clean_dir(source) for rel, content in entries.items(): full = os.path.join(source, rel) os.makedirs(os.path.dirname(full), exist_ok=True) with open(full, "wb") as fh: fh.write(content) clean_dir(dest) with ServerManager() as server: server.start(extra_args=["--allow-delete"]) result, _ = run_client(source, dest, port=server.port) assert result.returncode == 0, f"seed sync failed: {result.stderr[:200]}" received = get_dest_received_dir(dest, source) return source, received class TestDeleteScope: """#290 (1): --delete with --files-from is confined to the directories the transfer synchronized (rsync parity), so untransmitted paths outside a listed directory subtree are never deleted. Data-loss capable.""" def _write(self, path, content): os.makedirs(os.path.dirname(path), exist_ok=True) with open(path, "wb") as fh: fh.write(content) def _seed(self, tag): source = os.path.join(TEST_DATA_DIR, f"dscope_{tag}_src") clean_dir(source) for rel, content in { "listed.txt": b"listed\n", "unlisted.txt": b"unlisted\n", "other/c.txt": b"c\n", "sub/x.txt": b"x\n", "sub/y.txt": b"y\n", }.items(): self._write(os.path.join(source, rel), content) dest = os.path.join(TEST_DATA_DIR, f"dscope_{tag}_dst") clean_dir(dest) server = ServerManager() server.start(extra_args=["--allow-delete"]) result, _ = run_client(source, dest, port=server.port) assert result.returncode == 0, f"seed failed: {result.stderr[:200]}" received = get_dest_received_dir(dest, source) self._write(os.path.join(received, "sub", "extra.txt"), b"in-scope extra\n") self._write(os.path.join(received, "rootextra.txt"), b"root extra\n") self._write(os.path.join(received, "other", "extra.txt"), b"other extra\n") return source, dest, received, server @pytest.mark.parametrize("mt", [False, True]) @pytest.mark.ci def test_files_from_delete_confined_to_listed_dirs(self, mt): source, dest, received, server = self._seed(f"dir_{mt}") try: listed = _write_rel_list(b"listed.txt\nsub/\n") flags = ["--files-from", listed, "--delete"] + (["--threads"] if mt else []) result, _ = run_client(source, dest, flags=flags, port=server.port) assert result.returncode == 0, f"delete failed: {result.stderr[:300]}" assert not os.path.exists(os.path.join(received, "sub", "extra.txt")), \ "in-scope extra under a listed directory was not deleted" assert os.path.isfile(os.path.join(received, "sub", "x.txt")) assert os.path.exists(os.path.join(received, "unlisted.txt")), \ "unlisted path outside a listed directory was deleted (data loss)" assert os.path.exists(os.path.join(received, "other", "c.txt")), \ "unlisted sibling directory was deleted (data loss)" assert os.path.exists(os.path.join(received, "rootextra.txt")), \ "receive-root extra outside a listed directory was deleted (data loss)" finally: server.stop() @pytest.mark.parametrize("mt", [False, True]) @pytest.mark.ci def test_files_from_delete_file_listing_keeps_parent_extras(self, mt): source, dest, received, server = self._seed(f"file_{mt}") try: # Listing a FILE does not synchronize its parent directory, so the # parent's extras survive exactly like rsync. listed = _write_rel_list(b"listed.txt\nsub/x.txt\n") flags = ["--files-from", listed, "--delete"] + (["--threads"] if mt else []) result, _ = run_client(source, dest, flags=flags, port=server.port) assert result.returncode == 0, f"delete failed: {result.stderr[:300]}" assert os.path.exists(os.path.join(received, "sub", "extra.txt")), \ "a file-only --files-from made its parent a delete scope" assert os.path.exists(os.path.join(received, "rootextra.txt")) assert os.path.exists(os.path.join(received, "unlisted.txt")) finally: server.stop() @pytest.mark.parametrize("mt", [False, True]) @pytest.mark.parametrize("timing", ["--delete-during", "--delete-delay"]) @pytest.mark.ci def test_files_from_per_dir_timing_confined_to_listed_dirs(self, mt, timing): """The per-directory timings honor the same --files-from scope: an extra inside a listed directory is removed, while unlisted siblings and the receive-root extra survive.""" source, dest, received, server = self._seed(f"pd_{timing.strip('-')}_{mt}") try: listed = _write_rel_list(b"listed.txt\nsub/\n") flags = ["--files-from", listed, timing] + (["--threads"] if mt else []) result, _ = run_client(source, dest, flags=flags, port=server.port) assert result.returncode == 0, f"{timing} delete failed: {result.stderr[:300]}" assert not os.path.exists(os.path.join(received, "sub", "extra.txt")), \ f"{timing} did not delete the in-scope extra" assert os.path.isfile(os.path.join(received, "sub", "x.txt")) assert os.path.exists(os.path.join(received, "unlisted.txt")), \ f"{timing} deleted an unlisted path (data loss)" assert os.path.exists(os.path.join(received, "other", "c.txt")), \ f"{timing} deleted an unlisted sibling directory (data loss)" assert os.path.exists(os.path.join(received, "rootextra.txt")), \ f"{timing} deleted the receive-root extra (data loss)" finally: server.stop() class TestDeleteExtraneousSymlinks: """#290 (3): --delete unlinks extraneous destination symlinks (never follows them), matching rsync, and leaves their targets intact.""" @pytest.mark.parametrize("mt", [False, True]) @pytest.mark.ci def test_delete_unlinks_extraneous_symlinks(self, mt): source = os.path.join(TEST_DATA_DIR, f"dsym_{mt}_src") clean_dir(source) with open(os.path.join(source, "keep.txt"), "wb") as fh: fh.write(b"kept\n") dest = os.path.join(TEST_DATA_DIR, f"dsym_{mt}_dst") clean_dir(dest) with ServerManager() as server: server.start(extra_args=["--allow-delete"]) result, _ = run_client(source, dest, port=server.port) assert result.returncode == 0, f"seed failed: {result.stderr[:200]}" received = get_dest_received_dir(dest, source) outside = os.path.join(TEST_DATA_DIR, f"dsym_{mt}_outside") clean_dir(outside) with open(os.path.join(outside, "secret.txt"), "wb") as fh: fh.write(b"secret\n") os.symlink("keep.txt", os.path.join(received, "link_file")) os.symlink(outside, os.path.join(received, "link_dir")) os.symlink("/nonexistent-target", os.path.join(received, "link_broken")) os.makedirs(os.path.join(received, "realdir"), exist_ok=True) os.symlink("../realdir", os.path.join(received, "realdir", "self")) flags = ["--delete"] + (["--threads"] if mt else []) result, _ = run_client(source, dest, flags=flags, port=server.port) assert result.returncode == 0, f"delete failed: {result.stderr[:300]}" assert not os.path.lexists(os.path.join(received, "link_file")), \ "extraneous symlink to a file was not unlinked" assert not os.path.lexists(os.path.join(received, "link_dir")), \ "extraneous symlink to a directory was not unlinked" assert not os.path.lexists(os.path.join(received, "link_broken")), \ "extraneous dangling symlink was not unlinked" assert not os.path.lexists(os.path.join(received, "realdir", "self")), \ "extraneous self-referential symlink was not unlinked" assert os.path.isfile(os.path.join(received, "keep.txt")) assert os.path.isfile(os.path.join(outside, "secret.txt")), \ "an extraneous symlink was followed and its target deleted" class TestSizePruneProtection: """#290 (2): --max-size/--min-size pruned source mirrors survive --delete even with --delete-excluded (rsync keeps them).""" @pytest.mark.parametrize("mt", [False, True]) @pytest.mark.parametrize("flag", ["--max-size=1000", "--min-size=1000"]) @pytest.mark.ci def test_size_pruned_mirror_survives_delete_excluded(self, mt, flag): source = os.path.join(TEST_DATA_DIR, f"dsize_{mt}_{flag.strip('-=')}_src") clean_dir(source) with open(os.path.join(source, "small.txt"), "wb") as fh: fh.write(b"small\n") with open(os.path.join(source, "big.bin"), "wb") as fh: fh.write(b"0" * 5000) dest = os.path.join(TEST_DATA_DIR, f"dsize_{mt}_{flag.strip('-=')}_dst") clean_dir(dest) with ServerManager() as server: server.start(extra_args=["--allow-delete"]) result, _ = run_client(source, dest, port=server.port) assert result.returncode == 0, f"seed failed: {result.stderr[:200]}" received = get_dest_received_dir(dest, source) flags = [flag, "--delete", "--delete-excluded"] + (["--threads"] if mt else []) result, _ = run_client(source, dest, flags=flags, port=server.port) assert result.returncode == 0, f"size delete failed: {result.stderr[:300]}" assert os.path.isfile(os.path.join(received, "small.txt")), \ "size-pruned small mirror was deleted under --delete-excluded" assert os.path.isfile(os.path.join(received, "big.bin")), \ "size-pruned big mirror was deleted under --delete-excluded" class TestDeletePolicy: """Deletion-policy family: --delete-excluded, --max-delete, --force, --ignore-errors and --prune-empty-dirs.""" def _write(self, path, content): os.makedirs(os.path.dirname(path), exist_ok=True) with open(path, "wb") as fh: fh.write(content) @pytest.mark.parametrize("mt", [False, True]) @pytest.mark.parametrize("timing", ["--delete", "--delete-before", "--delete-after", "--delete-delay", "--delete-during"]) def test_delete_protects_excluded_by_default_and_delete_excluded_removes(self, mt, timing): """rsync parity: with a --delete timing the destination mirror path whose source was excluded survives (protected by default); --delete-excluded opts back into deleting it. Verified single-threaded and -m across every timing (commit and early).""" source = os.path.join(TEST_DATA_DIR, f"delexcl_{timing.strip('-')}_{mt}_src") clean_dir(source) entries = { "keep.txt": b"kept\n", "secret.log": b"secret\n", "sub/nested.log": b"nested secret\n", } for rel, content in entries.items(): self._write(os.path.join(source, rel), content) dest = os.path.join(TEST_DATA_DIR, f"delexcl_{timing.strip('-')}_{mt}_dst") clean_dir(dest) with ServerManager() as server: server.start(extra_args=["--allow-delete"]) result, _ = run_client(source, dest, port=server.port) assert result.returncode == 0, f"seed sync failed: {result.stderr[:200]}" received = get_dest_received_dir(dest, source) self._write(os.path.join(received, "extra.txt"), b"extra\n") # Default: the excluded mirrors survive --delete, genuine extras die. flags = ["--exclude", "*.log", timing] + (["--threads"] if mt else []) result, _ = run_client(source, dest, flags=flags, port=server.port) assert result.returncode == 0, \ f"default delete sync failed: {(result.stderr or result.stdout)[:300]}" assert os.path.exists(os.path.join(received, "secret.log")), \ "excluded dest file was deleted under plain --delete (rsync protects it)" assert os.path.exists(os.path.join(received, "sub", "nested.log")), \ "nested excluded dest file was deleted under plain --delete" assert not os.path.exists(os.path.join(received, "extra.txt")), \ "genuine extra was not deleted" # --delete-excluded: excluded mirrors are extras again and die. self._write(os.path.join(received, "extra.txt"), b"extra\n") flags = ["--exclude", "*.log", timing, "--delete-excluded"] + (["--threads"] if mt else []) result, _ = run_client(source, dest, flags=flags, port=server.port) assert result.returncode == 0, \ f"--delete-excluded sync failed: {(result.stderr or result.stdout)[:300]}" assert not os.path.exists(os.path.join(received, "secret.log")), \ "--delete-excluded did not remove the excluded dest file" assert not os.path.exists(os.path.join(received, "sub", "nested.log")), \ "--delete-excluded did not remove the nested excluded dest file" assert not os.path.exists(os.path.join(received, "extra.txt")), \ "genuine extra survived --delete-excluded" assert _read_file(os.path.join(received, "keep.txt")) == b"kept\n" @pytest.mark.parametrize("mt", [False, True]) def test_delete_excluded_excluded_directory_subtree(self, mt): """A whole source directory excluded by a filter rule protects its whole destination mirror by default; --delete-excluded removes the subtree.""" source = os.path.join(TEST_DATA_DIR, f"delexcldir_{mt}_src") clean_dir(source) self._write(os.path.join(source, "keep.txt"), b"kept\n") self._write(os.path.join(source, "skipdir", "a.log"), b"a\n") self._write(os.path.join(source, "skipdir", "deep", "b.log"), b"b\n") dest = os.path.join(TEST_DATA_DIR, f"delexcldir_{mt}_dst") clean_dir(dest) with ServerManager() as server: server.start(extra_args=["--allow-delete"]) result, _ = run_client(source, dest, port=server.port) assert result.returncode == 0, f"seed sync failed: {result.stderr[:200]}" received = get_dest_received_dir(dest, source) flags = ["--filter=- skipdir/", "--delete"] + (["--threads"] if mt else []) result, _ = run_client(source, dest, flags=flags, port=server.port) assert result.returncode == 0, \ f"default delete sync failed: {(result.stderr or result.stdout)[:300]}" assert os.path.exists(os.path.join(received, "skipdir", "a.log")), \ "excluded dir subtree was deleted under plain --delete" assert os.path.exists(os.path.join(received, "skipdir", "deep", "b.log")), \ "nested excluded dir content was deleted under plain --delete" flags = ["--filter=- skipdir/", "--delete", "--delete-excluded"] + (["--threads"] if mt else []) result, _ = run_client(source, dest, flags=flags, port=server.port) assert result.returncode == 0, \ f"--delete-excluded sync failed: {(result.stderr or result.stdout)[:300]}" assert not os.path.exists(os.path.join(received, "skipdir")), \ "--delete-excluded did not remove the excluded dir subtree" @pytest.mark.parametrize("mt", [False, True]) @pytest.mark.parametrize("timing", ["--delete", "--delete-before", "--delete-during"]) def test_max_delete_exceeded_deletes_up_to_cap_and_exits_25(self, mt, timing): """rsync parity: --max-delete=N deletes up to N extras, skips the rest and still succeeds as a transfer, exiting 25 with a diagnostic.""" source = os.path.join(TEST_DATA_DIR, f"maxdel_{timing.strip('-')}_{mt}_src") clean_dir(source) self._write(os.path.join(source, "keep.txt"), b"kept\n") dest = os.path.join(TEST_DATA_DIR, f"maxdel_{timing.strip('-')}_{mt}_dst") clean_dir(dest) with ServerManager() as server: server.start(extra_args=["--allow-delete"]) result, _ = run_client(source, dest, port=server.port) assert result.returncode == 0, f"seed sync failed: {result.stderr[:200]}" received = get_dest_received_dir(dest, source) for i in range(4): self._write(os.path.join(received, f"e{i}.txt"), b"extra\n") flags = ["--max-delete=2", timing] + (["--threads"] if mt else []) result, _ = run_client(source, dest, flags=flags, port=server.port) assert result.returncode == 25, \ f"--max-delete=2 with 4 extras should exit 25: {result.stderr[:300]}" remaining = [i for i in range(4) if os.path.exists(os.path.join(received, f"e{i}.txt"))] assert len(remaining) == 2, \ f"--max-delete=2 deleted {4 - len(remaining)} extras, expected 2" assert os.path.isfile(os.path.join(received, "keep.txt")) assert "--max-delete" in (result.stderr or result.stdout) @pytest.mark.parametrize("mt", [False, True]) def test_max_delete_zero_and_negative(self, mt): """--max-delete=0 warns about every extra without deleting (exit 25); a negative value is rsync's deprecated unlimited spelling (exit 0).""" source = os.path.join(TEST_DATA_DIR, f"maxdelzn_{mt}_src") clean_dir(source) self._write(os.path.join(source, "keep.txt"), b"kept\n") dest = os.path.join(TEST_DATA_DIR, f"maxdelzn_{mt}_dst") clean_dir(dest) with ServerManager() as server: server.start(extra_args=["--allow-delete"]) result, _ = run_client(source, dest, port=server.port) assert result.returncode == 0, f"seed sync failed: {result.stderr[:200]}" received = get_dest_received_dir(dest, source) for i in range(3): self._write(os.path.join(received, f"e{i}.txt"), b"extra\n") flags = ["--max-delete=0", "--delete"] + (["--threads"] if mt else []) result, _ = run_client(source, dest, flags=flags, port=server.port) assert result.returncode == 25, f"--max-delete=0 should exit 25: {result.stderr[:300]}" for i in range(3): assert os.path.exists(os.path.join(received, f"e{i}.txt")), \ "--max-delete=0 deleted an extra" # -1 (and any negative) means no client limit: every extra goes. flags = ["--max-delete=-1", "--delete"] + (["--threads"] if mt else []) result, _ = run_client(source, dest, flags=flags, port=server.port) assert result.returncode == 0, \ f"--max-delete=-1 should be unlimited: {result.stderr[:300]}" for i in range(3): assert not os.path.exists(os.path.join(received, f"e{i}.txt")), \ "--max-delete=-1 did not remove every extra" @pytest.mark.parametrize("mt", [False, True]) def test_max_delete_not_exceeded_deletes_exactly(self, mt): """When the extras are at or below --max-delete the run succeeds and removes exactly the extras.""" source = os.path.join(TEST_DATA_DIR, f"maxdelok_{mt}_src") clean_dir(source) self._write(os.path.join(source, "keep.txt"), b"kept\n") dest = os.path.join(TEST_DATA_DIR, f"maxdelok_{mt}_dst") clean_dir(dest) with ServerManager() as server: server.start(extra_args=["--allow-delete"]) result, _ = run_client(source, dest, port=server.port) assert result.returncode == 0, f"seed sync failed: {result.stderr[:200]}" received = get_dest_received_dir(dest, source) for i in range(3): self._write(os.path.join(received, f"e{i}.txt"), b"extra\n") flags = ["--max-delete=3", "--delete"] + (["--threads"] if mt else []) result, _ = run_client(source, dest, flags=flags, port=server.port) assert result.returncode == 0, \ f"--max-delete=3 with 3 extras failed: {(result.stderr or result.stdout)[:300]}" for i in range(3): assert not os.path.exists(os.path.join(received, f"e{i}.txt")), \ f"extra e{i}.txt not deleted under --max-delete=3" @pytest.mark.parametrize("mt", [False, True]) def test_force_replaces_nonempty_dir_with_file(self, mt): """--force lets an incoming regular file replace a non-empty destination directory; without it the write (and the run) fails.""" source = os.path.join(TEST_DATA_DIR, f"force_{mt}_src") clean_dir(source) self._write(os.path.join(source, "sub", "old.txt"), b"old\n") self._write(os.path.join(source, "keep.txt"), b"kept\n") dest = os.path.join(TEST_DATA_DIR, f"force_{mt}_dst") clean_dir(dest) with ServerManager() as server: server.start(extra_args=["--allow-delete"]) result, _ = run_client(source, dest, port=server.port) assert result.returncode == 0, f"seed sync failed: {result.stderr[:200]}" received = get_dest_received_dir(dest, source) # The source path `sub` becomes a regular file (the dir is gone). os.unlink(os.path.join(source, "sub", "old.txt")) os.rmdir(os.path.join(source, "sub")) self._write(os.path.join(source, "sub"), b"now a file\n") result, _ = run_client(source, dest, port=server.port) assert result.returncode != 0, \ "a file over a non-empty directory must fail without --force" assert os.path.isdir(os.path.join(received, "sub")), \ "directory was destroyed although the run failed without --force" assert os.path.exists(os.path.join(received, "sub", "old.txt")), \ "non-empty dir content was lost although the run failed without --force" flags = ["--force"] + (["--threads"] if mt else []) result, _ = run_client(source, dest, flags=flags, port=server.port) assert result.returncode == 0, \ f"--force run failed: {(result.stderr or result.stdout)[:300]}" assert os.path.isfile(os.path.join(received, "sub")), \ "--force did not replace the directory with the file" assert _read_file(os.path.join(received, "sub")) == b"now a file\n" @pytest.mark.parametrize("mt", [False, True]) def test_force_replaces_dir_under_delay_updates(self, mt): """rsync parity: --force also acts during a --delay-updates publication, clearing a non-empty destination directory that blocks an incoming file (without --force the run fails and the directory survives).""" source = os.path.join(TEST_DATA_DIR, f"force_delay_{mt}_src") clean_dir(source) self._write(os.path.join(source, "sub", "old.txt"), b"old\n") self._write(os.path.join(source, "keep.txt"), b"kept\n") dest = os.path.join(TEST_DATA_DIR, f"force_delay_{mt}_dst") clean_dir(dest) with ServerManager() as server: server.start(extra_args=["--allow-delete"]) result, _ = run_client(source, dest, port=server.port) assert result.returncode == 0, f"seed sync failed: {result.stderr[:200]}" received = get_dest_received_dir(dest, source) os.unlink(os.path.join(source, "sub", "old.txt")) os.rmdir(os.path.join(source, "sub")) self._write(os.path.join(source, "sub"), b"now a file\n") # Without --force the blocking directory is untouched and the run fails. result, _ = run_client(source, dest, flags=["--delay-updates"] + (["--threads"] if mt else []), port=server.port) assert result.returncode != 0, \ "--delay-updates replaced a non-empty directory without --force" assert os.path.isdir(os.path.join(received, "sub")) assert os.path.exists(os.path.join(received, "sub", "old.txt")) # With --force the publication clears it and installs the file. result, _ = run_client(source, dest, flags=["--force", "--delay-updates"] + (["--threads"] if mt else []), port=server.port) assert result.returncode == 0, \ f"--force --delay-updates failed: {result.stderr[:300]}" assert os.path.isfile(os.path.join(received, "sub")), \ "--force under --delay-updates did not replace the blocking directory" assert _read_file(os.path.join(received, "sub")) == b"now a file\n" @pytest.mark.parametrize("mt", [False, True]) def test_prune_empty_dirs_dirs_mode(self, mt): """--prune-empty-dirs omits an empty source directory's explicit entry in --dirs mode (nothing is created, and an existing empty mirror is removed by --delete). Recursive transfers never emit empty dirs, so the flag is a no-op there (documented rsync -m parity).""" source = os.path.join(TEST_DATA_DIR, f"prune_{mt}_src") clean_dir(source) os.makedirs(source, exist_ok=True) # physically empty source dir dest = os.path.join(TEST_DATA_DIR, f"prune_{mt}_dst") clean_dir(dest) with ServerManager() as server: server.start(extra_args=["--allow-delete"]) result, _ = run_client(source, dest, flags=["--dirs"], port=server.port) assert result.returncode == 0, f"-d seed failed: {result.stderr[:200]}" received = get_dest_received_dir(dest, source) assert os.path.isdir(received), "-d should create the empty mirror dir" assert os.listdir(received) == [] # prune-empty-dirs: the empty mirror is pruned by --delete. flags = ["--dirs", "--prune-empty-dirs", "--delete"] + (["--threads"] if mt else []) result, _ = run_client(source, dest, flags=flags, port=server.port) assert result.returncode == 0, \ f"--dirs --prune-empty-dirs --delete failed: {(result.stderr or result.stdout)[:300]}" assert not os.path.exists(received), \ "--prune-empty-dirs did not prune the empty dir (--delete left it)" # A fresh destination: prune-empty-dirs means the empty dir is never sent. dest2 = os.path.join(TEST_DATA_DIR, f"prune2_{mt}_dst") clean_dir(dest2) with ServerManager() as server: server.start(extra_args=["--allow-delete"]) flags = ["--dirs", "--prune-empty-dirs", "-i"] + (["--threads"] if mt else []) result, _ = run_client(source, dest2, flags=flags, port=server.port) assert result.returncode == 0, \ f"--dirs --prune-empty-dirs failed: {(result.stderr or result.stdout)[:300]}" received2 = get_dest_received_dir(dest2, source) assert not os.path.exists(received2), \ "--prune-empty-dirs transferred the empty directory" assert result.stdout == "", \ f"--prune-empty-dirs leaked an itemize line: {result.stdout[:200]}" @pytest.mark.parametrize("mt", [False, True]) def test_prune_empty_dirs_recursion_inherent(self, mt): """In recursive mode FastSync never transfers empty directories (rsync -m parity): a truly-empty destination directory chain is removed by --delete whether or not --prune-empty-dirs is given (the flag has no additional effect there), while directories holding kept files survive. A filter-excluded file's mirror is protected, so a directory that still holds one is left intact (rsync default delete-excluded semantics).""" source = os.path.join(TEST_DATA_DIR, f"prunerec_{mt}_src") clean_dir(source) self._write(os.path.join(source, "keep.txt"), b"kept\n") self._write(os.path.join(source, "a", "keep.log"), b"a log\n") self._write(os.path.join(source, "b", "deep", "kept.txt"), b"deep kept\n") dest = os.path.join(TEST_DATA_DIR, f"prunerec_{mt}_dst") clean_dir(dest) with ServerManager() as server: server.start(extra_args=["--allow-delete"]) result, _ = run_client(source, dest, port=server.port) assert result.returncode == 0, f"seed sync failed: {result.stderr[:200]}" received = get_dest_received_dir(dest, source) # A stray empty chain (FastSync recursion never creates such dirs, so # this models one left by an external tool / an earlier --dirs run). os.makedirs(os.path.join(received, "empty", "chain")) for prune in ([], ["--prune-empty-dirs"]): flags = prune + ["--delete"] + (["--threads"] if mt else []) result, _ = run_client(source, dest, flags=flags, port=server.port) assert result.returncode == 0, \ f"prune recursive sync failed: {(result.stderr or result.stdout)[:300]}" assert not os.path.exists(os.path.join(received, "empty")), \ "truly-empty dir chain was not removed by --delete" assert os.path.exists(os.path.join(received, "b", "deep", "kept.txt")), \ "non-empty dir subtree was wrongly removed" assert _read_file(os.path.join(received, "keep.txt")) == b"kept\n" # An excluded file's mirror is protected: the dir that holds it stays. flags = ["--exclude", "*.log", "--delete", "--prune-empty-dirs"] + (["--threads"] if mt else []) result, _ = run_client(source, dest, flags=flags, port=server.port) assert result.returncode == 0, \ f"prune recursive sync failed: {(result.stderr or result.stdout)[:300]}" assert os.path.exists(os.path.join(received, "a", "keep.log")), \ "excluded file mirror was deleted under --delete (rsync protects it)" def _run_client_as_nobody(self, source, dest, port, flags): cmd = CLIENT_CMD + ["--source-dir", source, "--dest-dir", dest, "--save-to-disk", "--server-port", str(port)] + flags return subprocess.run(["setpriv", "--reuid=65534", "--regid=65534", "--clear-groups"] + cmd, text=True, capture_output=True) @pytest.mark.parametrize("mt", [False, True]) @pytest.mark.setpriv def test_ignore_errors_keeps_deletion_active_on_scan_error(self, mt): """rsync's --ignore-errors semantics: a source I/O error (unreadable subdirectory) makes the run continue and transfer the readable tree, but the default suppresses deletion ("IO error encountered -- skipping file deletion"); --ignore-errors lets deletion proceed. Both exit 23. Run as an unprivileged user so the mode-000 directory is genuinely unreadable.""" if os.geteuid() != 0 or shutil.which("setpriv") is None: pytest.skip("requires root + setpriv to drop privileges for the client") tag = f"ioerr_{os.getpid()}_{mt}" source = os.path.join(TEST_DATA_DIR, f"{tag}_src") clean_dir(source) self._write(os.path.join(source, "top.txt"), b"top\n") self._write(os.path.join(source, "ok", "inside.txt"), b"inside\n") self._write(os.path.join(source, "locked", "blocked.txt"), b"blocked\n") dest = os.path.join(TEST_DATA_DIR, f"{tag}_dst") clean_dir(dest) with ServerManager() as server: server.start(extra_args=["--allow-delete"]) # Seed as root (server is root too). result, _ = run_client(source, dest, port=server.port) assert result.returncode == 0, f"seed sync failed: {result.stderr[:200]}" received = get_dest_received_dir(dest, source) try: os.chmod(os.path.join(source, "locked"), 0) # Default: the scan continues past the unreadable dir and the # readable tree transfers, but deletion is skipped (exit 23). self._write(os.path.join(received, "extra.txt"), b"extra\n") flags = ["--delete"] + (["--threads"] if mt else []) result = self._run_client_as_nobody(source, dest, server.port, flags) assert result.returncode == 23, \ f"unreadable source dir should exit 23 (got {result.returncode})" assert os.path.exists(os.path.join(received, "top.txt")), \ "readable tree did not transfer past the I/O error" assert os.path.exists(os.path.join(received, "extra.txt")), \ "default run deleted although the scan hit an I/O error" # --ignore-errors: the readable tree transfers, deletion still runs. self._write(os.path.join(received, "extra.txt"), b"extra\n") flags = ["--delete", "--ignore-errors"] + (["--threads"] if mt else []) result = self._run_client_as_nobody(source, dest, server.port, flags) assert result.returncode == 23, \ f"--ignore-errors run should still exit 23 (got {result.returncode})" assert not os.path.exists(os.path.join(received, "extra.txt")), \ f"--ignore-errors did not keep deletion active: {result.stderr[:300]}" assert not os.path.exists(os.path.join(received, "locked")), \ "mirror of the unreadable dir was left behind (should be an extra)" finally: os.chmod(os.path.join(source, "locked"), 0o755) @pytest.mark.parametrize("mt", [False, True]) @pytest.mark.parametrize("timing", ["--delete", "--delete-before"]) @pytest.mark.setpriv def test_ignore_errors_unreadable_root_never_deletes(self, mt, timing): """An unreadable SOURCE ROOT must never be treated as a skippable scan error: with --ignore-errors the sequential scanner treats the root as fatal (matching the -m path, which cannot even create its scanner), so no empty keep-set manifest is sent and the destination is never wiped. Run as an unprivileged user so the mode-000 root is genuinely unreadable.""" if os.geteuid() != 0 or shutil.which("setpriv") is None: pytest.skip("requires root + setpriv to drop privileges for the client") tag = f"rootio_{os.getpid()}_{mt}_{timing.strip('-')}" source = os.path.join(TEST_DATA_DIR, f"{tag}_src") clean_dir(source) self._write(os.path.join(source, "file.txt"), b"content\n") dest = os.path.join(TEST_DATA_DIR, f"{tag}_dst") clean_dir(dest) with ServerManager() as server: server.start(extra_args=["--allow-delete"]) result, _ = run_client(source, dest, port=server.port) assert result.returncode == 0, f"seed sync failed: {result.stderr[:200]}" received = get_dest_received_dir(dest, source) try: os.chmod(source, 0) self._write(os.path.join(received, "extra.txt"), b"extra\n") flags = [timing, "--ignore-errors"] + (["--threads"] if mt else []) result = self._run_client_as_nobody(source, dest, server.port, flags) assert result.returncode != 0, \ f"unreadable source root with {timing} (mt={mt}) unexpectedly succeeded" assert os.path.exists(os.path.join(received, "file.txt")), \ f"{timing} (mt={mt}) wiped a kept destination file" assert os.path.exists(os.path.join(received, "extra.txt")), \ f"{timing} (mt={mt}) deleted the extra although the scan could not read the root" finally: os.chmod(source, 0o755) def test_delete_protection_reapplied_on_receiver(self): """Plain --delete protects destination mirrors of files the SOURCE scan excluded, and (track 4a) also protects a destination-only file matching an exclude rule because the compiled rule set is re-applied on the receiver, matching rsync.""" source = os.path.join(TEST_DATA_DIR, "senderderived_src") clean_dir(source) self._write(os.path.join(source, "keep.txt"), b"kept\n") self._write(os.path.join(source, "secret.log"), b"secret\n") dest = os.path.join(TEST_DATA_DIR, "senderderived_dst") clean_dir(dest) with ServerManager() as server: server.start(extra_args=["--allow-delete"]) result, _ = run_client(source, dest, port=server.port) assert result.returncode == 0, f"seed sync failed: {result.stderr[:200]}" received = get_dest_received_dir(dest, source) # A destination-only file that happens to match the exclude rule. self._write(os.path.join(received, "stray.log"), b"never on the source\n") result, _ = run_client(source, dest, flags=["--exclude", "*.log", "--delete"], port=server.port) assert result.returncode == 0, \ f"delete sync failed: {(result.stderr or result.stdout)[:300]}" assert os.path.exists(os.path.join(received, "secret.log")), \ "source-excluded mirror was deleted under plain --delete" assert os.path.exists(os.path.join(received, "stray.log")), \ "destination-only file matching the exclude rule must be protected like rsync" def _pin_mtime(path, ts): os.utime(path, (ts, ts)) class TestBasisDestDirs: """--compare-dest / --copy-dest / --link-dest alternate basis directories. FastSync's basis directories are relative to the destination root and are confined below it. The "unchanged" decision is receiver-side and requires the per-file --incremental handshake (implied by these flags), so the basis snapshot must reproduce the exact destination-relative mirror path of the incoming files. """ STAGING = ".fastsync-stage" TS = 1577836800 # 2020-01-01 00:00:00 UTC, used to pin matching mtimes # fixture files: source and basis share the mtime pin, so the DEFAULT # (rsync-parity) quick-check is a size+mtime match and trusts the basis even # when the body differs. unchanged.txt is byte-identical; changed.txt is # byte-DIFFERENT but has the SAME SIZE as the source (and the same pinned # mtime), which is what the FastSync-only --verify-basis content gate # rejects; added.txt does not exist in the basis at all. UNCHANGED = "unchanged.txt" CHANGED = "changed.txt" ADDED = "added.txt" def _make_source(self, name, source_files): src = os.path.join(TEST_DATA_DIR, name) clean_dir(src) for rel, content in source_files.items(): full = os.path.join(src, rel) os.makedirs(os.path.dirname(full), exist_ok=True) with open(full, "wb") as fh: fh.write(content) _pin_mtime(full, self.TS) return src def _seed_basis_file(self, dest, source, basis_dir, rel, content, ts=None): base = os.path.join(dest, basis_dir, os.path.relpath( get_dest_received_dir(dest, source), dest)) full = os.path.join(base, rel) os.makedirs(os.path.dirname(full), exist_ok=True) with open(full, "wb") as fh: fh.write(content) _pin_mtime(full, self.TS if ts is None else ts) return full def _seed_basis(self, dest, source, basis_dir, basis_files): for rel, content in basis_files.items(): self._seed_basis_file(dest, source, basis_dir, rel, content) return os.path.join(dest, basis_dir, os.path.relpath( get_dest_received_dir(dest, source), dest)) def _source_tree(self, prefix): return { self.UNCHANGED: b"stable content v1\n", self.CHANGED: b"changed content now\n", self.ADDED: b"brand new content\n", } def _basis_tree(self, prefix): # unchanged.txt is identical to the source; changed.txt has a DIFFERENT # size (and body) so the size leg of the quick-check fails and it is # transferred normally; added.txt is missing from the basis. return { self.UNCHANGED: b"stable content v1\n", self.CHANGED: b"CHANGED CONTENT NOW AND LONGER\n", } def test_same_size_different_content_default_trusts_quick_check(self, shared_server): # Default rsync-parity behavior: equal size + pinned mtime is a basis # match, so the basis body is materialized/linked without reading it. # This mirrors rsync 3.4.1's quick check (differential-tested in # test_differential_parity.py::test_verify_basis_restores_strict_content). for flag, basis_dir in (("--link-dest", "szlb"), ("--copy-dest", "szcp"), ("--compare-dest", "szcmp")): source = self._make_source("basis_same_size_src", {self.UNCHANGED: b"same length body\n"}) dest = os.path.join(TEST_DATA_DIR, f"basis_same_size_dst_{basis_dir}") clean_dir(dest) basis_file = self._seed_basis_file(dest, source, basis_dir, self.UNCHANGED, b"SAME LENGTH BODY!") result, _ = run_client(source, dest, flags=[f"{flag}={basis_dir}"], port=shared_server.port) assert result.returncode == 0, \ f"{flag} same-size quick-check failed: {result.stderr[:300]}" received = get_dest_received_dir(dest, source) dest_file = os.path.join(received, self.UNCHANGED) if flag == "--compare-dest": assert not os.path.exists(dest_file), \ f"{flag}: compare-dest must leave a matching file sparse" else: assert _read_file(dest_file) == b"SAME LENGTH BODY!", \ f"{flag}: default quick-check did not trust the basis body" if flag == "--link-dest": assert os.stat(dest_file).st_ino == os.stat(basis_file).st_ino, \ f"{flag}: basis was not hard-linked" def test_verify_basis_rejects_same_size_different_content(self, shared_server): # FastSync-only --verify-basis: the whole-file digest gate rejects the # same-size/different-content basis, so the source data is transferred # instead of the wrong basis bytes. for flag, basis_dir in (("--link-dest", "vszlb"), ("--copy-dest", "vszcp"), ("--compare-dest", "vszcmp")): source = self._make_source("basis_verify_src", {self.UNCHANGED: b"same length body\n"}) dest = os.path.join(TEST_DATA_DIR, f"basis_verify_dst_{basis_dir}") clean_dir(dest) basis_file = self._seed_basis_file(dest, source, basis_dir, self.UNCHANGED, b"SAME LENGTH BODY!") result, _ = run_client(source, dest, flags=[f"{flag}={basis_dir}", "--verify-basis"], port=shared_server.port) assert result.returncode == 0, \ f"{flag} --verify-basis failed: {result.stderr[:300]}" received = get_dest_received_dir(dest, source) dest_file = os.path.join(received, self.UNCHANGED) assert _read_file(dest_file) == b"same length body\n", \ f"{flag}: basis content leaked into the destination on a hash mismatch" if flag != "--compare-dest": assert os.stat(dest_file).st_ino != os.stat(basis_file).st_ino, \ f"{flag}: linked/copied from a content-mismatched basis file" @pytest.mark.ci def test_compare_dest_skips_matching_and_transfers_missing(self, shared_server): source = self._make_source("basis_compare_src", self._source_tree("c")) dest = os.path.join(TEST_DATA_DIR, "basis_compare_dst") clean_dir(dest) self._seed_basis(dest, source, "cbasis", self._basis_tree("c")) result, _ = run_client(source, dest, flags=["--compare-dest=cbasis"], port=shared_server.port) assert result.returncode == 0, f"compare-dest failed: {result.stderr[:300]}" received = get_dest_received_dir(dest, source) # compare-dest never copies: an exact basis match is skipped, leaving a # sparse destination (rsync parity). assert not os.path.exists(os.path.join(received, self.UNCHANGED)), \ "compare-dest materialized the unchanged file" # Files the destination lacks AND the basis cannot satisfy are still # transferred normally. assert _read_file(os.path.join(received, self.CHANGED)) == \ self._source_tree("c")[self.CHANGED], "changed file not transferred" assert _read_file(os.path.join(received, self.ADDED)) == \ self._source_tree("c")[self.ADDED], "added file not transferred" @pytest.mark.ci def test_dry_run_compare_dest_quick_check_does_not_read_basis(self, shared_server): # A dry-run --compare-dest must never read/hash the basis file. Under # the default metadata quick-check a matching basis is reported as a # skip (matching rsync) without reading it; nothing is created. Under # --verify-basis, which would require hashing, the dry-run cannot # confirm the hit (that would be a 1-bit content oracle) and reports # would-transfer instead. source = self._make_source("basis_dry_src", {self.UNCHANGED: b"stable content v1\n"}) dest = os.path.join(TEST_DATA_DIR, "basis_dry_dst") clean_dir(dest) self._seed_basis(dest, source, "drybasis", {self.UNCHANGED: b"stable content v1\n"}) before = _snapshot_tree(dest) result, _ = run_client(source, dest, flags=["--compare-dest=drybasis", "--dry-run"], port=shared_server.port) assert result.returncode == 0, \ f"dry-run compare-dest failed: {result.stderr[:300]}" assert self.UNCHANGED not in result.stdout, ( "dry-run compare-dest did not honor the metadata quick-check " "(reported would-transfer for a matching basis)" ) assert _snapshot_tree(dest) == before, "dry-run compare-dest mutated the destination" # --verify-basis: the hit needs the basis content, which a dry-run must # not read, so the file is reported as would-transfer. dest2 = os.path.join(TEST_DATA_DIR, "basis_dry_verify_dst") clean_dir(dest2) self._seed_basis(dest2, source, "drybasis", {self.UNCHANGED: b"stable content v1\n"}) before2 = _snapshot_tree(dest2) result, _ = run_client(source, dest2, flags=["--compare-dest=drybasis", "--dry-run", "--verify-basis"], port=shared_server.port) assert result.returncode == 0, \ f"dry-run --verify-basis compare-dest failed: {result.stderr[:300]}" assert self.UNCHANGED in result.stdout, ( "dry-run --verify-basis must not read the basis to confirm a hit" ) assert _snapshot_tree(dest2) == before2, \ "dry-run --verify-basis compare-dest mutated the destination" def test_compare_dest_content_mismatch_forces_transfer(self, shared_server): # The basis holds a file with a DIFFERENT body: even though it shares # the mtime pin, the xxHash check fails and the data must be sent. source = self._make_source("basis_compare_mismatch_src", {self.UNCHANGED: b"real data\n"}) dest = os.path.join(TEST_DATA_DIR, "basis_compare_mismatch_dst") clean_dir(dest) basis = self._seed_basis(dest, source, "cbasis", {self.UNCHANGED: b"stale data!!\n"}) result, _ = run_client(source, dest, flags=["--compare-dest=cbasis"], port=shared_server.port) assert result.returncode == 0, f"compare-dest mismatch failed: {result.stderr[:300]}" received = get_dest_received_dir(dest, source) assert _read_file(os.path.join(received, self.UNCHANGED)) == b"real data\n", \ "content mismatch did not fall back to a normal transfer" assert os.stat(os.path.join(received, self.UNCHANGED)).st_ino != \ os.stat(os.path.join(basis, self.UNCHANGED)).st_ino def test_copy_dest_copies_unchanged_and_transfers_changed(self, shared_server): source = self._make_source("basis_copy_src", self._source_tree("cp")) dest = os.path.join(TEST_DATA_DIR, "basis_copy_dst") clean_dir(dest) basis = self._seed_basis(dest, source, "cpbasis", self._basis_tree("cp")) result, _ = run_client(source, dest, flags=["--copy-dest=cpbasis"], port=shared_server.port) assert result.returncode == 0, f"copy-dest failed: {result.stderr[:300]}" received = get_dest_received_dir(dest, source) unchanged = os.path.join(received, self.UNCHANGED) assert _read_file(unchanged) == b"stable content v1\n", "unchanged file not materialized" # A real local copy, NOT a hard link to the basis file. assert os.stat(unchanged).st_ino != os.stat(os.path.join(basis, self.UNCHANGED)).st_ino # Equal-size/different-content basis file falls back to the sender data. assert _read_file(os.path.join(received, self.CHANGED)) == \ self._source_tree("cp")[self.CHANGED] assert _read_file(os.path.join(received, self.ADDED)) == \ self._source_tree("cp")[self.ADDED] def test_link_dest_hardlinks_and_falls_back(self, shared_server): source = self._make_source("basis_link_src", self._source_tree("ln")) dest = os.path.join(TEST_DATA_DIR, "basis_link_dst") clean_dir(dest) basis = self._seed_basis(dest, source, "lnbasis", self._basis_tree("ln")) result, _ = run_client(source, dest, flags=["--link-dest=lnbasis"], port=shared_server.port) assert result.returncode == 0, f"link-dest failed: {result.stderr[:300]}" received = get_dest_received_dir(dest, source) unchanged = os.path.join(received, self.UNCHANGED) basis_file = os.path.join(basis, self.UNCHANGED) # Real hard link: same inode as the DIR file, nlink >= 2, no data copy. assert os.path.exists(unchanged) assert os.stat(unchanged).st_ino == os.stat(basis_file).st_ino, \ "link-dest did not produce a hard link" assert os.stat(unchanged).st_nlink >= 2 # Equal-size/different-content basis file must fall back to a plain # transfer (not a link). changed = os.path.join(received, self.CHANGED) assert _read_file(changed) == self._source_tree("ln")[self.CHANGED] assert os.stat(changed).st_ino != os.stat(os.path.join(basis, self.CHANGED)).st_ino @pytest.mark.parametrize("flag", ["--compare-dest", "--copy-dest", "--link-dest"]) def test_basis_dir_missing_is_a_clean_noop(self, shared_server, flag): # A basis directory that does not exist must simply transfer everything. source = self._make_source("basis_missing_src", {self.UNCHANGED: b"content\n"}) dest = os.path.join(TEST_DATA_DIR, "basis_missing_dst") clean_dir(dest) result, _ = run_client(source, dest, flags=[f"{flag}=nope"], port=shared_server.port) assert result.returncode == 0, f"{flag} with missing dir failed: {result.stderr[:300]}" received = get_dest_received_dir(dest, source) assert _read_file(os.path.join(received, self.UNCHANGED)) == b"content\n" def test_link_dest_multithreaded(self, shared_server): source = self._make_source("basis_link_mt_src", self._source_tree("mt")) dest = os.path.join(TEST_DATA_DIR, "basis_link_mt_dst") clean_dir(dest) basis = self._seed_basis(dest, source, "mtbasis", self._basis_tree("mt")) result, _ = run_client(source, dest, flags=["--link-dest=mtbasis", "--threads"], port=shared_server.port) assert result.returncode == 0, f"-m link-dest failed: {result.stderr[:300]}" received = get_dest_received_dir(dest, source) assert os.stat(os.path.join(received, self.UNCHANGED)).st_ino == \ os.stat(os.path.join(basis, self.UNCHANGED)).st_ino assert _read_file(os.path.join(received, self.ADDED)) == \ self._source_tree("mt")[self.ADDED] def test_link_dest_with_delay_updates_stages_and_publishes_link(self, shared_server): source = self._make_source("basis_link_delay_src", {self.UNCHANGED: b"v1\n"}) dest = os.path.join(TEST_DATA_DIR, "basis_link_delay_dst") clean_dir(dest) basis = self._seed_basis(dest, source, "delaybasis", {self.UNCHANGED: b"v1\n"}) result, _ = run_client(source, dest, flags=["--link-dest=delaybasis", "--delay-updates"], port=shared_server.port) assert result.returncode == 0, f"delay-updates link-dest failed: {result.stderr[:300]}" received = get_dest_received_dir(dest, source) unchanged = os.path.join(received, self.UNCHANGED) assert os.stat(unchanged).st_ino == \ os.stat(os.path.join(basis, self.UNCHANGED)).st_ino assert not os.path.isdir(os.path.join(dest, self.STAGING)), \ "delay-updates staging tree was not cleaned up" def test_delete_does_not_touch_basis_dir(self): """--delete removes genuine extras but must never treat a basis-dir snapshot (which a --link-dest run just linked from) as destination content.""" source = self._make_source("basis_delete_src", {self.UNCHANGED: b"v1\n"}) dest = os.path.join(TEST_DATA_DIR, "basis_delete_dst") clean_dir(dest) basis = self._seed_basis(dest, source, "delbasis", {self.UNCHANGED: b"v1\n"}) received = get_dest_received_dir(dest, source) os.makedirs(received, exist_ok=True) extra = os.path.join(received, "extra.txt") with open(extra, "wb") as fh: fh.write(b"extra") with ServerManager() as server: server.start(extra_args=["--allow-delete"]) result, _ = run_client(source, dest, flags=["--link-dest=delbasis", "--delete"], port=server.port) assert result.returncode == 0, \ f"delete+link-dest failed: {result.stderr[:300]}" assert not os.path.exists(extra), "genuine extra file was not deleted" assert _read_file(os.path.join(received, self.UNCHANGED)) == b"v1\n" assert os.path.exists(os.path.join(basis, self.UNCHANGED)), \ "basis directory was deleted by --delete" assert os.stat(os.path.join(received, self.UNCHANGED)).st_ino == \ os.stat(os.path.join(basis, self.UNCHANGED)).st_ino def test_delay_delete_keeps_nested_staging_named_dir_as_content(self): # The real --delay-updates staging directory is protected from --delete # only as a DIRECT child of the receive root. A nested destination # directory that merely shares the staging name is ordinary content, so # its extras must still be deleted (regression guard for the walker). source = self._make_source("basis_nested_stage_src", {"top.txt": b"top\n", "sub/real.txt": b"real\n"}) dest = os.path.join(TEST_DATA_DIR, "basis_nested_stage_dst") clean_dir(dest) self._seed_basis(dest, source, "nstbasis", {"top.txt": b"top\n", "sub/real.txt": b"real\n"}) received = get_dest_received_dir(dest, source) nested = os.path.join(received, "sub", self.STAGING) os.makedirs(nested, exist_ok=True) extra = os.path.join(nested, "extra.txt") with open(extra, "wb") as fh: fh.write(b"nested extra") with ServerManager() as server: server.start(extra_args=["--allow-delete"]) result, _ = run_client(source, dest, flags=["--link-dest=nstbasis", "--delete", "--delay-updates"], port=server.port) assert result.returncode == 0, \ f"delay-delete nested staging failed: {result.stderr[:300]}" assert not os.path.exists(extra), \ "extra inside a nested .fastsync-stage dir was not deleted" assert not os.path.isdir(nested), \ "nested .fastsync-stage dir should have been removed after its extra" assert _read_file(os.path.join(received, "sub", "real.txt")) == b"real\n" assert not os.path.isdir(os.path.join(dest, self.STAGING)), \ "real delay-updates staging tree was not cleaned up" def test_basis_priority_first_match_wins(self, shared_server): # Two link-dest dirs both hold the exact file: the FIRST (command-line # order) basis directory must win and supply the hard link. source = self._make_source("basis_prio_src", {"f.txt": b"content\n"}) dest = os.path.join(TEST_DATA_DIR, "basis_prio_dst") clean_dir(dest) first = self._seed_basis_file(dest, source, "b1", "f.txt", b"content\n") self._seed_basis_file(dest, source, "b2", "f.txt", b"content\n") result, _ = run_client(source, dest, flags=["--link-dest=b1", "--link-dest=b2"], port=shared_server.port) assert result.returncode == 0, f"link-dest priority failed: {result.stderr[:300]}" received = get_dest_received_dir(dest, source) assert os.stat(os.path.join(received, "f.txt")).st_ino == os.stat(first).st_ino, \ "first basis dir did not win over the second" def test_basis_priority_across_compare_and_link(self, shared_server): # A compare-dest entry listed BEFORE a link-dest entry shadows it (the # exact match is found first and nothing is materialized); reversing the # order lets the link-dest entry win and materialize a hard link. source = self._make_source("basis_prio_mixed_src", {"f.txt": b"content\n"}) dest = os.path.join(TEST_DATA_DIR, "basis_prio_mixed_dst") clean_dir(dest) self._seed_basis_file(dest, source, "cmpb", "f.txt", b"content\n") self._seed_basis_file(dest, source, "lnb", "f.txt", b"content\n") result, _ = run_client(source, dest, flags=["--compare-dest=cmpb", "--link-dest=lnb"], port=shared_server.port) assert result.returncode == 0, \ f"mixed priority (compare first) failed: {result.stderr[:300]}" received = get_dest_received_dir(dest, source) assert not os.path.exists(os.path.join(received, "f.txt")), \ "compare-dest matched first, so the file must stay sparse (no link-dest materialize)" dest = os.path.join(TEST_DATA_DIR, "basis_prio_mixed_dst2") clean_dir(dest) self._seed_basis_file(dest, source, "cmpb", "f.txt", b"content\n") linkb2 = self._seed_basis_file(dest, source, "lnb", "f.txt", b"content\n") result, _ = run_client(source, dest, flags=["--link-dest=lnb", "--compare-dest=cmpb"], port=shared_server.port) assert result.returncode == 0, \ f"mixed priority (link first) failed: {result.stderr[:300]}" received = get_dest_received_dir(dest, source) assert os.stat(os.path.join(received, "f.txt")).st_ino == os.stat(linkb2).st_ino, \ "link-dest did not materialize when listed before compare-dest" def test_link_dest_size_only_ignores_mtime(self, shared_server): # --size-only drops the mtime leg of the quick check: a basis file with # the SAME content but a DIFFERENT mtime is still an exact match. source = self._make_source("basis_sizeonly_src", {"f.txt": b"content\n"}) dest = os.path.join(TEST_DATA_DIR, "basis_sizeonly_dst") clean_dir(dest) basis_file = self._seed_basis_file(dest, source, "sob", "f.txt", b"content\n", ts=self.TS + 500) result, _ = run_client(source, dest, flags=["--link-dest=sob", "--size-only"], port=shared_server.port) assert result.returncode == 0, f"size-only link-dest failed: {result.stderr[:300]}" received = get_dest_received_dir(dest, source) assert os.stat(os.path.join(received, "f.txt")).st_ino == os.stat(basis_file).st_ino, \ "--size-only should link a basis file whose mtime differs" @pytest.mark.ci def test_link_dest_ignore_times_never_links(self, shared_server): # -I/--ignore-times forces every file to be updated, so a basis dir is # never used to hard-link (rsync parity). The file is transferred and # stored as a fresh inode even though it matches the basis exactly. source = self._make_source("basis_igntimes_src", {"f.txt": b"content\n"}) dest = os.path.join(TEST_DATA_DIR, "basis_igntimes_dst") clean_dir(dest) basis_file = self._seed_basis_file(dest, source, "itb", "f.txt", b"content\n") result, _ = run_client(source, dest, flags=["--link-dest=itb", "--ignore-times"], port=shared_server.port) assert result.returncode == 0, f"ignore-times link-dest failed: {result.stderr[:300]}" received = get_dest_received_dir(dest, source) dest_file = os.path.join(received, "f.txt") assert _read_file(dest_file) == b"content\n" assert os.stat(dest_file).st_ino != os.stat(basis_file).st_ino, \ "--ignore-times must not hard-link to a basis file" def test_basis_handles_file_above_whole_file_limit(self, shared_server): # Track 5a: a basis hit streams the copy (and the --verify-basis digest # streams the basis), so a source larger than the whole-file payload # bound is supported for basis dirs exactly like rsync. A basis MISS # still falls back to the normal transfer, which keeps its own bound. source = self._make_source("basis_oversize_src", {"small.txt": b"ok\n"}) big = os.path.join(source, "huge.bin") with open(big, "wb") as fh: os.ftruncate(fh.fileno(), 256 * 1024 * 1024 + 4096) dest = os.path.join(TEST_DATA_DIR, "basis_oversize_dst") clean_dir(dest) received = get_dest_received_dir(dest, source) rel = os.path.relpath(received, dest) basis_big = os.path.join(dest, "ob", rel, "huge.bin") os.makedirs(os.path.dirname(basis_big), exist_ok=True) shutil.copyfile(big, basis_big) os.utime(basis_big, (self.TS, self.TS)) os.utime(big, (self.TS, self.TS)) result, _ = run_client(source, dest, flags=["--link-dest=ob", "--incremental"], port=shared_server.port) assert result.returncode == 0, \ f"over-limit basis run failed: {result.stderr[:300]}" dest_big = os.path.join(received, "huge.bin") assert os.path.exists(dest_big), "over-limit basis hit was not materialized" assert os.path.getsize(dest_big) == 256 * 1024 * 1024 + 4096 assert os.stat(dest_big).st_ino == os.stat(basis_big).st_ino, \ "over-limit --link-dest did not hard-link to the basis" assert _read_file(os.path.join(received, "small.txt")) == b"ok\n" def _random_payloads(size=2 * 1024 * 1024, changed=64 * 1024, seed=1234): """Return (old, new) byte strings of equal length where `new` differs from `old` only in one contiguous `changed`-byte region. Incompressible (random) data keeps the whole-file wire cost near the file size, so a delta transfer is distinguishable from a whole-file one by its wire bytes.""" r = random.Random(seed) data = bytearray(r.randbytes(size)) old = bytes(data) off = size // 3 for i in range(off, off + changed): data[i] = r.randrange(256) return old, bytes(data) class TestFuzzy: """-y/--fuzzy similar-file delta basis. Scenario modelled on rsync's --fuzzy: a file is recreated under a similar NEW basename in the same directory. The destination still holds the old-named file (nothing deleted it), but the new path has no content of its own at the destination, so without --fuzzy the receiver has no delta basis and the whole file is sent. With --fuzzy the receiver searches the destination directory, picks the similar-named sibling as the delta basis, sends its block signature, and the sender transmits only the differences. The reconstructed file must be byte-identical to the source in every mode; only the wire usage changes (observed through CountingProxy, because client --stats report source lengths, not wire bytes). """ OLD_NAME = "report-2025.dat" NEW_NAME = "report-2026.dat" TS = 1577836800 # 2020-01-01, used to pin stale destination mtimes SIZE = 2 * 1024 * 1024 def _client_via_proxy(self, source, dest, flags, proxy): cmd = (CLIENT_CMD + ["--source-dir", source, "--dest-dir", dest, "--save-to-disk", "--server-port", str(proxy.port)] + flags) return proxy.run(cmd) def _seed_dest(self, source, dest, files, port): """Write `files` {rel: bytes} into source and mirror them to dest.""" for rel, content in files.items(): full = os.path.join(source, rel) os.makedirs(os.path.dirname(full), exist_ok=True) with open(full, "wb") as fh: fh.write(content) clean_dir(dest) result, _ = run_client(source, dest, port=port) assert result.returncode == 0, f"seed failed: {(result.stderr or result.stdout)[:300]}" def _prepare(self, tag): source = os.path.join(TEST_DATA_DIR, f"fuzzy_{tag}_src") dest = os.path.join(TEST_DATA_DIR, f"fuzzy_{tag}_dst") clean_dir(source) return source, dest def _run_measured(self, source, dest, flags, port): """Run a transfer through a byte-counting proxy. Returns (result, proxy).""" proxy = CountingProxy(port) result = self._client_via_proxy(source, dest, flags, proxy) return result, proxy def test_fuzzy_uses_similar_sibling_as_delta_basis(self, shared_server): source, dest = self._prepare("basis") old_bytes, new_bytes = _random_payloads() self._seed_dest(source, dest, {self.OLD_NAME: old_bytes}, shared_server.port) # Recreate the file under a similar new name; the old sibling stays on # the destination (nothing deletes it). os.unlink(os.path.join(source, self.OLD_NAME)) with open(os.path.join(source, self.NEW_NAME), "wb") as fh: fh.write(new_bytes) result, proxy = self._run_measured(source, dest, ["--fuzzy"], shared_server.port) assert result.returncode == 0, \ f"--fuzzy rename transfer failed: {(result.stderr or result.stdout)[:300]}" received = get_dest_received_dir(dest, source) assert _read_file(os.path.join(received, self.NEW_NAME)) == new_bytes, \ "fuzzy reconstruction is not byte-exact" # The wire carried the delta, not the 2 MiB whole file. assert proxy.client_to_server < len(new_bytes) // 4, \ f"fuzzy transfer sent {proxy.client_to_server} bytes; expected a delta" def test_without_fuzzy_sends_the_whole_file(self, shared_server): source, dest = self._prepare("whole") old_bytes, new_bytes = _random_payloads() self._seed_dest(source, dest, {self.OLD_NAME: old_bytes}, shared_server.port) os.unlink(os.path.join(source, self.OLD_NAME)) with open(os.path.join(source, self.NEW_NAME), "wb") as fh: fh.write(new_bytes) result, proxy = self._run_measured(source, dest, ["--incremental", "--delta"], shared_server.port) assert result.returncode == 0, f"no-fuzzy rename failed: {result.stderr[:300]}" received = get_dest_received_dir(dest, source) assert _read_file(os.path.join(received, self.NEW_NAME)) == new_bytes # No similar basis: the whole file goes over the wire. assert proxy.client_to_server > len(new_bytes) // 2, \ f"expected a whole-file transfer, got {proxy.client_to_server} bytes" @pytest.mark.parametrize("mt", [False, True]) def test_fuzzy_byte_exact_single_and_multithreaded(self, shared_server, mt): source, dest = self._prepare(f"mt{'1' if mt else '0'}") old_bytes, new_bytes = _random_payloads() self._seed_dest(source, dest, {self.OLD_NAME: old_bytes}, shared_server.port) os.unlink(os.path.join(source, self.OLD_NAME)) with open(os.path.join(source, self.NEW_NAME), "wb") as fh: fh.write(new_bytes) flags = ["--fuzzy"] + (["--threads"] if mt else []) result, proxy = self._run_measured(source, dest, flags, shared_server.port) assert result.returncode == 0, \ f"--fuzzy {'-m ' if mt else ''}rename failed: {(result.stderr or result.stdout)[:300]}" received = get_dest_received_dir(dest, source) assert _read_file(os.path.join(received, self.NEW_NAME)) == new_bytes, \ f"fuzzy {'-m ' if mt else ''}reconstruction is not byte-exact" assert proxy.client_to_server < len(new_bytes) // 4 def test_no_candidate_falls_back_to_whole_file(self, shared_server): # A brand-new destination directory holds no sibling at all, so --fuzzy # finds nothing and the file is transferred whole (and correctly). source, dest = self._prepare("nocand") clean_dir(dest) os.makedirs(source, exist_ok=True) _, new_bytes = _random_payloads() with open(os.path.join(source, self.NEW_NAME), "wb") as fh: fh.write(new_bytes) result, proxy = self._run_measured(source, dest, ["--fuzzy"], shared_server.port) assert result.returncode == 0, \ f"--fuzzy no-candidate fallback failed: {(result.stderr or result.stdout)[:300]}" received = get_dest_received_dir(dest, source) assert _read_file(os.path.join(received, self.NEW_NAME)) == new_bytes assert proxy.client_to_server > len(new_bytes) // 2, \ "no-candidate fuzzy run should have sent the whole file" def test_dissimilar_sibling_is_not_used(self, shared_server): # A sibling whose basename is too different from the incoming name is # rejected by rsync's fuzzy distance window (the length gap exceeds # 25), so the run falls back to a whole-file transfer. A distinct # mtime keeps rsync's exact size+mtime first pass from accepting it. source, dest = self._prepare("dissim") old_bytes, new_bytes = _random_payloads() long_name = "totally-unrelated-notes-with-a-very-long-name.bin" self._seed_dest(source, dest, {long_name: old_bytes}, shared_server.port) with open(os.path.join(source, self.NEW_NAME), "wb") as fh: fh.write(new_bytes) received_dir = get_dest_received_dir(dest, source) os.utime(os.path.join(received_dir, long_name), (self.TS, self.TS)) os.utime(os.path.join(source, self.NEW_NAME), (self.TS + 100000, self.TS + 100000)) result, proxy = self._run_measured(source, dest, ["--fuzzy"], shared_server.port) assert result.returncode == 0, \ f"--fuzzy dissimilar-sibling run failed: {(result.stderr or result.stdout)[:300]}" received = get_dest_received_dir(dest, source) assert _read_file(os.path.join(received, self.NEW_NAME)) == new_bytes assert proxy.client_to_server > len(new_bytes) // 2, \ "a dissimilar-named sibling must not be used as a fuzzy basis" def test_exact_size_mtime_sibling_is_used(self, shared_server): # rsync's fuzzy first pass accepts a sibling with an exact size+mtime # match regardless of how unrelated its name is (its content is almost # certainly the same). source, dest = self._prepare("exact") old_bytes, new_bytes = _random_payloads() self._seed_dest(source, dest, {"unrelated-blob.bin": old_bytes}, shared_server.port) with open(os.path.join(source, self.NEW_NAME), "wb") as fh: fh.write(new_bytes) received_dir = get_dest_received_dir(dest, source) ts = 1600000000 os.utime(os.path.join(received_dir, "unrelated-blob.bin"), (ts, ts)) os.utime(os.path.join(source, self.NEW_NAME), (ts, ts)) result, proxy = self._run_measured(source, dest, ["--fuzzy"], shared_server.port) assert result.returncode == 0, \ f"--fuzzy exact size+mtime run failed: {(result.stderr or result.stdout)[:300]}" received = get_dest_received_dir(dest, source) assert _read_file(os.path.join(received, self.NEW_NAME)) == new_bytes assert proxy.client_to_server < len(new_bytes) // 4, \ "an exact size+mtime sibling should be used as a fuzzy basis" def test_fuzzy_helps_when_dest_holds_an_unsuitable_file(self, shared_server): # The destination DOES hold the exact new name, but it is a tiny stale # file (below the delta engine's minimum, ratio far outside its window), # so it cannot serve as the basis. --fuzzy then falls back to the # similar-named sibling. source, dest = self._prepare("unsuitable") old_bytes, new_bytes = _random_payloads() self._seed_dest(source, dest, {self.OLD_NAME: old_bytes, self.NEW_NAME: b"stale small file\n"}, shared_server.port) with open(os.path.join(source, self.NEW_NAME), "wb") as fh: fh.write(new_bytes) result, proxy = self._run_measured(source, dest, ["--fuzzy"], shared_server.port) assert result.returncode == 0, \ f"--fuzzy unsuitable-dest run failed: {(result.stderr or result.stdout)[:300]}" received = get_dest_received_dir(dest, source) assert _read_file(os.path.join(received, self.NEW_NAME)) == new_bytes, \ "byte-exactness broken when the destination file was unsuitable" assert proxy.client_to_server < len(new_bytes) // 4, \ "fuzzy should have reused the similar sibling as the basis" def test_whole_file_makes_fuzzy_inert(self, shared_server): # -W/--whole-file switches the delta machinery off, so --fuzzy has # nothing to attach to and the file is transferred whole (rsync parity). source, dest = self._prepare("wholefile") old_bytes, new_bytes = _random_payloads() self._seed_dest(source, dest, {self.OLD_NAME: old_bytes}, shared_server.port) os.unlink(os.path.join(source, self.OLD_NAME)) with open(os.path.join(source, self.NEW_NAME), "wb") as fh: fh.write(new_bytes) result, proxy = self._run_measured(source, dest, ["--fuzzy", "-W"], shared_server.port) assert result.returncode == 0, \ f"--fuzzy -W run failed: {(result.stderr or result.stdout)[:300]}" received = get_dest_received_dir(dest, source) assert _read_file(os.path.join(received, self.NEW_NAME)) == new_bytes assert proxy.client_to_server > len(new_bytes) // 2, \ "--whole-file must disable the fuzzy delta basis" def test_fuzzy_via_short_y_alias(self, shared_server): source, dest = self._prepare("shorty") old_bytes, new_bytes = _random_payloads() self._seed_dest(source, dest, {self.OLD_NAME: old_bytes}, shared_server.port) os.unlink(os.path.join(source, self.OLD_NAME)) with open(os.path.join(source, self.NEW_NAME), "wb") as fh: fh.write(new_bytes) result, proxy = self._run_measured(source, dest, ["-y"], shared_server.port) assert result.returncode == 0, f"-y rename failed: {(result.stderr or result.stdout)[:300]}" received = get_dest_received_dir(dest, source) assert _read_file(os.path.join(received, self.NEW_NAME)) == new_bytes assert proxy.client_to_server < len(new_bytes) // 4, "-y did not enable fuzzy" def test_fuzzy_with_delay_updates_publishes_cleanly(self, shared_server): # A fuzzy-reconstructed file goes through the normal store engine, so # --delay-updates must stage and publish it with no staging leftovers. source, dest = self._prepare("delay") old_bytes, new_bytes = _random_payloads() self._seed_dest(source, dest, {self.OLD_NAME: old_bytes}, shared_server.port) os.unlink(os.path.join(source, self.OLD_NAME)) with open(os.path.join(source, self.NEW_NAME), "wb") as fh: fh.write(new_bytes) result, _ = run_client(source, dest, flags=["--fuzzy", "--delay-updates"], port=shared_server.port) assert result.returncode == 0, \ f"--fuzzy --delay-updates failed: {(result.stderr or result.stdout)[:300]}" received = get_dest_received_dir(dest, source) assert _read_file(os.path.join(received, self.NEW_NAME)) == new_bytes assert not os.path.isdir(os.path.join(dest, ".fastsync-stage")), \ "delay-updates staging tree was not cleaned up" def test_fuzzy_source_removed_after_transfer(self, shared_server): # A fuzzy transfer is a real transfer (not a skip), so # --remove-source-files must remove the renamed source file. source, dest = self._prepare("rm") old_bytes, new_bytes = _random_payloads() self._seed_dest(source, dest, {self.OLD_NAME: old_bytes}, shared_server.port) os.unlink(os.path.join(source, self.OLD_NAME)) with open(os.path.join(source, self.NEW_NAME), "wb") as fh: fh.write(new_bytes) result, _ = run_client(source, dest, flags=["--fuzzy", "--remove-source-files"], port=shared_server.port) assert result.returncode == 0, \ f"--fuzzy --remove-source-files failed: {(result.stderr or result.stdout)[:300]}" assert not os.path.exists(os.path.join(source, self.NEW_NAME)), \ "a fuzzy-transferred source should have been removed" received = get_dest_received_dir(dest, source) assert _read_file(os.path.join(received, self.NEW_NAME)) == new_bytes assert _read_file(os.path.join(received, self.OLD_NAME)) == old_bytes @staticmethod def _rand_bytes(size, seed): return random.Random(seed).randbytes(size) def _replace_source_file(self, source, old_name, new_name, new_bytes): """Remove old_name from source and add new_name with new_bytes.""" os.unlink(os.path.join(source, old_name)) with open(os.path.join(source, new_name), "wb") as fh: fh.write(new_bytes) def test_worthless_fuzzy_basis_falls_back_inside_handshake(self, shared_server): # The sibling passes the name AND size gates but shares no blocks with # the incoming file, so the sender's delta is not worthwhile: it replies # STATUS_NEXT and the receiver consumes the WHOLE file inside the delta # handshake. This proves a bad fuzzy basis cannot desync the protocol # or corrupt the result. source, dest = self._prepare("worthless") basis = self._rand_bytes(self.SIZE, 424242) target = self._rand_bytes(self.SIZE, 777777) self._seed_dest(source, dest, {self.OLD_NAME: basis}, shared_server.port) self._replace_source_file(source, self.OLD_NAME, self.NEW_NAME, target) result, proxy = self._run_measured(source, dest, ["--fuzzy"], shared_server.port) assert result.returncode == 0, \ f"--fuzzy worthless-basis run failed: {(result.stderr or result.stdout)[:300]}" received = get_dest_received_dir(dest, source) assert _read_file(os.path.join(received, self.NEW_NAME)) == target, \ "whole-file fallback after a worthless fuzzy basis is not byte-exact" assert proxy.client_to_server > self.SIZE // 2, \ "a worthless basis should have made the sender fall back to the whole file" def test_existing_dest_file_preferred_over_fuzzy_sibling(self, shared_server): # Non-displacement: the destination holds a file at the exact path that # is inside the delta size bounds (same size, different content, older # mtime). FastSync must delta against THAT file -- even though it # shares nothing with the source -- and must NOT reuse a similar-named # sibling that is byte-identical to the source. source, dest = self._prepare("nondisp") sibling = self._rand_bytes(self.SIZE, 111) # will equal the incoming file stale = self._rand_bytes(self.SIZE, 333) # worthless exact-path file self._seed_dest(source, dest, {self.OLD_NAME: sibling, self.NEW_NAME: stale}, shared_server.port) # Force the exact-path destination file's mtime into the past so the # quick check deterministically decides to transfer it. os.utime(os.path.join(get_dest_received_dir(dest, source), self.NEW_NAME), (self.TS, self.TS)) os.unlink(os.path.join(source, self.OLD_NAME)) with open(os.path.join(source, self.NEW_NAME), "wb") as fh: fh.write(sibling) result, proxy = self._run_measured(source, dest, ["--fuzzy"], shared_server.port) assert result.returncode == 0, \ f"--fuzzy non-displacement run failed: {(result.stderr or result.stdout)[:300]}" received = get_dest_received_dir(dest, source) assert _read_file(os.path.join(received, self.NEW_NAME)) == sibling assert proxy.client_to_server > self.SIZE // 2, \ "the exact-path destination file must be the delta basis, not the fuzzy sibling" def test_fuzzy_basis_larger_than_source(self, shared_server): # The similar sibling is LARGER than the incoming file (within the delta # engine's 10x ratio); the new file is an exact prefix of the basis, so # every block matches and only a tiny delta travels. source, dest = self._prepare("largerbasis") big = self._rand_bytes(1536 * 1024, 1) prefix = big[:1024 * 1024] self._seed_dest(source, dest, {self.OLD_NAME: big}, shared_server.port) self._replace_source_file(source, self.OLD_NAME, self.NEW_NAME, prefix) result, proxy = self._run_measured(source, dest, ["--fuzzy"], shared_server.port) assert result.returncode == 0, \ f"--fuzzy larger-basis run failed: {(result.stderr or result.stdout)[:300]}" received = get_dest_received_dir(dest, source) assert _read_file(os.path.join(received, self.NEW_NAME)) == prefix, \ "shrunken file reconstructed from a larger fuzzy basis is not byte-exact" assert proxy.client_to_server < len(prefix) // 4, \ "larger fuzzy basis should have carried most of the file as block matches" def test_fuzzy_basis_smaller_than_source(self, shared_server): # The similar sibling is SMALLER than the incoming file; the new file # appends data past the basis, so the appended tail travels as literals # while the shared prefix is block-matched. source, dest = self._prepare("smallerbasis") base = self._rand_bytes(self.SIZE, 2) tail = self._rand_bytes(64 * 1024, 3) new_bytes = base + tail self._seed_dest(source, dest, {self.OLD_NAME: base}, shared_server.port) self._replace_source_file(source, self.OLD_NAME, self.NEW_NAME, new_bytes) result, proxy = self._run_measured(source, dest, ["--fuzzy"], shared_server.port) assert result.returncode == 0, \ f"--fuzzy smaller-basis run failed: {(result.stderr or result.stdout)[:300]}" received = get_dest_received_dir(dest, source) assert _read_file(os.path.join(received, self.NEW_NAME)) == new_bytes, \ "grown file reconstructed from a smaller fuzzy basis is not byte-exact" assert proxy.client_to_server < len(new_bytes) // 4, \ "smaller fuzzy basis should have block-matched the shared prefix" def test_no_fuzzy_end_to_end_equals_no_flag(self, shared_server): # --no-fuzzy must not enable anything: a run with it behaves exactly # like a run without it (whole-file transfer, byte-exact output). source, dest = self._prepare("nofuzzye2e") old_bytes, new_bytes = _random_payloads() self._seed_dest(source, dest, {self.OLD_NAME: old_bytes}, shared_server.port) self._replace_source_file(source, self.OLD_NAME, self.NEW_NAME, new_bytes) result, proxy = self._run_measured(source, dest, ["--no-fuzzy"], shared_server.port) assert result.returncode == 0, \ f"--no-fuzzy run failed: {(result.stderr or result.stdout)[:300]}" received = get_dest_received_dir(dest, source) assert _read_file(os.path.join(received, self.NEW_NAME)) == new_bytes assert proxy.client_to_server > len(new_bytes) // 2, \ "--no-fuzzy should leave the default whole-file behavior intact" class TestIdentityMapping: """Ownership-application flags (--numeric-ids / --usermap / --groupmap / --chown). In CI the receiver usually runs unprivileged, so ownership apply is expected to fail from lack of privilege: the transfer must STILL succeed and exit 0 (the receiver warns and continues, rsync parity). The only assertion that requires the ownership to actually change is gated on os.geteuid() == 0 so it is skipped (not failed) as a non-root user.""" def test_numeric_ids_transfer_succeeds_unprivileged(self, shared_server): source = os.path.join(TEST_DATA_DIR, "identity_num_source") dest = os.path.join(TEST_DATA_DIR, "identity_num_dest") clean_dir(source) clean_dir(dest) with open(os.path.join(source, "f.txt"), "wb") as f: f.write(b"hello identity") result, _ = run_client(source, dest, flags=["--preserve", "--numeric-ids"], port=shared_server.port) assert result.returncode == 0, \ f"exit {result.returncode}: {(result.stderr or '')[:200]}" received = get_dest_received_dir(dest, source) with open(os.path.join(received, "f.txt"), "rb") as f: assert f.read() == b"hello identity" def test_usermap_and_groupmap_and_chown_succeed_unprivileged(self, shared_server): source = os.path.join(TEST_DATA_DIR, "identity_map_source") dest = os.path.join(TEST_DATA_DIR, "identity_map_dest") clean_dir(source) clean_dir(dest) with open(os.path.join(source, "f.txt"), "wb") as f: f.write(b"mapped") # #294: --chown cannot be mixed with --usermap/--groupmap on the same # side, so the maps travel together and --chown is exercised separately. result, _ = run_client( source, dest, flags=["--preserve", "--usermap=@1000:@1001", "--groupmap=@100:@101"], port=shared_server.port) assert result.returncode == 0, \ f"exit {result.returncode}: {(result.stderr or '')[:200]}" received = get_dest_received_dir(dest, source) with open(os.path.join(received, "f.txt"), "rb") as f: assert f.read() == b"mapped" result, _ = run_client(source, dest, flags=["--preserve", "--chown=@2000:@2001"], port=shared_server.port) assert result.returncode == 0, \ f"chown exit {result.returncode}: {(result.stderr or '')[:200]}" @pytest.mark.skipif(os.geteuid() != 0, reason="only root can change ownership") def test_numeric_ids_alone_does_not_apply_ownership_as_root(self, shared_server): # #286.1: --numeric-ids is a mapping modifier, not an ownership request. source = os.path.join(TEST_DATA_DIR, "identity_root_source") dest = os.path.join(TEST_DATA_DIR, "identity_root_dest") clean_dir(source) clean_dir(dest) src_file = os.path.join(source, "f.txt") with open(src_file, "wb") as f: f.write(b"owner") os.chown(src_file, 12345, 12346) result, _ = run_client(source, dest, flags=["--preserve", "--numeric-ids"], port=shared_server.port) assert result.returncode == 0, \ f"exit {result.returncode}: {(result.stderr or '')[:200]}" received = get_dest_received_dir(dest, source) dst_file = os.path.join(received, "f.txt") assert os.path.exists(dst_file) st = os.stat(dst_file) assert st.st_uid != 12345, \ f"--numeric-ids alone must not chown: uid={st.st_uid} gid={st.st_gid}" @pytest.mark.skipif(os.geteuid() != 0, reason="only root can change ownership") def test_chown_overrides_ownership_as_root(self, shared_server): source = os.path.join(TEST_DATA_DIR, "identity_chown_root_source") dest = os.path.join(TEST_DATA_DIR, "identity_chown_root_dest") clean_dir(source) clean_dir(dest) src_file = os.path.join(source, "f.txt") with open(src_file, "wb") as f: f.write(b"root chown") os.chown(src_file, 1, 1) result, _ = run_client(source, dest, flags=["--preserve", "--chown=@12345:@54321"], port=shared_server.port) assert result.returncode == 0, \ f"exit {result.returncode}: {(result.stderr or '')[:200]}" received = get_dest_received_dir(dest, source) dst_file = os.path.join(received, "f.txt") assert os.path.exists(dst_file) st = os.stat(dst_file) assert st.st_uid == 12345 and st.st_gid == 54321, \ f"--chown not applied: uid={st.st_uid} gid={st.st_gid}" class TestSuperPrivilege: """P7 Wave E: --super / --no-super control the receiver's already-confined super-user activities (ownership application, char/block device nodes). FastSync never elevates, so on an unprivileged receiver --super only permits a confined attempt (which then skips); --no-super forbids the activity even for root.""" def _seed(self, tag): source = os.path.join(TEST_DATA_DIR, f"super_{tag}_source") dest = os.path.join(TEST_DATA_DIR, f"super_{tag}_dest") clean_dir(source) clean_dir(dest) with open(os.path.join(source, "f.txt"), "wb") as f: f.write(b"super privilege\n") return source, dest def test_super_and_no_super_transfer_successfully(self, shared_server): """Both flags parse and the transfer completes normally regardless of the receiver's privilege level.""" for flag in ("--super", "--no-super"): source, dest = self._seed(flag.strip("-")) result, _ = run_client(source, dest, flags=[flag], port=shared_server.port) assert result.returncode == 0, \ f"{flag} exit {result.returncode}: {(result.stderr or '')[:300]}" received = get_dest_received_dir(dest, source) with open(os.path.join(received, "f.txt"), "rb") as f: assert f.read() == b"super privilege\n" @pytest.mark.skipif(os.geteuid() != 0, reason="only root can change ownership") def test_no_super_suppresses_ownership_as_root(self, shared_server): """As root the default gate would apply a raw numeric id; --no-super must suppress that ownership application entirely.""" source, dest = self._seed("nosuper") os.chown(os.path.join(source, "f.txt"), 12345, 12346) result, _ = run_client(source, dest, flags=["--preserve", "--numeric-ids", "--no-super"], port=shared_server.port) assert result.returncode == 0, \ f"exit {result.returncode}: {(result.stderr or '')[:300]}" received = get_dest_received_dir(dest, source) st = os.stat(os.path.join(received, "f.txt")) assert (st.st_uid, st.st_gid) != (12345, 12346), \ f"--no-super must not apply ownership (uid={st.st_uid} gid={st.st_gid})" @pytest.mark.skipif(os.geteuid() != 0, reason="only root can change ownership") def test_super_alone_does_not_apply_ownership_as_root(self, shared_server): """A3: --super no longer implies --numeric-ids, so --super alone must NOT apply client-chosen ownership even for root; the destination keeps the receiver's owner (the exact ownership --no-super would also suppress).""" source, dest = self._seed("superonly") os.chown(os.path.join(source, "f.txt"), 12345, 12346) result, _ = run_client(source, dest, flags=["--preserve", "--super"], port=shared_server.port) assert result.returncode == 0, \ f"exit {result.returncode}: {(result.stderr or '')[:300]}" received = get_dest_received_dir(dest, source) st = os.stat(os.path.join(received, "f.txt")) assert (st.st_uid, st.st_gid) != (12345, 12346), \ f"--super alone must not apply ownership (uid={st.st_uid} gid={st.st_gid})" @pytest.mark.skipif(os.geteuid() != 0, reason="only root can change ownership") def test_super_with_owner_numeric_ids_applies_ownership_as_root(self, shared_server): """Control: an explicit ownership request is what enables ownership, so -a --numeric-ids --super applies the raw ids as root (the very ownership --no-super suppresses). --numeric-ids itself is only the modifier.""" source, dest = self._seed("supernumeric") os.chown(os.path.join(source, "f.txt"), 12345, 12346) result, _ = run_client(source, dest, flags=["-a", "--numeric-ids", "--super"], port=shared_server.port) assert result.returncode == 0, \ f"exit {result.returncode}: {(result.stderr or '')[:300]}" received = get_dest_received_dir(dest, source) st = os.stat(os.path.join(received, "f.txt")) assert (st.st_uid, st.st_gid) == (12345, 12346), \ f"-a --numeric-ids --super should apply raw ids: uid={st.st_uid} gid={st.st_gid}" @pytest.mark.ci @pytest.mark.skipif(os.geteuid() != 0, reason="only root can change ownership") def test_fake_super_no_super_does_not_change_owner(self, shared_server): """--fake-super records the source owner, but --no-super must suppress the live chown even for root: the destination keeps the receiver's owner instead of the recorded source owner.""" source, dest = self._seed("fakesuper_nosuper") os.chown(os.path.join(source, "f.txt"), 12345, 12346) result, _ = run_client(source, dest, flags=["--fake-super", "--preserve", "--no-super"], port=shared_server.port) assert result.returncode == 0, \ f"exit {result.returncode}: {(result.stderr or '')[:300]}" received = get_dest_received_dir(dest, source) st = os.lstat(os.path.join(received, "f.txt")) assert (st.st_uid, st.st_gid) != (12345, 12346), \ f"--no-super must suppress fake-super's owner replay: uid={st.st_uid} gid={st.st_gid}" class TestStandaloneSuperDefault: """C3: a privileged (root) STANDALONE server without --allow-super forces SUPER_MODE_OFF, so a client cannot make it create device nodes, write raw devices, apply ownership, or use --copy-as. The shared_server fixture opts in with --allow-super to keep the historical behavior available to the existing root-only tests; these tests start their own un-opted server.""" @pytest.mark.ci def test_copy_as_refused_without_allow_super(self): """--copy-as is a client-chosen-ownership request and must be refused by a standalone server that did not opt in with --allow-super (on a non-root receiver it is refused for lack of privilege either way).""" source = os.path.join(TEST_DATA_DIR, "super_default_src") dest = os.path.join(TEST_DATA_DIR, "super_default_dst") clean_dir(source) clean_dir(dest) with open(os.path.join(source, "f.txt"), "wb") as f: f.write(b"no copy-as\n") server = ServerManager() server.start() # deliberately no --allow-super try: result, _ = run_client(source, dest, flags=["--preserve", "--copy-as=@65534:@65534"], port=server.port) finally: server.stop() assert result.returncode != 0, ( "standalone server accepted --copy-as without --allow-super" ) @pytest.mark.skipif(os.geteuid() != 0, reason="root can create the source device node") def test_devices_skipped_without_allow_super(self): """Root standalone server without --allow-super must skip device-node creation even for a client --devices request (the run still succeeds and the regular file transfers).""" source = os.path.join(TEST_DATA_DIR, "super_default_dev_src") dest = os.path.join(TEST_DATA_DIR, "super_default_dev_dst") clean_dir(source) clean_dir(dest) with open(os.path.join(source, "plain.txt"), "wb") as f: f.write(b"regular\n") os.mknod(os.path.join(source, "null"), stat.S_IFCHR | 0o666, os.makedev(1, 3)) server = ServerManager() server.start() # deliberately no --allow-super try: result, _ = run_client(source, dest, flags=["--devices"], port=server.port) finally: server.stop() assert result.returncode == 0, f"exit {result.returncode}: {(result.stderr or '')[:200]}" received = get_dest_received_dir(dest, source) assert not os.path.lexists(os.path.join(received, "null")), ( "root standalone server created a device node without --allow-super" ) class TestHardLinks: """-H/--hard-links: source files sharing an inode are re-created as hard links to one another on the destination (dedup preserved, first copy transferred once, the rest linked/copied). No root required.""" STAGING = ".fastsync-stage" def _make_source(self, name): src = os.path.join(TEST_DATA_DIR, name) clean_dir(src) with open(os.path.join(src, "a.txt"), "wb") as fh: fh.write(b"shared content\n" * 2000) os.link(os.path.join(src, "a.txt"), os.path.join(src, "b.txt")) with open(os.path.join(src, "c.txt"), "wb") as fh: fh.write(b"independent content\n" * 2000) return src @pytest.mark.parametrize("flags", [[], ["--threads"], ["--delay-updates"]]) def test_hard_links_preserved(self, shared_server, flags): src = self._make_source("hl_src") dest = os.path.join(TEST_DATA_DIR, "hl_dst") clean_dir(dest) result, _ = run_client(src, dest, flags=["-H"] + flags, port=shared_server.port) assert result.returncode == 0, f"Exit {result.returncode}: {result.stderr[:300]}" received = get_dest_received_dir(dest, src) a = os.path.join(received, "a.txt") b = os.path.join(received, "b.txt") c = os.path.join(received, "c.txt") assert os.path.isfile(a) and os.path.isfile(b) and os.path.isfile(c), \ "all three destination files exist" with open(a, "rb") as fa, open(b, "rb") as fb: assert fa.read() == fb.read(), "hard-linked pair content matches" assert os.stat(a).st_ino == os.stat(b).st_ino, \ "source hard links were not preserved on the destination" assert os.stat(a).st_ino != os.stat(c).st_ino, \ "independent files were incorrectly hard linked" with open(a, "rb") as fa, open(c, "rb") as fc: assert fa.read() != fc.read(), "independent files must differ in content" assert not os.path.isdir(os.path.join(dest, self.STAGING)), \ "--delay-updates left a staging tree behind" def test_hard_links_rejects_chunk_serialization(self, shared_server): src = self._make_source("hl_reject_src") dest = os.path.join(TEST_DATA_DIR, "hl_reject_dst") clean_dir(dest) result, _ = run_client(src, dest, flags=["-H", "--chunk-serialization"], port=shared_server.port) assert result.returncode != 0, "-H with -s was accepted" def test_hard_links_rejects_append(self, shared_server): src = self._make_source("hl_reject_app_src") dest = os.path.join(TEST_DATA_DIR, "hl_reject_app_dst") clean_dir(dest) result, _ = run_client(src, dest, flags=["-H", "--append"], port=shared_server.port) assert result.returncode != 0, "-H with --append was accepted" def test_hard_links_link_to_existing_first_member(self, shared_server): """A sibling whose first member is already up-to-date at the destination must still be created as a hard link to that existing file.""" src = os.path.join(TEST_DATA_DIR, "hl_exist_src") dest = os.path.join(TEST_DATA_DIR, "hl_exist_dst") clean_dir(src) clean_dir(dest) with open(os.path.join(src, "a.txt"), "wb") as fh: fh.write(b"seed content\n" * 1500) result, _ = run_client(src, dest, port=shared_server.port) assert result.returncode == 0, f"seed failed: {result.stderr[:200]}" # Introduce a hard-link sibling to the already-transferred first member. os.link(os.path.join(src, "a.txt"), os.path.join(src, "b.txt")) result, _ = run_client(src, dest, flags=["-H"], port=shared_server.port) assert result.returncode == 0, f"-H sync failed: {result.stderr[:300]}" received = get_dest_received_dir(dest, src) a = os.path.join(received, "a.txt") b = os.path.join(received, "b.txt") assert os.path.isfile(a) and os.path.isfile(b) assert os.stat(a).st_ino == os.stat(b).st_ino, \ "new sibling was not linked to the existing first member" with open(a, "rb") as fa, open(b, "rb") as fb: assert fa.read() == fb.read() def test_hard_links_existing_asymmetric_group(self, shared_server): """-H --existing with an asymmetric link group must succeed: when the first member's destination is absent (so it is skipped by --existing) but a sibling's destination already exists, the existing sibling is left in place instead of the whole transfer aborting on the absent first member.""" src = os.path.join(TEST_DATA_DIR, "hl_existing_src") dest = os.path.join(TEST_DATA_DIR, "hl_existing_dst") clean_dir(src) clean_dir(dest) with open(os.path.join(src, "a.txt"), "wb") as fh: fh.write(b"asymmetric group content\n" * 1200) # b.txt is a hard-link sibling of a.txt on the source. os.link(os.path.join(src, "a.txt"), os.path.join(src, "b.txt")) with open(os.path.join(src, "c.txt"), "wb") as fh: fh.write(b"independent\n" * 1200) # Pre-seed the destination with ONLY the sibling's file (the first # member has no destination entry). received = get_dest_received_dir(dest, src) os.makedirs(received, exist_ok=True) with open(os.path.join(received, "b.txt"), "wb") as fh: fh.write(b"asymmetric group content\n" * 1200) result, _ = run_client(src, dest, flags=["-H", "--existing"], port=shared_server.port) assert result.returncode == 0, \ f"-H --existing asymmetric group failed: {result.stderr[:300]}" # The existing sibling was preserved and its content is intact. with open(os.path.join(received, "b.txt"), "rb") as fh: assert fh.read() == b"asymmetric group content\n" * 1200 # Under --existing the absent first member is not created. assert not os.path.exists(os.path.join(received, "a.txt")) class TestAtimes: """-U/--atimes preserves the source access time on the destination. The sender captures atime during the scan (a stat, before any read for transfer), so the value is not clobbered by reading the source. This is verified by setting the source atime to a distinct value far in the past and comparing the destination atime to it (with whole-second tolerance; filesystems may round atime).""" PAYLOAD = b"atime preservation payload\n" @staticmethod def _make_source(source, dest): clean_dir(source) clean_dir(dest) path = os.path.join(source, "data.txt") with open(path, "wb") as f: f.write(TestAtimes.PAYLOAD) atime = 946684800 # 2000-01-01 00:00:00 UTC (far from "now") mtime = 951782400 os.utime(path, ns=(atime * 10**9 + 123456789, mtime * 10**9)) return path, atime @pytest.mark.ci @pytest.mark.parametrize("mt", [False, True]) def test_atimes_preserved(self, shared_server, mt): source = os.path.join(TEST_DATA_DIR, f"atime_{'m' if mt else 's'}_src") dest = os.path.join(TEST_DATA_DIR, f"atime_{'m' if mt else 's'}_dst") src_file, atime = self._make_source(source, dest) flags = ["-U"] + (["--threads"] if mt else []) result, _ = run_client(source, dest, flags=flags, port=shared_server.port) assert result.returncode == 0, \ f"-U failed: {(result.stderr or result.stdout)[:300]}" received = get_dest_received_dir(dest, source) dst_file = os.path.join(received, "data.txt") assert os.path.exists(dst_file) dst_st = os.stat(dst_file) assert abs(dst_st.st_atime - atime) < 1.5, \ f"dest atime {dst_st.st_atime} != source atime {atime}" @pytest.mark.ci @pytest.mark.parametrize("mt", [False, True]) def test_without_atimes_dest_differs(self, shared_server, mt): """Control: without -U the destination atime is not the source's old value (it reflects the fresh write, i.e. now), proving -U is what restores the source atime.""" source = os.path.join(TEST_DATA_DIR, f"atime_ctrl_{'m' if mt else 's'}_src") dest = os.path.join(TEST_DATA_DIR, f"atime_ctrl_{'m' if mt else 's'}_dst") src_file, atime = self._make_source(source, dest) now = time.time() flags = (["--threads"] if mt else []) result, _ = run_client(source, dest, flags=flags, port=shared_server.port) assert result.returncode == 0, f"control run failed: {(result.stderr or '')[:200]}" received = get_dest_received_dir(dest, source) dst_st = os.stat(os.path.join(received, "data.txt")) # The fresh destination atime is ~now, not the source's year-2000 value. assert abs(dst_st.st_atime - atime) > 24 * 3600, \ f"control dest atime {dst_st.st_atime} unexpectedly equals source atime {atime}" assert abs(dst_st.st_atime - now) < 24 * 3600, \ f"control dest atime {dst_st.st_atime} not ~now ({now})" class TestOpenNoatime: """--open-noatime opens the source with O_NOATIME so a transfer read does not bump the source's access time. O_NOATIME is honoured for a file owned by the reading process (or with CAP_FOWNER), so it works as non-root here; where it is unavailable/refused FastSync degrades to a normal open and the assertion below is skipped.""" @pytest.mark.skipif(not sys.platform.startswith("linux"), reason="O_NOATIME is Linux-specific") @pytest.mark.parametrize("mt", [False, True]) def test_open_noatime_preserves_source_atime(self, shared_server, mt): source = os.path.join(TEST_DATA_DIR, f"noatime_{'m' if mt else 's'}_src") dest = os.path.join(TEST_DATA_DIR, f"noatime_{'m' if mt else 's'}_dst") clean_dir(source) clean_dir(dest) path = os.path.join(source, "data.txt") with open(path, "wb") as f: f.write(b"open-noatime payload\n") atime = 730486800 # 1993-02-11, distinct and far from now os.utime(path, ns=(atime * 10**9, atime * 10**9)) flags = ["--open-noatime"] + (["--threads"] if mt else []) result, _ = run_client(source, dest, flags=flags, port=shared_server.port) assert result.returncode == 0, \ f"--open-noatime failed: {(result.stderr or result.stdout)[:300]}" after = os.stat(path) assert abs(after.st_atime - atime) < 1.5, \ f"source atime {after.st_atime} was bumped by the readable read (wanted {atime})" class TestCrtimes: """-N/--crtimes captures and transmits the source birth time. There is no portable way to SET a birth time (utimensat only sets atime/mtime), so the receiver deliberately does not apply it. The run must succeed without crashing; we do not assert the destination birth time changed. When the platform exposes a birth time (statx STATX_BTIME on Linux) we additionally confirm a capture path exists.""" @pytest.mark.parametrize("mt", [False, True]) def test_crtimes_run_succeeds(self, shared_server, mt): source = os.path.join(TEST_DATA_DIR, f"crtime_{'m' if mt else 's'}_src") dest = os.path.join(TEST_DATA_DIR, f"crtime_{'m' if mt else 's'}_dst") clean_dir(source) clean_dir(dest) path = os.path.join(source, "data.txt") payload = b"crtime transfer payload\n" with open(path, "wb") as f: f.write(payload) flags = ["-N"] + (["--threads"] if mt else []) result, _ = run_client(source, dest, flags=flags, port=shared_server.port) assert result.returncode == 0, \ f"-N failed: {(result.stderr or result.stdout)[:300]}" received = get_dest_received_dir(dest, source) dst_file = os.path.join(received, "data.txt") assert os.path.exists(dst_file) with open(dst_file, "rb") as f: assert f.read() == payload def test_crtimes_combines_with_atimes(self, shared_server): source = os.path.join(TEST_DATA_DIR, "crtime_atime_combined_src") dest = os.path.join(TEST_DATA_DIR, "crtime_atime_combined_dst") clean_dir(source) clean_dir(dest) path = os.path.join(source, "data.txt") with open(path, "wb") as f: f.write(b"combined U N payload\n") atime = 946684800 os.utime(path, ns=(atime * 10**9, 951782400 * 10**9)) result, _ = run_client(source, dest, flags=["-U", "-N"], port=shared_server.port) assert result.returncode == 0, \ f"-U -N failed: {(result.stderr or result.stdout)[:300]}" received = get_dest_received_dir(dest, source) dst_st = os.stat(os.path.join(received, "data.txt")) assert abs(dst_st.st_atime - atime) < 1.5, \ f"combined -U -N dest atime {dst_st.st_atime} != {atime}" class TestSparse: """-S/--sparse: the receiver preserves holes by skipping long zero runs with lseek (no wire change; the full image is in memory). The destination file must round-trip its logical size and content byte-for-byte; on filesystems that report holes (SEEK_HOLE/SEEK_DATA) we additionally assert the file is genuinely sparse via st_blocks, but that check is tolerant (CI filesystems may report no holes).""" def _make_sparse_source(self, name, total, zero_start, zero_len): source = os.path.join(TEST_DATA_DIR, name) clean_dir(source) sfile = os.path.join(source, "blob.bin") with open(sfile, "wb") as f: head = os.urandom(zero_start) tail = os.urandom(total - zero_start - zero_len) f.write(head) f.write(b"\x00" * zero_len) f.write(tail) assert f.tell() == total return source, sfile @pytest.mark.parametrize("flag", ["-S", "--sparse"]) @pytest.mark.parametrize("mt", [False, True]) def test_sparse_transfer_round_trips(self, shared_server, flag, mt): total = 4 * 1024 * 1024 source = os.path.join(TEST_DATA_DIR, f"sparse_mt{mt}_{flag.lstrip('-')}_src") dest = os.path.join(TEST_DATA_DIR, f"sparse_mt{mt}_{flag.lstrip('-')}_dst") clean_dir(source) clean_dir(dest) zero_start = 1 * 1024 * 1024 zero_len = 2 * 1024 * 1024 _, sfile = self._make_sparse_source(os.path.basename(source), total, zero_start, zero_len) with open(sfile, "rb") as f: src_bytes = f.read() flags = [flag] + (["--threads"] if mt else []) result, _ = run_client(source, dest, flags=flags, port=shared_server.port) assert result.returncode == 0, \ f"{flag} transfer failed: {(result.stderr or result.stdout)[:300]}" received = get_dest_received_dir(dest, source) dfile = os.path.join(received, "blob.bin") assert os.path.getsize(dfile) == total, "logical size must match data_size" with open(dfile, "rb") as f: assert f.read() == src_bytes, "sparse destination content must round-trip exactly" # Tolerant sparseness assert: if the filesystem reports holes, the file # must actually be sparse (fewer allocated blocks than its size). with open(dfile, "rb") as f: off = os.lseek(f.fileno(), zero_start, os.SEEK_DATA) if off >= 0: hole = os.lseek(f.fileno(), off, os.SEEK_HOLE) else: hole = -1 if hole > zero_start: st = os.stat(dfile) assert st.st_blocks * 512 < total, \ f"-S file not sparse: {st.st_blocks} blocks for {total} bytes" def test_sparse_inplace(self, shared_server): """--sparse must also preserve holes in the --inplace write path.""" total = 2 * 1024 * 1024 source = os.path.join(TEST_DATA_DIR, "sparse_inplace_src") dest = os.path.join(TEST_DATA_DIR, "sparse_inplace_dst") clean_dir(source) clean_dir(dest) _, sfile = self._make_sparse_source(os.path.basename(source), total, total // 2, total // 4) with open(sfile, "rb") as f: src_bytes = f.read() result, _ = run_client(source, dest, flags=["-S", "--inplace"], port=shared_server.port) assert result.returncode == 0, \ f"-S --inplace failed: {(result.stderr or result.stdout)[:300]}" received = get_dest_received_dir(dest, source) dfile = os.path.join(received, "blob.bin") assert os.path.getsize(dfile) == total with open(dfile, "rb") as f: assert f.read() == src_bytes class TestBlockSize: """--block-size / --delta-block: the checksum block size is genuinely honored by the delta engine (both spellings parse to config->delta_block_size). An end-to-end delta transfer with a non-default block size must still be byte-exact.""" @pytest.mark.parametrize("flag", ["--block-size", "--delta-block"]) def test_non_default_block_size_delta_transfer(self, shared_server, flag): source = os.path.join(TEST_DATA_DIR, "blocksize_delta_src") dest = os.path.join(TEST_DATA_DIR, "blocksize_delta_dst") clean_dir(source) clean_dir(dest) payload = os.urandom(300 * 1024) # enough for several 1 KiB blocks with open(os.path.join(source, "big.bin"), "wb") as f: f.write(payload) # First run installs the file as the destination basis (do NOT wipe it # afterwards: the second run's delta must be computed against it). result, _ = run_client(source, dest, port=shared_server.port) assert result.returncode == 0 received = get_dest_received_dir(dest, source) # Extend the source so it differs from the installed basis: the second # run with --delta must compute a real delta against that basis. with open(os.path.join(source, "big.bin"), "ab") as f: f.write(os.urandom(4096)) result, _ = run_client(source, dest, flags=["--incremental", "--delta", flag, "1024"], port=shared_server.port) assert result.returncode == 0, \ f"{flag} 1024 delta transfer failed: {(result.stderr or result.stdout)[:300]}" with open(os.path.join(received, "big.bin"), "rb") as f: with open(os.path.join(source, "big.bin"), "rb") as expect: assert f.read() == expect.read() class TestOmitTimes: """-O/--omit-dir-times and -J/--omit-link-times are recognized and cross the wire as receiver-side preferences. FastSync does not currently apply dir or symlink times at all, so they are forward-compatible preferences: the run must succeed and normal transfers must not break. A regular file's mtime (from -M) is unaffected by -O/-J.""" @pytest.mark.parametrize("flag", ["-O", "-J"]) @pytest.mark.parametrize("mt", [False, True]) def test_omit_times_accepted(self, shared_server, flag, mt): source = os.path.join(TEST_DATA_DIR, f"omit_{flag.strip('-')}_{'m' if mt else 's'}_src") dest = os.path.join(TEST_DATA_DIR, f"omit_{flag.strip('-')}_{'m' if mt else 's'}_dst") clean_dir(source) clean_dir(dest) with open(os.path.join(source, "a.txt"), "wb") as f: f.write(b"omit times content\n") flags = [flag] + (["--threads"] if mt else []) result, _ = run_client(source, dest, flags=flags, port=shared_server.port) assert result.returncode == 0, \ f"{flag} failed: {(result.stderr or result.stdout)[:300]}" received = get_dest_received_dir(dest, source) mismatches, missing = verify_transfer(source, received) assert not missing, f"Missing: {missing}" assert not mismatches, f"Mismatch: {mismatches}" @pytest.mark.ci def test_omit_times_with_dirs_and_regular_metadata(self, shared_server): source = os.path.join(TEST_DATA_DIR, "omit_dirs_meta_src") dest = os.path.join(TEST_DATA_DIR, "omit_dirs_meta_dst") clean_dir(source) clean_dir(dest) os.makedirs(os.path.join(source, "subdir")) with open(os.path.join(source, "f.txt"), "wb") as f: f.write(b"regular mtime preserved under -O/-J\n") result, _ = run_client(source, dest, flags=["-d", "--omit-dir-times"], port=shared_server.port) assert result.returncode == 0, \ f"-d -O failed: {(result.stderr or result.stdout)[:300]}" result, _ = run_client(source, dest, flags=["--preserve", "-O", "-J"], port=shared_server.port) assert result.returncode == 0, \ f"-M -O -J failed: {(result.stderr or result.stdout)[:300]}" received = get_dest_received_dir(dest, source) mismatches, missing = verify_transfer(source, received) assert not missing and not mismatches, f"missing={missing} mismatches={mismatches}" class TestSymlinkTrust: """Phase-4 symlink trust boundaries: -k/--copy-dirlinks, -K/--keep-dirlinks and --munge-links. Destination paths mirror the absolute source path below the destination root (run_client uses absolute --source-dir/--dest-dir).""" def test_copy_dirlinks_dereferences_dir_symlink(self, shared_server): source = os.path.join(TEST_DATA_DIR, "symlink_trust_copy_dirlinks") dest = os.path.join(TEST_DATA_DIR, "symlink_trust_copy_dirlinks_dst") clean_dir(source) clean_dir(dest) os.makedirs(os.path.join(source, "realdir")) with open(os.path.join(source, "realfile.txt"), "wb") as f: f.write(b"real file\n") with open(os.path.join(source, "realdir", "inside.txt"), "wb") as f: f.write(b"inside dir\n") os.symlink("realfile.txt", os.path.join(source, "link_file")) os.symlink("realdir", os.path.join(source, "link_dir")) # -k only dereferences directory symlinks; file symlinks need -l to be # carried as symlinks (rsync skips them otherwise). result, _ = run_client(source, dest, flags=["-l", "-k"], port=shared_server.port) assert result.returncode == 0, f"-l -k failed: {(result.stderr or result.stdout)[:300]}" received = get_dest_received_dir(dest, source) # link -> realdir dereferences into a real directory tree... link_dir = os.path.join(received, "link_dir") assert os.path.isdir(link_dir) assert not os.path.islink(link_dir) assert os.path.isfile(os.path.join(link_dir, "inside.txt")) # ... while a symlink to a regular file stays a symlink. link_file = os.path.join(received, "link_file") assert os.path.islink(link_file) assert os.readlink(link_file) == "realfile.txt" def test_keep_dirlinks_keeps_dest_symlink_to_dir(self, shared_server): source = os.path.join(TEST_DATA_DIR, "symlink_trust_keep_dirlinks") dest = os.path.join(TEST_DATA_DIR, "symlink_trust_keep_dirlinks_dst") clean_dir(source) clean_dir(dest) os.makedirs(os.path.join(source, "sub")) with open(os.path.join(source, "sub", "file.txt"), "wb") as f: f.write(b"under the kept symlinked dir\n") # Plant the destination's symlink-to-directory at the exact mirror path: # sub -> realdir (relative, both siblings under the mirror parent). parent = os.path.join(dest, os.path.abspath(source).lstrip(os.sep)) os.makedirs(parent) os.makedirs(os.path.join(parent, "realdir")) os.symlink("realdir", os.path.join(parent, "sub")) result, _ = run_client(source, dest, flags=["-K"], port=shared_server.port) assert result.returncode == 0, f"-K failed: {(result.stderr or result.stdout)[:300]}" received = get_dest_received_dir(dest, source) sub = os.path.join(received, "sub") # sub stays a symlink to the directory rather than being replaced... assert os.path.islink(sub) assert os.readlink(sub) == "realdir" # ... and the file is written beneath it, through to the referent dir. assert os.path.isfile(os.path.join(parent, "realdir", "file.txt")) @pytest.mark.ci def test_munge_links_prefixes_targets(self, shared_server): # rsync's --munge-links is a RECEIVER-side rewrite: every stored target # gets the /rsyncd-munged/ prefix, making the link unusable while that # directory does not exist. source = os.path.join(TEST_DATA_DIR, "symlink_munge") dest = os.path.join(TEST_DATA_DIR, "symlink_munge_dst") clean_dir(source) clean_dir(dest) with open(os.path.join(source, "a.txt"), "wb") as f: f.write(b"a\n") os.symlink("a.txt", os.path.join(source, "good")) os.symlink("/etc/passwd", os.path.join(source, "abs_escape")) os.symlink("../../escape", os.path.join(source, "dotdot_escape")) result, _ = run_client(source, dest, flags=["-l", "--munge-links"], port=shared_server.port) assert result.returncode == 0, f"--munge-links failed: {(result.stderr or result.stdout)[:300]}" received = get_dest_received_dir(dest, source) assert os.readlink(os.path.join(received, "good")) == "/rsyncd-munged/a.txt" assert os.readlink(os.path.join(received, "abs_escape")) == "/rsyncd-munged//etc/passwd" assert os.readlink(os.path.join(received, "dotdot_escape")) == "/rsyncd-munged/../../escape" assert os.path.isfile(os.path.join(received, "a.txt")) @pytest.mark.ci def test_links_copies_symlinks_as_symlinks(self, shared_server): source = os.path.join(TEST_DATA_DIR, "symlink_links") dest = os.path.join(TEST_DATA_DIR, "symlink_links_dst") clean_dir(source) clean_dir(dest) os.makedirs(os.path.join(source, "realdir")) with open(os.path.join(source, "realfile.txt"), "wb") as f: f.write(b"real\n") with open(os.path.join(source, "realdir", "x.txt"), "wb") as f: f.write(b"x\n") os.symlink("realfile.txt", os.path.join(source, "lf")) os.symlink("realdir", os.path.join(source, "ld")) result, _ = run_client(source, dest, flags=["-l"], port=shared_server.port) assert result.returncode == 0, f"-l failed: {(result.stderr or result.stdout)[:300]}" received = get_dest_received_dir(dest, source) assert os.path.islink(os.path.join(received, "lf")) assert os.readlink(os.path.join(received, "lf")) == "realfile.txt" assert os.path.islink(os.path.join(received, "ld")) assert os.readlink(os.path.join(received, "ld")) == "realdir" @pytest.mark.ci def test_links_preserves_absolute_and_dotdot_targets(self, shared_server): # rsync -l parity: -l stores a symlink target verbatim, including an # absolute target and an in-tree ".." target (no silent drop). source = os.path.join(TEST_DATA_DIR, "symlink_links_verbatim") dest = os.path.join(TEST_DATA_DIR, "symlink_links_verbatim_dst") clean_dir(source) clean_dir(dest) with open(os.path.join(source, "a.txt"), "wb") as f: f.write(b"a\n") os.makedirs(os.path.join(source, "sub")) os.symlink("a.txt", os.path.join(source, "good")) os.symlink("/etc/passwd", os.path.join(source, "unsafe_abs")) os.symlink("../a.txt", os.path.join(source, "sub", "up")) result, _ = run_client(source, dest, flags=["-l"], port=shared_server.port) assert result.returncode == 0, f"-l failed: {(result.stderr or result.stdout)[:300]}" received = get_dest_received_dir(dest, source) assert os.readlink(os.path.join(received, "good")) == "a.txt" assert os.readlink(os.path.join(received, "unsafe_abs")) == "/etc/passwd" assert os.readlink(os.path.join(received, "sub", "up")) == "../a.txt" @pytest.mark.ci def test_safe_links_keeps_safe_skips_unsafe(self, shared_server): # --safe-links keeps symlinks that stay inside the transfer tree (even # with a ".." that does not climb out) and drops absolute / escaping / # internally-".."-bearing targets. source = os.path.join(TEST_DATA_DIR, "symlink_safe") dest = os.path.join(TEST_DATA_DIR, "symlink_safe_dst") clean_dir(source) clean_dir(dest) with open(os.path.join(source, "a.txt"), "wb") as f: f.write(b"a\n") os.makedirs(os.path.join(source, "sub")) os.symlink("a.txt", os.path.join(source, "safe_rel")) os.symlink("../a.txt", os.path.join(source, "sub", "up")) os.symlink("/etc/passwd", os.path.join(source, "abs")) os.symlink("../outside.txt", os.path.join(source, "esc")) os.symlink("sub/../a.txt", os.path.join(source, "internal")) result, _ = run_client(source, dest, flags=["-l", "--safe-links"], port=shared_server.port) assert result.returncode == 0, f"--safe-links failed: {(result.stderr or result.stdout)[:300]}" received = get_dest_received_dir(dest, source) assert os.readlink(os.path.join(received, "safe_rel")) == "a.txt" assert os.readlink(os.path.join(received, "sub", "up")) == "../a.txt" for unsafe in ("abs", "esc", "internal"): assert not os.path.lexists(os.path.join(received, unsafe)), ( f"{unsafe} must be skipped by --safe-links" ) @pytest.mark.ci def test_safe_links_protects_dest_from_delete(self): # rsync counts an unsafe link ignored by --safe-links as present in the # transfer, so its destination mirror survives --delete. FastSync must # not delete it (no silent data loss). Own server: deletion needs # --allow-delete, which the shared session server does not grant. source = os.path.join(TEST_DATA_DIR, "symlink_safe_delete") dest = os.path.join(TEST_DATA_DIR, "symlink_safe_delete_dst") clean_dir(source) clean_dir(dest) with open(os.path.join(source, "keep.txt"), "wb") as f: f.write(b"keep\n") os.symlink("/etc/passwd", os.path.join(source, "unsafe_abs")) with ServerManager() as server: server.start(extra_args=["--allow-delete"]) received = get_dest_received_dir(dest, source) os.makedirs(received, exist_ok=True) mirror = os.path.join(received, "unsafe_abs") with open(mirror, "wb") as f: f.write(b"existing destination data\n") extra = os.path.join(received, "extra.txt") with open(extra, "wb") as f: f.write(b"extra\n") result, _ = run_client(source, dest, flags=["-l", "--safe-links", "--delete"], port=server.port) assert result.returncode == 0, ( f"--delete --safe-links failed: {(result.stderr or result.stdout)[:300]}" ) assert os.path.exists(mirror), ( "a destination mirror of a --safe-links-skipped link must survive --delete" ) assert not os.path.exists(extra), "a genuine extra must still be deleted" @pytest.mark.ci def test_copy_unsafe_links_derefs_only_unsafe(self, shared_server): # --copy-unsafe-links keeps safe symlinks and dereferences unsafe ones # (absolute or escaping) into regular files. source = os.path.join(TEST_DATA_DIR, "symlink_copy_unsafe") dest = os.path.join(TEST_DATA_DIR, "symlink_copy_unsafe_dst") clean_dir(source) clean_dir(dest) with open(os.path.join(source, "a.txt"), "wb") as f: f.write(b"a\n") with open(os.path.join(source, "refer.txt"), "wb") as f: f.write(b"refer\n") external = os.path.join(TEST_DATA_DIR, "symlink_copy_unsafe_external.txt") with open(external, "wb") as f: f.write(b"external\n") os.symlink("a.txt", os.path.join(source, "safe_rel")) os.symlink("refer.txt", os.path.join(source, "from_rel")) os.symlink("../symlink_copy_unsafe_external.txt", os.path.join(source, "esc")) os.symlink("/etc/hostname", os.path.join(source, "abs")) result, _ = run_client(source, dest, flags=["-l", "--copy-unsafe-links"], port=shared_server.port) assert result.returncode == 0, ( f"--copy-unsafe-links failed: {(result.stderr or result.stdout)[:300]}" ) received = get_dest_received_dir(dest, source) assert os.path.islink(os.path.join(received, "safe_rel")) assert os.readlink(os.path.join(received, "safe_rel")) == "a.txt" assert os.path.islink(os.path.join(received, "from_rel")), ( "a safe symlink must be preserved, not dereferenced" ) assert os.readlink(os.path.join(received, "from_rel")) == "refer.txt" # An escaping (..) symlink and an absolute symlink are both dereferenced # into regular files holding the referent's content. assert not os.path.islink(os.path.join(received, "esc")) with open(os.path.join(received, "esc"), "rb") as f: assert f.read() == b"external\n" assert not os.path.islink(os.path.join(received, "abs")) assert os.path.isfile(os.path.join(received, "abs")) def test_munge_prefix_roundtrip(self, shared_server): # A source target that already begins with /rsyncd-munged/ round-trips: # plain -l stores it verbatim, and --munge-links strips on the sender # then re-munges on the receiver, yielding the same stored value. source = os.path.join(TEST_DATA_DIR, "symlink_munge_roundtrip") dest = os.path.join(TEST_DATA_DIR, "symlink_munge_roundtrip_dst") clean_dir(source) clean_dir(dest) with open(os.path.join(source, "realfile.txt"), "wb") as f: f.write(b"real\n") os.symlink("/rsyncd-munged/realfile.txt", os.path.join(source, "prefixed")) os.symlink("#SYMLINK/realfile.txt", os.path.join(source, "oldmarker")) for flags, oldmarker_target in ( (["-l"], "#SYMLINK/realfile.txt"), (["-l", "--munge-links"], "/rsyncd-munged/#SYMLINK/realfile.txt"), ): clean_dir(dest) result, _ = run_client(source, dest, flags=flags, port=shared_server.port) assert result.returncode == 0, ( f"{' '.join(flags)} failed: {(result.stderr or result.stdout)[:300]}" ) received = get_dest_received_dir(dest, source) assert os.readlink(os.path.join(received, "prefixed")) == "/rsyncd-munged/realfile.txt" assert os.readlink(os.path.join(received, "oldmarker")) == oldmarker_target def _xattr_supported(path): """True when the filesystem hosting `path` supports user xattrs.""" try: os.setxattr(path, "user.fastsync-probe", b"p") os.removexattr(path, "user.fastsync-probe") return True except (OSError, AttributeError): return False class TestExtendedAttributes: """-X/--xattrs, -A/--acls, --fake-super: portable extended metadata. Runs unprivileged (CI is non-root). Everything is best-effort and guarded: a filesystem without xattr support, or an ACL toolchain/POSIX-ACL filesystem feature that is missing, is skipped rather than failed. The security boundary (only user.* and the system.posix_acl_* namespaces are ever applied) is asserted alongside the happy path.""" def _source_and_dest(self, name): source = os.path.join(TEST_DATA_DIR, name + "_src") dest = os.path.join(TEST_DATA_DIR, name + "_dst") clean_dir(source) clean_dir(dest) return source, dest @pytest.mark.ci def test_xattrs_preserves_user_namespace(self, shared_server): source, dest = self._source_and_dest("xattr") f = os.path.join(source, "data.txt") with open(f, "wb") as fh: fh.write(b"xattr payload\n") if not _xattr_supported(f): pytest.skip("filesystem does not support user xattrs") os.setxattr(f, "user.foo", b"preserved-value") result, _ = run_client(source, dest, flags=["-X"], port=shared_server.port) assert result.returncode == 0, \ f"-X sync failed: {(result.stderr or result.stdout)[:300]}" received = get_dest_received_dir(dest, source) assert os.getxattr(os.path.join(received, "data.txt"), "user.foo") == b"preserved-value" def test_without_xattrs_does_not_carry(self, shared_server): source, dest = self._source_and_dest("xattr_ctrl") f = os.path.join(source, "data.txt") with open(f, "wb") as fh: fh.write(b"plain\n") if not _xattr_supported(f): pytest.skip("filesystem does not support user xattrs") os.setxattr(f, "user.foo", b"must-not-travel") result, _ = run_client(source, dest, port=shared_server.port) assert result.returncode == 0, \ f"control sync failed: {(result.stderr or result.stdout)[:300]}" received = get_dest_received_dir(dest, source) with pytest.raises(OSError): os.getxattr(os.path.join(received, "data.txt"), "user.foo") def test_reserved_fake_super_key_not_forwarded(self, shared_server): """A source file that already carries the reserved user.fastsync.stat record must NOT have it planted on the receiver during a plain -X run (it is receiver-only, so it cannot be spoofed for a later privileged restore).""" source, dest = self._source_and_dest("xattr_reserved") f = os.path.join(source, "data.txt") with open(f, "wb") as fh: fh.write(b"reserved\n") if not _xattr_supported(f): pytest.skip("filesystem does not support user xattrs") os.setxattr(f, "user.fastsync.stat", b"0:0:644:0:0") # A normal user.* attr still travels alongside. os.setxattr(f, "user.keep", b"yes") result, _ = run_client(source, dest, flags=["-X"], port=shared_server.port) assert result.returncode == 0, \ f"-X reserved-key sync failed: {(result.stderr or result.stdout)[:300]}" received = get_dest_received_dir(dest, source) assert os.getxattr(os.path.join(received, "data.txt"), "user.keep") == b"yes" with pytest.raises(OSError): os.getxattr(os.path.join(received, "data.txt"), "user.fastsync.stat") @pytest.mark.ci def test_xattrs_multithreaded(self, shared_server): source, dest = self._source_and_dest("xattr_mt") f = os.path.join(source, "data.txt") with open(f, "wb") as fh: fh.write(b"mt xattr\n") if not _xattr_supported(f): pytest.skip("filesystem does not support user xattrs") os.setxattr(f, "user.k", b"v") result, _ = run_client(source, dest, flags=["-X", "--threads"], port=shared_server.port) assert result.returncode == 0, \ f"-X -m sync failed: {(result.stderr or result.stdout)[:300]}" received = get_dest_received_dir(dest, source) assert os.getxattr(os.path.join(received, "data.txt"), "user.k") == b"v" @pytest.mark.ci def test_acls_via_posix_acl_xattr(self, shared_server): source, dest = self._source_and_dest("acl") f = os.path.join(source, "data.txt") with open(f, "wb") as fh: fh.write(b"acl payload\n") if not _xattr_supported(f): pytest.skip("filesystem does not support xattrs") acl_blob = None if shutil.which("setfacl") is not None: acl = subprocess.run(["setfacl", "-m", "o::r", f], capture_output=True, text=True) if acl.returncode == 0: try: acl_blob = os.getxattr(f, "system.posix_acl_access") except OSError: acl_blob = None if acl_blob is None: # No setfacl (common in the minimal CI image): synthesize a valid # non-trivial POSIX ACL ("u:current-uid:r") xattr blob directly. import struct try: uid_for_acl = os.geteuid() if os.geteuid() != 0 else 65534 struct_entry = struct.pack(" 2: pytest.skip("filesystem does not preserve directory mtimes") return source, dest, ("", "sub", os.path.join("sub", "deep")) def _link_tree(self, name): source = os.path.join(TEST_DATA_DIR, name + "_src") dest = os.path.join(TEST_DATA_DIR, name + "_dst") clean_dir(source) clean_dir(dest) os.makedirs(os.path.join(source, "sub"), exist_ok=True) with open(os.path.join(source, "sub", "file.txt"), "wb") as fh: fh.write(b"target\n") link = os.path.join(source, "sub", "link") # A same-directory relative target (no ".."): FastSync refuses an # escaping/ambiguous symlink target, and ".." is a deliberate divergence. os.symlink("file.txt", link) os.utime(link, (DISTINCT_MTIME, DISTINCT_MTIME), follow_symlinks=False) if abs(os.lstat(link).st_mtime - DISTINCT_MTIME) > 2: pytest.skip("filesystem does not preserve symlink mtimes") return source, dest, os.path.join("sub", "link") def _run(self, source, dest, flags, shared_server): result, _ = run_client(source, dest, flags=flags, port=shared_server.port) assert result.returncode == 0, \ f"{flags} failed: {(result.stderr or result.stdout)[:400]}" return get_dest_received_dir(dest, source) @pytest.mark.ci @pytest.mark.parametrize("mt", [False, True]) def test_directory_mtime_round_trip(self, shared_server, mt): source, dest, rels = self._tree("dirtime") flags = ["-a"] + (["--threads"] if mt else []) received = self._run(source, dest, flags, shared_server) for rel in rels: src_m = os.stat(os.path.join(source, rel)).st_mtime dst_m = os.stat(os.path.join(received, rel)).st_mtime assert abs(dst_m - src_m) < 2, \ f"dir '{rel}': source={src_m} dest={dst_m} (flags={flags})" @pytest.mark.ci @pytest.mark.parametrize("mt", [False, True]) def test_omit_dir_times_suppresses_only_dirs(self, shared_server, mt): source, dest, rels = self._tree("omitdir") flags = ["-a", "-O"] + (["--threads"] if mt else []) received = self._run(source, dest, flags, shared_server) for rel in rels: dst_m = os.stat(os.path.join(received, rel)).st_mtime assert abs(dst_m - DISTINCT_MTIME) > 5, \ f"-O must not apply directory times ('{rel}' got {dst_m})" @pytest.mark.ci @pytest.mark.parametrize("mt", [False, True]) def test_symlink_mtime_round_trip(self, shared_server, mt): source, dest, rel = self._link_tree("linktime") flags = ["-a"] + (["--threads"] if mt else []) received = self._run(source, dest, flags, shared_server) src_link = os.path.join(source, rel) dst_link = os.path.join(received, rel) assert os.path.islink(dst_link), f"{dst_link} is not a symlink" src_m = os.lstat(src_link).st_mtime dst_m = os.lstat(dst_link).st_mtime assert abs(dst_m - src_m) < 2, f"symlink times: source={src_m} dest={dst_m}" @pytest.mark.ci @pytest.mark.parametrize("mt", [False, True]) def test_omit_link_times_suppresses_only_links(self, shared_server, mt): source, dest, rel = self._link_tree("omitlink") flags = ["-a", "-J"] + (["--threads"] if mt else []) received = self._run(source, dest, flags, shared_server) dst_link = os.path.join(received, rel) assert os.path.islink(dst_link), f"{dst_link} is not a symlink" dst_m = os.lstat(dst_link).st_mtime assert abs(dst_m - DISTINCT_MTIME) > 5, \ f"-J must not apply symlink times (got {dst_m})" @pytest.mark.ci def test_omit_flags_are_independent(self, shared_server): """-O suppresses only directory times and -J only symlink times: with -O the symlink time is still preserved, and with -J the dir times are.""" source, dest, rel = self._link_tree("omitindep") # Add a subdirectory mtime to check alongside the symlink. sub = os.path.join(source, "sub") os.utime(sub, (DISTINCT_MTIME, DISTINCT_MTIME)) # -O => dir times omitted, symlink time preserved. clean_dir(dest + "_o") recv_o = self._run(source, dest + "_o", ["-a", "-O"], shared_server) assert abs(os.lstat(os.path.join(recv_o, rel)).st_mtime - DISTINCT_MTIME) < 2, \ "-O must not suppress symlink times" assert abs(os.stat(os.path.join(recv_o, "sub")).st_mtime - DISTINCT_MTIME) > 5, \ "-O must suppress directory times" # -J => symlink times omitted, dir times preserved. clean_dir(dest + "_j") recv_j = self._run(source, dest + "_j", ["-a", "-J"], shared_server) assert abs(os.lstat(os.path.join(recv_j, rel)).st_mtime - DISTINCT_MTIME) > 5, \ "-J must suppress symlink times" assert abs(os.stat(os.path.join(recv_j, "sub")).st_mtime - DISTINCT_MTIME) < 2, \ "-J must not suppress directory times" @pytest.mark.ci @pytest.mark.parametrize("mt", [False, True]) def test_preserve_creates_empty_source_dir(self, shared_server, mt): """rsync parity: a recursive `-a` transfer recreates an empty source directory at the destination (the scanner emits it as an explicit directory entry).""" source = os.path.join(TEST_DATA_DIR, f"empty_dir_{'m' if mt else 's'}_src") dest = os.path.join(TEST_DATA_DIR, f"empty_dir_{'m' if mt else 's'}_dst") clean_dir(source) clean_dir(dest) with open(os.path.join(source, "keep.txt"), "wb") as fh: fh.write(b"regular file\n") os.makedirs(os.path.join(source, "empty_sub")) flags = ["-a"] + (["--threads"] if mt else []) received = self._run(source, dest, flags, shared_server) assert os.path.isfile(os.path.join(received, "keep.txt")), "regular file missing" assert os.path.isdir(os.path.join(received, "empty_sub")), \ f"-a did not recreate the empty source directory at {received}/empty_sub" @pytest.mark.ci @pytest.mark.parametrize("mt", [False, True]) def test_prune_empty_dirs_still_does_not_create_empty_dir(self, shared_server, mt): """P7 Wave D #1: `-a -m` (--prune-empty-dirs) keeps its semantics -- a captured empty directory is never created even though its time is recorded.""" source = os.path.join(TEST_DATA_DIR, f"prune_empty_{'m' if mt else 's'}_src") dest = os.path.join(TEST_DATA_DIR, f"prune_empty_{'m' if mt else 's'}_dst") clean_dir(source) clean_dir(dest) with open(os.path.join(source, "keep.txt"), "wb") as fh: fh.write(b"regular file\n") os.makedirs(os.path.join(source, "empty_sub")) flags = ["-a", "-m"] + (["--threads"] if mt else []) received = self._run(source, dest, flags, shared_server) assert os.path.isfile(os.path.join(received, "keep.txt")), "regular file missing" assert not os.path.lexists(os.path.join(received, "empty_sub")), \ f"-a -m created an empty source directory at {received}/empty_sub" @pytest.mark.ci @pytest.mark.parametrize("mt", [False, True]) def test_collision_at_empty_dir_path_replaces_blocker(self, shared_server, mt): """rsync parity: a pre-existing regular file at a source empty-dir's mirror path is replaced by the incoming directory (rsync removes the non-directory and creates the directory); the run succeeds.""" source = os.path.join(TEST_DATA_DIR, f"dirtime_collide_{'m' if mt else 's'}_src") dest = os.path.join(TEST_DATA_DIR, f"dirtime_collide_{'m' if mt else 's'}_dst") clean_dir(source) clean_dir(dest) with open(os.path.join(source, "keep.txt"), "wb") as fh: fh.write(b"regular file\n") os.makedirs(os.path.join(source, "collide")) # Plant a regular file at exactly the mirror path of source/collide. received = get_dest_received_dir(dest, source) os.makedirs(received, exist_ok=True) blocker = os.path.join(received, "collide") with open(blocker, "wb") as fh: fh.write(b"pre-existing blocker\n") flags = ["-a"] + (["--threads"] if mt else []) result, _ = run_client(source, dest, flags=flags, port=shared_server.port) assert result.returncode == 0, \ f"-a aborted on a pre-existing file at an empty-dir path: " \ f"{(result.stderr or result.stdout)[:400]}" assert os.path.isdir(blocker) and not os.path.islink(blocker), \ "the pre-existing blocker was not replaced by the incoming directory" assert os.path.isfile(os.path.join(received, "keep.txt")), "regular file missing" class TestCopyAs: """P7 Wave E: --copy-as=USER[:GROUP] safe subset. FastSync never switches the receiver's process credentials; the receiver forces the ownership of every entry it writes to the requested ids through the confined fd-relative identity path, which REQUIRES a privileged (root) receiver. An unprivileged receiver refuses the whole transfer up front at the config handshake, before any file data moves. """ @pytest.mark.ci def test_unprivileged_receiver_refuses_copy_as(self, shared_server): """The key assertable behavior: an unprivileged receiver REFUSES a --copy-as transfer cleanly (non-zero exit, no data written) instead of silently writing the wrong ownership.""" source = os.path.join(TEST_DATA_DIR, "copyas_refuse_src") dest = os.path.join(TEST_DATA_DIR, "copyas_refuse_dst") clean_dir(source) clean_dir(dest) with open(os.path.join(source, "secret.txt"), "wb") as fh: fh.write(b"must not be written\n") captured = None if os.geteuid() == 0: if shutil.which("setpriv") is None: pytest.skip("root runner without setpriv cannot start an unprivileged receiver") # The unprivileged receiver must execute the server binary out of the # test workspace, so the workspace path has to be traversable by uid # 65534. A checkout under a 0700 directory (e.g. /root) is not; skip # rather than fail — CI runs from a traversable workspace and still # exercises this behavior. probe = subprocess.run( ["setpriv", "--reuid=65534", "--regid=65534", "--clear-groups", "test", "-x", os.path.abspath(SERVER_CMD[0])], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) if probe.returncode != 0: pytest.skip("workspace is not traversable by the unprivileged receiver uid") os.chmod(dest, 0o777) proc, port = _start_captured_server( prefix=["setpriv", "--reuid=65534", "--regid=65534", "--clear-groups"]) captured = proc else: # The session server already runs unprivileged. port = shared_server.port try: result, _ = run_client(source, dest, flags=["--copy-as=@65534:@65534"], port=port) finally: if captured is not None: out, err = _stop_captured_server(captured) else: out, err = "", "" assert result.returncode != 0, ( f"an unprivileged receiver must refuse --copy-as: rc={result.returncode} " f"out={result.stdout[:200]!r} err={result.stderr[:200]!r}" ) received = get_dest_received_dir(dest, source) assert not os.path.exists(os.path.join(received, "secret.txt")), ( "--copy-as refusal leaked file data into the destination" ) if captured is not None: assert "copy-as requires a privileged receiver" in (out + err), ( f"refusal reason was not logged: out={out!r} err={err!r}" ) @pytest.mark.ci @pytest.mark.skipif(os.geteuid() != 0, reason="requires a root receiver to chown") def test_root_copy_as_chowns_transferred_file(self, shared_server): """Root-gated: --copy-as=USER:GROUP forces the transferred file's ownership to exactly that uid/gid (numeric form for determinism).""" source = os.path.join(TEST_DATA_DIR, "copyas_root_src") dest = os.path.join(TEST_DATA_DIR, "copyas_root_dst") clean_dir(source) clean_dir(dest) with open(os.path.join(source, "owned.txt"), "wb") as fh: fh.write(b"owned by nobody\n") result, _ = run_client(source, dest, flags=["--copy-as=@65534:@65534"], port=shared_server.port) assert result.returncode == 0, ( f"--copy-as root transfer failed: {(result.stderr or result.stdout)[:400]}" ) received = get_dest_received_dir(dest, source) target = os.path.join(received, "owned.txt") assert os.path.isfile(target), f"transferred file missing at {target}" st = os.lstat(target) assert (st.st_uid, st.st_gid) == (65534, 65534), ( f"--copy-as did not force ownership: uid={st.st_uid} gid={st.st_gid}" ) @pytest.mark.ci @pytest.mark.skipif(os.geteuid() != 0, reason="requires a root receiver to chown") def test_root_copy_as_owns_directory(self, shared_server): """--copy-as must own an explicitly-created directory entry, not just the files inside it. A listed directory (--files-from + --dirs -R) is sent as a STATUS_MKDIR entry, exercising the directory ownership path.""" source = os.path.join(TEST_DATA_DIR, "copyas_dir_src") dest = os.path.join(TEST_DATA_DIR, "copyas_dir_dst") clean_dir(source) clean_dir(dest) os.makedirs(os.path.join(source, "owned_dir"), exist_ok=True) lst = os.path.join(TEST_DATA_DIR, "copyas_dir_list.txt") with open(lst, "wb") as fh: fh.write(b"owned_dir\n") result, _ = run_client( source, dest, flags=["--copy-as=@65534:@65534", "--files-from", lst, "--dirs", "-R"], port=shared_server.port) assert result.returncode == 0, ( f"--copy-as directory transfer failed: {(result.stderr or result.stdout)[:400]}" ) target = os.path.join(dest, "owned_dir") assert os.path.isdir(target), f"explicit directory missing at {target}" st = os.stat(target) assert (st.st_uid, st.st_gid) == (65534, 65534), ( f"--copy-as did not own the directory: uid={st.st_uid} gid={st.st_gid}" ) @pytest.mark.ci @pytest.mark.skipif(os.geteuid() != 0, reason="requires a root receiver to chown") def test_root_copy_as_owns_implicit_parent_dirs(self, shared_server): """--copy-as must also own the intermediate directories that the receiver creates implicitly while writing a nested file (the scanner does not emit STATUS_MKDIR entries for ordinary traversal directories), not just the file itself.""" source = os.path.join(TEST_DATA_DIR, "copyas_nested_src") dest = os.path.join(TEST_DATA_DIR, "copyas_nested_dst") clean_dir(source) clean_dir(dest) nested = os.path.join(source, "top", "mid", "leaf") os.makedirs(nested, exist_ok=True) with open(os.path.join(nested, "deep.txt"), "wb") as fh: fh.write(b"nested copy-as ownership\n") result, _ = run_client(source, dest, flags=["--copy-as=@65534:@65534"], port=shared_server.port) assert result.returncode == 0, ( f"--copy-as nested transfer failed: {(result.stderr or result.stdout)[:400]}" ) received = get_dest_received_dir(dest, source) for rel in ("top", os.path.join("top", "mid"), os.path.join("top", "mid", "leaf")): target = os.path.join(received, rel) assert os.path.isdir(target), f"implicit directory missing at {target}" st = os.stat(target) assert (st.st_uid, st.st_gid) == (65534, 65534), ( f"--copy-as did not own implicit directory {rel}: " f"uid={st.st_uid} gid={st.st_gid}" ) @pytest.mark.ci @pytest.mark.skipif(os.geteuid() != 0, reason="requires a root receiver to chown") def test_root_copy_as_owns_fifo(self, shared_server): """--copy-as must own a recreated FIFO special node.""" source = os.path.join(TEST_DATA_DIR, "copyas_fifo_src") dest = os.path.join(TEST_DATA_DIR, "copyas_fifo_dst") clean_dir(source) clean_dir(dest) os.mkfifo(os.path.join(source, "pipe.fifo")) result, _ = run_client(source, dest, flags=["--copy-as=@65534:@65534", "--specials"], port=shared_server.port) assert result.returncode == 0, ( f"--copy-as FIFO transfer failed: {(result.stderr or result.stdout)[:400]}" ) received = get_dest_received_dir(dest, source) target = os.path.join(received, "pipe.fifo") assert stat.S_ISFIFO(os.lstat(target).st_mode), f"FIFO missing at {target}" st = os.lstat(target) assert (st.st_uid, st.st_gid) == (65534, 65534), ( f"--copy-as did not own the FIFO: uid={st.st_uid} gid={st.st_gid}" ) @pytest.mark.ci @pytest.mark.skipif(os.geteuid() != 0, reason="requires a root receiver to chown") def test_root_copy_as_with_fake_super_keeps_target_owner(self, shared_server): """#294: --fake-super records the RESOLVED copy-as ownership without real-chowning; the recorded source owner can never override copy-as.""" source = os.path.join(TEST_DATA_DIR, "copyas_fakesuper_src") dest = os.path.join(TEST_DATA_DIR, "copyas_fakesuper_dst") clean_dir(source) clean_dir(dest) src_file = os.path.join(source, "mixed.txt") with open(src_file, "wb") as fh: fh.write(b"copy-as wins over fake-super\n") if not _xattr_supported(src_file): pytest.skip("filesystem does not support user xattrs") os.chown(src_file, 12345, 12346) result, _ = run_client(source, dest, flags=["--copy-as=@65534:@65534", "--fake-super"], port=shared_server.port) assert result.returncode == 0, ( f"--copy-as --fake-super transfer failed: " f"{(result.stderr or result.stdout)[:400]}" ) received = get_dest_received_dir(dest, source) dst = os.path.join(received, "mixed.txt") record = os.getxattr(dst, "user.fastsync.stat").decode().split(":") assert (record[0], record[1]) == ("65534", "65534"), ( f"fake-super must record the resolved copy-as ownership: {record[:2]}" ) st = os.lstat(dst) assert (st.st_uid, st.st_gid) != (12345, 12346), ( f"--fake-super must not real-chown the recorded source owner: " f"uid={st.st_uid} gid={st.st_gid}" )