10 Commits
Author SHA1 Message Date
TapTap 9d17e951f1 docs: recount RSYNC_COMPAT to 111/20 after Phase 5 waves A-C; clang-format 18 reflow
CI / lint (push) Successful in 1m9s
CI / sanitizers (undefined) (push) Successful in 53s
CI / sanitizers (address) (push) Successful in 55s
CI / fuzz-build (push) Successful in 19s
CI / coverage (push) Successful in 43s
CI / valgrind (push) Successful in 37s
CI / build-and-test (push) Successful in 4m28s
2026-09-09 14:37:52 +02:00
TapTap 1e02ebcd32 Merge feat/p5-remote-option: --remote-option, --trust-sender
# Conflicts:
#	RSYNC_COMPAT.md
#	src/client/client_cli.c
#	src/client/client_send.c
#	src/shared/transport_ssh.c
#	src/shared/transport_ssh.h
#	tests/integration/test_ssh.py
#	tests/test_client_cli.c
#	tests/test_transport_ssh.c
2026-09-09 14:35:26 +02:00
TapTap b1c63ff947 Merge feat/p5-socket: --address, -4/-6, --sockopts, server bind options
# Conflicts:
#	RSYNC_COMPAT.md
#	tests/test_client_cli.c
2026-09-09 14:30:36 +02:00
TapTap 35f4297538 Merge feat/p5-rsh: --rsh/-e, --rsync-path, --blocking-io, --outbuf 2026-09-09 14:29:58 +02:00
TapTap f86ba7a556 fix(p5-socket): clang-format 18 reflow + cppcheck const-correctness 2026-09-09 14:29:46 +02:00
TapTap cdcaf21acd fix(p5-rsh): NULL-check argv tail str_dups in ssh_build_client_argv 2026-09-09 14:29:46 +02:00
TapTap ad228db915 fix(p5-remote-option): wire server --trust-sender, align save-layer gates, add hostile-sender test 2026-09-09 14:23:59 +02:00
TapTap 07d1dd84f7 feat(p5-socket): --address, -4/-6, --sockopts, server bind options 2026-09-09 13:41:28 +02:00
TapTap 858af3d63d feat(p5-rsh): --rsh/-e, --rsync-path, --blocking-io, --outbuf 2026-09-09 13:41:28 +02:00
TapTap 5e79d7d76b feat(p5-remote-option): --remote-option (probe 2.14.0), --trust-sender 2026-09-09 13:41:28 +02:00
28 changed files with 1962 additions and 142 deletions

No files matched your search

