fix(parity): receiver temp-dir confinement, server I/O floor, delete budget

Address review findings on feat/rsync-parity:
- confine --temp-dir below the receive root (reject absolute/.. like
  backup-dir/partial-dir); keep EXDEV non-atomic fallback
- floor server session I/O deadlines at SERVER_IO_TIMEOUT_SEC (60s) and
  install it on the socket layer at startup (slow-loris)
- charge each --delete-missing-args directory removal once and clamp the
  extras-walk remaining budget so it can never underflow past --max-delete
- normalize --compress-choice=auto to zstd client-side and accept it on
  receive so auto transfers no longer fail
- map received --max-alloc=0 to MAX_SERVER_ALLOC (receive path only)
- zero File.dest_state; include log-file-format in report_dest_info;
  add STATUS_DELETE_LIMIT name; recognize --skip-compress as a
  separate-value option; OOM-guard send_list_only root entry; drop the
  dead -M= branch; record the bare relative protected prefix for -R
  size-prunes in both scanners; refresh delete-manifest comment
- pin the rsync tarball sha256 and bump integrator image to v11

Tests: temp-dir rejection/relative/cross-device, server timeout floor,
delete-missing dir budget regression, compress-choice=auto e2e,
max-alloc=0 receive mapping, dest_state, report_dest_info modes,
skip-compress dash value, -M short forms, -R root size-prune mirror
protection (rsync 3.4.1 confirmed).
This commit is contained in:
2026-09-16 01:11:59 +02:00
parent 3f5b0250f4
commit 88bdfeeb58
18 changed files with 544 additions and 117 deletions
+14 -13
View File
@@ -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)
+15 -10
View File
@@ -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
+27 -16
View File
@@ -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;
+11 -5
View File
@@ -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. */
+22 -8
View File
@@ -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 : "<allocation failed>");
@@ -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");
+4 -3
View File
@@ -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. */
+1
View File
@@ -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;
}
+41 -27
View File
@@ -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;
+6
View File
@@ -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:
+23 -11
View File
@@ -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);