feat(p7-times): real directory/symlink time preservation; -O/-J meaningful

Wave D of Phase 7. Make -O/--omit-dir-times and -J/--omit-link-times real by
preserving directory and symlink times, and mark --secluded-args as an explicit
Impossible/Divergence no-op.

Wire: PROTOCOL_VERSION 2.16.0 -> 2.17.0. Adds a terminal STATUS_DIR_TIMES frame
(int count + (wire path, metadata) pairs) sent after all file data and the
optional delete manifest. STATUS_MKDIR also carries metadata for --dirs entries.
Config-frame layout is unchanged.

Sender: the recursive scanner captures every traversed source directory (both
DirectoryScanner and the parallel scanner root + workers, appends mutex-guarded)
into a shared list; the single-threaded and -m paths transmit it last.

Receiver: a DirTimeList accumulates received directory metadata and applies it
with fd-relative no-follow utimensat only at the very end -- after all children,
after the commit-style --delete, and after --delay-updates publication -- in the
single-threaded success frame and in server.c after the -m threads join. -O skips
the application. Symlink metadata is applied at link creation with
utimensat/fchownat/fchmodat AT_SYMLINK_NOFOLLOW; -J suppresses only link times.
identity_apply_ownership_link shares the identity resolver with the fd path.

Docs: -O/-J rows -> Implemented; --secluded-args -> Impossible/Divergence;
--protocol accepted/rejected values and Phase-6/7 notes updated.