+23 -13
View File
@@ -6,11 +6,11 @@ This document maps rsync's full feature set to FastSync's current implementation
| Status | Count | Description |
|--------|-------|-------------|
| ✅ Implemented | 101 | Feature works end-to-end |
| ✅ Implemented | 111 | Feature works end-to-end |
| 🔀 Alt Arg | 3 | Functionality exists but under different flag/semantics |
| ⚠️ Partial | 10 | Flag parsed/stored but behavior incomplete |
| 🔄 Compatibility No-op | 3 | Flag is accepted for CLI compatibility but has no effect |
| ❌ Not Implemented | 30 | Flag not recognized or no behavior |
| ❌ Not Implemented | 20 | Flag not recognized or no behavior |
| **Total** | **147** | |
---
@@ -607,16 +607,16 @@ now transmits targets (the prior behavior was broken/partial); its status moved
| Flag | Rsync Description | FastSync Status | Notes |
|------|-------------------|-----------------|-------|
| `-e`, `--rsh=COMMAND` | Remote shell to use | ❌ Not Implemented | Removed; SSH invokes `ssh` directly |
| `--rsync-path=PROGRAM` | rsync binary on remote | ❌ Not Implemented | Removed; use `--fastsync-server-path` |
| `-e`, `--rsh=COMMAND` | Remote shell to use | ✅ Implemented | `-e`/`--rsh` (and `--rsh=COMMAND`) select the remote-shell program used to build the SSH child argv, overriding the default `ssh`. The command is whitespace-split into the leading argv words so rsync's `-e "ssh -p 2222"` works; the standard `-o` family, an optional `-p` port, `user@host` and the quoted remote command (`fastsync-server --stdio`) follow. Stored in the `rsh_command` config field. **Client-only, never crosses the wire** (it is a launch concern, not a handshake property) |
| `--rsync-path=PROGRAM` | rsync binary on remote | ✅ Implemented | Alias for `--fastsync-server-path`: both write the `fastsync_server_path` config field used as the remote-side server program (quoted as one remote-shell word unless `--old-args`), which CROSSES the wire as before. Kept separate from `--rsh`, which names the local connecting program |
| `--port=PORT` | Alternate daemon port | ✅ Implemented | `server_port` config field |
| `--sockopts=OPTIONS` | Custom TCP options | ❌ Not Implemented | |
| `--blocking-io` | Use blocking I/O for remote shell | ❌ Not Implemented | |
| `--outbuf=N\|L\|B` | Set output buffering | ❌ Not Implemented | |
| `--address=ADDRESS` | Bind address for outgoing socket | ❌ Not Implemented | Removed because it had no effect |
| `-4`, `--ipv4` | Prefer IPv4 | ❌ Not Implemented | Removed because it had no effect |
| `-6`, `--ipv6` | Prefer IPv6 | ❌ Not Implemented | Removed because it had no effect |
| `--remote-option=OPT`, `-M` | Send an option only to the remote side | ❌ Not Implemented | `-M` is FastSync's metadata-preservation flag |
| `--sockopts=OPTIONS` | Custom TCP options | ✅ Implemented | Comma-separated allowlist of `OPT=VAL` applied via `setsockopt` after `socket()` before `connect()`/`bind()`. Only `TCP_NODELAY`, `SO_KEEPALIVE`, `SO_REUSEADDR` (0/1) and `SO_RCVBUF`/`SO_SNDBUF` (byte count) are accepted; an unknown option name or a bad value is rejected up front, never silently ignored. A value is required for every option (`OPT=VAL`; a bare name is an error). Applied to the outgoing TCP and TLS client socket; absent by default. `SockOptEntry`/`sockopts` config fields. Local socket concern: never crosses the wire |
| `--blocking-io` | Use blocking I/O for remote shell | ✅ Implemented | With `--blocking-io` the SSH-transport socketpair socket is left without `SO_RCVTIMEO`/`SO_SNDTIMEO`, so the transfer blocks naturally; by default it gets the same read/write timeout as the TCP transport (see `--timeout`). `blocking_io` config bool. **Client-only, never crosses the wire** |
| `--outbuf=N\|L\|B` | Set output buffering | ✅ Implemented | `N` (none/unbuffered) → `_IONBF`, `L` (line) → `_IOLBF`, `B` (block, the default) → `_IOFBF` via `setvbuf` on stdout and stderr. Garbage values are rejected. `outbuf` config field (`OutbufMode`). **Client-only, never crosses the wire** |
| `--address=ADDRESS` | Bind address for outgoing socket | ✅ Implemented | Binds the outgoing client socket to a local source address before `connect()` (resolved with the same `-4`/`-6` family hints as the destination). Local socket concern: never crosses the wire |
| `-4`, `--ipv4` | Prefer IPv4 | ✅ Implemented | Forces `AF_INET` in the `getaddrinfo` hints for client destination/source resolution and the server bind (see the Phase 5, Wave B note). Mutually exclusive with `-6` |
| `-6`, `--ipv6` | Prefer IPv6 | ✅ Implemented | Forces `AF_INET6` in the `getaddrinfo` hints for client destination/source resolution and the server bind. Mutually exclusive with `-4` |
| `--remote-option=OPT`, `-M` | Send an option only to the remote side | ✅ Implemented | Long form only; each value is appended to the remote server invocation over SSH as an individually single-quote-escaped shell word in `ssh_build_remote_command()`. Values are validated (non-empty, no control characters) and shell metacharacters cannot break out of the quoting (`;`, `&`, `|`, <code>`</code>, `$`, `(`, `)`, quotes are neutralized), so a value cannot inject an arbitrary remote command and a subsequent `--` on the client line cannot be turned into one. The options never cross the binary config frame. Divergence: the short `-M` form is intentionally unavailable because `-M` is already FastSync's metadata-preservation flag/multiplier (see Phase 5 notes below) |
## 14. Daemon Mode
@@ -639,7 +639,7 @@ now transmits targets (the prior behavior was broken/partial); its status moved
| Max data/string/chunk sizes | Prevent OOM attacks | ✅ Implemented | Per-message limits |
| Per-connection memory limit | 1GB per connection | ✅ Implemented | `MAX_CONNECTION_MEMORY` |
| `--max-alloc=SIZE` | Limit a single memory allocation | ✅ Implemented | Caps the largest single allocation; binary units, default 1G |
| `--trust-sender` | Trust remote sender's file list | ❌ Not Implemented | |
| `--trust-sender` | Trust remote sender's file list | ✅ Implemented | Long-form-only, receiver-local policy that never crosses the wire. The receiver skips its redundant up-front re-validation of the incoming file list (empty/`..` path rejection and the escaping-symlink-target containment), trusting the sender instead of double-checking (fewer checks, faster, potentially unsafe, matching rsync). Off by default. The low-level fd-relative confinement primitives (`file_open_secure_parent`, the O_NOFOLLOW parent walk, leaf/destination confinement) are deliberately KEPT even under `--trust-sender`, so a hostile sender still cannot write or link outside the authorized root (see Phase-5 notes below) |
| `--old-args` | Disable modern arg protection | ✅ Implemented | SSH-only legacy mode; restores raw remote command construction and permits shell interpretation of the configured server path |
| `--ignore-missing-args` | Ignore missing source args | ✅ Implemented | FastSync has a single source-root argument (which always exists), so the "explicitly requested source arguments" are the `--files-from` entries and the flags only ever apply there (inert without `--files-from`, like `-R`). Without the flag a listed-but-missing entry stays a hard pre-transfer error (nothing is transferred). With it each missing entry is skipped: nothing is sent for it, it never enters the keep-set, and the run succeeds for the rest — an all-missing non-empty list succeeds transferring nothing, matching rsync. `--dirs` + `--files-from` missing entries are skipped the same way. Every skipped entry is logged and a per-run warning names the count, so the handling is never a silent no-op. Divergences: an EMPTY `--files-from` file stays a hard error in every mode (no argument was requested at all; rsync likewise reports "no source files specified"); missing-arg skipping only applies to the pre-transfer list validation, so an entry that is present at preflight and vanishes mid-transfer still fails (matching rsync, whose flag "does not affect subsequent vanished-file errors"); `--no-ignore-missing-args` is not a supported negation |
| `--delete-missing-args` | Delete missing source args | ✅ Implemented | Implies `--ignore-missing-args` (order-independent) and additionally removes each missing entry's destination mirror receiver-side. The mirror is computed exactly like a present sibling's wire path: the bare relative entry under `-R`, otherwise the full source-mirror path below the destination root. rsync parity, verified against the man page: it does **not** imply `--delete` generally and is "independent of any other type of delete processing" — unrelated destination extras are untouched unless `--delete` is also present. Composition with `--delete` + timing: the exact-path deletions commit with the manifest, early for `--delete-before`/`--delete-during`, else only after a fully-successful transfer (delete-after/commit). A non-empty directory mirror is removed only when `--force` or `--delete` is in effect (otherwise it is left with a warning and the run continues, like rsync); an absent mirror is a no-op. An explicitly listed missing arg is a user request, not an excluded file: its deletion is never blocked by the filter-exclusion protection of excluded destination mirrors (a mirror sitting inside a filter-excluded directory is still removed). Safety/policy: gated by the server `--allow-delete` policy like `--delete`; the request paths cross the wire only in the delete-manifest frame and are confined by the same receiver validation as the keep-set (non-empty, relative, traversal-free, bounded by the per-section/per-frame manifest caps); the `--delay-updates` staging directory and basis snapshots are protected exactly as in the extras walker. Divergence: the missing-args deletions are not counted toward `--max-delete` (they are explicit per-path requests, not discovered extras). See the Phase-3 wire note below for the `PROTOCOL_VERSION` bump |
@@ -669,6 +669,10 @@ now transmits targets (the prior behavior was broken/partial); its status moved
## Implementation Difficulty Plan
**Phase 5 notes (remote-option wave):** `--remote-option=OPT` (long form only) and `--trust-sender` landed here.
- `--remote-option` is CLIENT-only and never serialized into the binary config frame. On the SSH transport the client forwards each value to the remote server by appending it to the remote command line in `ssh_build_remote_command()`, after ` --stdio`, as an individually single-quoted shell word (`'...'` with `'\''` for embedded quotes). Values are validated at CLI parse time (non-empty; no ASCII control characters) and rejected otherwise, and a non-conforming value is refused again in the command builder, so shell metacharacters (`;`, `&`, `|`, backticks, `$()`, quotes) can never break out of the quoting to inject an unrelated remote command — including after a client-side `--` separator, whose arguments are never forwarded anyway. Because the remote options affect the *remote server invocation*, not the transmitted config, the wire frame layout is unchanged, but `PROTOCOL_VERSION` was bumped **2.13.0 → 2.14.0** as the Phase-5 lockstep release marker (a 2.14 client against a 2.13 server fails the version check cleanly rather than the old server rejecting an unfamiliar forwarded argv later). Divergence: rsync's short `-M` form of `--remote-option` is intentionally NOT implemented, because `-M` is already FastSync's metadata-preservation mode/multiplier.
- `--trust-sender` is a receiver-local policy: it never crosses the wire (the sender's value is never serialized, so a wire peer can never enable it). On the receiving process it skips the up-front re-validation of the incoming file list (empty/`..` path rejection and the escaping-symlink-target containment), trusting the sender's list instead of double-checking — fewer checks, faster, and potentially unsafe, matching rsync. It is OFF by default (`config.trust_sender`). As a deliberate safety floor, the low-level fd-relative confinement primitives are NOT disabled: `file_open_secure_parent()` (O_NOFOLLOW walk, `..` rejection, root containment) and leaf/destination confinement still hold, so even under `--trust-sender` a hostile sender cannot write or create a symlink outside the authorized root — the relaxation only removes the redundant list-layer double-checks, never the root-confinement guarantees.
The estimates below cover the currently unimplemented features in this document. They assume one engineer familiar with the codebase, include implementation and focused tests, and exclude production rollout time. A feature should not be marked implemented until its behavior is tested in both local and SSH/TCP paths where applicable.
> **Note:** This plan is a superset snapshot written while several of the listed features were still outstanding. The Summary matrix above is the authoritative record of what is already shipped (for example quiet/info/debug output, `--existing`, `--remove-source-files`, `-h`, and `--size-only` are now implemented on `dev`). Treat the phases as sequencing guidance for the work that remains unimplemented.
@@ -744,11 +748,17 @@ These options affect process startup, authentication, sockets, and remote execut
| Features | Effort | Implementation plan |
|----------|--------|--------------------|
| `--rsh=COMMAND`, `-e`; `--rsync-path=PROGRAM`; `--blocking-io`; `--outbuf=N\|L\|B` | M | Generalize SSH command construction and subprocess I/O while retaining argument escaping and timeout guarantees. |
| `--rsh=COMMAND`, `-e`; `--rsync-path=PROGRAM`; `--blocking-io`; `--outbuf=N\|L\|B` | M | ✅ Wave A implemented (see the Connectivity table above). SSH argv construction is generalized: `-e`/`--rsh` replaces the hardcoded `ssh` program (whitespace-split, so `-e "ssh -p 2222"` works), `--rsync-path` aliases the existing `fastsync_server_path`, `--blocking-io` drops the SSH socket timeouts, and `--outbuf` maps N/L/B onto `setvbuf`. All four are client-only launch concerns and never cross the wire. |
| `--address=ADDRESS`; `--ipv4`, `-4`; `--ipv6`, `-6`; `--sockopts=OPTIONS`; `--port=PORT` daemon semantics | M | Add explicit socket-family/bind configuration and validate it independently for TCP client and daemon modes. |
**Phase 5, Wave B (socket/bind) shipping note:** `--sockopts` adds a strict allowlisted `OPT=VAL` socket-option layer applied with correct per-option value types; `--address` binds the outgoing client socket to a local source address; `-4`/`-6` pin the address family via `getaddrinfo` hints on both the client connect and the server bind; and the server bind now honors `--address` plus `-4`/`-6` (falling back to the historical IPv4 `INADDR_ANY` when none are given). All of these are local socket concerns and none cross the wire config frame (only `--port` maps to `server_port`).
| `--remote-option=OPT`, `-M`; `--trust-sender` | L | Add authenticated remote-option/config negotiation and reject unsafe sender-controlled values. `-M` conflicts with FastSync metadata mode. |
| `--daemon`; `--config=FILE`; `--dparam=OVERRIDE`; `--no-detach`; `--password-file=FILE`; `--early-input=FILE`; `--no-motd` | XL | Implement a real daemon lifecycle, module configuration, authentication, privilege separation, and process management. |
**Phase 5, Wave A (rsh/ssh) shipping note:** the SSH transport no longer hardcodes `ssh`. `-e`/`--rsh=COMMAND` selects the remote-shell program (whitespace-split into the leading child argv words), `--rsync-path=PROGRAM` aliases `--fastsync-server-path`, `--blocking-io` removes the SSH-socketpair `SO_RCVTIMEO`/`SO_SNDTIMEO` timeouts (by default they now match the TCP transport so a wedged shell cannot hang forever), and `--outbuf=N|L|B` maps onto `setvbuf` (`_IONBF`/`_IOLBF`/`_IOFBF`, garbage rejected). All four are client-only launch concerns and never cross the wire.
**Phase 5, Wave C (remote-option/trust-sender) shipping note (PROTOCOL 2.13.0 → 2.14.0):** `--remote-option=OPT` (long form only; the short `-M` is intentionally left as FastSync metadata mode — documented divergence) appends each validated value to the remote server invocation over SSH as an individually single-quote-escaped shell word, so shell metacharacters cannot break out and a `--` can never be turned into injection; options never cross the binary config frame. `--trust-sender` is a receiver-local policy (never serialized, so a wire peer can't enable it): when requested on the server (via `--remote-option=--trust-sender`), it removes only the redundant receiver/save-layer path re-checking; the low-level floor (`file_open_secure_parent`'s `..` rejection, the O_NOFOLLOW parent walk, leaf/destination confinement) stays enforced. Off by default. The wire config-frame layout is unchanged; the bump reflects that a 2.14 sender composing remote options requires a 2.14 receiver to honor them.
### Phase 6: Batch, Encoding, and Protocol Interoperability
These are the hardest compatibility items because they require durable formats or behavior that must interoperate with rsync itself.
+123
View File
@@ -148,6 +148,24 @@ static int set_compression_threads_option(int* dest, const char* value) {
return 0;
}
/* Parse and validate --sockopts=OPTIONS into the config. The strict allowlist
* (config_sockopts_parse) rejects an unknown option name or an invalid value
* up front, so a typo never silently disables a socket option. */
static int set_sockopts_option(Config* config, const char* value) {
SockOptEntry* entries = NULL;
int count = 0;
if (config_sockopts_parse(value, &entries, &count) != 0) {
log_message(LOG_LEVEL_ERROR,
"--sockopts must be a comma-separated OPT=VAL list of supported options "
"(TCP_NODELAY, SO_KEEPALIVE, SO_RCVBUF, SO_SNDBUF, SO_REUSEADDR)");
return -1;
}
free(config->sockopts);
config->sockopt_count = count;
config->sockopts = entries;
return 0;
}
/* Parse a string as a non-negative integer into *dest. Returns 0 on success, -1 on error. */
static int set_nonneg_int_option(int* dest, const char* value, const char* option_name) {
if (!parse_nonneg_int(value, dest)) {
@@ -157,6 +175,40 @@ static int set_nonneg_int_option(int* dest, const char* value, const char* optio
return 0;
}
/* Forward decl: config_add_pattern is defined below, but the --remote-option
* helper above needs it. */
static int config_add_pattern(char*** patterns, int* count, const char* value, const char* optname);
/* Validate and append one --remote-option=OPT value. OPT is forwarded to the
* remote server invocation (over SSH) by appending it to the remote command
* line, so it must be a single safe shell word: it must be non-empty and must
* contain no control characters that could break the single-quoted command
* word ssh_build_remote_command wraps it in (newline/CR and other ASCII
* control chars are rejected up front). Ordinary shell metacharacters
* (; & | ` $ () etc.) need not be rejected because they are neutralized by the
* single-quoting boundary, but rejecting control characters keeps the
* quoting scheme airtight regardless of the remote shell. Returns 0 on
* success, -1 on a rejected value. */
static int config_add_remote_option(Config* config, const char* value, const char* optname) {
if (!value || value[0] == '\0') {
log_message(LOG_LEVEL_ERROR, "%s requires a non-empty option value", optname);
return -1;
}
for (const unsigned char* p = (const unsigned char*)value; *p; p++) {
if (*p < 0x20 || *p == 0x7f) {
log_message(LOG_LEVEL_ERROR,
"%s value contains a control character that could break the remote shell "
"quoting; rejecting",
optname);
return -1;
}
}
if (config_add_pattern(&config->remote_options, &config->remote_option_count, value, optname) !=
0)
return -1;
return 0;
}
/* Validate and append one --compare-dest/--copy-dest/--link-dest directory.
* The path is interpreted on the receiver relative to the destination root,
* so it must be a non-empty relative path with no "." / ".." components (an
@@ -194,6 +246,35 @@ static int set_stderr_mode(const char* value) {
return 0;
}
/* Parse --outbuf=N|L|B into the config's OutbufMode. N=none (unbuffered),
* L=line-buffered, B=block-buffered (the stdio default). Anything else is a
* clear error, never a silent fallback. */
static int set_outbuf_option(Config* config, const char* value) {
if (strcmp(value, "N") == 0 || strcmp(value, "n") == 0)
config->outbuf = OUTBUF_NONE;
else if (strcmp(value, "L") == 0 || strcmp(value, "l") == 0)
config->outbuf = OUTBUF_LINE;
else if (strcmp(value, "B") == 0 || strcmp(value, "b") == 0)
config->outbuf = OUTBUF_BLOCK;
else {
log_message(LOG_LEVEL_ERROR, "--outbuf must be N (none), L (line), or B (block)");
return -1;
}
return 0;
}
#ifndef FASTSYNC_TEST_BUILD
/* Apply the parsed --outbuf style to stdout/stderr via setvbuf, matching stdio
* semantics: N -> _IONBF (unbuffered), L -> _IOLBF (line), B -> _IOFBF (block,
* the default). */
static void apply_output_buffering(const Config* config) {
int mode = config->outbuf;
int stdio_mode = (mode == OUTBUF_NONE) ? _IONBF : (mode == OUTBUF_LINE) ? _IOLBF : _IOFBF;
setvbuf(stdout, NULL, stdio_mode, 0);
setvbuf(stderr, NULL, stdio_mode, 0);
}
#endif
static int read_patterns_from_file(const char* filepath, char*** patterns, int* count);
static int parse_debug_flags(const char* value, Config* config) {
@@ -472,6 +553,8 @@ static const OptionEntry OPTION_TABLE[] = {
{"--secluded-args", NULL, OPT_NOOP, 0},
{"--update", "-u", OPT_FLAG, offsetof(Config, update)},
{"--old-args", NULL, OPT_FLAG, offsetof(Config, old_args)},
{"--rsh", "-e", OPT_STRING, offsetof(Config, rsh_command)},
{"--blocking-io", NULL, OPT_FLAG, offsetof(Config, blocking_io)},
{"--links", "-l", OPT_FLAG, offsetof(Config, follow_symlinks)},
{"--copy-links", NULL, OPT_FLAG, offsetof(Config, copy_links)},
{"--safe-links", NULL, OPT_FLAG, offsetof(Config, safe_links)},
@@ -521,6 +604,9 @@ static const OptionEntry OPTION_TABLE[] = {
{"--ca", NULL, OPT_STRING, offsetof(Config, tls_ca)},
{"--backup-dir", NULL, OPT_STRING, offsetof(Config, backup_dir)},
{"--fastsync-server-path", NULL, OPT_STRING, offsetof(Config, fastsync_server_path)},
/* --rsync-path is rsync's spelling for the same "server program path"; it
* is a pure alias for fastsync_server_path (never a distinct field). */
{"--rsync-path", NULL, OPT_STRING, offsetof(Config, fastsync_server_path)},
{"--temp-dir", NULL, OPT_STRING, offsetof(Config, temp_dir)},
{"--partial-dir", NULL, OPT_STRING, offsetof(Config, partial_dir)},
{"--suffix", NULL, OPT_STRING, offsetof(Config, suffix)},
@@ -530,6 +616,9 @@ static const OptionEntry OPTION_TABLE[] = {
{"--timeout", NULL, OPT_POS_INT, offsetof(Config, timeout)},
{"--contimeout", NULL, OPT_POS_INT, offsetof(Config, contimeout)},
{"--max-depth", NULL, OPT_NONNEG_INT, offsetof(Config, max_depth)},
{"--address", NULL, OPT_STRING, offsetof(Config, address)},
{"--ipv4", "-4", OPT_FLAG, offsetof(Config, ipv4)},
{"--ipv6", "-6", OPT_FLAG, offsetof(Config, ipv6)},
{"--max-size", NULL, OPT_ULL, offsetof(Config, max_size)},
{"--min-size", NULL, OPT_ULL, offsetof(Config, min_size)},
@@ -551,6 +640,11 @@ static const OptionEntry OPTION_TABLE[] = {
{"--xattrs", "-X", OPT_FLAG, offsetof(Config, preserve_xattrs)},
{"--acls", "-A", OPT_FLAG, offsetof(Config, preserve_acls)},
{"--fake-super", NULL, OPT_FLAG, offsetof(Config, fake_super)},
/* Long-form-only: rsync's -M short form of --remote-option is INTENTIONALLY
* unavailable because -M already means metadata mode in FastSync (a
* documented divergence; see RSYNC_COMPAT.md). --trust-sender is a local
* receiver policy and never travels to the remote peer. */
{"--trust-sender", NULL, OPT_FLAG, offsetof(Config, trust_sender)},
};
/* Only boolean options with no required argument are safe to negate. */
@@ -1125,6 +1219,26 @@ int parse_args(Config* config, int argc, char* argv[], int* positional_args,
}
if (set_checksum_seed(config, argv[++i]) != 0)
return -1;
} else if (strncmp(argv[i], "--sockopts=", 11) == 0) {
if (set_sockopts_option(config, argv[i] + 11) != 0)
return -1;
} else if (opt_is(argv[i], "--sockopts", NULL)) {
if (i + 1 >= argc) {
log_message(LOG_LEVEL_ERROR, "missing argument for --sockopts");
return -1;
}
if (set_sockopts_option(config, argv[++i]) != 0)
return -1;
} else if (strncmp(argv[i], "--remote-option=", 16) == 0) {
if (config_add_remote_option(config, argv[i] + 16, "--remote-option") != 0)
return -1;
} else if (opt_is(argv[i], "--remote-option", NULL)) {
if (i + 1 >= argc) {
log_message(LOG_LEVEL_ERROR, "missing argument for --remote-option");
return -1;
}
if (config_add_remote_option(config, argv[++i], "--remote-option") != 0)
return -1;
} else if (strncmp(argv[i], "--compare-dest=", 15) == 0) {
if (set_basis_dest_option(config, BASIS_DEST_COMPARE, argv[i] + 15, "--compare-dest") != 0)
return -1;
@@ -1191,6 +1305,12 @@ int parse_args(Config* config, int argc, char* argv[], int* positional_args,
if (identity_parse_chown(config, argv[++i]) != 0)
return -1;
config->use_metadata = true;
} else if (strncmp(argv[i], "--outbuf=", 9) == 0) {
if (set_outbuf_option(config, argv[i] + 9) != 0)
return -1;
} else if (opt_is(argv[i], "--outbuf", NULL)) {
if (i + 1 >= argc || set_outbuf_option(config, argv[++i]) != 0)
return -1;
} else if (argv[i][0] == '-') {
char* escaped = output_escape(argv[i], false);
fprintf(stderr, "Unknown option: %s\n", escaped ? escaped : "<allocation failed>");
@@ -1395,6 +1515,9 @@ int main(int argc, char* argv[]) {
goto cleanup;
}
/* Apply the requested --outbuf style now that the mode is parsed. */
apply_output_buffering(config);
/* --open-noatime is a sender-side policy: install it for every source read
(scan + data path) without touching the receiver. */
file_set_open_noatime(config->open_noatime);
+14 -4
View File
@@ -367,18 +367,28 @@ static Client* connect_transfer_client(const Config* config) {
return NULL;
}
return client_connect_ssh(config->ssh_destination, config->ssh_port,
config->fastsync_server_path, config->old_args);
config->fastsync_server_path, config->old_args, config->rsh_command,
config->blocking_io, config->remote_options,
config->remote_option_count);
}
Client* client = client_create();
if (!client)
return NULL;
/* Socket/connect concerns that never cross the wire: --address (source bind),
* -4/-6 (family pinning), and --sockopts. Passed straight to the TCP layer. */
TcpConnectOptions connect_opts;
connect_opts.bind_address = config->address;
connect_opts.family = tcp_connect_family(config->ipv4, config->ipv6);
connect_opts.sockopts = config->sockopts;
connect_opts.sockopt_count = config->sockopt_count;
bool connected;
if (config->use_tls) {
connected = client_connect_tls(client, config->server_host, config->server_port,
config->tls_cert, config->tls_key, config->tls_ca);
connected =
client_connect_tls_ex(client, config->server_host, config->server_port, config->tls_cert,
config->tls_key, config->tls_ca, &connect_opts);
} else {
connected = client_connect(client, config->server_host, config->server_port);
connected = client_connect_ex(client, config->server_host, config->server_port, &connect_opts);
}
if (!connected) {
client_disconnect(client);
+5
View File
@@ -34,6 +34,11 @@ bool validate_config(const Config* config) {
log_message(LOG_LEVEL_ERROR, "--incremental is not supported with -s (chunk serialization)");
return false;
}
/* -4 and -6 are mutually exclusive: a socket address family cannot be both. */
if (config->ipv4 && config->ipv6) {
log_message(LOG_LEVEL_ERROR, "-4/--ipv4 and -6/--ipv6 are mutually exclusive");
return false;
}
if (config->skip_compress_set && config->use_chunk_serialization) {
log_message(LOG_LEVEL_ERROR,
"--skip-compress cannot be combined with -s (chunk serialization)");
+24
View File
@@ -20,6 +20,15 @@ void print_usage(void) {
printf(" -n, --dry-run Show what would be transferred\n");
printf(" --remove-source-files Remove regular source files after successful transfer\n");
printf(" -p <port> SSH port (default: 22)\n");
printf(" -e, --rsh <command> Remote shell to launch on the client for the SSH\n");
printf(" transport (default: ssh). The command may include\n");
printf(" arguments, e.g. -e \"ssh -p 2222\"\n");
printf(" --rsync-path <path> Alias for --fastsync-server-path (path to the\n");
printf(" fastsync server binary on the remote side)\n");
printf(" --blocking-io Leave the SSH transport socket without read/write\n");
printf(" timeouts so it blocks naturally\n");
printf(" --outbuf=MODE stdout/stderr buffering: N (none/unbuffered),\n");
printf(" L (line-buffered), or B (block-buffered, default)\n");
printf(" --progress Show transfer progress\n");
printf(" -P Partial mode with progress (retention incomplete)\n");
printf(" -8, --8-bit-output Leave high-bit characters unescaped in output\n");
@@ -166,6 +175,11 @@ void print_usage(void) {
printf(" --timeout <sec> I/O timeout in seconds (default: 30)\n");
printf(" -T <sec> Alias for --timeout\n");
printf(" --contimeout <sec> Connection timeout in seconds (default: 10)\n");
printf(" --address <ip> Bind the outgoing client socket to this source address\n");
printf(" -4, --ipv4 Force IPv4 for destination resolution\n");
printf(" -6, --ipv6 Force IPv6 for destination resolution\n");
printf(" --sockopts=OPTS Comma-separated OPT=VAL socket options applied before connect:\n");
printf(" TCP_NODELAY, SO_KEEPALIVE, SO_RCVBUF, SO_SNDBUF, SO_REUSEADDR\n");
printf(" --backup Backup existing files before overwriting\n");
printf(" --backup-dir <dir> Directory for backups (requires --backup)\n");
printf(" --suffix <str> Backup suffix (default: ~)\n");
@@ -186,6 +200,16 @@ void print_usage(void) {
printf(" Path to fastsync-server on remote (default: fastsync-server)\n");
printf(
" --old-args Disable safe SSH command argument quoting (legacy compatibility)\n");
printf(" --remote-option=OPT Append OPT to the REMOTE server invocation over SSH\n");
printf(" (repeatable; each value is single-quote-escaped on the remote\n");
printf(" command line; empty values and values with control characters\n");
printf(" are rejected). Long form only: rsync's -M short form is NOT\n");
printf(" available because -M already means metadata preservation in\n");
printf(" FastSync (documented divergence)\n");
printf(" --trust-sender Trust the remote sender's file list: the receiver skips its\n");
printf(" own up-front path-traversal/containment re-validation of the\n");
printf(" incoming file list (fewer checks, faster, potentially unsafe).\n");
printf(" Local receiver policy: never sent to the peer, off by default\n");
printf(" -l, --links Copy symlinks as symlinks\n");
printf(" --copy-links Transform symlinks into referent files\n");
printf(" --safe-links Skip symlinks that point outside transfer tree\n");
+6 -1
View File
@@ -3,6 +3,7 @@
#include "chunk.h"
#include "config.h"
#include "delay_updates.h"
#include "file.h"
#include "file_receive.h"
#include "log.h"
#include "metadata.h"
@@ -92,7 +93,11 @@ static bool receiver_process_batch(Config* config, int file_descriptor) {
send_status(file_descriptor, STATUS_ERROR);
return false;
}
if (!utils_valid_batch_path(check_path)) {
/* --trust-sender: accept a ``..``/absolute check path (a trusted sender's
odd-but-legit entry) and defer containment to the secure stat below;
an empty path is still always rejected. */
if (check_path[0] == '\0' ||
(!file_get_trust_sender() && !utils_valid_batch_path(check_path))) {
free(check_path);
send_status(file_descriptor, STATUS_ERROR);
return false;
+36 -1
View File
@@ -22,6 +22,7 @@
static char* authorized_root;
static int authorized_root_fd = -1;
static bool allow_delete;
static bool trust_sender;
static bool allow_unauthenticated;
static const char* required_client_cn;
@@ -220,6 +221,15 @@ void handler(int file_descriptor) {
fd-walk reads a stable value during the whole transfer (and never bleeds
across the per-connection forked processes). */
file_set_keep_dirlinks(config->keep_dirlinks);
/* --trust-sender is a LOCAL receiver policy: it never crosses the wire (so a
wire peer can never enable it). The standalone server only honours it when
its own CLI was started with --trust-sender (the client forwards that switch
into the remote argv via --remote-option=--trust-sender; the server then
parses it here and applies the policy below). Set before any multithreaded
receiver/writer threads are spawned so the fd-walk reads a stable value
during the whole transfer, and never bleeds across the per-connection
forked processes. Off by default. */
file_set_trust_sender(trust_sender);
if (config->use_multithreading) {
Queue* q = queue_create(100, file_destroy);
if (q == NULL) {
@@ -341,7 +351,11 @@ static void print_server_usage(void) {
printf(" --ca <path> TLS CA certificate file (PEM)\n");
printf(" --client-cn <name> Required TLS client certificate CN\n");
printf(" --destination-root <path> Authorized destination root (default: .)\n");
printf(" --address <addr> Bind the listening socket to this address\n");
printf(" -4, --ipv4 Bind an IPv4 socket (default)\n");
printf(" -6, --ipv6 Bind an IPv6 socket\n");
printf(" --allow-delete Permit manifest deletion\n");
printf(" --trust-sender Trust the remote sender's file list\n");
printf(" --allow-unauthenticated Allow plaintext/anonymous network clients\n");
printf(" -v, --verbose Enable debug logging\n");
printf(" --help Show this help\n");
@@ -353,6 +367,8 @@ int main(int argc, char* argv[]) {
int port = 8080;
const char* destination_root = ".";
bool stdio_mode = false;
const char* bind_address = NULL;
int bind_family = AF_UNSPEC;
signal(SIGPIPE, SIG_IGN);
for (int i = 1; i < argc; i++) {
@@ -376,8 +392,24 @@ int main(int argc, char* argv[]) {
required_client_cn = argv[++i];
} else if (strcmp(argv[i], "--destination-root") == 0 && i + 1 < argc) {
destination_root = argv[++i];
} else if (strcmp(argv[i], "--address") == 0 && i + 1 < argc) {
bind_address = argv[++i];
} else if (strcmp(argv[i], "-4") == 0 || strcmp(argv[i], "--ipv4") == 0) {
if (bind_family == AF_INET6) {
fprintf(stderr, "Error: --ipv4 and --ipv6 are mutually exclusive\n");
return 1;
}
bind_family = AF_INET;
} else if (strcmp(argv[i], "-6") == 0 || strcmp(argv[i], "--ipv6") == 0) {
if (bind_family == AF_INET) {
fprintf(stderr, "Error: --ipv4 and --ipv6 are mutually exclusive\n");
return 1;
}
bind_family = AF_INET6;
} else if (strcmp(argv[i], "--allow-delete") == 0) {
allow_delete = true;
} else if (strcmp(argv[i], "--trust-sender") == 0) {
trust_sender = true;
} else if (strcmp(argv[i], "--allow-unauthenticated") == 0) {
allow_unauthenticated = true;
} else if (strcmp(argv[i], "-p") == 0 && i + 1 < argc) {
@@ -418,7 +450,10 @@ int main(int argc, char* argv[]) {
release_authorization();
return 0;
}
g_server = server_create(port);
ServerBindOptions bind_opts;
bind_opts.bind_address = bind_address;
bind_opts.family = bind_family;
g_server = server_create_ex(port, &bind_opts);
if (!g_server) {
log_message(LOG_LEVEL_ERROR, "Failed to create server");
release_authorization();
+125 -2
View File
@@ -11,6 +11,8 @@
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <limits.h>
#include <errno.h>
static void config_set_defaults(Config* config) {
config->version = str_dup(PROTOCOL_VERSION);
@@ -119,9 +121,12 @@ static void config_set_defaults(Config* config) {
config->dirs = false;
config->mkpath = false;
config->rsh_command = NULL;
config->rsync_path = NULL;
config->blocking_io = false;
config->outbuf = OUTBUF_BLOCK;
config->old_args = false;
config->temp_dir = NULL;
config->remote_options = NULL;
config->remote_option_count = 0;
config->basis_dirs = NULL;
config->basis_count = 0;
config->partial_dir = NULL;
@@ -133,6 +138,8 @@ static void config_set_defaults(Config* config) {
config->bind_address = NULL;
config->ipv6 = false;
config->ipv4 = false;
config->sockopts = NULL;
config->sockopt_count = 0;
config->daemon = false;
config->daemon_config = NULL;
config->server_mode = false;
@@ -161,6 +168,7 @@ static void config_set_defaults(Config* config) {
config->open_noatime = false;
config->use_xattrs = false;
config->fake_super = false;
config->trust_sender = false;
}
static bool valid_wire_bool(int value) {
@@ -339,6 +347,114 @@ int config_basis_append(Config* config, BasisDestType type, const char* path) {
return 0;
}
/* Strict --sockopts allowlist: map an option NAME to its SockOptId, or -1 when
* the name is not on the allowlist. The list is intentionally closed so an
* unknown option is an error, never a silent no-op. */
static int sockopt_id_from_name(const char* name) {
if (strcmp(name, "TCP_NODELAY") == 0)
return SOCKOPT_TCP_NODELAY;
if (strcmp(name, "SO_KEEPALIVE") == 0)
return SOCKOPT_SO_KEEPALIVE;
if (strcmp(name, "SO_RCVBUF") == 0)
return SOCKOPT_SO_RCVBUF;
if (strcmp(name, "SO_SNDBUF") == 0)
return SOCKOPT_SO_SNDBUF;
if (strcmp(name, "SO_REUSEADDR") == 0)
return SOCKOPT_SO_REUSEADDR;
return -1;
}
static bool sockopt_is_boolean(SockOptId id) {
return id == SOCKOPT_TCP_NODELAY || id == SOCKOPT_SO_KEEPALIVE || id == SOCKOPT_SO_REUSEADDR;
}
/* Parse one SockOptId's value. Booleans accept only 0/1 (a numeric "on" is
* rejected rather than coerced); buffer sizes accept any non-negative int.
* Returns 0 on success, -1 on a bad value. */
static int sockopt_parse_value(SockOptId id, const char* value, int* out) {
if (sockopt_is_boolean(id)) {
if (strcmp(value, "0") == 0) {
*out = 0;
return 0;
}
if (strcmp(value, "1") == 0) {
*out = 1;
return 0;
}
return -1;
}
if (!value || *value == '\0')
return -1;
char* end;
errno = 0;
long v = strtol(value, &end, 10);
if (errno != 0 || *end != '\0' || v < 0 || v > INT_MAX)
return -1;
*out = (int)v;
return 0;
}
int config_sockopts_parse(const char* spec, SockOptEntry** out, int* out_count) {
if (!spec || *spec == '\0' || !out || !out_count)
return -1;
char* copy = str_dup(spec);
if (!copy)
return -1;
int count = 0;
int capacity = 0;
SockOptEntry* entries = NULL;
char* saveptr = NULL;
bool ok = true;
for (const char* token = strtok_r(copy, ",", &saveptr); token != NULL;
token = strtok_r(NULL, ",", &saveptr)) {
if (*token == '\0') {
ok = false; /* empty entry: a stray/trailing comma */
break;
}
char* eq = strchr(token, '=');
if (eq)
*eq = '\0';
int id = sockopt_id_from_name(token);
if (id < 0) {
ok = false; /* unknown option name */
break;
}
int val;
/* rsync's --sockopts are OPT=VAL; a value is required for every option, so
* a bare option name (no '=') is rejected rather than coerced. */
if (eq == NULL || eq[1] == '\0') {
ok = false; /* missing '=' or missing value */
break;
}
if (sockopt_parse_value((SockOptId)id, eq + 1, &val) != 0) {
ok = false; /* bad value for an allowed option */
break;
}
if (count == capacity) {
int new_cap = capacity == 0 ? 4 : capacity * 2;
SockOptEntry* grown = realloc(entries, (size_t)new_cap * sizeof(SockOptEntry));
if (!grown) {
ok = false;
break;
}
entries = grown;
capacity = new_cap;
}
entries[count].id = (SockOptId)id;
entries[count].value = val;
count++;
}
free(copy);
if (!ok) {
free(entries);
return -1;
}
*out = entries;
*out_count = count;
return 0;
}
bool config_is_remote_dest(const char* s) {
if (s == NULL)
return false;
@@ -393,8 +509,14 @@ void config_delete(Config* config) {
free(config->files_from);
file_list_destroy((FileListSet*)config->files_from_set);
free(config->rsh_command);
free(config->rsync_path);
free(config->temp_dir);
if (config->remote_options) {
for (int i = 0; i < config->remote_option_count; i++)
free(config->remote_options[i]);
free(config->remote_options);
}
config->remote_options = NULL;
config->remote_option_count = 0;
for (int i = 0; i < config->basis_count; i++) {
free(config->basis_dirs[i].path);
config->basis_dirs[i].path = NULL;
@@ -406,6 +528,7 @@ void config_delete(Config* config) {
free(config->suffix);
free(config->address);
free(config->bind_address);
free(config->sockopts);
free(config->daemon_config);
free(config->compress_choice);
free(config->chmod_spec);
+95 -2
View File
@@ -9,6 +9,15 @@
typedef enum { TRANSPORT_TCP, TRANSPORT_SSH } TransportType;
/* --outbuf stdout/stderr buffering style (client-only launch concern, never
* crosses the wire). OUTBUF_BLOCK is the default, matching the stdio default
* (fully buffered when output is not a terminal). */
typedef enum {
OUTBUF_BLOCK = 0, /* _IOFBF */
OUTBUF_LINE, /* _IOLBF */
OUTBUF_NONE /* _IONBF */
} OutbufMode;
/* Receiver-side staging state for --delay-updates. Forward-declared here so
Config can carry it; the concrete type lives in delay_updates.h. */
typedef struct DelayUpdatesContext DelayUpdatesContext;
@@ -40,6 +49,24 @@ typedef struct {
int32_t to;
} IdentityMap;
/* --sockopts=OPTIONS allowlist. Only these option names are accepted; anything
* else is rejected (never silently ignored). TCP_NODELAY, SO_KEEPALIVE and
* SO_REUSEADDR are boolean options (value 0/1); SO_RCVBUF and SO_SNDBUF take a
* non-negative byte count. All are applied as int-sized setsockopt values. */
typedef enum {
SOCKOPT_TCP_NODELAY = 0,
SOCKOPT_SO_KEEPALIVE,
SOCKOPT_SO_RCVBUF,
SOCKOPT_SO_SNDBUF,
SOCKOPT_SO_REUSEADDR,
SOCKOPT_COUNT
} SockOptId;
typedef struct {
SockOptId id; /* allowlist index */
int value; /* 0/1 for booleans, byte count for SO_RCVBUF/SO_SNDBUF */
} SockOptEntry;
typedef struct Config {
char* version;
char* send_directory;
@@ -226,10 +253,27 @@ typedef struct Config {
bool mkpath;
// Issue #130: Remote shell/connection options
/* -e/--rsh: the remote-shell program used to establish the SSH transport.
* NULL means the default "ssh". Client-only launch concern: NEVER crosses
* the wire (it is not meaningful to the daemon/server handshake). */
char* rsh_command;
char* rsync_path;
/* --blocking-io: leave the SSH transport socket without
* SO_RCVTIMEO/SO_SNDTIMEO so it blocks naturally instead of timing out.
* Client-only launch concern: NEVER crosses the wire. */
bool blocking_io;
/* --outbuf mode (OutbufMode): stdout/stderr buffering. Client-only launch
* concern: NEVER crosses the wire. */
int outbuf;
bool old_args;
char* temp_dir;
/* --remote-option=OPT (Phase 5, long form only): one or more extra command-line
* options to append to the REMOTE server invocation over SSH. CLIENT-ONLY:
* they are composed into the remote command line by ssh_build_remote_command()
* (each valid word is shell-escaped with the same quoting boundary as the
* server path), and are NEVER serialized into the binary config frame. They
* do NOT cross the wire and are never parsed on the receiver process. */
char** remote_options;
int remote_option_count;
/* Alternate basis directories, ordered by command-line appearance. Each
* entry's type selects compare/copy/link behavior on an exact match. These
* cross the wire so the receiver can consult them; they are interpreted
@@ -264,6 +308,13 @@ typedef struct Config {
char* bind_address;
bool ipv6;
bool ipv4;
/* --sockopts=OPTIONS (Phase 5, Wave B): strict allowlist of TCP/socket
* options applied via setsockopt after socket() and before connect()/bind().
* These are LOCAL socket concerns: they never cross the wire config frame.
* .address is the outgoing/source bind address (--address); .bind_address is
* reserved for daemon-side binding and is not wired yet. */
SockOptEntry* sockopts;
int sockopt_count;
// PR #182: Daemon/server mode
bool daemon;
@@ -350,9 +401,44 @@ typedef struct Config {
* a reserved user.fastsync.stat xattr recording the source uid/gid/mode/mtime
* so a later privileged restore could re-apply them. Crosses the wire. */
bool fake_super;
// Phase 5: --trust-sender
/* Long-form-only, receiver-local policy. rsync's --trust-sender tells the
* receiving side to trust that the sender already produced a sane file list,
* relaxing the receiver's own up-front re-validation of every incoming path.
* In FastSync the receiver normally double-checks each transmitted file-list
* entry (empty / ".." path-traversal rejection) and refuses to materialize a
* symlink whose target could escape the receive root. When trust_sender is
* set, those redundant list-level re-checks are SKIPPED: the receiving side
* trusts the sender's list instead of re-validating it (fewer checks, faster,
* potentially unsafe, matching rsync). It is a LOCAL receiver policy and is
* NEVER serialized into the config frame (it exists only on the process that
* actually receives the file list). Even under trust_sender the low-level
* fd-relative confinement primitives (file_open_secure_parent, the O_NOFOLLOW
* parent walk, leaf/destination confinement) are deliberately KEPT as a hard
* floor, so a hostile sender still cannot write or link outside the
* authorized root (see the phase-5 notes in RSYNC_COMPAT.md). Off by
* default; only relaxes validation when explicitly requested. */
bool trust_sender;
} Config;
#define PROTOCOL_VERSION "2.13.0"
/* Phase 5 (remote-option wave): 2.13.0 -> 2.14.0.
*
* WHY the bump, grounded in the wire: the binary config-frame layout is
* UNCHANGED by this wave (neither --remote-option nor --trust-sender adds a
* serialized field; see the field comments above). --remote-option is
* forwarded to the remote server over the SSH remote-command line
* (ssh_build_remote_command) and --trust-sender is a purely local receiver
* policy, so there is no new frame byte to negotiate. The bump is still the
* correct release marker for Phase 5 because the client-to-server INVOCATION
* surface changed: a client that composes remote-options expects a server that
* knows how to honor them, and the only safe way to express "this feature set
* is one coordinated release" is the strict same-version handshake FastSync
* already performs for every release. A 2.14 client against a 2.13 server
* fails the version check cleanly up front (rather than the remote server
* rejecting an unfamiliar forwarded argv at a confusing later point), which is
* exactly what the lockstep convention of this project requires. */
#define PROTOCOL_VERSION "2.14.0"
#define DEFAULT_CHUNK_SIZE (10 * 1024 * 1024)
/* Upper bound on total basis-dir entries (rsync caps --link-dest at 20). */
#define MAX_BASIS_DIRS 64
@@ -390,4 +476,11 @@ int config_basis_append(Config* config, BasisDestType type, const char* path);
/* Validate a client-provided basis-dir path (relative, confined, non-empty). */
bool config_basis_path_valid(const char* path);
/* Parse and validate a --sockopts=OPTIONS comma-separated "OPT=VAL" list into a
* malloc'd array of at most *out_count entries. Returns 0 on success (the
* caller takes ownership of *out), or -1 on the first invalid option name or
* value. Pure/static-analysis friendly: performs no socket calls, so it is
* directly unit-testable. */
int config_sockopts_parse(const char* spec, SockOptEntry** out, int* out_count);
#endif
+27 -1
View File
@@ -363,6 +363,23 @@ bool file_get_keep_dirlinks(void) {
return file_keep_dirlinks;
}
/* --trust-sender (Phase 5) receiver process-wide policy: when set, the receiver
* trusts the sender's file list and skips its own redundant up-front re-
* validation (empty/".." path rejection, escaping-symlink-target containment).
* Kept OFF by default; the server's per-connection handler sets it once from the
* received config before any receiver/writer threads start (each connection is
* its own forked process, so this per-process value never bleeds across
* connections). */
static bool file_trust_sender = false;
void file_set_trust_sender(bool enable) {
file_trust_sender = enable;
}
bool file_get_trust_sender(void) {
return file_trust_sender;
}
/* True when `target` is a lexical symlink target that can never escape the
* receive root once created beneath it: relative (not absolute) and containing
* no ".." path component. Used by --munge-links' sender-side containment: an
@@ -425,7 +442,16 @@ char* file_symlink_munge(const char* target) {
* false) so a malicious sender can never materialize a symlink that points
* outside the receive root. */
bool file_symlink_at_secure(const char* path, const char* target) {
if (!path || !target || has_path_traversal(path) || !file_symlink_target_contained(target))
/* The link itself (`path`) is always kept below the authorized root. The
TARGET may point anywhere: normally only a contained (relative, ".."-free)
target is permitted so a malicious sender can never plant a symlink that
later dereferences outside the root. Under --trust-sender that target
containment check is relaxed (the receiver trusts the sender and copies the
link verbatim, matching rsync -l), but path/leaf confinement is never
disabled, so the link still cannot be placed outside the tree. */
if (!path || !target || has_path_traversal(path))
return false;
if (!file_trust_sender && !file_symlink_target_contained(target))
return false;
char* leaf = NULL;
int parent_fd = file_open_secure_parent(path, &leaf, true);
+9
View File
@@ -54,6 +54,15 @@ bool file_symlink_at_secure(const char* path, const char* target);
void file_set_keep_dirlinks(bool enable);
bool file_get_keep_dirlinks(void);
/* --trust-sender receiver process-wide policy (Phase 5). When set, the
* receiver trusts that the sender already produced a clean file list and skips
* its own redundant up-front re-validation of incoming paths (the empty/".."
* rejection and the escaping-symlink-target containment). The low-level
* fd-relative confinement primitives below are deliberately NOT disabled by
* this flag, so a hostile sender still cannot escape the authorized root. */
void file_set_trust_sender(bool enable);
bool file_get_trust_sender(void);
/* A configured fd without a canonical identity deliberately rejects paths. */
bool file_set_authorized_root(int fd, const char* canonical_path);
+22 -13
View File
@@ -329,8 +329,12 @@ bool file_special_rdev_valid(int32_t major, int32_t minor, mode_t mode) {
*/
static FileSaveResult file_save_special_to_disk(const char* root_directory, const File* file,
const Config* config) {
/* The empty-path and structural checks stay unconditional; the redundant
".." list-path re-check is skipped under --trust-sender exactly like the
receive layer (confinement is deferred to the secure parent walk below,
which is never disabled). */
if (!root_directory || !file || !file->path || file->path[0] == '\0' ||
has_path_traversal(file->path) || !file->metadata)
(!file_get_trust_sender() && has_path_traversal(file->path)) || !file->metadata)
return FILE_SAVE_ERROR;
mode_t mode = file->metadata->mode;
@@ -461,7 +465,7 @@ static FileSaveResult file_save_special_to_disk(const char* root_directory, cons
* entry is skipped), never aborts. */
static FileSaveResult file_save_write_device(const char* root_directory, const File* file) {
if (!root_directory || !file || !file->path || file->path[0] == '\0' ||
has_path_traversal(file->path))
(!file_get_trust_sender() && has_path_traversal(file->path)))
return FILE_SAVE_ERROR;
if (!file->data)
return FILE_SAVE_ERROR;
@@ -538,7 +542,7 @@ FileSaveResult file_save_to_disk_full(const char* root_directory, const File* fi
char *backup_path = NULL, *parent_copy = NULL;
if (!file || !file->path || !file->data || (file->data->size != 0 && !file->data->data) ||
has_path_traversal(file->path) ||
(!file_get_trust_sender() && has_path_traversal(file->path)) ||
(backup_enabled &&
(!backup_suffix || backup_suffix[0] == '\0' || strchr(backup_suffix, '/') != NULL ||
strcmp(backup_suffix, ".") == 0 || strcmp(backup_suffix, "..") == 0))) {
@@ -560,7 +564,7 @@ FileSaveResult file_save_to_disk_full(const char* root_directory, const File* fi
immediately (they are never staged by --delay-updates, matching rsync,
where directory creation is not delayed). */
if (file->is_dir) {
if (file->path[0] == '\0' || has_path_traversal(file->path)) {
if (file->path[0] == '\0' || (!file_get_trust_sender() && has_path_traversal(file->path))) {
log_message(LOG_LEVEL_ERROR, "Invalid directory path received");
return FILE_SAVE_ERROR;
}
@@ -577,7 +581,8 @@ FileSaveResult file_save_to_disk_full(const char* root_directory, const File* fi
threads start, so it is stable throughout this walk.) */
if (file->is_symlink) {
if (!file->symlink_target || file->path[0] == '\0' || has_path_traversal(file->path)) {
if (!file->symlink_target || file->path[0] == '\0' ||
(!file_get_trust_sender() && has_path_traversal(file->path))) {
log_message(LOG_LEVEL_ERROR, "Invalid symlink entry received");
return FILE_SAVE_ERROR;
}
@@ -595,8 +600,12 @@ FileSaveResult file_save_to_disk_full(const char* root_directory, const File* fi
could escape the receive root (absolute, or relative-with-"..") is never
materialized. It is contained (the entry is skipped) rather than failing
the whole transfer, so a hostile sender can inject a broken symlink but
can never redirect it outside the root. */
if (ok && !file_symlink_target_contained(target))
can never redirect it outside the root. --trust-sender deliberately
relaxes this receiver-side re-validation: a trusted sender's escaping
symlink target is copied verbatim (rsync -l parity). The low-level
leaf/destination confinement in file_symlink_at_secure still ensures the
link itself is placed inside the authorized root. */
if (ok && !file_get_trust_sender() && !file_symlink_target_contained(target))
ok = false;
if (!ok) {
/* Skip the escaping/empty target (contained) rather than abort. */
@@ -2076,7 +2085,7 @@ File* file_receive(const Config* config, int file_descriptor) {
char* path = receive_str(file_descriptor);
if (path == NULL)
return NULL;
if (path[0] == '\0' || has_path_traversal(path)) {
if (path[0] == '\0' || (!file_get_trust_sender() && has_path_traversal(path))) {
char* escaped_path = output_escape(path, log_get_8_bit_output());
log_message(LOG_LEVEL_ERROR, "Invalid received file path: %s",
escaped_path ? escaped_path : "<allocation failed>");
@@ -2136,7 +2145,7 @@ File* file_receive_directory(int file_descriptor) {
char* path = receive_str(file_descriptor);
if (path == NULL)
return NULL;
if (path[0] == '\0' || has_path_traversal(path)) {
if (path[0] == '\0' || (!file_get_trust_sender() && has_path_traversal(path))) {
char* escaped_path = output_escape(path, log_get_8_bit_output());
log_message(LOG_LEVEL_ERROR, "Invalid received directory path: %s",
escaped_path ? escaped_path : "<allocation failed>");
@@ -2163,7 +2172,7 @@ File* file_receive_hardlink(int file_descriptor) {
char* path = receive_str(file_descriptor);
if (path == NULL)
return NULL;
if (path[0] == '\0' || has_path_traversal(path)) {
if (path[0] == '\0' || (!file_get_trust_sender() && has_path_traversal(path))) {
char* escaped_path = output_escape(path, log_get_8_bit_output());
log_message(LOG_LEVEL_ERROR, "Invalid received hard-link path: %s",
escaped_path ? escaped_path : "<allocation failed>");
@@ -2182,7 +2191,7 @@ File* file_receive_hardlink(int file_descriptor) {
free(path);
return NULL;
}
if (target[0] == '\0' || has_path_traversal(target)) {
if (target[0] == '\0' || (!file_get_trust_sender() && has_path_traversal(target))) {
char* escaped = output_escape(target, log_get_8_bit_output());
log_message(LOG_LEVEL_ERROR, "Invalid hard-link target path: %s",
escaped ? escaped : "<allocation failed>");
@@ -2213,7 +2222,7 @@ File* file_receive_symlink(int file_descriptor, const Config* config) {
char* path = receive_str(file_descriptor);
if (path == NULL)
return NULL;
if (path[0] == '\0' || has_path_traversal(path)) {
if (path[0] == '\0' || (!file_get_trust_sender() && has_path_traversal(path))) {
char* escaped_path = output_escape(path, log_get_8_bit_output());
log_message(LOG_LEVEL_ERROR, "Invalid received symlink path: %s",
escaped_path ? escaped_path : "<allocation failed>");
@@ -2268,7 +2277,7 @@ File* file_receive_special(int file_descriptor) {
char* path = receive_str(file_descriptor);
if (path == NULL)
return NULL;
if (path[0] == '\0' || has_path_traversal(path)) {
if (path[0] == '\0' || (!file_get_trust_sender() && has_path_traversal(path))) {
char* escaped_path = output_escape(path, log_get_8_bit_output());
log_message(LOG_LEVEL_ERROR, "Invalid received special path: %s",
escaped_path ? escaped_path : "<allocation failed>");
+219 -48
View File
@@ -7,6 +7,7 @@
#include <stdlib.h>
#include <string.h>
#include <sys/socket.h>
#include <sys/time.h>
#include <sys/wait.h>
#include <unistd.h>
@@ -16,7 +17,9 @@ typedef struct {
char* remote_path;
} RemoteDest;
static void ssh_child_setup_failed(int status_fd) {
/* Writes the exec-failure marker and exits the child. Marked noreturn so
* static analyzers prove the caller's error path never falls through. */
__attribute__((noreturn)) static void ssh_child_setup_failed(int status_fd) {
ssize_t wret = write(status_fd, "x", 1);
(void)wret;
_exit(1);
@@ -75,52 +78,220 @@ static int parse_remote_dest(const char* dest, RemoteDest* r) {
return 0;
}
char* ssh_build_remote_command(const char* server_path, bool old_args) {
char* ssh_build_remote_command(const char* server_path, bool old_args, char* const* remote_options,
int remote_option_count) {
const char* path = server_path ? server_path : "fastsync-server";
const char* suffix = " --stdio";
/* Each --remote-option=OPT is appended after " --stdio" as one shell word,
escaped with the SAME single-quote boundary used for the server path. This
stays safe even in --old-args mode (which leaves the server path unquoted):
remote options are always single-quoted individually, so a value containing
shell metacharacters (; & | ` $ ()) can never break out of the quoting to
inject an unrelated remote command. Values are already validated at CLI
parse time (non-empty, no control characters); this layer only adds the
escaping boundary. */
size_t path_len = strlen(path);
size_t suffix_len = strlen(suffix);
/* The base command (server path, quoted unless --old-args, then " --stdio"). */
size_t command_len;
if (old_args) {
if (path_len > SIZE_MAX - suffix_len - 1)
return NULL;
char* command = malloc(path_len + suffix_len + 1);
if (!command)
command_len = path_len + suffix_len + 1;
} else {
size_t quote_count = 0;
for (const char* p = path; *p; p++)
if (*p == '\'')
quote_count++;
if (path_len > SIZE_MAX - suffix_len - 4 ||
quote_count > (SIZE_MAX - path_len - suffix_len - 4) / 4)
return NULL;
memcpy(command, path, path_len);
memcpy(command + path_len, suffix, suffix_len + 1);
return command;
command_len = path_len + quote_count * 4 + suffix_len + 4;
}
/* Quote the executable as one remote-shell word. This is the default safety boundary. */
size_t quote_count = 0;
for (const char* p = path; *p; p++)
if (*p == '\'')
quote_count++;
if (path_len > SIZE_MAX - suffix_len - 4 ||
quote_count > (SIZE_MAX - path_len - suffix_len - 4) / 4)
return NULL;
size_t command_len = path_len + quote_count * 4 + suffix_len + 4;
char* command = malloc(command_len + 1);
/* Add each remote option, escaped as one single-quoted word:
" '<body>'", i.e. 1 leading space + 1 open quote + body (len + 3 per
embedded single quote) + 1 close quote = len + q*3 + 3 bytes.
Defense-in-depth against a non-conforming caller: never forward an empty
or control-character value, independent of the CLI validation. */
for (int i = 0; i < remote_option_count; i++) {
const char* opt = remote_options[i];
if (!opt || opt[0] == '\0')
return NULL;
size_t len = 0, q = 0;
for (const char* p = opt; *p; p++) {
/* Defense-in-depth: never forward a control character (newline/CR/etc.)
that could break the single-quoted shell word regardless of the remote
shell, independent of the CLI validation. */
if ((unsigned char)*p < 0x20 || (unsigned char)*p == 0x7f)
return NULL;
if (*p == '\'')
q++;
len++;
}
if (len > SIZE_MAX - q * 3 || len + q * 3 + 3 > SIZE_MAX - command_len)
return NULL;
command_len += len + q * 3 + 3;
}
command_len += 1; /* NUL */
char* command = malloc(command_len);
if (!command)
return NULL;
char* out = command;
*out++ = '\'';
for (const char* p = path; *p; p++) {
if (*p == '\'') {
memcpy(out, "'\\''", 4);
out += 4;
} else {
*out++ = *p;
if (old_args) {
memcpy(out, path, path_len);
out += path_len;
memcpy(out, suffix, suffix_len + 1);
out += suffix_len;
} else {
*out++ = '\'';
for (const char* p = path; *p; p++) {
if (*p == '\'') {
memcpy(out, "'\\''", 4);
out += 4;
} else {
*out++ = *p;
}
}
*out++ = '\'';
memcpy(out, suffix, suffix_len + 1);
out += suffix_len;
}
*out++ = '\'';
memcpy(out, suffix, suffix_len + 1);
for (int i = 0; i < remote_option_count; i++) {
const char* opt = remote_options[i];
*out++ = ' ';
*out++ = '\'';
for (const char* p = opt; *p; p++) {
if (*p == '\'') {
memcpy(out, "'\\''", 4);
out += 4;
} else {
*out++ = *p;
}
}
*out++ = '\'';
}
*out = '\0';
return command;
}
/* A heap-owned, NULL-terminated argv whose every string is separately malloc'd
* (str_dup'd) so a caller can free arbitrary slots, including argv[0]. */
char** ssh_build_client_argv(const char* rsh_command, int port, const char* userhost,
const char* remote_command) {
const char* rsh = (rsh_command && *rsh_command) ? rsh_command : "ssh";
/* Whitespace-split the remote-shell command into the leading argv words so
* "-e 'ssh -p 2222'" (or "--rsh=ssh -p 2222") works like rsync's rsh. A
* blank command falls back to the default "ssh". */
char* copy = str_dup(rsh);
if (!copy)
return NULL;
char* save = NULL;
int nwords = 0;
char** words = NULL;
for (char* tok = strtok_r(copy, " \t", &save); tok; tok = strtok_r(NULL, " \t", &save)) {
char** grown = realloc(words, (size_t)(nwords + 1) * sizeof(char*));
if (!grown) {
for (int i = 0; i < nwords; i++)
free(words[i]);
free(words);
free(copy);
return NULL;
}
words = grown;
words[nwords] = str_dup(tok);
if (!words[nwords]) {
for (int i = 0; i < nwords; i++)
free(words[i]);
free(words);
free(copy);
return NULL;
}
nwords++;
}
free(copy);
if (nwords == 0) {
words = malloc(sizeof(char*));
if (!words)
return NULL;
words[0] = str_dup("ssh");
if (!words[0]) {
free(words);
return NULL;
}
nwords = 1;
}
/* Fixed tail: three -o pairs (6) + optional -p/value (2) + user@host +
* remote command + terminating NULL. */
int port_extra = (port > 0 && port != 22) ? 2 : 0;
size_t total = (size_t)nwords + 6 + (size_t)port_extra + 3;
char** argv = calloc(total, sizeof(char*));
if (!argv) {
for (int i = 0; i < nwords; i++)
free(words[i]);
free(words);
return NULL;
}
int ac = 0;
for (int i = 0; i < nwords; i++)
argv[ac++] = words[i];
free(words);
char* tail[] = {"-o", "Compression=no",
"-o", "ControlMaster=auto",
"-o", "ControlPath=~/.cache/fastsync-%r@%h:%p"};
for (size_t i = 0; i < sizeof(tail) / sizeof(tail[0]); i++) {
argv[ac] = str_dup(tail[i]);
if (!argv[ac])
goto fail_argv;
ac++;
}
if (port_extra) {
char port_str[16];
snprintf(port_str, sizeof(port_str), "%d", port);
argv[ac] = str_dup("-p");
if (!argv[ac])
goto fail_argv;
ac++;
argv[ac] = str_dup(port_str);
if (!argv[ac])
goto fail_argv;
ac++;
}
argv[ac] = str_dup(userhost);
if (!argv[ac])
goto fail_argv;
ac++;
argv[ac] = str_dup(remote_command);
if (!argv[ac])
goto fail_argv;
ac++;
argv[ac] = NULL;
return argv;
fail_argv:
for (int i = 0; i < ac; i++)
free(argv[i]);
free(argv);
return NULL;
}
void ssh_free_client_argv(char** argv) {
if (!argv)
return;
for (int i = 0; argv[i]; i++)
free(argv[i]);
free(argv);
}
Client* client_connect_ssh(const char* destination, int port, const char* server_path,
bool old_args) {
bool old_args, const char* rsh_command, bool blocking_io,
char* const* remote_options, int remote_option_count) {
RemoteDest r;
if (parse_remote_dest(destination, &r) != 0) {
char* escaped = output_escape(destination, false);
@@ -142,6 +313,17 @@ Client* client_connect_ssh(const char* destination, int port, const char* server
setsockopt(sv[1], SOL_SOCKET, SO_SNDBUF, &buf_size, sizeof(buf_size));
setsockopt(sv[1], SOL_SOCKET, SO_RCVBUF, &buf_size, sizeof(buf_size));
/* By default the SSH transport socket gets the same read/write timeout as
* the TCP transport so a wedged remote shell cannot hang forever. With
* --blocking-io the timeouts are skipped and the socket blocks naturally. */
if (!blocking_io) {
struct timeval tv;
tv.tv_sec = tcp_get_timeout_sec();
tv.tv_usec = 0;
setsockopt(sv[0], SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv));
setsockopt(sv[0], SOL_SOCKET, SO_SNDTIMEO, &tv, sizeof(tv));
}
int exec_pipe[2];
if (pipe(exec_pipe) < 0) {
log_perror("pipe failed");
@@ -187,29 +369,18 @@ Client* client_connect_ssh(const char* destination, int port, const char* server
else
snprintf(ssh_user, ssh_user_len, "%s", r.host);
char* ssh_argv[16];
int ac = 0;
char port_str[16];
char* remote_command = ssh_build_remote_command(server_path, old_args);
char* remote_command =
ssh_build_remote_command(server_path, old_args, remote_options, remote_option_count);
if (!remote_command)
ssh_child_setup_failed(exec_pipe[1]);
ssh_argv[ac++] = "ssh";
ssh_argv[ac++] = "-o";
ssh_argv[ac++] = "Compression=no";
ssh_argv[ac++] = "-o";
ssh_argv[ac++] = "ControlMaster=auto";
ssh_argv[ac++] = "-o";
ssh_argv[ac++] = "ControlPath=~/.cache/fastsync-%r@%h:%p";
if (port > 0 && port != 22) {
ssh_argv[ac++] = "-p";
snprintf(port_str, sizeof(port_str), "%d", port);
ssh_argv[ac++] = port_str;
}
ssh_argv[ac++] = ssh_user;
ssh_argv[ac++] = remote_command;
ssh_argv[ac] = NULL;
execvp("ssh", ssh_argv);
log_perror("exec of ssh failed");
char** ssh_argv = ssh_build_client_argv(rsh_command, port, ssh_user, remote_command);
free(ssh_user);
free(remote_command);
if (!ssh_argv)
ssh_child_setup_failed(exec_pipe[1]);
execvp(ssh_argv[0], ssh_argv);
log_perror("exec of remote shell failed");
ssh_free_client_argv(ssh_argv);
ssh_child_setup_failed(exec_pipe[1]);
}
+17 -2
View File
@@ -4,7 +4,22 @@
#include "transport_tcp.h"
Client* client_connect_ssh(const char* destination, int port, const char* server_path,
bool old_args);
char* ssh_build_remote_command(const char* server_path, bool old_args);
bool old_args, const char* rsh_command, bool blocking_io,
char* const* remote_options, int remote_option_count);
/* Build the escaped remote-shell command string (the server program path quoted
* as one remote-shell word unless --old-args, followed by ` --stdio` and each
* --remote-option value appended as an individually single-quoted shell word).
* Every --remote-option value is individually escaped with the '\'' sequence and
* values with empty/control characters are rejected at the CLI parse layer. */
char* ssh_build_remote_command(const char* server_path, bool old_args, char* const* remote_options,
int remote_option_count);
/* Build the NULL-terminated child argv for the remote-shell client (argv[0] is
* the exec/execvp program). rsh_command is whitespace-split into leading argv
* words (NULL or "" selects the default "ssh"); the standard -o family, the
* optional -p port, the user@host and the remote command are appended. Every
* string (including argv[0]) is heap-owned; free with ssh_free_client_argv. */
char** ssh_build_client_argv(const char* rsh_command, int port, const char* userhost,
const char* remote_command);
void ssh_free_client_argv(char** argv);
#endif
+210 -30
View File
@@ -5,6 +5,8 @@
#include <arpa/inet.h>
#include <errno.h>
#include <netdb.h>
#include <netinet/in.h>
#include <netinet/tcp.h>
#include <openssl/ssl.h>
#include <signal.h>
#include <stdio.h>
@@ -28,47 +30,91 @@ static void sigchld_handler(int sig) {
errno = saved_errno;
}
Server* server_create(int port) {
/* Map a listen socket's address to its numeric port for logging, independent
* of whether it is an IPv4 or IPv6 sockaddr. */
static unsigned short server_address_port(const struct sockaddr_storage* addr) {
if (addr->ss_family == AF_INET6)
return ntohs(((const struct sockaddr_in6*)addr)->sin6_port);
if (addr->ss_family == AF_INET)
return ntohs(((const struct sockaddr_in*)addr)->sin_port);
return 0;
}
Server* server_create_ex(int port, const ServerBindOptions* bind_opts) {
Server* server = (Server*)malloc(sizeof(Server));
if (server == NULL) {
log_perror("Could not allocate space for Server");
return NULL;
}
int file_descriptor = socket(AF_INET, SOCK_STREAM, 0);
if (file_descriptor < 0) {
log_perror("Could not create Socket!");
free(server);
return NULL;
}
server->file_descriptor = file_descriptor;
int opt = 1;
if (setsockopt(server->file_descriptor, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt))) {
log_perror("Error setting a socket option!");
close(server->file_descriptor);
/* Effective address family. preserve the historical default (IPv4 wildcard)
* when neither --address nor -4/-6 were given. */
int family = (bind_opts && bind_opts->family != AF_UNSPEC) ? bind_opts->family : AF_INET;
const char* bind_address = bind_opts ? bind_opts->bind_address : NULL;
struct addrinfo hints;
memset(&hints, 0, sizeof(hints));
hints.ai_family = family;
hints.ai_socktype = SOCK_STREAM;
hints.ai_protocol = IPPROTO_TCP;
hints.ai_flags = AI_PASSIVE;
char port_str[16];
snprintf(port_str, sizeof(port_str), "%d", port);
struct addrinfo* result = NULL;
int err = getaddrinfo(bind_address, port_str, &hints, &result);
if (err != 0 || result == NULL) {
char* escaped = bind_address ? output_escape(bind_address, false) : NULL;
fprintf(stderr, "Could not resolve bind address %s (%s)\n", escaped ? escaped : "(wildcard)",
gai_strerror(err));
free(escaped);
free(server);
return NULL;
}
server->address.sin_family = AF_INET;
server->address.sin_addr.s_addr = INADDR_ANY;
server->address.sin_port = htons(port);
server->address_length = sizeof(server->address);
int file_descriptor = -1;
struct addrinfo* rp;
for (rp = result; rp != NULL; rp = rp->ai_next) {
file_descriptor = socket(rp->ai_family, rp->ai_socktype, rp->ai_protocol);
if (file_descriptor < 0)
continue;
int opt = 1;
if (setsockopt(file_descriptor, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt)) != 0) {
log_perror("Error setting a socket option!");
close(file_descriptor);
file_descriptor = -1;
continue;
}
if (bind(file_descriptor, rp->ai_addr, (socklen_t)rp->ai_addrlen) == 0) {
memset(&server->address, 0, sizeof(server->address));
memcpy(&server->address, rp->ai_addr, rp->ai_addrlen);
server->address_length = rp->ai_addrlen;
break;
}
log_perror("Could not bind server address");
close(file_descriptor);
file_descriptor = -1;
}
freeaddrinfo(result);
if (file_descriptor < 0) {
free(server);
return NULL;
}
server->file_descriptor = file_descriptor;
server->ssl_ctx = NULL;
server->max_connections = 100;
server->active_connections = 0;
if (bind(server->file_descriptor, (struct sockaddr*)&server->address, server->address_length) <
0) {
log_perror("Could not bind server");
close(server->file_descriptor);
free(server);
return NULL;
}
return server;
}
Server* server_create(int port) {
return server_create_ex(port, NULL);
}
void server_delete(Server** server) {
if (server == NULL || *server == NULL)
return;
@@ -126,7 +172,7 @@ static void plain_child_fn(int fd, void* ctx) {
}
bool server_listen(Server* server, void (*handler)(int file_descriptor)) {
log_message(LOG_LEVEL_INFO, "Start Listening on Port: %d", ntohs(server->address.sin_port));
log_message(LOG_LEVEL_INFO, "Start Listening on Port: %d", server_address_port(&server->address));
struct plain_ctx ctx = {handler};
accept_loop(server, plain_child_fn, &ctx, "Received Connection");
return true;
@@ -134,7 +180,8 @@ bool server_listen(Server* server, void (*handler)(int file_descriptor)) {
void server_accept_loop(Server* server, void (*child_fn)(int, void*), void* child_ctx,
const char* log_fmt) {
log_message(LOG_LEVEL_INFO, "Start TLS Listening on Port: %d", ntohs(server->address.sin_port));
log_message(LOG_LEVEL_INFO, "Start TLS Listening on Port: %d",
server_address_port(&server->address));
accept_loop(server, child_fn, child_ctx, log_fmt);
}
@@ -178,11 +225,100 @@ Client* client_create() {
return client;
}
bool tcp_connect_socket(Client* client, char* host, int port) {
int tcp_connect_family(bool ipv4, bool ipv6) {
if (ipv4)
return AF_INET;
if (ipv6)
return AF_INET6;
return AF_UNSPEC;
}
/* The socket-option apply layer maps an allowlist SockOptId to the concrete
* level/optname pair and applies it with the correct (int) value type. The
* allowlist bounds what can ever reach this point, so the id-to-name mapping
* is total for every SOCKOPT_* value. */
static int tcp_sockopt_level(SockOptId id) {
return id == SOCKOPT_TCP_NODELAY ? IPPROTO_TCP : SOL_SOCKET;
}
static int tcp_sockopt_name(SockOptId id) {
switch (id) {
case SOCKOPT_TCP_NODELAY:
return TCP_NODELAY;
case SOCKOPT_SO_KEEPALIVE:
return SO_KEEPALIVE;
case SOCKOPT_SO_RCVBUF:
return SO_RCVBUF;
case SOCKOPT_SO_SNDBUF:
return SO_SNDBUF;
case SOCKOPT_SO_REUSEADDR:
return SO_REUSEADDR;
default:
return -1;
}
}
static bool tcp_apply_sockopts(int fd, const SockOptEntry* sockopts, int sockopt_count) {
for (int i = 0; i < sockopt_count; i++) {
int name = tcp_sockopt_name(sockopts[i].id);
if (name < 0) { /* unreachable for a validated allowlist, but stay defensive */
log_message(LOG_LEVEL_ERROR, "Unsupported socket option requested");
return false;
}
int value = sockopts[i].value;
if (setsockopt(fd, tcp_sockopt_level(sockopts[i].id), name, &value, sizeof(value)) != 0) {
log_perror("Could not apply socket option");
return false;
}
}
return true;
}
/* Resolve an explicit --address source/bind address into a sockaddr once, so
* the per-candidate connect loop can bind() the outgoing socket to it. The
* family follows the same -4/-6 hints as the destination resolution, so a
* forced family selects a matching local address; returns 0 on success. */
static int resolve_bind_address(const char* addr, int family, struct sockaddr_storage* out,
socklen_t* out_len, int* out_family) {
struct addrinfo hints;
memset(&hints, 0, sizeof(hints));
hints.ai_family = family; /* AF_UNSPEC when no -4/-6 */
hints.ai_socktype = SOCK_STREAM;
hints.ai_protocol = IPPROTO_TCP;
struct addrinfo* result = NULL;
int err = getaddrinfo(addr, NULL, &hints, &result);
if (err != 0 || result == NULL) {
char* escaped = output_escape(addr, false);
fprintf(stderr, "Could not resolve --address %s (%s)\n",
escaped ? escaped : "<allocation failed>", gai_strerror(err));
free(escaped);
return -1;
}
struct addrinfo* rp;
bool found = false;
for (rp = result; rp != NULL; rp = rp->ai_next) {
if (family != AF_UNSPEC && rp->ai_family != family)
continue;
memcpy(out, rp->ai_addr, rp->ai_addrlen);
*out_len = (socklen_t)rp->ai_addrlen;
*out_family = rp->ai_family;
found = true;
break;
}
freeaddrinfo(result);
return found ? 0 : -1;
}
bool tcp_connect_socket_ex(Client* client, const char* host, int port,
const TcpConnectOptions* opts) {
struct addrinfo hints;
struct addrinfo* result;
memset(&hints, 0, sizeof(hints));
hints.ai_family = AF_UNSPEC;
/* TcpConnectOptions.family already encodes -4/-6 (or AF_UNSPEC); feed it
* straight into the getaddrinfo hints so the destination resolution is
* (optionally) pinned to one address family. */
hints.ai_family = opts ? opts->family : AF_UNSPEC;
hints.ai_socktype = SOCK_STREAM;
hints.ai_protocol = IPPROTO_TCP;
@@ -198,6 +334,18 @@ bool tcp_connect_socket(Client* client, char* host, int port) {
return false;
}
/* Resolve the optional --address source address once up front. */
struct sockaddr_storage bind_addr;
socklen_t bind_addr_len = 0;
int bind_addr_family = 0;
if (opts && opts->bind_address) {
if (resolve_bind_address(opts->bind_address, hints.ai_family, &bind_addr, &bind_addr_len,
&bind_addr_family) != 0) {
freeaddrinfo(result);
return false;
}
}
struct addrinfo* rp;
bool connected = false;
for (rp = result; rp != NULL; rp = rp->ai_next) {
@@ -208,12 +356,33 @@ bool tcp_connect_socket(Client* client, char* host, int port) {
if (client->file_descriptor < 0)
continue;
if (opts && opts->sockopt_count > 0 &&
!tcp_apply_sockopts(client->file_descriptor, opts->sockopts, opts->sockopt_count)) {
close(client->file_descriptor);
client->file_descriptor = -1;
break;
}
struct timeval ct;
ct.tv_sec = g_contimeout_sec;
ct.tv_usec = 0;
setsockopt(client->file_descriptor, SOL_SOCKET, SO_RCVTIMEO, &ct, sizeof(ct));
setsockopt(client->file_descriptor, SOL_SOCKET, SO_SNDTIMEO, &ct, sizeof(ct));
if (bind_addr_family != 0) {
if (rp->ai_family != bind_addr_family) {
close(client->file_descriptor);
client->file_descriptor = -1;
continue;
}
if (bind(client->file_descriptor, (struct sockaddr*)&bind_addr, bind_addr_len) != 0) {
log_perror("Could not bind outgoing socket to --address");
close(client->file_descriptor);
client->file_descriptor = -1;
break;
}
}
memcpy(&client->address, rp->ai_addr, rp->ai_addrlen);
client->address_length = rp->ai_addrlen;
@@ -233,8 +402,19 @@ bool tcp_connect_socket(Client* client, char* host, int port) {
return true;
}
bool client_connect(Client* client, char* host, int port) {
if (!tcp_connect_socket(client, host, port))
bool tcp_connect_socket(Client* client, const char* host, int port) {
return tcp_connect_socket_ex(client, host, port, NULL);
}
bool client_connect_ex(Client* client, const char* host, int port, const TcpConnectOptions* opts) {
if (!tcp_connect_socket_ex(client, host, port, opts))
return false;
tcp_apply_socket_timeout(client->file_descriptor);
return true;
}
bool client_connect(Client* client, const char* host, int port) {
if (!tcp_connect_socket_ex(client, host, port, NULL))
return false;
tcp_apply_socket_timeout(client->file_descriptor);
return true;
+33 -3
View File
@@ -1,12 +1,14 @@
#ifndef TRANSPORT_TCP_H
#define TRANSPORT_TCP_H
#include "config.h"
#include <netdb.h>
#include <netinet/in.h>
#include <stdbool.h>
#include <sys/types.h>
typedef struct Server {
struct sockaddr_in address;
struct sockaddr_storage address;
unsigned int address_length;
int file_descriptor;
void* ssl_ctx;
@@ -23,18 +25,46 @@ typedef struct Client {
void* ssl_ctx;
} Client;
/* Options controlling the server's listening bind (/--address, -4/-6). When
* bind_address is NULL and family is AF_UNSPEC the existing default is used:
* an IPv4 wildcard (INADDR_ANY). */
typedef struct {
const char* bind_address; /* explicit address to bind, or NULL for wildcard */
int family; /* AF_INET / AF_INET6, or AF_UNSPEC to use the default */
} ServerBindOptions;
/* Options controlling an outgoing client connect (--address, -4/-6,
* --sockopts). All fields are client/connection-level and never cross the
* wire config frame. */
typedef struct {
const char* bind_address; /* --address: local source address to bind, or NULL */
int family; /* AF_INET / AF_INET6 / AF_UNSPEC (from -4 / -6) */
const SockOptEntry* sockopts; /* --sockopts allowlist entries */
int sockopt_count;
} TcpConnectOptions;
Server* server_create_ex(int port, const ServerBindOptions* bind_opts);
Server* server_create(int port);
bool server_listen(Server* server, void (*handler)(int file_descriptor));
void server_accept_loop(Server* server, void (*child_fn)(int, void*), void* child_ctx,
const char* log_fmt);
void server_delete(Server** server);
Client* client_create();
bool client_connect(Client* client, char* host, int port);
bool tcp_connect_socket(Client* client, char* host, int port);
bool client_connect_ex(Client* client, const char* host, int port, const TcpConnectOptions* opts);
bool client_connect(Client* client, const char* host, int port);
bool tcp_connect_socket_ex(Client* client, const char* host, int port,
const TcpConnectOptions* opts);
bool tcp_connect_socket(Client* client, const char* host, int port);
void client_disconnect(Client* client);
void client_delete(Client* client);
void tcp_set_timeouts(int timeout_sec, int contimeout_sec);
int tcp_get_contimeout_sec(void);
int tcp_get_timeout_sec(void);
/* Resolve -4/-6 flags to a getaddrinfo ai_family value. ipv4 wins over ipv6;
* when neither is set it returns AF_UNSPEC. 0 means "no preference" and is
* therefore never returned; callers that need the "no explicit flag" sentinel
* compare the flags directly. */
int tcp_connect_family(bool ipv4, bool ipv6);
#endif
+9 -3
View File
@@ -186,9 +186,10 @@ bool server_listen_tls(Server* server, void (*handler)(int file_descriptor)) {
return true;
}
bool client_connect_tls(Client* client, char* host, int port, const char* cert_path,
const char* key_path, const char* ca_path) {
if (!tcp_connect_socket(client, host, port)) {
bool client_connect_tls_ex(Client* client, const char* host, int port, const char* cert_path,
const char* key_path, const char* ca_path,
const TcpConnectOptions* opts) {
if (!tcp_connect_socket_ex(client, host, port, opts)) {
if (client->file_descriptor >= 0)
close(client->file_descriptor);
client->file_descriptor = -1;
@@ -219,3 +220,8 @@ bool client_connect_tls(Client* client, char* host, int port, const char* cert_p
io_set_ssl(ssl);
return true;
}
bool client_connect_tls(Client* client, const char* host, int port, const char* cert_path,
const char* key_path, const char* ca_path) {
return client_connect_tls_ex(client, host, port, cert_path, key_path, ca_path, NULL);
}
+4 -1
View File
@@ -9,7 +9,10 @@ bool tls_global_init(void);
bool server_create_tls(Server* server, const char* cert_path, const char* key_path,
const char* ca_path);
bool server_listen_tls(Server* server, void (*handler)(int file_descriptor));
bool client_connect_tls(Client* client, char* host, int port, const char* cert_path,
bool client_connect_tls_ex(Client* client, const char* host, int port, const char* cert_path,
const char* key_path, const char* ca_path,
const TcpConnectOptions* opts);
bool client_connect_tls(Client* client, const char* host, int port, const char* cert_path,
const char* key_path, const char* ca_path);
#endif
+64
View File
@@ -187,6 +187,20 @@ def setup_test_data():
class TestDryRun:
def test_trust_sender_transfer_completes(self, shared_server):
"""--trust-sender is a receiver-local policy (never sent to the peer).
A transfer run with it must still complete and produce byte-identical
results: the receiver keeps its low-level root confinement, so a normal
trusted transfer is unchanged."""
clean_dir(DEST_DIR)
received = get_dest_received_dir(DEST_DIR, SOURCE_DIR)
result, _ = run_client(SOURCE_DIR, DEST_DIR,
flags=["--trust-sender"], port=shared_server.port)
assert result.returncode == 0, f"Exit {result.returncode}: {result.stderr[:200]}"
mismatches, missing = verify_transfer(SOURCE_DIR, received)
assert not missing, f"Missing files: {missing[:5]}"
assert not mismatches, f"Mismatched files: {mismatches[:5]}"
def test_human_readable_dry_run(self):
result, dur = run_client(SOURCE_DIR, DEST_DIR, flags=["-h", "--dry-run"])
assert result.returncode == 0, f"Exit {result.returncode}: {result.stderr[:100]}"
@@ -4604,3 +4618,53 @@ class TestExtendedAttributes:
fields = record.split(":")
assert len(fields) == 5
assert fields[0] == str(uid), f"reserved uid field {fields[0]} != source uid {uid}"
class TestConnectivityClientOptions:
"""Phase 5 connectivity launch options (--outbuf, --blocking-io).
These are client-side launch concerns: --outbuf only restyles stdout/stderr
buffering and --blocking-io only skips the SSH transport socket timeouts.
Over the TCP transport both must parse cleanly and be inert -- a transfer
must still complete and verify byte-for-byte."""
def _source_and_dest(self, name):
source = os.path.join(TEST_DATA_DIR, name + "_src")
dest = os.path.join(TEST_DATA_DIR, name + "_dst")
clean_dir(source)
clean_dir(dest)
return source, dest
@pytest.mark.parametrize("flag", ["--outbuf=N", "--outbuf=L", "--outbuf=B",
"--blocking-io"])
def test_option_does_not_break_transfer(self, shared_server, flag):
source, dest = self._source_and_dest("connopt")
with open(os.path.join(source, "hello.txt"), "wb") as f:
f.write(b"connectivity options\n" * 100)
with open(os.path.join(source, "data.bin"), "wb") as f:
f.write(os.urandom(512 * 1024))
result, _ = run_client(source, dest, flags=[flag], port=shared_server.port)
assert result.returncode == 0, \
f"{flag} failed: {(result.stderr or result.stdout)[:300]}"
mismatches, missing = verify_transfer(source, get_dest_received_dir(dest, source))
assert not mismatches and not missing, \
f"{flag}: mismatches={mismatches[:3]} missing={missing[:3]}"
def test_rejects_invalid_outbuf(self, shared_server):
source, dest = self._source_and_dest("connopt_bad")
with open(os.path.join(source, "x.txt"), "wb") as f:
f.write(b"x")
result, _ = run_client(source, dest, flags=["--outbuf=Z"], port=shared_server.port)
assert result.returncode != 0, "--outbuf=Z must be rejected"
def test_blocking_io_does_not_break_compressed_transfer(self, shared_server):
source, dest = self._source_and_dest("connopt_zlib")
with open(os.path.join(source, "text.txt"), "wb") as f:
f.write(b"compress me\n" * 4096)
result, _ = run_client(source, dest, flags=["--blocking-io", "-c"],
port=shared_server.port)
assert result.returncode == 0, \
f"--blocking-io -c failed: {(result.stderr or result.stdout)[:300]}"
mismatches, missing = verify_transfer(source, get_dest_received_dir(dest, source))
assert not mismatches and not missing
+86 -3
View File
@@ -64,11 +64,12 @@ def setup_test_data():
shutil.rmtree(DEST_DIR, ignore_errors=True)
def _run_ssh_test(name, flags, expected_missing=None):
def _run_ssh_test(name, flags, expected_missing=None, path_args=None):
ssh_dest = f"localhost:{DEST_DIR}"
clean_dir(DEST_DIR)
cmd = CLIENT_CMD + [SOURCE_DIR, ssh_dest, "--save-to-disk",
"--fastsync-server-path", os.path.join(BUILD_DIR, "server")] + flags
if not path_args:
path_args = ["--fastsync-server-path", os.path.join(BUILD_DIR, "server")]
cmd = CLIENT_CMD + [SOURCE_DIR, ssh_dest, "--save-to-disk"] + path_args + flags
start = __import__("time").monotonic()
result = subprocess.run(cmd, text=True, capture_output=True)
duration = __import__("time").monotonic() - start
@@ -142,3 +143,85 @@ class TestSSHFeatures:
def test_preallocate(self):
r = _run_ssh_test("SSH Preallocate (--preallocate)", ["--preallocate"])
assert r["status"] == "Success", r["error"]
class TestSSHConnectivity:
"""Phase 5 connectivity options: -e/--rsh, --rsync-path, --blocking-io,
--outbuf. These are client-side launch concerns, so each must parse and
still drive a real SSH transfer to completion."""
@pytest.fixture(autouse=True)
def require_ssh(self):
if not SSH_AVAILABLE:
pytest.skip(SSH_SKIP_REASON)
def test_rsh_short_form_selects_ssh(self):
r = _run_ssh_test("SSH -e ssh", ["-e", "ssh"])
assert r["status"] == "Success", r["error"]
def test_rsh_long_form_selects_ssh(self):
r = _run_ssh_test("SSH --rsh=ssh", ["--rsh=ssh"])
assert r["status"] == "Success", r["error"]
def test_rsync_path_aliases_server_path(self):
r = _run_ssh_test("SSH --rsync-path",
[],
path_args=["--rsync-path", os.path.join(BUILD_DIR, "server")])
assert r["status"] == "Success", r["error"]
def test_blocking_io(self):
r = _run_ssh_test("SSH --blocking-io", ["--blocking-io"])
assert r["status"] == "Success", r["error"]
@pytest.mark.parametrize("mode", ["N", "L", "B"])
def test_outbuf_mode(self, mode):
r = _run_ssh_test(f"SSH --outbuf={mode}", [f"--outbuf={mode}"])
assert r["status"] == "Success", r["error"]
def test_blocking_io_with_compression(self):
r = _run_ssh_test("SSH --blocking-io -c", ["--blocking-io", "-c"])
assert r["status"] == "Success", r["error"]
def test_trust_sender(self):
r = _run_ssh_test("SSH Trust Sender (--trust-sender)", ["--trust-sender"])
assert r["status"] == "Success", r["error"]
def test_remote_option_reaches_server(self):
"""--remote-option=OPT appends OPT to the remote server command line and
the server honors it. Over SSH the server is launched without
--allow-delete, so a bare --delete is inert (nothing is removed). If
--remote-option=--allow-delete really reaches the remote server, the
receiver's deletion policy becomes permissive and the stale destination
file IS removed. Asserting the file is gone is therefore a positive
proof the forwarded option was honored by the server."""
src = SOURCE_DIR
if os.path.exists(src):
shutil.rmtree(src)
os.makedirs(src)
with open(os.path.join(src, "keep.txt"), "w") as f:
f.write("kept\n")
with open(os.path.join(src, "stale.txt"), "w") as f:
f.write("stale\n")
received = get_dest_received_dir(DEST_DIR, SOURCE_DIR)
# Initial push so the destination mirrors the source.
clean_dir(DEST_DIR)
ssh_dest = f"localhost:{DEST_DIR}"
base = CLIENT_CMD + [src, ssh_dest, "--save-to-disk",
"--fastsync-server-path", os.path.join(BUILD_DIR, "server")]
first = subprocess.run(base, text=True, capture_output=True)
assert first.returncode == 0, f"initial push failed: {(first.stderr or first.stdout)[:200]}"
assert os.path.exists(os.path.join(received, "stale.txt"))
# Remove stale.txt from the source and re-push with --delete +
# --remote-option=--allow-delete. Forwarding --allow-delete to the
# server is what makes the deletion actually happen.
os.remove(os.path.join(src, "stale.txt"))
second = subprocess.run(base + ["--delete", "--remote-option=--allow-delete"],
text=True, capture_output=True)
assert second.returncode == 0, \
f"second push failed: {(second.stderr or second.stdout)[:200]}"
assert not os.path.exists(os.path.join(received, "stale.txt")), (
"stale.txt still present: --allow-delete (forwarded via "
"--remote-option) did not reach the remote server"
)
assert os.path.exists(os.path.join(received, "keep.txt"))
+29
View File
@@ -110,6 +110,35 @@ class TestTCPFlags:
assert r["status"] == "Success", r["error"]
class TestTCPSocketOptions:
"""--sockopts, -4/-6 and --address: rsync-compatible socket/bind options.
These are purely local (client-side) socket concerns that never cross the
wire, so each is exercised by a normal transfer succeeding end-to-end."""
@pytest.mark.ci
def test_sockopts_apply(self, shared_server):
r = _run_tcp_test("Sockopts (TCP_NODELAY=1,SO_KEEPALIVE=1)", shared_server.port,
["--sockopts=TCP_NODELAY=1,SO_KEEPALIVE=1"])
assert r["status"] == "Success", r["error"]
def test_sockopts_buffer_sizes(self, shared_server):
r = _run_tcp_test("Sockopts buffer sizes (SO_RCVBUF/SO_SNDBUF)", shared_server.port,
["--sockopts=SO_RCVBUF=131072,SO_SNDBUF=131072"])
assert r["status"] == "Success", r["error"]
def test_ipv4_forced(self, shared_server):
r = _run_tcp_test("Force IPv4 (-4)", shared_server.port, ["-4"])
assert r["status"] == "Success", r["error"]
@pytest.mark.skipif(shutil.which("ip") is None,
reason="requires ip tooling to enumerate a usable local address")
def test_address_source_bind(self, shared_server):
r = _run_tcp_test("Source bind (--address=127.0.0.1)", shared_server.port,
["--address", "127.0.0.1"])
assert r["status"] == "Success", r["error"]
class TestTCPChunkSize:
def test_custom_chunk_size(self, shared_server):
r = _run_tcp_test("Chunk size 5MB", shared_server.port, ["--chunk-size", "5242880"])
+1
View File
@@ -55,6 +55,7 @@ int main() {
RUN_TEST(test_metadata);
RUN_TEST(test_glob);
RUN_TEST(test_file);
RUN_TEST(test_trust_sender);
RUN_TEST(test_delay_updates);
RUN_TEST(test_file_sendfile);
RUN_TEST(test_multiprocessing);
+312 -6
View File
@@ -831,13 +831,7 @@ static void test_parse_args_rejects_unimplemented_options() {
"--delete-excluded",
"--max-delete",
"--prune-empty-dirs",
"-e",
"--rsh",
"--rsync-path",
"--address",
"--bind-address",
"--ipv6",
"--ipv4",
"--daemon",
"--config",
"--server"};
@@ -1210,6 +1204,117 @@ static void test_parse_args_old_args() {
config_delete(cfg);
}
/* Phase 5 connectivity: -e/--rsh select the remote-shell program. Both the
* short (space-separated value) and long (=value and space) forms parse, and
* a multi-word command line is preserved verbatim for the transport layer. */
static void test_parse_args_rsh() {
Config* cfg = config_create();
char* argv[] = {"fastsync", "-e", "ssh -p 2222", "/src", "/dst"};
int positional_args[2];
int positional_count = 0;
EXPECT_EQ_INT(parse_args(cfg, 5, argv, positional_args, &positional_count), 0);
EXPECT_EQ_STR(cfg->rsh_command, "ssh -p 2222");
config_delete(cfg);
cfg = config_create();
positional_count = 0;
char* argv_eq[] = {"fastsync", "--rsh=customsh", "/src", "/dst"};
EXPECT_EQ_INT(parse_args(cfg, 4, argv_eq, positional_args, &positional_count), 0);
EXPECT_EQ_STR(cfg->rsh_command, "customsh");
config_delete(cfg);
cfg = config_create();
positional_count = 0;
char* argv_space[] = {"fastsync", "--rsh", "ssh -l bob", "/src", "/dst"};
EXPECT_EQ_INT(parse_args(cfg, 5, argv_space, positional_args, &positional_count), 0);
EXPECT_EQ_STR(cfg->rsh_command, "ssh -l bob");
config_delete(cfg);
/* A missing value is a hard error. */
cfg = config_create();
positional_count = 0;
char* argv_missing[] = {"fastsync", "-e"};
EXPECT_EQ_INT(parse_args(cfg, 2, argv_missing, positional_args, &positional_count), -1);
config_delete(cfg);
}
/* --rsync-path is rsync's spelling for the server program path: it aliases
* fastsync_server_path exactly like --fastsync-server-path. */
static void test_parse_args_rsync_path_alias() {
Config* cfg = config_create();
char* argv[] = {"fastsync", "--rsync-path", "/usr/bin/fastsync-server", "/src", "/dst"};
int positional_args[2];
int positional_count = 0;
EXPECT_EQ_INT(parse_args(cfg, 5, argv, positional_args, &positional_count), 0);
EXPECT_EQ_STR(cfg->fastsync_server_path, "/usr/bin/fastsync-server");
config_delete(cfg);
cfg = config_create();
positional_count = 0;
char* argv_eq[] = {"fastsync", "--rsync-path=/opt/bin/srv", "/src", "/dst"};
EXPECT_EQ_INT(parse_args(cfg, 4, argv_eq, positional_args, &positional_count), 0);
EXPECT_EQ_STR(cfg->fastsync_server_path, "/opt/bin/srv");
config_delete(cfg);
}
/* --blocking-io is a plain boolean flag that leaves the SSH socket with no
* timeouts; the default is off. */
static void test_parse_args_blocking_io() {
Config* cfg = config_create();
EXPECT_FALSE(cfg->blocking_io);
char* argv[] = {"fastsync", "--blocking-io", "/src", "/dst"};
int positional_args[2];
int positional_count = 0;
EXPECT_EQ_INT(parse_args(cfg, 4, argv, positional_args, &positional_count), 0);
EXPECT_TRUE(cfg->blocking_io);
config_delete(cfg);
}
/* --outbuf=N|L|B maps onto the OUTBUF_* modes (default: block). Garbage is
* rejected, never silently coerced. */
static void test_parse_args_outbuf() {
Config* cfg = config_create();
EXPECT_EQ_INT(cfg->outbuf, OUTBUF_BLOCK);
int positional_args[2];
int positional_count = 0;
char* argv_n[] = {"fastsync", "--outbuf=N", "/src", "/dst"};
EXPECT_EQ_INT(parse_args(cfg, 4, argv_n, positional_args, &positional_count), 0);
EXPECT_EQ_INT(cfg->outbuf, OUTBUF_NONE);
config_delete(cfg);
cfg = config_create();
positional_count = 0;
char* argv_l[] = {"fastsync", "--outbuf", "L", "/src", "/dst"};
EXPECT_EQ_INT(parse_args(cfg, 5, argv_l, positional_args, &positional_count), 0);
EXPECT_EQ_INT(cfg->outbuf, OUTBUF_LINE);
config_delete(cfg);
cfg = config_create();
positional_count = 0;
char* argv_b[] = {"fastsync", "--outbuf=b", "/src", "/dst"};
EXPECT_EQ_INT(parse_args(cfg, 4, argv_b, positional_args, &positional_count), 0);
EXPECT_EQ_INT(cfg->outbuf, OUTBUF_BLOCK);
config_delete(cfg);
static const char* const bad[] = {"G", "X", ""};
for (size_t i = 0; i < sizeof(bad) / sizeof(bad[0]); i++) {
cfg = config_create();
positional_count = 0;
char option[32];
snprintf(option, sizeof(option), "--outbuf=%s", bad[i]);
char* argv_bad[] = {"fastsync", option, "/src", "/dst"};
EXPECT_EQ_INT(parse_args(cfg, 4, argv_bad, positional_args, &positional_count), -1);
config_delete(cfg);
}
cfg = config_create();
positional_count = 0;
char* argv_missing[] = {"fastsync", "--outbuf"};
EXPECT_EQ_INT(parse_args(cfg, 2, argv_missing, positional_args, &positional_count), -1);
config_delete(cfg);
}
static void test_parse_args_fsync() {
Config* cfg = config_create();
char* argv[] = {"fastsync", "--fsync", "/src", "/dst"};
@@ -2480,6 +2585,193 @@ static void test_parse_args_devices_specials() {
config_delete(cfg);
}
/* --address binds the outgoing client socket; it is a plain string option. */
static void test_parse_args_address() {
Config* cfg = config_create();
char* argv[] = {"fastsync", "--address", "192.0.2.10", "/src", "/dst"};
int positional_args[2];
int positional_count = 0;
EXPECT_EQ_INT(parse_args(cfg, 5, argv, positional_args, &positional_count), 0);
EXPECT_EQ_STR(cfg->address, "192.0.2.10");
config_delete(cfg);
cfg = config_create();
positional_count = 0;
char* eq_argv[] = {"fastsync", "--address=10.0.0.5", "/src", "/dst"};
EXPECT_EQ_INT(parse_args(cfg, 4, eq_argv, positional_args, &positional_count), 0);
EXPECT_EQ_STR(cfg->address, "10.0.0.5");
config_delete(cfg);
}
/* -4/--ipv4 and -6/--ipv6 set the resolution family; both together are
* rejected by validate_config (an address cannot be both v4 and v6). */
static void test_parse_args_ipv4_ipv6() {
Config* cfg = config_create();
char* argv[] = {"fastsync", "-4", "/src", "/dst"};
int positional_args[2];
int positional_count = 0;
EXPECT_EQ_INT(parse_args(cfg, 4, argv, positional_args, &positional_count), 0);
EXPECT_TRUE(cfg->ipv4);
EXPECT_FALSE(cfg->ipv6);
config_delete(cfg);
cfg = config_create();
positional_count = 0;
char* longv6[] = {"fastsync", "--ipv6", "/src", "/dst"};
EXPECT_EQ_INT(parse_args(cfg, 4, longv6, positional_args, &positional_count), 0);
EXPECT_FALSE(cfg->ipv4);
EXPECT_TRUE(cfg->ipv6);
config_delete(cfg);
cfg = config_create();
positional_count = 0;
char* both[] = {"fastsync", "-4", "-6", "/src", "/dst"};
EXPECT_EQ_INT(parse_args(cfg, 5, both, positional_args, &positional_count), 0);
EXPECT_TRUE(cfg->ipv4);
EXPECT_TRUE(cfg->ipv6);
cfg->send_directory = str_dup("/src");
cfg->receive_root_directory = str_dup("/dst");
EXPECT_FALSE(validate_config(cfg));
config_delete(cfg);
}
/* --sockopts parses and stores the allowlist; unknown options and bad values
* are rejected at the CLI layer (never silently ignored). */
static void test_parse_args_sockopts() {
Config* cfg = config_create();
char* argv[] = {"fastsync", "--sockopts=TCP_NODELAY=1,SO_KEEPALIVE=1", "/src", "/dst"};
int positional_args[2];
int positional_count = 0;
EXPECT_EQ_INT(parse_args(cfg, 4, argv, positional_args, &positional_count), 0);
EXPECT_EQ_INT(cfg->sockopt_count, 2);
EXPECT_EQ_INT(cfg->sockopts[0].id, SOCKOPT_TCP_NODELAY);
EXPECT_EQ_INT(cfg->sockopts[0].value, 1);
EXPECT_EQ_INT(cfg->sockopts[1].id, SOCKOPT_SO_KEEPALIVE);
config_delete(cfg);
cfg = config_create();
positional_count = 0;
char* sep_argv[] = {"fastsync", "--sockopts", "SO_RCVBUF=65536", "/src", "/dst"};
EXPECT_EQ_INT(parse_args(cfg, 5, sep_argv, positional_args, &positional_count), 0);
EXPECT_EQ_INT(cfg->sockopt_count, 1);
EXPECT_EQ_INT(cfg->sockopts[0].id, SOCKOPT_SO_RCVBUF);
EXPECT_EQ_INT(cfg->sockopts[0].value, 65536);
config_delete(cfg);
static const char* const bad[] = {"--sockopts=IP_TTL=1", "--sockopts=TCP_NODELAY=2",
"--sockopts=SO_KEEPALIVE"};
for (size_t i = 0; i < sizeof(bad) / sizeof(bad[0]); i++) {
cfg = config_create();
positional_count = 0;
char* b[] = {"fastsync", (char*)bad[i], "/src", "/dst"};
EXPECT_EQ_INT(parse_args(cfg, 4, b, positional_args, &positional_count), -1);
config_delete(cfg);
}
}
/* --trust-sender parses; default is false (receiver-local policy, off). */
static void test_parse_args_trust_sender_default_false() {
Config* cfg = valid_client_config();
EXPECT_NOT_NULL(cfg);
char* argv[] = {"fastsync", "--source-dir", "/src", "--dest-dir", "/dst"};
int positional_args[2];
int positional_count = 0;
EXPECT_EQ_INT(parse_args(cfg, 5, argv, positional_args, &positional_count), 0);
EXPECT_FALSE(cfg->trust_sender);
config_delete(cfg);
}
static void test_parse_args_trust_sender() {
Config* cfg = valid_client_config();
EXPECT_NOT_NULL(cfg);
char* argv[] = {"fastsync", "--trust-sender", "--source-dir", "/src", "--dest-dir", "/dst"};
int positional_args[2];
int positional_count = 0;
EXPECT_EQ_INT(parse_args(cfg, 6, argv, positional_args, &positional_count), 0);
EXPECT_TRUE(cfg->trust_sender);
config_delete(cfg);
}
/* --remote-option=OPT is repeatable and stores each value in order. */
static void test_parse_args_remote_option_multiple() {
Config* cfg = valid_client_config();
EXPECT_NOT_NULL(cfg);
EXPECT_EQ_INT(cfg->remote_option_count, 0);
char* argv[] = {"fastsync",
"--source-dir",
"/src",
"--dest-dir",
"/dst",
"--remote-option=--allow-delete",
"--remote-option=--verbose"};
int positional_args[2];
int positional_count = 0;
EXPECT_EQ_INT(parse_args(cfg, 7, argv, positional_args, &positional_count), 0);
EXPECT_EQ_INT(cfg->remote_option_count, 2);
EXPECT_EQ_STR(cfg->remote_options[0], "--allow-delete");
EXPECT_EQ_STR(cfg->remote_options[1], "--verbose");
config_delete(cfg);
}
/* Space-separated form "--remote-option OPT" also parses. */
static void test_parse_args_remote_option_space_form() {
Config* cfg = valid_client_config();
EXPECT_NOT_NULL(cfg);
char* argv[] = {"fastsync", "--source-dir", "/src", "--dest-dir",
"/dst", "--remote-option", "-v"};
int positional_args[2];
int positional_count = 0;
EXPECT_EQ_INT(parse_args(cfg, 7, argv, positional_args, &positional_count), 0);
EXPECT_EQ_INT(cfg->remote_option_count, 1);
EXPECT_EQ_STR(cfg->remote_options[0], "-v");
config_delete(cfg);
}
/* A missing argument bare --remote-option is rejected. */
static void test_parse_args_remote_option_missing_value() {
Config* cfg = valid_client_config();
EXPECT_NOT_NULL(cfg);
char* argv[] = {"fastsync", "--source-dir", "/src", "--dest-dir", "/dst", "--remote-option"};
int positional_args[2];
int positional_count = 0;
EXPECT_EQ_INT(parse_args(cfg, 6, argv, positional_args, &positional_count), -1);
config_delete(cfg);
}
/* An empty --remote-option value and a value with control characters is
* rejected (the value would break the remote shell quoting). */
static void test_parse_args_remote_option_rejects_bad_values() {
Config* cfg = valid_client_config();
EXPECT_NOT_NULL(cfg);
char* argv[] = {"fastsync", "--source-dir", "/src", "--dest-dir", "/dst", "--remote-option="};
int positional_args[2];
int positional_count = 0;
EXPECT_EQ_INT(parse_args(cfg, 6, argv, positional_args, &positional_count), -1);
EXPECT_EQ_INT(cfg->remote_option_count, 0);
char* argv2[] = {"fastsync", "--source-dir", "/src", "--dest-dir",
"/dst", "--remote-option", "--bad\noption"};
positional_count = 0;
EXPECT_EQ_INT(parse_args(cfg, 7, argv2, positional_args, &positional_count), -1);
EXPECT_EQ_INT(cfg->remote_option_count, 0);
config_delete(cfg);
}
/* A short -M form must NOT be accepted as --remote-option: -M stays FastSync
* metadata mode (documented divergence). */
static void test_parse_args_remote_option_no_short_M() {
Config* cfg = valid_client_config();
EXPECT_NOT_NULL(cfg);
/* -M followed by a remote-option-looking word still means metadata mode. */
char* argv[] = {"fastsync", "-M", "-v", "--source-dir", "/src", "--dest-dir", "/dst"};
int positional_args[2];
int positional_count = 0;
EXPECT_EQ_INT(parse_args(cfg, 7, argv, positional_args, &positional_count), 0);
EXPECT_TRUE(cfg->use_metadata);
EXPECT_EQ_INT(cfg->remote_option_count, 0);
config_delete(cfg);
}
void test_client_cli() {
test_validate_config_required_paths();
test_parse_args_numeric_ids();
@@ -2493,6 +2785,9 @@ void test_client_cli() {
test_parse_args_devices_specials();
test_parse_args_atimes_long_and_short();
test_parse_args_omit_link_times_long();
test_parse_args_address();
test_parse_args_ipv4_ipv6();
test_parse_args_sockopts();
test_parse_args_append();
test_parse_args_append_verify();
test_parse_args_append_both();
@@ -2556,6 +2851,10 @@ void test_client_cli() {
test_parse_args_no_preserve_blocks_implicit_metadata();
test_parse_args_rejects_unsafe_negation();
test_parse_args_old_args();
test_parse_args_rsh();
test_parse_args_rsync_path_alias();
test_parse_args_blocking_io();
test_parse_args_outbuf();
test_parse_args_fsync();
test_parse_args_existing();
test_parse_args_ignore_times();
@@ -2608,4 +2907,11 @@ void test_client_cli() {
test_parse_args_delete_policy_invalid_values();
test_parse_args_max_delete_inert_without_delete();
test_parse_args_missing_args_flags();
test_parse_args_trust_sender_default_false();
test_parse_args_trust_sender();
test_parse_args_remote_option_multiple();
test_parse_args_remote_option_space_form();
test_parse_args_remote_option_missing_value();
test_parse_args_remote_option_rejects_bad_values();
test_parse_args_remote_option_no_short_M();
}
+55
View File
@@ -1226,15 +1226,70 @@ static void test_config_phase4_xattr_wire_roundtrip() {
}
}
/* --trust-sender defaults to OFF (a receiver-local policy). */
static void test_config_trust_sender_default_false() {
Config* cfg = config_create();
EXPECT_NOT_NULL(cfg);
EXPECT_FALSE(cfg->trust_sender);
EXPECT_NULL(cfg->remote_options);
EXPECT_EQ_INT(cfg->remote_option_count, 0);
config_delete(cfg);
}
/* --trust-sender and --remote-option are LOCAL to the process that sets them:
* they must never cross the wire. After a round-trip the receiver observes the
* neutral defaults (trust_sender=false, no remote options), even when the
* sender had them set. */
static void test_config_local_only_fields_not_serialized() {
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]);
Config* recv = config_receive(p[0]);
bool ok = recv != NULL && !recv->trust_sender && recv->remote_options == NULL &&
recv->remote_option_count == 0;
config_delete(recv);
close(p[0]);
_exit(ok ? 0 : 1);
}
close(p[0]);
io_set_fds(p[1], p[1]);
Config* send_cfg = config_create();
EXPECT_NOT_NULL(send_cfg);
send_cfg->trust_sender = true;
/* remote_options is client-side state; populate it like the CLI would. */
send_cfg->remote_options = malloc(sizeof(char*));
send_cfg->remote_options[0] = str_dup("--allow-delete");
send_cfg->remote_option_count = 1;
send_cfg->send_directory = str_dup("/src");
send_cfg->receive_root_directory = str_dup("/dst");
bool sent = config_send(p[1], send_cfg);
int status;
waitpid(pid, &status, 0);
close(p[1]);
config_delete(send_cfg);
EXPECT_TRUE(sent);
EXPECT_TRUE(WIFEXITED(status) && WEXITSTATUS(status) == 0);
}
void test_config() {
test_config_lifecycle();
test_config_ssh_dest();
test_config_ssh_dest_local_path();
test_config_ssh_dest_no_user();
test_config_trust_sender_default_false();
test_pipeline_sender_lifecycle();
test_pipeline_receiver_lifecycle();
if (!is_running_under_valgrind()) {
test_config_send_receive();
test_config_local_only_fields_not_serialized();
test_config_send_receive_version_mismatch();
test_config_receive_truncated();
test_config_string_null_vs_empty_roundtrip();
+194
View File
@@ -6,6 +6,7 @@
#include "protocol.h"
#include "test_utils.h"
#include <fcntl.h>
#include <limits.h>
#include <stdlib.h>
#include <string.h>
#include <sys/stat.h>
@@ -1022,6 +1023,199 @@ static void test_dir_entry_save_to_disk() {
rmdir(root);
}
/* ---- Phase 5 (--trust-sender) safety-floor tests ----
*
* --trust-sender is a receiver-local policy that never crosses the wire: a real
* receiver enables it from its own process (the standalone server's --trust-
* sender CLI switch, which a client forwards as --remote-option=--trust-sender),
* so these tests force file_set_trust_sender(true) directly. Trust must RELAX
* only the redundant list-level re-validation (an escaping symlink TARGET is
* copied verbatim, rsync -l parity) and must NEVER disable the low-level
* fd-relative confinement floor: file_open_secure_parent's ".." rejection, the
* O_NOFOLLOW parent walk, leaf/destination confinement, and the ungated
* has_path_traversal on the link's own placement path in file_symlink_at_secure
* stay hard. A hostile sender therefore still cannot place a file, directory
* or symlink outside the receive root even with trust on. */
static void test_trust_sender_relaxes_symlink_target() {
const char* root = "test_trust_sender_root";
const char* link = "test_trust_sender_root/escape_link";
unlink(link);
rmdir(root);
EXPECT_EQ_INT(mkdir(root, 0755), 0);
/* Control: without trust an absolute (escaping) target is refused and the
link is never placed. */
file_set_trust_sender(false);
EXPECT_FALSE(file_symlink_at_secure(link, "/etc/passwd"));
struct stat st;
EXPECT_EQ_INT(lstat(link, &st), -1);
/* Trust ON: the escaping target is copied verbatim (rsync -l parity) ... */
file_set_trust_sender(true);
EXPECT_TRUE(file_symlink_at_secure(link, "/etc/passwd"));
EXPECT_EQ_INT(lstat(link, &st), 0);
EXPECT_TRUE(S_ISLNK(st.st_mode));
/* ...but the link itself still lands beneath the receive root. */
char target[128];
ssize_t target_len = readlink(link, target, sizeof(target) - 1);
EXPECT_TRUE(target_len > 0);
// cppcheck-suppress knownConditionTrueFalse
if (target_len > 0) {
target[target_len] = '\0';
EXPECT_EQ_STR(target, "/etc/passwd");
}
unlink(link);
/* Same relaxation through the real save funnel (file_save_to_disk_full). */
Config* config = config_create();
EXPECT_NOT_NULL(config);
const char* save_link = "test_trust_sender_root/save_link";
unlink(save_link);
File* sym = file_create("save_link");
EXPECT_NOT_NULL(sym);
sym->is_symlink = true;
sym->symlink_target = str_dup("/etc/passwd");
EXPECT_NOT_NULL(sym->symlink_target);
file_set_trust_sender(false);
EXPECT_EQ_INT(file_save_to_disk_full(root, sym, config), FILE_SAVE_SKIPPED);
EXPECT_EQ_INT(lstat(save_link, &st), -1);
file_set_trust_sender(true);
EXPECT_EQ_INT(file_save_to_disk_full(root, sym, config), FILE_SAVE_WRITTEN);
EXPECT_EQ_INT(lstat(save_link, &st), 0);
EXPECT_TRUE(S_ISLNK(st.st_mode));
file_destroy(sym);
config_delete(config);
unlink(save_link);
rmdir(root);
}
static void test_trust_sender_confines_hostile_paths() {
const char* root = "test_trust_sender_root";
const char* escaped_file = "../test_trust_sender_escaped_file.txt";
const char* escaped_dir = "../test_trust_sender_escaped_dir";
const char* escaped_link = "../test_trust_sender_escaped_link";
unlink(escaped_file);
rmdir(escaped_dir);
unlink(escaped_link);
unlink(root);
rmdir(root);
EXPECT_EQ_INT(mkdir(root, 0755), 0);
Config* config = config_create();
EXPECT_NOT_NULL(config);
file_set_trust_sender(true);
struct stat st;
/* A hostile regular-file path that would escape the root is contained: the
save-layer ".." re-check is relaxed under trust, so the attempt reaches the
secure floor, which refuses the walk -- nothing appears outside. */
File* file = file_create(escaped_file);
EXPECT_NOT_NULL(file);
file->data->data = malloc(5);
EXPECT_NOT_NULL(file->data->data);
memcpy(file->data->data, "evil", 4);
file->data->size = 4;
EXPECT_EQ_INT(file_save_to_disk_full(root, file, config), FILE_SAVE_ERROR);
file_destroy(file);
EXPECT_EQ_INT(lstat(escaped_file, &st), -1);
/* A hostile directory entry is contained the same way. */
File* dir = file_create(escaped_dir);
EXPECT_NOT_NULL(dir);
dir->is_dir = true;
EXPECT_EQ_INT(file_save_to_disk_full(root, dir, config), FILE_SAVE_ERROR);
file_destroy(dir);
EXPECT_EQ_INT(lstat(escaped_dir, &st), -1);
/* A hostile symlink whose OWN placement path escapes the root is refused even
under trust: the ungated has_path_traversal in file_symlink_at_secure never
turns off. */
EXPECT_FALSE(file_symlink_at_secure("test_trust_sender_root/../escaped_link", "/etc/passwd"));
EXPECT_EQ_INT(lstat(escaped_link, &st), -1);
/* file_open_secure_parent still refuses a ".." component outright. */
char* leaf = NULL;
EXPECT_EQ_INT(file_open_secure_parent("test_trust_sender_root/../../etc/passwd", &leaf, true),
-1);
free(leaf);
config_delete(config);
rmdir(root);
}
/* The same guarantees under a configured authorized root: a within-root link
with an escaping target is created (relaxed), while a placement path that is
a clean absolute path OUTSIDE the authorized root (no ".." anywhere) is
refused by the leaf/destination confinement. */
static void test_trust_sender_authorized_root_confinement() {
const char* root = "test_trust_sender_root";
const char* sibling = "test_trust_sender_sibling";
unlink(root);
rmdir(root);
rmdir(sibling);
EXPECT_EQ_INT(mkdir(root, 0755), 0);
EXPECT_EQ_INT(mkdir(sibling, 0755), 0);
char root_abs[PATH_MAX];
char sibling_abs[PATH_MAX];
EXPECT_NOT_NULL(realpath(root, root_abs));
EXPECT_NOT_NULL(realpath(sibling, sibling_abs));
int root_fd = open(root_abs, O_RDONLY | O_DIRECTORY | O_CLOEXEC);
EXPECT_TRUE(root_fd >= 0);
// cppcheck-suppress knownConditionTrueFalse
if (root_fd < 0) {
rmdir(root);
rmdir(sibling);
return;
}
EXPECT_TRUE(file_set_authorized_root(root_fd, root_abs));
file_set_trust_sender(true);
struct stat st;
/* Within the authorized root, an escaping symlink TARGET is copied verbatim. */
char* inside_link = path_cat(root_abs, "authorized_escape_link");
EXPECT_NOT_NULL(inside_link);
unlink(inside_link);
EXPECT_TRUE(file_symlink_at_secure(inside_link, "/etc/passwd"));
EXPECT_EQ_INT(lstat(inside_link, &st), 0);
EXPECT_TRUE(S_ISLNK(st.st_mode));
unlink(inside_link);
/* A clean absolute path in a sibling directory (outside the authorized root)
is still refused even under trust. */
char* outside_link = path_cat(sibling_abs, "test_trust_sender_outside_link");
EXPECT_NOT_NULL(outside_link);
unlink(outside_link);
EXPECT_FALSE(file_symlink_at_secure(outside_link, "/etc/passwd"));
EXPECT_EQ_INT(lstat(outside_link, &st), -1);
free(outside_link);
free(inside_link);
file_set_authorized_root(-1, NULL);
close(root_fd);
unlink("test_trust_sender_outside_link");
rmdir(sibling);
rmdir(root);
}
void test_trust_sender() {
/* The final reset lines always run (a failing EXPECT only returns from the
helper), so a later group never inherits a stray trust/authorized-root
policy. */
file_set_trust_sender(false);
test_trust_sender_relaxes_symlink_target();
test_trust_sender_confines_hostile_paths();
test_trust_sender_authorized_root_confinement();
file_set_trust_sender(false);
file_set_authorized_root(-1, NULL);
}
void test_file() {
test_file_create();
test_file_special_rdev_valid();
+1
View File
@@ -2,5 +2,6 @@
#define TEST_FILE_H
void test_file();
void test_trust_sender();
#endif
+106 -8
View File
@@ -4,13 +4,14 @@
static void test_ssh_connect_invalid_dest_no_colon() {
/* cppcheck-suppress constVariablePointer */
Client* client = client_connect_ssh("invalid-destination-no-colon", 22, NULL, false);
Client* client =
client_connect_ssh("invalid-destination-no-colon", 22, NULL, false, NULL, false, NULL, 0);
EXPECT_NULL(client);
}
static void test_ssh_connect_invalid_dest_empty() {
/* cppcheck-suppress constVariablePointer */
Client* client = client_connect_ssh("", 22, NULL, false);
Client* client = client_connect_ssh("", 22, NULL, false, NULL, false, NULL, 0);
EXPECT_NULL(client);
}
@@ -21,7 +22,7 @@ static void test_ssh_connect_malformed() {
setenv("PATH", "", 1);
/* cppcheck-suppress constVariablePointer */
Client* client = client_connect_ssh(":", 22, NULL, false);
Client* client = client_connect_ssh(":", 22, NULL, false, NULL, false, NULL, 0);
if (saved_path) {
setenv("PATH", saved_path, 1);
@@ -36,7 +37,8 @@ static void test_ssh_connect_malformed() {
/* Test client_connect_ssh with valid format but unreachable host.
* The function launches ssh which will fail to connect, returns a Client. */
static void test_ssh_connect_unreachable() {
Client* client = client_connect_ssh("nonexistent.invalid:/remote/path", 22, NULL, false);
Client* client =
client_connect_ssh("nonexistent.invalid:/remote/path", 22, NULL, false, NULL, false, NULL, 0);
if (client != NULL) {
client_disconnect(client);
client_delete(client);
@@ -45,23 +47,119 @@ static void test_ssh_connect_unreachable() {
}
static void test_ssh_remote_command_argument_modes() {
char* command = ssh_build_remote_command("fast sync; touch /tmp/pwned", false);
char* command = ssh_build_remote_command("fast sync; touch /tmp/pwned", false, NULL, 0);
EXPECT_EQ_STR(command, "'fast sync; touch /tmp/pwned' --stdio");
free(command);
command = ssh_build_remote_command("fast'sync", false);
command = ssh_build_remote_command("fast'sync", false, NULL, 0);
EXPECT_EQ_STR(command, "'fast'\\''sync' --stdio");
free(command);
command = ssh_build_remote_command("fast sync; touch /tmp/pwned", true);
command = ssh_build_remote_command("fast sync; touch /tmp/pwned", true, NULL, 0);
EXPECT_EQ_STR(command, "fast sync; touch /tmp/pwned --stdio");
free(command);
}
/* The build for a single-word argv is [prog, six -o args, user, command]. */
static void test_ssh_build_client_argv_default_is_ssh() {
char** argv = ssh_build_client_argv(NULL, 0, "u@h", "'srv' --stdio");
EXPECT_NOT_NULL(argv);
EXPECT_EQ_STR(argv[0], "ssh");
EXPECT_EQ_STR(argv[1], "-o");
EXPECT_EQ_STR(argv[7], "u@h");
EXPECT_EQ_STR(argv[8], "'srv' --stdio");
EXPECT_NULL(argv[9]);
ssh_free_client_argv(argv);
}
/* A configured rsh must replace "ssh" as argv[0] (and never leak the default). */
static void test_ssh_build_client_argv_uses_custom_rsh() {
char** argv = ssh_build_client_argv("myrsh", 0, "u@h", "rc");
EXPECT_NOT_NULL(argv);
EXPECT_EQ_STR(argv[0], "myrsh");
EXPECT_NULL(argv[9]);
ssh_free_client_argv(argv);
}
/* A multi-word rsh command line (rsync -e "ssh -p 2222") is split into the
* leading argv words; a non-default port adds a -p/value pair. */
static void test_ssh_build_client_argv_whitespace_command_and_port() {
char** argv = ssh_build_client_argv("ssh -p 2222", 0, "u@h", "rc");
EXPECT_NOT_NULL(argv);
EXPECT_EQ_STR(argv[0], "ssh");
EXPECT_EQ_STR(argv[1], "-p");
EXPECT_EQ_STR(argv[2], "2222");
EXPECT_NULL(argv[11]);
ssh_free_client_argv(argv);
argv = ssh_build_client_argv("ssh", 2222, "u@h", "rc");
EXPECT_NOT_NULL(argv);
EXPECT_EQ_STR(argv[0], "ssh");
/* Flat [prog, -o x6, -p, port, user, command]. */
EXPECT_EQ_STR(argv[7], "-p");
EXPECT_EQ_STR(argv[8], "2222");
EXPECT_EQ_STR(argv[9], "u@h");
EXPECT_EQ_STR(argv[10], "rc");
EXPECT_NULL(argv[11]);
ssh_free_client_argv(argv);
}
/* --remote-option=OPT appends OPT to the remote command line after " --stdio",
* each escaped as its own single-quoted shell word. Metacharacters that could
* break out of the quoting are neutralized (never injected), matching the
* ssh_build_remote_command safety boundary for the server path. */
static void test_ssh_remote_command_with_remote_options() {
char* noop[] = {"--allow-delete"};
char* command = ssh_build_remote_command("fastsync-server", false, noop, 1);
EXPECT_EQ_STR(command, "'fastsync-server' --stdio '--allow-delete'");
free(command);
/* Multiple options append in order, each as its own quoted word. */
char* multi[] = {"-v", "--allow-delete"};
command = ssh_build_remote_command("srv", false, multi, 2);
EXPECT_EQ_STR(command, "'srv' --stdio '-v' '--allow-delete'");
free(command);
/* A remote option containing a single quote and shell metacharacters is
escaped with the same "'\''" boundary, so it stays one word and cannot
break out into an arbitrary remote command. */
char* val = strdup("--x=un'der; touch /tmp/pwned");
char* dangerous[1] = {val};
command = ssh_build_remote_command("srv", false, dangerous, 1);
EXPECT_EQ_STR(command, "'srv' --stdio '--x=un'\\''der; touch /tmp/pwned'");
free(command);
free(val);
/* --old-args leaves the server path unquoted but still quotes remote options. */
command = ssh_build_remote_command("srv", true, multi, 2);
EXPECT_EQ_STR(command, "srv --stdio '-v' '--allow-delete'");
free(command);
}
/* The remote command builder refuses to forward an empty or control-character
* remote option (defense-in-depth independent of the CLI validation). */
static void test_ssh_remote_command_rejects_bad_options() {
char* empty[] = {""};
EXPECT_NULL(ssh_build_remote_command("srv", false, empty, 1));
char nl = '\n';
char* newline[] = {&nl};
EXPECT_NULL(ssh_build_remote_command("srv", false, newline, 1));
char* with_null[] = {NULL};
EXPECT_NULL(ssh_build_remote_command("srv", false, with_null, 1));
}
void test_transport_ssh() {
test_ssh_connect_invalid_dest_no_colon();
test_ssh_connect_invalid_dest_empty();
test_ssh_connect_malformed();
test_ssh_connect_unreachable();
test_ssh_remote_command_argument_modes();
}
test_ssh_build_client_argv_default_is_ssh();
test_ssh_build_client_argv_uses_custom_rsh();
test_ssh_build_client_argv_whitespace_command_and_port();
test_ssh_remote_command_with_remote_options();
test_ssh_remote_command_rejects_bad_options();
}
+113 -1
View File
@@ -2,14 +2,120 @@
#include "protocol.h"
#include "test_utils.h"
#include "transport_tcp.h"
#include <netinet/in.h>
#include <netinet/tcp.h>
#include <string.h>
#include <unistd.h>
#include <sys/socket.h>
/* -4/-6 map to a getaddrinfo ai_family hint: -4 -> AF_INET, -6 -> AF_INET6,
* and neither -> AF_UNSPEC. Both flags together are rejected earlier (in
* validate_config), so this helper never needs to prefer one over the other. */
static void test_tcp_connect_family_hints() {
EXPECT_EQ_INT(tcp_connect_family(false, false), AF_UNSPEC);
EXPECT_EQ_INT(tcp_connect_family(true, false), AF_INET);
EXPECT_EQ_INT(tcp_connect_family(false, true), AF_INET6);
}
/* --sockopts parsing+validation: every allowlisted KEY works, OPT=VAL values
* are captured, and an unknown option or a bad value is rejected (never
* silently ignored). */
static void test_sockopts_parse_valid() {
SockOptEntry* out = NULL;
int count = 0;
EXPECT_EQ_INT(config_sockopts_parse("TCP_NODELAY=1,SO_KEEPALIVE=0", &out, &count), 0);
EXPECT_EQ_INT(count, 2);
EXPECT_EQ_INT(out[0].id, SOCKOPT_TCP_NODELAY);
EXPECT_EQ_INT(out[0].value, 1);
EXPECT_EQ_INT(out[1].id, SOCKOPT_SO_KEEPALIVE);
EXPECT_EQ_INT(out[1].value, 0);
free(out);
out = NULL;
count = 0;
EXPECT_EQ_INT(
config_sockopts_parse("SO_RCVBUF=65536,SO_SNDBUF=131072,SO_REUSEADDR=1", &out, &count), 0);
EXPECT_EQ_INT(count, 3);
EXPECT_EQ_INT(out[0].id, SOCKOPT_SO_RCVBUF);
EXPECT_EQ_INT(out[0].value, 65536);
EXPECT_EQ_INT(out[1].id, SOCKOPT_SO_SNDBUF);
EXPECT_EQ_INT(out[1].value, 131072);
EXPECT_EQ_INT(out[2].id, SOCKOPT_SO_REUSEADDR);
EXPECT_EQ_INT(out[2].value, 1);
free(out);
}
static void test_sockopts_parse_rejects() {
static const char* const bad[] = {"IP_TTL=1", /* unknown option name */
"SO_KEEPALIVE", /* missing '=' */
"=1", /* missing option name */
"TCP_NODELAY=", /* missing value */
"TCP_NODELAY=2", /* boolean must be 0/1 */
"TCP_NODELAY=on", /* non-numeric boolean */
"SO_RCVBUF=-1", /* negative buffer */
"SO_SNDBUF=abc", /* non-numeric buffer */
""}; /* empty spec */
for (size_t i = 0; i < sizeof(bad) / sizeof(bad[0]); i++) {
SockOptEntry* out = NULL;
int count = 0;
EXPECT_EQ_INT(config_sockopts_parse(bad[i], &out, &count), -1);
EXPECT_NULL(out);
}
}
/* Applying a validated allowlist entry must actually set the socket option (a
* real setsockopt on a fresh TCP socket) so the config->wire path is proven. */
static void test_sockopts_apply_sets_option() {
SockOptEntry* entries = NULL;
int count = 0;
EXPECT_EQ_INT(config_sockopts_parse("TCP_NODELAY=1,SO_REUSEADDR=1", &entries, &count), 0);
int fd = socket(AF_INET, SOCK_STREAM, 0);
EXPECT_TRUE(fd >= 0);
for (int i = 0; i < count; i++) {
int value = entries[i].value;
int level = entries[i].id == SOCKOPT_TCP_NODELAY ? IPPROTO_TCP : SOL_SOCKET;
int name = entries[i].id == SOCKOPT_TCP_NODELAY ? TCP_NODELAY : SO_REUSEADDR;
EXPECT_EQ_INT(setsockopt(fd, level, name, &value, sizeof(value)), 0);
}
int got = 0;
socklen_t len = sizeof(got);
EXPECT_EQ_INT(getsockopt(fd, IPPROTO_TCP, TCP_NODELAY, &got, &len), 0);
EXPECT_EQ_INT(got, 1);
close(fd);
free(entries);
}
/* server_create_ex with an explicit --address and family binds to that local
* address; the resulting socket's address family must match. */
static void test_server_create_bind_address() {
ServerBindOptions opts;
opts.bind_address = "127.0.0.1";
opts.family = AF_INET;
Server* s = server_create_ex(0, &opts);
EXPECT_NOT_NULL(s);
EXPECT_EQ_INT(s->address.ss_family, AF_INET);
server_delete(&s);
}
/* An IPv6 bind is honored when the host supports it; on a host with no IPv6 a
* NULL return is acceptable (the feature degrades to unavailable, not wrong). */
static void test_server_create_bind_ipv6() {
ServerBindOptions opts;
opts.bind_address = "::1";
opts.family = AF_INET6;
Server* s = server_create_ex(0, &opts);
if (s) {
EXPECT_EQ_INT(s->address.ss_family, AF_INET6);
server_delete(&s);
}
}
static void test_server_create_ephemeral() {
Server* s = server_create(0);
EXPECT_NOT_NULL(s);
EXPECT_TRUE(s->file_descriptor >= 0);
EXPECT_EQ_INT(s->address.sin_family, AF_INET);
EXPECT_EQ_INT(s->address.ss_family, AF_INET);
server_delete(&s);
EXPECT_NULL(s);
}
@@ -99,4 +205,10 @@ void test_transport_tcp() {
test_server_create_specific_port();
test_server_delete_double();
test_client_disconnect_delete();
test_tcp_connect_family_hints();
test_sockopts_parse_valid();
test_sockopts_parse_rejects();
test_sockopts_apply_sets_option();
test_server_create_bind_address();
test_server_create_bind_ipv6();
}