Merge PR #311: transport I/O vtable + symlink-xattr wire block (2.29.0)
CI / lint (push) Successful in 1m48s
CI / parity-fast (push) Skipped
CI / parity-full (push) Successful in 20s
CI / sanitizers (address) (push) Successful in 54s
CI / sanitizers (undefined) (push) Successful in 45s
CI / build-and-test (push) Successful in 1m20s
CI / fuzz-build (push) Successful in 48s
CI / coverage (push) Successful in 46s
CI / valgrind (push) Successful in 2m35s

This commit was merged in pull request #311.
This commit is contained in:
2026-09-23 01:50:51 +02:00
24 changed files with 1203 additions and 115 deletions
+1 -1
View File
@@ -16,7 +16,7 @@ Ask the user or determine from context:
- **Minor** (x.Y.0) — new features, backward compatible - **Minor** (x.Y.0) — new features, backward compatible
- **Patch** (x.y.Z) — bug fixes, no protocol changes - **Patch** (x.y.Z) — bug fixes, no protocol changes
Current version: `PROTOCOL_VERSION "2.26.0"` in `src/shared/config.h` Current version: `PROTOCOL_VERSION "2.29.0"` in `src/shared/config.h`
### Step 2: Check Protocol Version ### Step 2: Check Protocol Version
+22
View File
@@ -25,6 +25,28 @@ remain unimplemented (accepted-but-ignored); the matrix is therefore **119 ✅ /
fixes** below) moves `-F` and `-i` to ⚠️, for a final **117 ✅ / 13 ⚠️ / 27 ❌** fixes** below) moves `-F` and `-i` to ⚠️, for a final **117 ✅ / 13 ⚠️ / 27 ❌**
of 157 rows. of 157 rows.
A no-wire parity burn-down cycle follows on 2.28.0: it accepts
`--inc-recursive`/`--no-inc-recursive` as inert no-ops, accepts an absolute
`--temp-dir` that canonicalizes inside the receive root, closes the
`--delete-before` phase-0 divergence (both the single-threaded and `--threads`
data passes replay the pre-scan list), makes `--fake-super` interoperable with
rsync's `user.rsync.%stat` key/grammar (regular files and char/block devices
faked as regular files), turns a failed device `mknod` into a continuing
per-entry failure, and accepts a practical subset of rsync's `rsyncd.conf`
grammar (modules are read-only by default, and accepted-but-unenforced
access-control keys emit a startup warning). The matrix moves to **119 ✅ /
14 ⚠️ / 24 ❌** of 157 rows.
A structural cycle then lands a transport I/O vtable over TCP/TLS (fixing the
TLS-multithreaded sendfile path and making the per-thread SSL resolution
explicit) and bumps the wire to **2.29.0**: the `STATUS_SYMLINK` frame grows an
optional symlink-xattr block (captured no-follow with `llistxattr`/`lgetxattr`,
applied no-follow with `lsetxattr`). Because the handshake is strict, 2.28.0 and
2.29.0 peers are incompatible. Note: Linux refuses to associate xattrs with a
symlink at all, so the symlink-xattr block is a no-op on Linux and is carried
for correctness on platforms/filesystems that do support it; the config-frame
layout is unchanged (golden length still 886).
### Changed ### Changed
- **rsync-exact traversal order.** The sequential scanner now walks each - **rsync-exact traversal order.** The sequential scanner now walks each
+1 -1
View File
@@ -1,6 +1,6 @@
cmake_minimum_required(VERSION 3.22) cmake_minimum_required(VERSION 3.22)
project(FastFileTransfer VERSION 2.28.0) project(FastFileTransfer VERSION 2.29.0)
set(CMAKE_EXPORT_COMPILE_COMMANDS ON) set(CMAKE_EXPORT_COMPILE_COMMANDS ON)
set(CMAKE_C_STANDARD 11) set(CMAKE_C_STANDARD 11)
+1 -1
View File
@@ -832,7 +832,7 @@ before the module list, before authentication, and the connecting peer address
## Protocol and Security ## Protocol and Security
FastSync protocol version `2.28.0` is shared by the client and server. The FastSync protocol version `2.29.0` is shared by the client and server. The
current protocol is sender-driven and includes configuration negotiation, current protocol is sender-driven and includes configuration negotiation,
including the maximum allocation limit, incremental checks, checksums, including the maximum allocation limit, incremental checks, checksums,
manifests, keep-alives, abort handling, per-file remove-source results, and manifests, keep-alives, abort handling, per-file remove-source results, and
+3 -3
View File
@@ -339,7 +339,7 @@ why plain `--append` works on the normal atomic path, not only with `--inplace`.
| `-E`, `--executability` | Preserve executability | ✅ Parity | Preserves executable permission bits (implies metadata preservation) | | `-E`, `--executability` | Preserve executability | ✅ Parity | Preserves executable permission bits (implies metadata preservation) |
| `--chmod=CHMOD` | Affect file permissions | ✅ Parity | Faithful port of rsync 3.4.1's `parse_chmod`/`tweak_mode`: numeric octal and symbolic `ugo`/`rwx` changes, `D`/`F` directory/file selectors, `X` (execute only on directories or already-executable files), `s`/`t` setuid/setgid/sticky, and append semantics — repeated clauses and repeated `--chmod` options accumulate in order (joined with commas). The changes are applied to the new mode **without sanitization** (matching rsync), except that setuid/setgid/sticky are masked when the connection forbids super-user activities (audit-cycle fix, see `-p`), and `--chmod` does **not** imply `-p` (rsync parity). Applied to files and directories on the receiver | | `--chmod=CHMOD` | Affect file permissions | ✅ Parity | Faithful port of rsync 3.4.1's `parse_chmod`/`tweak_mode`: numeric octal and symbolic `ugo`/`rwx` changes, `D`/`F` directory/file selectors, `X` (execute only on directories or already-executable files), `s`/`t` setuid/setgid/sticky, and append semantics — repeated clauses and repeated `--chmod` options accumulate in order (joined with commas). The changes are applied to the new mode **without sanitization** (matching rsync), except that setuid/setgid/sticky are masked when the connection forbids super-user activities (audit-cycle fix, see `-p`), and `--chmod` does **not** imply `-p` (rsync parity). Applied to files and directories on the receiver |
| `-A`, `--acls` | Preserve ACLs | ✅ Parity | Implemented on Linux via the POSIX-ACL xattr representation: the sender captures the `system.posix_acl_access` / `system.posix_acl_default` xattrs and the receiver re-applies them fd-relative. A differential test with `setfacl` confirms the complete access and default ACL sets (including `mask`) are identical to rsync's on a directory. libacl is not required; a `fsetxattr` an unprivileged receiver may not perform is logged and skipped, never fatal. Only the `system.posix_acl_*` namespaces plus `user.*` are ever applied; privileged namespaces are never applied. Implies metadata transmission | | `-A`, `--acls` | Preserve ACLs | ✅ Parity | Implemented on Linux via the POSIX-ACL xattr representation: the sender captures the `system.posix_acl_access` / `system.posix_acl_default` xattrs and the receiver re-applies them fd-relative. A differential test with `setfacl` confirms the complete access and default ACL sets (including `mask`) are identical to rsync's on a directory. libacl is not required; a `fsetxattr` an unprivileged receiver may not perform is logged and skipped, never fatal. Only the `system.posix_acl_*` namespaces plus `user.*` are ever applied; privileged namespaces are never applied. Implies metadata transmission |
| `-X`, `--xattrs` | Preserve extended attributes | ❌ Divergent | Deliberately restricted to unprivileged `user.*` extended attributes plus the two POSIX ACL xattrs; `security.*` (SELinux, capabilities, ...) and `trusted.*` are **never** captured or applied — a client can never force a privileged attribute onto the destination, and the receiver independently re-validates every incoming name against the whitelist. This is a security-policy divergence from rsync, which can preserve the privileged namespaces with the needed privilege; implementing them would defeat FastSync's privilege-escalation guard. `user.*` capture/apply matches rsync in a differential test. Payloads are bounded on both ends. Incompatible with `-s`. **Also divergent: symlink xattrs/ACLs are not captured or applied** — `-X`/`-A` with `-l` carries only the link's owner/times/mode, not its xattrs (the capture uses path-following `listxattr`/`getxattr`, so the link's own xattrs are never read, and the receiver's symlink write path applies no xattr block). Closing this needs a dedicated symlink-xattr wire block and a `PROTOCOL_VERSION` bump | | `-X`, `--xattrs` | Preserve extended attributes | ❌ Divergent | Deliberately restricted to unprivileged `user.*` extended attributes plus the two POSIX ACL xattrs; `security.*` (SELinux, capabilities, ...) and `trusted.*` are **never** captured or applied — a client can never force a privileged attribute onto the destination, and the receiver independently re-validates every incoming name against the whitelist. This is a security-policy divergence from rsync, which can preserve the privileged namespaces with the needed privilege; implementing them would defeat FastSync's privilege-escalation guard. `user.*` capture/apply matches rsync in a differential test. Payloads are bounded on both ends. Incompatible with `-s`. **Symlink xattrs are now carried (protocol 2.29.0):** a symlink entry appends the same bounded trailing xattr block to its `STATUS_SYMLINK` frame as every other entry kind, captured with `llistxattr`/`lgetxattr` so the link's OWN attributes are read and never the referent's, and re-applied no-follow with `lsetxattr` through the already-confined parent directory (`fsetxattr` cannot target a symlink: there is no `*at` xattr syscall and an `O_PATH` fd is rejected). On Linux the VFS refuses to associate xattrs with a symlink at all — every `lsetxattr` on a link fails with `EPERM` for `user.*`, `trusted.*` and `security.*`, even as root, verified in the CI container — so on FastSync's supported platforms the captured block is always empty and the apply is a no-op; the wire block is present for correctness and for a filesystem/platform that does support symlink xattrs. rsync 3.4.1's `--fake-super` is not a counterexample: it stores a symlink as a regular file whose `user.rsync.%stat` records the `S_IFLNK` mode bits, not an xattr on a real symlink. The row stays divergent only for the never-preserved privileged namespaces above |
| `-H`, `--hard-links` | Preserve hard links | ✅ Parity | Files on the source that share an inode (`st_dev`+`st_ino`, e.g. a `cp -al` tree) are re-created as hard links to one another on the destination, so duplicate links stay deduplicated and only the first member's data is sent (later members are transmitted as payload-less `STATUS_HARDLINK` frames). The receiver links each sibling to the first member's installed file with an atomic link + rename; on `link()` failure it falls back to a byte-identical local copy of the first member, never a partial/corrupt file. Requires the sequential scan for ordering (the first member is always emitted and installed before any sibling is linked). Works single-threaded and under `-j`/`--threads`, `--inplace`, `--delay-updates` (links staged and published by rename) and `--partial`. Crosses the wire (`preserve_hard_links` bool; `PROTOCOL_VERSION` bumped **2.11.0 → 2.12.0**, peers must match). Incompatible with `-s` (chunk serialization) and `--append`/`--append-verify`, rejected up front with a distinct error. See the Phase-4 hard-links notes below | | `-H`, `--hard-links` | Preserve hard links | ✅ Parity | Files on the source that share an inode (`st_dev`+`st_ino`, e.g. a `cp -al` tree) are re-created as hard links to one another on the destination, so duplicate links stay deduplicated and only the first member's data is sent (later members are transmitted as payload-less `STATUS_HARDLINK` frames). The receiver links each sibling to the first member's installed file with an atomic link + rename; on `link()` failure it falls back to a byte-identical local copy of the first member, never a partial/corrupt file. Requires the sequential scan for ordering (the first member is always emitted and installed before any sibling is linked). Works single-threaded and under `-j`/`--threads`, `--inplace`, `--delay-updates` (links staged and published by rename) and `--partial`. Crosses the wire (`preserve_hard_links` bool; `PROTOCOL_VERSION` bumped **2.11.0 → 2.12.0**, peers must match). Incompatible with `-s` (chunk serialization) and `--append`/`--append-verify`, rejected up front with a distinct error. See the Phase-4 hard-links notes below |
| `-D` | Same as --devices --specials | ✅ Parity | Implies `--devices --specials`. `-D` was unassigned in FastSync (verified: no collision), so it is free to imply both device-node and special-file preservation. As of protocol 2.23.0 `--specials` genuinely covers **both FIFOs and unix sockets**, so `-D` covers the full rsync set. See the `--devices`/`--specials` rows and the Phase-4 devices notes below | | `-D` | Same as --devices --specials | ✅ Parity | Implies `--devices --specials`. `-D` was unassigned in FastSync (verified: no collision), so it is free to imply both device-node and special-file preservation. As of protocol 2.23.0 `--specials` genuinely covers **both FIFOs and unix sockets**, so `-D` covers the full rsync set. See the `--devices`/`--specials` rows and the Phase-4 devices notes below |
| `--devices` | Preserve device files | ⚠️ Caveat | Recreates char/block device nodes with `mknodat` (type + rdev strictly validated, confined fd-relative below the receive root). A device whose `mknodat` fails with `EPERM`/`EACCES` (no `CAP_MKNOD`, or super-user activity forbidden) is a **per-entry failure**: FastSync logs `cannot create device ...` (rsync logs `mknod ... failed`), counts it, **continues with the remaining files**, and ends the run with a non-OK terminal status. rsync parity: rsync likewise continues and exits partial (23). Residuals: (1) FastSync's default AUTO still *attempts* the node on a non-root receiver and therefore reports the per-entry failure, whereas rsync without `--super` silently ignores `--devices` and skips the non-regular entry with exit 0 — use `--no-super` for rsync's silent-skip behavior; (2) FastSync's process exit code for a receiver-side per-entry failure is the general error code 1, not rsync's partial 23 (a client exit-code-mapping residual that applies to every receiver file error, not just this branch); (3) with `--remove-source-files`, the non-OK terminal status means successfully transferred sources are not removed on a partial run. `--specials` (FIFOs and unix sockets) keeps the unprivileged skip path and remains parity | | `--devices` | Preserve device files | ⚠️ Caveat | Recreates char/block device nodes with `mknodat` (type + rdev strictly validated, confined fd-relative below the receive root). A device whose `mknodat` fails with `EPERM`/`EACCES` (no `CAP_MKNOD`, or super-user activity forbidden) is a **per-entry failure**: FastSync logs `cannot create device ...` (rsync logs `mknod ... failed`), counts it, **continues with the remaining files**, and ends the run with a non-OK terminal status. rsync parity: rsync likewise continues and exits partial (23). Residuals: (1) FastSync's default AUTO still *attempts* the node on a non-root receiver and therefore reports the per-entry failure, whereas rsync without `--super` silently ignores `--devices` and skips the non-regular entry with exit 0 — use `--no-super` for rsync's silent-skip behavior; (2) FastSync's process exit code for a receiver-side per-entry failure is the general error code 1, not rsync's partial 23 (a client exit-code-mapping residual that applies to every receiver file error, not just this branch); (3) with `--remove-source-files`, the non-OK terminal status means successfully transferred sources are not removed on a partial run. `--specials` (FIFOs and unix sockets) keeps the unprivileged skip path and remains parity |
@@ -804,7 +804,7 @@ modes or links.
| `--stop-after=MINS` | Stop after N minutes | ✅ Parity | 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-after=MINS` | Stop after N minutes | ✅ Parity | 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 | ✅ Parity | Deadline transfer stop (client-only, never serialized). Protocol 2.26.0 accepts rsync's full date/time grammar (`2030-12-31T23:59`, `2030/12/31T23:59`, `2030-12-31`, `12-31`, `14:00`, `:59`, `1`) in addition to FastSync's `HH:MM[:SS]` and `now+N[smhd]`; a past time stops immediately. Everything already transferred is kept and an early stop suppresses the late `--delete` keep-set so unscanned source mirrors survive. Works single-threaded and under `-j`/`--threads` | | `--stop-at=TIME` | Stop at specified time | ✅ Parity | Deadline transfer stop (client-only, never serialized). Protocol 2.26.0 accepts rsync's full date/time grammar (`2030-12-31T23:59`, `2030/12/31T23:59`, `2030-12-31`, `12-31`, `14:00`, `:59`, `1`) in addition to FastSync's `HH:MM[:SS]` and `now+N[smhd]`; a past time stops immediately. Everything already transferred is kept and an early stop suppresses the late `--delete` keep-set so unscanned source mirrors survive. Works single-threaded and under `-j`/`--threads` |
| `--fsync` | Fsync every written file before publication | ✅ Parity | | | `--fsync` | Fsync every written file before publication | ✅ Parity | |
| `--protocol=NUM` | Force older protocol version | ❌ Divergent | Forces the wire protocol version for this transfer. FastSync has exactly ONE wire format (`PROTOCOL_VERSION`, currently 2.28.0) with no downgrade/backward-compat code paths, so `--protocol=2.28.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.27.0`/`2.26.0`/`2.25.0`/`2.24.0`/`2.23.0`/`2.22.0`/`2.21.0`/`2.20.0`/`2.19.0`/`2.18.0`/`2.17.0`/`2.16.0`/`2.15.0`/`216`/`31`/garbage are all rejected. See the Phase-6 protocol note below | | `--protocol=NUM` | Force older protocol version | ❌ Divergent | Forces the wire protocol version for this transfer. FastSync has exactly ONE wire format (`PROTOCOL_VERSION`, currently 2.29.0) with no downgrade/backward-compat code paths, so `--protocol=2.29.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.28.0`/`2.27.0`/`2.26.0`/`2.25.0`/`2.24.0`/`2.23.0`/`2.22.0`/`2.21.0`/`2.20.0`/`2.19.0`/`2.18.0`/`2.17.0`/`2.16.0`/`2.15.0`/`216`/`31`/garbage are all rejected. See the Phase-6 protocol note below |
| `--iconv=CONVERT_SPEC` | Charset conversion | ✅ Parity | 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, matching rsync's rule that the spec "stays the same whether you're pushing or pulling": on a PUSH the destination end's charset is the spec's REMOTE half, so the default receiver writes the wire bytes verbatim, and only a server started with its own `--iconv` (the daemon `charset` analog) declares a different destination charset and converts REMOTE→that LOCAL (rsync push parity, differential-tested with and without a server `--iconv`). 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; protocol 2.26.0 additionally accepts `--iconv=.` (the locale's default charset for both directions), `--iconv=-` and `--no-iconv` (disable conversion). 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 | | `--iconv=CONVERT_SPEC` | Charset conversion | ✅ Parity | 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, matching rsync's rule that the spec "stays the same whether you're pushing or pulling": on a PUSH the destination end's charset is the spec's REMOTE half, so the default receiver writes the wire bytes verbatim, and only a server started with its own `--iconv` (the daemon `charset` analog) declares a different destination charset and converts REMOTE→that LOCAL (rsync push parity, differential-tested with and without a server `--iconv`). 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; protocol 2.26.0 additionally accepts `--iconv=.` (the locale's default charset for both directions), `--iconv=-` and `--no-iconv` (disable conversion). 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 | ✅ Parity | Sets the seed for FastSync's whole-file xxHash digest (full 64-bit seed) and for the delta path's per-block xxHash32 strong checksum (low 32 bits of the seed). **As of protocol 2.23.0 a seed of `0` — the default when the flag is unset — is randomized per transfer and the chosen seed is sent to the receiver**, exactly like rsync, so two runs against different content do not share a predictable seed; an explicit non-zero seed is used verbatim, so an explicit seed deterministically reproduces every computed digest on BOTH endpoints (the seed crosses in the config frame). `--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` | | `--checksum-seed=NUM` | Set checksum seed | ✅ Parity | Sets the seed for FastSync's whole-file xxHash digest (full 64-bit seed) and for the delta path's per-block xxHash32 strong checksum (low 32 bits of the seed). **As of protocol 2.23.0 a seed of `0` — the default when the flag is unset — is randomized per transfer and the chosen seed is sent to the receiver**, exactly like rsync, so two runs against different content do not share a predictable seed; an explicit non-zero seed is used verbatim, so an explicit seed deterministically reproduces every computed digest on BOTH endpoints (the seed crosses in the config frame). `--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` |
| `--secluded-args`, `-s` | Use protocol to send args | ❌ Divergent | 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`. | | `--secluded-args`, `-s` | Use protocol to send args | ❌ Divergent | 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`. |
@@ -964,7 +964,7 @@ These are the last compatibility items and the closing phase toward rsync flag p
**Wire:** two trailing config-frame blocks after the `--iconv` spec, in fixed order — `send_privilege_options`/`receive_privilege_options` (one `super_mode` int, validated `0..2`), then `send_copy_as_options`/`receive_copy_as_options` (presence int + two int32 ids, validated `>= 0`, with `copy_as_set ⇒ use_metadata`). `PROTOCOL_VERSION` bumped **2.17.0 → 2.18.0**. **Divergences from rsync:** rsync's `--super` elevates the receiver and `--copy-as` actually switches its credentials; FastSync never elevates and only permits/forwards confined attempts, and `--copy-as` forces ownership rather than switching identity. **Wire:** two trailing config-frame blocks after the `--iconv` spec, in fixed order — `send_privilege_options`/`receive_privilege_options` (one `super_mode` int, validated `0..2`), then `send_copy_as_options`/`receive_copy_as_options` (presence int + two int32 ids, validated `>= 0`, with `copy_as_set ⇒ use_metadata`). `PROTOCOL_VERSION` bumped **2.17.0 → 2.18.0**. **Divergences from rsync:** rsync's `--super` elevates the receiver and `--copy-as` actually switches its credentials; FastSync never elevates and only permits/forwards confined attempts, and `--copy-as` forces ownership rather than switching identity.
**Honest status after the parity 2.29 cycle (protocol 2.28.0, no wire change), updated by the parity cycle 2.29 pass, the audit-cycle follow-ups, the triage cycle, and a later no-wire parity pass.** ✅ Parity 119 / ⚠️ Caveat 14 / ❌ Divergent 24 = 157 rows. The no-wire parity pass accepted `--inc-recursive`/`--no-inc-recursive` as inert no-ops (❌ → ✅, since FastSync's full scan is rsync's `--no-inc-recursive` and the destination is identical), narrowed the `--temp-dir` divergence by accepting an absolute path that canonicalizes inside the receive root (the row stays ❌ for out-of-root absolute paths), closed the `--delete-before` phase-0 divergence (⚠️ → ✅: both the single-threaded and the `--threads` data passes now replay the pre-scan file list, so a source file created after the scan is neither transferred nor kept, matching rsync), and moved `--fake-super` and `--devices` ❌ → ⚠️ (`--fake-super` now writes/reads rsync's exact `user.rsync.%stat` key and `<octal-mode> <rdev_major>,<rdev_minor> <uid>:<gid>` grammar, interoperating with real rsync 3.4.1 for regular files and faking char/block devices as regular files carrying the real rdev; `--devices` now logs a failed device `mknod` as a per-entry failure that continues the transfer instead of a silent non-root skip — see those rows for the remaining directory-faking and exit-code residuals). A review pass then hardened the fake-super stat parser (strict range-checked parsing), made rsync-style daemon modules read-only by default with a startup warning for accepted-but-unenforced access-control keys, and extended the `--delete-before` replay to the `--threads` path. The 2.29 cycle closed the scanner-order, delete-timing, relative-basis, and fuzzy-eligibility residuals (moving `-n`/`--delete`/`--del`/`--delete-delay` to ✅) and improved the `--info`/`--stats`/`--debug` partial rows; the triage cycle moved `-F` and `-i`/`--itemize-changes` ✅ → ⚠️ for their documented residuals. The remaining ⚠️ rows are `--info`, `--debug`, `--msgs2stderr`, `--stats`, `--progress`, `-i`, `--filter`, `-F`, the three basis-dir options, `-y/--fuzzy`, `--fake-super`, and `--devices`. Earlier: **Honest status after the parity 2.28.0 cycle (protocol 2.28.0), updated by the rsync-parity-stats, rsync-parity-options, rsync-parity-fs, parity-review, no-wire parity-track-1/2b and wire parity-track-4a/5a passes.** ✅ Parity 116 / ⚠️ Caveat 14 / ❌ Divergent 27 = 157 rows. Earlier revisions of this document reported "143 ✅ / 0 divergence / 0 partial"; that conflated "parsed and tested" with "rsync parity", because many rows carried documented behavioral differences and some short options were not parsed at all. This reclassification makes every difference explicit. The completion wave closed 23 previously-caveated rows (9 that triage showed were already parity, plus 14 genuine fixes) and turned the 17 inherently non-rsync rows — native daemon config/auth, the FastSync batch container, the safe-subset device/privilege flags, `-X`'s privileged namespaces, `--fake-super`'s native xattr format, and the `--old-args` no-op — into explicit ❌ divergences. The stats pass flipped `--delete-delay` to ✅ (actual-removal accounting), but the parity-review pass moved it back to ⚠️ because FastSync charged the `--max-delete` budget at plan/snapshot time and left a refilled snapshotted directory in place, whereas rsync charges on actual removals and recursively removes a queued directory (including content created after its plan). The no-wire parity-track-1 pass fixed both (actual-removal charging plus recursive deferred removal with an independent deferred-list cap), narrowing the caveat to the partial-delete ordering. The stats pass also reclassified `--out-format` to ❌ (protocol-specific `%b`/delta-`%c`), and sharpened the `--stats`/`--progress`/`--checksum-choice` residuals. The options pass flipped `--bwlimit` and `--ignore-errors` to ✅ (rsync-exact size parsing and ~100 ms leaky-bucket throttling, and rsync's skip-unreadable-subdir plus IO-error-suppressed deletion with exit 23) and emits rsync-format `--info=name/flist/del/remove/nonreg/progress` lines (real-run `deleting`/`*deleting` carried over a new trailing `report_deletes` wire bool, `PROTOCOL_VERSION` 2.26.0 → 2.27.0), while reclassifying `-M` over daemon/TCP **Honest status after the parity 2.29 cycle (protocol 2.29.0 since the symlink-xattr wire wave, which adds no config-frame field and leaves this matrix unchanged), updated by the parity cycle 2.29 pass, the audit-cycle follow-ups, the triage cycle, and a later no-wire parity pass.** ✅ Parity 119 / ⚠️ Caveat 14 / ❌ Divergent 24 = 157 rows. The no-wire parity pass accepted `--inc-recursive`/`--no-inc-recursive` as inert no-ops (❌ → ✅, since FastSync's full scan is rsync's `--no-inc-recursive` and the destination is identical), narrowed the `--temp-dir` divergence by accepting an absolute path that canonicalizes inside the receive root (the row stays ❌ for out-of-root absolute paths), closed the `--delete-before` phase-0 divergence (⚠️ → ✅: both the single-threaded and the `--threads` data passes now replay the pre-scan file list, so a source file created after the scan is neither transferred nor kept, matching rsync), and moved `--fake-super` and `--devices` ❌ → ⚠️ (`--fake-super` now writes/reads rsync's exact `user.rsync.%stat` key and `<octal-mode> <rdev_major>,<rdev_minor> <uid>:<gid>` grammar, interoperating with real rsync 3.4.1 for regular files and faking char/block devices as regular files carrying the real rdev; `--devices` now logs a failed device `mknod` as a per-entry failure that continues the transfer instead of a silent non-root skip — see those rows for the remaining directory-faking and exit-code residuals). A review pass then hardened the fake-super stat parser (strict range-checked parsing), made rsync-style daemon modules read-only by default with a startup warning for accepted-but-unenforced access-control keys, and extended the `--delete-before` replay to the `--threads` path. The 2.29 cycle closed the scanner-order, delete-timing, relative-basis, and fuzzy-eligibility residuals (moving `-n`/`--delete`/`--del`/`--delete-delay` to ✅) and improved the `--info`/`--stats`/`--debug` partial rows; the triage cycle moved `-F` and `-i`/`--itemize-changes` ✅ → ⚠️ for their documented residuals. The remaining ⚠️ rows are `--info`, `--debug`, `--msgs2stderr`, `--stats`, `--progress`, `-i`, `--filter`, `-F`, the three basis-dir options, `-y/--fuzzy`, `--fake-super`, and `--devices`. Earlier: **Honest status after the parity 2.28.0 cycle (protocol 2.28.0), updated by the rsync-parity-stats, rsync-parity-options, rsync-parity-fs, parity-review, no-wire parity-track-1/2b and wire parity-track-4a/5a passes.** ✅ Parity 116 / ⚠️ Caveat 14 / ❌ Divergent 27 = 157 rows. Earlier revisions of this document reported "143 ✅ / 0 divergence / 0 partial"; that conflated "parsed and tested" with "rsync parity", because many rows carried documented behavioral differences and some short options were not parsed at all. This reclassification makes every difference explicit. The completion wave closed 23 previously-caveated rows (9 that triage showed were already parity, plus 14 genuine fixes) and turned the 17 inherently non-rsync rows — native daemon config/auth, the FastSync batch container, the safe-subset device/privilege flags, `-X`'s privileged namespaces, `--fake-super`'s native xattr format, and the `--old-args` no-op — into explicit ❌ divergences. The stats pass flipped `--delete-delay` to ✅ (actual-removal accounting), but the parity-review pass moved it back to ⚠️ because FastSync charged the `--max-delete` budget at plan/snapshot time and left a refilled snapshotted directory in place, whereas rsync charges on actual removals and recursively removes a queued directory (including content created after its plan). The no-wire parity-track-1 pass fixed both (actual-removal charging plus recursive deferred removal with an independent deferred-list cap), narrowing the caveat to the partial-delete ordering. The stats pass also reclassified `--out-format` to ❌ (protocol-specific `%b`/delta-`%c`), and sharpened the `--stats`/`--progress`/`--checksum-choice` residuals. The options pass flipped `--bwlimit` and `--ignore-errors` to ✅ (rsync-exact size parsing and ~100 ms leaky-bucket throttling, and rsync's skip-unreadable-subdir plus IO-error-suppressed deletion with exit 23) and emits rsync-format `--info=name/flist/del/remove/nonreg/progress` lines (real-run `deleting`/`*deleting` carried over a new trailing `report_deletes` wire bool, `PROTOCOL_VERSION` 2.26.0 → 2.27.0), while reclassifying `-M` over daemon/TCP
and receiver-side `protect`/`risk` re-derivation to ❌ (no argv channel / and receiver-side `protect`/`risk` re-derivation to ❌ (no argv channel /
receiver filter engine); the wire parity-track-4a pass later added that receiver filter engine); the wire parity-track-4a pass later added that
receiver filter engine, flipping `--filter=RULE` back to ✅ (see above; the receiver filter engine, flipping `--filter=RULE` back to ✅ (see above; the
+5 -1
View File
@@ -654,7 +654,11 @@ static bool send_symlink_entry(const Client* client, File* file, const Config* c
if (!send_status(fd, STATUS_SYMLINK) || !send_wire_str(fd, file_wire_path(file)) || if (!send_status(fd, STATUS_SYMLINK) || !send_wire_str(fd, file_wire_path(file)) ||
!send_wire_str(fd, file->symlink_target)) !send_wire_str(fd, file->symlink_target))
return false; return false;
return !config->use_metadata || metadata_send(fd, file->metadata); if (config->use_metadata && !metadata_send(fd, file->metadata))
return false;
/* Symlink xattrs/ACLs (-X/-A) ride the same trailing block as regular files
and directories when the xattr transport was negotiated. */
return !config->use_xattrs || xattr_send(fd, file->xattrs);
} }
// Send a single file directly via sendfile (non-incremental path). // Send a single file directly via sendfile (non-incremental path).
+6 -2
View File
@@ -282,11 +282,15 @@ bool entry_passes_selection(const FileListSet* file_list, const FilterRuleList*
} }
/* Best-effort capture of the file's whitelisted xattrs (-X/-A). A failure to /* Best-effort capture of the file's whitelisted xattrs (-X/-A). A failure to
* read xattrs is non-fatal: the file is transferred without them. */ * read xattrs is non-fatal: the file is transferred without them. A symlink
* entry reads the LINK's own xattrs (never the referent's) with the no-follow
* variant; on Linux the VFS refuses xattrs on symlinks, so that yields NULL. */
void scanner_capture_xattrs(const DirectoryScanner* scanner, File* file) { void scanner_capture_xattrs(const DirectoryScanner* scanner, File* file) {
if (!scanner || !file || !(scanner->options.preserve_xattrs || scanner->options.preserve_acls)) if (!scanner || !file || !(scanner->options.preserve_xattrs || scanner->options.preserve_acls))
return; return;
file->xattrs = xattr_capture_path(file->path, scanner->options.preserve_acls); file->xattrs = file->is_symlink
? xattr_capture_path_nofollow(file->path, scanner->options.preserve_acls)
: xattr_capture_path(file->path, scanner->options.preserve_acls);
} }
/* Apply --hard-links (-H) detection to one regular File. On a sibling (a /* Apply --hard-links (-H) detection to one regular File. On a sibling (a
+3 -1
View File
@@ -428,7 +428,9 @@ static void scan_root_entry(const ScannerOptions* options, const FilterNode* roo
} }
if ((options->preserve_xattrs || options->preserve_acls) && if ((options->preserve_xattrs || options->preserve_acls) &&
!(file->link_group != 0 && !file->link_first)) !(file->link_group != 0 && !file->link_first))
file->xattrs = xattr_capture_path(file->path, options->preserve_acls); file->xattrs = file->is_symlink
? xattr_capture_path_nofollow(file->path, options->preserve_acls)
: xattr_capture_path(file->path, options->preserve_acls);
if (!array_list_add(root_files, file)) { if (!array_list_add(root_files, file)) {
free(rel); free(rel);
file_destroy(file); file_destroy(file);
+15 -2
View File
@@ -83,7 +83,7 @@ typedef struct {
typedef enum SuperMode { SUPER_MODE_AUTO = 0, SUPER_MODE_ON = 1, SUPER_MODE_OFF = 2 } SuperMode; typedef enum SuperMode { SUPER_MODE_AUTO = 0, SUPER_MODE_ON = 1, SUPER_MODE_OFF = 2 } SuperMode;
/* =========================================================================== /* ===========================================================================
* Config wire-field table (single source of truth for protocol 2.28.0). * Config wire-field table (single source of truth for protocol 2.29.0).
* *
* Every field below crosses the wire. The table is the ONLY place a * Every field below crosses the wire. The table is the ONLY place a
* serialized field is named: config.h expands CONFIG_WIRE_FIELDS() to declare * serialized field is named: config.h expands CONFIG_WIRE_FIELDS() to declare
@@ -1071,7 +1071,20 @@ typedef struct Config {
* filter rules so the receiver can protect DESTINATION-ONLY entries from * filter rules so the receiver can protect DESTINATION-ONLY entries from
* --delete with `protect`/`risk` rules (rsync parity). The block appends after * --delete with `protect`/`risk` rules (rsync parity). The block appends after
* compression_algo; see CONFIG_WIRE_PROTECT_FIELDS. */ * compression_algo; see CONFIG_WIRE_PROTECT_FIELDS. */
#define PROTOCOL_VERSION "2.28.0" /* (10) Symlink xattrs/ACLs (protocol 2.29.0): the config-frame LAYOUT is
* unchanged (the derived use_xattrs bit already crosses the wire), but the
* STATUS_SYMLINK frame BODY grows a trailing bounded xattr block when -X/-A is
* negotiated -- exactly the block STATUS_MKDIR, STATUS_DIR_TIMES and regular
* files already carry. The sender captures the symlink's OWN xattrs with
* llistxattr/lgetxattr (so it can never attach the REFERENT's attributes to the
* link) and the receiver re-applies them to the link itself with lsetxattr on a
* confined /proc/self/fd/<parent>/<leaf> path (there is no *at xattr syscall and
* fsetxattr cannot target a symlink). A 2.28 peer that does not consume the new
* trailing block would desynchronize after every symlink, so the protocol
* version must bump; the strict same-version handshake (config_receive rejects a
* mismatched version before parsing anything else) keeps a 2.29 client and a
* 2.28 server from ever reaching that state. */
#define PROTOCOL_VERSION "2.29.0"
#define DEFAULT_CHUNK_SIZE (10 * 1024 * 1024) #define DEFAULT_CHUNK_SIZE (10 * 1024 * 1024)
/* Upper bound on total basis-dir entries (rsync caps --link-dest at 20). */ /* Upper bound on total basis-dir entries (rsync caps --link-dest at 20). */
#define MAX_BASIS_DIRS 64 #define MAX_BASIS_DIRS 64
+8
View File
@@ -482,6 +482,14 @@ File* file_receive_symlink(int file_descriptor, const Config* config) {
return NULL; return NULL;
} }
} }
/* Symlink xattrs/ACLs (-X/-A) arrive in the same trailing block as the other
entry kinds; the block is present iff use_xattrs (which itself implies
use_metadata, so the metadata frame above is always consumed first). */
if (config && !receive_file_xattrs(file, file_descriptor, config)) {
file_destroy(file);
free(target);
return NULL;
}
file->is_symlink = true; file->is_symlink = true;
file->symlink_target = target; file->symlink_target = target;
return file; return file;
+15
View File
@@ -923,6 +923,21 @@ static FileSaveResult file_save_symlink_to_disk(const FileSavePlan* plan, bool*
ok = file_restore_symlink_metadata(link_path, file->metadata, link_policy, ok = file_restore_symlink_metadata(link_path, file->metadata, link_policy,
config->omit_link_times); config->omit_link_times);
} }
/* -X/-A: apply the symlink's OWN xattrs with a no-follow primitive. The
confined parent directory is the anchor and the final component is applied
with lsetxattr, so the referent is never touched. Best-effort: on Linux
the VFS refuses xattrs on symlinks, so this is normally a no-op. Hoist the
empty-list check so the common Linux case (NULL/empty xattrs) does not pay
an open/close of the parent per symlink. */
if (ok && config && config->use_xattrs && file->xattrs && file->xattrs->count > 0) {
char* leaf = NULL;
int parent_fd = file_open_secure_parent(link_path, &leaf, false);
if (parent_fd >= 0) {
xattr_apply_path_nofollow(parent_fd, leaf, file->xattrs, config->preserve_acls);
close(parent_fd);
}
free(leaf);
}
if (ok && created && !link_existed) if (ok && created && !link_existed)
*created = true; *created = true;
free(link_path); free(link_path);
+6 -2
View File
@@ -124,8 +124,12 @@ bool file_send_sendfile_with_skip(File* file, int file_descriptor, bool use_meta
} }
/* sendfile cannot encrypt TLS records. Keep the framing identical but /* sendfile cannot encrypt TLS records. Keep the framing identical but
route encrypted transfers through the deadline-aware IO layer. */ route encrypted transfers through the deadline-aware IO layer. Resolve
if (io_get_ssl() != NULL) { the transport from the bound session, not the thread-local io_ssl: a
worker thread running a TLS transfer has its SSL only on the session it
bound, so io_get_ssl() would be NULL there and the raw sendfile() path
would be taken on an encrypted socket. */
if (protocol_current_ssl() != NULL) {
unsigned char buffer[64 * 1024]; unsigned char buffer[64 * 1024];
unsigned long long remaining = file_size; unsigned long long remaining = file_size;
bool ok = true; bool ok = true;
+172 -77
View File
@@ -38,6 +38,129 @@ static atomic_ullong io_bytes_read = 0;
static unsigned long long global_bwlimit(void); static unsigned long long global_bwlimit(void);
/* ------------------------------------------------------------------------- *
* Transport vtable implementations.
*
* Each op performs exactly one transfer attempt. WANT_READ/WANT_WRITE and an
* EINTR-interrupted syscall are reported as PROTOCOL_IO_RETRY (with
* *wait_events set to the poll event the caller must wait on); a clean peer
* close is PROTOCOL_IO_CLOSED and anything else is PROTOCOL_IO_ERROR. This
* keeps every WANT_READ/WANT_WRITE and EINTR retry exactly where it was before
* the vtable was introduced, just moved behind the function pointer.
* ------------------------------------------------------------------------- */
static ssize_t plain_io_send(ProtocolSession* session, const void* data, size_t size,
short* wait_events) {
ssize_t written = write(session->write_fd, data, size);
if (written < 0) {
if (errno == EINTR)
return PROTOCOL_IO_RETRY;
return PROTOCOL_IO_ERROR;
}
if (written == 0)
return PROTOCOL_IO_ERROR;
*wait_events = POLLOUT;
return written;
}
static ssize_t plain_io_recv(ProtocolSession* session, void* data, size_t size,
short* wait_events) {
ssize_t received = read(session->read_fd, data, size);
if (received < 0) {
if (errno == EINTR)
return PROTOCOL_IO_RETRY;
return PROTOCOL_IO_ERROR;
}
if (received == 0)
return PROTOCOL_IO_CLOSED;
*wait_events = POLLIN;
return received;
}
static bool plain_io_has_pending(const ProtocolSession* session) {
(void)session;
return false;
}
static ssize_t tls_io_send(ProtocolSession* session, const void* data, size_t size,
short* wait_events) {
/* SSL_write takes an int length; clamp a >INT_MAX request into chunks so the
* size_t downcast can never truncate into a negative/partial write. */
size_t chunk = size > (size_t)INT_MAX ? (size_t)INT_MAX : size;
ssize_t written = SSL_write(session->ssl, data, (int)chunk);
if (written <= 0) {
int ssl_err = SSL_get_error(session->ssl, (int)written);
if (ssl_err == SSL_ERROR_WANT_WRITE) {
*wait_events = POLLOUT;
return PROTOCOL_IO_RETRY;
}
if (ssl_err == SSL_ERROR_WANT_READ) {
*wait_events = POLLIN;
return PROTOCOL_IO_RETRY;
}
/* A signal (e.g. Ctrl-C) interrupts the blocking TLS write: retry so the
* send loop can observe the abort flag at the next checkpoint. Only an
* actual negative return is an interrupted syscall; a 0-byte SSL_write is
* not a valid EINTR retry. */
if (written < 0 && ssl_err == SSL_ERROR_SYSCALL && errno == EINTR)
return PROTOCOL_IO_RETRY;
return PROTOCOL_IO_ERROR;
}
*wait_events = POLLOUT;
return written;
}
static ssize_t tls_io_recv(ProtocolSession* session, void* data, size_t size, short* wait_events) {
/* SSL_read takes an int length; clamp a >INT_MAX request into chunks
* (mirrors the send path) so the size_t downcast can never truncate into a
* negative/partial read. */
size_t chunk = size > (size_t)INT_MAX ? (size_t)INT_MAX : size;
ssize_t received = SSL_read(session->ssl, data, (int)chunk);
if (received <= 0) {
int ssl_err = SSL_get_error(session->ssl, (int)received);
if (ssl_err == SSL_ERROR_WANT_WRITE) {
*wait_events = POLLOUT;
return PROTOCOL_IO_RETRY;
}
if (ssl_err == SSL_ERROR_WANT_READ) {
*wait_events = POLLIN;
return PROTOCOL_IO_RETRY;
}
/* A signal interrupts the blocking TLS read: retry (mirrors the send path)
* so the loop reaches its next abort/deadline checkpoint. Only an actual
* negative return is an interrupted syscall: a 0-byte SSL_read is an
* unexpected EOF (the peer closed without close_notify), which OpenSSL also
* reports as SSL_ERROR_SYSCALL with errno possibly still EINTR from an
* earlier interrupted poll/read. Retrying that would busy-spin the
* status-read loop until its deadline, so classify it as closed instead. */
if (received < 0 && ssl_err == SSL_ERROR_SYSCALL && errno == EINTR)
return PROTOCOL_IO_RETRY;
/* A zero-length SSL_read is the peer's clean close_notify (or EOF without
* one); report it distinctly so the caller can log it as a close. */
if (received == 0)
return PROTOCOL_IO_CLOSED;
return PROTOCOL_IO_ERROR;
}
*wait_events = POLLIN;
return received;
}
static bool tls_io_has_pending(const ProtocolSession* session) {
return session->ssl != NULL && SSL_pending(session->ssl) > 0;
}
static const ProtocolIoOps plain_io_ops = {
.send = plain_io_send,
.recv = plain_io_recv,
.has_pending = plain_io_has_pending,
};
static const ProtocolIoOps tls_io_ops = {
.send = tls_io_send,
.recv = tls_io_recv,
.has_pending = tls_io_has_pending,
};
static bool protocol_reserve_memory(ProtocolSession* session, size_t charge) { static bool protocol_reserve_memory(ProtocolSession* session, size_t charge) {
unsigned long long allocated = atomic_load(&session->total_allocated_bytes); unsigned long long allocated = atomic_load(&session->total_allocated_bytes);
while (true) { while (true) {
@@ -79,6 +202,7 @@ void io_set_fds(int read_fd, int write_fd) {
legacy_io_session.read_fd = read_fd; legacy_io_session.read_fd = read_fd;
legacy_io_session.write_fd = write_fd; legacy_io_session.write_fd = write_fd;
legacy_io_session.ssl = NULL; legacy_io_session.ssl = NULL;
legacy_io_session.ops = &plain_io_ops;
legacy_io_session.eight_bit_output = false; legacy_io_session.eight_bit_output = false;
atomic_store(&legacy_io_session.total_allocated_bytes, 0); atomic_store(&legacy_io_session.total_allocated_bytes, 0);
legacy_io_session.max_alloc = DEFAULT_MAX_ALLOC; legacy_io_session.max_alloc = DEFAULT_MAX_ALLOC;
@@ -91,6 +215,7 @@ void protocol_session_init(ProtocolSession* session, int read_fd, int write_fd)
memset(session, 0, sizeof(*session)); memset(session, 0, sizeof(*session));
session->read_fd = read_fd; session->read_fd = read_fd;
session->write_fd = write_fd; session->write_fd = write_fd;
session->ops = &plain_io_ops;
session->max_alloc = DEFAULT_MAX_ALLOC; session->max_alloc = DEFAULT_MAX_ALLOC;
session->io_timeout_sec = RECEIVE_TIMEOUT_SEC; session->io_timeout_sec = RECEIVE_TIMEOUT_SEC;
atomic_init(&session->total_allocated_bytes, 0); atomic_init(&session->total_allocated_bytes, 0);
@@ -158,8 +283,12 @@ void protocol_session_unbind(void) {
} }
void protocol_session_set_ssl(ProtocolSession* session, SSL* ssl) { void protocol_session_set_ssl(ProtocolSession* session, SSL* ssl) {
if (session) if (!session)
session->ssl = ssl; return;
session->ssl = ssl;
/* Select the transport dispatch once, here, instead of branching on the SSL
* pointer inside every I/O loop. */
session->ops = ssl ? &tls_io_ops : &plain_io_ops;
} }
static void bw_mutex_init(void) { static void bw_mutex_init(void) {
@@ -270,6 +399,20 @@ SSL* io_get_ssl(void) {
return io_ssl; return io_ssl;
} }
SSL* protocol_current_ssl(void) {
/* The bound session is the authoritative transport for a worker thread: it
* was explicitly handed to protocol_session_bind() and carries its own SSL,
* whereas io_ssl is thread-local and NULL in a thread that never performed
* the handshake. Only a session whose selected dispatch is TLS may supply
* the SSL: a bound plaintext session has ssl == NULL and must not shadow a
* live thread-local io_ssl, or file_send.c would take the raw sendfile(2)
* path on a socket this thread is driving with TLS. With no TLS session
* bound (plaintext session, or the fd-shim path), fall back to io_ssl. */
if (bound_session && bound_session->ops == &tls_io_ops && bound_session->ssl)
return bound_session->ssl;
return io_ssl;
}
unsigned long long protocol_bytes_written(void) { unsigned long long protocol_bytes_written(void) {
return atomic_load(&io_bytes_written); return atomic_load(&io_bytes_written);
} }
@@ -298,6 +441,7 @@ static ProtocolSession* legacy_session(int read_fd, int write_fd) {
protocol_session_set_bwlimit(&legacy_io_session, global_bwlimit()); protocol_session_set_bwlimit(&legacy_io_session, global_bwlimit());
} }
legacy_io_session.ssl = io_ssl; legacy_io_session.ssl = io_ssl;
legacy_io_session.ops = io_ssl ? &tls_io_ops : &plain_io_ops;
return &legacy_io_session; return &legacy_io_session;
} }
@@ -335,8 +479,9 @@ bool protocol_send_n_data(ProtocolSession* session, const void* data, size_t dat
if (!data && data_size != 0) if (!data && data_size != 0)
return false; return false;
log_debug_message(LOG_DEBUG_IO, " Sending n Data: %zu", data_size); log_debug_message(LOG_DEBUG_IO, " Sending n Data: %zu", data_size);
if (!session) if (!session || !session->ops)
return false; return false;
const ProtocolIoOps* ops = session->ops;
/* A non-positive session timeout disables the deadline entirely (rsync's /* A non-positive session timeout disables the deadline entirely (rsync's
* --timeout=0 default); poll then blocks until the socket becomes writable. */ * --timeout=0 default); poll then blocks until the socket becomes writable. */
int timeout_sec = session->io_timeout_sec > 0 ? session->io_timeout_sec : 0; int timeout_sec = session->io_timeout_sec > 0 ? session->io_timeout_sec : 0;
@@ -362,36 +507,16 @@ bool protocol_send_n_data(ProtocolSession* session, const void* data, size_t dat
continue; continue;
if (pfd.revents & (POLLERR | POLLNVAL)) if (pfd.revents & (POLLERR | POLLNVAL))
return false; return false;
ssize_t bytes_send; ssize_t bytes_send =
if (session->ssl) { ops->send(session, (const char*)data + total_bytes_send, chunk, &wait_events);
/* SSL_write takes an int length; clamp a >INT_MAX request into chunks so if (bytes_send == PROTOCOL_IO_RETRY)
* the size_t downcast can never truncate into a negative/partial write. */ continue;
size_t ssl_chunk = chunk > (size_t)INT_MAX ? (size_t)INT_MAX : chunk;
bytes_send = SSL_write(session->ssl, (const char*)data + total_bytes_send, (int)ssl_chunk);
} else {
bytes_send = write(fd, (const char*)data + total_bytes_send, chunk);
}
if (bytes_send <= 0) { if (bytes_send <= 0) {
if (session->ssl) {
int ssl_err = SSL_get_error(session->ssl, (int)bytes_send);
if (ssl_err == SSL_ERROR_WANT_WRITE || ssl_err == SSL_ERROR_WANT_READ) {
wait_events = ssl_err == SSL_ERROR_WANT_WRITE ? POLLOUT : POLLIN;
continue;
}
/* A signal (e.g. Ctrl-C) interrupts the blocking TLS write: retry so
the send loop can observe the abort flag at the next checkpoint. */
if (ssl_err == SSL_ERROR_SYSCALL && errno == EINTR)
continue;
} else if (errno == EINTR) {
continue;
}
log_message(LOG_LEVEL_ERROR, "Could not send data"); log_message(LOG_LEVEL_ERROR, "Could not send data");
return false; return false;
} }
bw_throttle_session(session, (size_t)bytes_send); bw_throttle_session(session, (size_t)bytes_send);
total_bytes_send += bytes_send; total_bytes_send += bytes_send;
if (session->ssl)
wait_events = POLLOUT;
} }
log_debug_message(LOG_DEBUG_IO, " Send n Data: %zd", total_bytes_send); log_debug_message(LOG_DEBUG_IO, " Send n Data: %zd", total_bytes_send);
atomic_fetch_add(&io_bytes_written, (unsigned long long)total_bytes_send); atomic_fetch_add(&io_bytes_written, (unsigned long long)total_bytes_send);
@@ -424,14 +549,15 @@ bool protocol_receive_n_data(ProtocolSession* session, void* data, size_t data_s
static bool protocol_receive_n_data_until(ProtocolSession* session, void* data, size_t data_size, static bool protocol_receive_n_data_until(ProtocolSession* session, void* data, size_t data_size,
const struct timespec* deadline) { const struct timespec* deadline) {
log_debug_message(LOG_DEBUG_IO, " Receiving n Data: %zu", data_size); log_debug_message(LOG_DEBUG_IO, " Receiving n Data: %zu", data_size);
if (!session) if (!session || !session->ops)
return false; return false;
const ProtocolIoOps* ops = session->ops;
int fd = session->read_fd; int fd = session->read_fd;
size_t total_bytes_received = 0; size_t total_bytes_received = 0;
short wait_events = POLLIN; short wait_events = POLLIN;
while (total_bytes_received < data_size) { while (total_bytes_received < data_size) {
if (!session->ssl || SSL_pending(session->ssl) == 0) { if (!ops->has_pending(session)) {
struct pollfd pfd = {.fd = fd, .events = wait_events}; struct pollfd pfd = {.fd = fd, .events = wait_events};
/* A NULL deadline means "wait indefinitely" (timeout disabled). */ /* A NULL deadline means "wait indefinitely" (timeout disabled). */
int poll_result = poll(&pfd, 1, deadline ? deadline_remaining_ms(deadline) : -1); int poll_result = poll(&pfd, 1, deadline ? deadline_remaining_ms(deadline) : -1);
@@ -449,43 +575,19 @@ static bool protocol_receive_n_data_until(ProtocolSession* session, void* data,
return false; return false;
} }
ssize_t bytes_received; ssize_t bytes_received = ops->recv(session, (char*)data + total_bytes_received,
if (session->ssl) { data_size - total_bytes_received, &wait_events);
/* SSL_read takes an int length; clamp a >INT_MAX request into chunks if (bytes_received == PROTOCOL_IO_RETRY)
* (mirrors the send path) so the size_t downcast can never truncate into continue;
* a negative/partial read. */ if (bytes_received == PROTOCOL_IO_CLOSED) {
size_t ssl_chunk = data_size - total_bytes_received > (size_t)INT_MAX log_message(LOG_LEVEL_ERROR, "Connection closed while receiving data");
? (size_t)INT_MAX return false;
: data_size - total_bytes_received;
bytes_received = SSL_read(session->ssl, (char*)data + total_bytes_received, (int)ssl_chunk);
} else {
bytes_received =
read(fd, (char*)data + total_bytes_received, data_size - total_bytes_received);
} }
if (bytes_received <= 0) { if (bytes_received <= 0) {
if (session->ssl) { log_message(LOG_LEVEL_ERROR, "Could not receive bytes");
int ssl_err = SSL_get_error(session->ssl, (int)bytes_received);
if (ssl_err == SSL_ERROR_WANT_WRITE || ssl_err == SSL_ERROR_WANT_READ) {
wait_events = ssl_err == SSL_ERROR_WANT_WRITE ? POLLOUT : POLLIN;
continue;
}
/* A signal interrupts the blocking TLS read: retry (mirrors the send
path and protocol_read_status_until) so the loop reaches its next
abort/deadline checkpoint instead of failing spuriously. */
if (ssl_err == SSL_ERROR_SYSCALL && errno == EINTR)
continue;
} else if (errno == EINTR) {
continue;
}
if (bytes_received == 0)
log_message(LOG_LEVEL_ERROR, "Connection closed while receiving data");
else
log_message(LOG_LEVEL_ERROR, "Could not receive bytes");
return false; return false;
} }
total_bytes_received += (size_t)bytes_received; total_bytes_received += (size_t)bytes_received;
if (session->ssl)
wait_events = POLLIN;
} }
log_debug_message(LOG_DEBUG_IO, " Received n Data: %zu", total_bytes_received); log_debug_message(LOG_DEBUG_IO, " Received n Data: %zu", total_bytes_received);
atomic_fetch_add(&io_bytes_read, (unsigned long long)total_bytes_received); atomic_fetch_add(&io_bytes_read, (unsigned long long)total_bytes_received);
@@ -845,11 +947,14 @@ bool protocol_receive_status_timed(ProtocolSession* session, Status* status, int
* reply across a frame boundary. Returns false on timeout/EOF/error. */ * reply across a frame boundary. Returns false on timeout/EOF/error. */
static bool protocol_read_status_until(ProtocolSession* session, Status* status, static bool protocol_read_status_until(ProtocolSession* session, Status* status,
const struct timespec* deadline) { const struct timespec* deadline) {
if (!session || !session->ops)
return false;
const ProtocolIoOps* ops = session->ops;
Status received = STATUS_ERROR; Status received = STATUS_ERROR;
size_t got = 0; size_t got = 0;
short wait_events = POLLIN; short wait_events = POLLIN;
while (got < sizeof(Status)) { while (got < sizeof(Status)) {
if (!session->ssl || SSL_pending(session->ssl) == 0) { if (!ops->has_pending(session)) {
int remaining_ms = deadline ? deadline_remaining_ms(deadline) : -1; int remaining_ms = deadline ? deadline_remaining_ms(deadline) : -1;
if (remaining_ms == 0) { if (remaining_ms == 0) {
log_message(LOG_LEVEL_ERROR, "Receive timeout while reading status"); log_message(LOG_LEVEL_ERROR, "Receive timeout while reading status");
@@ -869,21 +974,11 @@ static bool protocol_read_status_until(ProtocolSession* session, Status* status,
if (pfd.revents & (POLLERR | POLLNVAL)) if (pfd.revents & (POLLERR | POLLNVAL))
return false; return false;
} }
ssize_t bytes_received; ssize_t bytes_received =
if (session->ssl) ops->recv(session, (char*)&received + got, sizeof(Status) - got, &wait_events);
bytes_received = SSL_read(session->ssl, (char*)&received + got, sizeof(Status) - got); if (bytes_received == PROTOCOL_IO_RETRY)
else continue;
bytes_received = read(session->read_fd, (char*)&received + got, sizeof(Status) - got);
if (bytes_received <= 0) { if (bytes_received <= 0) {
if (session->ssl) {
int ssl_err = SSL_get_error(session->ssl, (int)bytes_received);
if (ssl_err == SSL_ERROR_WANT_READ || ssl_err == SSL_ERROR_WANT_WRITE) {
wait_events = ssl_err == SSL_ERROR_WANT_WRITE ? POLLOUT : POLLIN;
continue;
}
}
if (bytes_received < 0 && errno == EINTR)
continue;
log_message(LOG_LEVEL_ERROR, "Connection closed while receiving status"); log_message(LOG_LEVEL_ERROR, "Connection closed while receiving status");
return false; return false;
} }
@@ -912,7 +1007,7 @@ bool protocol_receive_status_keepalive(ProtocolSession* session, Status* status,
while (true) { while (true) {
if (abort_check && abort_check()) if (abort_check && abort_check())
return false; return false;
if (!session->ssl || SSL_pending(session->ssl) == 0) { if (!session->ops || !session->ops->has_pending(session)) {
int remaining_ms = deadline_remaining_ms(&deadline); int remaining_ms = deadline_remaining_ms(&deadline);
if (remaining_ms <= 0) { if (remaining_ms <= 0) {
log_message(LOG_LEVEL_ERROR, "Receive timeout after %ds", timeout_sec); log_message(LOG_LEVEL_ERROR, "Receive timeout after %ds", timeout_sec);
+45 -2
View File
@@ -50,16 +50,48 @@
typedef struct ssl_st SSL; typedef struct ssl_st SSL;
typedef struct ProtocolSession ProtocolSession;
/*
* Transport vtable: the per-session set of I/O primitives the three protocol
* loops (send, receive, status-read) dispatch through. The ops are selected
* once, when the session is initialized or its SSL is installed, so the loops
* never branch on the transport at runtime. A plaintext session uses the
* read()/write() ops; a TLS session uses the SSL_read()/SSL_write() ops.
*
* `send`/`recv` attempt exactly one transfer and return:
* > 0 bytes transferred,
* PROTOCOL_IO_RETRY no progress; poll on *wait_events and retry,
* PROTOCOL_IO_CLOSED peer closed the stream,
* PROTOCOL_IO_ERROR fatal transport error.
* `has_pending` reports bytes already buffered by the transport (a TLS record
* residue); the receive loops skip the poll() gate when it is true.
*/
typedef struct ProtocolIoOps {
ssize_t (*send)(ProtocolSession* session, const void* data, size_t size, short* wait_events);
ssize_t (*recv)(ProtocolSession* session, void* data, size_t size, short* wait_events);
bool (*has_pending)(const ProtocolSession* session);
} ProtocolIoOps;
/* Negative sentinels returned by ProtocolIoOps.send/recv (see above). */
enum {
PROTOCOL_IO_RETRY = -1,
PROTOCOL_IO_CLOSED = -2,
PROTOCOL_IO_ERROR = -3,
};
/* /*
* Explicit owner of protocol I/O. A session does not own the descriptors or * Explicit owner of protocol I/O. A session does not own the descriptors or
* SSL object; it only describes the transport used by a transfer. This makes * SSL object; it only describes the transport used by a transfer. This makes
* it safe to pass the transport to a worker without relying on inherited * it safe to pass the transport to a worker without relying on inherited
* thread-local state. * thread-local state.
*/ */
typedef struct ProtocolSession { struct ProtocolSession {
int read_fd; int read_fd;
int write_fd; int write_fd;
SSL* ssl; SSL* ssl;
/* Transport dispatch selected by protocol_session_init()/set_ssl(). */
const ProtocolIoOps* ops;
unsigned long long bwlimit; unsigned long long bwlimit;
long long bw_tokens; long long bw_tokens;
long long bw_last_refill_sec; long long bw_last_refill_sec;
@@ -75,7 +107,7 @@ typedef struct ProtocolSession {
* SO_RCVTIMEO/SO_SNDTIMEO. The server does not propagate a client 0 here: it * SO_RCVTIMEO/SO_SNDTIMEO. The server does not propagate a client 0 here: it
* installs protocol_server_io_timeout_sec() so its sessions keep a floor. */ * installs protocol_server_io_timeout_sec() so its sessions keep a floor. */
int io_timeout_sec; int io_timeout_sec;
} ProtocolSession; };
typedef int Status; typedef int Status;
enum NET_STATUS { enum NET_STATUS {
@@ -216,6 +248,17 @@ void io_set_bwlimit(unsigned long long bytes_per_sec);
unsigned long long io_get_bwlimit(void); unsigned long long io_get_bwlimit(void);
void io_set_ssl(SSL* ssl); void io_set_ssl(SSL* ssl);
SSL* io_get_ssl(void); SSL* io_get_ssl(void);
/* SSL object of the transport in effect on this thread: the currently bound
* session's SSL when a TLS session is bound, otherwise the legacy thread-local
* io_ssl. NULL for a plaintext transport. Unlike io_get_ssl(), this resolves
* worker threads that bound a TLS session via protocol_session_set_ssl()/
* protocol_session_bind() but never called io_set_ssl() themselves (C11
* _Thread_local state is not inherited by a new thread). A bound session only
* wins when its selected dispatch is TLS; a bound plaintext session (ssl ==
* NULL) falls back to io_ssl so it can never mask a live encrypted transport.
* Callers that must choose a TLS-only code path (e.g. file_send.c's sendfile
* fallback) must use this instead of io_get_ssl(). */
SSL* protocol_current_ssl(void);
/* Process-wide wire byte counters. protocol_send_n_data/protocol_receive_n_data /* Process-wide wire byte counters. protocol_send_n_data/protocol_receive_n_data
* update them; the zero-copy sendfile path reports through * update them; the zero-copy sendfile path reports through
+73 -5
View File
@@ -134,16 +134,23 @@ static bool xattr_name_is_posix_acl(const char* name) {
/* ---- SENDER: capture ---- */ /* ---- SENDER: capture ---- */
FileXattrList* xattr_capture_path(const char* path, bool preserve_acls) { /* The two syscall families differ only in whether the FINAL component is
* followed (`listxattr`/`getxattr` follow; `llistxattr`/`lgetxattr` do not), so
* one common implementation backs both public entry points. */
typedef ssize_t (*XattrListFn)(const char* path, char* list, size_t size);
typedef ssize_t (*XattrGetFn)(const char* path, const char* name, void* value, size_t size);
static FileXattrList* xattr_capture_common(const char* path, bool preserve_acls,
XattrListFn list_fn, XattrGetFn get_fn) {
if (!path) if (!path)
return NULL; return NULL;
ssize_t list_size = listxattr(path, NULL, 0); ssize_t list_size = list_fn(path, NULL, 0);
if (list_size <= 0) if (list_size <= 0)
return NULL; /* no xattrs, ENOTSUP, or error: nothing appliable */ return NULL; /* no xattrs, ENOTSUP, or error: nothing appliable */
char* names = malloc((size_t)list_size); char* names = malloc((size_t)list_size);
if (!names) if (!names)
return NULL; return NULL;
ssize_t got = listxattr(path, names, (size_t)list_size); ssize_t got = list_fn(path, names, (size_t)list_size);
if (got < 0) { if (got < 0) {
free(names); free(names);
return NULL; return NULL;
@@ -166,7 +173,7 @@ FileXattrList* xattr_capture_path(const char* path, bool preserve_acls) {
negotiated. Without it a plain -X capture never carries an ACL. */ negotiated. Without it a plain -X capture never carries an ACL. */
if (!xattr_name_appliable(name, preserve_acls)) if (!xattr_name_appliable(name, preserve_acls))
continue; continue;
ssize_t value_size = getxattr(path, name, NULL, 0); ssize_t value_size = get_fn(path, name, NULL, 0);
if (value_size < 0) if (value_size < 0)
continue; continue;
if (value_size > XATTR_VALUE_MAX) if (value_size > XATTR_VALUE_MAX)
@@ -179,7 +186,7 @@ FileXattrList* xattr_capture_path(const char* path, bool preserve_acls) {
free(names); free(names);
return NULL; return NULL;
} }
ssize_t read_len = getxattr(path, name, buffer, (size_t)value_size); ssize_t read_len = get_fn(path, name, buffer, (size_t)value_size);
if (read_len < 0 || read_len != value_size) { if (read_len < 0 || read_len != value_size) {
free(buffer); free(buffer);
continue; continue;
@@ -201,6 +208,14 @@ FileXattrList* xattr_capture_path(const char* path, bool preserve_acls) {
return list; return list;
} }
FileXattrList* xattr_capture_path(const char* path, bool preserve_acls) {
return xattr_capture_common(path, preserve_acls, listxattr, getxattr);
}
FileXattrList* xattr_capture_path_nofollow(const char* path, bool preserve_acls) {
return xattr_capture_common(path, preserve_acls, llistxattr, lgetxattr);
}
/* ---- WIRE ---- */ /* ---- WIRE ---- */
bool xattr_send(int fd, const FileXattrList* list) { bool xattr_send(int fd, const FileXattrList* list) {
@@ -364,6 +379,59 @@ bool xattr_apply_fd(int fd, const FileXattrList* list) {
return true; return true;
} }
/* Symlink counterpart of xattr_apply_fd(): target the link ITSELF, never its
* referent. fsetxattr cannot be used (no *at xattr syscall exists, and the
* kernel rejects xattr syscalls on an O_PATH descriptor), so the already-open,
* confinement-checked parent directory is addressed through /proc/self/fd and
* the final component is applied with lsetxattr, which does not follow it.
*
* The list is trusted to come from xattr_receive() (already whitelisted), but
* every name is re-validated here so this path-based primitive is confined on
* its own -- this is the only apply primitive that addresses a path, and the
* header promises a whitelisted apply. The apply is best-effort: if /proc is
* not mounted (the anchor cannot be formed) or the kernel refuses the set, the
* failure is skipped and never fails the transfer. See xattr.h for the bounded
* residual TOCTOU between link creation and lsetxattr. */
bool xattr_apply_path_nofollow(int parent_fd, const char* leaf, const FileXattrList* list,
bool preserve_acls) {
if (parent_fd < 0 || !leaf || leaf[0] == '\0' || strchr(leaf, '/') != NULL || !list)
return false;
if (list->count == 0)
return true;
char prefix[64];
int prefix_len = snprintf(prefix, sizeof(prefix), "/proc/self/fd/%d/", parent_fd);
if (prefix_len < 0 || (size_t)prefix_len >= sizeof(prefix))
return false;
size_t leaf_len = strlen(leaf);
char* path = malloc((size_t)prefix_len + leaf_len + 1);
if (!path)
return false;
memcpy(path, prefix, (size_t)prefix_len);
memcpy(path + prefix_len, leaf, leaf_len + 1);
bool warned = false;
int first_errno = 0;
for (int i = 0; i < list->count; i++) {
const FileXattr* xa = &list->items[i];
/* Defense in depth: re-validate against the receiver's full whitelist, so a
hand-crafted list can never apply a privileged namespace or the reserved
--fake-super key through this path-based primitive. */
if (!xattr_name_appliable(xa->name, preserve_acls))
continue;
if (lsetxattr(path, xa->name, xa->value, xa->value_len, 0) != 0) {
if (!warned) {
warned = true;
first_errno = errno;
}
}
}
if (warned)
log_message(LOG_LEVEL_WARNING,
"could not set one or more xattrs on the destination symlink: %s",
strerror(first_errno));
free(path);
return true;
}
/* ---- --fake-super: park ownership/mode/rdev in a reserved xattr ---- */ /* ---- --fake-super: park ownership/mode/rdev in a reserved xattr ---- */
void fake_super_store_fd(int fd, uint32_t uid, uint32_t gid, uint32_t mode, uint32_t rdev_major, void fake_super_store_fd(int fd, uint32_t uid, uint32_t gid, uint32_t mode, uint32_t rdev_major,
+45 -2
View File
@@ -26,8 +26,11 @@
* and total bytes) on BOTH ends to prevent OOM/memory abuse; an oversized * and total bytes) on BOTH ends to prevent OOM/memory abuse; an oversized
* or malformed frame is a clean protocol rejection, never an allocation * or malformed frame is a clean protocol rejection, never an allocation
* blowup. * blowup.
* * Application is confined to the exact destination file descriptor * * Application is confined to the exact destination entry: fsetxattr on the
* (fsetxattr on the just-written fd), never a caller-controlled path. * just-written fd for regular files/directories, and for a symlink an
* lsetxattr on "/proc/self/fd/<parent_fd>/<leaf>" reached through the
* already-opened, confinement-checked parent directory -- never a
* caller-controlled path, and never following the link.
*/ */
/* Reserved key used by --fake-super to park the source's privileged ownership /* Reserved key used by --fake-super to park the source's privileged ownership
@@ -79,6 +82,17 @@ bool xattr_name_appliable(const char* name, bool preserve_acls);
* distinct from NULL. */ * distinct from NULL. */
FileXattrList* xattr_capture_path(const char* path, bool preserve_acls); FileXattrList* xattr_capture_path(const char* path, bool preserve_acls);
/* Sender: like xattr_capture_path() but reads the xattrs of `path` ITSELF,
* never following a final symlink (llistxattr/lgetxattr). A symlink entry must
* use this so the scanner never captures the REFERENT's attributes onto the
* link (the path-following variant would). On Linux the VFS refuses to
* associate xattrs with symlinks at all, so this normally returns NULL; it is
* still correct and portable for a filesystem/platform that supports them.
* The same whitelist/bounds as xattr_capture_path() apply. Returns NULL when
* the link has no appliable xattrs (or the filesystem does not support them);
* an empty-but-valid list is never returned distinct from NULL. */
FileXattrList* xattr_capture_path_nofollow(const char* path, bool preserve_acls);
/* Wire: bounded serialization. xattr_send returns false on write failure; an /* Wire: bounded serialization. xattr_send returns false on write failure; an
* empty/NULL list transmits a zero-count block. xattr_receive returns NULL and * empty/NULL list transmits a zero-count block. xattr_receive returns NULL and
* sets *ok = 0 on any malformed / oversized / non-whitelisted entry. When * sets *ok = 0 on any malformed / oversized / non-whitelisted entry. When
@@ -94,6 +108,35 @@ FileXattrList* xattr_receive(int fd, int* ok, bool preserve_acls);
* true when apply was attempted (allowing callers to treat it as best-effort). */ * true when apply was attempted (allowing callers to treat it as best-effort). */
bool xattr_apply_fd(int fd, const FileXattrList* list); bool xattr_apply_fd(int fd, const FileXattrList* list);
/* Receiver: apply every entry to the symlink named by (parent_fd, leaf) WITHOUT
* following it, via lsetxattr() on the confined path
* "/proc/self/fd/<parent_fd>/<leaf>". Every incoming name is independently
* re-validated against xattr_name_appliable() with `preserve_acls`, exactly like
* xattr_apply_fd(): a non-whitelisted namespace (including the reserved
* --fake-super key) is skipped, so this primitive stays confined even if handed
* a hand-crafted list. A symlink cannot be targeted by the fd-relative
* fsetxattr() path: there is no *at() xattr syscall and the kernel rejects
* xattr syscalls on an O_PATH descriptor, so the already-opened,
* confinement-checked parent directory is the anchor and only the final
* component is the (no-follow) link. `leaf` must be a single path component.
*
* Portability: the "/proc/self/fd/<parent_fd>" anchor requires a mounted /proc.
* Where /proc is unavailable (or the fd cannot be addressed that way) the
* lsetxattr simply fails and is skipped -- the apply is best-effort exactly like
* xattr_apply_fd(), so no error is propagated and the transfer continues. A
* per-attribute failure (on Linux every set on a symlink fails with EPERM) is
* logged once and skipped, never fatal. Returns false only for an invalid
* anchor/list; true when an apply was attempted.
*
* Residual TOCTOU: `leaf` is a caller-supplied name resolved by path in the
* parent, so a local writer could replace the just-created symlink between its
* creation and lsetxattr(). This is bounded: it requires write access to the
* confinement-checked destination directory (already trusted), can only install
* a whitelisted user namespace or POSIX-ACL name, and never follows the link (a
* replacement symlink is still applied to as the final, no-follow component). */
bool xattr_apply_path_nofollow(int parent_fd, const char* leaf, const FileXattrList* list,
bool preserve_acls);
/* --fake-super: write the source uid/gid/mode/rdev record into the reserved /* --fake-super: write the source uid/gid/mode/rdev record into the reserved
* FAKESUPER_XATTR on `fd`, using rsync 3.4.1's exact grammar (see the key * FAKESUPER_XATTR on `fd`, using rsync 3.4.1's exact grammar (see the key
* comment above). `mode` is the full st_mode including its S_IFMT bits. * comment above). `mode` is the full st_mode including its S_IFMT bits.
+1 -1
View File
@@ -36,7 +36,7 @@ from common import ( # noqa: E402
verify_transfer, verify_transfer,
) )
PROTOCOL_VERSION = b"2.28.0" PROTOCOL_VERSION = b"2.29.0"
STATUS_MANIFEST = 5 STATUS_MANIFEST = 5
STATUS_OK = 0 STATUS_OK = 0
+62
View File
@@ -6637,6 +6637,68 @@ class TestExtendedAttributes:
received = get_dest_received_dir(dest, source) received = get_dest_received_dir(dest, source)
assert os.getxattr(os.path.join(received, "data.txt"), "user.k") == b"v" assert os.getxattr(os.path.join(received, "data.txt"), "user.k") == b"v"
@pytest.mark.ci
def test_symlink_own_xattrs_never_referent(self, shared_server):
"""Protocol 2.29.0: a symlink's STATUS_SYMLINK frame carries a trailing
xattr block captured with llistxattr/lgetxattr (no follow) and applied
with lsetxattr on the link itself. Linux's VFS refuses to associate
xattrs with a symlink at all, so the portable guarantee asserted here is
the no-follow one: a referent that carries user.* must NOT have those
attributes appear on the destination symlink entry (the old
path-following capture would have copied the referent's attrs onto the
link). On a platform/filesystem that does support symlink xattrs the
full round-trip of the link's own attribute is asserted too."""
source, dest = self._source_and_dest("symlink_xattr")
target = os.path.join(source, "target.txt")
with open(target, "wb") as fh:
fh.write(b"referent payload\n")
if not _xattr_supported(target):
pytest.skip("filesystem does not support user xattrs")
os.setxattr(target, "user.referent-only", b"referent-value")
link = os.path.join(source, "link")
os.symlink("target.txt", link)
link_xattr_supported = False
try:
os.setxattr(link, "user.link-own", b"link-value", follow_symlinks=False)
link_xattr_supported = os.getxattr(
link, "user.link-own", follow_symlinks=False
) == b"link-value"
except (OSError, AttributeError, NotImplementedError):
link_xattr_supported = False
result, _ = run_client(source, dest, flags=["-aX"], port=shared_server.port)
assert result.returncode == 0, \
f"-aX symlink sync failed: {(result.stderr or result.stdout)[:300]}"
received = get_dest_received_dir(dest, source)
dst_link = os.path.join(received, "link")
assert os.path.islink(dst_link), "destination link entry is not a symlink"
assert os.readlink(dst_link) == "target.txt"
# The no-follow guarantee. Checking only the link's own xattr list is
# vacuous on Linux (lsetxattr on a symlink always fails EPERM), so also
# prove the apply never followed the link: the destination REFERENT must
# keep its own user.* value untouched.
dst_target = os.path.join(received, "target.txt")
assert os.getxattr(dst_target, "user.referent-only") == b"referent-value", (
"the destination symlink apply followed the link and rewrote the "
"referent's xattr"
)
link_names = os.listxattr(dst_link, follow_symlinks=False)
assert "user.referent-only" not in link_names, (
"the destination symlink captured its REFERENT's xattr "
"(path-following capture bug)"
)
if sys.platform.startswith("linux"):
assert link_names == [], (
"Linux associates no xattrs with a symlink; the link entry must "
"carry none"
)
if link_xattr_supported:
assert os.getxattr(
dst_link, "user.link-own", follow_symlinks=False
) == b"link-value", "the symlink's own xattr did not round-trip"
@pytest.mark.ci @pytest.mark.ci
def test_acls_via_posix_acl_xattr(self, shared_server): def test_acls_via_posix_acl_xattr(self, shared_server):
source, dest = self._source_and_dest("acl") source, dest = self._source_and_dest("acl")
+3 -3
View File
@@ -133,14 +133,14 @@ def _seed_protocol_source(source):
class TestProtocol: class TestProtocol:
@pytest.mark.ci @pytest.mark.ci
def test_protocol_current_version_accepted(self, shared_server): def test_protocol_current_version_accepted(self, shared_server):
"""--protocol=2.28.0 (the current PROTOCOL_VERSION) is accepted and the """--protocol=2.29.0 (the current PROTOCOL_VERSION) is accepted and the
transfer completes normally.""" transfer completes normally."""
source = os.path.join(TEST_DATA_DIR, "proto_ok_src") source = os.path.join(TEST_DATA_DIR, "proto_ok_src")
dest = os.path.join(TEST_DATA_DIR, "proto_ok_dst") dest = os.path.join(TEST_DATA_DIR, "proto_ok_dst")
shutil.rmtree(dest, ignore_errors=True) shutil.rmtree(dest, ignore_errors=True)
os.makedirs(dest) os.makedirs(dest)
_seed_protocol_source(source) _seed_protocol_source(source)
result, _ = run_client(source, dest, flags=["--protocol=2.28.0"], result, _ = run_client(source, dest, flags=["--protocol=2.29.0"],
port=shared_server.port) port=shared_server.port)
assert result.returncode == 0, \ assert result.returncode == 0, \
f"--protocol current run failed: {(result.stderr or result.stdout)[:400]}" f"--protocol current run failed: {(result.stderr or result.stdout)[:400]}"
@@ -157,7 +157,7 @@ class TestProtocol:
shutil.rmtree(dest, ignore_errors=True) shutil.rmtree(dest, ignore_errors=True)
os.makedirs(dest) os.makedirs(dest)
_seed_protocol_source(source) _seed_protocol_source(source)
for bad in ("2.27.0", "2.26.0", "2.25.0", "2.24.0", "2.23.0", "2.22.0", "2.21.0", "2.20.0", for bad in ("2.28.0", "2.27.0", "2.26.0", "2.25.0", "2.24.0", "2.23.0", "2.22.0", "2.21.0", "2.20.0",
"2.19.0", "2.18.0", "2.17.0", "2.15.0", "2.16.0", "216", "31"): "2.19.0", "2.18.0", "2.17.0", "2.15.0", "2.16.0", "216", "31"):
result, _ = run_client(source, dest, flags=[f"--protocol={bad}"], result, _ = run_client(source, dest, flags=[f"--protocol={bad}"],
port=shared_server.port) port=shared_server.port)
+10 -3
View File
@@ -146,9 +146,16 @@ class TestTLSBasic:
assert not missing, f"Missing files: {missing}" assert not missing, f"Missing files: {missing}"
assert not mismatches, f"Mismatched files: {mismatches}" assert not mismatches, f"Mismatched files: {mismatches}"
@pytest.mark.xfail(reason="TLS multithreading has architectural limitations with per-thread SSL context") @pytest.mark.ci
def test_tls_with_multithreading(self, certs): def test_tls_with_multithreading(self, certs):
"""TLS + multithreading.""" """TLS + multithreading + --sendfile.
Exercises the TLS/sendfile interaction end to end: with --sendfile the
sender must route the file body through the buffered TLS path rather
than raw sendfile(2) on the encrypted socket. The focused decision
guard lives in tests/test_protocol.c
(test_tls_sendfile_decision_uses_buffered_path).
"""
clean_dir(DEST_DIR) clean_dir(DEST_DIR)
with ServerManager() as server: with ServerManager() as server:
server.start(extra_args=[ server.start(extra_args=[
@@ -157,7 +164,7 @@ class TestTLSBasic:
]) ])
result, dur = run_client( result, dur = run_client(
SOURCE_DIR, DEST_DIR, SOURCE_DIR, DEST_DIR,
flags=["--threads", "--tls", flags=["--threads", "--sendfile", "--tls",
"--cert", certs["client_cert"], "--key", certs["client_key"], "--cert", certs["client_cert"], "--key", certs["client_key"],
"--ca", certs["ca"]], "--ca", certs["ca"]],
port=server.port, port=server.port,
+3 -3
View File
@@ -352,7 +352,7 @@ static void test_parse_args_protocol_accept_current() {
Config* cfg = valid_client_config(); Config* cfg = valid_client_config();
EXPECT_NOT_NULL(cfg); EXPECT_NOT_NULL(cfg);
char* argv_equals[] = {"fastsync", "--source-dir", "/src", char* argv_equals[] = {"fastsync", "--source-dir", "/src",
"--dest-dir", "/dst", "--protocol=2.28.0"}; "--dest-dir", "/dst", "--protocol=2.29.0"};
int positional_args[2]; int positional_args[2];
int positional_count = 0; int positional_count = 0;
EXPECT_EQ_INT(parse_args(cfg, 6, argv_equals, positional_args, &positional_count), 0); EXPECT_EQ_INT(parse_args(cfg, 6, argv_equals, positional_args, &positional_count), 0);
@@ -362,7 +362,7 @@ static void test_parse_args_protocol_accept_current() {
cfg = valid_client_config(); cfg = valid_client_config();
EXPECT_NOT_NULL(cfg); EXPECT_NOT_NULL(cfg);
char* argv_space[] = {"fastsync", "--source-dir", "/src", "--dest-dir", char* argv_space[] = {"fastsync", "--source-dir", "/src", "--dest-dir",
"/dst", "--protocol", "2.28.0"}; "/dst", "--protocol", "2.29.0"};
positional_count = 0; positional_count = 0;
EXPECT_EQ_INT(parse_args(cfg, 7, argv_space, positional_args, &positional_count), 0); EXPECT_EQ_INT(parse_args(cfg, 7, argv_space, positional_args, &positional_count), 0);
EXPECT_EQ_STR(cfg->version, PROTOCOL_VERSION); EXPECT_EQ_STR(cfg->version, PROTOCOL_VERSION);
@@ -375,7 +375,7 @@ static void test_parse_args_protocol_rejects_other_versions() {
static const char* const bad_versions[] = {"2.17", "2.16", "2.15.0", "2.16.0", "2.17.0", static const char* const bad_versions[] = {"2.17", "2.16", "2.15.0", "2.16.0", "2.17.0",
"2.18.0", "2.19.0", "2.20.0", "2.21.0", "2.22.0", "2.18.0", "2.19.0", "2.20.0", "2.21.0", "2.22.0",
"2.23.0", "2.24.0", "2.25.0", "2.26.0", "2.27.0", "2.23.0", "2.24.0", "2.25.0", "2.26.0", "2.27.0",
"216", "31", "abc", ""}; "2.28.0", "216", "31", "abc", ""};
for (size_t i = 0; i < sizeof(bad_versions) / sizeof(bad_versions[0]); i++) { for (size_t i = 0; i < sizeof(bad_versions) / sizeof(bad_versions[0]); i++) {
Config* cfg = valid_client_config(); Config* cfg = valid_client_config();
EXPECT_NOT_NULL(cfg); EXPECT_NOT_NULL(cfg);
+8 -5
View File
@@ -2924,7 +2924,7 @@ static void golden_config_populate(Config* c) {
array_list_add(c->filters, str_dup("- /sub/dir/")); array_list_add(c->filters, str_dup("- /sub/dir/"));
} }
/* The pinned golden frame (protocol 2.28.0). The values below are the only /* The pinned golden frame (protocol 2.29.0). The values below are the only
* thing that ties the generated table to the historical wire format; update * thing that ties the generated table to the historical wire format; update
* them ONLY with a PROTOCOL_VERSION bump and a documented reason. The 2.24.0 * them ONLY with a PROTOCOL_VERSION bump and a documented reason. The 2.24.0
* delete-plan wave changed only the version string; 2.25.0 appended the * delete-plan wave changed only the version string; 2.25.0 appended the
@@ -2933,10 +2933,13 @@ static void golden_config_populate(Config* c) {
* appended the receiver-side delete-protection rule block (the STATUS_STATS * appended the receiver-side delete-protection rule block (the STATUS_STATS
* body also grew, but that is not part of this frame). Track 5a appends the * body also grew, but that is not part of this frame). Track 5a appends the
* FastSync-only verify_basis bool to the basis block WITHOUT a version bump * FastSync-only verify_basis bool to the basis block WITHOUT a version bump
* (project decision), so the frame grew by one int to 886 bytes. The * (project decision), so the frame grew by one int to 886 bytes. The 2.29.0
* byte-exact values are recomputed for the merged layout. */ * symlink-xattr wave changes only the version string: the config-frame layout
* is unchanged (use_xattrs already crosses the wire); the STATUS_SYMLINK frame
* body grows instead. The byte-exact values are recomputed for the merged
* layout. */
#define GOLDEN_WIRE_LEN 886 #define GOLDEN_WIRE_LEN 886
#define GOLDEN_WIRE_HASH 5809509022716816757ULL #define GOLDEN_WIRE_HASH 17827864270611927842ULL
static unsigned long long fnv1a_64(const unsigned char* buf, size_t len) { static unsigned long long fnv1a_64(const unsigned char* buf, size_t len) {
unsigned long long h = 1469598103934665603ULL; unsigned long long h = 1469598103934665603ULL;
@@ -3018,7 +3021,7 @@ static unsigned long long capture_wire_hash(const Config* cfg, size_t* out_len)
return h; return h;
} }
/* Byte-for-byte wire compatibility guard (protocol 2.28.0). The expected hash /* Byte-for-byte wire compatibility guard (protocol 2.29.0). The expected hash
* pins the pre-X-macro byte stream; the refactor MUST NOT change it. */ * pins the pre-X-macro byte stream; the refactor MUST NOT change it. */
static void test_config_wire_golden() { static void test_config_wire_golden() {
if (is_running_under_valgrind()) if (is_running_under_valgrind())
+459
View File
@@ -1,9 +1,17 @@
#include "protocol.h" #include "protocol.h"
#include "file.h"
#include "test_utils.h" #include "test_utils.h"
#include "utils.h"
#include <errno.h>
#include <fcntl.h> #include <fcntl.h>
#include <limits.h> #include <limits.h>
#include <openssl/evp.h>
#include <openssl/ssl.h>
#include <openssl/x509.h>
#include <poll.h>
#include <stdlib.h> #include <stdlib.h>
#include <string.h> #include <string.h>
#include <sys/socket.h>
#include <time.h> #include <time.h>
#include <unistd.h> #include <unistd.h>
#include <threads.h> #include <threads.h>
@@ -786,6 +794,452 @@ static void test_protocol_throttle_bytes_legacy_same_session() {
io_set_fds(-1, -1); io_set_fds(-1, -1);
} }
/* ------------------------------------------------------------------------- *
* Transport-vtable dispatch tests.
* ------------------------------------------------------------------------- */
static int dispatch_send_calls;
static int dispatch_recv_calls;
static ssize_t counting_send(ProtocolSession* session, const void* data, size_t size,
short* wait_events) {
dispatch_send_calls++;
ssize_t written = write(session->write_fd, data, size);
if (written < 0)
return errno == EINTR ? PROTOCOL_IO_RETRY : PROTOCOL_IO_ERROR;
if (written == 0)
return PROTOCOL_IO_ERROR;
*wait_events = POLLOUT;
return written;
}
static ssize_t counting_recv(ProtocolSession* session, void* data, size_t size,
short* wait_events) {
dispatch_recv_calls++;
ssize_t received = read(session->read_fd, data, size);
if (received < 0)
return errno == EINTR ? PROTOCOL_IO_RETRY : PROTOCOL_IO_ERROR;
if (received == 0)
return PROTOCOL_IO_CLOSED;
*wait_events = POLLIN;
return received;
}
static bool counting_has_pending(const ProtocolSession* session) {
(void)session;
return false;
}
static const ProtocolIoOps counting_ops = {
.send = counting_send,
.recv = counting_recv,
.has_pending = counting_has_pending,
};
/* A plain-TCP socketpair session must route every byte through the ops table:
* installing a counting ops wrapper proves the send/receive loops dispatch via
* session->ops instead of branching on session->ssl. */
static void test_protocol_dispatch_via_ops() {
int sv[2];
EXPECT_EQ_INT(socketpair(AF_UNIX, SOCK_STREAM, 0, sv), 0);
ProtocolSession sender;
ProtocolSession receiver;
protocol_session_init(&sender, sv[0], sv[0]);
protocol_session_set_bwlimit(&sender, 0);
protocol_session_init(&receiver, sv[1], sv[1]);
protocol_session_set_bwlimit(&receiver, 0);
EXPECT_NOT_NULL(sender.ops);
EXPECT_NOT_NULL(receiver.ops);
dispatch_send_calls = 0;
dispatch_recv_calls = 0;
sender.ops = &counting_ops;
receiver.ops = &counting_ops;
const char payload[] = "dispatch-through-vtable";
EXPECT_TRUE(protocol_send_n_data(&sender, payload, sizeof(payload)));
char received[sizeof(payload)] = {0};
EXPECT_TRUE(protocol_receive_n_data(&receiver, received, sizeof(received)));
EXPECT_EQ_INT(memcmp(payload, received, sizeof(payload)), 0);
EXPECT_TRUE(dispatch_send_calls > 0);
EXPECT_TRUE(dispatch_recv_calls > 0);
close(sv[0]);
close(sv[1]);
}
/* Retry-contract tests: an op that reports PROTOCOL_IO_RETRY once (and hands the
* loop a switched wait event) must be retried rather than treated as a fatal
* error or a close. The send/receive loops had no unit coverage for this path
* even though every TLS WANT_READ/WANT_WRITE and EINTR retry relies on it. */
static int retry_send_calls;
static short retry_send_last_wait;
static int retry_recv_calls;
static short retry_recv_last_wait;
static ssize_t retry_once_send(ProtocolSession* session, const void* data, size_t size,
short* wait_events) {
retry_send_calls++;
if (retry_send_calls == 1) {
/* Simulate a WANT_READ-style retry: switch the poll event and make no
* progress. The send loop must consume this and retry. */
*wait_events = POLLIN;
return PROTOCOL_IO_RETRY;
}
ssize_t written = write(session->write_fd, data, size);
if (written < 0)
return PROTOCOL_IO_ERROR;
if (written == 0)
return PROTOCOL_IO_ERROR;
*wait_events = POLLOUT;
retry_send_last_wait = *wait_events;
return written;
}
static ssize_t retry_once_recv(ProtocolSession* session, void* data, size_t size,
short* wait_events) {
retry_recv_calls++;
if (retry_recv_calls == 1) {
*wait_events = POLLOUT;
return PROTOCOL_IO_RETRY;
}
ssize_t received = read(session->read_fd, data, size);
if (received < 0)
return PROTOCOL_IO_ERROR;
if (received == 0)
return PROTOCOL_IO_CLOSED;
*wait_events = POLLIN;
retry_recv_last_wait = *wait_events;
return received;
}
static const ProtocolIoOps retry_send_ops = {
.send = retry_once_send,
.recv = counting_recv,
.has_pending = counting_has_pending,
};
static const ProtocolIoOps retry_recv_ops = {
.send = counting_send,
.recv = retry_once_recv,
.has_pending = counting_has_pending,
};
static void test_protocol_io_retry_contract() {
const char payload[] = "retry-contract";
/* The send loop: the first attempt reports RETRY and switches the poll event
* to POLLIN. A pre-seeded readable byte on the *opposite* end of the
* socketpair keeps that poll immediately satisfiable, so the retry is the
* only thing under test. */
int send_sv[2];
EXPECT_EQ_INT(socketpair(AF_UNIX, SOCK_STREAM, 0, send_sv), 0);
char seed = 'x';
EXPECT_EQ_INT(write(send_sv[1], &seed, 1), 1);
ProtocolSession sender;
protocol_session_init(&sender, send_sv[0], send_sv[0]);
protocol_session_set_bwlimit(&sender, 0);
sender.ops = &retry_send_ops;
retry_send_calls = 0;
retry_send_last_wait = 0;
EXPECT_TRUE(protocol_send_n_data(&sender, payload, sizeof(payload)));
EXPECT_EQ_INT(retry_send_calls, 2);
EXPECT_EQ_INT(retry_send_last_wait, POLLOUT);
close(send_sv[0]);
close(send_sv[1]);
/* The receive loop: the first attempt reports RETRY and switches the poll
* event to POLLOUT, which a socketpair read fd satisfies immediately. */
int recv_sv[2];
EXPECT_EQ_INT(socketpair(AF_UNIX, SOCK_STREAM, 0, recv_sv), 0);
EXPECT_EQ_INT((int)write(recv_sv[0], payload, sizeof(payload)), (int)sizeof(payload));
ProtocolSession receiver;
protocol_session_init(&receiver, recv_sv[1], recv_sv[1]);
protocol_session_set_bwlimit(&receiver, 0);
receiver.ops = &retry_recv_ops;
retry_recv_calls = 0;
retry_recv_last_wait = 0;
char received[sizeof(payload)] = {0};
EXPECT_TRUE(protocol_receive_n_data(&receiver, received, sizeof(received)));
EXPECT_EQ_INT(memcmp(payload, received, sizeof(payload)), 0);
EXPECT_EQ_INT(retry_recv_calls, 2);
EXPECT_EQ_INT(retry_recv_last_wait, POLLIN);
close(recv_sv[0]);
close(recv_sv[1]);
}
typedef struct {
ProtocolSession* session;
SSL* expected_ssl;
SSL* resolved_ssl;
SSL* thread_local_ssl;
} SslResolverWorkerArg;
static int ssl_resolver_worker(void* arg) {
SslResolverWorkerArg* worker = arg;
protocol_session_bind(worker->session);
worker->resolved_ssl = protocol_current_ssl();
worker->thread_local_ssl = io_get_ssl();
protocol_session_unbind();
return thrd_success;
}
/* The worker-thread bug fix: a thread that bound a TLS session but never ran
* the handshake has io_ssl == NULL, yet protocol_current_ssl() must return the
* session's SSL so callers pick the TLS path. */
static void test_protocol_current_ssl_prefers_bound_session() {
SSL_CTX* ctx = SSL_CTX_new(TLS_method());
EXPECT_NOT_NULL(ctx);
SSL* ssl = SSL_new(ctx);
EXPECT_NOT_NULL(ssl);
int sv[2];
EXPECT_EQ_INT(socketpair(AF_UNIX, SOCK_STREAM, 0, sv), 0);
ProtocolSession session;
protocol_session_init(&session, sv[0], sv[0]);
const ProtocolIoOps* plain_ops = session.ops;
protocol_session_set_ssl(&session, ssl);
/* set_ssl must select a distinct (TLS) dispatch table; protocol_current_ssl
* only returns a bound session's SSL for TLS ops, so arg.resolved_ssl == ssl
* below also proves the bound session's ops are the TLS ops. */
EXPECT_NOT_NULL(plain_ops);
EXPECT_TRUE(session.ops != plain_ops);
EXPECT_TRUE(session.ssl == ssl);
/* Clear the calling thread's legacy SSL: only the bound session carries it. */
io_set_fds(-1, -1);
SslResolverWorkerArg arg = {
.session = &session, .expected_ssl = ssl, .resolved_ssl = NULL, .thread_local_ssl = ssl};
thrd_t worker;
EXPECT_EQ_INT(thrd_create(&worker, ssl_resolver_worker, &arg), thrd_success);
EXPECT_EQ_INT(thrd_join(worker, NULL), thrd_success);
EXPECT_TRUE(arg.resolved_ssl == arg.expected_ssl);
EXPECT_TRUE(arg.resolved_ssl == ssl);
EXPECT_NULL(arg.thread_local_ssl);
close(sv[0]);
close(sv[1]);
SSL_free(ssl);
SSL_CTX_free(ctx);
}
/* A bound plaintext session must NOT mask a live thread-local TLS transport:
* protocol_current_ssl() only trusts a bound session whose dispatch is TLS, so
* it falls back to io_ssl here. This is the safe direction for the sendfile
* decision -- returning NULL would let file_send.c take raw sendfile(2) on a
* socket this thread is encrypting. */
static void test_protocol_current_ssl_plaintext_bound_falls_back() {
SSL_CTX* ctx = SSL_CTX_new(TLS_method());
EXPECT_NOT_NULL(ctx);
SSL* ssl = SSL_new(ctx);
EXPECT_NOT_NULL(ssl);
int sv[2];
EXPECT_EQ_INT(socketpair(AF_UNIX, SOCK_STREAM, 0, sv), 0);
/* Live thread-local TLS, then a bound plaintext session: the plaintext
* session's NULL ssl must not shadow the encrypted transport. */
io_set_ssl(ssl);
ProtocolSession plain;
protocol_session_init(&plain, sv[0], sv[0]);
protocol_session_bind(&plain);
EXPECT_TRUE(protocol_current_ssl() == ssl);
protocol_session_unbind();
/* A bound TLS session still wins over a different thread-local TLS object. */
SSL* other = SSL_new(ctx);
EXPECT_NOT_NULL(other);
io_set_ssl(other);
ProtocolSession tls;
protocol_session_init(&tls, sv[0], sv[0]);
protocol_session_set_ssl(&tls, ssl);
protocol_session_bind(&tls);
EXPECT_TRUE(protocol_current_ssl() == ssl);
EXPECT_TRUE(protocol_current_ssl() != other);
protocol_session_unbind();
io_set_fds(-1, -1);
close(sv[0]);
close(sv[1]);
SSL_free(other);
SSL_free(ssl);
SSL_CTX_free(ctx);
}
/* ------------------------------------------------------------------------- *
* Genuine TLS + sendfile regression test.
*
* file_send_sendfile_with_skip() must route a TLS transfer through the
* buffered SSL path, resolved from the bound session, even in a worker thread
* whose thread-local io_ssl was never installed. This drives a real TLS
* handshake between two in-memory endpoints and calls the production
* file_send entry from a worker that bound a TLS session only: if the sendfile
* decision regresses to io_get_ssl() it sees NULL, takes raw sendfile(2), and
* copies the file's plaintext into the encrypted stream, so the peer's final
* SSL_read here fails. A tautology-free end-to-end decision guard.
* ------------------------------------------------------------------------- */
static void test_set_fd_nonblocking(int fd) {
int flags = fcntl(fd, F_GETFL, 0);
if (flags != -1)
fcntl(fd, F_SETFL, flags | O_NONBLOCK);
}
static SSL_CTX* test_tls_context_with_self_signed_cert(void) {
EVP_PKEY* key = EVP_PKEY_new();
EVP_PKEY_CTX* key_ctx = EVP_PKEY_CTX_new_id(EVP_PKEY_RSA, NULL);
if (!key || !key_ctx) {
EVP_PKEY_free(key);
EVP_PKEY_CTX_free(key_ctx);
return NULL;
}
bool key_ok = EVP_PKEY_keygen_init(key_ctx) == 1 &&
EVP_PKEY_CTX_set_rsa_keygen_bits(key_ctx, 2048) == 1 &&
EVP_PKEY_keygen(key_ctx, &key) == 1;
EVP_PKEY_CTX_free(key_ctx);
X509* cert = key_ok ? X509_new() : NULL;
bool cert_ok = cert != NULL && X509_set_version(cert, 2) == 1 &&
ASN1_INTEGER_set(X509_get_serialNumber(cert), 1) == 1 &&
X509_gmtime_adj(X509_getm_notBefore(cert), 0) != NULL &&
X509_gmtime_adj(X509_getm_notAfter(cert), 3600) != NULL &&
X509_set_pubkey(cert, key) == 1;
if (cert_ok) {
X509_NAME* name = X509_get_subject_name(cert);
cert_ok = X509_NAME_add_entry_by_txt(name, "CN", MBSTRING_ASC, (unsigned char*)"localhost", -1,
-1, 0) == 1 &&
X509_set_issuer_name(cert, name) == 1 && X509_sign(cert, key, EVP_sha256()) > 0;
}
SSL_CTX* ctx = cert_ok ? SSL_CTX_new(TLS_method()) : NULL;
bool installed = ctx != NULL && SSL_CTX_use_certificate(ctx, cert) == 1 &&
SSL_CTX_use_PrivateKey(ctx, key) == 1;
if (ctx && !installed) {
SSL_CTX_free(ctx);
ctx = NULL;
}
if (ctx)
SSL_CTX_set_verify(ctx, SSL_VERIFY_NONE, NULL);
X509_free(cert);
EVP_PKEY_free(key);
return ctx;
}
static bool test_tls_pump_handshake(SSL* ssl, int* done) {
int result = SSL_do_handshake(ssl);
if (result == 1) {
*done = 1;
return true;
}
int err = SSL_get_error(ssl, result);
return err == SSL_ERROR_WANT_READ || err == SSL_ERROR_WANT_WRITE;
}
static bool test_tls_read_exact(SSL* ssl, void* out, size_t size) {
char* bytes = out;
size_t got = 0;
while (got < size) {
int result = SSL_read(ssl, bytes + got, (int)(size - got));
if (result > 0) {
got += (size_t)result;
continue;
}
int err = SSL_get_error(ssl, result);
if (err != SSL_ERROR_WANT_READ && err != SSL_ERROR_WANT_WRITE)
return false;
struct pollfd pfd = {.fd = SSL_get_fd(ssl),
.events = err == SSL_ERROR_WANT_READ ? POLLIN : POLLOUT};
if (poll(&pfd, 1, 5000) <= 0)
return false;
}
return true;
}
typedef struct {
ProtocolSession* session;
File* file;
int fd;
bool ok;
} TlsSendfileWorkerArg;
static int tls_sendfile_worker(void* arg) {
TlsSendfileWorkerArg* worker = arg;
/* Deliberately never call io_set_ssl(): the bound session is the only
* transport this thread has, exactly like a worker in the -m pipeline. */
protocol_session_bind(worker->session);
worker->ok =
file_send_sendfile_with_skip(worker->file, worker->fd, false, 0, false, NULL, 0, 0, false);
protocol_session_unbind();
return thrd_success;
}
static void test_tls_sendfile_decision_uses_buffered_path() {
const char content[] = "tls-sendfile-regression-payload";
const char* path = "test_tls_sendfile_regression.bin";
EXPECT_TRUE(file_write_to_disk(path, content, sizeof(content), false, false));
SSL_CTX* ctx = test_tls_context_with_self_signed_cert();
EXPECT_NOT_NULL(ctx);
SSL* server_ssl = SSL_new(ctx);
SSL* client_ssl = SSL_new(ctx);
EXPECT_NOT_NULL(server_ssl);
EXPECT_NOT_NULL(client_ssl);
int sv[2];
EXPECT_EQ_INT(socketpair(AF_UNIX, SOCK_STREAM, 0, sv), 0);
test_set_fd_nonblocking(sv[0]);
test_set_fd_nonblocking(sv[1]);
EXPECT_EQ_INT(SSL_set_fd(server_ssl, sv[0]), 1);
EXPECT_EQ_INT(SSL_set_fd(client_ssl, sv[1]), 1);
SSL_set_accept_state(server_ssl);
SSL_set_connect_state(client_ssl);
int server_done = 0;
int client_done = 0;
for (int i = 0; i < 1000 && !(server_done && client_done); i++) {
bool server_ok = server_done || test_tls_pump_handshake(server_ssl, &server_done);
bool client_ok = client_done || test_tls_pump_handshake(client_ssl, &client_done);
if (!server_ok || !client_ok)
break;
}
EXPECT_TRUE(server_done && client_done);
File* file = file_create(path);
EXPECT_NOT_NULL(file);
file->data->size = sizeof(content);
ProtocolSession session;
protocol_session_init(&session, sv[0], sv[0]);
protocol_session_set_bwlimit(&session, 0);
protocol_session_set_ssl(&session, server_ssl);
TlsSendfileWorkerArg arg = {.session = &session, .file = file, .fd = sv[0], .ok = false};
thrd_t worker;
EXPECT_EQ_INT(thrd_create(&worker, tls_sendfile_worker, &arg), thrd_success);
EXPECT_EQ_INT(thrd_join(worker, NULL), thrd_success);
EXPECT_TRUE(arg.ok);
/* The peer must be able to decrypt the whole framing: size header and the
* file body, both produced through the TLS transport. */
unsigned long long wire_size = 0;
EXPECT_TRUE(test_tls_read_exact(client_ssl, &wire_size, sizeof(wire_size)));
EXPECT_EQ_INT((int)wire_size, (int)sizeof(content));
char received[sizeof(content)] = {0};
EXPECT_TRUE(test_tls_read_exact(client_ssl, received, sizeof(received)));
EXPECT_EQ_INT(memcmp(received, content, sizeof(content)), 0);
file_destroy(file);
close(sv[0]);
close(sv[1]);
SSL_free(server_ssl);
SSL_free(client_ssl);
SSL_CTX_free(ctx);
unlink(path);
}
void test_protocol() { void test_protocol() {
test_send_receive_n_data(); test_send_receive_n_data();
test_send_receive_n_data_zero(); test_send_receive_n_data_zero();
@@ -819,4 +1273,9 @@ void test_protocol() {
test_protocol_throttle_bytes_paces(); test_protocol_throttle_bytes_paces();
test_protocol_throttle_bytes_unlimited(); test_protocol_throttle_bytes_unlimited();
test_protocol_throttle_bytes_legacy_same_session(); test_protocol_throttle_bytes_legacy_same_session();
test_protocol_dispatch_via_ops();
test_protocol_io_retry_contract();
test_protocol_current_ssl_prefers_bound_session();
test_protocol_current_ssl_plaintext_bound_falls_back();
test_tls_sendfile_decision_uses_buffered_path();
} }
+236
View File
@@ -1,16 +1,21 @@
#include "test_xattr.h" #include "test_xattr.h"
#include "xattr.h" #include "xattr.h"
#include "charset.h"
#include "config.h" #include "config.h"
#include "file.h" #include "file.h"
#include "file_receive.h"
#include "file_save.h" #include "file_save.h"
#include "identity.h" #include "identity.h"
#include "metadata.h"
#include "protocol.h" #include "protocol.h"
#include "scanner_internal.h"
#include "test_utils.h" #include "test_utils.h"
#include <fcntl.h> #include <fcntl.h>
#include <stdint.h> #include <stdint.h>
#include <stdio.h> #include <stdio.h>
#include <stdlib.h> #include <stdlib.h>
#include <string.h> #include <string.h>
#include <sys/socket.h>
#include <sys/stat.h> #include <sys/stat.h>
#include <sys/wait.h> #include <sys/wait.h>
#include <sys/xattr.h> #include <sys/xattr.h>
@@ -660,8 +665,239 @@ static void test_file_save_directory_applies_xattrs() {
rmdir(root); rmdir(root);
} }
/* Symlink xattrs (protocol 2.29.0): the no-follow capture must read the LINK's
* OWN attributes and never the REFERENT's. Linux's VFS refuses to associate
* xattrs with a symlink at all, so the nofollow capture returns NULL while the
* path-following capture sees the referent's attribute -- which is exactly the
* bug the no-follow variant exists to prevent (a symlink entry must not carry
* its target's attributes). Guarded on filesystem xattr support. */
static void test_xattr_capture_symlink_nofollow() {
const char* target = "test_symlink_xattr_capture_target";
const char* link = "test_symlink_xattr_capture_link";
unlink(link);
unlink(target);
int fd = open(target, O_WRONLY | O_CREAT | O_TRUNC, 0600);
if (fd < 0)
return;
bool has_xattr = setxattr(target, "user.symref", "referent", 8, 0) == 0;
close(fd);
if (!has_xattr) {
unlink(target);
return; /* filesystem without xattr support */
}
if (symlink(target, link) != 0) {
unlink(target);
return;
}
/* The no-follow capture must never pick up the referent's attributes. */
FileXattrList* nofollow = xattr_capture_path_nofollow(link, false);
EXPECT_NULL(nofollow);
/* The path-following capture does, proving the referent really carries one
and that the no-follow variant differs. */
FileXattrList* follow = xattr_capture_path(link, false);
bool saw = false;
for (int i = 0; follow && i < follow->count; i++) {
if (strcmp(follow->items[i].name, "user.symref") == 0)
saw = true;
}
EXPECT_TRUE(saw);
xattr_list_free(follow);
xattr_list_free(nofollow);
unlink(link);
unlink(target);
}
/* Symlink xattrs (protocol 2.29.0): the no-follow apply must target the LINK,
* never its referent. On Linux the LSETXATTR is refused (the VFS does not
* allow symlink xattrs), but the critical guarantee is observable: the
* referent's attributes are UNCHANGED. A regression from lsetxattr to the
* path-following setxattr would rewrite the referent here and fail this test. */
static void test_xattr_apply_path_nofollow_does_not_follow() {
const char* root = "test_symlink_xattr_apply_tmp";
const char* target = "test_symlink_xattr_apply_tmp/target";
const char* link = "test_symlink_xattr_apply_tmp/link";
unlink(link);
unlink(target);
rmdir(root);
EXPECT_EQ_INT(mkdir(root, 0700), 0);
int tfd = open(target, O_WRONLY | O_CREAT | O_TRUNC, 0600);
if (tfd < 0) {
rmdir(root);
return;
}
bool has_xattr = setxattr(target, "user.orig", "orig", 4, 0) == 0;
close(tfd);
if (!has_xattr) {
unlink(target);
rmdir(root);
return; /* filesystem without xattr support */
}
EXPECT_EQ_INT(symlink("target", link), 0);
FileXattrList* list = xattr_list_new();
EXPECT_NOT_NULL(list);
EXPECT_TRUE(xattr_list_append(list, "user.orig", "hacked", 6));
EXPECT_TRUE(xattr_list_append(list, "user.added", "x", 1));
/* Invalid anchors are refused before any syscall (no fd/leaf/list). */
EXPECT_FALSE(xattr_apply_path_nofollow(-1, "link", list, false));
EXPECT_FALSE(xattr_apply_path_nofollow(0, "", list, false));
EXPECT_FALSE(xattr_apply_path_nofollow(0, "a/b", list, false));
EXPECT_FALSE(xattr_apply_path_nofollow(0, "link", NULL, false));
/* The confined parent directory is the anchor; the final component is the
link. Best-effort: returns true even when the kernel refuses. */
int dir_fd = open(root, O_RDONLY | O_DIRECTORY);
EXPECT_TRUE(dir_fd >= 0);
EXPECT_TRUE(xattr_apply_path_nofollow(dir_fd, "link", list, false));
close(dir_fd);
/* The referent must be untouched: a following apply would have set user.orig
to "hacked" and created user.added on the target. */
char buf[16];
ssize_t got = getxattr(target, "user.orig", buf, sizeof(buf));
EXPECT_EQ_INT(4, (int)got);
if (got == 4)
EXPECT_TRUE(memcmp(buf, "orig", 4) == 0);
EXPECT_TRUE(getxattr(target, "user.added", buf, sizeof(buf)) < 0);
/* If the platform DOES support symlink xattrs, they must have landed on the
link itself; on Linux the VFS refuses them, so the link stays empty. */
if (llistxattr(link, NULL, 0) > 0) {
ssize_t n = lgetxattr(link, "user.added", buf, sizeof(buf));
EXPECT_EQ_INT(1, (int)n);
if (n == 1)
EXPECT_TRUE(buf[0] == 'x');
}
xattr_list_free(list);
unlink(link);
unlink(target);
rmdir(root);
}
/* Protocol 2.29.0 scanner wiring: scanner_capture_xattrs() must choose the
* NO-FOLLOW capture for a symlink entry, so the link's FileXattrList never
* carries the REFERENT's user.* attributes. xattr_capture_path_nofollow() is
* already covered directly above; this exercises the scanner CALL SITE, which is
* what makes the no-follow variant actually reach symlink entries. If the
* scanner regressed to the path-following capture, file->xattrs would contain
* user.symref and this test fails. Guarded on filesystem xattr support. */
static void test_scanner_symlink_capture_is_nofollow() {
const char* target = "test_scanner_symlink_xattr_target";
const char* link = "test_scanner_symlink_xattr_link";
unlink(link);
unlink(target);
int fd = open(target, O_WRONLY | O_CREAT | O_TRUNC, 0600);
if (fd < 0)
return;
bool has_xattr = setxattr(target, "user.symref", "referent", 8, 0) == 0;
close(fd);
if (!has_xattr) {
unlink(target);
return; /* filesystem without xattr support */
}
if (symlink(target, link) != 0) {
unlink(target);
return;
}
DirectoryScanner scanner;
memset(&scanner, 0, sizeof(scanner));
scanner.options.preserve_xattrs = true;
File* file = file_create(link);
EXPECT_NOT_NULL(file);
file->is_symlink = true;
scanner_capture_xattrs(&scanner, file);
/* The referent's attribute must not appear on the symlink's captured list. */
bool leaked = false;
for (int i = 0; file->xattrs && i < file->xattrs->count; i++) {
if (strcmp(file->xattrs->items[i].name, "user.symref") == 0)
leaked = true;
}
EXPECT_FALSE(leaked);
/* On Linux the VFS associates no xattrs with a symlink, so the capture is
NULL (never an empty-but-valid list). */
EXPECT_NULL(file->xattrs);
file_destroy(file);
unlink(link);
unlink(target);
}
/* Protocol 2.29.0: a STATUS_SYMLINK frame followed by an -X/-A xattr block is
* decoded by file_receive_symlink() with the block attached to the File. This
* is the wire round-trip for the new trailing symlink xattr block. */
static void run_recv_symlink_with_xattrs(int fd) {
/* Stack-allocated so the forked child leaks nothing at _exit() (a
config_create() in the parent would be inherited and never freed here). */
Config config;
memset(&config, 0, sizeof(config));
config.use_metadata = true;
config.use_xattrs = true;
config.preserve_xattrs = true;
File* file = file_receive_symlink(fd, &config);
if (!file)
_exit(1);
bool ok = file->is_symlink && file->symlink_target != NULL &&
strcmp(file->symlink_target, "target") == 0;
ok = ok && file->xattrs != NULL && file->xattrs->count == 1 &&
strcmp(file->xattrs->items[0].name, "user.sym") == 0 &&
file->xattrs->items[0].value_len == 3 && memcmp(file->xattrs->items[0].value, "sym", 3) == 0;
file_destroy(file);
_exit(ok ? 0 : 1);
}
static void test_symlink_frame_carries_xattrs() {
int p[2];
EXPECT_EQ_INT(socketpair(AF_UNIX, SOCK_STREAM, 0, p), 0);
io_set_fds(p[0], p[1]);
io_set_bwlimit(0);
pid_t pid = fork();
if (pid == 0) {
close(p[1]);
io_set_fds(p[0], p[0]);
io_set_bwlimit(0);
run_recv_symlink_with_xattrs(p[0]);
}
close(p[0]);
io_set_fds(p[1], p[1]);
io_set_bwlimit(0);
FileMetadata m;
memset(&m, 0, sizeof(m));
m.mode = S_IFLNK | 0777;
m.uid = (uint32_t)geteuid();
m.gid = (uint32_t)getegid();
m.mtime_sec = 1700000000;
FileXattrList* list = xattr_list_new();
EXPECT_NOT_NULL(list);
EXPECT_TRUE(xattr_list_append(list, "user.sym", "sym", 3));
/* Exactly the sender's order: path, target, metadata, xattr block. */
bool wrote = send_wire_str(p[1], "link") && send_wire_str(p[1], "target") &&
metadata_send(p[1], &m) && xattr_send(p[1], list);
xattr_list_free(list);
close(p[1]);
int status = 0;
waitpid(pid, &status, 0);
EXPECT_TRUE(wrote);
EXPECT_TRUE(WIFEXITED(status) && WEXITSTATUS(status) == 0);
}
void test_xattr() { void test_xattr() {
test_xattr_list_clone(); test_xattr_list_clone();
test_xattr_capture_symlink_nofollow();
test_scanner_symlink_capture_is_nofollow();
test_xattr_apply_path_nofollow_does_not_follow();
test_symlink_frame_carries_xattrs();
test_xattr_wire_roundtrip(); test_xattr_wire_roundtrip();
test_xattr_reject_privileged_namespace(); test_xattr_reject_privileged_namespace();
test_xattr_reject_oversized_value(); test_xattr_reject_oversized_value();