rsync 3.4.1 drop-in parity (#285-#297) + parity completion (protocol 2.26.0) #298

Merged
TapTap merged 77 commits from feat/rsync-parity into dev 2026-09-17 20:33:17 +02:00
25 changed files with 1095 additions and 222 deletions
Showing only changes of commit 3eec5a4cc3 - Show all commits
+71 -22
View File
@@ -126,26 +126,40 @@ static int set_positive_int_option(int* dest, const char* value, const char* opt
return 0;
}
/* Set and validate the compression algorithm selected by the client. */
/* Set and validate the compression algorithm selected by the client. rsync
* 3.4.1 can be built with zstd, none, lz4, zlibx, zlib and auto; FastSync only
* implements zstd (and no compression). "auto" is accepted as the default
* zstd choice; any other rsync choice is rejected by name instead of being
* silently accepted and ignored. */
static int set_compression_choice(Config* config, const char* value) {
if (strcmp(value, "zstd") != 0 && strcmp(value, "none") != 0) {
log_message(LOG_LEVEL_ERROR, "--compress-choice must be zstd or none");
if (strcmp(value, "zstd") != 0 && strcmp(value, "none") != 0 && strcmp(value, "auto") != 0) {
log_message(LOG_LEVEL_ERROR,
"--compress-choice '%s' is not implemented; FastSync supports zstd, none or auto "
"(rsync's lz4/zlib/zlibx are rejected, never silently ignored)",
value);
return -1;
}
if (set_string_option(&config->compress_choice, value, "--compress-choice") != 0)
return -1;
config->use_compression = strcmp(value, "zstd") == 0;
config->use_compression = strcmp(value, "none") != 0;
return 0;
}
/* Validate and store the --checksum-choice/--cc algorithm. Only the algorithms
* the engine genuinely supports are accepted (xxHash64 and md5); anything else
* is a clear error, never a silent no-op. "xxhash" is accepted as rsync's
* spelling of xxHash64. */
* the engine genuinely supports are accepted (xxh64/xxhash, xxh3, xxh128, md5);
* rsync's compiled-in choices that FastSync does not implement (md4, sha1,
* none) and the two-name transfer/pre-transfer syntax are a clear error, never
* a silent no-op. "auto" (rsync's default automatic choice) selects FastSync's
* default algorithm. */
static int set_checksum_choice(Config* config, const char* value) {
if (strcasecmp(value, "auto") == 0)
return 0;
int algo = checksum_algo_from_name(value);
if (algo < 0) {
log_message(LOG_LEVEL_ERROR, "--checksum-choice must be xxh64 (or xxhash) or md5 (got '%s')",
log_message(LOG_LEVEL_ERROR,
"--checksum-choice '%s' is not implemented; FastSync supports xxh64 (or xxhash), "
"xxh3, xxh128, md5 or auto (rsync's md4/sha1/none and the two-name "
"transfer,pre-transfer form are rejected, never silently ignored)",
value);
return -1;
}
@@ -518,10 +532,6 @@ static int parse_size_arg_allow_zero(const char* value, unsigned long long* out,
return 0;
}
static int parse_size_arg(const char* value, unsigned long long* out) {
return parse_size_arg_allow_zero(value, out, false);
}
/* Append a duplicated pattern to a growable pattern array. Returns 0 on success, -1 on error. */
static int config_add_pattern(char*** patterns, int* count, const char* value,
const char* optname) {
@@ -573,7 +583,10 @@ static int parse_skip_compress(Config* config, const char* value) {
if (!list)
return -1;
config->skip_compress_set = true;
for (char* token = strtok(list, ","); token; token = strtok(NULL, ",")) {
/* rsync documents the LIST as slash-separated; accept that along with the
* historical comma-separated spelling. A leading dot is optional (rsync's
* suffixes have none, FastSync historically used them). */
for (char* token = strtok(list, ",/"); token; token = strtok(NULL, ",/")) {
while (*token == ' ' || *token == '\t')
token++;
size_t len = strlen(token);
@@ -741,8 +754,8 @@ static const OptionEntry OPTION_TABLE[] = {
{"--compress-choice", "--zc", OPT_STRING, offsetof(Config, compress_choice)},
{"--compress-level", "--zl", OPT_POS_INT, offsetof(Config, compression_level)},
{"--timeout", NULL, OPT_POS_INT, offsetof(Config, timeout)},
{"--contimeout", NULL, OPT_POS_INT, offsetof(Config, contimeout)},
{"--timeout", NULL, OPT_NONNEG_INT, offsetof(Config, timeout)},
{"--contimeout", NULL, OPT_NONNEG_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)},
@@ -781,6 +794,7 @@ static const NegatableOption NEGATABLE_OPTIONS[] = {
{"delete", NULL, offsetof(Config, use_delete)},
{"incremental", NULL, offsetof(Config, use_incremental)},
{"delta", NULL, offsetof(Config, use_delta)},
{"whole-file", "W", offsetof(Config, whole_file)},
{"fuzzy", NULL, offsetof(Config, fuzzy)},
{"save-to-disk", NULL, offsetof(Config, save_to_disk)},
{"progress", NULL, offsetof(Config, show_progress)},
@@ -995,6 +1009,17 @@ static bool cli_handle_pre_negation(CliParseCtx* ctx) {
config->super_mode = SUPER_MODE_OFF;
return true;
}
/* rsync's --no-timeout / --no-contimeout explicit spellings clear the
* corresponding (integer) deadline; handled before the generic --no-* branch
* because the negation table only models boolean fields. */
if (strcmp(arg, "--no-timeout") == 0) {
config->timeout = 0;
return true;
}
if (strcmp(arg, "--no-contimeout") == 0) {
config->contimeout = 0;
return true;
}
if (strncmp(arg, "--no-", strlen("--no-")) == 0) {
if (strcmp(arg, "--no-delta") == 0)
ctx->no_delta = true;
@@ -1051,7 +1076,8 @@ static bool cli_handle_range_time_options(CliParseCtx* ctx) {
}
if (strncmp(arg, "--stop-at=", 10) == 0) {
if (!stop_parse_at_time(arg + 10, time(NULL), &config->stop_at)) {
log_message(LOG_LEVEL_ERROR, "--stop-at must be HH:MM[:SS] or now+N[smhd]");
log_message(LOG_LEVEL_ERROR, "--stop-at must be a date/time such as 2000-12-31T23:59, 12-31, "
"14:00, :59, HH:MM[:SS] or now+N[smhd]");
ctx->exit_code = -1;
return true;
}
@@ -1065,7 +1091,8 @@ static bool cli_handle_range_time_options(CliParseCtx* ctx) {
return true;
}
if (!stop_parse_at_time(ctx->argv[++ctx->i], time(NULL), &config->stop_at)) {
log_message(LOG_LEVEL_ERROR, "--stop-at must be HH:MM[:SS] or now+N[smhd]");
log_message(LOG_LEVEL_ERROR, "--stop-at must be a date/time such as 2000-12-31T23:59, 12-31, "
"14:00, :59, HH:MM[:SS] or now+N[smhd]");
ctx->exit_code = -1;
return true;
}
@@ -1089,8 +1116,11 @@ static bool cli_handle_range_time_options(CliParseCtx* ctx) {
}
value = ctx->argv[++ctx->i];
}
if (parse_size_arg(value, &config->max_alloc) != 0) {
log_message(LOG_LEVEL_ERROR, "--max-alloc must be a positive size (B, K, M, G, T, P, or E)");
/* rsync: --max-alloc=0 means "no alloc limit" (it maps to SIZE_MAX). A
* size with an optional binary suffix is also accepted. */
if (parse_size_arg_allow_zero(value, &config->max_alloc, true) != 0) {
log_message(LOG_LEVEL_ERROR, "--max-alloc must be a size (0 = no limit; B, K, M, G, T, P, E "
"suffixes allowed)");
ctx->exit_code = -1;
}
return true;
@@ -1401,7 +1431,7 @@ static bool cli_handle_transfer_flags(CliParseCtx* ctx) {
const char* arg = ctx->argv[ctx->i];
if (opt_is(arg, "-z", "--compress")) {
config->use_compression =
!config->compress_choice || strcmp(config->compress_choice, "zstd") == 0;
!config->compress_choice || strcmp(config->compress_choice, "none") != 0;
log_info_message(LOG_INFO_MISC, "Enabled Compression");
if (ctx->i + 1 < ctx->argc) {
char* end_ptr;
@@ -2011,7 +2041,16 @@ static bool cli_handle_outbuf_option(CliParseCtx* ctx) {
static int cli_finalize_config(Config* config, bool verbose, bool no_delta, bool no_incremental) {
set_log_level(config->quiet ? LOG_LEVEL_ERROR : (verbose ? LOG_LEVEL_DEBUG : LOG_LEVEL_WARNING));
if (config->compress_choice)
config->use_compression = strcmp(config->compress_choice, "zstd") == 0;
config->use_compression = strcmp(config->compress_choice, "none") != 0;
/* rsync randomizes the checksum seed for every transfer when the user did not
* supply one (a seed of 0, including an explicit --checksum-seed=0), using
* time(NULL) ^ (getpid() << 6), and transmits it so both ends agree. Mirror
* that: the wire config carries the value, so the receiver uses the exact
* seed this sender hashed with. A non-zero --checksum-seed is honored
* verbatim (deterministic). */
if (config->checksum_seed == 0)
config->checksum_seed = (uint64_t)time(NULL) ^ ((uint64_t)getpid() << 6);
/* --files-from is loaded after every argument is seen so that -0/--from0 may
* appear anywhere on the command line. A missing or unreadable file, and
@@ -2063,6 +2102,16 @@ static int cli_finalize_config(Config* config, bool verbose, bool no_delta, bool
config->use_incremental = true;
}
/* -c/--checksum switches the per-file quick-check from size+mtime to a
* content digest; FastSync expresses that comparison through the incremental
* handshake, so -c implies --incremental. rsync's -c does NOT imply -t (the
* digest alone decides), so the incremental auto-preserve below must not be
* triggered by a checksum-only implication: capture the explicitly requested
* incremental/delta state first. */
bool preserve_implied = config->use_incremental || config->use_delta;
if (config->checksum)
config->use_incremental = true;
/* --incremental/--delta historically auto-enabled the metadata path, which
* applied mode+mtime (README: "--incremental Auto-enables --preserve").
* Restore that behavior by turning on the two attributes unless the user
@@ -2070,7 +2119,7 @@ static int cli_finalize_config(Config* config, bool verbose, bool no_delta, bool
* BEFORE the derived use_metadata bit so the transport frame is still sent
* for the incremental/delta handshake even when both attributes were negated
* via --no-preserve (metadata_explicitly_disabled handles that opt-out). */
if ((config->use_incremental || config->use_delta) && !config->metadata_explicitly_disabled) {
if (preserve_implied && !config->metadata_explicitly_disabled) {
if (!config->preserve_perms_explicit_off)
config->preserve_perms = true;
if (!config->preserve_times_explicit_off)
+10
View File
@@ -60,6 +60,16 @@ bool validate_config(const Config* config) {
log_message(LOG_LEVEL_ERROR, "-f/--sendfile is not supported with SSH transport");
return false;
}
/* -M/--remote-option appends an option to the REMOTE server's argv, which
* only exists on the SSH (user@host:path) transport. A daemon
* (host::module/path) or local TCP destination has no remote command line,
* so the option would be silently ignored; reject it by name instead. */
if (config->remote_option_count > 0 && config->transport != TRANSPORT_SSH) {
log_message(LOG_LEVEL_ERROR,
"-M/--remote-option is only valid with the SSH transport (user@host:path); it "
"cannot be used with a daemon (host::module/path) or local TCP destination");
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");
+50 -26
View File
@@ -59,6 +59,8 @@ void print_usage(void) {
printf(" Emit the batch file only (no destination, no server)\n");
printf(" --read-batch=FILE Apply the batch file to the destination (no source, no\n");
printf(" server); takes only the destination as an argument\n");
printf(" NOTE: the FastSync batch format is NOT interoperable with rsync's batch\n");
printf(" files (different container format); do not mix the two tools.\n");
printf(" --delete Delete files on receiver not in source\n");
printf(" (default timing: delete only after the whole\n");
printf(" transfer has succeeded)\n");
@@ -118,7 +120,8 @@ void print_usage(void) {
printf(" -F Apply per-directory .rsync-filter files during the scan\n");
printf(" --max-size <n> Skip files larger than n bytes\n");
printf(" --min-size <n> Skip files smaller than n bytes\n");
printf(" --max-alloc <SIZE> Maximum single allocation (default: 1G)\n");
printf(" --max-alloc <SIZE> Maximum single allocation (default: 1G; 0 = no limit,\n");
printf(" matching rsync)\n");
printf(" --incremental Skip files unchanged since last transfer\n");
printf(" --size-only Skip incremental files matching in size, ignoring mtime\n");
printf(" -I, --ignore-times Transfer files even when size and mtime match\n");
@@ -133,13 +136,17 @@ void print_usage(void) {
printf(" --link-dest <dir> Like --copy-dest, but hard-links the unchanged file from DIR\n");
printf(" into the destination (repeatable; earlier DIRs win)\n");
printf(" --checksum-choice, --cc <alg> Whole-file checksum algorithm for --incremental/\n");
printf(" --checksum compares (xxh64/xxhash or md5; default xxh64 with\n");
printf(" seed 0). The seed comes from --checksum-seed\n");
printf(" --checksum-seed <num> Seed for the whole-file xxHash64 digest (and the delta\n");
printf(" block strong hash, low 32 bits); md5 ignores the seed. The\n");
printf(" digest algorithm and seed must match on sender and receiver\n");
printf(" --checksum compares. Accepted: xxh64 (aka xxhash), xxh3,\n");
printf(" xxh128, md5, or auto (default xxh64). rsync choices FastSync\n");
printf(" does not implement (md4, sha1, none) and the two-name\n");
printf(" transfer,pre-transfer form are rejected by name\n");
printf(" --checksum-seed <num> Seed for the whole-file xxHash digest (and the delta\n");
printf(" block strong hash, low 32 bits); md5 ignores the seed. A seed\n");
printf(" of 0 (the default) is randomized per transfer, exactly like\n");
printf(" rsync, and the chosen seed is sent to the receiver\n");
printf(" --delta Delta transfer for changed files (requires --incremental)\n");
printf(" -W, --whole-file Transfer changed files without delta processing\n");
printf(" --no-whole-file rsync spelling that clears -W/--whole-file\n");
printf(" -y, --fuzzy Use a similar-named file already in the destination\n");
printf(" directory as the delta basis when the destination has no\n");
printf(" usable file at the exact path (saves bandwidth; implies\n");
@@ -223,9 +230,10 @@ void print_usage(void) {
printf(" --server-port <n> Server port (default: 8080)\n");
printf(" --port <n> Alias for --server-port\n");
printf(" --password-file <f> Authenticate a host::module/path daemon destination.\n");
printf(" The file's first user:password line supplies the\n");
printf(" username and password (only a SHA-256 digest of the\n");
printf(" password is sent; keep the file mode 0600)\n");
printf(" FastSync-native SCRAM/PBKDF2 credential scheme (NOT\n");
printf(" rsync's --password-file): the file's first user:password\n");
printf(" line supplies the username and password; no password or\n");
printf(" reusable digest is sent (keep the file mode 0600)\n");
printf(" --no-motd Suppress display of the daemon's MOTD (the server\n");
printf(" still sends it; the client just does not show it)\n");
printf(" --bwlimit <KB/s> Bandwidth limit in kilobytes per second\n");
@@ -233,15 +241,19 @@ void print_usage(void) {
printf(" --cert <path> TLS certificate file (PEM)\n");
printf(" --key <path> TLS private key file (PEM)\n");
printf(" --ca <path> TLS CA certificate file (PEM)\n");
printf(" --timeout <sec> I/O timeout in seconds (default: 30; long form only)\n");
printf(" --contimeout <sec> Connection timeout in seconds (default: 10)\n");
printf(" --timeout <sec> I/O timeout in seconds (default: 0 = disabled, matching\n");
printf(" rsync). 0 disables it; --no-timeout is the same\n");
printf(" --contimeout <sec> Connection timeout in seconds (default: 60, matching\n");
printf(" rsync); 0 disables it (--no-contimeout)\n");
printf(" --stop-after=MINS Stop the transfer after MINS minutes (a positive\n");
printf(" integer); whatever was already transferred is kept\n");
printf(" --stop-at=TIME Stop at an absolute time: HH:MM, HH:MM:SS, or\n");
printf(" now+N[smhd] (a time already in the past stops the\n");
printf(" transfer immediately; client-only). An early stop\n");
printf(" skips the late --delete keep-set so it cannot delete\n");
printf(" source mirrors that were not yet scanned\n");
printf(" --stop-at=TIME Stop at an absolute time. Accepts rsync's date form\n");
printf(" (Y-M-DTh:m, Y/M/DTh:m, abbreviable fields such as 12-31,\n");
printf(" 14:00, :59, 1) plus FastSync's HH:MM[:SS] and now+N[smhd]\n");
printf(" (a time already in the past stops the transfer\n");
printf(" immediately; client-only). An early stop skips the late\n");
printf(" --delete keep-set so it cannot delete source mirrors that\n");
printf(" were not yet scanned\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");
@@ -262,19 +274,29 @@ void print_usage(void) {
printf(" --stderr=MODE Route logging to stderr: errors or all\n");
printf(" --partial Keep partial files on interrupted transfer\n");
printf(" --partial-dir <dir> Directory for partial files\n");
printf(" -T, --temp-dir <dir> Scratch dir for temp files before atomic install\n");
printf(" -T, --temp-dir <dir> Scratch dir for temp files before atomic install.\n");
printf(" Relative dirs resolve below the destination root; absolute\n");
printf(" dirs are used as-is (rsync semantics). The dir must\n");
printf(" already exist; a different filesystem falls back to a\n");
printf(" non-atomic copy instead of aborting\n");
printf(" --fastsync-server-path <path>\n");
printf(" Path to fastsync-server on remote (default: fastsync-server)\n");
printf(" --old-args Accepted for rsync CLI compatibility; no effect (the\n");
printf(" remote server path is always safely quoted now)\n");
printf(" -M, --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; -M OPT, -M=OPT and --remote-option=OPT work)\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(" -M, --remote-option=OPT Append OPT to the REMOTE server invocation. SSH\n");
printf(" transport ONLY (user@host:path): a daemon (host::module) or\n");
printf(" local TCP destination rejects it (no remote command line to\n");
printf(" append to). Repeatable; each value is single-quote-escaped on\n");
printf(" the remote command line; empty values and values with control\n");
printf(" characters are rejected; -M OPT, -M=OPT and\n");
printf(" --remote-option=OPT work\n");
printf(" --trust-sender RECEIVER-LOCAL policy: trust the remote sender's file list\n");
printf(" and skip the receiver's own up-front path-traversal/\n");
printf(" containment re-validation of the incoming list (fewer checks,\n");
printf(" faster, potentially unsafe). It is never sent to the peer, so\n");
printf(" for a push it must be enabled on the receiving SERVER\n");
printf(" (fastsync-server --trust-sender) or forwarded with\n");
printf(" -M--trust-sender; the client flag alone has no effect\n");
printf(" -l, --links Copy symlinks as symlinks\n");
printf(" -L, --copy-links Transform symlinks into referent files\n");
printf(" --safe-links Skip symlinks that point outside transfer tree\n");
@@ -303,7 +325,9 @@ void print_usage(void) {
printf(" --fsync Fsync every written file before publication\n");
printf(" --compress-level <n> Compression level (default: 5)\n");
printf(" --zl <n> Alias for --compress-level\n");
printf(" --skip-compress=LIST Skip compression for comma-separated suffixes\n");
printf(" --skip-compress=LIST Skip compression for suffixes in LIST (separated by\n");
printf(" '/' as in rsync, or ','); a leading dot is optional. The\n");
printf(" default is rsync 3.4.1's built-in skip-compress list\n");
printf(" --compress-threads <n> Compression worker threads (requires zstd threaded support)\n");
printf(" --no-OPTION Disable a supported boolean option\n");
printf(" --help Show this help\n");
+6 -3
View File
@@ -1041,8 +1041,9 @@ static void print_server_usage(void) {
printf(" hosts allow, hosts deny)\n");
printf(" --no-detach Stay in the foreground (default detaches to\n");
printf(" background when running --daemon)\n");
printf(" --password-file=FILE Credential store for modules that declare\n");
printf(" 'auth users' (line format:\n");
printf(" --password-file=FILE FastSync-native SCRAM/PBKDF2 credential store (NOT\n");
printf(" rsync's auth scheme) for modules that declare 'auth\n");
printf(" users' (line format:\n");
printf(" user:$fastsync$1$pbkdf2-sha256$iters$salt$stored$server,\n");
printf(" generated by --hash-credentials). Legacy\n");
printf(" user:SHA256HEX lines are rejected. Requires\n");
@@ -1062,7 +1063,9 @@ static void print_server_usage(void) {
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(" --trust-sender Trust the remote sender's file list (receiver-local;\n");
printf(" this server-side flag is the only one that matters -- a\n");
printf(" client --trust-sender is never sent to the server)\n");
printf(" --no-super Operator veto: never attempt super-user activities\n");
printf(" (ownership, device nodes) even as root, and refuse\n");
printf(" any client --copy-as/--super request\n");
+26 -1
View File
@@ -21,6 +21,20 @@ bool checksum_digest(ChecksumAlgo algo, uint64_t seed, const void* data, size_t
return true;
}
if (algo == CHECKSUM_ALGO_XXH3) {
uint64_t digest = XXH3_64bits_withSeed(data, size, seed);
memcpy(out, &digest, sizeof(digest));
*out_len = sizeof(digest);
return true;
}
if (algo == CHECKSUM_ALGO_XXH128) {
XXH128_hash_t digest = XXH3_128bits_withSeed(data, size, seed);
memcpy(out, &digest, sizeof(digest));
*out_len = sizeof(digest);
return true;
}
if (algo == CHECKSUM_ALGO_MD5) {
/* md5 takes no seed; the caller's seed is deliberately ignored (documented
* in RSYNC_COMPAT.md). OpenSSL's one-shot EVP_Digest needs a non-NULL
@@ -45,6 +59,10 @@ int checksum_algo_from_name(const char* name) {
return -1;
if (strcasecmp(name, "xxh64") == 0 || strcasecmp(name, "xxhash") == 0)
return (int)CHECKSUM_ALGO_XXH64;
if (strcasecmp(name, "xxh3") == 0)
return (int)CHECKSUM_ALGO_XXH3;
if (strcasecmp(name, "xxh128") == 0)
return (int)CHECKSUM_ALGO_XXH128;
if (strcasecmp(name, "md5") == 0)
return (int)CHECKSUM_ALGO_MD5;
return -1;
@@ -54,6 +72,10 @@ const char* checksum_algo_name(ChecksumAlgo algo) {
switch (algo) {
case CHECKSUM_ALGO_XXH64:
return "xxh64";
case CHECKSUM_ALGO_XXH3:
return "xxh3";
case CHECKSUM_ALGO_XXH128:
return "xxh128";
case CHECKSUM_ALGO_MD5:
return "md5";
}
@@ -61,13 +83,16 @@ const char* checksum_algo_name(ChecksumAlgo algo) {
}
bool checksum_algo_valid(int algo) {
return algo == (int)CHECKSUM_ALGO_XXH64 || algo == (int)CHECKSUM_ALGO_MD5;
return algo == (int)CHECKSUM_ALGO_XXH64 || algo == (int)CHECKSUM_ALGO_MD5 ||
algo == (int)CHECKSUM_ALGO_XXH3 || algo == (int)CHECKSUM_ALGO_XXH128;
}
uint8_t checksum_digest_len(ChecksumAlgo algo) {
switch (algo) {
case CHECKSUM_ALGO_XXH64:
case CHECKSUM_ALGO_XXH3:
return 8;
case CHECKSUM_ALGO_XXH128:
case CHECKSUM_ALGO_MD5:
return 16;
}
+15 -6
View File
@@ -9,10 +9,17 @@
* seeded with --checksum-seed. The ids are the values actually placed on the
* wire (config frame), so they must be kept stable and validated on receive.
* CHECKSUM_ALGO_XXH64 == 0 is the default and is byte-for-byte what FastSync
* computed before these options existed (xxHash64 with seed 0). */
typedef enum { CHECKSUM_ALGO_XXH64 = 0, CHECKSUM_ALGO_MD5 = 1 } ChecksumAlgo;
* computed before these options existed (xxHash64 with seed 0). The set mirrors
* the algorithms rsync 3.4.1 can be built with; the ones FastSync does not
* implement (md4, sha1, none) are rejected by name at parse time. */
typedef enum {
CHECKSUM_ALGO_XXH64 = 0,
CHECKSUM_ALGO_MD5 = 1,
CHECKSUM_ALGO_XXH3 = 2,
CHECKSUM_ALGO_XXH128 = 3
} ChecksumAlgo;
/* md5 digest is 16 bytes, the longest supported. */
/* xxh128 digest is 16 bytes, the longest supported. */
#define CHECKSUM_MAX_DIGEST_LEN 16
/* Compute the whole-file digest of the first `size` bytes of `data`.
@@ -29,8 +36,10 @@ bool checksum_digest(ChecksumAlgo algo, uint64_t seed, const void* data, size_t
size_t out_capacity, size_t* out_len);
/* Resolve a --checksum-choice string (case-insensitive) to an algorithm id.
* Accepts "xxh64" and "xxhash" (both map to CHECKSUM_ALGO_XXH64, rsync's
* xxhash spelling) and "md5". Returns -1 for any unsupported name. */
* Accepts "xxh64"/"xxhash", "xxh3", "xxh128" and "md5". "auto", rsync's
* default automatic choice, is resolved to the default by the caller (it is not
* a distinct algorithm here). Returns -1 for any name FastSync does not
* implement (md4/sha1/none included). */
int checksum_algo_from_name(const char* name);
/* Canonical name of an algorithm (used in CLI error messages). */
@@ -39,7 +48,7 @@ const char* checksum_algo_name(ChecksumAlgo algo);
/* True when `algo` is a supported id (used by config receive validation). */
bool checksum_algo_valid(int algo);
/* Digest length in bytes for an algorithm (xxx64 = 8, md5 = 16). */
/* Digest length in bytes for an algorithm (xxh64/xxh3 = 8, md5/xxh128 = 16). */
uint8_t checksum_digest_len(ChecksumAlgo algo);
#endif /* CHECKSUM_H */
+41 -10
View File
@@ -14,23 +14,54 @@
#define INITIAL_DECOMPRESS_BUF_SIZE (1024 * 1024)
#define MAX_DECOMPRESSED_SIZE (100ULL * 1024 * 1024) /* 100 MB hard ceiling */
static char* SKIP_COMPRESSION_EXTENSIONS[] = {".jpg", ".jpeg", ".png", ".gif", ".mp4", ".mkv",
".zip", ".gz", ".xz", ".zst", NULL};
/* rsync 3.4.1's built-in skip-compress suffix list (the `--skip-compress`
* defaults, in the man page's order). rsync stores it as space-separated
* "*.suffix" globs; FastSync matches the plain suffix after the final dot, so
* the leading "*." is omitted here. A user --skip-compress list replaces this
* default entirely (matching rsync). */
#define DEFAULT_SKIP_COMPRESS_SUFFIXES \
"3g2 3gp 7z aac ace apk avi bz2 deb dmg ear f4v flac flv gpg gz iso jar jpeg jpg lrz lz lz4 " \
"lzma " \
"lzo m1a m1v m2a m2ts m2v m4a m4b m4p m4r m4v mka mkv mov mp1 mp2 mp3 mp4 mpa mpeg mpg mpv mts " \
"odb odf odg odi odm odp ods odt oga ogg ogm ogv ogx opus otg oth otp ots ott oxt png qt rar " \
"rpm " \
"rz rzip spx squashfs sxc sxd sxg sxm sxw sz tbz tbz2 tgz tlz ts txz tzo vob war webm webp xz " \
"z " \
"zip zst"
/* Case-insensitive match of a bare suffix (no leading dot) against a
* space-separated suffix list. */
static bool suffix_in_list(const char* name, const char* list) {
size_t name_len = strlen(name);
while (*list) {
while (*list == ' ')
list++;
const char* start = list;
while (*list && *list != ' ')
list++;
size_t len = (size_t)(list - start);
if (len == name_len && strncasecmp(name, start, len) == 0)
return true;
}
return false;
}
bool compression_should_skip_with_suffixes(const char* path, char* const* suffixes, int count) {
if (!path)
return false;
const char* dot = strrchr(path, '.');
if (!dot)
if (!dot || dot[1] == '\0')
return false;
if (count < 0) {
suffixes = SKIP_COMPRESSION_EXTENSIONS;
count = 0;
while (SKIP_COMPRESSION_EXTENSIONS[count])
count++;
}
const char* name = dot + 1;
/* count < 0 (the user gave no --skip-compress) selects rsync's built-in
* default list; a non-negative count is the user's explicit list. */
if (count < 0)
return suffix_in_list(name, DEFAULT_SKIP_COMPRESS_SUFFIXES);
for (int i = 0; i < count; i++) {
if (strcasecmp(dot, suffixes[i]) == 0)
const char* suffix = suffixes[i];
if (suffix[0] == '.')
suffix++;
if (strcasecmp(name, suffix) == 0)
return true;
}
return false;
+10 -8
View File
@@ -45,12 +45,12 @@ static void config_set_defaults(Config* config) {
config->server_port = 8080;
config->server_port_set = false;
config->server_host_set = false;
/* 0 means "--timeout not given": the transport keeps its own built-in 30 s
* socket timeout (tcp_set_timeouts ignores non-positive values) and the
* protocol layer keeps its built-in 60 s per-message deadline. A positive
* value overrides BOTH (see protocol_session_set_io_timeout). */
/* rsync defaults: --timeout=0 (I/O timeouts disabled) and --contimeout=60.
* A value of 0 disables the deadline on both the socket layer
* (tcp_set_timeouts) and the protocol layer
* (protocol_session_set_io_timeout); a positive value sets it. */
config->timeout = 0;
config->contimeout = 10;
config->contimeout = 60;
config->quiet = false;
config->stats = false;
config->max_depth = 0;
@@ -208,7 +208,7 @@ static bool validate_received_config(const Config* config) {
config->delta_block_size <= DELTA_BLOCK_SIZE_MAX &&
config->delta_max_file_size <= DELTA_MAX_FILE_SIZE && config->modify_window >= 0 &&
config->max_delete >= -1 && config->skip_compress_count >= 0 &&
config->skip_compress_count <= MAX_SKIP_COMPRESS_SUFFIXES && config->max_alloc > 0 &&
config->skip_compress_count <= MAX_SKIP_COMPRESS_SUFFIXES &&
(!config->chmod_spec || !*config->chmod_spec ||
chmod_apply(0, config->chmod_spec, &(mode_t){0})) &&
config->super_mode >= SUPER_MODE_AUTO && config->super_mode <= SUPER_MODE_OFF;
@@ -780,9 +780,11 @@ void config_delete(Config* config) {
* ------------------------------------------------------------------------- */
/* --max-alloc: raw 64-bit value, clamped server-side and installed as the
* session allocation ceiling. A zero value is rejected. */
* session allocation ceiling. Zero means "no alloc limit" (rsync's
* --max-alloc=0) and is passed through; a non-zero value is clamped to the
* server's own ceiling. */
static bool config_receive_max_alloc(int fd, unsigned long long* value) {
if (!receive_n_data(fd, value, sizeof(*value)) || *value == 0)
if (!receive_n_data(fd, value, sizeof(*value)))
return false;
if (*value > MAX_SERVER_ALLOC)
*value = MAX_SERVER_ALLOC;
+6 -5
View File
@@ -314,12 +314,13 @@ typedef struct Config {
char* tls_cert;
char* tls_key;
char* tls_ca;
/* --timeout: per-message I/O deadline in seconds. 0 (the default/unset
* sentinel) leaves the transport's built-in 30 s socket timeout and the
* protocol's built-in 60 s per-message deadline in place; a positive value
* overrides both. See protocol_session_set_io_timeout. */
/* --timeout: per-message I/O deadline in seconds. 0 (rsync's default)
* disables the deadline entirely on both the socket layer and the protocol
* layer; a positive value sets it. See protocol_session_set_io_timeout and
* tcp_set_timeouts. */
int timeout;
/* --contimeout: connect()/accept timeout, transport layer only. */
/* --contimeout: connect()/accept timeout in seconds (rsync's default 60);
* 0 disables it. Transport layer only. */
int contimeout;
bool quiet;
bool stats;
+40 -11
View File
@@ -913,6 +913,19 @@ int file_open_private_dir(const char* dir_path) {
return fd;
}
/* Open a --temp-dir scratch directory exactly as rsync does: the directory must
* already exist and is used as given (an absolute path is used verbatim, a
* relative one was already resolved against the destination root by the
* caller). Unlike file_open_private_dir this neither creates it nor confines
* it below the receive root, because rsync accepts any temp dir -- including
* one outside the destination tree or on another filesystem. Returns an
* O_DIRECTORY|O_CLOEXEC fd, or -1 on error. */
int file_open_temp_dir(const char* dir_path) {
if (!dir_path)
return -1;
return open(dir_path, O_RDONLY | O_DIRECTORY | O_CLOEXEC);
}
/* After the content and mode/times are restored on the just-written file, apply
* the per-file xattrs (-X/-A) and, for --fake-super, park the source's
* uid/gid/mode/mtime in the reserved xattr. All fd-relative (confined to the
@@ -946,6 +959,10 @@ static bool file_to_disk_secure_impl(const char* path, const void* data,
return false;
int fd = -1;
bool ok = false;
/* Set when a --temp-dir install fails with EXDEV: rsync then falls back to a
* non-atomic write directly in the destination directory (see the tail of
* this function). */
bool cross_device_fallback = false;
/* The base mode applied when --perms is off (neither the source mode nor an
* exec-only change is taken wholesale): a pre-existing destination keeps its
* own mode (special bits dropped), while a brand-new file uses
@@ -1076,10 +1093,11 @@ static bool file_to_disk_secure_impl(const char* path, const void* data,
file is created in the destination directory, exactly as historically. */
int scratch_dirfd = -1;
if (temp_dir) {
scratch_dirfd = file_open_private_dir(temp_dir);
scratch_dirfd = file_open_temp_dir(temp_dir);
if (scratch_dirfd < 0) {
int saved_errno = errno;
log_message(LOG_LEVEL_ERROR, "could not open --temp-dir scratch directory '%s': %s",
log_message(LOG_LEVEL_ERROR,
"--temp-dir '%s' could not be opened (rsync requires it to already exist): %s",
temp_dir, strerror(saved_errno));
close(dirfd);
free(leaf);
@@ -1177,17 +1195,16 @@ static bool file_to_disk_secure_impl(const char* path, const void* data,
errno != ENOENT)
ok = false;
} else {
/* Cross-device (or otherwise impossible) link: rsync falls back to
writing the file directly in the destination directory. Record
it and retry below with no scratch dir. */
if (scratch_dirfd >= 0 && errno == EXDEV)
log_message(LOG_LEVEL_ERROR,
"temp dir is on a different filesystem than the destination; cannot "
"link file into place (EXDEV); no fallback copy is attempted");
cross_device_fallback = true;
ok = false;
}
} else if (renameat(scratch_dirfd >= 0 ? scratch_dirfd : dirfd, tmp, dirfd, leaf) != 0) {
if (scratch_dirfd >= 0 && errno == EXDEV)
log_message(LOG_LEVEL_ERROR,
"temp dir is on a different filesystem than the destination; cannot "
"atomically install file (EXDEV); no fallback copy is attempted");
cross_device_fallback = true;
ok = false;
}
}
@@ -1220,6 +1237,17 @@ static bool file_to_disk_secure_impl(const char* path, const void* data,
close(fd);
close(dirfd);
free(leaf);
if (cross_device_fallback) {
/* rsync semantics: a --temp-dir on another filesystem must not abort the
write. Retry once with no scratch dir so the file is written and
installed non-atomically in the destination directory. */
log_message(LOG_LEVEL_WARNING,
"temp dir is on a different filesystem than the destination; falling back to a "
"non-atomic copy into the destination directory");
return file_to_disk_secure_impl(path, data, data_size, inplace, sparse, preallocate, metadata,
policy, update, no_replace, use_fsync, NULL, xattrs, fake_super,
keep_partial);
}
return ok;
}
@@ -1299,11 +1327,12 @@ static bool file_to_disk_secure_link_impl(const char* path, const char* basis_pa
int scratch_dirfd = -1;
if (temp_dir) {
scratch_dirfd = file_open_private_dir(temp_dir);
scratch_dirfd = file_open_temp_dir(temp_dir);
if (scratch_dirfd < 0) {
int saved_errno = errno;
log_message(LOG_LEVEL_ERROR, "could not open --temp-dir scratch directory '%s': %s", temp_dir,
strerror(saved_errno));
log_message(LOG_LEVEL_ERROR,
"--temp-dir '%s' could not be opened (rsync requires it to already exist): %s",
temp_dir, strerror(saved_errno));
close(dirfd);
free(leaf);
return false;
+14 -10
View File
@@ -86,19 +86,23 @@ bool file_rename_secure(const char* old_path, const char* new_path);
regular file. See the .c for the exact success semantics. */
bool file_remove_tree_secure(const char* path);
/* Open a private 0700 directory (creating it on demand) that must live below
the authorized root. Used for the --temp-dir scratch directory and the
--delay-updates staging directory. */
the authorized root. Used for the --delay-updates staging directory. */
int file_open_private_dir(const char* dir_path);
/* Open an existing --temp-dir scratch directory as-is (absolute or relative;
no creation, no root confinement), matching rsync's --temp-dir handling. */
int file_open_temp_dir(const char* dir_path);
/* The file_to_disk_secure* variants write a temporary copy in the destination
directory and atomically rename it over `path`. temp_dir is an absolute,
root-confined scratch directory (already validated by the caller): when it
is non-NULL the temporary copy is instead created there (with a name unique
across the whole scratch directory) and atomically renamed into the
destination directory once fully written and fsynced. A rename across
filesystems (EXDEV) fails the write with an error; the file is never
silently copied into place. Pass NULL for the historical same-directory
behavior. --inplace writes never use temp_dir. */
directory and atomically rename it over `path`. temp_dir is a scratch
directory (an absolute path, or one the caller already resolved against the
destination root): when it is non-NULL the temporary copy is instead created
there (with a name unique across the whole scratch directory) and atomically
renamed into the destination directory once fully written and fsynced. When
that rename/link fails with EXDEV (the scratch dir is on another filesystem)
the write falls back to a non-atomic copy directly in the destination
directory, matching rsync. Pass NULL for the same-directory behavior.
--inplace writes never use temp_dir. */
bool file_to_disk_secure(const char* path, const void* data, unsigned long long data_size,
bool inplace, bool sparse, bool preallocate, const FileMetadata* metadata,
FileAttrPolicy policy, const char* temp_dir);
+37 -17
View File
@@ -294,13 +294,26 @@ static FileSaveResult file_save_hardlink_sibling(const char* root_directory, con
free(destination_path);
return absent_result;
}
const char* temp_dir = (cfg && cfg->temp_dir) ? cfg->temp_dir : NULL;
/* Resolve a relative --temp-dir against the destination root, exactly as the
* primary save path does; an absolute one is used verbatim. */
char* resolved_temp = NULL;
if (cfg->temp_dir) {
resolved_temp =
cfg->temp_dir[0] == '/' ? str_dup(cfg->temp_dir) : path_cat(root_directory, cfg->temp_dir);
if (!resolved_temp) {
free(content);
free(first_disk);
free(destination_path);
return FILE_SAVE_ERROR;
}
}
FileXattrList* sibling_xattrs =
cfg->use_xattrs ? xattr_capture_path(first_disk, cfg->preserve_acls) : NULL;
bool ok = file_to_disk_secure_link_attrs(destination_path, first_disk, content, content_size,
preallocate, file->metadata, policy, use_fsync,
sibling_xattrs, cfg ? cfg->fake_super : false, temp_dir);
bool ok = file_to_disk_secure_link_attrs(
destination_path, first_disk, content, content_size, preallocate, file->metadata, policy,
use_fsync, sibling_xattrs, cfg ? cfg->fake_super : false, resolved_temp);
xattr_list_free(sibling_xattrs);
free(resolved_temp);
free(content);
free(first_disk);
free(destination_path);
@@ -765,14 +778,15 @@ FileSaveResult file_save_to_disk_full(const char* root_directory, const File* fi
return file_save_hardlink_sibling(root_directory, file, config);
}
/* These options arrive from the client. They are names below the server
root, never independent filesystem roots. --temp-dir is confined exactly
like --backup-dir/--partial-dir: an absolute or `..`-escaping scratch
directory is rejected outright so nothing is ever created outside the
authorized destination root. */
/* These options arrive from the client. --backup-dir and --partial-dir are
names below the server root, never independent filesystem roots: an
absolute or `..`-escaping value is rejected outright. --temp-dir is
deliberately NOT confined: rsync accepts any temp dir (absolute, or
relative to the destination root), including one outside the destination
tree or on another filesystem, and falls back to a non-atomic copy when
the install rename hits EXDEV. */
if ((backup_dir && (backup_dir[0] == '/' || has_path_traversal(backup_dir))) ||
(partial_dir && (partial_dir[0] == '/' || has_path_traversal(partial_dir))) ||
(temp_dir && (temp_dir[0] == '/' || has_path_traversal(temp_dir))))
(partial_dir && (partial_dir[0] == '/' || has_path_traversal(partial_dir))))
return FILE_SAVE_ERROR;
if (backup_dir && !(confined_backup = path_cat(root_directory, backup_dir)))
return FILE_SAVE_ERROR;
@@ -897,15 +911,21 @@ FileSaveResult file_save_to_disk_full(const char* root_directory, const File* fi
}
/* A configured --temp-dir sends the temporary working copy to a scratch
directory resolved below the receive root; the engine then atomically
renames the completed file into the final destination directory. The
partial-dir flow already keeps its working copy in a separate directory
and --inplace writes directly, so neither diverts through the scratch
dir (matching rsync, where --inplace/--partial-dir supersede --temp-dir). */
directory; the engine then atomically renames the completed file into the
final destination directory. rsync resolves a relative temp dir against
the destination directory and uses an absolute one verbatim, requiring
that it already exist; the engine falls back to a non-atomic copy on
EXDEV. The partial-dir flow already keeps its working copy in a separate
directory and --inplace writes directly, so neither diverts through the
scratch dir (matching rsync, where --inplace/--partial-dir supersede
--temp-dir). */
char* confined_temp = NULL;
bool use_temp_dir = temp_dir != NULL && !inplace && !use_partial_root;
if (use_temp_dir) {
confined_temp = path_cat(root_directory, temp_dir);
if (temp_dir[0] == '/')
confined_temp = str_dup(temp_dir);
else
confined_temp = path_cat(root_directory, temp_dir);
if (!confined_temp)
goto fail;
/* A user-supplied trailing slash would leave the scratch path ending in
+18 -10
View File
@@ -143,20 +143,28 @@ bool file_send_sendfile_with_skip(File* file, int file_descriptor, bool use_meta
}
off_t offset = 0;
/* A non-positive --timeout disables the deadline: poll blocks until the
* socket is writable (rsync's --timeout=0 default). */
int io_timeout_sec = protocol_get_io_timeout_sec();
struct timespec deadline;
clock_gettime(CLOCK_MONOTONIC, &deadline);
deadline.tv_sec += protocol_get_io_timeout_sec();
if (io_timeout_sec > 0) {
clock_gettime(CLOCK_MONOTONIC, &deadline);
deadline.tv_sec += io_timeout_sec;
}
while ((unsigned long long)offset < file_size) {
struct timespec now;
clock_gettime(CLOCK_MONOTONIC, &now);
long long remaining = (long long)(deadline.tv_sec - now.tv_sec) * 1000LL +
(deadline.tv_nsec - now.tv_nsec) / 1000000LL;
if (remaining <= 0) {
close(fd);
return false;
int timeout = -1;
if (io_timeout_sec > 0) {
struct timespec now;
clock_gettime(CLOCK_MONOTONIC, &now);
long long remaining = (long long)(deadline.tv_sec - now.tv_sec) * 1000LL +
(deadline.tv_nsec - now.tv_nsec) / 1000000LL;
if (remaining <= 0) {
close(fd);
return false;
}
timeout = remaining > INT_MAX ? INT_MAX : (int)remaining;
}
struct pollfd pfd = {.fd = file_descriptor, .events = POLLOUT};
int timeout = remaining > INT_MAX ? INT_MAX : (int)remaining;
int polled = poll(&pfd, 1, timeout);
if (polled <= 0 || (pfd.revents & (POLLERR | POLLHUP | POLLNVAL))) {
close(fd);
+41 -22
View File
@@ -12,8 +12,7 @@
#include <time.h>
#include <unistd.h>
#define RECEIVE_TIMEOUT_SEC 60 /* 60 second per-message timeout */
#define SEND_TIMEOUT_SEC 60
#define RECEIVE_TIMEOUT_SEC 60 /* built-in fallback for explicit -timed calls only */
static __thread int io_read_fd = -1;
static __thread int io_write_fd = -1;
@@ -99,8 +98,10 @@ void protocol_session_set_io_timeout(ProtocolSession* session, int sec) {
int protocol_get_io_timeout_sec(void) {
const ProtocolSession* session = bound_session ? bound_session : &legacy_io_session;
int sec = session->io_timeout_sec;
return sec > 0 ? sec : RECEIVE_TIMEOUT_SEC;
/* 0 (or negative) means the session timeout is disabled, matching rsync's
* --timeout=0 default. Callers must treat a non-positive result as "wait
* without a deadline" instead of substituting a built-in window. */
return session->io_timeout_sec > 0 ? session->io_timeout_sec : 0;
}
void protocol_session_set_max_alloc(ProtocolSession* session, unsigned long long max_alloc) {
@@ -110,7 +111,8 @@ void protocol_session_set_max_alloc(ProtocolSession* session, unsigned long long
}
static bool allocation_allowed(const ProtocolSession* session, size_t size) {
return (unsigned long long)size <= session->max_alloc;
/* max_alloc == 0 is rsync's --max-alloc=0 "no limit". */
return session->max_alloc == 0 || (unsigned long long)size <= session->max_alloc;
}
static void* protocol_alloc_for_session(const ProtocolSession* session, size_t size) {
@@ -280,11 +282,15 @@ bool protocol_send_n_data(ProtocolSession* session, const void* data, size_t dat
log_debug_message(LOG_DEBUG_IO, " Sending n Data: %zu", data_size);
if (!session)
return false;
int timeout_sec = session->io_timeout_sec > 0 ? session->io_timeout_sec : SEND_TIMEOUT_SEC;
/* A non-positive session timeout disables the deadline entirely (rsync's
* --timeout=0 default); poll then blocks until the socket becomes writable. */
int timeout_sec = session->io_timeout_sec > 0 ? session->io_timeout_sec : 0;
int fd = session->write_fd;
struct timespec deadline;
clock_gettime(CLOCK_MONOTONIC, &deadline);
deadline.tv_sec += timeout_sec;
if (timeout_sec > 0) {
clock_gettime(CLOCK_MONOTONIC, &deadline);
deadline.tv_sec += timeout_sec;
}
short wait_events = POLLOUT;
ssize_t total_bytes_send = 0;
while ((size_t)total_bytes_send < data_size) {
@@ -292,7 +298,7 @@ bool protocol_send_n_data(ProtocolSession* session, const void* data, size_t dat
if (session->bwlimit > 0 && chunk > 65536)
chunk = 65536;
struct pollfd pfd = {.fd = fd, .events = wait_events};
int poll_result = poll(&pfd, 1, deadline_remaining_ms(&deadline));
int poll_result = poll(&pfd, 1, timeout_sec > 0 ? deadline_remaining_ms(&deadline) : -1);
if (poll_result == 0 || (poll_result < 0 && errno != EINTR)) {
log_message(LOG_LEVEL_ERROR, "Send timeout or poll failure");
return false;
@@ -338,12 +344,21 @@ bool protocol_send_n_data(ProtocolSession* session, const void* data, size_t dat
bool protocol_receive_n_data_timed(ProtocolSession* session, void* data, size_t data_size,
int timeout_sec);
static bool protocol_receive_n_data_until(ProtocolSession* session, void* data, size_t data_size,
const struct timespec* deadline);
bool protocol_receive_n_data(ProtocolSession* session, void* data, size_t data_size) {
/* Honor the session's configured deadline; protocol_receive_n_data_timed
* re-applies the built-in 60 s default when the value is <= 0. */
int timeout_sec = session ? session->io_timeout_sec : 0;
return protocol_receive_n_data_timed(session, data, data_size, timeout_sec);
/* Honor the session's configured deadline. A non-positive value disables the
* deadline (rsync's --timeout=0 default): wait without a poll timeout. The
* explicit _timed variants keep their own 0 -> built-in-default contract. */
if (!session)
return false;
if (session->io_timeout_sec <= 0)
return protocol_receive_n_data_until(session, data, data_size, NULL);
struct timespec deadline;
clock_gettime(CLOCK_MONOTONIC, &deadline);
deadline.tv_sec += session->io_timeout_sec;
return protocol_receive_n_data_until(session, data, data_size, &deadline);
}
/* Read exactly `data_size` bytes from `session` before `deadline` elapses
@@ -353,7 +368,7 @@ bool protocol_receive_n_data(ProtocolSession* session, void* data, size_t data_s
static bool protocol_receive_n_data_until(ProtocolSession* session, void* data, size_t data_size,
const struct timespec* deadline) {
log_debug_message(LOG_DEBUG_IO, " Receiving n Data: %zu", data_size);
if (!session || !deadline)
if (!session)
return false;
int fd = session->read_fd;
@@ -362,7 +377,8 @@ static bool protocol_receive_n_data_until(ProtocolSession* session, void* data,
while (total_bytes_received < data_size) {
if (!session->ssl || SSL_pending(session->ssl) == 0) {
struct pollfd pfd = {.fd = fd, .events = wait_events};
int poll_result = poll(&pfd, 1, deadline_remaining_ms(deadline));
/* A NULL deadline means "wait indefinitely" (timeout disabled). */
int poll_result = poll(&pfd, 1, deadline ? deadline_remaining_ms(deadline) : -1);
if (poll_result == 0) {
log_message(LOG_LEVEL_ERROR, "Receive timeout");
return false;
@@ -702,13 +718,16 @@ static bool protocol_capture_error_detail(ProtocolSession* session, Status* stat
bool protocol_receive_status(ProtocolSession* session, Status* status) {
if (!session || !status)
return false;
int timeout_sec = session->io_timeout_sec > 0 ? session->io_timeout_sec : RECEIVE_TIMEOUT_SEC;
struct timespec deadline;
clock_gettime(CLOCK_MONOTONIC, &deadline);
deadline.tv_sec += timeout_sec;
if (!protocol_receive_n_data_until(session, status, sizeof(Status), &deadline))
const struct timespec* deadline_ptr = NULL;
if (session->io_timeout_sec > 0) {
clock_gettime(CLOCK_MONOTONIC, &deadline);
deadline.tv_sec += session->io_timeout_sec;
deadline_ptr = &deadline;
}
if (!protocol_receive_n_data_until(session, status, sizeof(Status), deadline_ptr))
return false;
if (!protocol_capture_error_detail(session, status, &deadline, NULL))
if (!protocol_capture_error_detail(session, status, deadline_ptr, NULL))
return false;
log_debug_message(LOG_DEBUG_PROTO, "Received Status: %s", status_to_string(*status));
return true;
@@ -747,8 +766,8 @@ static bool protocol_read_status_until(ProtocolSession* session, Status* status,
short wait_events = POLLIN;
while (got < sizeof(Status)) {
if (!session->ssl || SSL_pending(session->ssl) == 0) {
int remaining_ms = deadline_remaining_ms(deadline);
if (remaining_ms <= 0) {
int remaining_ms = deadline ? deadline_remaining_ms(deadline) : -1;
if (remaining_ms == 0) {
log_message(LOG_LEVEL_ERROR, "Receive timeout while reading status");
return false;
}
+181 -21
View File
@@ -44,6 +44,181 @@ static bool parse_two_digits(const char* s, int* out) {
return true;
}
/* True when the current character of the cursor is a decimal digit. */
static bool is_digit(const char* cp) {
return *cp >= '0' && *cp <= '9';
}
/* rsync 3.4.1's flexible --stop-at date parser (ported from
* options.c:parse_time). Returns a time_t, or (time_t)-1 on a malformed value.
* Accepted forms include Y-M-DTh:m, Y/M/DTh:m, Y-M-D, M-D, D, h:m, :m and
* "T h:m"; a 1- or 2-digit year and omitted fields are resolved to the next
* matching point in time in the local timezone. Seconds are NOT accepted
* (rsync rejects them too); FastSync keeps its own HH:MM:SS spelling as an
* extension handled by the caller. `now` is passed in so tests are
* deterministic; production passes time(NULL). */
static time_t parse_time_rsync(const char* value, time_t now) {
const char* cp;
time_t val;
struct tm today;
if (!localtime_r(&now, &today))
return (time_t)-1;
struct tm t;
int in_date, old_mday, n;
memset(&t, 0, sizeof t);
t.tm_year = t.tm_mon = t.tm_mday = -1;
t.tm_hour = t.tm_min = t.tm_isdst = -1;
cp = value;
if (*cp == 'T' || *cp == 't' || *cp == ':') {
in_date = *cp == ':' ? 0 : -1;
cp++;
} else
in_date = 1;
for (;; cp++) {
if (!is_digit(cp))
return (time_t)-1;
n = 0;
do {
n = n * 10 + *cp++ - '0';
} while (is_digit(cp));
if (*cp == ':')
in_date = 0;
if (in_date > 0) {
if (t.tm_year != -1)
return (time_t)-1;
t.tm_year = t.tm_mon;
t.tm_mon = t.tm_mday;
t.tm_mday = n;
if (!*cp)
break;
if (*cp == 'T' || *cp == 't') {
if (!cp[1])
break;
in_date = -1;
} else if (*cp != '-' && *cp != '/')
return (time_t)-1;
continue;
}
if (t.tm_hour != -1)
return (time_t)-1;
t.tm_hour = t.tm_min;
t.tm_min = n;
if (!*cp) {
if (in_date < 0)
return (time_t)-1;
break;
}
if (*cp != ':')
return (time_t)-1;
in_date = 0;
}
in_date = 0;
if (t.tm_year < 0) {
t.tm_year = today.tm_year;
in_date = 1;
} else if (t.tm_year < 100) {
while (t.tm_year < today.tm_year)
t.tm_year += 100;
} else
t.tm_year -= 1900;
if (t.tm_mon < 0) {
t.tm_mon = today.tm_mon;
in_date = 2;
} else
t.tm_mon--;
if (t.tm_mday < 0) {
t.tm_mday = today.tm_mday;
in_date = 3;
}
n = 0;
if (t.tm_min < 0) {
t.tm_hour = t.tm_min = 0;
} else if (t.tm_hour < 0) {
if (in_date != 3)
return (time_t)-1;
in_date = 0;
t.tm_hour = today.tm_hour;
n = 60 * 60;
}
/* mktime() may roll a too-large tm_mday into the following month; undo that
* in the "next match" loop below. */
old_mday = t.tm_mday;
if (t.tm_hour > 23 || t.tm_min > 59 || t.tm_mon < 0 || t.tm_mon >= 12 || t.tm_mday < 1 ||
t.tm_mday > 31 || (val = mktime(&t)) == (time_t)-1)
return (time_t)-1;
while (in_date && (val <= now || t.tm_mday < old_mday)) {
switch (in_date) {
case 3:
old_mday = ++t.tm_mday;
break;
case 2:
if (t.tm_mday < old_mday)
t.tm_mday = old_mday; /* the month already got bumped forward */
else if (++t.tm_mon == 12) {
t.tm_mon = 0;
t.tm_year++;
}
break;
case 1:
if (t.tm_mday < old_mday) {
/* mon==1 mday==29 got bumped to mon==2 */
if (t.tm_mon != 2 || old_mday != 29)
return (time_t)-1;
t.tm_mon = 1;
t.tm_mday = 29;
}
t.tm_year++;
break;
}
if ((val = mktime(&t)) == (time_t)-1) {
if (in_date != 3 || t.tm_mday <= 28)
return (time_t)-1;
t.tm_mday = old_mday = 1;
in_date = 2;
}
}
if (n) {
while (val <= now)
val += n;
}
return val;
}
/* FastSync's HH:MM or HH:MM:SS spelling on the current local day. rsync's own
* --stop-at accepts only HH:MM, so this is a strict superset extension. */
static bool parse_clock_time(const char* value, time_t now, time_t* out_deadline) {
size_t len = strlen(value);
if (len != 5 && len != 8)
return false;
if (value[2] != ':' || (len == 8 && value[5] != ':'))
return false;
int hh, mm, ss = 0;
if (!parse_two_digits(value, &hh) || !parse_two_digits(value + 3, &mm))
return false;
if (len == 8 && !parse_two_digits(value + 6, &ss))
return false;
if (hh > 23 || mm > 59 || ss > 59)
return false;
struct tm today;
if (!localtime_r(&now, &today))
return false;
today.tm_hour = hh;
today.tm_min = mm;
today.tm_sec = ss;
today.tm_isdst = -1;
time_t deadline = mktime(&today);
if (deadline == (time_t)-1)
return false;
*out_deadline = deadline;
return true;
}
bool stop_parse_at_time(const char* value, time_t now, time_t* out_deadline) {
if (!value || !out_deadline)
return false;
@@ -91,28 +266,13 @@ bool stop_parse_at_time(const char* value, time_t now, time_t* out_deadline) {
return true;
}
/* HH:MM or HH:MM:SS on the current local day. */
size_t len = strlen(value);
if (len != 5 && len != 8)
return false;
if (value[2] != ':' || (len == 8 && value[5] != ':'))
return false;
int hh, mm, ss = 0;
if (!parse_two_digits(value, &hh) || !parse_two_digits(value + 3, &mm))
return false;
if (len == 8 && !parse_two_digits(value + 6, &ss))
return false;
if (hh > 23 || mm > 59 || ss > 59)
return false;
/* HH:MM or HH:MM:SS on the current local day (FastSync extension). */
if (parse_clock_time(value, now, out_deadline))
return true;
struct tm today;
if (!localtime_r(&now, &today))
return false;
today.tm_hour = hh;
today.tm_min = mm;
today.tm_sec = ss;
today.tm_isdst = -1;
time_t deadline = mktime(&today);
/* rsync's full/partial date-and-time form (e.g. 2000-12-31T23:59, 12-31,
* 14:00, :59, 1, 1-30). */
time_t deadline = parse_time_rsync(value, now);
if (deadline == (time_t)-1)
return false;
*out_deadline = deadline;
+19 -11
View File
@@ -289,14 +289,14 @@ void server_accept_loop(Server* server, void (*child_fn)(int, void*), void* chil
accept_loop(server, child_fn, child_ctx, log_fmt);
}
static int g_timeout_sec = 30;
static int g_contimeout_sec = 10;
/* rsync defaults: --timeout=0 (disabled) and --contimeout=60. A non-positive
* value means "no timeout" rather than "leave the built-in value in place". */
static int g_timeout_sec = 0;
static int g_contimeout_sec = 60;
void tcp_set_timeouts(int timeout_sec, int contimeout_sec) {
if (timeout_sec > 0)
g_timeout_sec = timeout_sec;
if (contimeout_sec > 0)
g_contimeout_sec = contimeout_sec;
g_timeout_sec = timeout_sec > 0 ? timeout_sec : 0;
g_contimeout_sec = contimeout_sec > 0 ? contimeout_sec : 0;
}
int tcp_get_contimeout_sec(void) {
@@ -308,6 +308,10 @@ int tcp_get_timeout_sec(void) {
}
static void tcp_apply_socket_timeout(int fd) {
/* timeout 0 means no timeout: leave the socket in its default (blocking)
* mode instead of installing a zero SO_RCVTIMEO/SO_SNDTIMEO. */
if (g_timeout_sec <= 0)
return;
struct timeval tv;
tv.tv_sec = g_timeout_sec;
tv.tv_usec = 0;
@@ -483,11 +487,15 @@ bool tcp_connect_socket_ex(Client* client, const char* host, int port,
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));
/* --contimeout=0 disables the connect timeout: skip the pre-connect socket
* timeouts entirely. */
if (g_contimeout_sec > 0) {
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) {
+209 -17
View File
@@ -1300,7 +1300,56 @@ class TestChecksumChoice:
)
assert result.returncode != 0, "sha256 must be rejected, not silently ignored"
@pytest.mark.parametrize("algo", ["xxh64", "md5"])
@pytest.mark.ci
def test_checksum_alone_skips_unchanged(self, shared_server):
"""-c alone (no explicit --incremental) must switch the quick-check to a
content digest: an unchanged file whose mtime differs is skipped."""
source = os.path.join(TEST_DATA_DIR, "checksum_alone_src")
dest = os.path.join(TEST_DATA_DIR, "checksum_alone_dst")
clean_dir(source)
clean_dir(dest)
with open(os.path.join(source, "f.txt"), "wb") as fh:
fh.write(b"same content\n")
result, _ = run_client(source, dest, port=shared_server.port)
assert result.returncode == 0, result.stderr[:200]
received = os.path.join(get_dest_received_dir(dest, source), "f.txt")
assert os.path.exists(received)
# Make the destination mtime differ without changing the bytes.
bumped = os.stat(received).st_mtime + 100
os.utime(received, (bumped, bumped))
result, _ = run_client(source, dest, flags=["-c"], port=shared_server.port)
assert result.returncode == 0, result.stderr[:200]
# A skip leaves our bumped mtime in place; a transfer would rewrite it.
assert os.stat(received).st_mtime == pytest.approx(bumped), \
"-c did not skip an unchanged file"
# A same-size, same-mtime content change is still detected.
with open(received, "wb") as fh:
fh.write(b"DIFF content\n")
os.utime(received, (bumped, bumped))
result, _ = run_client(source, dest, flags=["-c"], port=shared_server.port)
assert result.returncode == 0, result.stderr[:200]
with open(received, "rb") as fh:
assert fh.read() == b"same content\n"
@pytest.mark.ci
def test_checksum_choice_md4_single_name_rejected(self, shared_server):
for bad in ("md4", "sha1", "none", "xxh64,md5"):
result, _ = run_client(SOURCE_DIR, DEST_DIR,
flags=[f"--checksum-choice={bad}"],
port=shared_server.port)
assert result.returncode != 0, f"{bad} must be rejected"
@pytest.mark.ci
def test_compress_choice_unsupported_rejected(self, shared_server):
for bad in ("lz4", "zlib", "zlibx"):
result, _ = run_client(SOURCE_DIR, DEST_DIR,
flags=[f"--compress-choice={bad}"],
port=shared_server.port)
assert result.returncode != 0, f"{bad} must be rejected"
@pytest.mark.parametrize("algo", ["xxh64", "xxh3", "xxh128", "md5"])
@pytest.mark.parametrize("mt", [False, True])
def test_unchanged_skipped_and_bytes_preserved(self, shared_server, algo, mt):
clean_dir(DEST_DIR)
@@ -1321,7 +1370,7 @@ class TestChecksumChoice:
# detected (and re-transferred byte-exactly) because the whole-file digest
# differs -- the explicit reason --checksum exists. This exercises the
# sender/receiver digest agreement for a non-default algorithm.
@pytest.mark.parametrize("algo", ["xxh64", "md5"])
@pytest.mark.parametrize("algo", ["xxh64", "xxh3", "xxh128", "md5"])
@pytest.mark.parametrize("mt", [False, True])
def test_changed_same_size_mtime_redetected(self, shared_server, algo, mt):
clean_dir(DEST_DIR)
@@ -2066,6 +2115,8 @@ class TestTempDir:
source = self._make_source("tempdir_src")
dest = os.path.join(TEST_DATA_DIR, "tempdir_dst")
clean_dir(dest)
# rsync requires the temp dir to already exist (it is not created).
os.makedirs(os.path.join(dest, "scratch"), exist_ok=True)
flags = ["--temp-dir=scratch"] + (["--threads"] if mt else [])
result, _ = run_client(source, dest, flags=flags, port=shared_server.port)
assert result.returncode == 0, f"temp-dir sync failed: {result.stderr[:200]}"
@@ -2125,26 +2176,167 @@ class TestTempDir:
assert not os.path.exists(os.path.join(dest, "scratch")), \
"--partial-dir wrote through the scratch dir"
def test_temp_dir_escape_rejected(self, shared_server):
source = self._make_source("tempdir_escape_src")
dest = os.path.join(TEST_DATA_DIR, "tempdir_escape_dst")
def test_temp_dir_must_exist(self, shared_server):
"""rsync does not create the temp dir; a missing one is a clear error."""
source = self._make_source("tempdir_missing_src")
dest = os.path.join(TEST_DATA_DIR, "tempdir_missing_dst")
clean_dir(dest)
# "../escape" would resolve one level above the destination root.
outside = os.path.join(TEST_DATA_DIR, "escape")
assert not os.path.lexists(outside)
result, _ = run_client(source, dest, flags=["--temp-dir=../escape"],
missing_rel = os.path.join(dest, "no_such_scratch")
assert not os.path.lexists(missing_rel)
result, _ = run_client(source, dest, flags=["--temp-dir=no_such_scratch"],
port=shared_server.port)
assert result.returncode != 0, "relative escaping --temp-dir was not rejected"
assert not os.path.lexists(outside), "file created outside the destination root"
assert result.returncode != 0, "a missing relative --temp-dir must fail"
missing_abs = os.path.join(TEST_DATA_DIR, "no_such_abs_scratch")
assert not os.path.lexists(missing_abs)
clean_dir(dest)
abs_escape = os.path.join(TEST_DATA_DIR, "abs_escape_probe")
assert not os.path.lexists(abs_escape)
result, _ = run_client(source, dest, flags=["--temp-dir", abs_escape],
result, _ = run_client(source, dest, flags=["--temp-dir", missing_abs],
port=shared_server.port)
assert result.returncode != 0, "absolute --temp-dir was not rejected"
assert not os.path.lexists(abs_escape), "file created outside the destination root"
assert result.returncode != 0, "a missing absolute --temp-dir must fail"
def test_temp_dir_absolute_outside_root_is_used(self, shared_server):
"""rsync accepts any temp dir, including one outside the destination
tree; the completed files are still installed below the root and no
temp files remain in the scratch dir."""
source = self._make_source("tempdir_abs_src")
dest = os.path.join(TEST_DATA_DIR, "tempdir_abs_dst")
clean_dir(dest)
scratch = os.path.join(TEST_DATA_DIR, "tempdir_abs_scratch")
shutil.rmtree(scratch, ignore_errors=True)
os.makedirs(scratch)
result, _ = run_client(source, dest, flags=["--temp-dir", scratch],
port=shared_server.port)
assert result.returncode == 0, f"absolute temp-dir sync failed: {result.stderr[:200]}"
received = get_dest_received_dir(dest, source)
mismatches, missing = verify_transfer(source, received)
assert not missing, f"Missing: {missing}"
assert not mismatches, f"Mismatch: {mismatches}"
self._assert_clean_scratch(scratch)
shutil.rmtree(scratch, ignore_errors=True)
class TestTimeoutAndAllocLimits:
"""#295: rsync defaults --timeout=0 (disabled), --contimeout=60, and
--max-alloc=0 (no limit); 0 must be accepted for all three."""
def _seed(self, name):
source = os.path.join(TEST_DATA_DIR, name)
dest = os.path.join(TEST_DATA_DIR, name + "_dst")
clean_dir(source)
clean_dir(dest)
with open(os.path.join(source, "f.txt"), "wb") as fh:
fh.write(b"payload\n" * 100)
return source, dest
@pytest.mark.ci
def test_timeout_zero_disables_and_transfers(self, shared_server):
source, dest = self._seed("timeout_zero_src")
result, _ = run_client(source, dest, flags=["--timeout=0", "--contimeout=0"],
port=shared_server.port)
assert result.returncode == 0, result.stderr[:200]
received = get_dest_received_dir(dest, source)
mismatches, missing = verify_transfer(source, received)
assert not missing and not mismatches
@pytest.mark.ci
def test_no_timeout_forms(self, shared_server):
source, dest = self._seed("timeout_no_src")
result, _ = run_client(source, dest, flags=["--timeout=30", "--no-timeout",
"--no-contimeout"],
port=shared_server.port)
assert result.returncode == 0, result.stderr[:200]
@pytest.mark.ci
def test_max_alloc_zero_means_no_limit(self, shared_server):
source, dest = self._seed("max_alloc_zero_src")
result, _ = run_client(source, dest, flags=["--max-alloc=0"], port=shared_server.port)
assert result.returncode == 0, result.stderr[:200]
received = get_dest_received_dir(dest, source)
mismatches, missing = verify_transfer(source, received)
assert not missing and not mismatches
def test_temp_dir_cross_filesystem_fallback(self, shared_server):
"""A --temp-dir on another filesystem must fall back to a non-atomic
copy instead of aborting (rsync parity). Skipped when no second
filesystem is available."""
shm = "/dev/shm"
if not os.path.isdir(shm):
pytest.skip("/dev/shm not available")
if os.stat(shm).st_dev == os.stat(TEST_DATA_DIR).st_dev:
pytest.skip("/dev/shm is on the same filesystem as the test data")
scratch = os.path.join(shm, f"fastsync_tmp_{os.getpid()}")
shutil.rmtree(scratch, ignore_errors=True)
os.makedirs(scratch)
try:
source, dest = self._seed("tempdir_xdev_src")
result, _ = run_client(source, dest, flags=["--temp-dir", scratch],
port=shared_server.port)
assert result.returncode == 0, f"cross-fs temp-dir failed: {result.stderr[:300]}"
received = get_dest_received_dir(dest, source)
mismatches, missing = verify_transfer(source, received)
assert not missing, f"Missing: {missing}"
assert not mismatches, f"Mismatch: {mismatches}"
assert os.listdir(scratch) == [], "temp files left behind"
finally:
shutil.rmtree(scratch, ignore_errors=True)
class TestRemoteOptionTransport:
"""#296: -M/--remote-option is SSH-only; a daemon/TCP destination rejects it
instead of silently ignoring it."""
@pytest.mark.ci
def test_remote_option_rejected_for_tcp(self, shared_server):
for flag in ("--remote-option=--allow-delete", "-M--allow-delete", "-M=--allow-delete"):
result, _ = run_client(SOURCE_DIR, DEST_DIR, flags=[flag],
port=shared_server.port)
assert result.returncode != 0, f"{flag} must be rejected for a TCP destination"
assert "remote-option" in (result.stderr + result.stdout), \
f"{flag}: error must name --remote-option"
class TestTrustSenderServerPath:
"""--trust-sender is a receiver-local policy: only the receiving SERVER's
own flag matters. For a push, a client --trust-sender is never sent to the
peer, so it must not relax a server that did not opt in; a server started
with --trust-sender must copy an escaping symlink target verbatim (its
normal mode skips it while still confining the link itself)."""
def _make_source(self, name):
source = os.path.join(TEST_DATA_DIR, name)
clean_dir(source)
with open(os.path.join(source, "file.txt"), "wb") as fh:
fh.write(b"content\n")
os.symlink("/etc/passwd", os.path.join(source, "escape_link"))
return source
def _run_with_server(self, extra_args, flags, tag):
server = ServerManager()
server.start(extra_args=extra_args)
try:
source = self._make_source(f"trust_sender_src_{tag}")
dest = os.path.join(TEST_DATA_DIR, f"trust_sender_dst_{tag}")
clean_dir(dest)
result, _ = run_client(source, dest, flags=["-l"] + flags, port=server.port)
link = os.path.join(get_dest_received_dir(dest, source), "escape_link")
return result, link
finally:
server.stop()
@pytest.mark.ci
def test_client_flag_does_not_relax_server(self):
result, link = self._run_with_server([], ["--trust-sender"], "client")
assert result.returncode == 0, result.stderr[:200]
assert not os.path.lexists(link), \
"a client --trust-sender must not relax a server that did not opt in"
@pytest.mark.ci
def test_server_flag_materializes_escaping_symlink(self):
result, link = self._run_with_server(["--trust-sender"], [], "server")
assert result.returncode == 0, result.stderr[:200]
assert os.path.islink(link), "server --trust-sender should materialize the symlink"
assert os.readlink(link) == "/etc/passwd"
def _source_files():
+25 -2
View File
@@ -150,13 +150,36 @@ class TestStopAt:
assert _received_files(received) == [], \
f"expected nothing transferred, got {_received_files(received)}"
@pytest.mark.ci
def test_stop_at_rsync_date_form(self, shared_server):
"""rsync's full date form (Y-M-DTh:m) is accepted; a deadline well in the
future lets the transfer complete normally."""
source, dest = _make("dateform")
_seed_source(source)
stamp = time.strftime("%Y-%m-%dT%H:%M", time.localtime(time.time() + 3600))
result, _ = run_client(source, dest, flags=[f"--stop-at={stamp}"],
port=shared_server.port)
assert result.returncode == 0, \
f"--stop-at={stamp} should be accepted: " \
f"{(result.stderr or result.stdout)[:400]}"
received = get_dest_received_dir(dest, source)
mismatches, missing = verify_transfer(source, received)
assert not mismatches and not missing
# The slash-separated date spelling is accepted too.
slash = time.strftime("%Y/%m/%dT%H:%M", time.localtime(time.time() + 3600))
result, _ = run_client(source, dest, flags=[f"--stop-at={slash}"],
port=shared_server.port)
assert result.returncode == 0, f"--stop-at={slash} should be accepted"
@pytest.mark.ci
def test_stop_rejects_garbage(self, shared_server):
"""Malformed --stop-at/--stop-after values are rejected up front."""
source, dest = _make("garbage")
_seed_source(source)
for flag in ("--stop-after=abc", "--stop-at=12:99", "--stop-at=12",
"--stop-at=now+5x", "--stop-at=now-5s"):
for flag in ("--stop-after=abc", "--stop-at=12:99", "--stop-at=1234",
"--stop-at=now+5x", "--stop-at=now-5s",
"--stop-at=2000-13-45", "--stop-at=2030-12-31T23:59:59"):
result, _ = run_client(source, dest, flags=[flag],
port=shared_server.port)
assert result.returncode != 0, f"{flag} should be rejected"
+31 -1
View File
@@ -98,18 +98,47 @@ static void test_checksum_algo_name_mapping() {
EXPECT_EQ_INT(checksum_algo_from_name("XXHASH"), (int)CHECKSUM_ALGO_XXH64);
EXPECT_EQ_INT(checksum_algo_from_name("md5"), (int)CHECKSUM_ALGO_MD5);
EXPECT_EQ_INT(checksum_algo_from_name("MD5"), (int)CHECKSUM_ALGO_MD5);
EXPECT_EQ_INT(checksum_algo_from_name("xxh3"), (int)CHECKSUM_ALGO_XXH3);
EXPECT_EQ_INT(checksum_algo_from_name("XXH3"), (int)CHECKSUM_ALGO_XXH3);
EXPECT_EQ_INT(checksum_algo_from_name("xxh128"), (int)CHECKSUM_ALGO_XXH128);
EXPECT_EQ_INT(checksum_algo_from_name("XXH128"), (int)CHECKSUM_ALGO_XXH128);
/* rsync choices FastSync does not implement are rejected by name. */
EXPECT_TRUE(checksum_algo_from_name("md4") < 0);
EXPECT_TRUE(checksum_algo_from_name("sha1") < 0);
EXPECT_TRUE(checksum_algo_from_name("sha256") < 0);
EXPECT_TRUE(checksum_algo_from_name("crc32") < 0);
EXPECT_TRUE(checksum_algo_from_name("none") < 0);
EXPECT_TRUE(checksum_algo_from_name("xxh3") < 0);
EXPECT_TRUE(checksum_algo_from_name("") < 0);
EXPECT_TRUE(checksum_algo_from_name(NULL) < 0);
EXPECT_TRUE(checksum_algo_valid((int)CHECKSUM_ALGO_XXH64));
EXPECT_TRUE(checksum_algo_valid((int)CHECKSUM_ALGO_MD5));
EXPECT_TRUE(checksum_algo_valid((int)CHECKSUM_ALGO_XXH3));
EXPECT_TRUE(checksum_algo_valid((int)CHECKSUM_ALGO_XXH128));
EXPECT_FALSE(checksum_algo_valid(99));
EXPECT_EQ_STR(checksum_algo_name(CHECKSUM_ALGO_XXH64), "xxh64");
EXPECT_EQ_STR(checksum_algo_name(CHECKSUM_ALGO_MD5), "md5");
EXPECT_EQ_STR(checksum_algo_name(CHECKSUM_ALGO_XXH3), "xxh3");
EXPECT_EQ_STR(checksum_algo_name(CHECKSUM_ALGO_XXH128), "xxh128");
}
/* xxh3 is 8 bytes and seed-aware; xxh128 is 16 bytes and differs from both
* xxh64 and md5 for the same input. */
static void test_checksum_xxh3_xxh128() {
EXPECT_EQ_INT((int)checksum_digest_len(CHECKSUM_ALGO_XXH3), 8);
EXPECT_EQ_INT((int)checksum_digest_len(CHECKSUM_ALGO_XXH128), 16);
uint8_t a[CHECKSUM_MAX_DIGEST_LEN], b[CHECKSUM_MAX_DIGEST_LEN];
size_t alen = 0, blen = 0;
EXPECT_TRUE(checksum_digest(CHECKSUM_ALGO_XXH3, 0, "payload", 7, a, sizeof(a), &alen));
EXPECT_TRUE(alen == (size_t)8);
EXPECT_TRUE(checksum_digest(CHECKSUM_ALGO_XXH3, 5, "payload", 7, b, sizeof(b), &blen));
EXPECT_TRUE(memcmp(a, b, alen) != 0);
EXPECT_TRUE(checksum_digest(CHECKSUM_ALGO_XXH128, 0, "payload", 7, a, sizeof(a), &alen));
EXPECT_TRUE(alen == (size_t)16);
EXPECT_TRUE(checksum_digest(CHECKSUM_ALGO_XXH128, 0, "payload", 7, b, sizeof(b), &blen));
EXPECT_TRUE(memcmp(a, b, blen) == 0);
}
static void test_checksum_truncated_buffer_rejected() {
@@ -142,6 +171,7 @@ void test_checksum(void) {
test_checksum_algo_lengths_distinct();
test_checksum_md5_seed_ignored();
test_checksum_algo_name_mapping();
test_checksum_xxh3_xxh128();
test_checksum_truncated_buffer_rejected();
test_checksum_null_empty_digest();
}
+136 -8
View File
@@ -801,8 +801,11 @@ static void test_parse_args_rejects_invalid_modify_window() {
}
static void test_parse_args_max_alloc_sizes() {
const char* values[] = {"1", "4K", "2m", "3G", "1T", "1P", "1E", "512B"};
const unsigned long long expected[] = {1,
/* "0" is rsync's "no alloc limit" sentinel: it must parse to 0, not be
* rejected. */
const char* values[] = {"0", "1", "4K", "2m", "3G", "1T", "1P", "1E", "512B"};
const unsigned long long expected[] = {0,
1,
4ULL * 1024,
2ULL * 1024 * 1024,
3ULL * 1024 * 1024 * 1024,
@@ -830,8 +833,8 @@ static void test_parse_args_max_alloc_sizes() {
}
static void test_parse_args_rejects_invalid_max_alloc() {
const char* values[] = {"0", "-1", "+1", " 1", "1 ",
"1Z", "1K2", "1 K", "1\tK", "18446744073709551615K"};
const char* values[] = {
"-1", "+1", " 1", "1 ", "1Z", "1K2", "1 K", "1\tK", "18446744073709551615K"};
for (size_t i = 0; i < sizeof(values) / sizeof(values[0]); i++) {
Config* cfg = config_create();
char* argv[] = {"fastsync", "--max-alloc", (char*)values[i], "/src", "/dst"};
@@ -1413,7 +1416,8 @@ static void test_parse_args_checksum_choice_equals_forms() {
/* An algorithm FastSync does not support must be rejected, never a silent
no-op. */
static void test_parse_args_checksum_choice_rejects_unsupported() {
static const char* const bad[] = {"md4", "sha256", "crc32", "none", "bogus"};
static const char* const bad[] = {"md4", "sha1", "sha256", "crc32",
"none", "bogus", "xxh64,md5", "xxhash:md5"};
for (size_t i = 0; i < sizeof(bad) / sizeof(bad[0]); i++) {
Config* cfg = config_create();
char* argv[] = {"fastsync", "--checksum-choice", (char*)bad[i], "/src", "/dst"};
@@ -1424,6 +1428,123 @@ static void test_parse_args_checksum_choice_rejects_unsupported() {
}
}
/* xxh3/xxh128 are accepted; "auto" keeps the default algorithm. */
static void test_parse_args_checksum_choice_new_algos() {
Config* cfg = config_create();
char* argv[] = {"fastsync", "--checksum-choice=xxh3", "/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->checksum_algo, (int)CHECKSUM_ALGO_XXH3);
config_delete(cfg);
cfg = config_create();
char* argv2[] = {"fastsync", "--cc=xxh128", "/src", "/dst"};
positional_count = 0;
EXPECT_EQ_INT(parse_args(cfg, 4, argv2, positional_args, &positional_count), 0);
EXPECT_EQ_INT(cfg->checksum_algo, (int)CHECKSUM_ALGO_XXH128);
config_delete(cfg);
cfg = config_create();
char* argv3[] = {"fastsync", "--checksum-choice=auto", "/src", "/dst"};
positional_count = 0;
EXPECT_EQ_INT(parse_args(cfg, 4, argv3, positional_args, &positional_count), 0);
EXPECT_EQ_INT(cfg->checksum_algo, (int)CHECKSUM_ALGO_XXH64);
config_delete(cfg);
}
/* -c/--checksum must run the per-file content-check handshake (FastSync's
* --incremental), but unlike --incremental it must NOT auto-preserve -t/-p. */
static void test_parse_args_checksum_implies_incremental_only() {
Config* cfg = config_create();
char* argv[] = {"fastsync", "-c", "/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->checksum);
EXPECT_TRUE(cfg->use_incremental);
EXPECT_FALSE(cfg->preserve_times);
EXPECT_FALSE(cfg->preserve_perms);
config_delete(cfg);
}
/* rsync's --no-whole-file spelling clears -W. */
static void test_parse_args_no_whole_file() {
Config* cfg = config_create();
char* argv[] = {"fastsync", "-W", "--no-whole-file", "/src", "/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->whole_file);
config_delete(cfg);
}
/* --timeout/--contimeout accept 0 (rsync default: disabled) and the
* --no-timeout/--no-contimeout spellings clear them. */
static void test_parse_args_timeout_zero_and_no_forms() {
Config* cfg = config_create();
char* argv[] = {"fastsync", "--timeout=0", "--contimeout=0", "/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_INT(cfg->timeout, 0);
EXPECT_EQ_INT(cfg->contimeout, 0);
config_delete(cfg);
cfg = config_create();
char* argv2[] = {"fastsync", "--timeout", "45", "--contimeout", "90", "/src", "/dst"};
positional_count = 0;
EXPECT_EQ_INT(parse_args(cfg, 6, argv2, positional_args, &positional_count), 0);
EXPECT_EQ_INT(cfg->timeout, 45);
EXPECT_EQ_INT(cfg->contimeout, 90);
config_delete(cfg);
cfg = config_create();
char* argv3[] = {"fastsync", "--timeout=30", "--no-timeout", "--no-contimeout", "/src", "/dst"};
positional_count = 0;
EXPECT_EQ_INT(parse_args(cfg, 6, argv3, positional_args, &positional_count), 0);
EXPECT_EQ_INT(cfg->timeout, 0);
EXPECT_EQ_INT(cfg->contimeout, 0);
config_delete(cfg);
}
/* rsync's --compress-choice choices FastSync does not implement are rejected by
* name; zstd/none/auto are accepted. */
static void test_parse_args_compress_choice_parity() {
static const char* const good[] = {"zstd", "none", "auto"};
for (size_t i = 0; i < sizeof(good) / sizeof(good[0]); i++) {
Config* cfg = config_create();
char* argv[] = {"fastsync", "--compress-choice", (char*)good[i], "/src", "/dst"};
int positional_args[2];
int positional_count = 0;
EXPECT_EQ_INT(parse_args(cfg, 5, argv, positional_args, &positional_count), 0);
config_delete(cfg);
}
static const char* const bad[] = {"lz4", "zlib", "zlibx", "bogus"};
for (size_t i = 0; i < sizeof(bad) / sizeof(bad[0]); i++) {
Config* cfg = config_create();
char* argv[] = {"fastsync", "--compress-choice", (char*)bad[i], "/src", "/dst"};
int positional_args[2];
int positional_count = 0;
EXPECT_EQ_INT(parse_args(cfg, 5, argv, positional_args, &positional_count), -1);
config_delete(cfg);
}
}
/* -M/--remote-option is SSH-only: a daemon or local TCP destination must reject
* it instead of silently ignoring it. */
static void test_validate_config_remote_option_requires_ssh() {
Config* cfg = valid_client_config();
cfg->remote_options = malloc(sizeof(char*));
cfg->remote_options[0] = str_dup("--allow-delete");
cfg->remote_option_count = 1;
cfg->transport = TRANSPORT_TCP;
EXPECT_FALSE(validate_config(cfg));
cfg->transport = TRANSPORT_SSH;
EXPECT_TRUE(validate_config(cfg));
config_delete(cfg);
}
/* --checksum-seed parses as a 64-bit non-negative integer (space and = forms);
invalid values are rejected. */
static void test_parse_args_checksum_seed() {
@@ -1442,12 +1563,13 @@ static void test_parse_args_checksum_seed() {
EXPECT_TRUE(cfg->checksum_seed == 12345ULL);
config_delete(cfg);
/* 0 is a valid (and default) seed. */
/* An explicit seed of 0 is randomized per transfer (rsync behavior), so the
* parsed config must come back non-zero. */
cfg = config_create();
char* argv3[] = {"fastsync", "--checksum-seed=0", "/src", "/dst"};
positional_count = 0;
EXPECT_EQ_INT(parse_args(cfg, 4, argv3, positional_args, &positional_count), 0);
EXPECT_TRUE(cfg->checksum_seed == 0ULL);
EXPECT_TRUE(cfg->checksum_seed != 0ULL);
config_delete(cfg);
/* Non-numeric and negative seeds are rejected. */
@@ -1469,7 +1591,7 @@ static void test_parse_args_checksum_seed() {
}
static void test_parse_args_rejects_unsafe_negation() {
static const char* const options[] = {"--no-archive", "--no-timeout", "--no-unknown"};
static const char* const options[] = {"--no-archive", "--no-unknown"};
for (size_t i = 0; i < sizeof(options) / sizeof(options[0]); i++) {
Config* cfg = config_create();
char* argv[] = {"fastsync", (char*)options[i], "/src", "/dst"};
@@ -3973,6 +4095,12 @@ void test_client_cli() {
test_parse_args_checksum_choice_requires_value();
test_parse_args_checksum_choice_equals_forms();
test_parse_args_checksum_choice_rejects_unsupported();
test_parse_args_checksum_choice_new_algos();
test_parse_args_checksum_implies_incremental_only();
test_parse_args_no_whole_file();
test_parse_args_timeout_zero_and_no_forms();
test_parse_args_compress_choice_parity();
test_validate_config_remote_option_requires_ssh();
test_parse_args_checksum_seed();
test_parse_args_temp_dir();
test_parse_args_delay_updates();
+15
View File
@@ -64,6 +64,21 @@ static void test_skip_compress_suffix_matching() {
EXPECT_TRUE(compression_should_skip_with_suffixes("backup.TAR.GZ", suffixes, 2));
EXPECT_FALSE(compression_should_skip_with_suffixes("notes.txt", suffixes, 2));
EXPECT_FALSE(compression_should_skip_with_suffixes("archive.zip", suffixes, 0));
/* A user suffix may omit the leading dot (rsync's spelling). */
char* bare[] = {"zip", "gz"};
EXPECT_TRUE(compression_should_skip_with_suffixes("archive.zip", bare, 2));
EXPECT_TRUE(compression_should_skip_with_suffixes("x.GZ", bare, 2));
/* No user list (count < 0) selects rsync 3.4.1's built-in default list. */
EXPECT_TRUE(compression_should_skip_with_suffixes("movie.mp4", NULL, -1));
EXPECT_TRUE(compression_should_skip_with_suffixes("archive.TAR.GZ", NULL, -1));
EXPECT_TRUE(compression_should_skip_with_suffixes("photo.jpeg", NULL, -1));
EXPECT_TRUE(compression_should_skip_with_suffixes("disk.squashfs", NULL, -1));
EXPECT_TRUE(compression_should_skip_with_suffixes("data.7z", NULL, -1));
EXPECT_FALSE(compression_should_skip_with_suffixes("notes.txt", NULL, -1));
EXPECT_FALSE(compression_should_skip_with_suffixes("program", NULL, -1));
EXPECT_FALSE(compression_should_skip_with_suffixes("trailing.", NULL, -1));
}
static void test_data_compress_with_threads_roundtrip() {
+3 -2
View File
@@ -2899,13 +2899,14 @@ static void test_config_wire_receive_bounds() {
/* BOOL: only 0/1 is a legal wire value. */
EXPECT_TRUE(receive_hand_built_frame_rejected(write_frame_with_invalid_bool));
/* RAW_MAXALLOC: zero is rejected before it can become the session ceiling. */
/* RAW_MAXALLOC: zero is rsync's --max-alloc=0 "no limit" and round-trips;
* only the over-ceiling clamp is applied server-side. */
Config* c = config_create();
EXPECT_NOT_NULL(c);
c->send_directory = str_dup("/src");
c->receive_root_directory = str_dup("/dst");
c->max_alloc = 0;
EXPECT_TRUE(roundtrip_config_rejected(c));
EXPECT_FALSE(roundtrip_config_rejected(c));
config_delete(c);
/* STR_MODULE: a name outside [A-Za-z0-9._-] is refused. */
+31
View File
@@ -291,6 +291,35 @@ static void test_max_alloc_allows_configured_buffer() {
protocol_session_unbind();
}
/* max_alloc == 0 is rsync's --max-alloc=0 "no limit": allocations of any size
* are permitted. */
static void test_max_alloc_zero_means_unlimited() {
ProtocolSession session;
protocol_session_init(&session, -1, -1);
protocol_session_set_max_alloc(&session, 0);
protocol_session_bind(&session);
void* first = protocol_alloc(1024 * 1024);
void* second = protocol_alloc(8 * 1024 * 1024);
EXPECT_NOT_NULL(first);
EXPECT_NOT_NULL(second);
free(first);
free(second);
protocol_session_unbind();
}
/* A non-positive session io timeout disables the deadline: the getter reports 0
* (not the built-in 60 s fallback) so callers know to wait indefinitely. */
static void test_protocol_get_io_timeout_zero_disables() {
ProtocolSession session;
protocol_session_init(&session, -1, -1);
protocol_session_bind(&session);
protocol_session_set_io_timeout(&session, 0);
EXPECT_EQ_INT(protocol_get_io_timeout_sec(), 0);
protocol_session_set_io_timeout(&session, 45);
EXPECT_EQ_INT(protocol_get_io_timeout_sec(), 45);
protocol_session_unbind();
}
static void test_max_alloc_is_bound_in_worker_threads() {
enum { WORKER_COUNT = 4 };
ProtocolSession sessions[WORKER_COUNT];
@@ -650,6 +679,8 @@ void test_protocol() {
test_max_alloc_rejects_single_buffer();
test_explicit_session_max_alloc_cannot_be_bypassed();
test_max_alloc_allows_configured_buffer();
test_max_alloc_zero_means_unlimited();
test_protocol_get_io_timeout_zero_disables();
test_max_alloc_is_bound_in_worker_threads();
test_protocol_accounting_is_released_in_worker_threads();
test_protocol_accounting_reservation_is_atomic();
+49 -2
View File
@@ -74,8 +74,6 @@ static void test_stop_at_parse_now_plus() {
static void test_stop_at_parse_invalid() {
time_t now = 1700000000;
time_t deadline = 0;
EXPECT_FALSE(stop_parse_at_time("12", now, &deadline));
EXPECT_FALSE(stop_parse_at_time("12:3", now, &deadline));
EXPECT_FALSE(stop_parse_at_time("1234", now, &deadline));
EXPECT_FALSE(stop_parse_at_time("12:30:5", now, &deadline));
EXPECT_FALSE(stop_parse_at_time("12:30:5x", now, &deadline));
@@ -100,6 +98,54 @@ static void test_stop_at_parse_invalid() {
EXPECT_FALSE(stop_parse_at_time(NULL, now, &deadline));
}
/* rsync's flexible date form for --stop-at (y-m-dTh:m, with / separators and
* abbreviable fields). */
static void test_stop_at_parse_date_forms() {
time_t now = 1700000000;
time_t deadline = 0;
struct tm t;
EXPECT_TRUE(stop_parse_at_time("2030-12-31T23:59", now, &deadline));
EXPECT_NOT_NULL(localtime_r(&deadline, &t));
EXPECT_EQ_INT(t.tm_year + 1900, 2030);
EXPECT_EQ_INT(t.tm_mon + 1, 12);
EXPECT_EQ_INT(t.tm_mday, 31);
EXPECT_EQ_INT(t.tm_hour, 23);
EXPECT_EQ_INT(t.tm_min, 59);
EXPECT_TRUE(stop_parse_at_time("2030/12/31T23:59", now, &deadline));
EXPECT_NOT_NULL(localtime_r(&deadline, &t));
EXPECT_EQ_INT(t.tm_year + 1900, 2030);
EXPECT_EQ_INT(t.tm_mon + 1, 12);
EXPECT_EQ_INT(t.tm_mday, 31);
EXPECT_TRUE(stop_parse_at_time("2030-12-31", now, &deadline));
EXPECT_NOT_NULL(localtime_r(&deadline, &t));
EXPECT_EQ_INT(t.tm_year + 1900, 2030);
EXPECT_EQ_INT(t.tm_hour, 0);
EXPECT_EQ_INT(t.tm_min, 0);
/* Partial forms resolve to the next matching point in the future. */
EXPECT_TRUE(stop_parse_at_time(":59", now, &deadline));
EXPECT_TRUE(deadline > now);
EXPECT_NOT_NULL(localtime_r(&deadline, &t));
EXPECT_EQ_INT(t.tm_min, 59);
EXPECT_TRUE(stop_parse_at_time("1-30", now, &deadline));
EXPECT_TRUE(deadline > now);
EXPECT_NOT_NULL(localtime_r(&deadline, &t));
EXPECT_EQ_INT(t.tm_mon + 1, 1);
EXPECT_EQ_INT(t.tm_mday, 30);
EXPECT_TRUE(stop_parse_at_time("1", now, &deadline));
EXPECT_TRUE(deadline > now);
EXPECT_NOT_NULL(localtime_r(&deadline, &t));
EXPECT_EQ_INT(t.tm_mday, 1);
/* Seconds are not part of rsync's date form. */
EXPECT_FALSE(stop_parse_at_time("2030-12-31T23:59:59", now, &deadline));
}
static void test_stop_deadline_latency() {
struct timespec now;
EXPECT_EQ_INT(clock_gettime(CLOCK_MONOTONIC, &now), 0);
@@ -145,6 +191,7 @@ void test_stop(void) {
test_stop_after_parse_invalid();
test_stop_at_parse_hhmm();
test_stop_at_parse_now_plus();
test_stop_at_parse_date_forms();
test_stop_at_parse_invalid();
test_stop_deadline_latency();
}
+11 -7
View File
@@ -144,14 +144,18 @@ static void test_client_delete_null() {
client_delete(c);
}
/* Test tcp_set_timeouts with valid values */
/* Test tcp_set_timeouts: a non-positive value disables the timeout (rsync's
* --timeout=0 / --contimeout=0), it is not a "leave unchanged" sentinel. */
static void test_tcp_set_timeouts() {
/* Just verify the function doesn't crash with edge cases */
tcp_set_timeouts(0, 0); /* zero means "don't change" */
tcp_set_timeouts(60, 20); /* normal values */
tcp_set_timeouts(-1, -1); /* negative means "don't change" */
/* If we got here without crashing, the test passes */
EXPECT_TRUE(true);
tcp_set_timeouts(0, 0);
EXPECT_EQ_INT(tcp_get_timeout_sec(), 0);
EXPECT_EQ_INT(tcp_get_contimeout_sec(), 0);
tcp_set_timeouts(60, 20);
EXPECT_EQ_INT(tcp_get_timeout_sec(), 60);
EXPECT_EQ_INT(tcp_get_contimeout_sec(), 20);
tcp_set_timeouts(-1, -1);
EXPECT_EQ_INT(tcp_get_timeout_sec(), 0);
EXPECT_EQ_INT(tcp_get_contimeout_sec(), 0);
}
/* Test client_connect with an invalid host (should fail gracefully) */