diff --git a/.opencode/agents/integrator.md b/.opencode/agents/integrator.md index 47d9afb..ec57b31 100644 --- a/.opencode/agents/integrator.md +++ b/.opencode/agents/integrator.md @@ -116,7 +116,7 @@ The project uses Gitea Actions. Key jobs: jobs: new-job: runs-on: ubuntu-latest - container: gitea.tap-tap.win/taptap/fastsync-ci:v10 + container: gitea.tap-tap.win/taptap/fastsync-ci:v11 steps: - uses: actions/checkout@v4 - name: Configure diff --git a/Dockerfile b/Dockerfile index 5f28e9f..ab76164 100644 --- a/Dockerfile +++ b/Dockerfile @@ -12,7 +12,9 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ # rsync is used as the reference implementation for drop-in parity tests. # Ubuntu 24.04 ships 3.2.7, so build the pinned 3.4.1 reference from source. ARG RSYNC_VERSION=3.4.1 +ARG RSYNC_SHA256=2924bcb3a1ed8b551fc101f740b9f0fe0a202b115027647cf69850d65fd88c52 RUN curl -fsSL "https://download.samba.org/pub/rsync/src/rsync-${RSYNC_VERSION}.tar.gz" -o /tmp/rsync.tar.gz && \ + echo "${RSYNC_SHA256} /tmp/rsync.tar.gz" | sha256sum -c - && \ tar -xzf /tmp/rsync.tar.gz -C /tmp && \ cd "/tmp/rsync-${RSYNC_VERSION}" && \ ./configure --enable-zstd --enable-xxhash --enable-lz4 && \ diff --git a/src/client/client_cli.c b/src/client/client_cli.c index 2730c26..48815d5 100644 --- a/src/client/client_cli.c +++ b/src/client/client_cli.c @@ -132,16 +132,20 @@ static int set_positive_int_option(int* dest, const char* value, const char* opt * 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 && strcmp(value, "auto") != 0) { + /* rsync's "auto" is normalized to the canonical "zstd" at parse time (like + --checksum-choice=auto), so the value that crosses the wire is always one + the receiver accepts. */ + const char* canonical = strcmp(value, "auto") == 0 ? "zstd" : value; + if (strcmp(canonical, "zstd") != 0 && strcmp(canonical, "none") != 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) + if (set_string_option(&config->compress_choice, canonical, "--compress-choice") != 0) return -1; - config->use_compression = strcmp(value, "none") != 0; + config->use_compression = strcmp(canonical, "none") != 0; return 0; } @@ -1992,11 +1996,6 @@ static bool cli_handle_remote_basis_options(CliParseCtx* ctx) { ctx->exit_code = -1; return true; } - if (strncmp(arg, "-M=", 3) == 0) { - if (config_add_remote_option(config, arg + 3, "-M") != 0) - ctx->exit_code = -1; - return true; - } if (opt_is(arg, "--remote-option", "-M")) { if (ctx->i + 1 >= ctx->argc) { log_message(LOG_LEVEL_ERROR, "missing argument for --remote-option"); @@ -2271,10 +2270,12 @@ static int cli_finalize_config(Config* config, bool verbose, bool no_delta, bool * --no-xattrs/--no-acls negation) so the sender's wire gate always matches * the flags the receiver will recompute from the received config. */ config->use_xattrs = config->preserve_acls || config->preserve_xattrs; - /* Output parity: -i/--itemize-changes and --out-format need the pre-transfer - * destination snapshot (new vs modified and which attributes differ), so ask - * the receiver to report it on every per-file check. This is a wire field. */ - config->report_dest_info = config->itemize_changes || config->out_format != NULL; + /* Output parity: -i/--itemize-changes, --out-format and --log-file-format + * need the pre-transfer destination snapshot (new vs modified and which + * attributes differ), so ask the receiver to report it on every per-file + * check. This is a wire field. */ + config->report_dest_info = config->itemize_changes || config->out_format != NULL || + (config->log_file != NULL && config->log_file_format != NULL); return 0; } @@ -2306,7 +2307,7 @@ static bool cli_long_takes_separate_value(const char* arg) { "--checksum-choice", "--cc", "--checksum-seed", "--sockopts", "--remote-option", "--compare-dest", "--copy-dest", "--link-dest", "--usermap", "--groupmap", "--chown", "--copy-as", - "--outbuf", "--debug", "--info", + "--outbuf", "--debug", "--info", "--skip-compress", }; for (size_t i = 0; i < sizeof(extra) / sizeof(extra[0]); i++) if (strcmp(arg, extra[i]) == 0) diff --git a/src/client/client_send.c b/src/client/client_send.c index ae43bcb..4da392f 100644 --- a/src/client/client_send.c +++ b/src/client/client_send.c @@ -908,8 +908,10 @@ static int send_list_only(const Config* config) { entries = calloc(capacity, sizeof(ListEntry)); if (entries == NULL) { oom = true; + } else if ((entries[0].name = str_dup("")) == NULL) { + /* A NULL name would be dereferenced by qsort/render: fail the listing. */ + oom = true; } else { - entries[0].name = str_dup(""); entries[0].mode = st.st_mode; entries[0].mtime = st.st_mtime; entries[0].mtime_nsec = st.st_mtim.tv_nsec; @@ -1015,15 +1017,18 @@ static int send_list_only(const Config* config) { return 0; } -/* Send the delete manifest (keep-set paths plus the protected excluded - prefixes and the --delete-missing-args exact-delete paths) to the server. - Returns 0 on success, -1 on failure. When --delete-excluded is given - `protected` is empty: excluded destination mirrors are then ordinary extras - and are removed. When --delete-missing-args is active `missing_args` holds - the destination mirrors of missing --files-from entries: each is an explicit - receiver-side deletion request, independent of the extras walk. A NULL - keep-set / protected / missing list transmits an empty section. All three - sections are unbounded on the sender; the receiver enforces +/* Send the delete manifest to the server. Returns 0 on success, -1 on + failure. It carries FOUR sections: the keep-set paths, the protected + excluded prefixes, the --delete-missing-args exact-delete paths, and the + destination-relative directories the sender synchronized this run. + When --delete-excluded is given `protected` is empty: excluded destination + mirrors are then ordinary extras and are removed. When + --delete-missing-args is active `missing_args` holds the destination mirrors + of missing --files-from entries: each is an explicit receiver-side deletion + request, independent of the extras walk. `synced_dirs` confines the extras + walk to entries directly inside a synchronized directory. A NULL + keep-set / protected / missing / dirs list transmits an empty section. All + four sections are unbounded on the sender; the receiver enforces MAX_MANIFEST_ENTRIES per section and a single MAX_MANIFEST_BYTES budget shared across the sections, rejecting (with STATUS_ERROR) an over-budget frame. A heavily filtered source whose exclusion list is large therefore diff --git a/src/client/scanner.c b/src/client/scanner.c index 8737a68..99057bf 100644 --- a/src/client/scanner.c +++ b/src/client/scanner.c @@ -1120,18 +1120,23 @@ Chunk* directory_scanner_next(DirectoryScanner* scanner) { if (inspection == 0) { /* A user-selection exclude protects its destination mirror from --delete unless --delete-excluded; a size prune is always protected. Other - skips (unreadable, symlink policy) protect nothing. */ + skips (unreadable, symlink policy) protect nothing. Under -R + + --files-from the protected prefix must be the entry's bare relative + wire path, not its source path (which would not match the destination + layout and would leave the mirror deletable). */ if (inspected.excluded) { - char* abs_path = path_cat(scanner->current_path, entry->d_name); - if (!abs_path) { + char* protected_path = scanner->relative_mode + ? child_rel_path(scanner->current_rel, entry->d_name) + : path_cat(scanner->current_path, entry->d_name); + if (!protected_path) { scanner->failed = true; break; } if (inspected.size_excluded) - scanner_record_size_skipped(scanner, abs_path); + scanner_record_size_skipped(scanner, protected_path); else - scanner_record_excluded(scanner, abs_path); - free(abs_path); + scanner_record_excluded(scanner, protected_path); + free(protected_path); } continue; } @@ -1510,17 +1515,23 @@ static void scan_root_entry(const ScannerOptions* options, const FilterNode* roo if (inspected.excluded) sink = inspected.size_excluded ? options->size_skipped_paths : options->excluded_paths; if (sink) { - /* A root-level prune protects the destination mirror of the same-named - wire path (at the root the bare name is the wire path in every - layout). */ - char* abs_path = path_cat(root_directory, entry->d_name); - if (!abs_path) { - ps->failed = true; - } else { - const char* rel = *abs_path == '/' ? abs_path + 1 : abs_path; - if (!excluded_sink_append(sink, options->excluded_mutex, rel)) + /* A root-level prune protects the destination mirror of the entry's wire + path: under -R + --files-from that is the bare relative name, otherwise + it is the full source path with a leading '/' removed (matching the + send_path/file_wire_path the scanner hands the sender). */ + if (options->relative && options->file_list != NULL) { + if (!excluded_sink_append(sink, options->excluded_mutex, entry->d_name)) ps->failed = true; - free(abs_path); + } else { + char* abs_path = path_cat(root_directory, entry->d_name); + if (!abs_path) { + ps->failed = true; + } else { + const char* rel = *abs_path == '/' ? abs_path + 1 : abs_path; + if (!excluded_sink_append(sink, options->excluded_mutex, rel)) + ps->failed = true; + free(abs_path); + } } } return; diff --git a/src/server/server.c b/src/server/server.c index ea975bb..6084945 100644 --- a/src/server/server.c +++ b/src/server/server.c @@ -752,10 +752,11 @@ void handler(int file_descriptor) { protocol_set_8_bit_output(config->eight_bit_output); /* Server-side per-message protocol deadline for every frame from here on. * `timeout` is not serialized, so this is the server's own config (the server - * has no --timeout CLI and defaults it to 0): the built-in 60 s window stays - * in effect. A client's --timeout tightens only that client's own protocol - * I/O and the server's socket read/write timeout is the transport default. */ - protocol_session_set_io_timeout(&session, config->timeout); + * has no --timeout CLI and defaults it to 0). A client's --timeout tightens + * only that client's own protocol I/O; the server floors its own deadline at + * SERVER_IO_TIMEOUT_SEC so a silent peer can never hold a session slot + * forever (the socket layer gets the same floor at startup). */ + protocol_session_set_io_timeout(&session, protocol_server_io_timeout_sec(config->timeout)); const char* authorized_root = utils_get_authorized_root_path(); if (!authorized_root) { log_message(LOG_LEVEL_ERROR, "No server-side destination root configured"); @@ -904,7 +905,8 @@ void handler(int file_descriptor) { goto done; } protocol_session_set_max_alloc(&context->session, config->max_alloc); - protocol_session_set_io_timeout(&context->session, config->timeout); + protocol_session_set_io_timeout(&context->session, + protocol_server_io_timeout_sec(config->timeout)); atomic_store(&context->session.total_allocated_bytes, atomic_load(&session.total_allocated_bytes)); pipeline_context_receiver_set_queue_byte_limit(context, RECEIVER_QUEUE_MAX_BYTES); @@ -1220,6 +1222,10 @@ int main(int argc, char* argv[]) { server_iconv_spec = opts.iconv_spec; signal(SIGINT, cleanup); signal(SIGTERM, cleanup); + /* Server-owned socket deadline floor: the client default --timeout=0 would + * otherwise leave accepted sockets without SO_RCVTIMEO/SO_SNDTIMEO and let a + * silent peer hold a connection (and its process slot) forever. */ + tcp_set_timeouts(SERVER_IO_TIMEOUT_SEC, SERVER_IO_TIMEOUT_SEC); if (opts.stdio_mode) { /* SSH authenticates the stdio transport outside of FastSync. */ diff --git a/src/shared/config.c b/src/shared/config.c index 58123e3..153f94d 100644 --- a/src/shared/config.c +++ b/src/shared/config.c @@ -46,9 +46,11 @@ static void config_set_defaults(Config* config) { config->server_port_set = false; config->server_host_set = false; /* rsync defaults: --timeout=0 (I/O timeouts disabled) and --contimeout=60. - * A value of 0 disables the deadline on both the socket layer + * A value of 0 disables the client's own deadline on both the socket layer * (tcp_set_timeouts) and the protocol layer - * (protocol_session_set_io_timeout); a positive value sets it. */ + * (protocol_session_set_io_timeout); a positive value sets it. A server + * session floors the deadline at SERVER_IO_TIMEOUT_SEC so 0 can never hold a + * connection open forever. */ config->timeout = 0; config->contimeout = 60; config->quiet = false; @@ -207,7 +209,8 @@ static bool validate_received_config(const Config* config) { config->delta_block_size >= DELTA_BLOCK_SIZE_MIN && 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->max_delete >= -1 && config->max_alloc <= MAX_SERVER_ALLOC && + config->skip_compress_count >= 0 && config->skip_compress_count <= MAX_SKIP_COMPRESS_SUFFIXES && (!config->chmod_spec || !*config->chmod_spec || chmod_apply(0, config->chmod_spec, &(mode_t){0})) && @@ -788,13 +791,14 @@ void config_delete(Config* config) { * ------------------------------------------------------------------------- */ /* --max-alloc: raw 64-bit value, clamped server-side and installed as the - * 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. */ + * session allocation ceiling. A received 0 is rsync's "no alloc limit"; on the + * receive path it is mapped to the server ceiling so a client can never disable + * it (client-side 0 remains unlimited). Any value above the ceiling is clamped + * to it. */ static bool config_receive_max_alloc(int fd, unsigned long long* value) { if (!receive_n_data(fd, value, sizeof(*value))) return false; - if (*value > MAX_SERVER_ALLOC) + if (*value == 0 || *value > MAX_SERVER_ALLOC) *value = MAX_SERVER_ALLOC; protocol_session_set_max_alloc(NULL, *value); return true; @@ -1362,7 +1366,8 @@ Config* config_receive_with_validate(int file_descriptor, ConfigValidateFunc val !receive_output_options(file_descriptor, config, &budget)) goto error; if (config->compress_choice[0] != '\0' && strcmp(config->compress_choice, "zstd") != 0 && - strcmp(config->compress_choice, "none") != 0) { + strcmp(config->compress_choice, "none") != 0 && + strcmp(config->compress_choice, "auto") != 0) { char* escaped_choice = output_escape(config->compress_choice, config->eight_bit_output); log_message(LOG_LEVEL_ERROR, "Unsupported compression choice: %s", escaped_choice ? escaped_choice : ""); @@ -1373,6 +1378,15 @@ Config* config_receive_with_validate(int file_descriptor, ConfigValidateFunc val free(escaped_choice); goto error; } + /* Defensive: an older/hostile client may still send "auto"; canonicalize it + to zstd (its effective choice) so the stored value is always concrete. */ + if (strcmp(config->compress_choice, "auto") == 0) { + char* canonical = str_dup("zstd"); + if (!canonical) + goto error; + free(config->compress_choice); + config->compress_choice = canonical; + } if (!validate_received_config(config)) { log_message(LOG_LEVEL_ERROR, "Invalid configuration received from client"); send_error_detail(file_descriptor, "invalid configuration received from client"); diff --git a/src/shared/config.h b/src/shared/config.h index 8ce1e44..6762acc 100644 --- a/src/shared/config.h +++ b/src/shared/config.h @@ -328,9 +328,10 @@ typedef struct Config { char* tls_key; char* tls_ca; /* --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. */ + * disables the deadline entirely on the client's own socket and protocol + * layers; a positive value sets it. A server session never inherits the + * disabled value: it applies the SERVER_IO_TIMEOUT_SEC floor (see + * protocol_server_io_timeout_sec and tcp_set_timeouts). */ int timeout; /* --contimeout: connect()/accept timeout in seconds (rsync's default 60); * 0 disables it. Transport layer only. */ diff --git a/src/shared/file.c b/src/shared/file.c index 7a636f4..bd03e6c 100644 --- a/src/shared/file.c +++ b/src/shared/file.c @@ -182,6 +182,7 @@ File* file_create(const char* path) { file->rdev_major = 0; file->rdev_minor = 0; file->xattrs = NULL; + file->dest_state = (OutputDestState){0}; return file; } diff --git a/src/shared/file_receive.c b/src/shared/file_receive.c index a2f561f..282704c 100644 --- a/src/shared/file_receive.c +++ b/src/shared/file_receive.c @@ -295,12 +295,17 @@ static FileSaveResult file_save_hardlink_sibling(const char* root_directory, con free(destination_path); return absent_result; } - /* Resolve a relative --temp-dir against the destination root, exactly as the - * primary save path does; an absolute one is used verbatim. */ + /* Resolve a relative --temp-dir under the destination root, exactly as the + * primary save path does; an absolute or `..`-escaping value is rejected. */ 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 (cfg->temp_dir[0] == '/' || has_path_traversal(cfg->temp_dir)) { + free(content); + free(first_disk); + free(destination_path); + return FILE_SAVE_ERROR; + } + resolved_temp = path_cat(root_directory, cfg->temp_dir); if (!resolved_temp) { free(content); free(first_disk); @@ -772,15 +777,16 @@ 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. --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. */ + /* These options arrive from the client. --backup-dir, --partial-dir and + --temp-dir are names below the server root, never independent filesystem + roots: an absolute or `..`-escaping value is rejected outright (rsync's + daemon confines temp-dir to the module the same way). A relative temp dir + is resolved under the receive root below; if that resolution still lands on + a different filesystem than the destination the install falls back to a + non-atomic copy (see file_to_disk_secure_impl), never an abort. */ if ((backup_dir && (backup_dir[0] == '/' || has_path_traversal(backup_dir))) || - (partial_dir && (partial_dir[0] == '/' || has_path_traversal(partial_dir)))) + (partial_dir && (partial_dir[0] == '/' || has_path_traversal(partial_dir))) || + (temp_dir && (temp_dir[0] == '/' || has_path_traversal(temp_dir)))) return FILE_SAVE_ERROR; if (backup_dir && !(confined_backup = path_cat(root_directory, backup_dir))) return FILE_SAVE_ERROR; @@ -906,20 +912,16 @@ 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; 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). */ + final destination directory. A relative temp dir is resolved under the + receive root and must already exist (an absolute or `..`-escaping value was + rejected above); 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) { - if (temp_dir[0] == '/') - confined_temp = str_dup(temp_dir); - else - confined_temp = path_cat(root_directory, temp_dir); + 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 @@ -3061,8 +3063,15 @@ static bool delete_extras_budgeted(const Config* config, DeleteManifest* manifes idx++; } } - size_t remaining = - budget->max_delete == SIZE_MAX ? SIZE_MAX : budget->max_delete - budget->deleted; + /* Clamp rather than subtract: an accounting bug where deleted already exceeds + max_delete must never underflow into an effectively unlimited budget. */ + size_t remaining; + if (budget->max_delete == SIZE_MAX) + remaining = SIZE_MAX; + else if (budget->deleted >= budget->max_delete) + remaining = 0; + else + remaining = budget->max_delete - budget->deleted; size_t deleted = 0; size_t skipped = 0; DeleteWalkResult result = @@ -3195,7 +3204,11 @@ static bool delete_missing_args_budgeted(const Config* config, DeleteManifest* m parity); the now-empty directory itself costs one more. A run that hits the cap leaves the remaining entries in place. */ ArrayList* no_keeps = array_list_create(free); - size_t remaining = budget->max_delete - budget->deleted; + /* Never let an accounting slip (deleted > max_delete) underflow the + remaining budget into SIZE_MAX, which would grant unlimited + deletions. */ + size_t remaining = + budget->deleted >= budget->max_delete ? 0 : budget->max_delete - budget->deleted; size_t contents_deleted = 0; size_t contents_skipped = 0; DeleteWalkResult walk = @@ -3214,7 +3227,8 @@ static bool delete_missing_args_budgeted(const Config* config, DeleteManifest* m budget->limit_hit = true; budget->skipped++; } else if (file_remove_tree_secure(full)) { - budget->deleted++; + /* The shared `if (removed)` tail charges this directory exactly + once; counting it here too would consume two budget units. */ removed = true; } else { ok = false; diff --git a/src/shared/protocol.c b/src/shared/protocol.c index 3f9e971..f3402a4 100644 --- a/src/shared/protocol.c +++ b/src/shared/protocol.c @@ -104,6 +104,10 @@ int protocol_get_io_timeout_sec(void) { return session->io_timeout_sec > 0 ? session->io_timeout_sec : 0; } +int protocol_server_io_timeout_sec(int client_timeout) { + return client_timeout > 0 ? client_timeout : SERVER_IO_TIMEOUT_SEC; +} + void protocol_session_set_max_alloc(ProtocolSession* session, unsigned long long max_alloc) { if (!session) session = bound_session ? bound_session : &legacy_io_session; @@ -495,6 +499,8 @@ static const char* status_to_string(Status status) { return "ERROR_DETAIL"; case STATUS_DRY_RUN_TRANSFER: return "DRY_RUN_TRANSFER"; + case STATUS_DELETE_LIMIT: + return "DELETE_LIMIT"; case STATUS_DEST_INFO: return "DEST_INFO"; default: diff --git a/src/shared/protocol.h b/src/shared/protocol.h index 5259a4a..95d95d4 100644 --- a/src/shared/protocol.h +++ b/src/shared/protocol.h @@ -34,6 +34,11 @@ #define DEFAULT_MAX_ALLOC (1ULL * 1024 * 1024 * 1024) /* Server policy ceiling for a client-provided allocation limit. */ #define MAX_SERVER_ALLOC (256ULL * 1024 * 1024) +/* Server-owned floor for the per-message I/O deadline. A client --timeout=0 + (rsync's default) disables the client's own deadlines, but a server session + must never be held open forever by a silent peer (slow-loris), so the server + floors the effective deadline at this value. */ +#define SERVER_IO_TIMEOUT_SEC 60 /* Bounded cumulative per-connection receive budget. In-flight wire buffers, decompression buffers and queued (not yet written) file payloads for a connection must stay within this ceiling. */ @@ -59,10 +64,12 @@ typedef struct ProtocolSession { bool eight_bit_output; unsigned long long max_alloc; /* Per-session deadline (seconds) applied to every protocol send/receive by - * protocol_send_n_data / protocol_receive_n_data. Defaults to the built-in - * 60 s window; a value <= 0 falls back to that default. Set from the - * negotiated Config->timeout so --timeout is honored by the poll()-driven - * protocol I/O, not just the socket SO_RCVTIMEO/SO_SNDTIMEO. */ + * protocol_send_n_data / protocol_receive_n_data. The initialized default is + * the built-in 60 s window; a value <= 0 disables the deadline (rsync's + * --timeout=0). Set from the negotiated Config->timeout so --timeout is + * honored by the poll()-driven protocol I/O, not just the socket + * SO_RCVTIMEO/SO_SNDTIMEO. The server does not propagate a client 0 here: it + * installs protocol_server_io_timeout_sec() so its sessions keep a floor. */ int io_timeout_sec; } ProtocolSession; @@ -189,15 +196,20 @@ void protocol_session_unbind(void); void protocol_session_set_ssl(ProtocolSession* session, SSL* ssl); void protocol_session_set_bwlimit(ProtocolSession* session, unsigned long long bytes_per_sec); void protocol_session_set_max_alloc(ProtocolSession* session, unsigned long long max_alloc); -/* Override the per-message send/receive deadline for this session. - * `sec` <= 0 restores the built-in 60 s default (used for --timeout=0/unset). - * An explicit long deadline (e.g. the delete-ack wait) is applied per-call by - * protocol_receive_status_timed and is unaffected by this setter. */ +/* Override the per-message send/receive deadline for this session. The value + * is stored verbatim: a positive value sets the deadline, `sec` <= 0 disables + * it (rsync's --timeout=0). An explicit long deadline (e.g. the delete-ack + * wait) is applied per-call by protocol_receive_status_timed and is unaffected + * by this setter. */ void protocol_session_set_io_timeout(ProtocolSession* session, int sec); -/* Effective per-message I/O deadline (seconds) for the currently-bound session, - * falling back to the built-in default. Used by the plaintext sendfile path - * which bypasses the protocol send primitive. */ +/* Effective per-message I/O deadline (seconds) for the currently-bound session. + * Zero means the deadline is disabled (rsync's --timeout=0). Used by the + * plaintext sendfile path which bypasses the protocol send primitive. */ int protocol_get_io_timeout_sec(void); +/* The server-side effective deadline for a client-requested timeout: a positive + * client value is honored, otherwise the SERVER_IO_TIMEOUT_SEC floor applies so + * a silent peer can never hold a session open forever. */ +int protocol_server_io_timeout_sec(int client_timeout); void* protocol_alloc(size_t size); void* protocol_realloc(void* ptr, size_t size); void protocol_session_set_8_bit_output(ProtocolSession* session, bool enabled); diff --git a/tests/integration/common.py b/tests/integration/common.py index c47e6dd..9678546 100644 --- a/tests/integration/common.py +++ b/tests/integration/common.py @@ -254,6 +254,13 @@ def _wait_for_port(port, timeout=5): def _wait_proc(proc, timeout=5): + """Stop a long-lived subprocess promptly. The server installs a SIGTERM + handler, so signal first and only escalate to SIGKILL if it does not exit; + waiting without signalling would burn the full timeout on every stop.""" + if proc.poll() is not None: + proc.wait() + return + proc.terminate() try: proc.wait(timeout=timeout) except subprocess.TimeoutExpired: diff --git a/tests/integration/test_features.py b/tests/integration/test_features.py index be20ab3..ef96422 100644 --- a/tests/integration/test_features.py +++ b/tests/integration/test_features.py @@ -1379,6 +1379,20 @@ class TestChecksumChoice: port=shared_server.port) assert result.returncode != 0, f"{bad} must be rejected" + @pytest.mark.ci + def test_compress_choice_auto_transfers(self, shared_server): + """--compress-choice=auto is normalized to zstd client-side, so the + receiver never rejects the transfer (#4).""" + clean_dir(DEST_DIR) + flags = ["-z", "--compress-choice=auto"] + result, _ = run_client(SOURCE_DIR, DEST_DIR, flags=flags, port=shared_server.port) + assert result.returncode == 0, \ + f"--compress-choice=auto sync failed: {(result.stderr or result.stdout)[:300]}" + received = get_dest_received_dir(DEST_DIR, SOURCE_DIR) + mismatches, missing = verify_transfer(SOURCE_DIR, received) + assert not missing, f"Missing: {missing}" + assert not mismatches, f"Mismatch: {mismatches}" + @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): @@ -2215,17 +2229,10 @@ class TestTempDir: port=shared_server.port) 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) - result, _ = run_client(source, dest, flags=["--temp-dir", missing_abs], - port=shared_server.port) - 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.""" + def test_temp_dir_absolute_rejected(self, shared_server): + """The receiver confines --temp-dir to the destination root: an absolute + (or `..`-escaping) value is rejected before any write, so a client can + never make the receiver create scratch files in an arbitrary directory.""" source = self._make_source("tempdir_abs_src") dest = os.path.join(TEST_DATA_DIR, "tempdir_abs_dst") clean_dir(dest) @@ -2235,13 +2242,14 @@ class TestTempDir: 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) + assert result.returncode != 0, "an absolute --temp-dir must be rejected" + assert os.listdir(scratch) == [], "receiver wrote into an unconfined temp dir" + # A relative traversal is rejected for the same reason. + result, _ = run_client(source, dest, flags=["--temp-dir=../escape_scratch"], + port=shared_server.port) + assert result.returncode != 0, "a `..` --temp-dir must be rejected" shutil.rmtree(scratch, ignore_errors=True) + shutil.rmtree(os.path.join(TEST_DATA_DIR, "escape_scratch"), ignore_errors=True) class TestTimeoutAndAllocLimits: @@ -2285,7 +2293,8 @@ class TestTimeoutAndAllocLimits: 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 + """A confined relative --temp-dir that resolves (via a symlink under the + destination root) to 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" @@ -2298,14 +2307,18 @@ class TestTimeoutAndAllocLimits: os.makedirs(scratch) try: source, dest = self._seed("tempdir_xdev_src") - result, _ = run_client(source, dest, flags=["--temp-dir", scratch], + # The receiver resolves a relative temp dir under the destination + # root; a symlink there points the scratch at the second filesystem. + link = os.path.join(dest, "xdev_scratch") + os.symlink(scratch, link) + result, _ = run_client(source, dest, flags=["--temp-dir", "xdev_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" + assert os.listdir(scratch) == [], "temp files left behind in the cross-fs scratch" finally: shutil.rmtree(scratch, ignore_errors=True) @@ -2910,6 +2923,43 @@ class TestRelativeFilesFrom: assert not os.path.exists(os.path.join(dest, "sub", "y.txt")), \ "directory-listed --delete did not remove the in-scope extra" + @pytest.mark.parametrize("mt", [False, True]) + def test_relative_root_size_prune_protects_mirror_from_delete(self, mt): + """#12: a root-level --max-size prune under -R + --files-from must record + the bare relative wire path as its delete-protected prefix, so the + size-pruned entry's destination mirror survives --delete (rsync parity).""" + source = _make_relative_source("rel_rootsize_src") + # Big enough that a 100-byte cap prunes only this entry. + with open(os.path.join(source, "big.txt"), "wb") as fh: + fh.write(b"b" * 1000) + dest = os.path.join(TEST_DATA_DIR, "rel_rootsize_dst") + clean_dir(dest) + with ServerManager() as server: + server.start(extra_args=["--allow-delete"]) + # "." lists the whole tree, so the receive root is a delete scope + # (a file-only list would leave the root out of scope, masking the + # protected-prefix mismatch this test targets). + lst = _write_rel_list(b".\n") + result, _ = run_client(source, dest, + flags=["--files-from", lst, "-R"] + (["--threads"] if mt else []), + port=server.port) + assert result.returncode == 0, f"seed -R sync failed: {result.stderr[:200]}" + assert os.path.isfile(os.path.join(dest, "big.txt")) + with open(os.path.join(dest, "unrelated.txt"), "w") as fh: + fh.write("x") + + # --max-size=100 prunes only big.txt; its dest mirror is always protected. + result, _ = run_client(source, dest, + flags=["--files-from", lst, "-R", "--delete", "--max-size=100"] + + (["--threads"] if mt else []), + port=server.port) + assert result.returncode == 0, f"-R size+delete sync failed: {result.stderr[:300]}" + assert not os.path.exists(os.path.join(dest, "unrelated.txt")), "delete not active" + assert os.path.isfile(os.path.join(dest, "big.txt")), \ + "the size-pruned entry's mirror was wrongly deleted (protected prefix mismatch)" + assert os.path.isfile(os.path.join(dest, "sub", "x.txt")) + assert os.path.isfile(os.path.join(dest, "top.txt")) + class TestMissingArgs: """--ignore-missing-args / --delete-missing-args: a --files-from entry that diff --git a/tests/test_client_cli.c b/tests/test_client_cli.c index c766e9a..564012e 100644 --- a/tests/test_client_cli.c +++ b/tests/test_client_cli.c @@ -1518,6 +1518,9 @@ static void test_parse_args_compress_choice_parity() { int positional_args[2]; int positional_count = 0; EXPECT_EQ_INT(parse_args(cfg, 5, argv, positional_args, &positional_count), 0); + /* "auto" is normalized to the canonical "zstd" the receiver accepts. */ + EXPECT_EQ_STR(cfg->compress_choice, strcmp(good[i], "auto") == 0 ? "zstd" : good[i]); + EXPECT_EQ_INT(cfg->use_compression, strcmp(good[i], "none") != 0 ? 1 : 0); config_delete(cfg); } static const char* const bad[] = {"lz4", "zlib", "zlibx", "bogus"}; @@ -2326,6 +2329,8 @@ static void test_parse_args_log_file_format() { int positional_count = 0; EXPECT_EQ_INT(parse_args(cfg, 4, argv, positional_args, &positional_count), 0); EXPECT_EQ_STR(cfg->log_file_format, "%n %M"); + /* The format alone is inert (no --log-file): no destination report needed. */ + EXPECT_FALSE(cfg->report_dest_info); config_delete(cfg); cfg = config_create(); @@ -2334,6 +2339,61 @@ static void test_parse_args_log_file_format() { EXPECT_EQ_INT(parse_args(cfg, 5, separate_argv, positional_args, &positional_count), 0); EXPECT_EQ_STR(cfg->log_file_format, "%n %M"); config_delete(cfg); + + /* With --log-file the log-format is a real output mode whose %i/%n columns + need the receiver's destination snapshot (same as -i/--out-format). */ + const char* log_path = "cli_log_fmt_test.txt"; + cfg = config_create(); + char log_arg[64]; + snprintf(log_arg, sizeof(log_arg), "--log-file=%s", log_path); + char* both_argv[] = {"fastsync", log_arg, "--log-file-format=%i %n", "/src", "/dst"}; + positional_count = 0; + EXPECT_EQ_INT(parse_args(cfg, 5, both_argv, positional_args, &positional_count), 0); + EXPECT_NOT_NULL(cfg->log_file); + EXPECT_TRUE(cfg->report_dest_info); + config_delete(cfg); + remove(log_path); +} + +/* --skip-compress takes a separate value even when it starts with '-' (e.g. a + * suffix typed as "-foo"); the cluster expander must copy it verbatim rather + * than treat it as a short-option cluster. */ +static void test_parse_args_skip_compress_dash_value() { + Config* cfg = config_create(); + char* argv[] = {"fastsync", "--skip-compress", "-foo/bar", "/src", "/dst"}; + int positional_args[2]; + int positional_count = 0; + EXPECT_EQ_INT(parse_args(cfg, 5, argv, positional_args, &positional_count), 0); + EXPECT_TRUE(cfg->skip_compress_set); + EXPECT_EQ_INT(cfg->skip_compress_count, 2); + EXPECT_EQ_STR(cfg->skip_compress_suffixes[0], "-foo"); + EXPECT_EQ_STR(cfg->skip_compress_suffixes[1], "bar"); + config_delete(cfg); +} + +/* -i and --out-format also request the destination snapshot. */ +static void test_parse_args_report_dest_info_modes() { + Config* cfg = config_create(); + char* itemize_argv[] = {"fastsync", "-i", "/src", "/dst"}; + int positional_args[2]; + int positional_count = 0; + EXPECT_EQ_INT(parse_args(cfg, 3, itemize_argv, positional_args, &positional_count), 0); + EXPECT_TRUE(cfg->report_dest_info); + config_delete(cfg); + + cfg = config_create(); + char* out_argv[] = {"fastsync", "--out-format=%n", "/src", "/dst"}; + positional_count = 0; + EXPECT_EQ_INT(parse_args(cfg, 3, out_argv, positional_args, &positional_count), 0); + EXPECT_TRUE(cfg->report_dest_info); + config_delete(cfg); + + cfg = config_create(); + char* plain_argv[] = {"fastsync", "/src", "/dst"}; + positional_count = 0; + EXPECT_EQ_INT(parse_args(cfg, 3, plain_argv, positional_args, &positional_count), 0); + EXPECT_FALSE(cfg->report_dest_info); + config_delete(cfg); } /* --delay-updates is a plain boolean receiver option. */ @@ -3344,6 +3404,24 @@ static void test_parse_args_remote_option_multiple() { config_delete(cfg); } +/* The -M=value and -Mvalue short forms are expanded by the cluster expander to + * "-M value" before parsing; both must still collect the remote option (there + * is no dedicated -M= branch). */ +static void test_parse_args_remote_option_short_forms() { + static const char* const forms[] = {"-M=--allow-delete", "-M--allow-delete"}; + for (size_t i = 0; i < sizeof(forms) / sizeof(forms[0]); i++) { + Config* cfg = valid_client_config(); + EXPECT_NOT_NULL(cfg); + char* argv[] = {"fastsync", "--source-dir", "/src", "--dest-dir", "/dst", (char*)forms[i]}; + int positional_args[2]; + int positional_count = 0; + EXPECT_EQ_INT(parse_args(cfg, 6, argv, positional_args, &positional_count), 0); + EXPECT_EQ_INT(cfg->remote_option_count, 1); + EXPECT_EQ_STR(cfg->remote_options[0], "--allow-delete"); + config_delete(cfg); + } +} + /* Space-separated form "--remote-option OPT" also parses. */ static void test_parse_args_remote_option_space_form() { Config* cfg = valid_client_config(); @@ -4225,6 +4303,8 @@ void test_client_cli() { test_parse_args_list_only(); test_parse_args_out_format(); test_parse_args_log_file_format(); + test_parse_args_report_dest_info_modes(); + test_parse_args_skip_compress_dash_value(); test_parse_args_checksum_choice_aliases(); test_parse_args_checksum_choice_requires_value(); test_parse_args_checksum_choice_equals_forms(); @@ -4253,6 +4333,7 @@ void test_client_cli() { test_parse_args_trust_sender_default_false(); test_parse_args_trust_sender(); test_parse_args_remote_option_multiple(); + test_parse_args_remote_option_short_forms(); test_parse_args_remote_option_space_form(); test_parse_args_remote_option_missing_value(); test_parse_args_remote_option_rejects_bad_values(); diff --git a/tests/test_config.c b/tests/test_config.c index e181b79..e8ed56b 100644 --- a/tests/test_config.c +++ b/tests/test_config.c @@ -552,6 +552,83 @@ static void test_config_send_receive() { } } +/* #5: a received --max-alloc=0 (rsync's "no limit") is floored to the server + * ceiling on the receive path, so a client cannot disable it. */ +static void test_config_receive_max_alloc_zero_floored() { + Config* send_cfg = config_create(); + EXPECT_NOT_NULL(send_cfg); + send_cfg->send_directory = str_dup("/send/src"); + send_cfg->receive_root_directory = str_dup("/send/dst"); + send_cfg->max_alloc = 0; + + int p[2]; + EXPECT_EQ_INT(socketpair(AF_UNIX, SOCK_STREAM, 0, p), 0); + io_set_fds(p[0], p[1]); + io_set_bwlimit(0); + + pid_t pid = fork(); + if (pid == 0) { + close(p[1]); + io_set_fds(p[0], p[0]); + Config* recv_cfg = config_receive(p[0]); + bool ok = recv_cfg != NULL && recv_cfg->max_alloc == MAX_SERVER_ALLOC; + config_delete(recv_cfg); + close(p[0]); + close(p[1]); + _exit(ok ? 0 : 1); + } else { + close(p[0]); + io_set_fds(p[1], p[1]); + bool sent = config_send(p[1], send_cfg); + int status; + waitpid(pid, &status, 0); + close(p[0]); + close(p[1]); + config_delete(send_cfg); + EXPECT_TRUE(sent); + EXPECT_TRUE(WIFEXITED(status) && WEXITSTATUS(status) == 0); + } +} + +/* #4: a hostile/older client that still sends compress_choice=auto must be + * accepted (as zstd) rather than failing the whole transfer. */ +static void test_config_receive_compress_choice_auto_canonicalized() { + Config* send_cfg = config_create(); + EXPECT_NOT_NULL(send_cfg); + send_cfg->send_directory = str_dup("/send/src"); + send_cfg->receive_root_directory = str_dup("/send/dst"); + free(send_cfg->compress_choice); + send_cfg->compress_choice = str_dup("auto"); + + int p[2]; + EXPECT_EQ_INT(socketpair(AF_UNIX, SOCK_STREAM, 0, p), 0); + io_set_fds(p[0], p[1]); + io_set_bwlimit(0); + + pid_t pid = fork(); + if (pid == 0) { + close(p[1]); + io_set_fds(p[0], p[0]); + Config* recv_cfg = config_receive(p[0]); + bool ok = recv_cfg != NULL && strcmp(recv_cfg->compress_choice, "zstd") == 0; + config_delete(recv_cfg); + close(p[0]); + close(p[1]); + _exit(ok ? 0 : 1); + } else { + close(p[0]); + io_set_fds(p[1], p[1]); + bool sent = config_send(p[1], send_cfg); + int status; + waitpid(pid, &status, 0); + close(p[0]); + close(p[1]); + config_delete(send_cfg); + EXPECT_TRUE(sent); + EXPECT_TRUE(WIFEXITED(status) && WEXITSTATUS(status) == 0); + } +} + static void test_config_send_receive_version_mismatch() { /* A peer using the previous wire format must be rejected. */ Config* cfg = config_create(); @@ -3051,6 +3128,8 @@ void test_config() { test_pipeline_receiver_lifecycle(); if (!is_running_under_valgrind()) { test_config_send_receive(); + test_config_receive_max_alloc_zero_floored(); + test_config_receive_compress_choice_auto_canonicalized(); test_config_local_only_fields_not_serialized(); test_config_send_receive_version_mismatch(); test_config_receive_truncated(); diff --git a/tests/test_file.c b/tests/test_file.c index 0a0e41a..a9a3c27 100644 --- a/tests/test_file.c +++ b/tests/test_file.c @@ -28,6 +28,10 @@ static void test_file_create() { EXPECT_NULL(f->data->data); EXPECT_EQ_INT((int)f->data->size, 0); EXPECT_NULL(f->metadata); + /* An unset destination snapshot must read as known == false, never + indeterminate bytes (-i/--out-format without --incremental). */ + EXPECT_FALSE(f->dest_state.known); + EXPECT_FALSE(f->dest_state.existed); file_destroy(f); } @@ -326,6 +330,52 @@ static void test_file_save_to_disk_partial_install() { rmdir(root); } +/* --temp-dir is a client-controlled wire value that must be confined below the + * receive root: an absolute or `..`-escaping value is rejected (a client must + * never make the receiver write scratch files in an arbitrary directory), while + * a relative one resolves under the root and is used for the atomic install. */ +static void test_file_save_to_disk_temp_dir_confined() { + const char* root = "test_temp_confine_tmp"; + const char* dest_file = "test_temp_confine_tmp/file.txt"; + char outside[PATH_MAX]; + snprintf(outside, sizeof(outside), "/tmp/fastsync_temp_outside_%d", (int)getpid()); + unlink(dest_file); + rmdir("test_temp_confine_tmp/scratch"); + rmdir(root); + mkdir(root, 0755); + mkdir("test_temp_confine_tmp/scratch", 0755); + mkdir(outside, 0755); + + File* f = file_create("file.txt"); + EXPECT_NOT_NULL(f); + const char* content = "confined temp dir"; + f->data->data = malloc(strlen(content)); + EXPECT_NOT_NULL(f->data->data); + memcpy(f->data->data, content, strlen(content)); + f->data->size = strlen(content); + + Config* config = config_create(); + EXPECT_NOT_NULL(config); + config->temp_dir = str_dup(outside); + EXPECT_EQ_INT(file_save_to_disk_full(root, f, config), FILE_SAVE_ERROR); + EXPECT_EQ_INT(access(dest_file, F_OK), -1); + free(config->temp_dir); + config->temp_dir = str_dup("../escape"); + EXPECT_EQ_INT(file_save_to_disk_full(root, f, config), FILE_SAVE_ERROR); + EXPECT_EQ_INT(access(dest_file, F_OK), -1); + free(config->temp_dir); + config->temp_dir = str_dup("scratch"); + EXPECT_EQ_INT(file_save_to_disk_full(root, f, config), FILE_SAVE_WRITTEN); + EXPECT_EQ_INT(access(dest_file, F_OK), 0); + + file_destroy(f); + config_delete(config); + unlink(dest_file); + rmdir("test_temp_confine_tmp/scratch"); + rmdir(root); + rmdir(outside); +} + /* Issue #251: file_save_to_disk_full must distinguish receiver-side skips (--existing/--ignore-existing/--update) from real writes so the sender can decide whether --remove-source-files may unlink its source. */ @@ -1883,6 +1933,81 @@ static void test_keep_dirlinks_secure_open() { file_set_keep_dirlinks(false); } +/* Build an ArrayList of str_dup'd strings (NULL on allocation failure). */ +static ArrayList* make_manifest_string_list(const char* const* entries, int count) { + ArrayList* list = array_list_create(free); + if (!list) + return NULL; + for (int i = 0; i < count; i++) { + char* dup = str_dup(entries[i]); + if (!dup || !array_list_add(list, dup)) { + free(dup); + array_list_delete(list); + return NULL; + } + } + return list; +} + +/* Regression (#3): a non-empty --delete-missing-args directory charges each + * removed entry exactly once. The directory itself must not be counted twice; + * if it were, `deleted` would exceed --max-delete and the extras walk would + * underflow its remaining budget and delete past the user's cap. */ +static void test_manifest_delete_missing_dir_budget_double_count() { + char root[PATH_MAX]; + snprintf(root, sizeof(root), "/tmp/fastsync_mgdir_%d", (int)getpid()); + char* gone = path_cat(root, "gone"); + char* gone_file = path_cat(gone, "f0"); + char* extra = path_cat(root, "extra.txt"); + EXPECT_NOT_NULL(gone); + EXPECT_NOT_NULL(gone_file); + EXPECT_NOT_NULL(extra); + mkdir(root, 0755); + mkdir(gone, 0755); + EXPECT_EQ_INT(access(extra, F_OK), -1); + EXPECT_TRUE(file_write_to_disk(extra, "extra", 5, false, false)); + /* The missing-arg directory holds N-1 == 2 entries; with the directory itself + that is exactly --max-delete=3. */ + EXPECT_TRUE(file_write_to_disk(gone_file, "x", 1, false, false)); + char* gone_file2 = path_cat(gone, "f1"); + EXPECT_TRUE(gone_file2 != NULL && file_write_to_disk(gone_file2, "x", 1, false, false)); + + Config* cfg = config_create(); + EXPECT_NOT_NULL(cfg); + cfg->receive_root_directory = str_dup(root); + cfg->use_delete = true; + cfg->delete_missing_args = true; + cfg->max_delete = 3; + + const char* missing_names[] = {"gone"}; + const char* synced[] = {"."}; + DeleteManifest manifest = {0}; + manifest.keeps = make_manifest_string_list(NULL, 0); + manifest.missing = make_manifest_string_list(missing_names, 1); + manifest.dirs = make_manifest_string_list(synced, 1); + EXPECT_NOT_NULL(manifest.keeps); + EXPECT_NOT_NULL(manifest.missing); + EXPECT_NOT_NULL(manifest.dirs); + + DeleteCommitResult result = manifest_delete_all(cfg, &manifest); + EXPECT_EQ_INT((int)result, (int)DELETE_COMMIT_LIMIT_REACHED); + /* The whole missing-arg directory is gone (dir + its 2 entries == 3). */ + EXPECT_EQ_INT(access(gone, F_OK), -1); + /* The saturated budget must leave the in-scope extra untouched. */ + EXPECT_EQ_INT(access(extra, F_OK), 0); + + array_list_delete(manifest.keeps); + array_list_delete(manifest.missing); + array_list_delete(manifest.dirs); + config_delete(cfg); + unlink(extra); + free(gone); + free(gone_file); + free(gone_file2); + free(extra); + rmdir(root); +} + void test_file() { test_file_create(); test_file_special_rdev_valid(); @@ -1896,6 +2021,7 @@ void test_file() { test_file_save_to_disk_ignore_existing(); test_file_save_to_disk_ignore_existing_entry_types(); test_file_save_to_disk_partial_install(); + test_file_save_to_disk_temp_dir_confined(); test_file_save_to_disk_reports_skips(); test_file_write_to_disk_sparse_preserves_holes(); test_file_write_to_disk_partial_retention(); @@ -1936,4 +2062,5 @@ void test_file() { test_inplace_overwrite_truncates_shorter_payload(); test_inplace_refuses_fifo_destination(); test_inplace_refuses_device_destination(); + test_manifest_delete_missing_dir_budget_double_count(); } diff --git a/tests/test_protocol.c b/tests/test_protocol.c index 9242491..53e59d7 100644 --- a/tests/test_protocol.c +++ b/tests/test_protocol.c @@ -522,10 +522,19 @@ static void test_data_create_starts_uncharged_and_unowned() { data_destroy(reserved); } +/* The server floors a client --timeout=0 at SERVER_IO_TIMEOUT_SEC so a silent + * peer can never hold a session slot forever (slow-loris). */ +static void test_protocol_server_io_timeout_floor() { + EXPECT_EQ_INT(protocol_server_io_timeout_sec(0), SERVER_IO_TIMEOUT_SEC); + EXPECT_EQ_INT(protocol_server_io_timeout_sec(-7), SERVER_IO_TIMEOUT_SEC); + EXPECT_EQ_INT(protocol_server_io_timeout_sec(30), 30); + EXPECT_TRUE(SERVER_IO_TIMEOUT_SEC > 0); +} + static void test_protocol_session_io_timeout() { - /* Default is the built-in 60 s window; the setter stores exactly what it is - * given (<= 0 means "fall back to the default") so callers can propagate - * --timeout without special-casing 0. */ + /* The default is the built-in 60 s window; the setter stores exactly what it + * is given (<= 0 disables the deadline, matching rsync's --timeout=0) so + * callers can propagate --timeout without special-casing 0. */ ProtocolSession session; protocol_session_init(&session, -1, -1); EXPECT_EQ_INT(session.io_timeout_sec, 60); @@ -670,6 +679,7 @@ void test_protocol() { test_send_receive_int(); test_send_receive_status(); test_protocol_session_io_timeout(); + test_protocol_server_io_timeout_floor(); test_send_receive_status_timed(); test_receive_status_keepalive_skips_reply(); test_receive_status_keepalive_aborts();