Merge feat/p7-times: Phase 7 Wave D (real dir/symlink time preservation making -O/-J meaningful; secluded-args -> Impossible/Divergence; PROTOCOL 2.17.0)
This commit is contained in:
+27
-19
@@ -75,7 +75,7 @@ This document maps rsync's full feature set to FastSync's current implementation
|
||||
| `-r`, `--recursive` | Recurse into directories | ✅ Implemented | Default behavior |
|
||||
| `-R`, `--relative` | Use relative path names | ✅ Implemented | Meaningful together with `--files-from` (FastSync's default full-tree scan always mirrors the full source argument path below the destination root, so -R does not change it). With `-R` + `--files-from` each listed entry is transmitted under its bare relative destination path: an entry `sub/x.txt` lands at `<dest>/sub/x.txt` (its leading components preserved) instead of under the `<dest>/<full source path>` mirror. Only the path sent on the wire changes; the client still reads the absolute source path, and the delete manifest derives from the sent (relative) paths so `--delete` and `--remove-source-files` stay consistent in both layouts. Works single-threaded and under `-j`/`--threads` (including chunk serialization) |
|
||||
| `--no-implied-dirs` | Don't send implied dirs with -R | ✅ Implemented | Client-side, meaningful only with `-R` + `--files-from`. rsync would normally create the ancestor directories implied by a listed file so it can be written; with `--no-implied-dirs` a listed file whose parent directory is not itself (or via an ancestor) explicitly listed cannot be placed, and FastSync fails the whole run up front with a clear error (`--no-implied-dirs: cannot place file '...': parent directory '...' is not explicitly listed`). Listing the directory (or an ancestor of it, or the whole tree `.`) permits the file. In every other mode the option has no effect. FastSync has no per-entry skip channel, so the rsync "omit the file" case is surfaced as a hard pre-transfer error |
|
||||
| `-d`, `--dirs`, `--old-dirs`, `--old-d` | Transfer dirs without recursing | ✅ Implemented | `-d <dir>` transmits an explicit directory entry for the source-root directory, so the destination mirror is created empty and nothing is descended into. With `--files-from` exactly the listed items are transferred: a listed directory is created empty (no descent) and a listed file is transferred with its content; the dest layout follows the same -R rules as plain files. A new wire frame (`STATUS_MKDIR`) carries each directory entry (path only); the receiver creates it with the same confined mkdir-parent semantics as regular writes, in single-threaded and `-j`/`--threads` receivers (chunk serialization carries a per-entry type marker). Directory entries appear in the delete manifest so `--delete` prunes correctly. FastSync divergences: directory mtimes/modes are not transmitted, filter/`--exclude` rules are not re-applied to the listed dirs mode (there is no descent during which they would apply), and `-d` never creates the intermediate directories between the destination root and a listed file beyond the usual on-demand parent creation. Under `--delay-updates` only regular files are staged: directory entries are created immediately, so a delayed run that fails part way can leave the already-created empty directories behind (matching rsync, which also creates directories as it processes the file list and only delays regular-file data) |
|
||||
| `-d`, `--dirs`, `--old-dirs`, `--old-d` | Transfer dirs without recursing | ✅ Implemented | `-d <dir>` transmits an explicit directory entry for the source-root directory, so the destination mirror is created empty and nothing is descended into. With `--files-from` exactly the listed items are transferred: a listed directory is created empty (no descent) and a listed file is transferred with its content; the dest layout follows the same -R rules as plain files. A new wire frame (`STATUS_MKDIR`) carries each directory entry — the path and, when `--preserve`/`-a` (metadata mode) is negotiated, the directory's metadata; the receiver creates it with the same confined mkdir-parent semantics as regular writes, in single-threaded and `-j`/`--threads` receivers (chunk serialization carries a per-entry type marker). Directory entries appear in the delete manifest so `--delete` prunes correctly. Directory TIMES are transmitted (the `STATUS_DIR_TIMES` frame carries every traversed source directory's captured times, including `--dirs` entries) and applied by the receiver at the END of the transfer, after all children and the delete/publication phases, so a later child write cannot clobber a directory's mtime (`-O`/`--omit-dir-times` skips this application). FastSync divergences: directory modes/ownership are still not applied (only times are), and empty directories are still never created (a `STATUS_DIR_TIMES` entry is record-only), filter/`--exclude` rules are not re-applied to the listed dirs mode (there is no descent during which they would apply), and `-d` never creates the intermediate directories between the destination root and a listed file beyond the usual on-demand parent creation. Under `--delay-updates` only regular files are staged: directory entries are created immediately, so a delayed run that fails part way can leave the already-created empty directories behind (matching rsync, which also creates directories as it processes the file list and only delays regular-file data) |
|
||||
| `--mkpath` | Create missing path components | ✅ Implemented | Wire option (client → server). At connection start the server creates the client's destination root directory (and any missing leading components below its own authorized root) when `--mkpath` is set, failing the connection cleanly if it cannot. Without `--mkpath` a destination root that does not exist yet is rejected up front (rsync semantics), so the flag is the only way to transfer into a not-yet-created destination directory. Creation is confined by the same secure mkdir walk as file writes (`O_NOFOLLOW`, no `..`) |
|
||||
|
||||
## 5. Transfer Modifications
|
||||
@@ -113,7 +113,7 @@ This document maps rsync's full feature set to FastSync's current implementation
|
||||
| `--max-delete=NUM` | Max files to delete | ✅ Implemented | `max_delete` config field (default -1 = no client limit; 0 = delete nothing). NUM bounds a `--delete` run with rsync's all-or-nothing semantics: the receiver rehearses the deletion first and, if the destination holds more than NUM extras, deletes NOTHING and fails the transfer with a distinct `--max-delete` error. A run at or below NUM deletes exactly the extras. NUM only applies together with `--delete` (it is inert otherwise, matching rsync). The hard server bound `MAX_SERVER_DELETE_COUNT` (100000) still caps the walk; a NUM above it never raises that cap, and exceeding the server bound is its own all-or-nothing error. Directories count toward the limit (each removed empty directory is one deletion), like rsync |
|
||||
| `--ignore-errors` | Delete even with I/O errors | ✅ Implemented | Sender-side, client-only config field. rsync suppresses `--delete` when the transfer had I/O errors; FastSync's equivalent is a source-scan I/O error (an unreadable directory, e.g. EACCES): by default the scan aborts the run so no deletion happens. With `--ignore-errors` the scan continues past the unreadable directory, the readable tree is transferred and the deletion still runs (the mirror of the unreadable directory is treated as an extra). The run still exits non-zero (the error is reported, matching rsync's error status). Divergence: without the flag FastSync aborts the whole run on the scan error, whereas rsync transfers the rest of the tree and merely skips the deletion; both leave the deletion undone |
|
||||
| `--force` | Force deletion of non-empty dirs | ✅ Implemented | `force_delete` receiver config field (crosses the wire). rsync's `--force` lets an incoming non-directory replace a destination directory; FastSync implements exactly that: when a regular file is written to a path that is currently a (possibly non-empty) destination directory, `--force` removes that directory tree first — confined to the receive root and symlink-safe (O_NOFOLLOW fd walk, symlinks removed by name, never followed) — so the atomic install can place the file. Without `--force` such a write fails and the run aborts. Divergence: `--force` acts on the immediate-install path only; under `--delay-updates` a blocking directory is not cleared (publication renames over regular files) |
|
||||
| `-m`, `--prune-empty-dirs` | Prune empty dir chains | ✅ Implemented | `-m`/`--prune-empty-dirs` (Phase 7 Wave A freed the rsync short `-m`; FastSync multithreading is now `-j`/`--threads`). FastSync's recursive transfer never emits directory entries, so empty directories are inherently never transferred (which is rsync's `-m` behavior) and truly-empty destination directory chains are removed by `--delete` regardless of this flag. The flag's additional real effect is on the `--dirs` explicit directory-entry generator: a plain `-d <empty-dir>` run omits the empty source directory's entry, so nothing is created at the destination (no `STATUS_MKDIR`, no `-i`/`--out-format` change line, and an existing empty mirror becomes an extra that `--delete` prunes). Explicitly `--files-from`-listed directories always pass through (documented `--files-from` behavior). A directory that still holds an excluded-but-protected file survives, matching the `--delete-excluded` default |
|
||||
| `-m`, `--prune-empty-dirs` | Prune empty dir chains | ✅ Implemented | `-m`/`--prune-empty-dirs` (Phase 7 Wave A freed the rsync short `-m`; FastSync multithreading is now `-j`/`--threads`). FastSync's recursive transfer records directory times but never CREATES an empty directory (a `STATUS_DIR_TIMES` entry is record-only, and `--dirs` empty entries are pruned by this flag), so empty directories are inherently never transferred (which is rsync's `-m` behavior) and truly-empty destination directory chains are removed by `--delete` regardless of this flag. The flag's additional real effect is on the `--dirs` explicit directory-entry generator: a plain `-d <empty-dir>` run omits the empty source directory's entry, so nothing is created at the destination (no `STATUS_MKDIR`, no `-i`/`--out-format` change line, and an existing empty mirror becomes an extra that `--delete` prunes). Explicitly `--files-from`-listed directories always pass through (documented `--files-from` behavior). A directory that still holds an excluded-but-protected file survives, matching the `--delete-excluded` default |
|
||||
|
||||
**Deletion-timing implementation notes (Phase 3):** the delete flags above are
|
||||
real. Two new config booleans (`delete_during`, `delete_delay`) join the already
|
||||
@@ -255,8 +255,8 @@ why plain `--append` works on the normal atomic path, not only with `--inplace`.
|
||||
| `--write-devices` | Write to devices as files | ✅ Implemented | 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 | ⛔ Impossible/Divergence | Birth-times cannot be set by any portable filesystem call (`utimensat`/`futimens` only set atime/mtime), so this row is an explicit **Impossible/Divergence** (Phase 7 Wave B). Capture + transmit stays: `statx(STATX_BTIME)` on Linux records the source birth time as a wire field; the receiver logs a debug note that it cannot be applied 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 trailing `STATUS_DIR_TIMES` frame(s) **after all file data and the optional delete manifest** (chunked at the receiver's `MAX_MANIFEST_ENTRIES` per-frame cap); a dir-time entry only RECORDS metadata and never creates the directory, so empty source directories stay untransferred. 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`/`--preserve` 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 | ✅ Implemented | Phase 7 Wave B: full record **and replay**. The receiver writes the source `uid:gid:mode:mtime_sec:mtime_nsec` into a reserved `user.fastsync.stat` xattr on each written file (best-effort, fd-relative, format unchanged), then immediately re-applies it via `fake_super_restore_fd`: `fchown` (only where privileged — a non-root EPERM/EACCES is skipped silently, matching FastSync's identity philosophy), `fchmod`, and `futimens`. The restored mode goes through the same sanitization as the normal metadata path (group/other write bits are never granted, so a recorded 0666 restores as 0644), so fake-super replay can never grant group/other-write that plain `--preserve` would refuse. Absence or a malformed record is a silent no-op, never fatal. The recording format diverges from rsync's `user.rsync.%stat%`; 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 |
|
||||
@@ -349,14 +349,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 trailing `STATUS_DIR_TIMES` frame(s),
|
||||
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
|
||||
@@ -524,9 +525,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
|
||||
@@ -673,10 +675,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. |
|
||||
|
||||
---
|
||||
@@ -789,7 +791,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).
|
||||
|
||||
@@ -816,7 +818,13 @@ These are the last compatibility items and the closing phase toward rsync flag p
|
||||
|
||||
**Wave C — Devices & special files (finalize statuses + tests) (✅ implemented).** The four special-file rows are finalized with coverage tests. `--devices`, `--copy-devices`, and `--write-devices` are **✅ Implemented**, each with a documented, safety-driven divergence: device-node creation is privilege-gated, so a receiver without `CAP_MKNOD` skips that entry with a warning (a per-entry skip, never a transfer failure); `--copy-devices` copies a device/FIFO's reported size into an ordinary regular file (a size-bounded safe divergence from rsync's unbounded dd-like read); `--write-devices` writes only into an existing char/block node under the confined receive root and skips every unusable target rather than clobbering or aborting. `--specials` is classified **⛔ Impossible/Divergence** for one reason only: **FIFO recreation works** (unprivileged `mkfifo`, asserted under CI), but **sockets cannot be recreated by any standard filesystem call**, so a source socket is skipped with an explicit note. Tests assert FIFO recreation, the safe socket skip, the regular-file result of `--copy-devices`, the skipped/missing and non-device `--write-devices` targets, and (root-gated) real device-node creation; a root runner additionally drops the receiver to an unprivileged user to assert the `CAP_MKNOD` skip is graceful.
|
||||
|
||||
**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 trailing `STATUS_DIR_TIMES` frames (each: int count + count × (wire path, metadata) pairs) sent **after all file data and after the optional delete manifest**, just before `STATUS_FINISHED`. A tree larger than `MAX_MANIFEST_ENTRIES` (1 048 576) directories is chunked into repeated frames, each within the receiver's per-frame bound. A dir-time entry is RECORD-ONLY (`file->dir_time_only`): `file_save_to_disk_full` returns `FILE_SAVE_SKIPPED` without creating anything, so a source directory that was empty (or pruned by `-m/--prune-empty-dirs`) is never resurrected. 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: an absent path (an intentionally uncreated empty dir) is skipped QUIETLY and only a real existing directory is stamped. `-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 `-j`/`--threads` 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.
|
||||
|
||||
|
||||
@@ -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,51 @@ 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 terminal
|
||||
STATUS_DIR_TIMES frames (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.
|
||||
|
||||
The receiver rejects a frame whose count exceeds MAX_MANIFEST_ENTRIES, so a
|
||||
huge tree is CHUNKED into repeated frames of at most that many entries each
|
||||
(the receiver's loop handles repeated STATUS_DIR_TIMES frames). Every frame
|
||||
stays within the receiver's bound, and a frame that would exceed it is never
|
||||
emitted. */
|
||||
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;
|
||||
int fd = client->file_descriptor;
|
||||
int index = 0;
|
||||
while (index < dir_entries->size) {
|
||||
int remaining = dir_entries->size - index;
|
||||
int chunk = remaining > MAX_MANIFEST_ENTRIES ? MAX_MANIFEST_ENTRIES : remaining;
|
||||
if (!send_status(fd, STATUS_DIR_TIMES) || !send_int(fd, chunk))
|
||||
return false;
|
||||
for (int i = 0; i < chunk; i++) {
|
||||
File* file = (File*)dir_entries->items[index + 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;
|
||||
}
|
||||
index += chunk;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/* Transmit one symlink entry: a STATUS_SYMLINK frame carrying the destination
|
||||
@@ -1313,10 +1355,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;
|
||||
@@ -1501,6 +1543,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,
|
||||
@@ -1542,6 +1591,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
|
||||
@@ -1843,6 +1896,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 trailing
|
||||
STATUS_DIR_TIMES frame(s) (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);
|
||||
@@ -1855,6 +1911,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)
|
||||
@@ -1919,6 +1980,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;
|
||||
@@ -2037,6 +2102,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,
|
||||
@@ -2078,6 +2148,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);
|
||||
|
||||
@@ -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 trailing STATUS_DIR_TIMES frame(s). `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);
|
||||
|
||||
@@ -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 trailing STATUS_DIR_TIMES
|
||||
* frame(s) 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 {
|
||||
|
||||
+44
-2
@@ -74,6 +74,25 @@ static bool receiver_process_chunk(Chunk* chunk, const ReceiverSink* sink) {
|
||||
return true;
|
||||
}
|
||||
|
||||
/* P7 Wave D: read one STATUS_DIR_TIMES frame (a count followed by that many
|
||||
* (path, metadata) directory entries) and route every entry through the regular
|
||||
* store_file sink. A dir-time entry is RECORD-ONLY (file->dir_time_only): the
|
||||
* sink accumulates its metadata for end-of-transfer application but creates
|
||||
* nothing, so an empty/pruned source directory is never resurrected. A large
|
||||
* tree arrives as repeated frames, each bounded by MAX_MANIFEST_ENTRIES; 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 +180,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 +204,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 +333,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 +349,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 +381,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;
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
|
||||
+22
-2
@@ -543,8 +543,28 @@ 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 STATUS_DIR_TIMES
|
||||
* frame(s) (each a count followed by (path, metadata) pairs, chunked so no
|
||||
* frame exceeds the receiver's MAX_MANIFEST_ENTRIES bound) 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
|
||||
|
||||
@@ -149,6 +149,7 @@ File* file_create(const char* path) {
|
||||
file->metadata = NULL;
|
||||
file->skip = false;
|
||||
file->is_dir = false;
|
||||
file->dir_time_only = false;
|
||||
file->basis_link = NULL;
|
||||
file->link_group = 0;
|
||||
file->link_first = false;
|
||||
|
||||
+174
-5
@@ -551,6 +551,18 @@ FileSaveResult file_save_to_disk_full(const char* root_directory, const File* fi
|
||||
return FILE_SAVE_ERROR;
|
||||
}
|
||||
|
||||
/* P7 Wave D #1: a STATUS_DIR_TIMES entry is RECORD-ONLY. The scanner
|
||||
captures every traversed directory -- including empty ones whose parents
|
||||
were never created by a child write and directories pruned by
|
||||
-m/--prune-empty-dirs. Creating them here would resurrect empty
|
||||
directories (an -a behavior change) and could abort the whole transfer on a
|
||||
pre-existing regular file/symlink at the mirror path. Short-circuit before
|
||||
any device/write-devices/directory branch and report it as skipped so the
|
||||
sink still accumulates its metadata for the deferred DirTimeList
|
||||
application, but create nothing. */
|
||||
if (file->dir_time_only)
|
||||
return FILE_SAVE_SKIPPED;
|
||||
|
||||
/* Device/special node (--devices/--specials): recreate the node instead of
|
||||
writing content (privilege-gated, confined, rdev-validated). */
|
||||
if (file->is_special)
|
||||
@@ -621,6 +633,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 +2155,119 @@ 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;
|
||||
/* Assign each grown array as soon as its realloc succeeds: the old block is
|
||||
already freed by then, so discarding the pointer would dangle. capacity
|
||||
is advanced only after BOTH reallocs succeed, so a partial failure leaves
|
||||
capacity no larger than the entries allocation (the paths array may be
|
||||
over-allocated, which is harmless) -- never a mismatched list the next
|
||||
add could write past. */
|
||||
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. */
|
||||
int parent_fd = file_open_secure_parent(dir_path, &leaf, false);
|
||||
if (parent_fd < 0) {
|
||||
free(dir_path);
|
||||
continue;
|
||||
}
|
||||
/* A dir-time entry only records metadata: the directory is (deliberately)
|
||||
not created from it, so an empty source directory (or one pruned by
|
||||
-m/--prune-empty-dirs) may well not exist here. Skip absent paths
|
||||
QUIETLY rather than warning for every one, and apply the times only to a
|
||||
real directory that does exist. AT_SYMLINK_NOFOLLOW keeps a same-named
|
||||
symlink from being followed; a pre-existing regular file/symlink is not a
|
||||
directory, so it is left completely untouched. */
|
||||
struct stat st;
|
||||
if (fstatat(parent_fd, leaf, &st, AT_SYMLINK_NOFOLLOW) != 0 || !S_ISDIR(st.st_mode)) {
|
||||
close(parent_fd);
|
||||
free(leaf);
|
||||
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 +2284,50 @@ 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 a 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, dir_time_only
|
||||
* entry routed through the regular store_file sink: the sink records its
|
||||
* metadata into the deferred DirTimeList but never creates the directory (the
|
||||
* scanner captures every traversed directory, including empty ones). Unlike a
|
||||
* STATUS_MKDIR entry, this one must not create anything. */
|
||||
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;
|
||||
file->dir_time_only = 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;
|
||||
}
|
||||
|
||||
|
||||
@@ -8,13 +8,40 @@
|
||||
/* 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
|
||||
* trailing STATUS_DIR_TIMES frame(s)) 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: an absent
|
||||
* directory (an empty/pruned source dir that was deliberately not created) or a
|
||||
* non-directory at the path is skipped QUIETLY, an unreachable one with a
|
||||
* warning, and 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
|
||||
|
||||
@@ -42,6 +42,14 @@ typedef struct {
|
||||
/* True when this entry is an explicit directory entry (--dirs mode): the
|
||||
* receiver creates the directory instead of writing a regular file. */
|
||||
bool is_dir;
|
||||
/* Receiver-only (P7 Wave D): this is a STATUS_DIR_TIMES entry. It carries a
|
||||
* traversed source directory's metadata for DEFERRED application, but must
|
||||
* NEVER create the directory: the scanner captures every traversed directory
|
||||
* (including empty ones whose parents no child write created), so creation
|
||||
* would resurrect the empty dirs that FastSync deliberately never transfers.
|
||||
* file_save_to_disk_full short-circuits such an entry as FILE_SAVE_SKIPPED,
|
||||
* and the sink still accumulates the metadata into its DirTimeList. */
|
||||
bool dir_time_only;
|
||||
/* Receiver-only, --link-dest: when set, install the destination entry as a
|
||||
* hard link to this absolute (root-confined) path instead of writing
|
||||
* `data`. The matching code has already verified the link target's content
|
||||
|
||||
+58
-29
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 trailing STATUS_DIR_TIMES frame(s). 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,
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
+11
-1
@@ -103,7 +103,17 @@ 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): one or more
|
||||
* trailing frames sent after all file data (and after the optional delete
|
||||
* manifest) carrying the source directories' captured metadata so the
|
||||
* receiver can apply directory mtimes/atimes AFTER all of a directory's
|
||||
* children have been written. Payload per frame: an int count, then count
|
||||
* repetitions of (wire path string, metadata frame); an entry count larger
|
||||
* than MAX_MANIFEST_ENTRIES is split across repeated frames. 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);
|
||||
|
||||
@@ -4904,3 +4904,197 @@ 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"
|
||||
|
||||
@pytest.mark.ci
|
||||
@pytest.mark.parametrize("mt", [False, True])
|
||||
def test_preserve_does_not_create_empty_source_dir(self, shared_server, mt):
|
||||
"""P7 Wave D #1: a captured-but-EMPTY source directory is never created
|
||||
at the destination. The scanner records its time (it is transmitted via
|
||||
STATUS_DIR_TIMES), but the receiver treats that entry as record-only, so
|
||||
`-a` keeps the documented "empty dirs are never transferred" behavior."""
|
||||
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 not os.path.lexists(os.path.join(received, "empty_sub")), \
|
||||
f"-a created an 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_dir_time_path_does_not_abort(self, shared_server, mt):
|
||||
"""P7 Wave D #1: a pre-existing regular file at a source-empty-dir's
|
||||
mirror path must not abort the transfer (the old mkdir failed and failed
|
||||
the run) and must not be clobbered."""
|
||||
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.isfile(blocker) and not os.path.islink(blocker), \
|
||||
"the pre-existing blocker was replaced by a directory"
|
||||
with open(blocker, "rb") as fh:
|
||||
assert fh.read() == b"pre-existing blocker\n", "the blocker file was clobbered"
|
||||
assert os.path.isfile(os.path.join(received, "keep.txt")), "regular file missing"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -223,7 +223,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);
|
||||
@@ -233,7 +233,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);
|
||||
@@ -243,7 +243,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);
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
#include "test_file.h"
|
||||
#include "file.h"
|
||||
#include "file_store.h"
|
||||
#include "file_receive.h"
|
||||
#include "data.h"
|
||||
#include "config.h"
|
||||
#include "utils.h"
|
||||
@@ -1336,6 +1337,39 @@ static void test_file_write_to_disk_partial_retention() {
|
||||
EXPECT_TRUE(access(path, F_OK) == -1);
|
||||
}
|
||||
|
||||
/* 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();
|
||||
@@ -1376,6 +1410,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();
|
||||
|
||||
@@ -302,6 +302,45 @@ 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 positive apply path is
|
||||
* asserted when the filesystem actually stores symlink timestamps; a filesystem
|
||||
* that silently ignores them (or a platform where utimensat AT_SYMLINK_NOFOLLOW
|
||||
* is unsupported) is tolerated, in which case only the omit-path invariant is
|
||||
* checked. */
|
||||
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);
|
||||
|
||||
/* Positive path: a non-omitted apply stamps the link's own mtime. */
|
||||
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));
|
||||
bool symlink_times_supported = ((int)st.st_mtime == 1000000000);
|
||||
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);
|
||||
if (symlink_times_supported)
|
||||
EXPECT_EQ_INT((int)st.st_mtime, 1000000000);
|
||||
|
||||
unlink(link);
|
||||
unlink(target);
|
||||
rmdir(dir);
|
||||
}
|
||||
|
||||
void test_metadata() {
|
||||
test_metadata_to_from_buf_roundtrip();
|
||||
test_metadata_to_buf_null();
|
||||
@@ -315,5 +354,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();
|
||||
}
|
||||
|
||||
@@ -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,60 @@ 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 and an EMPTY directory. The empty dir is captured even
|
||||
* though the receiver deliberately never creates it, so its time can still be
|
||||
* applied when the destination already holds that directory. */
|
||||
static void test_scanner_captures_directory_times() {
|
||||
const char* root = "test_scan_dirtime";
|
||||
const char* sub = "test_scan_dirtime/sub";
|
||||
const char* empty = "test_scan_dirtime/empty";
|
||||
const char* file1 = "test_scan_dirtime/sub/a.txt";
|
||||
EXPECT_EQ_INT(mkdir(root, 0755), 0);
|
||||
EXPECT_EQ_INT(mkdir(sub, 0755), 0);
|
||||
EXPECT_EQ_INT(mkdir(empty, 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;
|
||||
int found_empty = 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;
|
||||
if (strcmp(file->path, empty) == 0)
|
||||
found_empty = 1;
|
||||
}
|
||||
EXPECT_TRUE(found_root);
|
||||
EXPECT_TRUE(found_sub);
|
||||
EXPECT_TRUE(found_empty);
|
||||
|
||||
directory_scanner_destroy(scanner);
|
||||
array_list_delete(dirs);
|
||||
unlink(file1);
|
||||
rmdir(empty);
|
||||
rmdir(sub);
|
||||
rmdir(root);
|
||||
}
|
||||
|
||||
void test_scanner() {
|
||||
test_scanner_single_file();
|
||||
test_scanner_multiple_files();
|
||||
@@ -1297,4 +1352,5 @@ void test_scanner() {
|
||||
test_dirs_no_descent();
|
||||
test_dirs_files_from();
|
||||
test_files_from_relative_send_path();
|
||||
test_scanner_captures_directory_times();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user