Three receiver security fixes from the audit: 1. --temp-dir symlink escape (High): file_open_temp_dir() opened the client-controlled scratch dir with a bare open(), so a symlink planted under the receive root let a peer redirect receiver scratch files outside the authorized root. The opened dir is now judged by the REAL path of its fd (via /proc/self/fd), and any target outside the authorized receive root is refused with a logged error (EACCES). An in-root symlink (the EXDEV cross-filesystem fallback case) still works, and the no-root local batch path is unchanged. 2. setuid/setgid/sticky under SUPER_MODE_OFF (High): the special bits were applied under --perms (and via --chmod) even when the connection forbade super-user activities. FileAttrPolicy gains super_permitted, set by file_attr_policy_from_config() from privilege_super_mode_permitted(); metadata_mode_for_policy(), the symlink path, the special-node creation path, and the deferred directory-mode apply now strip the special bits when it is false. Exact rsync semantics are preserved when permitted. 3. daemon umask (Low): daemonize() forced umask(0), so implied parent directories created without -p were world-writable 0777. Set the conventional daemon umask 022 instead (rsync never forces 0); -p/-a mode preservation is unaffected because it restores modes via fchmod. Tests: new unit tests for file_open_temp_dir confinement and the masked/unmasked special-bit policy (incl. the --chmod path), a daemon world-writable-dir regression test, an integration escape test, and a root-only integration test asserting special bits are masked without --allow-super. The old cross-filesystem test encoded the vulnerable behavior (symlink target outside the root) and is replaced by the escape test; the EXDEV fallback code is retained for in-root links.
Integration tests
The integration suite drives the built build/server and build/client
against local corpora. Unit tests live in tests/ (the custom C framework);
the Python suite here covers the full transfer pipeline, transports, features,
and rsync parity.
Running
# Full suite (excludes privilege-dependent tests on CI runners)
python3 -m pytest tests/integration/ -n 4 --dist=load -m "not setpriv"
# Fast PR subset only
python3 -m pytest tests/integration/ -n 4 --dist=load -m ci
The tests expect build/server and build/client (configure/build with CMake
first); common.py derives BUILD_DIR from the repository root.
Differential rsync-parity gate
test_differential_parity.py runs the same transfer with real
rsync 3.4.1 and with FastSync over separate destinations, then compares:
- the destination trees — relative paths, file content hashes, symlink targets, modes (where the case is about perms), and hard-link grouping;
- the normalized stdout for output-oriented flags (
-i,--out-format=...,--stats), after stripping volatile fields (timings, rates, wire byte counts) and directory-only itemize lines that FastSync's recursive scanner documents as absent.
FastSync mirrors the absolute source path under its receive root (see
get_dest_received_dir); the harness normalizes that layout (and the
-R/--files-from layouts) before comparing.
# Fast subset that guards the ✅ surface on pull requests
python3 -m pytest tests/integration/test_differential_parity.py -n 4 --dist=load -m parity_ci
# Full differential case table (`_CASES`): every row is marked `parity`, and a
# case with an allowlisted residual in `parity_caveats.py` is included too.
python3 -m pytest tests/integration/test_differential_parity.py -n 4 --dist=load -m parity
-m parity selects only the _CASES table in this module. Differential
coverage for options outside that table (--temp-dir, --delay-updates,
--dry-run, --fuzzy, the basis-dir options, -M over daemon/TCP, and
receiver filter-protect) lives in dedicated modules (test_option_parity.py,
test_parity_blockers.py, test_parity_quickwins.py, ...) and is not part of
this gate. The suite skips cleanly when rsync is not installed.
Allowlist (parity_caveats.py)
parity_caveats.py is the single data-driven allowlist of known differences.
Each entry maps a case id to the aspects that may differ (tree, stdout,
extra, rc) and cites the governing row in RSYNC_COMPAT.md:
CAVEATS = {
# no known residuals at present -- the burn-down reached zero
# "some_case_id": {"tree": "documented residual ... ref: RSYNC_COMPAT.md ..."},
}
A differential mismatch in an aspect that is not listed fails the gate with a readable tree/stdout diff.
If a case is allowlisted but now matches rsync, the gate emits a loud warning
naming the stale entry — that is the parity burn-down signal. Run with
FASTSYNC_PARITY_STRICT=1 to make stale entries fail instead (the full CI
parity job sets this). To add a residual:
- Reproduce it with
-m parityand read the failure's tree/stdout diff. - Confirm it is a documented
⚠️/❌residual (or get the✅row reclassified) and cite the row. - Add the case id and aspect(s) to
CAVEATS, keeping the reason concise.
Do not allowlist an undocumented divergence from a ✅ row — fix it or get the
row reclassified first.