rsync 3.4.1 drop-in parity (#285-#297) + parity completion (protocol 2.26.0) #298
@@ -116,7 +116,7 @@ The project uses Gitea Actions. Key jobs:
|
|||||||
jobs:
|
jobs:
|
||||||
new-job:
|
new-job:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
container: gitea.tap-tap.win/taptap/fastsync-ci:v10
|
container: gitea.tap-tap.win/taptap/fastsync-ci:v11
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- name: Configure
|
- name: Configure
|
||||||
|
|||||||
@@ -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.
|
# 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.
|
# 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_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 && \
|
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 && \
|
tar -xzf /tmp/rsync.tar.gz -C /tmp && \
|
||||||
cd "/tmp/rsync-${RSYNC_VERSION}" && \
|
cd "/tmp/rsync-${RSYNC_VERSION}" && \
|
||||||
./configure --enable-zstd --enable-xxhash --enable-lz4 && \
|
./configure --enable-zstd --enable-xxhash --enable-lz4 && \
|
||||||
|
|||||||
+14
-13
@@ -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
|
* zstd choice; any other rsync choice is rejected by name instead of being
|
||||||
* silently accepted and ignored. */
|
* silently accepted and ignored. */
|
||||||
static int set_compression_choice(Config* config, const char* value) {
|
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,
|
log_message(LOG_LEVEL_ERROR,
|
||||||
"--compress-choice '%s' is not implemented; FastSync supports zstd, none or auto "
|
"--compress-choice '%s' is not implemented; FastSync supports zstd, none or auto "
|
||||||
"(rsync's lz4/zlib/zlibx are rejected, never silently ignored)",
|
"(rsync's lz4/zlib/zlibx are rejected, never silently ignored)",
|
||||||
value);
|
value);
|
||||||
return -1;
|
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;
|
return -1;
|
||||||
config->use_compression = strcmp(value, "none") != 0;
|
config->use_compression = strcmp(canonical, "none") != 0;
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1992,11 +1996,6 @@ static bool cli_handle_remote_basis_options(CliParseCtx* ctx) {
|
|||||||
ctx->exit_code = -1;
|
ctx->exit_code = -1;
|
||||||
return true;
|
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 (opt_is(arg, "--remote-option", "-M")) {
|
||||||
if (ctx->i + 1 >= ctx->argc) {
|
if (ctx->i + 1 >= ctx->argc) {
|
||||||
log_message(LOG_LEVEL_ERROR, "missing argument for --remote-option");
|
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
|
* --no-xattrs/--no-acls negation) so the sender's wire gate always matches
|
||||||
* the flags the receiver will recompute from the received config. */
|
* the flags the receiver will recompute from the received config. */
|
||||||
config->use_xattrs = config->preserve_acls || config->preserve_xattrs;
|
config->use_xattrs = config->preserve_acls || config->preserve_xattrs;
|
||||||
/* Output parity: -i/--itemize-changes and --out-format need the pre-transfer
|
/* Output parity: -i/--itemize-changes, --out-format and --log-file-format
|
||||||
* destination snapshot (new vs modified and which attributes differ), so ask
|
* need the pre-transfer destination snapshot (new vs modified and which
|
||||||
* the receiver to report it on every per-file check. This is a wire field. */
|
* attributes differ), so ask the receiver to report it on every per-file
|
||||||
config->report_dest_info = config->itemize_changes || config->out_format != NULL;
|
* 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;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2306,7 +2307,7 @@ static bool cli_long_takes_separate_value(const char* arg) {
|
|||||||
"--checksum-choice", "--cc", "--checksum-seed", "--sockopts",
|
"--checksum-choice", "--cc", "--checksum-seed", "--sockopts",
|
||||||
"--remote-option", "--compare-dest", "--copy-dest", "--link-dest",
|
"--remote-option", "--compare-dest", "--copy-dest", "--link-dest",
|
||||||
"--usermap", "--groupmap", "--chown", "--copy-as",
|
"--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++)
|
for (size_t i = 0; i < sizeof(extra) / sizeof(extra[0]); i++)
|
||||||
if (strcmp(arg, extra[i]) == 0)
|
if (strcmp(arg, extra[i]) == 0)
|
||||||
|
|||||||
+15
-10
@@ -908,8 +908,10 @@ static int send_list_only(const Config* config) {
|
|||||||
entries = calloc(capacity, sizeof(ListEntry));
|
entries = calloc(capacity, sizeof(ListEntry));
|
||||||
if (entries == NULL) {
|
if (entries == NULL) {
|
||||||
oom = true;
|
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 {
|
} else {
|
||||||
entries[0].name = str_dup("");
|
|
||||||
entries[0].mode = st.st_mode;
|
entries[0].mode = st.st_mode;
|
||||||
entries[0].mtime = st.st_mtime;
|
entries[0].mtime = st.st_mtime;
|
||||||
entries[0].mtime_nsec = st.st_mtim.tv_nsec;
|
entries[0].mtime_nsec = st.st_mtim.tv_nsec;
|
||||||
@@ -1015,15 +1017,18 @@ static int send_list_only(const Config* config) {
|
|||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Send the delete manifest (keep-set paths plus the protected excluded
|
/* Send the delete manifest to the server. Returns 0 on success, -1 on
|
||||||
prefixes and the --delete-missing-args exact-delete paths) to the server.
|
failure. It carries FOUR sections: the keep-set paths, the protected
|
||||||
Returns 0 on success, -1 on failure. When --delete-excluded is given
|
excluded prefixes, the --delete-missing-args exact-delete paths, and the
|
||||||
`protected` is empty: excluded destination mirrors are then ordinary extras
|
destination-relative directories the sender synchronized this run.
|
||||||
and are removed. When --delete-missing-args is active `missing_args` holds
|
When --delete-excluded is given `protected` is empty: excluded destination
|
||||||
the destination mirrors of missing --files-from entries: each is an explicit
|
mirrors are then ordinary extras and are removed. When
|
||||||
receiver-side deletion request, independent of the extras walk. A NULL
|
--delete-missing-args is active `missing_args` holds the destination mirrors
|
||||||
keep-set / protected / missing list transmits an empty section. All three
|
of missing --files-from entries: each is an explicit receiver-side deletion
|
||||||
sections are unbounded on the sender; the receiver enforces
|
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
|
MAX_MANIFEST_ENTRIES per section and a single MAX_MANIFEST_BYTES budget
|
||||||
shared across the sections, rejecting (with STATUS_ERROR) an over-budget
|
shared across the sections, rejecting (with STATUS_ERROR) an over-budget
|
||||||
frame. A heavily filtered source whose exclusion list is large therefore
|
frame. A heavily filtered source whose exclusion list is large therefore
|
||||||
|
|||||||
+27
-16
@@ -1120,18 +1120,23 @@ Chunk* directory_scanner_next(DirectoryScanner* scanner) {
|
|||||||
if (inspection == 0) {
|
if (inspection == 0) {
|
||||||
/* A user-selection exclude protects its destination mirror from --delete
|
/* A user-selection exclude protects its destination mirror from --delete
|
||||||
unless --delete-excluded; a size prune is always protected. Other
|
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) {
|
if (inspected.excluded) {
|
||||||
char* abs_path = path_cat(scanner->current_path, entry->d_name);
|
char* protected_path = scanner->relative_mode
|
||||||
if (!abs_path) {
|
? child_rel_path(scanner->current_rel, entry->d_name)
|
||||||
|
: path_cat(scanner->current_path, entry->d_name);
|
||||||
|
if (!protected_path) {
|
||||||
scanner->failed = true;
|
scanner->failed = true;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
if (inspected.size_excluded)
|
if (inspected.size_excluded)
|
||||||
scanner_record_size_skipped(scanner, abs_path);
|
scanner_record_size_skipped(scanner, protected_path);
|
||||||
else
|
else
|
||||||
scanner_record_excluded(scanner, abs_path);
|
scanner_record_excluded(scanner, protected_path);
|
||||||
free(abs_path);
|
free(protected_path);
|
||||||
}
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -1510,17 +1515,23 @@ static void scan_root_entry(const ScannerOptions* options, const FilterNode* roo
|
|||||||
if (inspected.excluded)
|
if (inspected.excluded)
|
||||||
sink = inspected.size_excluded ? options->size_skipped_paths : options->excluded_paths;
|
sink = inspected.size_excluded ? options->size_skipped_paths : options->excluded_paths;
|
||||||
if (sink) {
|
if (sink) {
|
||||||
/* A root-level prune protects the destination mirror of the same-named
|
/* A root-level prune protects the destination mirror of the entry's wire
|
||||||
wire path (at the root the bare name is the wire path in every
|
path: under -R + --files-from that is the bare relative name, otherwise
|
||||||
layout). */
|
it is the full source path with a leading '/' removed (matching the
|
||||||
char* abs_path = path_cat(root_directory, entry->d_name);
|
send_path/file_wire_path the scanner hands the sender). */
|
||||||
if (!abs_path) {
|
if (options->relative && options->file_list != NULL) {
|
||||||
ps->failed = true;
|
if (!excluded_sink_append(sink, options->excluded_mutex, entry->d_name))
|
||||||
} else {
|
|
||||||
const char* rel = *abs_path == '/' ? abs_path + 1 : abs_path;
|
|
||||||
if (!excluded_sink_append(sink, options->excluded_mutex, rel))
|
|
||||||
ps->failed = true;
|
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;
|
return;
|
||||||
|
|||||||
+11
-5
@@ -752,10 +752,11 @@ void handler(int file_descriptor) {
|
|||||||
protocol_set_8_bit_output(config->eight_bit_output);
|
protocol_set_8_bit_output(config->eight_bit_output);
|
||||||
/* Server-side per-message protocol deadline for every frame from here on.
|
/* 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
|
* `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
|
* has no --timeout CLI and defaults it to 0). A client's --timeout tightens
|
||||||
* in effect. A client's --timeout tightens only that client's own protocol
|
* only that client's own protocol I/O; the server floors its own deadline at
|
||||||
* I/O and the server's socket read/write timeout is the transport default. */
|
* SERVER_IO_TIMEOUT_SEC so a silent peer can never hold a session slot
|
||||||
protocol_session_set_io_timeout(&session, config->timeout);
|
* 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();
|
const char* authorized_root = utils_get_authorized_root_path();
|
||||||
if (!authorized_root) {
|
if (!authorized_root) {
|
||||||
log_message(LOG_LEVEL_ERROR, "No server-side destination root configured");
|
log_message(LOG_LEVEL_ERROR, "No server-side destination root configured");
|
||||||
@@ -904,7 +905,8 @@ void handler(int file_descriptor) {
|
|||||||
goto done;
|
goto done;
|
||||||
}
|
}
|
||||||
protocol_session_set_max_alloc(&context->session, config->max_alloc);
|
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_store(&context->session.total_allocated_bytes,
|
||||||
atomic_load(&session.total_allocated_bytes));
|
atomic_load(&session.total_allocated_bytes));
|
||||||
pipeline_context_receiver_set_queue_byte_limit(context, RECEIVER_QUEUE_MAX_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;
|
server_iconv_spec = opts.iconv_spec;
|
||||||
signal(SIGINT, cleanup);
|
signal(SIGINT, cleanup);
|
||||||
signal(SIGTERM, 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) {
|
if (opts.stdio_mode) {
|
||||||
/* SSH authenticates the stdio transport outside of FastSync. */
|
/* SSH authenticates the stdio transport outside of FastSync. */
|
||||||
|
|||||||
+22
-8
@@ -46,9 +46,11 @@ static void config_set_defaults(Config* config) {
|
|||||||
config->server_port_set = false;
|
config->server_port_set = false;
|
||||||
config->server_host_set = false;
|
config->server_host_set = false;
|
||||||
/* rsync defaults: --timeout=0 (I/O timeouts disabled) and --contimeout=60.
|
/* 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
|
* (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->timeout = 0;
|
||||||
config->contimeout = 60;
|
config->contimeout = 60;
|
||||||
config->quiet = false;
|
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_MIN &&
|
||||||
config->delta_block_size <= DELTA_BLOCK_SIZE_MAX &&
|
config->delta_block_size <= DELTA_BLOCK_SIZE_MAX &&
|
||||||
config->delta_max_file_size <= DELTA_MAX_FILE_SIZE && config->modify_window >= 0 &&
|
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->skip_compress_count <= MAX_SKIP_COMPRESS_SUFFIXES &&
|
||||||
(!config->chmod_spec || !*config->chmod_spec ||
|
(!config->chmod_spec || !*config->chmod_spec ||
|
||||||
chmod_apply(0, config->chmod_spec, &(mode_t){0})) &&
|
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
|
/* --max-alloc: raw 64-bit value, clamped server-side and installed as the
|
||||||
* session allocation ceiling. Zero means "no alloc limit" (rsync's
|
* session allocation ceiling. A received 0 is rsync's "no alloc limit"; on the
|
||||||
* --max-alloc=0) and is passed through; a non-zero value is clamped to the
|
* receive path it is mapped to the server ceiling so a client can never disable
|
||||||
* server's own ceiling. */
|
* 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) {
|
static bool config_receive_max_alloc(int fd, unsigned long long* value) {
|
||||||
if (!receive_n_data(fd, value, sizeof(*value)))
|
if (!receive_n_data(fd, value, sizeof(*value)))
|
||||||
return false;
|
return false;
|
||||||
if (*value > MAX_SERVER_ALLOC)
|
if (*value == 0 || *value > MAX_SERVER_ALLOC)
|
||||||
*value = MAX_SERVER_ALLOC;
|
*value = MAX_SERVER_ALLOC;
|
||||||
protocol_session_set_max_alloc(NULL, *value);
|
protocol_session_set_max_alloc(NULL, *value);
|
||||||
return true;
|
return true;
|
||||||
@@ -1362,7 +1366,8 @@ Config* config_receive_with_validate(int file_descriptor, ConfigValidateFunc val
|
|||||||
!receive_output_options(file_descriptor, config, &budget))
|
!receive_output_options(file_descriptor, config, &budget))
|
||||||
goto error;
|
goto error;
|
||||||
if (config->compress_choice[0] != '\0' && strcmp(config->compress_choice, "zstd") != 0 &&
|
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);
|
char* escaped_choice = output_escape(config->compress_choice, config->eight_bit_output);
|
||||||
log_message(LOG_LEVEL_ERROR, "Unsupported compression choice: %s",
|
log_message(LOG_LEVEL_ERROR, "Unsupported compression choice: %s",
|
||||||
escaped_choice ? escaped_choice : "<allocation failed>");
|
escaped_choice ? escaped_choice : "<allocation failed>");
|
||||||
@@ -1373,6 +1378,15 @@ Config* config_receive_with_validate(int file_descriptor, ConfigValidateFunc val
|
|||||||
free(escaped_choice);
|
free(escaped_choice);
|
||||||
goto error;
|
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)) {
|
if (!validate_received_config(config)) {
|
||||||
log_message(LOG_LEVEL_ERROR, "Invalid configuration received from client");
|
log_message(LOG_LEVEL_ERROR, "Invalid configuration received from client");
|
||||||
send_error_detail(file_descriptor, "invalid configuration received from client");
|
send_error_detail(file_descriptor, "invalid configuration received from client");
|
||||||
|
|||||||
+4
-3
@@ -328,9 +328,10 @@ typedef struct Config {
|
|||||||
char* tls_key;
|
char* tls_key;
|
||||||
char* tls_ca;
|
char* tls_ca;
|
||||||
/* --timeout: per-message I/O deadline in seconds. 0 (rsync's default)
|
/* --timeout: per-message I/O deadline in seconds. 0 (rsync's default)
|
||||||
* disables the deadline entirely on both the socket layer and the protocol
|
* disables the deadline entirely on the client's own socket and protocol
|
||||||
* layer; a positive value sets it. See protocol_session_set_io_timeout and
|
* layers; a positive value sets it. A server session never inherits the
|
||||||
* tcp_set_timeouts. */
|
* disabled value: it applies the SERVER_IO_TIMEOUT_SEC floor (see
|
||||||
|
* protocol_server_io_timeout_sec and tcp_set_timeouts). */
|
||||||
int timeout;
|
int timeout;
|
||||||
/* --contimeout: connect()/accept timeout in seconds (rsync's default 60);
|
/* --contimeout: connect()/accept timeout in seconds (rsync's default 60);
|
||||||
* 0 disables it. Transport layer only. */
|
* 0 disables it. Transport layer only. */
|
||||||
|
|||||||
@@ -182,6 +182,7 @@ File* file_create(const char* path) {
|
|||||||
file->rdev_major = 0;
|
file->rdev_major = 0;
|
||||||
file->rdev_minor = 0;
|
file->rdev_minor = 0;
|
||||||
file->xattrs = NULL;
|
file->xattrs = NULL;
|
||||||
|
file->dest_state = (OutputDestState){0};
|
||||||
return file;
|
return file;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+41
-27
@@ -295,12 +295,17 @@ static FileSaveResult file_save_hardlink_sibling(const char* root_directory, con
|
|||||||
free(destination_path);
|
free(destination_path);
|
||||||
return absent_result;
|
return absent_result;
|
||||||
}
|
}
|
||||||
/* Resolve a relative --temp-dir against the destination root, exactly as the
|
/* Resolve a relative --temp-dir under the destination root, exactly as the
|
||||||
* primary save path does; an absolute one is used verbatim. */
|
* primary save path does; an absolute or `..`-escaping value is rejected. */
|
||||||
char* resolved_temp = NULL;
|
char* resolved_temp = NULL;
|
||||||
if (cfg->temp_dir) {
|
if (cfg->temp_dir) {
|
||||||
resolved_temp =
|
if (cfg->temp_dir[0] == '/' || has_path_traversal(cfg->temp_dir)) {
|
||||||
cfg->temp_dir[0] == '/' ? str_dup(cfg->temp_dir) : path_cat(root_directory, 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) {
|
if (!resolved_temp) {
|
||||||
free(content);
|
free(content);
|
||||||
free(first_disk);
|
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);
|
return file_save_hardlink_sibling(root_directory, file, config);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* These options arrive from the client. --backup-dir and --partial-dir are
|
/* These options arrive from the client. --backup-dir, --partial-dir and
|
||||||
names below the server root, never independent filesystem roots: an
|
--temp-dir are names below the server root, never independent filesystem
|
||||||
absolute or `..`-escaping value is rejected outright. --temp-dir is
|
roots: an absolute or `..`-escaping value is rejected outright (rsync's
|
||||||
deliberately NOT confined: rsync accepts any temp dir (absolute, or
|
daemon confines temp-dir to the module the same way). A relative temp dir
|
||||||
relative to the destination root), including one outside the destination
|
is resolved under the receive root below; if that resolution still lands on
|
||||||
tree or on another filesystem, and falls back to a non-atomic copy when
|
a different filesystem than the destination the install falls back to a
|
||||||
the install rename hits EXDEV. */
|
non-atomic copy (see file_to_disk_secure_impl), never an abort. */
|
||||||
if ((backup_dir && (backup_dir[0] == '/' || has_path_traversal(backup_dir))) ||
|
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;
|
return FILE_SAVE_ERROR;
|
||||||
if (backup_dir && !(confined_backup = path_cat(root_directory, backup_dir)))
|
if (backup_dir && !(confined_backup = path_cat(root_directory, backup_dir)))
|
||||||
return FILE_SAVE_ERROR;
|
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
|
/* A configured --temp-dir sends the temporary working copy to a scratch
|
||||||
directory; the engine then atomically renames the completed file into the
|
directory; the engine then atomically renames the completed file into the
|
||||||
final destination directory. rsync resolves a relative temp dir against
|
final destination directory. A relative temp dir is resolved under the
|
||||||
the destination directory and uses an absolute one verbatim, requiring
|
receive root and must already exist (an absolute or `..`-escaping value was
|
||||||
that it already exist; the engine falls back to a non-atomic copy on
|
rejected above); the engine falls back to a non-atomic copy on EXDEV. The
|
||||||
EXDEV. The partial-dir flow already keeps its working copy in a separate
|
partial-dir flow already keeps its working copy in a separate directory and
|
||||||
directory and --inplace writes directly, so neither diverts through the
|
--inplace writes directly, so neither diverts through the scratch dir
|
||||||
scratch dir (matching rsync, where --inplace/--partial-dir supersede
|
(matching rsync, where --inplace/--partial-dir supersede --temp-dir). */
|
||||||
--temp-dir). */
|
|
||||||
char* confined_temp = NULL;
|
char* confined_temp = NULL;
|
||||||
bool use_temp_dir = temp_dir != NULL && !inplace && !use_partial_root;
|
bool use_temp_dir = temp_dir != NULL && !inplace && !use_partial_root;
|
||||||
if (use_temp_dir) {
|
if (use_temp_dir) {
|
||||||
if (temp_dir[0] == '/')
|
confined_temp = path_cat(root_directory, temp_dir);
|
||||||
confined_temp = str_dup(temp_dir);
|
|
||||||
else
|
|
||||||
confined_temp = path_cat(root_directory, temp_dir);
|
|
||||||
if (!confined_temp)
|
if (!confined_temp)
|
||||||
goto fail;
|
goto fail;
|
||||||
/* A user-supplied trailing slash would leave the scratch path ending in
|
/* 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++;
|
idx++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
size_t remaining =
|
/* Clamp rather than subtract: an accounting bug where deleted already exceeds
|
||||||
budget->max_delete == SIZE_MAX ? SIZE_MAX : budget->max_delete - budget->deleted;
|
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 deleted = 0;
|
||||||
size_t skipped = 0;
|
size_t skipped = 0;
|
||||||
DeleteWalkResult result =
|
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
|
parity); the now-empty directory itself costs one more. A run that
|
||||||
hits the cap leaves the remaining entries in place. */
|
hits the cap leaves the remaining entries in place. */
|
||||||
ArrayList* no_keeps = array_list_create(free);
|
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_deleted = 0;
|
||||||
size_t contents_skipped = 0;
|
size_t contents_skipped = 0;
|
||||||
DeleteWalkResult walk =
|
DeleteWalkResult walk =
|
||||||
@@ -3214,7 +3227,8 @@ static bool delete_missing_args_budgeted(const Config* config, DeleteManifest* m
|
|||||||
budget->limit_hit = true;
|
budget->limit_hit = true;
|
||||||
budget->skipped++;
|
budget->skipped++;
|
||||||
} else if (file_remove_tree_secure(full)) {
|
} 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;
|
removed = true;
|
||||||
} else {
|
} else {
|
||||||
ok = false;
|
ok = false;
|
||||||
|
|||||||
@@ -104,6 +104,10 @@ int protocol_get_io_timeout_sec(void) {
|
|||||||
return session->io_timeout_sec > 0 ? session->io_timeout_sec : 0;
|
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) {
|
void protocol_session_set_max_alloc(ProtocolSession* session, unsigned long long max_alloc) {
|
||||||
if (!session)
|
if (!session)
|
||||||
session = bound_session ? bound_session : &legacy_io_session;
|
session = bound_session ? bound_session : &legacy_io_session;
|
||||||
@@ -495,6 +499,8 @@ static const char* status_to_string(Status status) {
|
|||||||
return "ERROR_DETAIL";
|
return "ERROR_DETAIL";
|
||||||
case STATUS_DRY_RUN_TRANSFER:
|
case STATUS_DRY_RUN_TRANSFER:
|
||||||
return "DRY_RUN_TRANSFER";
|
return "DRY_RUN_TRANSFER";
|
||||||
|
case STATUS_DELETE_LIMIT:
|
||||||
|
return "DELETE_LIMIT";
|
||||||
case STATUS_DEST_INFO:
|
case STATUS_DEST_INFO:
|
||||||
return "DEST_INFO";
|
return "DEST_INFO";
|
||||||
default:
|
default:
|
||||||
|
|||||||
+23
-11
@@ -34,6 +34,11 @@
|
|||||||
#define DEFAULT_MAX_ALLOC (1ULL * 1024 * 1024 * 1024)
|
#define DEFAULT_MAX_ALLOC (1ULL * 1024 * 1024 * 1024)
|
||||||
/* Server policy ceiling for a client-provided allocation limit. */
|
/* Server policy ceiling for a client-provided allocation limit. */
|
||||||
#define MAX_SERVER_ALLOC (256ULL * 1024 * 1024)
|
#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,
|
/* Bounded cumulative per-connection receive budget. In-flight wire buffers,
|
||||||
decompression buffers and queued (not yet written) file payloads for a
|
decompression buffers and queued (not yet written) file payloads for a
|
||||||
connection must stay within this ceiling. */
|
connection must stay within this ceiling. */
|
||||||
@@ -59,10 +64,12 @@ typedef struct ProtocolSession {
|
|||||||
bool eight_bit_output;
|
bool eight_bit_output;
|
||||||
unsigned long long max_alloc;
|
unsigned long long max_alloc;
|
||||||
/* Per-session deadline (seconds) applied to every protocol send/receive by
|
/* Per-session deadline (seconds) applied to every protocol send/receive by
|
||||||
* protocol_send_n_data / protocol_receive_n_data. Defaults to the built-in
|
* protocol_send_n_data / protocol_receive_n_data. The initialized default is
|
||||||
* 60 s window; a value <= 0 falls back to that default. Set from the
|
* the built-in 60 s window; a value <= 0 disables the deadline (rsync's
|
||||||
* negotiated Config->timeout so --timeout is honored by the poll()-driven
|
* --timeout=0). Set from the negotiated Config->timeout so --timeout is
|
||||||
* protocol I/O, not just the socket SO_RCVTIMEO/SO_SNDTIMEO. */
|
* 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;
|
int io_timeout_sec;
|
||||||
} ProtocolSession;
|
} ProtocolSession;
|
||||||
|
|
||||||
@@ -189,15 +196,20 @@ void protocol_session_unbind(void);
|
|||||||
void protocol_session_set_ssl(ProtocolSession* session, SSL* ssl);
|
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_bwlimit(ProtocolSession* session, unsigned long long bytes_per_sec);
|
||||||
void protocol_session_set_max_alloc(ProtocolSession* session, unsigned long long max_alloc);
|
void protocol_session_set_max_alloc(ProtocolSession* session, unsigned long long max_alloc);
|
||||||
/* Override the per-message send/receive deadline for this session.
|
/* Override the per-message send/receive deadline for this session. The value
|
||||||
* `sec` <= 0 restores the built-in 60 s default (used for --timeout=0/unset).
|
* is stored verbatim: a positive value sets the deadline, `sec` <= 0 disables
|
||||||
* An explicit long deadline (e.g. the delete-ack wait) is applied per-call by
|
* it (rsync's --timeout=0). An explicit long deadline (e.g. the delete-ack
|
||||||
* protocol_receive_status_timed and is unaffected by this setter. */
|
* 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);
|
void protocol_session_set_io_timeout(ProtocolSession* session, int sec);
|
||||||
/* Effective per-message I/O deadline (seconds) for the currently-bound session,
|
/* 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
|
* Zero means the deadline is disabled (rsync's --timeout=0). Used by the
|
||||||
* which bypasses the protocol send primitive. */
|
* plaintext sendfile path which bypasses the protocol send primitive. */
|
||||||
int protocol_get_io_timeout_sec(void);
|
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_alloc(size_t size);
|
||||||
void* protocol_realloc(void* ptr, size_t size);
|
void* protocol_realloc(void* ptr, size_t size);
|
||||||
void protocol_session_set_8_bit_output(ProtocolSession* session, bool enabled);
|
void protocol_session_set_8_bit_output(ProtocolSession* session, bool enabled);
|
||||||
|
|||||||
@@ -254,6 +254,13 @@ def _wait_for_port(port, timeout=5):
|
|||||||
|
|
||||||
|
|
||||||
def _wait_proc(proc, 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:
|
try:
|
||||||
proc.wait(timeout=timeout)
|
proc.wait(timeout=timeout)
|
||||||
except subprocess.TimeoutExpired:
|
except subprocess.TimeoutExpired:
|
||||||
|
|||||||
@@ -1379,6 +1379,20 @@ class TestChecksumChoice:
|
|||||||
port=shared_server.port)
|
port=shared_server.port)
|
||||||
assert result.returncode != 0, f"{bad} must be rejected"
|
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("algo", ["xxh64", "xxh3", "xxh128", "md5"])
|
||||||
@pytest.mark.parametrize("mt", [False, True])
|
@pytest.mark.parametrize("mt", [False, True])
|
||||||
def test_unchanged_skipped_and_bytes_preserved(self, shared_server, algo, mt):
|
def test_unchanged_skipped_and_bytes_preserved(self, shared_server, algo, mt):
|
||||||
@@ -2215,17 +2229,10 @@ class TestTempDir:
|
|||||||
port=shared_server.port)
|
port=shared_server.port)
|
||||||
assert result.returncode != 0, "a missing relative --temp-dir must fail"
|
assert result.returncode != 0, "a missing relative --temp-dir must fail"
|
||||||
|
|
||||||
missing_abs = os.path.join(TEST_DATA_DIR, "no_such_abs_scratch")
|
def test_temp_dir_absolute_rejected(self, shared_server):
|
||||||
assert not os.path.lexists(missing_abs)
|
"""The receiver confines --temp-dir to the destination root: an absolute
|
||||||
clean_dir(dest)
|
(or `..`-escaping) value is rejected before any write, so a client can
|
||||||
result, _ = run_client(source, dest, flags=["--temp-dir", missing_abs],
|
never make the receiver create scratch files in an arbitrary directory."""
|
||||||
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."""
|
|
||||||
source = self._make_source("tempdir_abs_src")
|
source = self._make_source("tempdir_abs_src")
|
||||||
dest = os.path.join(TEST_DATA_DIR, "tempdir_abs_dst")
|
dest = os.path.join(TEST_DATA_DIR, "tempdir_abs_dst")
|
||||||
clean_dir(dest)
|
clean_dir(dest)
|
||||||
@@ -2235,13 +2242,14 @@ class TestTempDir:
|
|||||||
|
|
||||||
result, _ = run_client(source, dest, flags=["--temp-dir", scratch],
|
result, _ = run_client(source, dest, flags=["--temp-dir", scratch],
|
||||||
port=shared_server.port)
|
port=shared_server.port)
|
||||||
assert result.returncode == 0, f"absolute temp-dir sync failed: {result.stderr[:200]}"
|
assert result.returncode != 0, "an absolute --temp-dir must be rejected"
|
||||||
received = get_dest_received_dir(dest, source)
|
assert os.listdir(scratch) == [], "receiver wrote into an unconfined temp dir"
|
||||||
mismatches, missing = verify_transfer(source, received)
|
# A relative traversal is rejected for the same reason.
|
||||||
assert not missing, f"Missing: {missing}"
|
result, _ = run_client(source, dest, flags=["--temp-dir=../escape_scratch"],
|
||||||
assert not mismatches, f"Mismatch: {mismatches}"
|
port=shared_server.port)
|
||||||
self._assert_clean_scratch(scratch)
|
assert result.returncode != 0, "a `..` --temp-dir must be rejected"
|
||||||
shutil.rmtree(scratch, ignore_errors=True)
|
shutil.rmtree(scratch, ignore_errors=True)
|
||||||
|
shutil.rmtree(os.path.join(TEST_DATA_DIR, "escape_scratch"), ignore_errors=True)
|
||||||
|
|
||||||
|
|
||||||
class TestTimeoutAndAllocLimits:
|
class TestTimeoutAndAllocLimits:
|
||||||
@@ -2285,7 +2293,8 @@ class TestTimeoutAndAllocLimits:
|
|||||||
assert not missing and not mismatches
|
assert not missing and not mismatches
|
||||||
|
|
||||||
def test_temp_dir_cross_filesystem_fallback(self, shared_server):
|
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
|
copy instead of aborting (rsync parity). Skipped when no second
|
||||||
filesystem is available."""
|
filesystem is available."""
|
||||||
shm = "/dev/shm"
|
shm = "/dev/shm"
|
||||||
@@ -2298,14 +2307,18 @@ class TestTimeoutAndAllocLimits:
|
|||||||
os.makedirs(scratch)
|
os.makedirs(scratch)
|
||||||
try:
|
try:
|
||||||
source, dest = self._seed("tempdir_xdev_src")
|
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)
|
port=shared_server.port)
|
||||||
assert result.returncode == 0, f"cross-fs temp-dir failed: {result.stderr[:300]}"
|
assert result.returncode == 0, f"cross-fs temp-dir failed: {result.stderr[:300]}"
|
||||||
received = get_dest_received_dir(dest, source)
|
received = get_dest_received_dir(dest, source)
|
||||||
mismatches, missing = verify_transfer(source, received)
|
mismatches, missing = verify_transfer(source, received)
|
||||||
assert not missing, f"Missing: {missing}"
|
assert not missing, f"Missing: {missing}"
|
||||||
assert not mismatches, f"Mismatch: {mismatches}"
|
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:
|
finally:
|
||||||
shutil.rmtree(scratch, ignore_errors=True)
|
shutil.rmtree(scratch, ignore_errors=True)
|
||||||
|
|
||||||
@@ -2910,6 +2923,43 @@ class TestRelativeFilesFrom:
|
|||||||
assert not os.path.exists(os.path.join(dest, "sub", "y.txt")), \
|
assert not os.path.exists(os.path.join(dest, "sub", "y.txt")), \
|
||||||
"directory-listed --delete did not remove the in-scope extra"
|
"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:
|
class TestMissingArgs:
|
||||||
"""--ignore-missing-args / --delete-missing-args: a --files-from entry that
|
"""--ignore-missing-args / --delete-missing-args: a --files-from entry that
|
||||||
|
|||||||
@@ -1518,6 +1518,9 @@ static void test_parse_args_compress_choice_parity() {
|
|||||||
int positional_args[2];
|
int positional_args[2];
|
||||||
int positional_count = 0;
|
int positional_count = 0;
|
||||||
EXPECT_EQ_INT(parse_args(cfg, 5, argv, positional_args, &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);
|
config_delete(cfg);
|
||||||
}
|
}
|
||||||
static const char* const bad[] = {"lz4", "zlib", "zlibx", "bogus"};
|
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;
|
int positional_count = 0;
|
||||||
EXPECT_EQ_INT(parse_args(cfg, 4, argv, positional_args, &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");
|
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);
|
config_delete(cfg);
|
||||||
|
|
||||||
cfg = config_create();
|
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_INT(parse_args(cfg, 5, separate_argv, positional_args, &positional_count), 0);
|
||||||
EXPECT_EQ_STR(cfg->log_file_format, "%n %M");
|
EXPECT_EQ_STR(cfg->log_file_format, "%n %M");
|
||||||
config_delete(cfg);
|
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. */
|
/* --delay-updates is a plain boolean receiver option. */
|
||||||
@@ -3344,6 +3404,24 @@ static void test_parse_args_remote_option_multiple() {
|
|||||||
config_delete(cfg);
|
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. */
|
/* Space-separated form "--remote-option OPT" also parses. */
|
||||||
static void test_parse_args_remote_option_space_form() {
|
static void test_parse_args_remote_option_space_form() {
|
||||||
Config* cfg = valid_client_config();
|
Config* cfg = valid_client_config();
|
||||||
@@ -4225,6 +4303,8 @@ void test_client_cli() {
|
|||||||
test_parse_args_list_only();
|
test_parse_args_list_only();
|
||||||
test_parse_args_out_format();
|
test_parse_args_out_format();
|
||||||
test_parse_args_log_file_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_aliases();
|
||||||
test_parse_args_checksum_choice_requires_value();
|
test_parse_args_checksum_choice_requires_value();
|
||||||
test_parse_args_checksum_choice_equals_forms();
|
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_default_false();
|
||||||
test_parse_args_trust_sender();
|
test_parse_args_trust_sender();
|
||||||
test_parse_args_remote_option_multiple();
|
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_space_form();
|
||||||
test_parse_args_remote_option_missing_value();
|
test_parse_args_remote_option_missing_value();
|
||||||
test_parse_args_remote_option_rejects_bad_values();
|
test_parse_args_remote_option_rejects_bad_values();
|
||||||
|
|||||||
@@ -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() {
|
static void test_config_send_receive_version_mismatch() {
|
||||||
/* A peer using the previous wire format must be rejected. */
|
/* A peer using the previous wire format must be rejected. */
|
||||||
Config* cfg = config_create();
|
Config* cfg = config_create();
|
||||||
@@ -3051,6 +3128,8 @@ void test_config() {
|
|||||||
test_pipeline_receiver_lifecycle();
|
test_pipeline_receiver_lifecycle();
|
||||||
if (!is_running_under_valgrind()) {
|
if (!is_running_under_valgrind()) {
|
||||||
test_config_send_receive();
|
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_local_only_fields_not_serialized();
|
||||||
test_config_send_receive_version_mismatch();
|
test_config_send_receive_version_mismatch();
|
||||||
test_config_receive_truncated();
|
test_config_receive_truncated();
|
||||||
|
|||||||
@@ -28,6 +28,10 @@ static void test_file_create() {
|
|||||||
EXPECT_NULL(f->data->data);
|
EXPECT_NULL(f->data->data);
|
||||||
EXPECT_EQ_INT((int)f->data->size, 0);
|
EXPECT_EQ_INT((int)f->data->size, 0);
|
||||||
EXPECT_NULL(f->metadata);
|
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);
|
file_destroy(f);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -326,6 +330,52 @@ static void test_file_save_to_disk_partial_install() {
|
|||||||
rmdir(root);
|
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
|
/* Issue #251: file_save_to_disk_full must distinguish receiver-side skips
|
||||||
(--existing/--ignore-existing/--update) from real writes so the sender can
|
(--existing/--ignore-existing/--update) from real writes so the sender can
|
||||||
decide whether --remove-source-files may unlink its source. */
|
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);
|
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() {
|
void test_file() {
|
||||||
test_file_create();
|
test_file_create();
|
||||||
test_file_special_rdev_valid();
|
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();
|
||||||
test_file_save_to_disk_ignore_existing_entry_types();
|
test_file_save_to_disk_ignore_existing_entry_types();
|
||||||
test_file_save_to_disk_partial_install();
|
test_file_save_to_disk_partial_install();
|
||||||
|
test_file_save_to_disk_temp_dir_confined();
|
||||||
test_file_save_to_disk_reports_skips();
|
test_file_save_to_disk_reports_skips();
|
||||||
test_file_write_to_disk_sparse_preserves_holes();
|
test_file_write_to_disk_sparse_preserves_holes();
|
||||||
test_file_write_to_disk_partial_retention();
|
test_file_write_to_disk_partial_retention();
|
||||||
@@ -1936,4 +2062,5 @@ void test_file() {
|
|||||||
test_inplace_overwrite_truncates_shorter_payload();
|
test_inplace_overwrite_truncates_shorter_payload();
|
||||||
test_inplace_refuses_fifo_destination();
|
test_inplace_refuses_fifo_destination();
|
||||||
test_inplace_refuses_device_destination();
|
test_inplace_refuses_device_destination();
|
||||||
|
test_manifest_delete_missing_dir_budget_double_count();
|
||||||
}
|
}
|
||||||
|
|||||||
+13
-3
@@ -522,10 +522,19 @@ static void test_data_create_starts_uncharged_and_unowned() {
|
|||||||
data_destroy(reserved);
|
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() {
|
static void test_protocol_session_io_timeout() {
|
||||||
/* Default is the built-in 60 s window; the setter stores exactly what it is
|
/* The default is the built-in 60 s window; the setter stores exactly what it
|
||||||
* given (<= 0 means "fall back to the default") so callers can propagate
|
* is given (<= 0 disables the deadline, matching rsync's --timeout=0) so
|
||||||
* --timeout without special-casing 0. */
|
* callers can propagate --timeout without special-casing 0. */
|
||||||
ProtocolSession session;
|
ProtocolSession session;
|
||||||
protocol_session_init(&session, -1, -1);
|
protocol_session_init(&session, -1, -1);
|
||||||
EXPECT_EQ_INT(session.io_timeout_sec, 60);
|
EXPECT_EQ_INT(session.io_timeout_sec, 60);
|
||||||
@@ -670,6 +679,7 @@ void test_protocol() {
|
|||||||
test_send_receive_int();
|
test_send_receive_int();
|
||||||
test_send_receive_status();
|
test_send_receive_status();
|
||||||
test_protocol_session_io_timeout();
|
test_protocol_session_io_timeout();
|
||||||
|
test_protocol_server_io_timeout_floor();
|
||||||
test_send_receive_status_timed();
|
test_send_receive_status_timed();
|
||||||
test_receive_status_keepalive_skips_reply();
|
test_receive_status_keepalive_skips_reply();
|
||||||
test_receive_status_keepalive_aborts();
|
test_receive_status_keepalive_aborts();
|
||||||
|
|||||||
Reference in New Issue
Block a user