Tests: unit (scanner dir capture, DirTimeList apply, symlink metadata, protocol
version values) and integration (dir mtime round-trip + -O, symlink mtime
round-trip + -J, independent suppression), parameterized over single/multithread.
This commit is contained in:
2026-09-12 10:31:20 +02:00
parent f34eb34f87
commit f1a447bb4a
23 changed files with 857 additions and 75 deletions
+25 -17
View File
@@ -254,8 +254,8 @@ why plain `--append` works on the normal atomic path, not only with `--inplace`.
| `--write-devices` | Write to devices as files | ⚠️ Partial | Write the received data directly into an **existing** device node on the destination instead of creating a regular file. Restricted and best-effort: the destination must already exist and be a char/block device (opened only under the confined receive root, with `O_NOFOLLOW` + `O_NONBLOCK`); a missing, symlinked, FIFO-with-no-reader (`ENXIO`), non-device destination, or any write failure is **skipped with a warning** rather than allowed, so a run can never clobber the system, never blocks on a special-file target, and never aborts on an unusable target. See the Phase-4 devices notes |
| `-U`, `--atimes` | Preserve access times | ✅ Implemented | Captures the source access time (from the scanner's pre-read stat, so it is not clobbered by reading the file for transfer) and transmits it over the wire; the receiver restores it together with the mtime via `futimens`/`utimensat`. Implies metadata transmission (the times travel inside the `-M` metadata payload), but does not enable ownership application (that stays opt-in via the identity flags). Wire: new `atime` fields on the metadata frame + a `preserve_atimes` config boolean; `PROTOCOL_VERSION` bumped **2.11.0 → 2.12.0** |
| `-N`, `--crtimes` | Preserve create times | ⚠️ Partial | Captures the source birth time via `statx(STATX_BTIME)` on Linux and transmits it (recorded as a wire field), but there is **no portable way to set a birth time** (`utimensat` can only set atime/mtime), so the receiver explicitly does NOT apply it: it logs a debug note and continues — never failing the transfer and never pretending it worked. On platforms without `statx` it parses as a documented no-op (flag accepted; nothing is captured). Implies metadata transmission. Wire: new `crtime` fields + a `preserve_crtimes` config boolean; `PROTOCOL_VERSION` bumped **2.11.0 → 2.12.0** (see the Phase-4 metadata-time notes) |
| `-O`, `--omit-dir-times` | Omit dirs from --times | 🔄 Compatibility No-op | Accepted and parsed for CLI compatibility, and the config boolean crosses the wire, but it has **no effect**: FastSync never preserves directory mtimes in the first place (directories are created via `mkdir` with no metadata, a documented divergence under `-d`/recursive), so there is nothing for an "omit" to suppress. It never breaks a normal run |
| `-J`, `--omit-link-times` | Omit symlinks from --times | 🔄 Compatibility No-op | Accepted and parsed for CLI compatibility, and the config boolean crosses the wire, but it has **no effect**: FastSync never sets symlink times (`-l`/`--links` copies symlinks as symlinks but the receiver does not apply timestamps/owner to symlink entries), so there is nothing for an "omit" to suppress. It never breaks a normal run |
| `-O`, `--omit-dir-times` | Omit dirs from --times | ✅ Implemented | Real modifier now that FastSync preserves directory times. With metadata on, the scanner captures every traversed source directory's mtime (and atime under `-U`) and the sender transmits them in one terminal `STATUS_DIR_TIMES` frame **after all file data and the optional delete manifest**; the receiver defers applying them until its delete / `--delay-updates` publication phases have committed, so writing or removing a child never clobbers a parent directory's mtime (rsync applies directory times at the end for exactly this reason). When `-O` is set (the boolean crosses the wire) the receiver does not apply any of them; without `-O` an `-a`/`-M` transfer now restores directory times (reversing the old "never preserves dir times" divergence). Wire change: the terminal `STATUS_DIR_TIMES` frame; `PROTOCOL_VERSION` bumped **2.16.0 → 2.17.0** |
| `-J`, `--omit-link-times` | Omit symlinks from --times | ✅ Implemented | Real modifier now that FastSync preserves symlink times. Symlink entries already carried their metadata on `STATUS_SYMLINK`; the receiver now applies it with **no-follow primitives only** (`utimensat(..., AT_SYMLINK_NOFOLLOW)`, plus best-effort `fchmodat(..., AT_SYMLINK_NOFOLLOW)` and policy-gated `fchownat(..., AT_SYMLINK_NOFOLLOW)`), so the link itself is stamped without ever dereferencing it, confined fd-relative below the authorized receive root. A symlink has no children, so the times are applied immediately at creation. When `-J` is set (the boolean crosses the wire) the receiver skips the timestamps (mode/ownership are unaffected); without `-J` an `-a`/`-l` transfer restores symlink mtimes. Wire change alongside `-O`: the shared `STATUS_DIR_TIMES` frame; `PROTOCOL_VERSION` bumped **2.16.0 → 2.17.0** |
| `--super` | Receiver attempts super-user activities | ❌ Not Implemented | |
| `--fake-super` | Store/recover privileged attrs via xattrs | ⚠️ Partial | Honest, limited subset. The receiver records the source `uid:gid:mode:mtime_sec:mtime_nsec` into a reserved `user.fastsync.stat` xattr on each written file (best-effort, fd-relative), so a later privileged restore could re-apply them — without attempting the (typically failing as non-root) `chown`. Full rsync fake-super **replay** (parsing that xattr to actually re-apply ownership on a later privileged run) is out of scope and is **divergent** from rsync, which uses its own `user.rsync.%stat%` format; no cross-tool conversion is attempted. Implies metadata transmission so the source uid/gid/mode/mtime are available. Both it and `-X`/`-A` are incompatible with `-s` (chunk serialization), rejected up front |
| `--open-noatime` | Avoid changing access time when opening files | ✅ Implemented | Sender-side policy: the sender opens source files with `O_NOATIME` (Linux) when reading them for transfer, so the open/read does NOT bump the source's on-disk access time. Degrades safely when `O_NOATIME` is unavailable (not defined) or refused (`EPERM`, since it needs `CAP_FOWNER` or file ownership): the code falls back to a normal open, so the data always transfers — only the atime-bump is skipped. It does not itself capture/preserve atime; it only avoids modifying it. **Client-only, never crosses the wire.** Exposed as `file_open_for_read()` and applied to both the buffered data path and the sendfile path |
@@ -348,14 +348,15 @@ fails the transfer and never pretends the crtime was applied. This is the
explicit, documented unsupported-attribute handling. On platforms without
`statx` the flag is accepted but nothing is captured (a documented no-op).
**omit-dir-times / omit-link-times:** `-O` and `-J` are **accepted and parsed
for CLI compatibility** and their config booleans cross the wire, but they are
genuine **no-ops**: FastSync does not apply directory or symlink times at all
(directories are made via `mkdir` with no metadata; symlinks are dereferenced
or skipped, never written with a target), so there is nothing for an "omit" to
suppress. They never break a normal run. This is documented as a
divergence — the flags recognize the rsync interface but have no filtering
effect in FastSync.
**omit-dir-times / omit-link-times:** `-O` and `-J` are **real modifiers** as of
P7 Wave D (`🔄 → ✅ Implemented`). FastSync now preserves directory mtimes
(captured by the scanner, transmitted in a terminal `STATUS_DIR_TIMES` frame,
applied only after all children and the delete/publication phases) and symlink
mtime/owner/mode (no-follow `utimensat`/`fchownat`/`fchmodat` at link creation).
`-O` makes the receiver skip the directory-time set; `-J` makes it skip the
symlink timestamps (ownership/mode application is unaffected and stays governed
by the identity opt-in). Both config booleans already crossed the wire. See the
`-O`/`-J` rows and the Wave D note below.
**-U/-N and -M interaction:** because FastSync carries all metadata (mode, uid,
gid, mtime, and now atime/crtime) in one bounded payload that is only sent when
@@ -523,9 +524,10 @@ was bumped **2.12.0 → 2.13.0** (peers must match, exactly as prior phases did)
predicate unconditionally, a plain `-l` sync **refuses to round-trip a
legitimate absolute symlink target** (it is dropped, never created pointing
outside the root — see the `--munge-links` note for the symmetric trust
boundary); a relative in-root target is copied as-is. FastSync also does not
set timestamps/owner on symlinks (no symlink-mode metadata application),
matching its existing no-op `--omit-link-times`.
boundary); a relative in-root target is copied as-is. As of P7 Wave D FastSync
also applies the symlink's own metadata with no-follow primitives
(`utimensat`/`fchownat`/`fchmodat` with `AT_SYMLINK_NOFOLLOW`), so `-J` is a
real omit switch rather than a no-op.
- **`-k/--copy-dirlinks`** (sender): a symlink whose referent is a directory is
dereferenced and recursed into as a real directory; a symlink to a regular
file (or any non-directory) is kept as a symlink. This is rsync's `-k`. When
@@ -672,10 +674,10 @@ now transmits targets (the prior behavior was broken/partial); its status moved
| `--stop-after=MINS` | Stop after N minutes | ✅ Implemented | Client-only sender stop deadline (Phase 6): computing `--stop-after=MINS` (a positive minute count; 0/negative/garbage rejected) and `--stop-at=TIME` (`HH:MM`, `HH:MM:SS`, or `now+N[smhd]`; a past time stops immediately). The transfer stops ELEGANTLY at the next chunk boundary: everything already fully sent is kept and applied, the run returns 0, and --delete (late/delete-after timing) does NOT wipe the destination — when the scan is cut short the partial keep-set manifest is suppressed with a warning (the delete walk is skipped rather than acting on an incomplete keep-set, so unscanned source mirrors survive). `--delete-before`/`--delete-during` still run their complete pre-scan (which ignores the deadline). Local client-only fields: never serialized into the wire config frame, so no PROTOCOL_VERSION bump. `--stop-after` uses CLOCK_MONOTONIC; `--stop-at` uses the wall clock. Works single-threaded and under `-j`/`--threads` (multithreaded). Divergence: rsync computes `--stop-after` from the run start; FastSync likewise. When both are given, the earlier of the two deadlines wins (checked per iteration). See the Phase-6 stop notes below |
| `--stop-at=TIME` | Stop at specified time | ✅ Implemented | Same feature as `--stop-after` (deadline transfer stop), absolute wall-clock form (`HH:MM[:SS]` or `now+N[smhd]`). See the row above and the Phase-6 stop notes |
| `--fsync` | Fsync every written file before publication | ✅ Implemented | |
| `--protocol=NUM` | Force older protocol version | ✅ Implemented | Forces the wire protocol version for this transfer. FastSync has exactly ONE wire format (`PROTOCOL_VERSION`, currently 2.16.0) with no downgrade/backward-compat code paths, so `--protocol=2.16.0` is accepted (it sets the version claim the client sends, which the server already requires to match exactly) and **every other value is rejected up front** with a clear error before any connection — it does not and cannot speak an older or virtual wire format. Divergence from rsync (which negotiates a range and downgrades to an integer 0..31): FastSync's honest contract is force-to-the-one-supported-value; a genuine downgrade would require a per-version compatibility layer that does not exist. Client-only; the server-side exact-match check is unchanged. `--protocol=2.16`/`2.15.0`/`2.17.0`/`216`/`31`/garbage are all rejected. See the Phase-6 protocol note below |
| `--protocol=NUM` | Force older protocol version | ✅ Implemented | Forces the wire protocol version for this transfer. FastSync has exactly ONE wire format (`PROTOCOL_VERSION`, currently 2.17.0) with no downgrade/backward-compat code paths, so `--protocol=2.17.0` is accepted (it sets the version claim the client sends, which the server already requires to match exactly) and **every other value is rejected up front** with a clear error before any connection — it does not and cannot speak an older or virtual wire format. Divergence from rsync (which negotiates a range and downgrades to an integer 0..31): FastSync's honest contract is force-to-the-one-supported-value; a genuine downgrade would require a per-version compatibility layer that does not exist. Client-only; the server-side exact-match check is unchanged. `--protocol=2.17`/`2.16.0`/`2.15.0`/`216`/`31`/garbage are all rejected. See the Phase-6 protocol note below |
| `--iconv=CONVERT_SPEC` | Charset conversion | ✅ Implemented | Charset conversion of FILE NAMES (not content) at the protocol boundary via iconv(3): `--iconv=LOCAL[,REMOTE]` — the sender converts each local filename LOCAL→REMOTE before transmitting, and the receiver converts each wire filename REMOTE→LOCAL before creating/writing. The full CONVERT_SPEC is serialized into the config frame as a new trailing string field so the peer knows the wire charset; **PROTOCOL_VERSION bumped 2.15.0 → 2.16.0**. `LOCAL[,REMOTE]` parse: single charset ⇒ LOCAL==REMOTE (identity both ways); garbage rejected up front. Validation probes BOTH directions (a spec that only opens one way is refused, as is a NUL-emitting target charset like utf-16/utf-32/ucs-2, since filenames cannot contain NUL). An unrepresentable name (EILSEQ/EINVAL) fails that path cleanly with a logged `--iconv: cannot convert file name ...` and is never written mangled/truncated. Conversion is applied at EVERY wire-path site (regular/MKDIR/hardlink path+target/symlink path+target/SPECIAL, the delete manifest, the incremental-check path, and the `-s`/`chunk_serialize` embedded blob path), on both client and server (`--iconv` is also a server/daemon option). Zero overhead when unset. See the Phase-6 iconv notes below |
| `--checksum-seed=NUM` | Set checksum seed | ✅ Implemented | Sets the seed for FastSync's whole-file xxHash64 digest (full 64-bit seed) and for the delta path's per-block xxHash32 strong checksum (low 32 bits of the seed). An explicit seed deterministically changes every computed digest on BOTH endpoints (sender and receiver share the seed via the config frame, protocol 2.10.0), so identical runs with the same seed skip the same files and a changed seed changes the digests — the explicit-seed path that makes xxHash comparisons deterministic. `--checksum-choice=md5` has no seed and ignores it (documented). The value is a strict decimal 0..2⁶⁴-1 (blank, signed, or non-numeric values are rejected). Like rsync, a seed only matters where a digest is actually computed (`--checksum` or a basis-dir run, or a delta transfer); it does not by itself enable `--checksum`/`--delta`. Divergence from rsync: the default is seed 0, and FastSync never randomizes the seed (rsync uses a random per-transfer seed when `--checksum-seed` is unset); FastSync's unset default therefore reproduces its historical byte-for-byte behavior |
| `--secluded-args`, `-s` | Use protocol to send args | 🔄 Compatibility No-op | Accepted for CLI compatibility, including the rsync short `-s` (Phase 7 Wave A); it does not change FastSync transport or protocol behavior, because remote SSH argv is already built injection-safe (single-quote-escaped). Chunk serialization is the long-only `--chunk-serialization`. |
| `--secluded-args`, `-s` | Use protocol to send args | ⛔ Impossible/Divergence | Accepted for CLI compatibility (including the rsync short `-s`, Phase 7 Wave A) but a documented **no-op / divergence**. rsync's `-s` protects arguments from shell expansion by shipping them over the protocol; FastSync never passes remote arguments through a shell expansion boundary in the first place — its SSH transport builds the remote argv as **single-quote-escaped shell words** (`ssh_build_remote_command`), so the injection/leak that `-s` guards against does not exist and there is nothing to "seclude". Implementing a true arg-send protocol would mean replacing the argv-based SSH launch with an in-band argument channel, a large redesign of the transport that buys no security here. Chunk serialization remains the long-only `--chunk-serialization`. |
| `--no-OPTION` | Turn off implied option | ✅ Supported | Supported boolean FastSync options and archive-implied options; unsafe or value-taking options are rejected. |
---
@@ -788,7 +790,7 @@ These are the hardest compatibility items because they require durable formats o
**Phase 6, Wave B (iconv) shipping note (PROTOCOL 2.15.0 → 2.16.0):** `--iconv=LOCAL[,REMOTE]` converts file NAMES at the wire boundary (never content). The full CONVERT_SPEC is serialized into the config frame as a new trailing string field (empty→NULL canonicalized), so both ends share the same wire charset interpretation; this required the PROTOCOL bump because the frame is a strict ordered sequence and a peer that does not parse the new trailing field would desynchronize. Each end derives LOCAL (its own charset) and REMOTE (the wire charset): the sender opens LOCAL→REMOTE and converts every transmitted filename; the receiver opens REMOTE→LOCAL and converts every received filename before creating/writing. Conversion is applied at every wire-path site (regular/MKDIR/hardlink path+target/symlink path+target/SPECIAL, the delete manifest keep/protected/missing entries, the incremental-check path, and the embedded `-s`/chunk-blob path). A name it cannot convert (EILSEQ/EINVAL) is failed cleanly with a logged `--iconv: cannot convert file name ...` and is never written truncated/mangled. Validation probes both directions up front (both the sender local→remote and the receiver remote→local, and, for a server/daemon with its own `--iconv`, the client-REMOTE→server-LOCAL pair) so an unusable spec is rejected before the connection rather than mid-transfer, and NUL-emitting target charsets (utf-16/utf-32/ucs-2) are refused because filenames cannot contain NUL. Divergence documented upstream: the receiver does NOT half-swap; the wire charset always comes from the sender's REMOTE half, so a server whose local charset differs from the client's LOCAL must declare it with its own `--iconv`. Conversion is process-global and runs on a single thread per process (sender thread / receiver-loop thread), initialized before worker threads start and freed after they join.
**Phase 6, Wave C (protocol-version) shipping note (no PROTOCOL_VERSION change):** `--protocol=NUM` lets the client force the wire protocol version for a transfer. FastSync's protocol is a single lockstep format: the config frame is a strict ordered sequence and the server requires the client's version string to equal `PROTOCOL_VERSION` exactly (`config_receive_with_validate`, src/shared/config.c) — there are no older-format code paths and no downgrade/negotiation machinery, so a lower/higher/virtual version can never be spoken. The honest contract is therefore: `--protocol=2.16.0` (the current `PROTOCOL_VERSION`) is accepted and stored into the client's `version` claim (which `config_send` already transmits), and every other value — `2.16`, `2.15.0`, `2.17.0`, `3.0.0`, rsync-integer spellings like `216`/`31`, garbage, empty — is rejected up front in `validate_config()` before any connection, with a clear error that FastSync supports only its current wire protocol and cannot speak an older or virtual one. Implementation is client-only: a server-side `--protocol` is intentionally not added because the server has no negotiation (it only enforces exact match), and it could only ever be the current version. This preserves (and slightly tightens) existing validation: the client now also refuses to launch with a version it cannot actually speak, rather than only the server rejecting it later. A genuine downgrade would require a per-version compatibility layer for every frame/feature added since (append 2.10, preallocate 2.11, hardlinks 2.12, devices/specials/symlink-trust/xattr 2.13, remote-option 2.14, daemon module/auth 2.15, iconv 2.16) and is intentionally out of scope — documented divergences from rsync's integer-negotiated downgrade remain.
**Phase 6, Wave C (protocol-version) shipping note (no PROTOCOL_VERSION change):** `--protocol=NUM` lets the client force the wire protocol version for a transfer. FastSync's protocol is a single lockstep format: the config frame is a strict ordered sequence and the server requires the client's version string to equal `PROTOCOL_VERSION` exactly (`config_receive_with_validate`, src/shared/config.c) — there are no older-format code paths and no downgrade/negotiation machinery, so a lower/higher/virtual version can never be spoken. The honest contract is therefore: `--protocol=2.17.0` (the current `PROTOCOL_VERSION`, as of the P7 Wave D times bump) is accepted and stored into the client's `version` claim (which `config_send` already transmits), and every other value — `2.17`, `2.16.0`, `2.15.0`, `3.0.0`, rsync-integer spellings like `216`/`31`, garbage, empty — is rejected up front in `validate_config()` before any connection, with a clear error that FastSync supports only its current wire protocol and cannot speak an older or virtual one. Implementation is client-only: a server-side `--protocol` is intentionally not added because the server has no negotiation (it only enforces exact match), and it could only ever be the current version. This preserves (and slightly tightens) existing validation: the client now also refuses to launch with a version it cannot actually speak, rather than only the server rejecting it later. A genuine downgrade would require a per-version compatibility layer for every frame/feature added since (append 2.10, preallocate 2.11, hardlinks 2.12, devices/specials/symlink-trust/xattr 2.13, remote-option 2.14, daemon module/auth 2.15, iconv 2.16, dir/symlink times 2.17) and is intentionally out of scope — documented divergences from rsync's integer-negotiated downgrade remain.
**Phase-1/2 selection-and-update status correction (docs):** `-I/--ignore-times`, `--size-only`, `-@/--modify-window`, `--existing`, `--ignore-existing`, `-u/--update`, `-W/--whole-file`, and `--compress-threads` were previously listed as not-implemented in this document but are in fact fully implemented and tested on `dev`. This pass corrects the matrix to match the code. The realistic model of these is that FastSync is a *sender-driven* whole-tree copy, so the size+mtime quick-check and all three receiver-policy skips (`--existing`, `--ignore-existing`, `-u`) are evaluated against the **destination** on the receiver side, and their booleans cross the wire in the config frame. `-I`/`--size-only`/`--modify-window` modify the `--incremental` per-file `STATUS_CHECK` handshake's match predicate (`-I` disables the mtime leg and forces transfer; `--size-only` drops only the mtime leg; `--modify-window` adds tolerance to `metadata_mtime_matches`); they require `--incremental` (or a basis dir) to have a handshake to affect, mirroring how they only matter where a quick-check exists in rsync. `--existing`/`--ignore-existing`/`-u` are receiver write-time policies (skipping the write / newer-destination guard) applied across the regular-file, `--delay-updates`-staged, hardlink-sibling, and special/device paths; `-u` implies `-M` metadata and uses a second-then-nanosecond strict `>` newer check; both correctly influence `--remove-source-files` (a skipped source is not removed). `-W/--whole-file` disables block-level delta (opt-in via `--delta`), folded into the wire `use_delta` so no protocol bump was needed, and makes `--fuzzy` inert; `--append`/`--append-verify` are rejected with `-W`. `--compress-threads=NUM` (1..64, client-only, never crosses the wire) sizes the zstd compression worker pool. No code was changed by this correction; the implementation had landed in earlier merge waves (feat/ignore-times, feat/ignore-existing via the newer `file_to_disk_secure_no_replace`/`linkat EEXIST` path, feat/size-only, feat/modify-window, feat/whole-file, feat/update, compression-threads).
@@ -815,7 +817,13 @@ These are the last compatibility items and the closing phase toward rsync flag p
**Wave C — Devices & special files (finalize statuses + tests).** `--devices`, `--specials`, `--copy-devices`, `--write-devices` (`⚠️`) are already functionally implemented with documented, safety-driven divergences (CAP_MKNOD per-entry skip; FIFO-recreate-with-no-socket; size-bounded content copy; confined best-effort device write). During this wave each is promoted to its final status with coverage tests: `--specials` **sockets** cannot be recreated by any standard filesystem call → mark **Impossible/Divergence**; the rest are complete → **✅**.
**Wave D — Times superstructure & arg-protection no-ops (`🔄`).** `-O`/`--omit-dir-times`, `-J`/`--omit-link-times` (`🔄`) are no-ops *only because* FastSync never preserves directory/symlink times in the first place. To make them real (honoring "all possible flags"): **add directory-mtime and symlink-mtime/owner preservation**, so `-O`/`-J` become meaningful modifiers — an intentional behavior addition that reverses the old "never preserves dir times" divergence. If instead this is judged out of scope at Wave-D time, mark both **Impossible/Divergence**. `--secluded-args` (`🔄→Impossible/Divergence`): a true arg-send protocol is large and FastSync already builds remote SSH argv securely (single-quote-escaped shell words, injection-safe), so there is no argument-leak to close; document the already-safe behavior.
**Wave D — Times superstructure & arg-protection no-ops (✅ implemented, `--secluded-args` ⛔).** `-O`/`--omit-dir-times` and `-J`/`--omit-link-times` are now **real modifiers** (both `🔄 → ✅ Implemented`), reversing the old "never preserves directory/symlink times" divergence:
- **Directory times.** The recursive scanner captures every traversed source directory's metadata (mtime, plus atime under `-U`) into a per-transfer list — two paths are covered: the sequential `DirectoryScanner` captures each opened directory (including the transfer root), and the parallel scanner captures both the root in `parallel_scanner_create_with_options` and each worker's subdirectories in `open_next_directory` (appends are guarded by a mutex shared with the sender's pipeline context). The sender transmits them in ONE terminal `STATUS_DIR_TIMES` frame (int count + (wire path, metadata) pairs) sent **after all file data and after the optional delete manifest**, just before `STATUS_FINISHED`. The receiver accumulates received directory metadata in a `DirTimeList` and applies it only at the very end — after the entire stream, after the commit-style `--delete` deletion, and after `--delay-updates` publication — because creating or removing a child bumps the parent's mtime. Application is fd-relative/walk-confined (`file_open_secure_parent` + `utimensat(..., AT_SYMLINK_NOFOLLOW)`) and best-effort per entry. `-O` (config boolean, already on the wire) makes the receiver skip the whole set. The single-threaded sink applies in `receiver_send_success_frame`; the `-m` sink accumulates in `write_thread` and server.c applies after both threads join and the deletion commits.
- **Symlink times/owner/mode.** `STATUS_SYMLINK` already carried metadata; the receiver now applies it with no-follow primitives only: `utimensat(..., AT_SYMLINK_NOFOLLOW)`, best-effort `fchmodat(..., AT_SYMLINK_NOFOLLOW)` (honest no-op where unsupported, e.g. Linux), and policy-gated `fchownat(..., AT_SYMLINK_NOFOLLOW)` via a new `identity_apply_ownership_link` that shares the identity resolver with the fd path. `-J` suppresses only the timestamps; ownership stays governed by the identity opt-in (`--numeric-ids`/`--usermap`/`--groupmap`/`--chown`) exactly like regular files. A symlink has no children, so this is applied immediately at creation.
- **Wire:** the shared `STATUS_DIR_TIMES` frame (and metadata on `STATUS_MKDIR` for `--dirs` entries) is a frame-sequence change, so `PROTOCOL_VERSION` was bumped **2.16.0 → 2.17.0**; every version-sensitive test (`--protocol` accepted/rejected values) was updated. The config-frame layout itself is unchanged (the omit booleans already crossed). Non-metadata and `--no-preserve` transfers send no `STATUS_DIR_TIMES` frame and no directory metadata, keeping them byte-identical.
`--secluded-args` (`🔄 → ⛔ Impossible/Divergence`): a true arg-send protocol would replace the argv-based SSH launch with an in-band channel, and FastSync already builds the remote SSH argv injection-safe (single-quote-escaped shell words), so there is no argument-leak to close; the already-safe behavior is documented in the row and no transport change is made.
**Wave E (LAST) — Privilege (deferred decision, `❌`).** `--super`, `--copy-as=USER[:GROUP]`: **deferred by explicit project decision — the privilege model must be decided when this wave starts.** Candidate directions to fix then: a **safe** receiver model — `--copy-as` performs a drop-to-uid/group only when the process is privileged (and a clear refusal otherwise, never blind elevation); `--super` lifts only within the confined receive root — versus a **full setuid/elevation** model (higher security-review burden). Recommended: the safe-subset + clear-refusal direction, consistent with FastSync's confinement philosophy. These are the only remaining `❌` rows.
+71 -9
View File
@@ -127,6 +127,11 @@ static bool prepare_scanner(const Config* config, int num_threads, PreparedScann
options->excluded_paths = NULL;
options->excluded_mutex = NULL;
options->hardlinks = NULL;
/* P7 Wave D: capture source directory times whenever metadata rides the
wire. Whether they are APPLIED is decided receiver-side (-O skips). */
options->capture_dir_times = config->use_metadata;
options->dir_entries = NULL;
options->dir_entries_mutex = NULL;
if (config->preserve_hard_links) {
out->hardlinks = hardlink_table_create();
if (!out->hardlinks) {
@@ -1115,14 +1120,41 @@ static bool send_file_direct(File* file, int fd, bool use_metadata, int compress
}
/* Transmit one explicit directory entry (--dirs): a STATUS_MKDIR frame whose
payload is only the destination path. The receiver validates the path and
creates the directory under the receive root. */
static bool send_directory_entry(Client* client, File* file) {
payload is the destination path and, when metadata is negotiated, the
directory's metadata frame. The receiver validates the path, creates the
directory under the receive root, and (metadata case) defers applying its
times to the end of the transfer so -O/--omit-dir-times is honored. */
static bool send_directory_entry(const Client* client, File* file, const Config* config) {
if (!file || !file_wire_path(file))
return false;
if (!send_status(client->file_descriptor, STATUS_MKDIR))
if (!send_status(client->file_descriptor, STATUS_MKDIR) ||
!send_wire_str(client->file_descriptor, file_wire_path(file)))
return false;
return send_wire_str(client->file_descriptor, file_wire_path(file));
return !config->use_metadata || metadata_send(client->file_descriptor, file->metadata);
}
/* P7 Wave D: transmit every captured source directory's metadata in one
terminal STATUS_DIR_TIMES frame (count, then (path, metadata) pairs) after all
file data and the optional delete manifest. The receiver applies them at the
END of its own transfer (after deletion and --delay-updates publication) so a
directory's mtime is not clobbered by writing its children. A non-metadata
transfer (or an empty set) sends nothing, keeping the stream byte-identical. */
static bool send_dir_times(const Client* client, const Config* config, ArrayList* dir_entries) {
if (!client || !config || !config->use_metadata || !dir_entries || dir_entries->size == 0)
return true;
if (dir_entries->size > INT_MAX)
return false;
int fd = client->file_descriptor;
if (!send_status(fd, STATUS_DIR_TIMES) || !send_int(fd, dir_entries->size))
return false;
for (int i = 0; i < dir_entries->size; i++) {
File* file = (File*)dir_entries->items[i];
if (!file || !file_wire_path(file))
return false;
if (!send_wire_str(fd, file_wire_path(file)) || !metadata_send(fd, file->metadata))
return false;
}
return true;
}
/* Transmit one symlink entry: a STATUS_SYMLINK frame carrying the destination
@@ -1300,10 +1332,10 @@ static int send_chunk_with_removal(Client* client, Chunk* chunk, Config* config,
if (f == NULL)
continue;
if (f->is_dir) {
/* Explicit directory entry (--dirs): a MKDIR frame carrying only the
destination path. Directories have no source to remove and no
incremental check. */
if (!send_directory_entry(client, f))
/* Explicit directory entry (--dirs): a MKDIR frame carrying the
destination path (and metadata when negotiated). Directories have no
source to remove and no incremental check. */
if (!send_directory_entry(client, f, config))
return -1;
change_emit_dir_sent(config, f);
continue;
@@ -1487,6 +1519,13 @@ static int send_chunks_multithreaded(void* pipeline_context) {
if (send_delete_manifest(client->file_descriptor, NULL, NULL, context->missing_args) != 0)
goto send_fail;
}
/* P7 Wave D: transmit the captured directory times last. The scanner thread
(and all parallel workers) has been joined before scanner_done was set, so
the list is complete and race-free; on an early stop the list may be
incomplete and is deliberately not sent. */
if (!context->scan_stopped_early &&
!send_dir_times(client, context->config, context->dir_entries))
goto send_fail;
bool ok = finalize_transfer(client, context->config, context->remove_source_files);
if (!ok && context->config->use_delete)
log_message(LOG_LEVEL_ERROR,
@@ -1528,6 +1567,10 @@ static int scan_directory_multithreaded(void* pipeline_context) {
return thrd_error;
}
prepared.options.stop_condition = &context->stop_condition;
/* P7 Wave D: the recursive scan feeds the shared directory-time list; the
parallel workers append under the context's dedicated mutex. */
prepared.options.dir_entries = context->dir_entries;
prepared.options.dir_entries_mutex = &context->dir_entries_mutex;
/* The keep-set manifest for the late modes is built from this data pass, so
the parallel scanner records the protected excluded prefixes here. The
early modes already transmitted the pre-scan keep-set and its protected
@@ -1829,6 +1872,9 @@ int send_files(Config* config) {
DirectoryScanner* scanner = NULL;
ArrayList* manifest = NULL;
ArrayList* remove_sources = NULL;
/* P7 Wave D: captured source directory times, transmitted in one terminal
STATUS_DIR_TIMES frame (only when metadata rides the wire). */
ArrayList* dir_entries = NULL;
/* Protected excluded prefixes (delete-excluded default protection). */
ArrayList* excluded = NULL;
bool delete_early = config->use_delete && config_delete_timing_early(config);
@@ -1841,6 +1887,11 @@ int send_files(Config* config) {
receive_daemon_motd(client, config);
if (!prepare_scanner(config, 0, &prepared))
goto send_fail;
if (config->use_metadata) {
dir_entries = array_list_create(file_destroy);
if (!dir_entries)
goto send_fail;
}
if (config->remove_source_files)
remove_sources = array_list_create(source_file_destroy);
if (config->remove_source_files && !remove_sources)
@@ -1905,6 +1956,10 @@ int send_files(Config* config) {
StopCondition stop = stop_condition_make(config->stop_after_mins > 0, config->stop_after_mins,
config->stop_at_set, config->stop_at, now_mono);
prepared.options.stop_condition = &stop;
/* The early-delete pre-scan above already ran; only the data pass should feed
the directory-time list (otherwise every directory would be captured
twice). */
prepared.options.dir_entries = dir_entries;
scanner = directory_scanner_create_with_options(config->send_directory, &prepared.options);
if (!scanner)
goto send_fail;
@@ -2023,6 +2078,11 @@ int send_files(Config* config) {
}
}
}
/* P7 Wave D: every directory has now been traversed (or the scan stopped
early), so transmit the captured directory times last. The receiver defers
applying them until after its own deletion/publication phase. */
if (!send_dir_times(client, config, dir_entries))
goto send_fail;
bool ok = finalize_transfer(client, config, remove_sources);
if (!ok && config->use_delete)
log_message(LOG_LEVEL_ERROR,
@@ -2064,6 +2124,8 @@ send_fail:
array_list_delete(missing_args);
if (remove_sources)
array_list_delete(remove_sources);
if (dir_entries)
array_list_delete(dir_entries);
if (scanner)
directory_scanner_destroy(scanner);
prepared_scanner_destroy(&prepared);
+83
View File
@@ -488,6 +488,9 @@ DirectoryScanner* directory_scanner_create_with_options(const char* root_directo
scanner->hardlinks = options->hardlinks;
scanner->prune_empty_dirs = options->prune_empty_dirs;
scanner->stop_condition = options->stop_condition;
scanner->capture_dir_times = options->capture_dir_times;
scanner->dir_entries = options->dir_entries;
scanner->dir_entries_mutex = options->dir_entries_mutex;
scanner->dirs_root_emitted = false;
scanner->list_index = 0;
scanner->dirs_batch = NULL;
@@ -595,6 +598,63 @@ static Chunk* chunk_data_to_chunk(ArrayList* chunk_data) {
return chunk;
}
/* P7 Wave D: append one traversed source directory's captured metadata to the
* shared pending-directory-time list. The File carries no payload; only the
* wire path (absolute fs path normally, the bare relative path under
* -R + --files-from) and its metadata are used, and the sender transmits them
* in one terminal STATUS_DIR_TIMES frame. `mutex` (optional) serializes the
* append for the parallel scanner's shared workers. An unstattable or
* non-directory path is silently skipped (the transfer is unaffected); an
* allocation failure is fatal and reported to the caller. */
static bool scanner_capture_dir_time(ArrayList* dir_entries, mtx_t* mutex, const char* root_path,
const char* fs_path, bool relative_mode, bool preserve_atimes,
bool preserve_crtimes) {
if (!dir_entries || !root_path || !fs_path)
return true;
struct stat st;
if (stat(fs_path, &st) != 0 || !S_ISDIR(st.st_mode))
return true;
char* rel = scanner_path_relative(root_path, fs_path);
if (!rel)
return true;
if (relative_mode && rel[0] == '\0') {
/* -R + --files-from: the transfer root itself has no bare relative wire
path (matches the -R scan, which never emits the root). */
free(rel);
return true;
}
File* file = file_create(fs_path);
if (!file) {
free(rel);
return false;
}
file->is_dir = true;
file->metadata = file_metadata_create(fs_path, &st, preserve_atimes, preserve_crtimes);
if (!file->metadata) {
free(rel);
file_destroy(file);
return false;
}
if (relative_mode) {
file->send_path = rel;
rel = NULL;
}
free(rel);
bool added;
if (mutex) {
mtx_lock(mutex);
added = array_list_add(dir_entries, file);
mtx_unlock(mutex);
} else {
added = array_list_add(dir_entries, file);
}
if (!added) {
file_destroy(file);
return false;
}
return true;
}
/* Open the next queued directory and set up its filter context. Returns 1 when
a directory is open, 0 when the queue is exhausted, and -1 on a fatal error.
A directory that cannot be opened is an I/O error: it is recorded on the
@@ -661,6 +721,17 @@ static int open_next_directory(DirectoryScanner* scanner) {
scanner->current_path = NULL;
return -1;
}
if (scanner->capture_dir_times &&
!scanner_capture_dir_time(scanner->dir_entries, scanner->dir_entries_mutex,
scanner->root_path, scanner->current_path, scanner->relative_mode,
scanner->preserve_atimes, scanner->preserve_crtimes)) {
closedir(scanner->current_dir);
scanner->current_dir = NULL;
free(scanner->current_path);
scanner->current_path = NULL;
scanner->failed = true;
return -1;
}
return 1;
}
return 0;
@@ -1584,6 +1655,18 @@ ParallelScanner* parallel_scanner_create_with_options(const char* root_directory
parallel_scanner_destroy(ps);
return NULL;
}
/* P7 Wave D: the parallel scanner never runs a DirectoryScanner over the
transfer root itself (it hands the root's immediate subdirectories to
workers), so capture the root's directory time here. */
if (options->capture_dir_times &&
!scanner_capture_dir_time(options->dir_entries, options->dir_entries_mutex, root_directory,
root_directory, options->relative && options->file_list != NULL,
options->preserve_atimes, options->preserve_crtimes)) {
array_list_delete(root_files);
array_list_delete(subdirs);
parallel_scanner_destroy(ps);
return NULL;
}
unsigned long long cs = options->chunk_size > 0 ? options->chunk_size : DESIRED_CHUNK_SIZE;
ps->initial_chunk = batch_files(root_files, cs, ps->result_queue, &ps->failed);
+16
View File
@@ -98,6 +98,18 @@ typedef struct {
* (without marking the scan as failed), so a busy scan itself stops early.
* Client-only, never serialized to the wire. */
const StopCondition* stop_condition;
/* P7 Wave D (protocol 2.17.0): directory-time capture sink. When
* `capture_dir_times` is true the recursive scan appends one is_dir File
* (with metadata, no payload) per source directory it traverses to
* `dir_entries`, so the sender can transmit a single trailing
* STATUS_DIR_TIMES frame and the receiver can apply directory mtimes AFTER
* all children are written. `dir_entries_mutex` (optional) guards the list
* for the parallel scanner's shared worker threads; the caller owns both.
* The --dirs generator does not use this (its directory entries carry their
* metadata inline through STATUS_MKDIR). */
bool capture_dir_times;
ArrayList* dir_entries;
mtx_t* dir_entries_mutex;
} ScannerOptions;
/* Internal per-scanner filter state. FilterNode chains represent the ordered
@@ -173,6 +185,10 @@ typedef struct {
HardLinkTable* hardlinks;
/* Phase 6: sender stop deadline (from ScannerOptions). */
const StopCondition* stop_condition;
/* P7 Wave D directory-time capture (see ScannerOptions). */
bool capture_dir_times;
ArrayList* dir_entries;
mtx_t* dir_entries_mutex;
} DirectoryScanner;
typedef struct {
+43 -2
View File
@@ -74,6 +74,24 @@ static bool receiver_process_chunk(Chunk* chunk, const ReceiverSink* sink) {
return true;
}
/* P7 Wave D: read the single terminal STATUS_DIR_TIMES frame (a count followed
* by that many (path, metadata) directory entries) and route every directory
* through the regular store_file sink. The sink's write path creates each
* directory (idempotent -- the recursive transfer already created it as a
* parent) and accumulates its metadata for end-of-transfer application. A
* malformed count or entry is a hard error. */
static bool receiver_process_dir_times(int fd, const Config* config, const ReceiverSink* sink) {
int count;
if (!receive_int(fd, &count) || count < 0 || count > MAX_MANIFEST_ENTRIES)
return false;
for (int i = 0; i < count; i++) {
File* dir = file_receive_dir_time(fd, config);
if (!dir || !sink->store_file(dir, sink->context))
return false;
}
return true;
}
static bool receiver_process_batch(Config* config, int file_descriptor) {
int count;
if (config->checksum || !receive_int(file_descriptor, &count) || count < 0 ||
@@ -161,7 +179,7 @@ int receiver_process_pending(Config* config, int file_descriptor, const Receiver
while (status == STATUS_NEXT || status == STATUS_CHUNK || status == STATUS_CHECK ||
status == STATUS_KEEPALIVE || status == STATUS_ABORT || status == STATUS_CHECK_BATCH ||
status == STATUS_MKDIR || status == STATUS_MANIFEST || status == STATUS_HARDLINK ||
status == STATUS_SYMLINK || status == STATUS_SPECIAL) {
status == STATUS_SYMLINK || status == STATUS_SPECIAL || status == STATUS_DIR_TIMES) {
if (status == STATUS_KEEPALIVE) {
if (!send_status(file_descriptor, STATUS_KEEPALIVE))
goto fail;
@@ -185,9 +203,12 @@ int receiver_process_pending(Config* config, int file_descriptor, const Receiver
goto fail;
goto next_status;
} else if (status == STATUS_MKDIR) {
File* dir = file_receive_directory(file_descriptor);
File* dir = file_receive_directory(file_descriptor, config);
if (!dir || !sink->store_file(dir, sink->context))
goto receive_error;
} else if (status == STATUS_DIR_TIMES) {
if (!receiver_process_dir_times(file_descriptor, config, sink))
goto receive_error;
} else if (status == STATUS_HARDLINK) {
File* file = file_receive_hardlink(file_descriptor);
if (!file || !sink->store_file(file, sink->context))
@@ -311,6 +332,10 @@ receive_error:
typedef struct {
Config* config;
ReceiverOutcomes outcomes;
/* P7 Wave D: directory metadata accumulated during the stream, applied only
after the whole transfer (and its delete/publication phases) has run so a
child write never clobbers a directory mtime. */
DirTimeList dir_times;
} ReceiverSaveContext;
static bool receiver_save_file(File* file, void* context_pointer) {
@@ -323,6 +348,15 @@ static bool receiver_save_file(File* file, void* context_pointer) {
} else {
result = file_save_to_disk_full(context->config->receive_root_directory, file, context->config);
}
/* A directory's times are deferred, never applied inline: collect the
metadata now and apply it at the end. -O/--omit-dir-times is honored by
dir_time_list_apply's caller (see receiver_send_success_frame). */
if (result != FILE_SAVE_ERROR && file->is_dir && file->metadata &&
context->config->use_metadata && !context->config->omit_dir_times &&
!dir_time_list_add(&context->dir_times, file->path, file->metadata)) {
file_destroy(file);
return false;
}
if (result != FILE_SAVE_ERROR && context->config->remove_source_files && !file->is_dir &&
!file->is_special && !file->skip &&
!receiver_outcomes_append(&context->outcomes, (unsigned char)result)) {
@@ -346,15 +380,22 @@ static bool receiver_send_success_frame(int fd, void* context_pointer) {
return false;
}
}
/* P7 Wave D: every child is now written and the delete / --delay-updates
phases have committed, so it is finally safe to stamp directory times.
This runs after the deferred deletion because receiver_process commits it
before calling this success frame. */
dir_time_list_apply(&context->dir_times, context->config->receive_root_directory);
return receiver_send_final_success(fd, context->config, &context->outcomes);
}
int receiver_receive_files(Config* config, int file_descriptor) {
ReceiverSaveContext context = {.config = config, .outcomes = {0}};
dir_time_list_init(&context.dir_times);
ReceiverSink sink = {receiver_save_file, &context, true, true, receiver_send_success_frame};
int ret = receiver_process(config, file_descriptor, &sink);
if (ret != 0 && config->delay_updates && config->delay_context)
delay_updates_cleanup(config->delay_context);
receiver_outcomes_destroy(&context.outcomes);
dir_time_list_free(&context.dir_times);
return ret;
}
+6
View File
@@ -493,6 +493,12 @@ void handler(int file_descriptor) {
!delay_updates_publish(config->delay_context, config)) {
transfer_ok = false;
}
/* P7 Wave D: all writers have joined and the late deletion (and
--delay-updates publication) has committed above, so it is finally safe
to stamp directory times; a directory's mtime must not be clobbered by
its children or by an extra removal. */
if (transfer_ok)
dir_time_list_apply(&context->dir_times, config->receive_root_directory);
}
if (transfer_ok) {
if (!receiver_send_final_success(file_descriptor, config, &context->outcomes))
+21 -2
View File
@@ -543,8 +543,27 @@ typedef struct Config {
* new trailing bytes would desynchronize on the frame boundary, and the strict
* same-version handshake (config_receive rejects a mismatched version before
* parsing anything else) is what keeps a 2.16 client and a 2.15 server from
* ever reaching that state. */
#define PROTOCOL_VERSION "2.16.0"
* ever reaching that state.
*
* Times Wave (P7 Wave D): 2.16.0 -> 2.17.0.
*
* WHY the bump, grounded in the wire: this wave makes -O/--omit-dir-times and
* -J/--omit-link-times REAL by adding directory and symlink time preservation.
* The config-frame LAYOUT is unchanged (the omit flags already crossed the
* wire), but the FRAME STREAM gains a new terminal frame: after all file data
* and the optional delete manifest, the sender transmits one STATUS_DIR_TIMES
* frame (a count followed by (path, metadata) pairs) carrying every source
* directory's captured times, so the receiver can apply them AFTER all of a
* directory's children have been written (writing a child bumps the parent's
* mtime). Symlink entries already carry their metadata on the STATUS_SYMLINK
* frame; the receiver now applies it (utimensat/lchown with
* AT_SYMLINK_NOFOLLOW) unless -J is set. Any change to the frame sequence must
* bump the protocol version: a 2.16 peer that does not know STATUS_DIR_TIMES
* would desynchronize on the unknown frame, and the strict same-version
* handshake (config_receive rejects a mismatched version before parsing
* anything else) is what keeps a 2.17 client and a 2.16 server from ever
* reaching that state. */
#define PROTOCOL_VERSION "2.17.0"
#define DEFAULT_CHUNK_SIZE (10 * 1024 * 1024)
/* Upper bound on total basis-dir entries (rsync caps --link-dest at 20). */
#define MAX_BASIS_DIRS 64
+141 -5
View File
@@ -621,6 +621,12 @@ FileSaveResult file_save_to_disk_full(const char* root_directory, const File* fi
}
ok = file_symlink_at_secure(link_path, target);
free(target);
/* P7 Wave D: apply the symlink's own metadata with no-follow primitives
(utimensat/lchown/fchmodat AT_SYMLINK_NOFOLLOW). -J/--omit-link-times
suppresses the timestamps; ownership stays gated by the identity policy.
A symlink has no children, so this can be applied immediately. */
if (ok && config && config->use_metadata)
file_restore_symlink_metadata(link_path, file->metadata, config->omit_link_times);
free(link_path);
return ok ? FILE_SAVE_WRITTEN : FILE_SAVE_ERROR;
}
@@ -2137,12 +2143,101 @@ File* file_receive(const Config* config, int file_descriptor) {
return file;
}
/* ---- P7 Wave D: deferred directory times ---- */
void dir_time_list_init(DirTimeList* list) {
if (!list)
return;
list->paths = NULL;
list->entries = NULL;
list->count = 0;
list->capacity = 0;
}
void dir_time_list_free(DirTimeList* list) {
if (!list)
return;
for (size_t i = 0; i < list->count; i++)
free(list->paths[i]);
free(list->paths);
free(list->entries);
list->paths = NULL;
list->entries = NULL;
list->count = 0;
list->capacity = 0;
}
bool dir_time_list_add(DirTimeList* list, const char* wire_path, const FileMetadata* metadata) {
if (!list || !wire_path || !metadata)
return true; /* nothing to remember; never a hard error */
if (list->count == list->capacity) {
size_t new_capacity = list->capacity == 0 ? 16 : list->capacity * 2;
if (new_capacity < list->capacity)
return false;
char** grown_paths = realloc(list->paths, new_capacity * sizeof(char*));
if (!grown_paths)
return false;
list->paths = grown_paths;
FileMetadata* grown_entries = realloc(list->entries, new_capacity * sizeof(FileMetadata));
if (!grown_entries)
return false;
list->entries = grown_entries;
list->capacity = new_capacity;
}
char* copy = str_dup(wire_path);
if (!copy)
return false;
list->paths[list->count] = copy;
list->entries[list->count] = *metadata;
list->count++;
return true;
}
void dir_time_list_apply(const DirTimeList* list, const char* root_directory) {
if (!list || !root_directory)
return;
for (size_t i = 0; i < list->count; i++) {
char* dir_path = path_cat(root_directory, list->paths[i]);
if (!dir_path)
continue;
char* leaf = NULL;
/* The parent walk is fd-relative and O_NOFOLLOW, so a symlink planted in a
parent component can never redirect the utimensat outside the root. The
final component is a directory; AT_SYMLINK_NOFOLLOW additionally refuses
to follow a same-named symlink (a --keep-dirlinks style path). */
int parent_fd = file_open_secure_parent(dir_path, &leaf, false);
if (parent_fd < 0) {
free(dir_path);
continue;
}
struct timespec times[2] = {
{.tv_sec = 0, .tv_nsec = UTIME_OMIT},
{.tv_sec = list->entries[i].mtime_sec, .tv_nsec = list->entries[i].mtime_nsec}};
if (list->entries[i].atime_valid) {
times[0].tv_sec = list->entries[i].atime_sec;
times[0].tv_nsec = list->entries[i].atime_nsec;
}
if (utimensat(parent_fd, leaf, times, AT_SYMLINK_NOFOLLOW) != 0) {
char* escaped_path = output_escape(dir_path, log_get_8_bit_output());
log_message(LOG_LEVEL_WARNING, "Failed to set directory timestamps on %s: %s",
escaped_path ? escaped_path : "<allocation failed>", strerror(errno));
free(escaped_path);
}
close(parent_fd);
free(leaf);
free(dir_path);
}
}
/* Receive an explicit directory entry (--dirs): a STATUS_MKDIR frame carries
only the destination path; the entry carries no payload. The same path
validation as a regular file applies (non-empty, relative-or-mirrored, no
traversal), and the created File is routed through the regular store_file
sink so single-threaded and -m receivers handle directories identically. */
File* file_receive_directory(int file_descriptor) {
the destination path and, when metadata is negotiated, the directory's
metadata frame. The same path validation as a regular file applies
(non-empty, relative-or-mirrored, no traversal), and the created File is
routed through the regular store_file sink so single-threaded and -m
receivers handle directories identically. The metadata is NOT applied here:
the sink accumulates it into a DirTimeList that is applied only after the
whole transfer (children would otherwise clobber the directory mtime). */
File* file_receive_directory(int file_descriptor, const Config* config) {
char* path = receive_wire_str(file_descriptor);
if (path == NULL)
return NULL;
@@ -2159,6 +2254,47 @@ File* file_receive_directory(int file_descriptor) {
if (file == NULL)
return NULL;
file->is_dir = true;
if (config && config->use_metadata) {
int meta_ok = 1;
file->metadata = metadata_receive(file_descriptor, &meta_ok);
if (!meta_ok) {
file_destroy(file);
return NULL;
}
}
return file;
}
/* Receive one directory-time entry from the terminal STATUS_DIR_TIMES frame:
* the destination-relative wire path and (when metadata is negotiated) the
* directory's metadata frame. The created File is an is_dir entry routed
* through the regular store_file sink, exactly like a STATUS_MKDIR entry, so
* the same deferred DirTimeList application covers both. */
File* file_receive_dir_time(int file_descriptor, const Config* config) {
char* path = receive_wire_str(file_descriptor);
if (path == NULL)
return NULL;
if (path[0] == '\0' || (!file_get_trust_sender() && has_path_traversal(path))) {
char* escaped_path = output_escape(path, log_get_8_bit_output());
log_message(LOG_LEVEL_ERROR, "Invalid received directory-time path: %s",
escaped_path ? escaped_path : "<allocation failed>");
free(escaped_path);
free(path);
return NULL;
}
File* file = file_create(path);
free(path);
if (!file)
return NULL;
file->is_dir = true;
if (config && config->use_metadata) {
int meta_ok = 1;
file->metadata = metadata_receive(file_descriptor, &meta_ok);
if (!meta_ok) {
file_destroy(file);
return NULL;
}
}
return file;
}
+26 -1
View File
@@ -8,13 +8,38 @@
/* Server-side file receive/save path. */
File* file_receive(const Config* config, int file_descriptor);
File* file_receive_directory(int file_descriptor);
File* file_receive_directory(int file_descriptor, const Config* config);
File* file_receive_dir_time(int file_descriptor, const Config* config);
File* file_receive_hardlink(int file_descriptor);
File* file_receive_symlink(int file_descriptor, const Config* config);
File* file_receive_special(int file_descriptor);
bool file_special_rdev_valid(int32_t major, int32_t minor, mode_t mode);
File* receive_incremental_check(int fd, const Config* config, bool* skipped);
/* P7 Wave D directory-time accumulator. The receiver collects the metadata of
* every directory it creates/receives (STATUS_MKDIR with metadata and/or the
* terminal STATUS_DIR_TIMES frame) and applies the times only at the END of the
* transfer, after all children have been written and after the delete /
* --delay-updates phases have committed (writing or removing a child bumps the
* parent's mtime). -O/--omit-dir-times skips the application entirely. The
* list owns deep copies of the paths and metadata; freed on every path. */
typedef struct {
char** paths; /* owned, destination-relative wire paths */
FileMetadata* entries; /* owned, parallel to paths */
size_t count;
size_t capacity;
} DirTimeList;
void dir_time_list_init(DirTimeList* list);
void dir_time_list_free(DirTimeList* list);
/* Deep-copy one directory's path + metadata into the list. Returns false on
* allocation failure (the caller fails the transfer). */
bool dir_time_list_add(DirTimeList* list, const char* wire_path, const FileMetadata* metadata);
/* Apply every accumulated directory's mtime (and atime when captured) beneath
* `root_directory`, confined fd-relative. Best-effort per entry: a missing or
* unreachable directory is skipped with a warning, never fatal. */
void dir_time_list_apply(const DirTimeList* list, const char* root_directory);
/* A received delete-manifest frame: the keep-set (`keeps`, destination-relative
paths the sender transferred/keeps) plus `protected`, destination-relative
prefixes the sender asks the receiver never to delete (paths excluded on the
+59 -30
View File
@@ -2,6 +2,7 @@
#include "log.h"
#include "utils.h"
#include <errno.h>
#include <fcntl.h>
#include <grp.h>
#include <limits.h>
#include <pwd.h>
@@ -370,16 +371,11 @@ static bool identity_map_lookup(const IdentityMap* map, int count, int32_t sourc
return false;
}
void identity_apply_ownership(int fd, int32_t source_uid, int32_t source_gid) {
/* Ownership application is OFF unless the client requested an identity flag.
* This is the controlled gate: a default (or plain -M) transfer never changes
* ownership, byte-for-byte preserving FastSync's existing behavior. */
if (!identity_active_enabled() || fd < 0)
return;
struct stat st;
if (fstat(fd, &st) != 0)
return;
/* Resolve the target ownership from the negotiated policy against the entry's
* current stat. Shared by the fd (regular file) and no-follow (symlink) apply
* paths. Returns false when no side is to be changed. */
static bool identity_resolve_targets(const struct stat* st, int32_t source_uid, int32_t source_gid,
uid_t* out_uid, gid_t* out_gid) {
bool set_uid = false;
bool set_gid = false;
uid_t uid = 0;
@@ -431,29 +427,62 @@ void identity_apply_ownership(int fd, int32_t source_uid, int32_t source_gid) {
}
if (!set_uid && !set_gid)
return;
return false;
/* An unset side keeps the file's current id so the other side can change. */
if (!set_uid)
uid = st.st_uid;
uid = st->st_uid;
if (!set_gid)
gid = st.st_gid;
gid = st->st_gid;
/* Only change ownership when the target differs (avoid needless syscalls and
* any chance of clearing setuid/setgid on an already-correct entry). */
if (st->st_uid == uid && st->st_gid == gid)
return false;
*out_uid = uid;
*out_gid = gid;
return true;
}
/* Only call fchown when the target differs (avoid needless syscalls and any
* chance of clearing setuid/setgid on an already-correct file). */
if (st.st_uid == uid && st.st_gid == gid)
static void identity_log_chown_failure(const char* what, uid_t uid, gid_t gid) {
/* EPERM/EACCES are expected when the receiver is not privileged (e.g. the CI
* `nobody` user): warn and continue, never abort the transfer. Any other
* error (EIO/EROFS/ENOSPC/...) is a real failure and must not be silently
* downgraded to a warning. */
if (errno == EPERM || errno == EACCES)
log_message(LOG_LEVEL_WARNING, "could not apply ownership (uid=%ld gid=%ld): %s; leaving as-is",
(long)uid, (long)gid, strerror(errno));
else
log_message(LOG_LEVEL_ERROR, "failed to apply ownership on %s (uid=%ld gid=%ld): %s", what,
(long)uid, (long)gid, strerror(errno));
}
void identity_apply_ownership(int fd, int32_t source_uid, int32_t source_gid) {
/* Ownership application is OFF unless the client requested an identity flag.
* This is the controlled gate: a default (or plain -M) transfer never changes
* ownership, byte-for-byte preserving FastSync's existing behavior. */
if (!identity_active_enabled() || fd < 0)
return;
struct stat st;
if (fstat(fd, &st) != 0)
return;
uid_t uid;
gid_t gid;
if (!identity_resolve_targets(&st, source_uid, source_gid, &uid, &gid))
return;
if (fchown(fd, uid, gid) != 0)
identity_log_chown_failure("file", uid, gid);
}
if (fchown(fd, uid, gid) != 0) {
/* EPERM/EACCES are expected when the receiver is not privileged (e.g. the
* CI `nobody` user): warn and continue, never abort the transfer. Any
* other error (EIO/EROFS/ENOSPC/...) is a real failure and must not be
* silently downgraded to a warning. */
if (errno == EPERM || errno == EACCES)
log_message(LOG_LEVEL_WARNING,
"could not apply ownership (uid=%ld gid=%ld): %s; leaving as-is", (long)uid,
(long)gid, strerror(errno));
else
log_message(LOG_LEVEL_ERROR, "failed to apply ownership (uid=%ld gid=%ld): %s", (long)uid,
(long)gid, strerror(errno));
}
}
void identity_apply_ownership_link(int parent_fd, const char* leaf, int32_t source_uid,
int32_t source_gid) {
if (!identity_active_enabled() || parent_fd < 0 || !leaf)
return;
struct stat st;
if (fstatat(parent_fd, leaf, &st, AT_SYMLINK_NOFOLLOW) != 0)
return;
uid_t uid;
gid_t gid;
if (!identity_resolve_targets(&st, source_uid, source_gid, &uid, &gid))
return;
if (fchownat(parent_fd, leaf, uid, gid, AT_SYMLINK_NOFOLLOW) != 0)
identity_log_chown_failure("symlink", uid, gid);
}
+7
View File
@@ -55,6 +55,13 @@ bool identity_active_enabled(void);
* never fatal (rsync parity: the transfer must not abort). */
void identity_apply_ownership(int fd, int32_t source_uid, int32_t source_gid);
/* P7 Wave D: the no-follow (symlink) counterpart. Resolves the same
* usermap/groupmap/chown/numeric-ids policy but applies it with
* fchownat(..., AT_SYMLINK_NOFOLLOW) so a symlink's own ownership is changed
* without ever dereferencing it. A no-op unless an identity flag is active. */
void identity_apply_ownership_link(int parent_fd, const char* leaf, int32_t source_uid,
int32_t source_gid);
/* Receiver-side wire validation of the resolved identity fields. */
bool identity_wire_valid(const Config* config);
+37
View File
@@ -357,6 +357,43 @@ void file_restore_metadata(const char* path, const FileMetadata* metadata,
}
}
void file_restore_symlink_metadata(const char* path, const FileMetadata* metadata,
bool omit_link_times) {
if (path == NULL || metadata == NULL)
return;
char* leaf = NULL;
int parent_fd = file_open_secure_parent(path, &leaf, false);
if (parent_fd < 0)
return;
/* Ownership (only when the identity policy is active) via lchown semantics:
fchownat with AT_SYMLINK_NOFOLLOW never dereferences the link. */
identity_apply_ownership_link(parent_fd, leaf, (int32_t)metadata->uid, (int32_t)metadata->gid);
/* Symlink mode: not settable on Linux (fchmodat AT_SYMLINK_NOFOLLOW returns
EOPNOTSUPP/ENOTSUP); attempt it for platforms that support it and quietly
ignore the unsupported case so the transfer never fails over it. */
mode_t link_mode = metadata->mode & 0777;
if (fchmodat(parent_fd, leaf, link_mode, AT_SYMLINK_NOFOLLOW) != 0 && errno != EOPNOTSUPP &&
errno != ENOTSUP && errno != ENOSYS) {
log_message(LOG_LEVEL_DEBUG, "Could not set symlink mode on %s: %s", path, strerror(errno));
}
if (!omit_link_times) {
struct timespec times[2] = {{.tv_sec = 0, .tv_nsec = UTIME_OMIT},
{.tv_sec = metadata->mtime_sec, .tv_nsec = metadata->mtime_nsec}};
if (metadata->atime_valid) {
times[0].tv_sec = metadata->atime_sec;
times[0].tv_nsec = metadata->atime_nsec;
}
if (utimensat(parent_fd, leaf, times, AT_SYMLINK_NOFOLLOW) != 0) {
char* escaped_path = output_escape(path, log_get_8_bit_output());
log_message(LOG_LEVEL_WARNING, "Failed to set symlink timestamps on %s: %s",
escaped_path ? escaped_path : "<allocation failed>", strerror(errno));
free(escaped_path);
}
}
close(parent_fd);
free(leaf);
}
bool file_restore_metadata_fd(int fd, const FileMetadata* metadata, bool preserve_executability) {
if (fd < 0 || metadata == NULL)
return metadata == NULL;
+8
View File
@@ -39,6 +39,14 @@ FileMetadata* metadata_receive(int file_descriptor, int* ok);
void file_restore_metadata(const char* path, const FileMetadata* metadata,
bool preserve_executability);
bool file_restore_metadata_fd(int fd, const FileMetadata* metadata, bool preserve_executability);
/* P7 Wave D: apply a SYMLINK's own metadata using no-follow primitives only
* (utimensat/lchown/fchmodat with AT_SYMLINK_NOFOLLOW), confined fd-relative
* under the authorized root. `omit_link_times` (-J/--omit-link-times)
* suppresses the timestamps; the link's mode/ownership are still attempted
* (ownership stays gated by the identity policy and by default is not applied).
* A null metadata or an unfollowable parent is a harmless no-op. */
void file_restore_symlink_metadata(const char* path, const FileMetadata* metadata,
bool omit_link_times);
/* Compare timestamps using rsync's whole-second modification window. */
bool metadata_mtime_matches(time_t left_sec, long left_nsec, time_t right_sec, long right_nsec,
+38
View File
@@ -39,7 +39,14 @@ PipelineContextSender* pipeline_context_sender_create(Config* config, Queue* que
atomic_init(&context->cancelled, false);
protocol_session_init(&context->allocation_session, -1, -1);
protocol_session_set_max_alloc(&context->allocation_session, config->max_alloc);
context->dir_entries = NULL;
context->dir_entries_mutex_init = false;
int init = 0;
if (config->use_metadata) {
context->dir_entries = array_list_create(file_destroy);
if (!context->dir_entries)
goto fail;
}
if (mtx_init(&context->mutex_scanner, mtx_plain) != thrd_success)
goto fail;
init++;
@@ -62,10 +69,17 @@ PipelineContextSender* pipeline_context_sender_create(Config* config, Queue* que
goto fail;
// cppcheck-suppress unreadVariable
init++;
if (mtx_init(&context->dir_entries_mutex, mtx_plain) != thrd_success)
goto fail;
context->dir_entries_mutex_init = true;
return context;
fail:
log_perror("Error initializing synchronization objects");
if (context->dir_entries_mutex_init)
mtx_destroy(&context->dir_entries_mutex);
if (context->dir_entries)
array_list_delete(context->dir_entries);
if (init >= 6)
cnd_destroy(&context->condition_not_empty_loader);
if (init >= 5)
@@ -92,6 +106,10 @@ void pipeline_context_sender_destroy(PipelineContextSender* context) {
array_list_delete(context->missing_args);
if (context->remove_source_files)
array_list_delete(context->remove_source_files);
if (context->dir_entries)
array_list_delete(context->dir_entries);
if (context->dir_entries_mutex_init)
mtx_destroy(&context->dir_entries_mutex);
config_delete(context->config);
queue_destroy(context->queue_scanner);
queue_destroy(context->queue_loader);
@@ -117,6 +135,7 @@ PipelineContextReceiver* pipeline_context_receiver_create(Config* config, Queue*
context->outcomes.entries = NULL;
context->outcomes.count = 0;
context->outcomes.capacity = 0;
dir_time_list_init(&context->dir_times);
protocol_session_init(&context->session, file_descriptor, file_descriptor);
protocol_session_set_ssl(&context->session, ssl);
context->receiver_done = false;
@@ -155,6 +174,7 @@ void pipeline_context_receiver_destroy(PipelineContextReceiver* context) {
delete_manifest_free(context->deferred_manifest);
queue_destroy(context->queue);
receiver_outcomes_destroy(&context->outcomes);
dir_time_list_free(&context->dir_times);
mtx_destroy(&context->mutex);
cnd_destroy(&context->condition_not_full);
cnd_destroy(&context->condition_not_empty);
@@ -307,6 +327,24 @@ int write_thread(void* pipeline_context) {
return thrd_error;
}
}
/* P7 Wave D: a directory's times are never applied inline (a later child
write would clobber them); accumulate the metadata here and let the
caller apply it once every writer has drained. */
if (result != FILE_SAVE_ERROR && file->is_dir && file->metadata &&
context->config->use_metadata && !context->config->omit_dir_times &&
!dir_time_list_add(&context->dir_times, file->path, file->metadata)) {
file_destroy(file);
pipeline_context_receiver_note_bytes_released(context, file_bytes);
mtx_lock(&context->mutex);
atomic_store(&context->cancelled, true);
context->receiver_done = true;
cnd_broadcast(&context->condition_not_full);
cnd_broadcast(&context->condition_not_empty);
mtx_unlock(&context->mutex);
free(root_directory);
protocol_session_unbind();
return thrd_error;
}
/* Record the per-file outcome so a --remove-source-files sender learns
which sources were actually written versus skipped on the receiver.
Explicit directory entries and recreated device/special nodes have no
+13 -2
View File
@@ -67,6 +67,13 @@ typedef struct {
* before it reads the manifest, so no additional synchronization is needed
* to suppress the manifest. */
bool scan_stopped_early;
/* P7 Wave D: captured source directory times, filled by the scanner thread
* (and its parallel workers, guarded by dir_entries_mutex) and drained by the
* sender thread in the terminal STATUS_DIR_TIMES frame. Owned by the
* context; NULL for non-metadata transfers. */
ArrayList* dir_entries;
mtx_t dir_entries_mutex;
bool dir_entries_mutex_init;
} PipelineContextSender;
typedef struct PipelineContextReceiver {
@@ -94,9 +101,13 @@ typedef struct PipelineContextReceiver {
protocol stream but hands the manifest here instead of deleting while the
disk writer may still be draining; the caller (server.c) commits the
deletion after both threads have joined, so no extra is removed unless the
transfer truly succeeded. NULL in the early delete modes (which delete at
the manifest). */
transfer truly succeeded. NULL in the early delete modes (which delete at
the manifest). */
DeleteManifest* deferred_manifest;
/* P7 Wave D: directory metadata collected by write_thread from received
directory entries. Only write_thread mutates it (before it joins); the
caller (server.c) applies it after the delete/delay-updates phase. */
DirTimeList dir_times;
} PipelineContextReceiver;
PipelineContextSender* pipeline_context_sender_create(Config* config, Queue* queue_scanner,
+6
View File
@@ -407,6 +407,12 @@ static const char* status_to_string(Status status) {
return "APPEND_DATA";
case STATUS_HARDLINK:
return "HARDLINK";
case STATUS_SYMLINK:
return "SYMLINK";
case STATUS_SPECIAL:
return "SPECIAL";
case STATUS_DIR_TIMES:
return "DIR_TIMES";
default:
return "UNKNOWN";
}
+10 -1
View File
@@ -103,7 +103,16 @@ enum NET_STATUS {
* int32 rdev major/minor fields. The receiver validates the kind and rdev,
* confines the node below the receive root, and recreates it (mknod/mkfifo),
* privilege-gating the mknod. Protocol 2.13.0. */
STATUS_SPECIAL
STATUS_SPECIAL,
/* Directory-time superstructure (P7 Wave D, protocol 2.17.0): a single
* terminal frame sent after all file data (and after the optional delete
* manifest) carrying every source directory's captured metadata so the
* receiver can apply directory mtimes/atimes AFTER all of a directory's
* children have been written. Payload: an int count, then count repetitions
* of (wire path string, metadata frame). The receiver defers the actual
* utimensat until its own delete/publish phase has committed, then skips the
* whole set when -O/--omit-dir-times is set. */
STATUS_DIR_TIMES
};
void io_set_fds(int read_fd, int write_fd);
+125
View File
@@ -4672,3 +4672,128 @@ class TestConnectivityClientOptions:
f"--blocking-io -c failed: {(result.stderr or result.stdout)[:300]}"
mismatches, missing = verify_transfer(source, get_dest_received_dir(dest, source))
assert not mismatches and not missing
DISTINCT_MTIME = 1_000_000_000 # 2001-09-09T01:46:40Z; a whole second
class TestDirectoryAndSymlinkTimes:
"""P7 Wave D: -O/--omit-dir-times and -J/--omit-link-times are real.
FastSync now captures and applies directory mtimes (deferred to the end of
the transfer, after children) and symlink mtimes (immediate, via no-follow
primitives). -O/-J suppress exactly their own class of times.
"""
def _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", "deep"), exist_ok=True)
with open(os.path.join(source, "sub", "file.txt"), "wb") as fh:
fh.write(b"content\n")
with open(os.path.join(source, "sub", "deep", "deep.txt"), "wb") as fh:
fh.write(b"deeper\n")
dirs = (source, os.path.join(source, "sub"), os.path.join(source, "sub", "deep"))
for d in dirs:
os.utime(d, (DISTINCT_MTIME, DISTINCT_MTIME))
if abs(os.stat(source).st_mtime - DISTINCT_MTIME) > 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"
+3 -3
View File
@@ -94,14 +94,14 @@ def _seed_protocol_source(source):
class TestProtocol:
@pytest.mark.ci
def test_protocol_current_version_accepted(self, shared_server):
"""--protocol=2.16.0 (the current PROTOCOL_VERSION) is accepted and the
"""--protocol=2.17.0 (the current PROTOCOL_VERSION) is accepted and the
transfer completes normally."""
source = os.path.join(TEST_DATA_DIR, "proto_ok_src")
dest = os.path.join(TEST_DATA_DIR, "proto_ok_dst")
shutil.rmtree(dest, ignore_errors=True)
os.makedirs(dest)
_seed_protocol_source(source)
result, _ = run_client(source, dest, flags=["--protocol=2.16.0"],
result, _ = run_client(source, dest, flags=["--protocol=2.17.0"],
port=shared_server.port)
assert result.returncode == 0, \
f"--protocol current run failed: {(result.stderr or result.stdout)[:400]}"
@@ -118,7 +118,7 @@ class TestProtocol:
shutil.rmtree(dest, ignore_errors=True)
os.makedirs(dest)
_seed_protocol_source(source)
for bad in ("2.15.0", "2.17.0", "216", "31"):
for bad in ("2.15.0", "2.16.0", "216", "31"):
result, _ = run_client(source, dest, flags=[f"--protocol={bad}"],
port=shared_server.port)
assert result.returncode != 0, f"--protocol={bad} should be rejected"
+3 -3
View File
@@ -222,7 +222,7 @@ static void test_parse_args_protocol_accept_current() {
Config* cfg = valid_client_config();
EXPECT_NOT_NULL(cfg);
char* argv_equals[] = {"fastsync", "--source-dir", "/src",
"--dest-dir", "/dst", "--protocol=2.16.0"};
"--dest-dir", "/dst", "--protocol=2.17.0"};
int positional_args[2];
int positional_count = 0;
EXPECT_EQ_INT(parse_args(cfg, 6, argv_equals, positional_args, &positional_count), 0);
@@ -232,7 +232,7 @@ static void test_parse_args_protocol_accept_current() {
cfg = valid_client_config();
EXPECT_NOT_NULL(cfg);
char* argv_space[] = {"fastsync", "--source-dir", "/src", "--dest-dir",
"/dst", "--protocol", "2.16.0"};
"/dst", "--protocol", "2.17.0"};
positional_count = 0;
EXPECT_EQ_INT(parse_args(cfg, 7, argv_space, positional_args, &positional_count), 0);
EXPECT_EQ_STR(cfg->version, PROTOCOL_VERSION);
@@ -242,7 +242,7 @@ static void test_parse_args_protocol_accept_current() {
/* Any --protocol value other than the current PROTOCOL_VERSION must end in
* failure (parse_args simply stores it; validate_config rejects it up front). */
static void test_parse_args_protocol_rejects_other_versions() {
static const char* const bad_versions[] = {"2.16", "2.15.0", "2.17.0", "216", "31", "abc", ""};
static const char* const bad_versions[] = {"2.16", "2.15.0", "2.16.0", "216", "31", "abc", ""};
for (size_t i = 0; i < sizeof(bad_versions) / sizeof(bad_versions[0]); i++) {
Config* cfg = valid_client_config();
EXPECT_NOT_NULL(cfg);
+35
View File
@@ -1,5 +1,6 @@
#include "test_file.h"
#include "file.h"
#include "file_receive.h"
#include "data.h"
#include "config.h"
#include "utils.h"
@@ -1216,6 +1217,39 @@ void test_trust_sender() {
file_set_authorized_root(-1, NULL);
}
/* P7 Wave D: the deferred directory-time list deep-copies entries and applies
* them (fd-relative, no-follow) to an existing directory, then frees cleanly. */
static void test_dir_time_list() {
const char* root = "test_dir_time_root";
const char* sub = "test_dir_time_root/sub";
file_set_authorized_root(-1, NULL);
rmdir(sub);
rmdir(root);
EXPECT_EQ_INT(mkdir(root, 0755), 0);
EXPECT_EQ_INT(mkdir(sub, 0755), 0);
DirTimeList list;
dir_time_list_init(&list);
EXPECT_EQ_INT((int)list.count, 0);
FileMetadata metadata = {.mtime_sec = 1000000000, .mtime_nsec = 0};
EXPECT_TRUE(dir_time_list_add(&list, "sub", &metadata));
EXPECT_TRUE(dir_time_list_add(&list, "sub", &metadata));
EXPECT_EQ_INT((int)list.count, 2);
dir_time_list_apply(&list, root);
struct stat st;
EXPECT_EQ_INT(stat(sub, &st), 0);
EXPECT_EQ_INT((int)st.st_mtime, 1000000000);
dir_time_list_free(&list);
EXPECT_EQ_INT((int)list.count, 0);
EXPECT_NULL(list.paths);
EXPECT_NULL(list.entries);
rmdir(sub);
rmdir(root);
}
void test_file() {
test_file_create();
test_file_special_rdev_valid();
@@ -1254,6 +1288,7 @@ void test_file() {
test_file_send_single_calls_metadata_and_path();
}
test_file_metadata_create();
test_dir_time_list();
test_inplace_overwrite_clears_special_mode_bits();
test_inplace_overwrite_metadata_strips_special_bits();
test_inplace_overwrite_truncates_shorter_payload();
+34
View File
@@ -302,6 +302,39 @@ static void test_chmod_changes() {
EXPECT_FALSE(chmod_apply(0777, "a+r,", &result));
}
/* P7 Wave D: symlink metadata is applied with no-follow primitives, and -J
* (omit_link_times) suppresses the timestamp. The test is robust to
* filesystems that silently ignore symlink timestamps: it mainly proves the
* omit path never touches the stored time. */
static void test_file_restore_symlink_metadata() {
const char* dir = "temp_symlink_md_test";
const char* target = "temp_symlink_md_test/target";
const char* link = "temp_symlink_md_test/link";
EXPECT_EQ_INT(mkdir(dir, 0755), 0);
FILE* f = fopen(target, "w");
EXPECT_NOT_NULL(f);
fputs("t", f);
fclose(f);
EXPECT_EQ_INT(symlink("target", link), 0);
FileMetadata applied = {.mtime_sec = 1000000000, .mtime_nsec = 0};
file_restore_symlink_metadata(link, &applied, false);
struct stat st;
EXPECT_EQ_INT(lstat(link, &st), 0);
EXPECT_TRUE(S_ISLNK(st.st_mode));
time_t t1 = st.st_mtime;
/* -J: a different time must be left untouched. */
FileMetadata newer = {.mtime_sec = 1234567890, .mtime_nsec = 0};
file_restore_symlink_metadata(link, &newer, true);
EXPECT_EQ_INT(lstat(link, &st), 0);
EXPECT_EQ_INT((int)st.st_mtime, (int)t1);
unlink(link);
unlink(target);
rmdir(dir);
}
void test_metadata() {
test_metadata_to_from_buf_roundtrip();
test_metadata_to_buf_null();
@@ -315,5 +348,6 @@ void test_metadata() {
test_file_restore_metadata_applies_atime();
test_file_restore_executability_only();
test_directory_restore_executability_only();
test_file_restore_symlink_metadata();
test_chmod_changes();
}
+47
View File
@@ -1,5 +1,6 @@
#include "test_utils.h"
#include "scanner.h"
#include "array_list.h"
#include "file.h"
#include "file_list.h"
#include "filter.h"
@@ -1262,6 +1263,51 @@ static void test_files_from_relative_send_path() {
rmdir(root);
}
/* P7 Wave D: the recursive scan captures every traversed source directory as an
* is_dir File (metadata, no payload) in the shared dir_entries list, including
* the transfer root, so the sender can transmit directory times at the end. */
static void test_scanner_captures_directory_times() {
const char* root = "test_scan_dirtime";
const char* sub = "test_scan_dirtime/sub";
const char* file1 = "test_scan_dirtime/sub/a.txt";
EXPECT_EQ_INT(mkdir(root, 0755), 0);
EXPECT_EQ_INT(mkdir(sub, 0755), 0);
create_test_file(file1, "x");
ArrayList* dirs = array_list_create(file_destroy);
EXPECT_NOT_NULL(dirs);
ScannerOptions options = {0};
options.use_metadata = true;
options.capture_dir_times = true;
options.dir_entries = dirs;
DirectoryScanner* scanner = directory_scanner_create_with_options(root, &options);
EXPECT_NOT_NULL(scanner);
Chunk* chunk;
while ((chunk = directory_scanner_next(scanner)) != NULL)
chunk_destroy(chunk);
EXPECT_FALSE(directory_scanner_failed(scanner));
int found_root = 0;
int found_sub = 0;
for (int i = 0; i < dirs->size; i++) {
const File* file = (const File*)dirs->items[i];
EXPECT_TRUE(file->is_dir);
EXPECT_NOT_NULL(file->metadata);
if (strcmp(file->path, root) == 0)
found_root = 1;
if (strcmp(file->path, sub) == 0)
found_sub = 1;
}
EXPECT_TRUE(found_root);
EXPECT_TRUE(found_sub);
directory_scanner_destroy(scanner);
array_list_delete(dirs);
unlink(file1);
rmdir(sub);
rmdir(root);
}
void test_scanner() {
test_scanner_single_file();
test_scanner_multiple_files();
@@ -1297,4 +1343,5 @@ void test_scanner() {
test_dirs_no_descent();
test_dirs_files_from();
test_files_from_relative_send_path();
test_scanner_captures_directory_times();
}