diff --git a/src/client/client_cli.c b/src/client/client_cli.c index 00bc37e..81d58e4 100644 --- a/src/client/client_cli.c +++ b/src/client/client_cli.c @@ -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) diff --git a/src/client/client_validation.c b/src/client/client_validation.c index 2d47559..37d173f 100644 --- a/src/client/client_validation.c +++ b/src/client/client_validation.c @@ -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"); diff --git a/src/client/usage.c b/src/client/usage.c index b2bd9b4..d745b06 100644 --- a/src/client/usage.c +++ b/src/client/usage.c @@ -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 Skip files larger than n bytes\n"); printf(" --min-size Skip files smaller than n bytes\n"); - printf(" --max-alloc Maximum single allocation (default: 1G)\n"); + printf(" --max-alloc 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 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 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 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 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 Server port (default: 8080)\n"); printf(" --port Alias for --server-port\n"); printf(" --password-file 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 Bandwidth limit in kilobytes per second\n"); @@ -233,15 +241,19 @@ void print_usage(void) { printf(" --cert TLS certificate file (PEM)\n"); printf(" --key TLS private key file (PEM)\n"); printf(" --ca TLS CA certificate file (PEM)\n"); - printf(" --timeout I/O timeout in seconds (default: 30; long form only)\n"); - printf(" --contimeout Connection timeout in seconds (default: 10)\n"); + printf(" --timeout I/O timeout in seconds (default: 0 = disabled, matching\n"); + printf(" rsync). 0 disables it; --no-timeout is the same\n"); + printf(" --contimeout 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 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 Directory for partial files\n"); - printf(" -T, --temp-dir Scratch dir for temp files before atomic install\n"); + printf(" -T, --temp-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 \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 Compression level (default: 5)\n"); printf(" --zl 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 Compression worker threads (requires zstd threaded support)\n"); printf(" --no-OPTION Disable a supported boolean option\n"); printf(" --help Show this help\n"); diff --git a/src/server/server.c b/src/server/server.c index 328a05c..f0ffad2 100644 --- a/src/server/server.c +++ b/src/server/server.c @@ -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"); diff --git a/src/shared/checksum.c b/src/shared/checksum.c index f17698f..b95a8bc 100644 --- a/src/shared/checksum.c +++ b/src/shared/checksum.c @@ -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; } diff --git a/src/shared/checksum.h b/src/shared/checksum.h index ddc6ee5..c323730 100644 --- a/src/shared/checksum.h +++ b/src/shared/checksum.h @@ -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 */ \ No newline at end of file diff --git a/src/shared/compression.c b/src/shared/compression.c index dad630c..70a8bad 100644 --- a/src/shared/compression.c +++ b/src/shared/compression.c @@ -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; diff --git a/src/shared/config.c b/src/shared/config.c index fa1b79b..7c968f5 100644 --- a/src/shared/config.c +++ b/src/shared/config.c @@ -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; diff --git a/src/shared/config.h b/src/shared/config.h index 6dfb1c7..8a5317c 100644 --- a/src/shared/config.h +++ b/src/shared/config.h @@ -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; diff --git a/src/shared/file.c b/src/shared/file.c index d3af701..f64ef20 100644 --- a/src/shared/file.c +++ b/src/shared/file.c @@ -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; diff --git a/src/shared/file.h b/src/shared/file.h index 5333bc7..573b92e 100644 --- a/src/shared/file.h +++ b/src/shared/file.h @@ -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); diff --git a/src/shared/file_receive.c b/src/shared/file_receive.c index 39cd387..085ae28 100644 --- a/src/shared/file_receive.c +++ b/src/shared/file_receive.c @@ -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 diff --git a/src/shared/file_send.c b/src/shared/file_send.c index f15b77a..dfdeb5c 100644 --- a/src/shared/file_send.c +++ b/src/shared/file_send.c @@ -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); diff --git a/src/shared/protocol.c b/src/shared/protocol.c index c5b7991..ea14542 100644 --- a/src/shared/protocol.c +++ b/src/shared/protocol.c @@ -12,8 +12,7 @@ #include #include -#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; } diff --git a/src/shared/stop_condition.c b/src/shared/stop_condition.c index b49053c..35a0615 100644 --- a/src/shared/stop_condition.c +++ b/src/shared/stop_condition.c @@ -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; diff --git a/src/shared/transport_tcp.c b/src/shared/transport_tcp.c index 9fedfb7..059e4f1 100644 --- a/src/shared/transport_tcp.c +++ b/src/shared/transport_tcp.c @@ -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) { diff --git a/tests/integration/test_features.py b/tests/integration/test_features.py index cf2f46f..a9a599f 100644 --- a/tests/integration/test_features.py +++ b/tests/integration/test_features.py @@ -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(): diff --git a/tests/integration/test_stop.py b/tests/integration/test_stop.py index 28cde86..e0d86ec 100644 --- a/tests/integration/test_stop.py +++ b/tests/integration/test_stop.py @@ -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" diff --git a/tests/test_checksum.c b/tests/test_checksum.c index a816271..3c37e1b 100644 --- a/tests/test_checksum.c +++ b/tests/test_checksum.c @@ -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(); } \ No newline at end of file diff --git a/tests/test_client_cli.c b/tests/test_client_cli.c index 60dd58a..506068a 100644 --- a/tests/test_client_cli.c +++ b/tests/test_client_cli.c @@ -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(); diff --git a/tests/test_compression.c b/tests/test_compression.c index b80b95b..ebd762e 100644 --- a/tests/test_compression.c +++ b/tests/test_compression.c @@ -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() { diff --git a/tests/test_config.c b/tests/test_config.c index 3b6026e..99c8404 100644 --- a/tests/test_config.c +++ b/tests/test_config.c @@ -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. */ diff --git a/tests/test_protocol.c b/tests/test_protocol.c index 32e4a64..9242491 100644 --- a/tests/test_protocol.c +++ b/tests/test_protocol.c @@ -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(); diff --git a/tests/test_stop.c b/tests/test_stop.c index 4fc71bf..b18bbac 100644 --- a/tests/test_stop.c +++ b/tests/test_stop.c @@ -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(); } \ No newline at end of file diff --git a/tests/test_transport_tcp.c b/tests/test_transport_tcp.c index a3043cf..c6fd1fa 100644 --- a/tests/test_transport_tcp.c +++ b/tests/test_transport_tcp.c @@ -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) */