Compare commits
12
Commits
33980bc4c8
...
1167e7970b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1167e7970b | ||
|
|
e98729f00e | ||
|
|
c0020364b2 | ||
|
|
d7ac940a6b | ||
|
|
be20e836de | ||
|
|
b549887138 | ||
|
|
1ffd4744c6 | ||
|
|
494cef2a0b | ||
|
|
ed7527cc2c | ||
|
|
934defa965 | ||
|
|
ade9be8600 | ||
|
|
707bb659e8 |
@@ -99,6 +99,7 @@ set(SHARED_SRCS
|
||||
src/shared/daemon_limits.c
|
||||
src/shared/data.c
|
||||
src/shared/delay_updates.c
|
||||
src/shared/delete.c
|
||||
src/shared/delete_commit.c
|
||||
src/shared/delete_plan.c
|
||||
src/shared/delta.c
|
||||
|
||||
+6
-1
@@ -1013,7 +1013,12 @@ integration tests unless it is explicitly listed as a limitation.
|
||||
|
||||
- **`--temp-dir` is confined to the receive root on the receiver:** a relative
|
||||
dir resolves below it; an absolute path or one containing `..` is rejected.
|
||||
An `EXDEV` install falls back to a non-atomic copy instead of aborting.
|
||||
An `EXDEV` install falls back to a non-atomic copy instead of aborting. (The
|
||||
confined receiver path cannot be mount-tested in the CI container — no
|
||||
`CAP_SYS_ADMIN` and unprivileged user namespaces are disabled — so the
|
||||
cross-filesystem fallback is exercised end-to-end through the unconfined local
|
||||
`--read-batch` apply against a `/dev/shm` scratch dir, in
|
||||
`tests/integration/test_temp_dir_exdev.py`.)
|
||||
- **Deletion scoping:** the manifest carries the synchronized directories, so
|
||||
the extras walk only visits their subtrees; `--files-from` subsets no longer
|
||||
delete untransmitted paths outside the listed directories.
|
||||
|
||||
@@ -245,7 +245,7 @@ static char* change_render_name_uptodate(const ChangeEvent* event) {
|
||||
* resolves to xxh128, so an explicit selection and the default both render the
|
||||
* selected algorithm's digest. */
|
||||
static ChecksumAlgo out_format_checksum_algo(const Config* config) {
|
||||
return (ChecksumAlgo)config->checksum_transfer_algo;
|
||||
return (ChecksumAlgo)config->cli.checksum_transfer_algo;
|
||||
}
|
||||
|
||||
/* Render a digest as rsync's sum_as_hex: xxh128 prints the HIGH 64-bit half
|
||||
|
||||
+34
-31
@@ -170,7 +170,7 @@ static int set_positive_int_option(int* dest, const char* value, const char* opt
|
||||
* name is a hard error with rsync's exit code 4, never a silent no-op. */
|
||||
static int set_compression_choice(Config* config, const char* value) {
|
||||
if (!value) {
|
||||
config->cli_exit_code = 4;
|
||||
config->cli.cli_exit_code = 4;
|
||||
return -1;
|
||||
}
|
||||
int algo;
|
||||
@@ -178,7 +178,7 @@ static int set_compression_choice(Config* config, const char* value) {
|
||||
algo = compression_choice_resolve();
|
||||
if (algo < 0) {
|
||||
log_message(LOG_LEVEL_ERROR, "RSYNC_COMPRESS_LIST names no supported compression algorithm");
|
||||
config->cli_exit_code = 4;
|
||||
config->cli.cli_exit_code = 4;
|
||||
return -1;
|
||||
}
|
||||
} else {
|
||||
@@ -189,7 +189,7 @@ static int set_compression_choice(Config* config, const char* value) {
|
||||
"--compress-choice '%s' is not a supported algorithm; FastSync supports zstd, "
|
||||
"lz4, zlib, zlibx, none or auto",
|
||||
value);
|
||||
config->cli_exit_code = 4;
|
||||
config->cli.cli_exit_code = 4;
|
||||
return -1;
|
||||
}
|
||||
const char* canonical = compression_algo_name((CompressionAlgo)algo);
|
||||
@@ -226,7 +226,7 @@ static int resolve_checksum_name(const char* name, size_t len, int* out) {
|
||||
* resolves to FastSync's negotiated default (xxh128). */
|
||||
static int set_checksum_choice(Config* config, const char* value) {
|
||||
if (!value) {
|
||||
config->cli_exit_code = 4;
|
||||
config->cli.cli_exit_code = 4;
|
||||
return -1;
|
||||
}
|
||||
const char* comma = strchr(value, ',');
|
||||
@@ -244,7 +244,7 @@ static int set_checksum_choice(Config* config, const char* value) {
|
||||
"--checksum-choice '%s' is invalid; FastSync supports xxh64 (or xxhash), xxh128, "
|
||||
"xxh3, md5, md4, sha1, none or auto, optionally as 'transfer,pre-transfer'",
|
||||
value);
|
||||
config->cli_exit_code = 4;
|
||||
config->cli.cli_exit_code = 4;
|
||||
return -1;
|
||||
}
|
||||
int negotiated = -1;
|
||||
@@ -252,7 +252,7 @@ static int set_checksum_choice(Config* config, const char* value) {
|
||||
negotiated = checksum_choice_resolve();
|
||||
if (negotiated < 0) {
|
||||
log_message(LOG_LEVEL_ERROR, "RSYNC_CHECKSUM_LIST names no supported checksum algorithm");
|
||||
config->cli_exit_code = 4;
|
||||
config->cli.cli_exit_code = 4;
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
@@ -264,8 +264,8 @@ static int set_checksum_choice(Config* config, const char* value) {
|
||||
pre = negotiated;
|
||||
|
||||
config->checksum_algo = pre;
|
||||
config->checksum_transfer_algo = transfer;
|
||||
config->checksum_choice_set = true;
|
||||
config->cli.checksum_transfer_algo = transfer;
|
||||
config->cli.checksum_choice_set = true;
|
||||
/* rsync: "none" for the transfer checksum forces --whole-file. */
|
||||
if (transfer == (int)CHECKSUM_ALGO_NONE)
|
||||
config->whole_file = true;
|
||||
@@ -963,7 +963,10 @@ static const OptionEntry OPTION_TABLE[] = {
|
||||
* faithful no-op (accepted silently, never consumes an argument). */
|
||||
{"--recursive", "-r", OPT_NOOP, 0},
|
||||
{"--update", "-u", OPT_FLAG, offsetof(Config, update)},
|
||||
{"--old-args", NULL, OPT_FLAG, offsetof(Config, old_args)},
|
||||
/* rsync's --old-args: accepted for CLI compatibility as a documented no-op
|
||||
* (the remote server path is always safely quoted; see usage.c). It is
|
||||
* recognized but stores no Config field. */
|
||||
{"--old-args", NULL, OPT_NOOP, 0},
|
||||
{"--rsh", "-e", OPT_STRING, offsetof(Config, rsh_command)},
|
||||
{"--blocking-io", NULL, OPT_FLAG, offsetof(Config, blocking_io)},
|
||||
{"--links", "-l", OPT_FLAG, offsetof(Config, follow_symlinks)},
|
||||
@@ -1196,12 +1199,12 @@ static int apply_negation(Config* config, const char* arg) {
|
||||
config->preserve_times = false;
|
||||
config->preserve_owner = false;
|
||||
config->preserve_group = false;
|
||||
config->metadata_explicitly_disabled = true;
|
||||
config->cli.metadata_explicitly_disabled = true;
|
||||
/* --no-preserve is an explicit opt-out of the whole bundle: record it so
|
||||
* the --incremental/--delta auto-preserve in cli_finalize_config does not
|
||||
* silently re-enable perms/times. */
|
||||
config->preserve_perms_explicit_off = true;
|
||||
config->preserve_times_explicit_off = true;
|
||||
config->cli.preserve_perms_explicit_off = true;
|
||||
config->cli.preserve_times_explicit_off = true;
|
||||
return 0;
|
||||
}
|
||||
*(bool*)((char*)config + entry->offset) = false;
|
||||
@@ -1209,9 +1212,9 @@ static int apply_negation(Config* config, const char* arg) {
|
||||
* auto-preserve the OTHER attribute without undoing this one. A later
|
||||
* -p/-t sets the attribute directly; this flag only gates the implication. */
|
||||
if (entry->offset == offsetof(Config, preserve_perms))
|
||||
config->preserve_perms_explicit_off = true;
|
||||
config->cli.preserve_perms_explicit_off = true;
|
||||
else if (entry->offset == offsetof(Config, preserve_times))
|
||||
config->preserve_times_explicit_off = true;
|
||||
config->cli.preserve_times_explicit_off = true;
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -1440,7 +1443,7 @@ static bool cli_handle_range_time_options(CliParseCtx* ctx) {
|
||||
ctx->exit_code = -1;
|
||||
return true;
|
||||
}
|
||||
config->stop_at_set = true;
|
||||
config->cli.stop_at_set = true;
|
||||
return true;
|
||||
}
|
||||
if (strcmp(arg, "--stop-at") == 0) {
|
||||
@@ -1455,7 +1458,7 @@ static bool cli_handle_range_time_options(CliParseCtx* ctx) {
|
||||
ctx->exit_code = -1;
|
||||
return true;
|
||||
}
|
||||
config->stop_at_set = true;
|
||||
config->cli.stop_at_set = true;
|
||||
return true;
|
||||
}
|
||||
const char* threads_prefix = "--compress-threads=";
|
||||
@@ -1526,7 +1529,7 @@ static bool cli_handle_table_option(CliParseCtx* ctx) {
|
||||
return true;
|
||||
}
|
||||
if (entry->offset == offsetof(Config, compression_level))
|
||||
config->compression_level_set = true;
|
||||
config->cli.compression_level_set = true;
|
||||
if (entry->offset == offsetof(Config, chmod_spec)) {
|
||||
mode_t ignored;
|
||||
if (!chmod_apply(0, config->chmod_spec, &ignored)) {
|
||||
@@ -1539,7 +1542,7 @@ static bool cli_handle_table_option(CliParseCtx* ctx) {
|
||||
defaults to 127.0.0.1, so a value check cannot distinguish it). Used
|
||||
by --dry-run to route an explicit remote target to the server. */
|
||||
if (entry->offset == offsetof(Config, server_host))
|
||||
config->server_host_set = true;
|
||||
config->cli.server_host_set = true;
|
||||
}
|
||||
} else if (apply_table_option(config, entry, NULL) != 0) {
|
||||
ctx->exit_code = -1;
|
||||
@@ -1813,7 +1816,7 @@ static bool cli_handle_transfer_flags(CliParseCtx* ctx) {
|
||||
return true;
|
||||
}
|
||||
config->compression_level = (int)level;
|
||||
config->compression_level_set = true;
|
||||
config->cli.compression_level_set = true;
|
||||
log_info_message(LOG_INFO_MISC, "Set Compression level to %ld", level);
|
||||
ctx->i++;
|
||||
}
|
||||
@@ -1877,7 +1880,7 @@ static int set_server_port_option(Config* config, const char* value, const char*
|
||||
return -1;
|
||||
}
|
||||
config->server_port = port;
|
||||
config->server_port_set = true;
|
||||
config->cli.server_port_set = true;
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -2648,7 +2651,7 @@ static int cli_finalize_config(Config* config, bool verbose, bool no_delta, bool
|
||||
int resolved = compression_choice_resolve();
|
||||
if (resolved < 0) {
|
||||
log_message(LOG_LEVEL_ERROR, "RSYNC_COMPRESS_LIST names no supported compression algorithm");
|
||||
config->cli_exit_code = 4;
|
||||
config->cli.cli_exit_code = 4;
|
||||
return -1;
|
||||
}
|
||||
config->compression_algo = resolved;
|
||||
@@ -2659,7 +2662,7 @@ static int cli_finalize_config(Config* config, bool verbose, bool no_delta, bool
|
||||
* clamped to the codec's range, otherwise the codec's own default is used. */
|
||||
if (config->use_compression) {
|
||||
CompressionAlgo algo = (CompressionAlgo)config->compression_algo;
|
||||
config->compression_level = config->compression_level_set
|
||||
config->compression_level = config->cli.compression_level_set
|
||||
? compression_clamp_level(algo, config->compression_level)
|
||||
: compression_default_level(algo);
|
||||
log_debug_message(LOG_DEBUG_UTIL, "Client compression: %s (level %d)",
|
||||
@@ -2668,22 +2671,22 @@ static int cli_finalize_config(Config* config, bool verbose, bool no_delta, bool
|
||||
/* The negotiated checksum is always resolved (rsync negotiates one for the
|
||||
* delta strong sum even without --checksum): RSYNC_CHECKSUM_LIST first, then
|
||||
* the compiled-in order. An explicit --checksum-choice already set it. */
|
||||
if (!config->checksum_choice_set) {
|
||||
if (!config->cli.checksum_choice_set) {
|
||||
int resolved = checksum_choice_resolve();
|
||||
if (resolved < 0) {
|
||||
log_message(LOG_LEVEL_ERROR, "RSYNC_CHECKSUM_LIST names no supported checksum algorithm");
|
||||
config->cli_exit_code = 4;
|
||||
config->cli.cli_exit_code = 4;
|
||||
return -1;
|
||||
}
|
||||
config->checksum_algo = resolved;
|
||||
config->checksum_transfer_algo = resolved;
|
||||
config->cli.checksum_transfer_algo = resolved;
|
||||
}
|
||||
/* rsync parity: "none" as the pre-transfer checksum cannot be combined with
|
||||
* --checksum (exit 4). The check runs here because --checksum may appear on
|
||||
* either side of --checksum-choice. */
|
||||
if (config->checksum && config->checksum_algo == (int)CHECKSUM_ALGO_NONE) {
|
||||
log_message(LOG_LEVEL_ERROR, "Invalid checksum-choice for --checksum: none");
|
||||
config->cli_exit_code = 4;
|
||||
config->cli.cli_exit_code = 4;
|
||||
return -1;
|
||||
}
|
||||
|
||||
@@ -2762,11 +2765,11 @@ static int cli_finalize_config(Config* config, bool verbose, bool no_delta, bool
|
||||
* explicitly negated them (--no-perms/--no-times/--no-preserve). This runs
|
||||
* BEFORE the derived use_metadata bit so the transport frame is still sent
|
||||
* for the incremental/delta handshake even when both attributes were negated
|
||||
* via --no-preserve (metadata_explicitly_disabled handles that opt-out). */
|
||||
if (preserve_implied && !config->metadata_explicitly_disabled) {
|
||||
if (!config->preserve_perms_explicit_off)
|
||||
* via --no-preserve (cli.metadata_explicitly_disabled handles that opt-out). */
|
||||
if (preserve_implied && !config->cli.metadata_explicitly_disabled) {
|
||||
if (!config->cli.preserve_perms_explicit_off)
|
||||
config->preserve_perms = true;
|
||||
if (!config->preserve_times_explicit_off)
|
||||
if (!config->cli.preserve_times_explicit_off)
|
||||
config->preserve_times = true;
|
||||
}
|
||||
|
||||
@@ -3153,7 +3156,7 @@ int main(int argc, char* argv[]) {
|
||||
int parse_ret = parse_args(config, argc, argv, positional_args, &positional_count);
|
||||
if (parse_ret != 0) {
|
||||
if (parse_ret < 0)
|
||||
exit_code = config->cli_exit_code ? config->cli_exit_code : 1;
|
||||
exit_code = config->cli.cli_exit_code ? config->cli.cli_exit_code : 1;
|
||||
goto cleanup;
|
||||
}
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@ bool dry_run_targets_server(const Config* config) {
|
||||
return true;
|
||||
if (config->module && config->module[0] != '\0')
|
||||
return true;
|
||||
if (config->server_host_set || config->server_port_set)
|
||||
if (config->cli.server_host_set || config->cli.server_port_set)
|
||||
return true;
|
||||
if (config->use_tls)
|
||||
return true;
|
||||
|
||||
@@ -1578,7 +1578,7 @@ static bool send_files_run(Config* config, SendFilesState* state) {
|
||||
now_mono.tv_nsec = 0;
|
||||
}
|
||||
state->stop = stop_condition_make(config->stop_after_mins > 0, config->stop_after_mins,
|
||||
config->stop_at_set, config->stop_at, now_mono);
|
||||
config->cli.stop_at_set, config->stop_at, now_mono);
|
||||
state->prepared.options.stop_condition = &state->stop;
|
||||
/* The early-delete pre-scan above already ran; only the data pass should feed
|
||||
the directory-time list (otherwise every directory would be captured
|
||||
@@ -1653,7 +1653,7 @@ static bool send_files_run(Config* config, SendFilesState* state) {
|
||||
/* Completion tail: send the late delete manifest and captured directory times,
|
||||
* finalize the receiver handshake, remove transferred sources and report stats.
|
||||
* Returns the rsync-compatible exit code. */
|
||||
static int send_files_finalize(Config* config, SendFilesState* state) {
|
||||
static int send_files_finalize(const Config* config, SendFilesState* state) {
|
||||
Client* client = state->client;
|
||||
if (directory_scanner_failed(state->scanner))
|
||||
return 1;
|
||||
@@ -1916,8 +1916,8 @@ int send_files_multithreaded(Config* config) {
|
||||
now_mono.tv_nsec = 0;
|
||||
}
|
||||
context->stop_condition =
|
||||
stop_condition_make(config->stop_after_mins > 0, config->stop_after_mins, config->stop_at_set,
|
||||
config->stop_at, now_mono);
|
||||
stop_condition_make(config->stop_after_mins > 0, config->stop_after_mins,
|
||||
config->cli.stop_at_set, config->stop_at, now_mono);
|
||||
bool collect_excluded = config->use_delete && !config->delete_excluded;
|
||||
unsigned long long pre_scan_non_dir = 0;
|
||||
if (config->use_delete) {
|
||||
|
||||
+302
-193
@@ -303,6 +303,264 @@ int receiver_process(Config* config, int file_descriptor, const ReceiverSink* si
|
||||
return receiver_process_pending(config, file_descriptor, sink, NULL, NULL);
|
||||
}
|
||||
|
||||
/* Per-connection state threaded through the status handlers below. The parked
|
||||
keep-set / per-directory session live here so one teardown helper can release
|
||||
them on every exit path. */
|
||||
typedef struct {
|
||||
Config* config;
|
||||
int fd;
|
||||
const ReceiverSink* sink;
|
||||
DeleteManifest** pending_manifest;
|
||||
DeletePlanSession** pending_plans;
|
||||
/* Parked keep-set for the late/commit timing. Every exit path frees it
|
||||
exactly once; the only exception is the successful FINISHED handoff, which
|
||||
transfers ownership to *pending_manifest (used by the -m receiver). */
|
||||
DeleteManifest* deferred_manifest;
|
||||
/* Per-directory delete session for --delete-during/--delete-delay. During the
|
||||
loop it applies plans inline (during) or snapshots their extras (delay); on
|
||||
a successful FINISHED it is either committed here or handed to
|
||||
*pending_plans so the -m caller commits after its disk writer drained. */
|
||||
DeletePlanSession* plan_session;
|
||||
bool early_delete;
|
||||
bool per_dir_delete;
|
||||
bool delete_limit_noted;
|
||||
} ReceiverPendingState;
|
||||
|
||||
/* Outcome of one frame handler. NEXT reads the following status frame; FAIL
|
||||
tears the connection down without a peer STATUS_ERROR; ERROR tears it down
|
||||
and (when the sink owns error reporting) emits STATUS_ERROR. */
|
||||
typedef enum {
|
||||
RECEIVER_STEP_NEXT,
|
||||
RECEIVER_STEP_FAIL,
|
||||
RECEIVER_STEP_ERROR,
|
||||
} ReceiverStep;
|
||||
|
||||
static ReceiverStep receiver_handle_keepalive(ReceiverPendingState* state) {
|
||||
if (!send_status(state->fd, STATUS_KEEPALIVE))
|
||||
return RECEIVER_STEP_FAIL;
|
||||
return RECEIVER_STEP_NEXT;
|
||||
}
|
||||
|
||||
static ReceiverStep receiver_handle_abort(ReceiverPendingState* state) {
|
||||
(void)state;
|
||||
log_message(LOG_LEVEL_INFO, "Received abort from client, cleaning up");
|
||||
return RECEIVER_STEP_FAIL;
|
||||
}
|
||||
|
||||
static ReceiverStep receiver_handle_check(ReceiverPendingState* state) {
|
||||
bool skipped = false;
|
||||
bool would_transfer = false;
|
||||
File* file = receive_incremental_check_ex(state->fd, state->config, &skipped, &would_transfer);
|
||||
if (state->config->dry_run) {
|
||||
/* Server-contacting --dry-run: the reply has already been sent
|
||||
(STATUS_OK = up to date, STATUS_DRY_RUN_TRANSFER = would transfer) and
|
||||
nothing may be stored. Both flags false means a genuine protocol
|
||||
error (STATUS_ERROR already sent or sent by receive_error below). */
|
||||
if (!skipped && !would_transfer)
|
||||
return RECEIVER_STEP_ERROR;
|
||||
} else if (!skipped && (!file || !state->sink->store_file(file, state->sink->context))) {
|
||||
return RECEIVER_STEP_ERROR;
|
||||
}
|
||||
return RECEIVER_STEP_NEXT;
|
||||
}
|
||||
|
||||
static ReceiverStep receiver_handle_chunk(ReceiverPendingState* state) {
|
||||
Chunk* chunk = receive_chunk_data(state->fd, state->config);
|
||||
if (!chunk || !receiver_process_chunk(chunk, state->sink))
|
||||
return RECEIVER_STEP_ERROR;
|
||||
return RECEIVER_STEP_NEXT;
|
||||
}
|
||||
|
||||
static ReceiverStep receiver_handle_check_batch(ReceiverPendingState* state) {
|
||||
if (!receiver_process_batch(state->config, state->fd))
|
||||
return RECEIVER_STEP_FAIL;
|
||||
return RECEIVER_STEP_NEXT;
|
||||
}
|
||||
|
||||
static ReceiverStep receiver_handle_mkdir(ReceiverPendingState* state) {
|
||||
File* dir = file_receive_directory(state->fd, state->config);
|
||||
if (!dir || !state->sink->store_file(dir, state->sink->context))
|
||||
return RECEIVER_STEP_ERROR;
|
||||
return RECEIVER_STEP_NEXT;
|
||||
}
|
||||
|
||||
static ReceiverStep receiver_handle_dir_times(const ReceiverPendingState* state) {
|
||||
if (!receiver_process_dir_times(state->fd, state->config, state->sink))
|
||||
return RECEIVER_STEP_ERROR;
|
||||
return RECEIVER_STEP_NEXT;
|
||||
}
|
||||
|
||||
static ReceiverStep receiver_handle_hardlink(ReceiverPendingState* state) {
|
||||
File* file = file_receive_hardlink(state->fd);
|
||||
if (!file || !state->sink->store_file(file, state->sink->context))
|
||||
return RECEIVER_STEP_ERROR;
|
||||
return RECEIVER_STEP_NEXT;
|
||||
}
|
||||
|
||||
static ReceiverStep receiver_handle_symlink(ReceiverPendingState* state) {
|
||||
File* sym = file_receive_symlink(state->fd, state->config);
|
||||
if (!sym || !state->sink->store_file(sym, state->sink->context))
|
||||
return RECEIVER_STEP_ERROR;
|
||||
return RECEIVER_STEP_NEXT;
|
||||
}
|
||||
|
||||
static ReceiverStep receiver_handle_special(ReceiverPendingState* state) {
|
||||
File* file = file_receive_special(state->fd);
|
||||
if (!file || !state->sink->store_file(file, state->sink->context))
|
||||
return RECEIVER_STEP_ERROR;
|
||||
return RECEIVER_STEP_NEXT;
|
||||
}
|
||||
|
||||
static ReceiverStep receiver_handle_manifest(ReceiverPendingState* state) {
|
||||
Config* config = state->config;
|
||||
int fd = state->fd;
|
||||
const ReceiverSink* sink = state->sink;
|
||||
DeleteManifest* manifest = receive_manifest_entries(fd);
|
||||
if (!manifest)
|
||||
return RECEIVER_STEP_FAIL; /* receive_manifest_entries already sent STATUS_ERROR */
|
||||
if (config->dry_run) {
|
||||
/* Server-contacting --dry-run mutates nothing, so a keep-set manifest
|
||||
is consumed and discarded. The early-delete mode still needs its ACK
|
||||
so a sender blocked on the delete handshake is not left hanging.
|
||||
When would-delete reporting is armed, enumerate (read-only) the
|
||||
destination extras so the terminal STATUS_STATS frame can list them. */
|
||||
if (config->use_delete && sink->would_delete) {
|
||||
size_t count = 0;
|
||||
if (!manifest_would_delete_list(config, manifest, sink->would_delete, &count))
|
||||
log_message(LOG_LEVEL_WARNING, "dry-run: could not enumerate would-delete paths");
|
||||
}
|
||||
delete_manifest_free(manifest);
|
||||
if (state->early_delete && !send_status(fd, STATUS_OK))
|
||||
return RECEIVER_STEP_FAIL;
|
||||
return RECEIVER_STEP_NEXT;
|
||||
}
|
||||
if (state->early_delete) {
|
||||
/* --delete-before: the whole-tree manifest is authoritative the moment
|
||||
it arrives, before any file data. Delete now and acknowledge so the
|
||||
sender only starts streaming once the deletion committed (or failed).
|
||||
A later transfer failure does not restore these deletions. A
|
||||
--max-delete-capped commit still succeeds and the transfer proceeds;
|
||||
the terminal success frame reports the cap. */
|
||||
size_t deleted = 0;
|
||||
DeletePathObserver observer =
|
||||
(config->report_deletes && sink->deleted_paths) ? receiver_record_deleted_path : NULL;
|
||||
DeleteCommitResult deletion =
|
||||
(config->use_delete || config->delete_missing_args)
|
||||
? manifest_delete_all_observed(config, manifest, &deleted, observer,
|
||||
(void*)sink->deleted_paths)
|
||||
: DELETE_COMMIT_OK;
|
||||
receiver_tally_deleted(sink, deleted);
|
||||
delete_manifest_free(manifest);
|
||||
if (deletion == DELETE_COMMIT_ERROR) {
|
||||
send_status(fd, STATUS_ERROR);
|
||||
return RECEIVER_STEP_FAIL;
|
||||
}
|
||||
if (deletion == DELETE_COMMIT_LIMIT_REACHED && sink->note_delete_limit)
|
||||
sink->note_delete_limit(sink->context);
|
||||
if (!send_status(fd, STATUS_OK))
|
||||
return RECEIVER_STEP_FAIL;
|
||||
} else if (config->use_delete || config->delete_missing_args) {
|
||||
/* Plain --delete / --delete-after and the --delete-missing-args
|
||||
exact-path deletions: hold the manifest and commit it only after
|
||||
STATUS_FINISHED. The per-directory modes never send this frame. */
|
||||
if (state->deferred_manifest) {
|
||||
log_message(LOG_LEVEL_ERROR, "Received a second delete manifest");
|
||||
delete_manifest_free(state->deferred_manifest);
|
||||
state->deferred_manifest = NULL;
|
||||
delete_manifest_free(manifest);
|
||||
send_status(fd, STATUS_ERROR);
|
||||
return RECEIVER_STEP_FAIL;
|
||||
}
|
||||
state->deferred_manifest = manifest;
|
||||
} else {
|
||||
delete_manifest_free(manifest);
|
||||
}
|
||||
return RECEIVER_STEP_NEXT;
|
||||
}
|
||||
|
||||
static ReceiverStep receiver_handle_delete_plan(ReceiverPendingState* state) {
|
||||
Config* config = state->config;
|
||||
int fd = state->fd;
|
||||
const ReceiverSink* sink = state->sink;
|
||||
if (!state->per_dir_delete) {
|
||||
log_message(LOG_LEVEL_ERROR, "Received a per-directory delete plan without a per-dir "
|
||||
"delete timing");
|
||||
send_status(fd, STATUS_ERROR);
|
||||
return RECEIVER_STEP_FAIL;
|
||||
}
|
||||
if (!state->plan_session) {
|
||||
state->plan_session = delete_plan_session_create(config);
|
||||
if (state->plan_session && config->report_deletes && sink->deleted_paths)
|
||||
delete_plan_session_set_delete_observer(state->plan_session, receiver_record_deleted_path,
|
||||
(void*)sink->deleted_paths);
|
||||
}
|
||||
if (!state->plan_session || delete_plan_session_receive(state->plan_session, config, fd) != 0)
|
||||
return RECEIVER_STEP_FAIL;
|
||||
if (delete_plan_session_limit_reached(state->plan_session) && !state->delete_limit_noted &&
|
||||
sink->note_delete_limit) {
|
||||
sink->note_delete_limit(sink->context);
|
||||
state->delete_limit_noted = true;
|
||||
}
|
||||
return RECEIVER_STEP_NEXT;
|
||||
}
|
||||
|
||||
static ReceiverStep receiver_handle_file(ReceiverPendingState* state) {
|
||||
File* file = file_receive(state->config, state->fd);
|
||||
if (!file) {
|
||||
log_message(LOG_LEVEL_ERROR, "Failed to receive file");
|
||||
return RECEIVER_STEP_ERROR;
|
||||
}
|
||||
if (!state->sink->store_file(file, state->sink->context))
|
||||
return RECEIVER_STEP_ERROR;
|
||||
return RECEIVER_STEP_NEXT;
|
||||
}
|
||||
|
||||
/* One dispatch per admitted frame type; STATUS_NEXT (and any other
|
||||
data-bearing status) falls through to the regular file receiver. */
|
||||
static ReceiverStep receiver_dispatch_status(ReceiverPendingState* state, Status status) {
|
||||
switch (status) {
|
||||
case STATUS_KEEPALIVE:
|
||||
return receiver_handle_keepalive(state);
|
||||
case STATUS_ABORT:
|
||||
return receiver_handle_abort(state);
|
||||
case STATUS_CHECK:
|
||||
return receiver_handle_check(state);
|
||||
case STATUS_CHUNK:
|
||||
return receiver_handle_chunk(state);
|
||||
case STATUS_CHECK_BATCH:
|
||||
return receiver_handle_check_batch(state);
|
||||
case STATUS_MKDIR:
|
||||
return receiver_handle_mkdir(state);
|
||||
case STATUS_DIR_TIMES:
|
||||
return receiver_handle_dir_times(state);
|
||||
case STATUS_HARDLINK:
|
||||
return receiver_handle_hardlink(state);
|
||||
case STATUS_SYMLINK:
|
||||
return receiver_handle_symlink(state);
|
||||
case STATUS_SPECIAL:
|
||||
return receiver_handle_special(state);
|
||||
case STATUS_MANIFEST:
|
||||
return receiver_handle_manifest(state);
|
||||
case STATUS_DELETE_PLAN:
|
||||
return receiver_handle_delete_plan(state);
|
||||
default:
|
||||
return receiver_handle_file(state);
|
||||
}
|
||||
}
|
||||
|
||||
/* Release the parked keep-set / per-directory session exactly once on every
|
||||
failure exit. Never commit a deletion for a failed stream. */
|
||||
static void receiver_drop_pending(ReceiverPendingState* state) {
|
||||
if (state->deferred_manifest) {
|
||||
delete_manifest_free(state->deferred_manifest);
|
||||
state->deferred_manifest = NULL;
|
||||
}
|
||||
if (state->plan_session) {
|
||||
delete_plan_session_destroy(state->plan_session);
|
||||
state->plan_session = NULL;
|
||||
}
|
||||
}
|
||||
|
||||
/* Runs the whole receive loop. The delete manifest may legitimately arrive
|
||||
either FIRST (--delete-before / --delete-during: the sender transmits the
|
||||
validated keep-set before any file data) or LAST (--delete-after /
|
||||
@@ -312,9 +570,9 @@ int receiver_process(Config* config, int file_descriptor, const ReceiverSink* si
|
||||
deletion has committed (or failed); in the late modes the manifest is held
|
||||
and the deletion is committed only after the terminal STATUS_FINISHED proves
|
||||
the whole transfer succeeded. A plain --delete defaults to the per-directory
|
||||
delete-during plan mode (no manifest at all). See
|
||||
receiver_process_pending() for how the -m receiver defers that commit until
|
||||
its disk writer has drained. */
|
||||
delete-during plan mode (no manifest at all). See the per-frame handlers
|
||||
above for how the -m receiver defers that commit until its disk writer has
|
||||
drained. */
|
||||
int receiver_process_pending(Config* config, int file_descriptor, const ReceiverSink* sink,
|
||||
DeleteManifest** pending_manifest, DeletePlanSession** pending_plans) {
|
||||
Status status;
|
||||
@@ -329,166 +587,29 @@ int receiver_process_pending(Config* config, int file_descriptor, const Receiver
|
||||
last_progress = session_start;
|
||||
if (!receiver_note_status(&session_start, &last_progress, status, file_descriptor, sink))
|
||||
return -1;
|
||||
bool early_delete = config_delete_timing_early(config);
|
||||
bool per_dir_delete = config_delete_timing_per_dir(config);
|
||||
/* Parked keep-set for the late/commit timing. Every exit path below frees it
|
||||
exactly once; the only exception is the successful FINISHED handoff, which
|
||||
transfers ownership to *pending_manifest (used by the -m receiver). */
|
||||
DeleteManifest* deferred_manifest = NULL;
|
||||
/* Per-directory delete session for --delete-during/--delete-delay. During the
|
||||
loop it applies plans inline (during) or snapshots their extras (delay); on
|
||||
a successful FINISHED it is either committed here or handed to
|
||||
*pending_plans so the -m caller commits after its disk writer drained. */
|
||||
DeletePlanSession* plan_session = NULL;
|
||||
bool delete_limit_noted = false;
|
||||
ReceiverPendingState state = {
|
||||
.config = config,
|
||||
.fd = file_descriptor,
|
||||
.sink = sink,
|
||||
.pending_manifest = pending_manifest,
|
||||
.pending_plans = pending_plans,
|
||||
.deferred_manifest = NULL,
|
||||
.plan_session = NULL,
|
||||
.early_delete = config_delete_timing_early(config),
|
||||
.per_dir_delete = config_delete_timing_per_dir(config),
|
||||
.delete_limit_noted = false,
|
||||
};
|
||||
bool notify_peer = false;
|
||||
while (status == STATUS_NEXT || status == STATUS_CHUNK || status == STATUS_CHECK ||
|
||||
status == STATUS_KEEPALIVE || status == STATUS_ABORT || status == STATUS_CHECK_BATCH ||
|
||||
status == STATUS_MKDIR || status == STATUS_MANIFEST || status == STATUS_HARDLINK ||
|
||||
status == STATUS_SYMLINK || status == STATUS_SPECIAL || status == STATUS_DIR_TIMES ||
|
||||
status == STATUS_DELETE_PLAN) {
|
||||
if (status == STATUS_KEEPALIVE) {
|
||||
if (!send_status(file_descriptor, STATUS_KEEPALIVE))
|
||||
goto fail;
|
||||
goto next_status;
|
||||
}
|
||||
if (status == STATUS_ABORT) {
|
||||
log_message(LOG_LEVEL_INFO, "Received abort from client, cleaning up");
|
||||
ReceiverStep step = receiver_dispatch_status(&state, status);
|
||||
if (step == RECEIVER_STEP_FAIL)
|
||||
goto fail;
|
||||
}
|
||||
if (status == STATUS_CHECK) {
|
||||
bool skipped = false;
|
||||
bool would_transfer = false;
|
||||
File* file = receive_incremental_check_ex(file_descriptor, config, &skipped, &would_transfer);
|
||||
if (config->dry_run) {
|
||||
/* Server-contacting --dry-run: the reply has already been sent
|
||||
(STATUS_OK = up to date, STATUS_DRY_RUN_TRANSFER = would transfer) and
|
||||
nothing may be stored. Both flags false means a genuine protocol
|
||||
error (STATUS_ERROR already sent or sent by receive_error below). */
|
||||
if (!skipped && !would_transfer)
|
||||
goto receive_error;
|
||||
} else if (!skipped && (!file || !sink->store_file(file, sink->context))) {
|
||||
goto receive_error;
|
||||
}
|
||||
} else if (status == STATUS_CHUNK) {
|
||||
Chunk* chunk = receive_chunk_data(file_descriptor, config);
|
||||
if (!chunk || !receiver_process_chunk(chunk, sink))
|
||||
goto receive_error;
|
||||
} else if (status == STATUS_CHECK_BATCH) {
|
||||
if (!receiver_process_batch(config, file_descriptor))
|
||||
goto fail;
|
||||
goto next_status;
|
||||
} else if (status == STATUS_MKDIR) {
|
||||
File* dir = file_receive_directory(file_descriptor, config);
|
||||
if (!dir || !sink->store_file(dir, sink->context))
|
||||
goto receive_error;
|
||||
} else if (status == STATUS_DIR_TIMES) {
|
||||
if (!receiver_process_dir_times(file_descriptor, config, sink))
|
||||
goto receive_error;
|
||||
} else if (status == STATUS_HARDLINK) {
|
||||
File* file = file_receive_hardlink(file_descriptor);
|
||||
if (!file || !sink->store_file(file, sink->context))
|
||||
goto receive_error;
|
||||
} else if (status == STATUS_SYMLINK) {
|
||||
File* sym = file_receive_symlink(file_descriptor, config);
|
||||
if (!sym || !sink->store_file(sym, sink->context))
|
||||
goto receive_error;
|
||||
} else if (status == STATUS_SPECIAL) {
|
||||
File* file = file_receive_special(file_descriptor);
|
||||
if (!file || !sink->store_file(file, sink->context))
|
||||
goto receive_error;
|
||||
} else if (status == STATUS_MANIFEST) {
|
||||
DeleteManifest* manifest = receive_manifest_entries(file_descriptor);
|
||||
if (!manifest)
|
||||
goto fail; /* receive_manifest_entries already sent STATUS_ERROR */
|
||||
if (config->dry_run) {
|
||||
/* Server-contacting --dry-run mutates nothing, so a keep-set manifest
|
||||
is consumed and discarded. The early-delete mode still needs its ACK
|
||||
so a sender blocked on the delete handshake is not left hanging.
|
||||
When would-delete reporting is armed, enumerate (read-only) the
|
||||
destination extras so the terminal STATUS_STATS frame can list them. */
|
||||
if (config->use_delete && sink->would_delete) {
|
||||
size_t count = 0;
|
||||
if (!manifest_would_delete_list(config, manifest, sink->would_delete, &count))
|
||||
log_message(LOG_LEVEL_WARNING, "dry-run: could not enumerate would-delete paths");
|
||||
}
|
||||
delete_manifest_free(manifest);
|
||||
if (early_delete && !send_status(file_descriptor, STATUS_OK))
|
||||
goto fail;
|
||||
goto next_status;
|
||||
}
|
||||
if (early_delete) {
|
||||
/* --delete-before: the whole-tree manifest is authoritative the moment
|
||||
it arrives, before any file data. Delete now and acknowledge so the
|
||||
sender only starts streaming once the deletion committed (or failed).
|
||||
A later transfer failure does not restore these deletions. A
|
||||
--max-delete-capped commit still succeeds and the transfer proceeds;
|
||||
the terminal success frame reports the cap. */
|
||||
size_t deleted = 0;
|
||||
DeletePathObserver observer =
|
||||
(config->report_deletes && sink->deleted_paths) ? receiver_record_deleted_path : NULL;
|
||||
DeleteCommitResult deletion =
|
||||
(config->use_delete || config->delete_missing_args)
|
||||
? manifest_delete_all_observed(config, manifest, &deleted, observer,
|
||||
(void*)sink->deleted_paths)
|
||||
: DELETE_COMMIT_OK;
|
||||
receiver_tally_deleted(sink, deleted);
|
||||
delete_manifest_free(manifest);
|
||||
if (deletion == DELETE_COMMIT_ERROR) {
|
||||
send_status(file_descriptor, STATUS_ERROR);
|
||||
goto fail;
|
||||
}
|
||||
if (deletion == DELETE_COMMIT_LIMIT_REACHED && sink->note_delete_limit)
|
||||
sink->note_delete_limit(sink->context);
|
||||
if (!send_status(file_descriptor, STATUS_OK))
|
||||
goto fail;
|
||||
} else if (config->use_delete || config->delete_missing_args) {
|
||||
/* Plain --delete / --delete-after and the --delete-missing-args
|
||||
exact-path deletions: hold the manifest and commit it only after
|
||||
STATUS_FINISHED. The per-directory modes never send this frame. */
|
||||
if (deferred_manifest) {
|
||||
log_message(LOG_LEVEL_ERROR, "Received a second delete manifest");
|
||||
delete_manifest_free(deferred_manifest);
|
||||
deferred_manifest = NULL;
|
||||
delete_manifest_free(manifest);
|
||||
send_status(file_descriptor, STATUS_ERROR);
|
||||
goto fail;
|
||||
}
|
||||
deferred_manifest = manifest;
|
||||
} else {
|
||||
delete_manifest_free(manifest);
|
||||
}
|
||||
goto next_status;
|
||||
} else if (status == STATUS_DELETE_PLAN) {
|
||||
if (!per_dir_delete) {
|
||||
log_message(LOG_LEVEL_ERROR, "Received a per-directory delete plan without a per-dir "
|
||||
"delete timing");
|
||||
send_status(file_descriptor, STATUS_ERROR);
|
||||
goto fail;
|
||||
}
|
||||
if (!plan_session) {
|
||||
plan_session = delete_plan_session_create(config);
|
||||
if (plan_session && config->report_deletes && sink->deleted_paths)
|
||||
delete_plan_session_set_delete_observer(plan_session, receiver_record_deleted_path,
|
||||
(void*)sink->deleted_paths);
|
||||
}
|
||||
if (!plan_session || delete_plan_session_receive(plan_session, config, file_descriptor) != 0)
|
||||
goto fail;
|
||||
if (delete_plan_session_limit_reached(plan_session) && !delete_limit_noted &&
|
||||
sink->note_delete_limit) {
|
||||
sink->note_delete_limit(sink->context);
|
||||
delete_limit_noted = true;
|
||||
}
|
||||
goto next_status;
|
||||
} else {
|
||||
File* file = file_receive(config, file_descriptor);
|
||||
if (!file) {
|
||||
log_message(LOG_LEVEL_ERROR, "Failed to receive file");
|
||||
goto receive_error;
|
||||
}
|
||||
if (!sink->store_file(file, sink->context))
|
||||
goto receive_error;
|
||||
}
|
||||
next_status:
|
||||
if (step == RECEIVER_STEP_ERROR)
|
||||
goto receive_error;
|
||||
if (!receive_status(file_descriptor, &status))
|
||||
goto receive_error;
|
||||
if (!receiver_note_status(&session_start, &last_progress, status, file_descriptor, sink))
|
||||
@@ -507,19 +628,19 @@ int receiver_process_pending(Config* config, int file_descriptor, const Receiver
|
||||
disk writer may still be draining; the caller commits after the writer has
|
||||
joined so no extra file is removed unless the transfer is known to have
|
||||
succeeded. */
|
||||
if (deferred_manifest) {
|
||||
if (pending_manifest) {
|
||||
*pending_manifest = deferred_manifest;
|
||||
deferred_manifest = NULL;
|
||||
if (state.deferred_manifest) {
|
||||
if (state.pending_manifest) {
|
||||
*state.pending_manifest = state.deferred_manifest;
|
||||
state.deferred_manifest = NULL;
|
||||
} else {
|
||||
size_t deleted = 0;
|
||||
DeletePathObserver observer =
|
||||
(config->report_deletes && sink->deleted_paths) ? receiver_record_deleted_path : NULL;
|
||||
DeleteCommitResult deletion = manifest_delete_all_observed(
|
||||
config, deferred_manifest, &deleted, observer, (void*)sink->deleted_paths);
|
||||
config, state.deferred_manifest, &deleted, observer, (void*)sink->deleted_paths);
|
||||
receiver_tally_deleted(sink, deleted);
|
||||
delete_manifest_free(deferred_manifest);
|
||||
deferred_manifest = NULL;
|
||||
delete_manifest_free(state.deferred_manifest);
|
||||
state.deferred_manifest = NULL;
|
||||
if (deletion == DELETE_COMMIT_ERROR) {
|
||||
send_status(file_descriptor, STATUS_ERROR);
|
||||
goto fail;
|
||||
@@ -533,28 +654,28 @@ int receiver_process_pending(Config* config, int file_descriptor, const Receiver
|
||||
nothing yet and applies its decompressed snapshot here. The -m receiver
|
||||
hands the session to its caller instead, which commits after the disk
|
||||
writer drained. */
|
||||
if (plan_session) {
|
||||
if (state.plan_session) {
|
||||
if (config->report_deletes && sink->deleted_paths)
|
||||
delete_plan_session_set_delete_observer(plan_session, receiver_record_deleted_path,
|
||||
delete_plan_session_set_delete_observer(state.plan_session, receiver_record_deleted_path,
|
||||
(void*)sink->deleted_paths);
|
||||
if (pending_plans) {
|
||||
*pending_plans = plan_session;
|
||||
plan_session = NULL;
|
||||
if (state.pending_plans) {
|
||||
*state.pending_plans = state.plan_session;
|
||||
state.plan_session = NULL;
|
||||
} else if (config->dry_run) {
|
||||
/* Central dry-run no-op: never commit a deletion for a -n run. */
|
||||
delete_plan_session_destroy(plan_session);
|
||||
plan_session = NULL;
|
||||
delete_plan_session_destroy(state.plan_session);
|
||||
state.plan_session = NULL;
|
||||
} else {
|
||||
DeleteCommitResult deletion = delete_plan_session_commit(plan_session, config);
|
||||
bool limit = delete_plan_session_limit_reached(plan_session);
|
||||
receiver_tally_deleted(sink, delete_plan_session_deleted(plan_session));
|
||||
delete_plan_session_destroy(plan_session);
|
||||
plan_session = NULL;
|
||||
DeleteCommitResult deletion = delete_plan_session_commit(state.plan_session, config);
|
||||
bool limit = delete_plan_session_limit_reached(state.plan_session);
|
||||
receiver_tally_deleted(sink, delete_plan_session_deleted(state.plan_session));
|
||||
delete_plan_session_destroy(state.plan_session);
|
||||
state.plan_session = NULL;
|
||||
if (deletion == DELETE_COMMIT_ERROR) {
|
||||
send_status(file_descriptor, STATUS_ERROR);
|
||||
goto fail;
|
||||
}
|
||||
if (limit && !delete_limit_noted && sink->note_delete_limit)
|
||||
if (limit && !state.delete_limit_noted && sink->note_delete_limit)
|
||||
sink->note_delete_limit(sink->context);
|
||||
}
|
||||
}
|
||||
@@ -568,26 +689,14 @@ int receiver_process_pending(Config* config, int file_descriptor, const Receiver
|
||||
}
|
||||
return 0;
|
||||
|
||||
receive_error:
|
||||
notify_peer = true;
|
||||
fail:
|
||||
/* Failure exits that must not (or already did) report a STATUS_ERROR. The
|
||||
parked keep-set/session is dropped: never commit a deletion for a failed
|
||||
stream. */
|
||||
if (deferred_manifest) {
|
||||
delete_manifest_free(deferred_manifest);
|
||||
deferred_manifest = NULL;
|
||||
}
|
||||
if (plan_session)
|
||||
delete_plan_session_destroy(plan_session);
|
||||
return -1;
|
||||
|
||||
receive_error:
|
||||
if (deferred_manifest) {
|
||||
delete_manifest_free(deferred_manifest);
|
||||
deferred_manifest = NULL;
|
||||
}
|
||||
if (plan_session)
|
||||
delete_plan_session_destroy(plan_session);
|
||||
if (sink->send_error)
|
||||
receiver_drop_pending(&state);
|
||||
if (notify_peer && sink->send_error)
|
||||
send_status(file_descriptor, STATUS_ERROR);
|
||||
return -1;
|
||||
}
|
||||
|
||||
+269
-186
@@ -705,69 +705,85 @@ static const char* server_module_gate(const Config* config, void* context) {
|
||||
return module_gate_install_root(config, module);
|
||||
}
|
||||
|
||||
void handler(int file_descriptor) {
|
||||
SSL* ssl = io_get_ssl();
|
||||
/* Per-connection state threaded through the handler phase helpers below. The
|
||||
* fields are a faithful split of the former handler() locals: the protocol
|
||||
* session, the config-frame gate context, the accepted config, the optional
|
||||
* multithreaded pipeline context and the teardown bookkeeping all live here so
|
||||
* the single `done` epilogue in handler() can release them exactly as before. */
|
||||
typedef struct ServerSession {
|
||||
int fd;
|
||||
SSL* ssl;
|
||||
ProtocolSession session;
|
||||
protocol_session_init(&session, file_descriptor, file_descriptor);
|
||||
protocol_session_set_ssl(&session, ssl);
|
||||
protocol_session_bind(&session);
|
||||
ModuleGateContext gate_ctx;
|
||||
gate_ctx.ssl = ssl;
|
||||
gate_ctx.fd = file_descriptor;
|
||||
gate_ctx.super_mode_override = -1;
|
||||
gate_ctx.has_peer_ip = false;
|
||||
gate_ctx.peer_ip[0] = '\0';
|
||||
gate_ctx.is_local = false;
|
||||
/* All teardown state starts empty so the single `done` epilogue is safe to
|
||||
* reach from any error path (including before the config frame arrives). */
|
||||
Config* config = NULL;
|
||||
PipelineContextReceiver* context = NULL;
|
||||
char* joined_destination = NULL;
|
||||
bool charset_ready = false;
|
||||
config = config_receive_with_validate(file_descriptor, server_module_gate, &gate_ctx);
|
||||
if (config == NULL) {
|
||||
Config* config;
|
||||
PipelineContextReceiver* context;
|
||||
char* joined_destination;
|
||||
bool charset_ready;
|
||||
} ServerSession;
|
||||
|
||||
/* Phase 1 -- config receipt + validation. Receives the client config frame
|
||||
* through the module gate, applies the super-mode override the gate recorded
|
||||
* exactly once, and installs the per-connection protocol/compression state.
|
||||
* Returns false when the config frame was refused (the gate has already
|
||||
* answered the client); the caller jumps to the shared `done` epilogue. */
|
||||
static bool server_accept_config(ServerSession* state) {
|
||||
state->config = config_receive_with_validate(state->fd, server_module_gate, &state->gate_ctx);
|
||||
if (state->config == NULL) {
|
||||
log_message(LOG_LEVEL_ERROR, "Failed to receive config");
|
||||
goto done;
|
||||
return false;
|
||||
}
|
||||
/* Apply the super-mode veto the gate decided on (operator --no-super, or a
|
||||
* daemon module without the `client owner = yes` opt-in) exactly once, so
|
||||
* every downstream gate (identity_apply_ownership via privilege_super_permitted,
|
||||
* device-node creation) sees SUPER_MODE_OFF. The gate never mutated the
|
||||
* received config. */
|
||||
if (gate_ctx.super_mode_override != -1)
|
||||
config->super_mode = (SuperMode)gate_ctx.super_mode_override;
|
||||
if (state->gate_ctx.super_mode_override != -1)
|
||||
state->config->super_mode = (SuperMode)state->gate_ctx.super_mode_override;
|
||||
/* Install the codec this connection negotiated before the receiver/writer
|
||||
* threads start (the server forks per connection, so the process-global
|
||||
* codec is private to this session). */
|
||||
compression_set_algo((CompressionAlgo)config->compression_algo);
|
||||
compression_set_algo((CompressionAlgo)state->config->compression_algo);
|
||||
/* If the client requested ownership but the effective super mode forbids it
|
||||
* (operator --no-super, a privileged standalone receiver's secure default, or
|
||||
* a daemon module without `client owner = yes`), say so ONCE per connection so
|
||||
* a successful -a/-o/-g transfer is not mistaken for preserved ownership. */
|
||||
if (config->super_mode == SUPER_MODE_OFF && identity_ownership_requested(config))
|
||||
if (state->config->super_mode == SUPER_MODE_OFF && identity_ownership_requested(state->config))
|
||||
log_message(LOG_LEVEL_WARNING,
|
||||
"requested ownership will NOT be applied: super-user activities are disabled "
|
||||
"for this connection (operator veto, or module without `client owner = yes`)");
|
||||
protocol_set_8_bit_output(config->eight_bit_output);
|
||||
protocol_set_8_bit_output(state->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). 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));
|
||||
protocol_session_set_io_timeout(&state->session,
|
||||
protocol_server_io_timeout_sec(state->config->timeout));
|
||||
return true;
|
||||
}
|
||||
|
||||
/* Phase 2 -- security gates. The ORDER here is load-bearing and must not be
|
||||
* merged or reordered: transport/authentication (plaintext refusal, TLS
|
||||
* client-CN verification), then daemon-root confinement (absolute-destination
|
||||
* rejection, traversal + within-authorized-root), then delete/force
|
||||
* authorization -- exactly the sequence the former handler() used. Returns
|
||||
* false after logging the matching rejection; the caller jumps to the shared
|
||||
* `done` epilogue. */
|
||||
static bool server_apply_security_gates(ServerSession* state) {
|
||||
Config* config = state->config;
|
||||
const char* authorized_root = utils_get_authorized_root_path();
|
||||
if (!authorized_root) {
|
||||
log_message(LOG_LEVEL_ERROR, "No server-side destination root configured");
|
||||
goto done;
|
||||
return false;
|
||||
}
|
||||
if (!allow_unauthenticated && ssl == NULL) {
|
||||
if (!allow_unauthenticated && state->ssl == NULL) {
|
||||
log_message(LOG_LEVEL_ERROR, "Rejected unauthenticated plaintext connection");
|
||||
goto done;
|
||||
return false;
|
||||
}
|
||||
if (ssl && required_client_cn && !tls_client_identity_allowed(ssl)) {
|
||||
if (state->ssl && required_client_cn && !tls_client_identity_allowed(state->ssl)) {
|
||||
log_message(LOG_LEVEL_ERROR, "Rejected TLS client with unauthorized identity");
|
||||
goto done;
|
||||
return false;
|
||||
}
|
||||
/* Daemon mode: the module's root is the authorized root (installed by
|
||||
server_module_gate), and the client's destination is a MODULE-RELATIVE
|
||||
@@ -777,27 +793,27 @@ void handler(int file_descriptor) {
|
||||
if (g_daemon_conf && config->receive_root_directory && config->receive_root_directory[0] == '/') {
|
||||
log_message(LOG_LEVEL_ERROR, "Rejected absolute daemon destination (must be relative to the "
|
||||
"selected module root)");
|
||||
goto done;
|
||||
return false;
|
||||
}
|
||||
char* destination = config->receive_root_directory;
|
||||
if (destination && destination[0] != '/')
|
||||
joined_destination = path_cat(authorized_root, destination);
|
||||
if (joined_destination)
|
||||
destination = joined_destination;
|
||||
state->joined_destination = path_cat(authorized_root, destination);
|
||||
if (state->joined_destination)
|
||||
destination = state->joined_destination;
|
||||
if (!destination || has_path_traversal(destination) ||
|
||||
!path_is_within_root(authorized_root, destination)) {
|
||||
log_message(LOG_LEVEL_ERROR, "Rejected destination outside authorized root");
|
||||
free(joined_destination);
|
||||
joined_destination = NULL;
|
||||
goto done;
|
||||
free(state->joined_destination);
|
||||
state->joined_destination = NULL;
|
||||
return false;
|
||||
}
|
||||
if (joined_destination) {
|
||||
if (state->joined_destination) {
|
||||
free(config->receive_root_directory);
|
||||
config->receive_root_directory = joined_destination;
|
||||
joined_destination = NULL;
|
||||
config->receive_root_directory = state->joined_destination;
|
||||
state->joined_destination = NULL;
|
||||
}
|
||||
if (!config->receive_root_directory) {
|
||||
goto done;
|
||||
return false;
|
||||
}
|
||||
config->use_delete = config->use_delete && allow_delete;
|
||||
/* --force (receiver-side) is deletion authority too: it lets an incoming
|
||||
@@ -807,6 +823,18 @@ void handler(int file_descriptor) {
|
||||
* --delete-missing-args, so a client cannot use --force to bypass the delete
|
||||
* policy. */
|
||||
config->force_delete = config->force_delete && allow_delete;
|
||||
return true;
|
||||
}
|
||||
|
||||
/* Phase 3 -- session preparation. Installs the negotiated conversion, applies
|
||||
* the remaining deletion policy, materializes the destination root (--mkpath),
|
||||
* creates the --delay-updates staging tree, snapshots the identity policy, and
|
||||
* publishes the --keep-dirlinks/--trust-sender globals and the daemon MOTD.
|
||||
* All of it must happen before any receiver/writer thread is spawned. Returns
|
||||
* false after logging the matching failure; the caller jumps to the shared
|
||||
* `done` epilogue. */
|
||||
static bool server_prepare_session(ServerSession* state) {
|
||||
Config* config = state->config;
|
||||
/* --iconv (protocol 2.16.0): install the receiver-side wire->local conversion
|
||||
now that the client's full CONVERT_SPEC has been received and validated,
|
||||
before any received file name is decoded. The server's own --iconv (if
|
||||
@@ -817,14 +845,14 @@ void handler(int file_descriptor) {
|
||||
if (!charset_wire_init_receiver(config->iconv_spec, server_iconv_spec)) {
|
||||
log_message(LOG_LEVEL_ERROR,
|
||||
"--iconv: unsupported charset conversion requested (LOCAL[,REMOTE])");
|
||||
goto done;
|
||||
return false;
|
||||
}
|
||||
charset_ready = true;
|
||||
state->charset_ready = true;
|
||||
}
|
||||
/* --delete-missing-args deletes destination mirrors receiver-side, so it is
|
||||
deletion and stays gated by the same --allow-delete server policy. When
|
||||
the server policy is off the flag is inert (the missing entries are still
|
||||
skipped via its implied --ignore-missing-args, but nothing is deleted). */
|
||||
* deletion and stays gated by the same --allow-delete server policy. When
|
||||
* the server policy is off the flag is inert (the missing entries are still
|
||||
* skipped via its implied --ignore-missing-args, but nothing is deleted). */
|
||||
config->delete_missing_args = config->delete_missing_args && allow_delete;
|
||||
/* --mkpath: create the destination root (and its missing leading components)
|
||||
* before anything else; without it the root must pre-exist. The precondition
|
||||
@@ -839,7 +867,7 @@ void handler(int file_descriptor) {
|
||||
log_message(LOG_LEVEL_ERROR, "destination root is not available: %s",
|
||||
escaped_root ? escaped_root : "<allocation failed>");
|
||||
free(escaped_root);
|
||||
goto done;
|
||||
return false;
|
||||
}
|
||||
/* A --delay-updates transfer stages under a private 0700 directory inside
|
||||
the receive root. Create it up front (wiping leftovers of any previously
|
||||
@@ -849,7 +877,7 @@ void handler(int file_descriptor) {
|
||||
config->delay_context = delay_updates_context_create(config->receive_root_directory);
|
||||
if (!config->delay_context || !delay_updates_prepare(config->delay_context)) {
|
||||
log_message(LOG_LEVEL_ERROR, "Failed to initialize --delay-updates staging area");
|
||||
goto done;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
/* Preserve the negotiated identity policy for the fd-relative ownership
|
||||
@@ -859,7 +887,7 @@ void handler(int file_descriptor) {
|
||||
rather than silently applying the wrong ownership policy. */
|
||||
if (!identity_set_active(config)) {
|
||||
log_message(LOG_LEVEL_ERROR, "Failed to activate identity policy");
|
||||
goto done;
|
||||
return false;
|
||||
}
|
||||
/* Persist the negotiated --keep-dirlinks policy once, here at config-accept,
|
||||
before any multithreaded receiver/writer threads are spawned, so the
|
||||
@@ -887,140 +915,195 @@ void handler(int file_descriptor) {
|
||||
Wave C note in config.h). */
|
||||
if (g_daemon_conf) {
|
||||
char* motd = motd_read_file(g_daemon_conf->global.motd_file);
|
||||
if (!motd_send(file_descriptor, motd ? motd : "")) {
|
||||
if (!motd_send(state->fd, motd ? motd : "")) {
|
||||
free(motd);
|
||||
log_message(LOG_LEVEL_ERROR, "Failed to send daemon MOTD");
|
||||
goto done;
|
||||
return false;
|
||||
}
|
||||
free(motd);
|
||||
}
|
||||
if (config->use_multithreading) {
|
||||
Queue* q = queue_create(100, file_destroy);
|
||||
if (q == NULL)
|
||||
goto done;
|
||||
context = pipeline_context_receiver_create(config, q, file_descriptor, ssl);
|
||||
if (context == NULL) {
|
||||
queue_destroy(q);
|
||||
goto done;
|
||||
}
|
||||
protocol_session_set_max_alloc(&context->session, config->max_alloc);
|
||||
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);
|
||||
thrd_t receiver = {0};
|
||||
thrd_t writer = {0};
|
||||
bool receiver_created = thrd_create(&receiver, receive_thread, context) == thrd_success;
|
||||
bool writer_created = false;
|
||||
if (receiver_created)
|
||||
writer_created = thrd_create(&writer, write_thread, context) == thrd_success;
|
||||
if (!receiver_created || !writer_created) {
|
||||
log_perror("Error creating Threads");
|
||||
if (receiver_created) {
|
||||
mtx_lock(&context->mutex);
|
||||
atomic_store(&context->cancelled, true);
|
||||
cnd_broadcast(&context->condition_not_full);
|
||||
cnd_broadcast(&context->condition_not_empty);
|
||||
mtx_unlock(&context->mutex);
|
||||
/* Unblock a worker parked in socket I/O without closing the fd (the
|
||||
* child owns the single close). shutdown() only affects sockets; for
|
||||
* the --stdio pipe the receiver's per-message poll timeout still
|
||||
* bounds the join, so do nothing there rather than close a descriptor
|
||||
* another thread may still be using. */
|
||||
struct stat fd_stat;
|
||||
if (fstat(file_descriptor, &fd_stat) == 0 && S_ISSOCK(fd_stat.st_mode))
|
||||
shutdown(file_descriptor, SHUT_RDWR);
|
||||
thrd_join(receiver, NULL);
|
||||
}
|
||||
if (writer_created)
|
||||
thrd_join(writer, NULL);
|
||||
goto done;
|
||||
}
|
||||
int receiver_result;
|
||||
int writer_result;
|
||||
thrd_join(receiver, &receiver_result);
|
||||
thrd_join(writer, &writer_result);
|
||||
bool transfer_ok = receiver_result == thrd_success && writer_result == thrd_success;
|
||||
if (transfer_ok && !config->dry_run) {
|
||||
/* Commit-style (late) deletion: receive_thread handed the keep-set
|
||||
manifest here instead of deleting while write_thread might still be
|
||||
draining, so by now every file is on disk and the whole transfer is
|
||||
known to have succeeded. Remove the extras before publishing a
|
||||
--delay-updates run; the walker skips the staging directory. A
|
||||
server-contacting --dry-run deletes nothing (no manifest is sent). */
|
||||
if (context->deferred_manifest) {
|
||||
size_t deleted = 0;
|
||||
DeletePathObserver observer = config->report_deletes ? receiver_record_deleted_path : NULL;
|
||||
DeleteCommitResult deletion = manifest_delete_all_observed(
|
||||
config, context->deferred_manifest, &deleted, observer, (void*)context->deleted_paths);
|
||||
context->stats.deleted_files += deleted;
|
||||
if (deletion == DELETE_COMMIT_ERROR) {
|
||||
transfer_ok = false;
|
||||
} else if (deletion == DELETE_COMMIT_LIMIT_REACHED) {
|
||||
/* The transfer still succeeds; the terminal frame reports the capped
|
||||
deletion so the sender exits 25 like rsync. */
|
||||
context->delete_limit_reached = true;
|
||||
}
|
||||
delete_manifest_free(context->deferred_manifest);
|
||||
context->deferred_manifest = NULL;
|
||||
}
|
||||
/* --delete-delay: receive_thread snapshotted each plan's extras as it
|
||||
arrived; with the disk writer drained, commit the deferred removals.
|
||||
--delete-during already applied its plans on the receive thread. */
|
||||
if (context->deferred_plans) {
|
||||
/* Defence in depth (the enclosing block already excludes dry-run): a
|
||||
-n run never commits a deletion. */
|
||||
if (config->report_deletes)
|
||||
delete_plan_session_set_delete_observer(
|
||||
context->deferred_plans, receiver_record_deleted_path, (void*)context->deleted_paths);
|
||||
DeleteCommitResult deletion =
|
||||
config->dry_run ? DELETE_COMMIT_OK
|
||||
: delete_plan_session_commit(context->deferred_plans, config);
|
||||
context->stats.deleted_files += delete_plan_session_deleted(context->deferred_plans);
|
||||
if (deletion == DELETE_COMMIT_ERROR) {
|
||||
transfer_ok = false;
|
||||
} else if (deletion == DELETE_COMMIT_LIMIT_REACHED) {
|
||||
context->delete_limit_reached = true;
|
||||
}
|
||||
delete_plan_session_destroy(context->deferred_plans);
|
||||
context->deferred_plans = NULL;
|
||||
}
|
||||
}
|
||||
if (transfer_ok && !config->dry_run) {
|
||||
/* --delay-updates: receive_thread has finished the whole protocol stream
|
||||
(including manifest/delete handling) and write_thread has drained its
|
||||
queue, so every staged file is complete. Publish atomically before the
|
||||
success/outcome frame so a --remove-source-files sender only learns of
|
||||
files that were actually installed. */
|
||||
if (config->delay_updates && config->delay_context &&
|
||||
!delay_updates_publish(config->delay_context, config)) {
|
||||
transfer_ok = false;
|
||||
}
|
||||
/* P7 Wave D: all writers have joined and the late deletion (and
|
||||
--delay-updates publication) has committed above, so it is finally safe
|
||||
to stamp directory times; a directory's mtime must not be clobbered by
|
||||
its children or by an extra removal. */
|
||||
if (transfer_ok)
|
||||
dir_metadata_list_apply(&context->dir_times, config->receive_root_directory, config);
|
||||
}
|
||||
if (transfer_ok) {
|
||||
Status final_status = context->delete_limit_reached ? STATUS_DELETE_LIMIT : STATUS_OK;
|
||||
/* Emit the optional wire-stats record first (protocol 2.25.0), then the
|
||||
success/outcome frame, exactly like the single-threaded receiver. */
|
||||
if (!receiver_send_stats_frame(file_descriptor, config, &context->stats,
|
||||
context->would_delete, context->deleted_paths) ||
|
||||
!receiver_send_final_success(file_descriptor, config, &context->outcomes, final_status))
|
||||
transfer_ok = false;
|
||||
} else {
|
||||
send_error_detail(file_descriptor, "transfer failed on receiver");
|
||||
}
|
||||
if (!transfer_ok)
|
||||
log_message(LOG_LEVEL_ERROR, "Transfer failed");
|
||||
} else {
|
||||
if (receiver_receive_files(config, file_descriptor) != 0)
|
||||
log_message(LOG_LEVEL_ERROR, "Transfer failed");
|
||||
return true;
|
||||
}
|
||||
|
||||
/* Phase 4a -- transfer via the multithreaded receiver. Spawns the receive/write
|
||||
* thread pair, joins them, then commits the late deletion, --delay-updates
|
||||
* publication and directory times before emitting the terminal stats/success
|
||||
* frame. On any failure the helper just returns; the caller's `done` epilogue
|
||||
* releases the pipeline context (which owns the config and queue) exactly as the
|
||||
* former inline code did. */
|
||||
static void server_run_mt_receiver(ServerSession* state) {
|
||||
Config* config = state->config;
|
||||
Queue* q = queue_create(100, file_destroy);
|
||||
if (q == NULL)
|
||||
return;
|
||||
state->context = pipeline_context_receiver_create(config, q, state->fd, state->ssl);
|
||||
if (state->context == NULL) {
|
||||
queue_destroy(q);
|
||||
return;
|
||||
}
|
||||
protocol_session_set_max_alloc(&state->context->session, config->max_alloc);
|
||||
protocol_session_set_io_timeout(&state->context->session,
|
||||
protocol_server_io_timeout_sec(config->timeout));
|
||||
atomic_store(&state->context->session.total_allocated_bytes,
|
||||
atomic_load(&state->session.total_allocated_bytes));
|
||||
pipeline_context_receiver_set_queue_byte_limit(state->context, RECEIVER_QUEUE_MAX_BYTES);
|
||||
thrd_t receiver = {0};
|
||||
thrd_t writer = {0};
|
||||
bool receiver_created = thrd_create(&receiver, receive_thread, state->context) == thrd_success;
|
||||
bool writer_created = false;
|
||||
if (receiver_created)
|
||||
writer_created = thrd_create(&writer, write_thread, state->context) == thrd_success;
|
||||
if (!receiver_created || !writer_created) {
|
||||
log_perror("Error creating Threads");
|
||||
if (receiver_created) {
|
||||
mtx_lock(&state->context->mutex);
|
||||
atomic_store(&state->context->cancelled, true);
|
||||
cnd_broadcast(&state->context->condition_not_full);
|
||||
cnd_broadcast(&state->context->condition_not_empty);
|
||||
mtx_unlock(&state->context->mutex);
|
||||
/* Unblock a worker parked in socket I/O without closing the fd (the
|
||||
* child owns the single close). shutdown() only affects sockets; for
|
||||
* the --stdio pipe the receiver's per-message poll timeout still
|
||||
* bounds the join, so do nothing there rather than close a descriptor
|
||||
* another thread may still be using. */
|
||||
struct stat fd_stat;
|
||||
if (fstat(state->fd, &fd_stat) == 0 && S_ISSOCK(fd_stat.st_mode))
|
||||
shutdown(state->fd, SHUT_RDWR);
|
||||
thrd_join(receiver, NULL);
|
||||
}
|
||||
if (writer_created)
|
||||
thrd_join(writer, NULL);
|
||||
return;
|
||||
}
|
||||
int receiver_result;
|
||||
int writer_result;
|
||||
thrd_join(receiver, &receiver_result);
|
||||
thrd_join(writer, &writer_result);
|
||||
bool transfer_ok = receiver_result == thrd_success && writer_result == thrd_success;
|
||||
PipelineContextReceiver* context = state->context;
|
||||
if (transfer_ok && !config->dry_run) {
|
||||
/* Commit-style (late) deletion: receive_thread handed the keep-set
|
||||
manifest here instead of deleting while write_thread might still be
|
||||
draining, so by now every file is on disk and the whole transfer is
|
||||
known to have succeeded. Remove the extras before publishing a
|
||||
--delay-updates run; the walker skips the staging directory. A
|
||||
server-contacting --dry-run deletes nothing (no manifest is sent). */
|
||||
if (context->deferred_manifest) {
|
||||
size_t deleted = 0;
|
||||
DeletePathObserver observer = config->report_deletes ? receiver_record_deleted_path : NULL;
|
||||
DeleteCommitResult deletion = manifest_delete_all_observed(
|
||||
config, context->deferred_manifest, &deleted, observer, (void*)context->deleted_paths);
|
||||
context->stats.deleted_files += deleted;
|
||||
if (deletion == DELETE_COMMIT_ERROR) {
|
||||
transfer_ok = false;
|
||||
} else if (deletion == DELETE_COMMIT_LIMIT_REACHED) {
|
||||
/* The transfer still succeeds; the terminal frame reports the capped
|
||||
deletion so the sender exits 25 like rsync. */
|
||||
context->delete_limit_reached = true;
|
||||
}
|
||||
delete_manifest_free(context->deferred_manifest);
|
||||
context->deferred_manifest = NULL;
|
||||
}
|
||||
/* --delete-delay: receive_thread snapshotted each plan's extras as it
|
||||
arrived; with the disk writer drained, commit the deferred removals.
|
||||
--delete-during already applied its plans on the receive thread. */
|
||||
if (context->deferred_plans) {
|
||||
/* Defence in depth (the enclosing block already excludes dry-run): a
|
||||
-n run never commits a deletion. */
|
||||
if (config->report_deletes)
|
||||
delete_plan_session_set_delete_observer(
|
||||
context->deferred_plans, receiver_record_deleted_path, (void*)context->deleted_paths);
|
||||
DeleteCommitResult deletion =
|
||||
config->dry_run ? DELETE_COMMIT_OK
|
||||
: delete_plan_session_commit(context->deferred_plans, config);
|
||||
context->stats.deleted_files += delete_plan_session_deleted(context->deferred_plans);
|
||||
if (deletion == DELETE_COMMIT_ERROR) {
|
||||
transfer_ok = false;
|
||||
} else if (deletion == DELETE_COMMIT_LIMIT_REACHED) {
|
||||
context->delete_limit_reached = true;
|
||||
}
|
||||
delete_plan_session_destroy(context->deferred_plans);
|
||||
context->deferred_plans = NULL;
|
||||
}
|
||||
}
|
||||
if (transfer_ok && !config->dry_run) {
|
||||
/* --delay-updates: receive_thread has finished the whole protocol stream
|
||||
(including manifest/delete handling) and write_thread has drained its
|
||||
queue, so every staged file is complete. Publish atomically before the
|
||||
success/outcome frame so a --remove-source-files sender only learns of
|
||||
files that were actually installed. */
|
||||
if (config->delay_updates && config->delay_context &&
|
||||
!delay_updates_publish(config->delay_context, config)) {
|
||||
transfer_ok = false;
|
||||
}
|
||||
/* P7 Wave D: all writers have joined and the late deletion (and
|
||||
--delay-updates publication) has committed above, so it is finally safe
|
||||
to stamp directory times; a directory's mtime must not be clobbered by
|
||||
its children or by an extra removal. */
|
||||
if (transfer_ok)
|
||||
dir_metadata_list_apply(&context->dir_times, config->receive_root_directory, config);
|
||||
}
|
||||
if (transfer_ok) {
|
||||
Status final_status = context->delete_limit_reached ? STATUS_DELETE_LIMIT : STATUS_OK;
|
||||
/* Emit the optional wire-stats record first (protocol 2.25.0), then the
|
||||
success/outcome frame, exactly like the single-threaded receiver. */
|
||||
if (!receiver_send_stats_frame(state->fd, config, &context->stats, context->would_delete,
|
||||
context->deleted_paths) ||
|
||||
!receiver_send_final_success(state->fd, config, &context->outcomes, final_status))
|
||||
transfer_ok = false;
|
||||
} else {
|
||||
send_error_detail(state->fd, "transfer failed on receiver");
|
||||
}
|
||||
if (!transfer_ok)
|
||||
log_message(LOG_LEVEL_ERROR, "Transfer failed");
|
||||
}
|
||||
|
||||
/* Phase 4b -- transfer via the single-threaded receiver. Failure is logged
|
||||
* exactly as before; the caller's `done` epilogue then releases the config. */
|
||||
static void server_run_st_receiver(ServerSession* state) {
|
||||
if (receiver_receive_files(state->config, state->fd) != 0)
|
||||
log_message(LOG_LEVEL_ERROR, "Transfer failed");
|
||||
}
|
||||
|
||||
/* Phase 4 dispatch -- choose the receiver implementation the config asks for.
|
||||
* Both helpers own their success/failure logging; the caller falls through to
|
||||
* the shared `done` epilogue either way. */
|
||||
static void server_run_transfer(ServerSession* state) {
|
||||
if (state->config->use_multithreading)
|
||||
server_run_mt_receiver(state);
|
||||
else
|
||||
server_run_st_receiver(state);
|
||||
}
|
||||
|
||||
void handler(int file_descriptor) {
|
||||
/* Single per-connection state; every phase helper below advances it and
|
||||
* returns false on a logged failure. All teardown state starts empty so the
|
||||
* single `done` epilogue is safe to reach from any error path (including
|
||||
* before the config frame arrives). */
|
||||
ServerSession state;
|
||||
state.fd = file_descriptor;
|
||||
state.ssl = io_get_ssl();
|
||||
protocol_session_init(&state.session, file_descriptor, file_descriptor);
|
||||
protocol_session_set_ssl(&state.session, state.ssl);
|
||||
protocol_session_bind(&state.session);
|
||||
state.gate_ctx.ssl = state.ssl;
|
||||
state.gate_ctx.fd = file_descriptor;
|
||||
state.gate_ctx.super_mode_override = -1;
|
||||
state.gate_ctx.has_peer_ip = false;
|
||||
state.gate_ctx.peer_ip[0] = '\0';
|
||||
state.gate_ctx.is_local = false;
|
||||
state.config = NULL;
|
||||
state.context = NULL;
|
||||
state.joined_destination = NULL;
|
||||
state.charset_ready = false;
|
||||
|
||||
if (!server_accept_config(&state))
|
||||
goto done;
|
||||
if (!server_apply_security_gates(&state))
|
||||
goto done;
|
||||
if (!server_prepare_session(&state))
|
||||
goto done;
|
||||
server_run_transfer(&state);
|
||||
|
||||
done:
|
||||
/* Single cleanup epilogue: every error path jumps here, so the iconv
|
||||
@@ -1029,22 +1112,22 @@ done:
|
||||
* connection fd is deliberately NOT closed here -- the child functions own
|
||||
* its single close (plain_child_fn / tls_child_fn), and the --stdio call
|
||||
* site must leave stdin/stdout open. */
|
||||
if (charset_ready)
|
||||
if (state.charset_ready)
|
||||
charset_wire_free();
|
||||
/* The delay-updates staging tree is released by config_delete (which the
|
||||
branch below always reaches), so it is cleaned exactly once. */
|
||||
identity_clear_active();
|
||||
protocol_session_unbind();
|
||||
if (context != NULL) {
|
||||
if (state.context != NULL) {
|
||||
/* context owns both the config and the queue it was created with. */
|
||||
pipeline_context_receiver_destroy(context);
|
||||
context = NULL;
|
||||
config = NULL;
|
||||
pipeline_context_receiver_destroy(state.context);
|
||||
state.context = NULL;
|
||||
state.config = NULL;
|
||||
} else {
|
||||
config_delete(config);
|
||||
config = NULL;
|
||||
config_delete(state.config);
|
||||
state.config = NULL;
|
||||
}
|
||||
free(joined_destination);
|
||||
free(state.joined_destination);
|
||||
}
|
||||
|
||||
#ifndef FASTSYNC_SERVER_AS_LIB
|
||||
|
||||
+12
-12
@@ -20,9 +20,9 @@
|
||||
|
||||
static void config_set_defaults(Config* config) {
|
||||
config->scanner_threads = 0;
|
||||
config->metadata_explicitly_disabled = false;
|
||||
config->preserve_perms_explicit_off = false;
|
||||
config->preserve_times_explicit_off = false;
|
||||
config->cli.preserve_perms_explicit_off = false;
|
||||
config->cli.preserve_times_explicit_off = false;
|
||||
config->cli.metadata_explicitly_disabled = false;
|
||||
config->show_progress = false;
|
||||
config->compression_threads = 0;
|
||||
config->ssh_port = 22;
|
||||
@@ -44,8 +44,8 @@ static void config_set_defaults(Config* config) {
|
||||
config->tls_ca = NULL;
|
||||
config->server_host = str_dup("127.0.0.1");
|
||||
config->server_port = 8080;
|
||||
config->server_port_set = false;
|
||||
config->server_host_set = false;
|
||||
config->cli.server_port_set = false;
|
||||
config->cli.server_host_set = false;
|
||||
/* rsync defaults: --timeout=0 (I/O timeouts disabled) and --contimeout=60.
|
||||
* A value of 0 disables the client's own deadline on both the socket layer
|
||||
* (tcp_set_timeouts) and the protocol layer
|
||||
@@ -68,10 +68,10 @@ static void config_set_defaults(Config* config) {
|
||||
config->human_readable = false;
|
||||
config->ignore_errors = false;
|
||||
config->ignore_missing_args = false;
|
||||
config->checksum_transfer_algo = CHECKSUM_ALGO_DEFAULT;
|
||||
config->cli_exit_code = 0;
|
||||
config->compression_level_set = false;
|
||||
config->checksum_choice_set = false;
|
||||
config->cli.checksum_transfer_algo = CHECKSUM_ALGO_DEFAULT;
|
||||
config->cli.cli_exit_code = 0;
|
||||
config->cli.compression_level_set = false;
|
||||
config->cli.checksum_choice_set = false;
|
||||
config->filters = NULL;
|
||||
config->files_from = NULL;
|
||||
config->files_from_set = NULL;
|
||||
@@ -85,7 +85,6 @@ static void config_set_defaults(Config* config) {
|
||||
config->rsh_command = NULL;
|
||||
config->blocking_io = false;
|
||||
config->outbuf = OUTBUF_BLOCK;
|
||||
config->old_args = false;
|
||||
config->remote_options = NULL;
|
||||
config->remote_option_count = 0;
|
||||
config->address = NULL;
|
||||
@@ -101,7 +100,7 @@ static void config_set_defaults(Config* config) {
|
||||
config->trust_sender = false;
|
||||
config->stop_after_mins = 0;
|
||||
config->stop_at = 0;
|
||||
config->stop_at_set = false;
|
||||
config->cli.stop_at_set = false;
|
||||
config->write_batch = NULL;
|
||||
config->only_write_batch = NULL;
|
||||
config->read_batch = NULL;
|
||||
@@ -342,7 +341,8 @@ bool config_derived_use_metadata(const Config* config) {
|
||||
config->chown_uid_set || config->chown_gid_set || config->usermap_count > 0 ||
|
||||
config->groupmap_count > 0 || config->update)
|
||||
return true;
|
||||
return (config->use_incremental || config->use_delta) && !config->metadata_explicitly_disabled;
|
||||
return (config->use_incremental || config->use_delta) &&
|
||||
!config->cli.metadata_explicitly_disabled;
|
||||
}
|
||||
|
||||
bool config_has_basis(const Config* config) {
|
||||
|
||||
+48
-40
@@ -342,22 +342,60 @@ typedef enum SuperMode { SUPER_MODE_AUTO = 0, SUPER_MODE_ON = 1, SUPER_MODE_OFF
|
||||
CONFIG_WIRE_CODEC_FIELDS(X) \
|
||||
CONFIG_WIRE_PROTECT_FIELDS(X)
|
||||
|
||||
/* Client-only, CLI-parse bookkeeping (never serialized). These members exist
|
||||
* only so the client command-line parser can record HOW an option was
|
||||
* specified (explicitly set, explicitly negated, or a parser-requested exit
|
||||
* code); no other module and no wire peer ever needs them. Grouping them in
|
||||
* one nested member keeps the public Config free of client-CLI-only state. */
|
||||
typedef struct {
|
||||
/* Set when the user explicitly turned an attribute off with --no-perms /
|
||||
* --no-times (long or short form). --incremental/--delta historically
|
||||
* auto-enabled mode and mtime preservation; these flags let
|
||||
* cli_finalize_config restore that behavior while still honoring the
|
||||
* explicit per-attribute negation. A later -p/-t re-enables the attribute
|
||||
* directly, so the flag only prevents the incremental/delta implication,
|
||||
* never a POSITIVE request. */
|
||||
bool preserve_perms_explicit_off;
|
||||
bool preserve_times_explicit_off;
|
||||
/* Set by --no-preserve, the explicit opt-out of the whole preservation
|
||||
* bundle, so the --incremental/--delta auto-preserve implication stays off. */
|
||||
bool metadata_explicitly_disabled;
|
||||
/* True when --server-port/--port was explicitly given. --dry-run uses it to
|
||||
* decide whether a real server handshake was requested, so a plain local
|
||||
* destination (no explicit port) keeps the existing client-side dry-run
|
||||
* behavior instead of dialing the default 127.0.0.1:8080. */
|
||||
bool server_port_set;
|
||||
/* True when --server-host was explicitly given, and distinct from the
|
||||
* "127.0.0.1" default: --dry-run uses it to route an explicit remote target
|
||||
* to the server so it reports receiver state exactly like a real run,
|
||||
* instead of silently running the client-side manifest. */
|
||||
bool server_host_set;
|
||||
/* Codec-negotiation CLI state. The effective pre-transfer checksum is
|
||||
* Config->checksum_algo (serialized); checksum_transfer_algo is the rsync
|
||||
* "transfer" half of a two-name --checksum-choice form (validated and used
|
||||
* only to mirror rsync's whole-file forcing, since FastSync's per-block
|
||||
* strong hash is fixed). cli_exit_code carries a parser-requested process
|
||||
* exit status (rsync uses 4 for an unsupported checksum/compress algorithm)
|
||||
* so main() can mirror it. */
|
||||
int checksum_transfer_algo;
|
||||
int cli_exit_code;
|
||||
/* "The user explicitly chose" bits. They let the per-codec default level /
|
||||
* checksum list be applied only when the corresponding rsync option was
|
||||
* omitted (an explicit --compress-level / --checksum-choice always wins). */
|
||||
bool compression_level_set;
|
||||
bool checksum_choice_set;
|
||||
/* True when --stop-at was given. */
|
||||
bool stop_at_set;
|
||||
} ConfigCliParse;
|
||||
|
||||
typedef struct Config {
|
||||
/* -j/--threads=N: number of parallel scanner worker threads for the -m
|
||||
* pipeline. 0 (the default, also set by bare -j/--threads) means "use the
|
||||
* scanner's built-in default" (4). CLIENT-ONLY: it is a local scheduling
|
||||
* concern and is NEVER serialized into the wire config frame. */
|
||||
int scanner_threads;
|
||||
bool metadata_explicitly_disabled;
|
||||
/* CLIENT-ONLY (never serialized; not in CONFIG_WIRE_FIELDS). Set when the
|
||||
* user explicitly turned an attribute off with --no-perms / --no-times (long
|
||||
* or short form). --incremental/--delta historically auto-enabled mode and
|
||||
* mtime preservation; these flags let cli_finalize_config restore that
|
||||
* behavior while still honoring the explicit per-attribute negation. A
|
||||
* later -p/-t re-enables the attribute directly, so the flag only prevents
|
||||
* the incremental/delta implication, never a POSITIVE request. */
|
||||
bool preserve_perms_explicit_off;
|
||||
bool preserve_times_explicit_off;
|
||||
/* Client-only CLI-parse bookkeeping (never serialized). See ConfigCliParse. */
|
||||
ConfigCliParse cli;
|
||||
bool show_progress;
|
||||
int compression_threads;
|
||||
int ssh_port;
|
||||
@@ -378,18 +416,6 @@ typedef struct Config {
|
||||
bool use_tls;
|
||||
char* server_host;
|
||||
int server_port;
|
||||
/* True when --server-port/--port was explicitly given. CLIENT-ONLY (never
|
||||
* serialized): --dry-run uses it to decide whether a real server handshake
|
||||
* was requested, so a plain local destination (no explicit port) keeps the
|
||||
* existing client-side dry-run behavior instead of dialing the default
|
||||
* 127.0.0.1:8080. */
|
||||
bool server_port_set;
|
||||
/* True when --server-host was explicitly given. CLIENT-ONLY (never
|
||||
* serialized), and distinct from the "127.0.0.1" default: --dry-run uses it
|
||||
* to route an explicit remote target to the server so it reports receiver
|
||||
* state exactly like a real run, instead of silently running the client-side
|
||||
* manifest. */
|
||||
bool server_host_set;
|
||||
char* tls_cert;
|
||||
char* tls_key;
|
||||
char* tls_ca;
|
||||
@@ -435,22 +461,6 @@ typedef struct Config {
|
||||
* enters the keep-set. Implied by --delete-missing-args. */
|
||||
bool ignore_missing_args;
|
||||
|
||||
/* Codec-negotiation CLI state (all client-only, never serialized). The
|
||||
* effective pre-transfer checksum is Config->checksum_algo (serialized);
|
||||
* checksum_transfer_algo is the rsync "transfer" half of a two-name
|
||||
* --checksum-choice form (validated and used only to mirror rsync's
|
||||
* whole-file forcing, since FastSync's per-block strong hash is fixed).
|
||||
* cli_exit_code carries a parser-requested process exit status (rsync uses 4
|
||||
* for an unsupported checksum/compress algorithm) so main() can mirror it. */
|
||||
int checksum_transfer_algo;
|
||||
int cli_exit_code;
|
||||
/* Client-only "the user explicitly chose" bits. They let the per-codec
|
||||
* default level / checksum list be applied only when the corresponding
|
||||
* rsync option was omitted (an explicit --compress-level / --checksum-choice
|
||||
* always wins). Never serialized. */
|
||||
bool compression_level_set;
|
||||
bool checksum_choice_set;
|
||||
|
||||
// Issue #129: Advanced file selection. These fields are CLIENT-ONLY: they are
|
||||
// never serialized to the wire (the receiver must not learn them).
|
||||
ArrayList* filters; /* --filter=RULE rule strings, in order */
|
||||
@@ -486,7 +496,6 @@ typedef struct Config {
|
||||
/* --outbuf mode (OutbufMode): stdout/stderr buffering. Client-only launch
|
||||
* concern: NEVER crosses the wire. */
|
||||
int outbuf;
|
||||
bool old_args;
|
||||
/* --remote-option=OPT (Phase 5, long form only): one or more extra command-line
|
||||
* options to append to the REMOTE server invocation over SSH. CLIENT-ONLY:
|
||||
* they are composed into the remote command line by ssh_build_remote_command()
|
||||
@@ -556,7 +565,6 @@ typedef struct Config {
|
||||
* process and are NEVER serialized into the config frame. */
|
||||
int stop_after_mins; /* --stop-after=MINS minutes; 0 when unset */
|
||||
time_t stop_at; /* --stop-at=... absolute wall-clock deadline */
|
||||
bool stop_at_set; /* true when --stop-at was given */
|
||||
|
||||
/* Client-only residual-batch paths. A residual batch is a self-contained
|
||||
* single-file record of the whole source tree (full file images using the
|
||||
|
||||
@@ -0,0 +1,656 @@
|
||||
#include "delete.h"
|
||||
|
||||
#include "delay_updates.h"
|
||||
#include "filter.h"
|
||||
#include "log.h"
|
||||
#include "utils.h"
|
||||
#include <dirent.h>
|
||||
#include <errno.h>
|
||||
#include <fcntl.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <sys/stat.h>
|
||||
#include <unistd.h>
|
||||
|
||||
/* Build the keep-set index from the exact manifest entries only. A lookup of
|
||||
`rel` succeeds iff `rel` is a kept entry, a kept directory, or an ancestor
|
||||
directory of kept content (the old is_dir_in_manifest predicate); the sorted
|
||||
view answers "is an ancestor of kept content" without materializing any
|
||||
per-component prefix copy, so the index is O(manifest size) memory. */
|
||||
static bool build_keep_index(const ArrayList* manifest, PathIndex* index) {
|
||||
if (!manifest || manifest->size <= 0)
|
||||
return path_index_build(index, NULL, 0);
|
||||
return path_index_build(index, (const char* const*)manifest->items, (size_t)manifest->size);
|
||||
}
|
||||
|
||||
static bool keep_is_dir(const PathIndex* index, const char* rel_path) {
|
||||
return path_index_contains(index, rel_path) || path_index_has_descendant(index, rel_path);
|
||||
}
|
||||
|
||||
static bool keep_is_file(const PathIndex* index, const char* rel_path) {
|
||||
return path_index_contains(index, rel_path);
|
||||
}
|
||||
|
||||
/* True when child_rel is, or lies below, a protected entry. A prefix "a"
|
||||
therefore protects "a" and "a/b/c" but not "ab". Entries with top_level_only
|
||||
set only protect DIRECT children of the receive root (at_root); nested
|
||||
directories that share such a name stay ordinary destination content. */
|
||||
bool path_under_skip_prefix(const char* child_rel, bool at_root, const DeleteSkipEntry* skips,
|
||||
int skip_count) {
|
||||
for (int i = 0; i < skip_count; i++) {
|
||||
if (skips[i].top_level_only && !at_root)
|
||||
continue;
|
||||
size_t prefix_len = strlen(skips[i].prefix);
|
||||
if (strncmp(child_rel, skips[i].prefix, prefix_len) == 0 &&
|
||||
(child_rel[prefix_len] == '\0' || child_rel[prefix_len] == '/'))
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/* Per-run deletion budget and tallies. `max_delete` is the cap on the number
|
||||
of entries the walker may remove (SIZE_MAX = unlimited); once it is reached
|
||||
the remaining extras are counted in `skipped` and left in place, matching
|
||||
rsync's partial --max-delete behavior. */
|
||||
typedef struct {
|
||||
size_t max_delete;
|
||||
size_t deleted;
|
||||
size_t skipped;
|
||||
bool limit_hit;
|
||||
} DeleteBudget;
|
||||
|
||||
/* True when direct children of the directory named by `rel` may be removed.
|
||||
With no synchronization info (dirs == NULL) the whole tree is deletable; when
|
||||
a dirs index is supplied only its exact entries are (the receive root is the
|
||||
"." sentinel). */
|
||||
static bool is_synced_dir(const PathIndex* dirs, const char* rel) {
|
||||
if (!dirs)
|
||||
return true;
|
||||
return path_index_contains(dirs, rel[0] == '\0' ? "." : rel);
|
||||
}
|
||||
|
||||
/* Unsigned byte-wise string compare, matching rsync's u_strcmp (a signed
|
||||
strcmp would order bytes >= 0x80 differently). */
|
||||
static int delete_name_cmp(const char* a, const char* b) {
|
||||
const unsigned char* pa = (const unsigned char*)a;
|
||||
const unsigned char* pb = (const unsigned char*)b;
|
||||
while (*pa != '\0' && *pa == *pb) {
|
||||
pa++;
|
||||
pb++;
|
||||
}
|
||||
return (int)*pa - (int)*pb;
|
||||
}
|
||||
|
||||
bool delete_dir_entries_collect(int dirfd, DeleteDirEntry** out, size_t* count,
|
||||
bool* operation_ok) {
|
||||
*out = NULL;
|
||||
*count = 0;
|
||||
if (operation_ok)
|
||||
*operation_ok = true;
|
||||
int scanfd = openat(dirfd, ".", O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC);
|
||||
if (scanfd < 0)
|
||||
return false;
|
||||
DIR* dir = fdopendir(scanfd);
|
||||
if (!dir) {
|
||||
close(scanfd);
|
||||
return false;
|
||||
}
|
||||
DeleteDirEntry* entries = NULL;
|
||||
size_t used = 0;
|
||||
size_t capacity = 0;
|
||||
bool ok = true;
|
||||
const struct dirent* entry;
|
||||
while ((entry = readdir(dir)) != NULL) {
|
||||
if (strcmp(entry->d_name, ".") == 0 || strcmp(entry->d_name, "..") == 0)
|
||||
continue;
|
||||
struct stat st;
|
||||
if (fstatat(dirfd, entry->d_name, &st, AT_SYMLINK_NOFOLLOW) != 0) {
|
||||
if (errno != ENOENT && operation_ok)
|
||||
*operation_ok = false;
|
||||
continue;
|
||||
}
|
||||
if (used == capacity) {
|
||||
size_t next = capacity == 0 ? 16 : capacity * 2;
|
||||
DeleteDirEntry* grown = realloc(entries, next * sizeof(*grown));
|
||||
if (!grown) {
|
||||
ok = false;
|
||||
break;
|
||||
}
|
||||
entries = grown;
|
||||
capacity = next;
|
||||
}
|
||||
entries[used].name = str_dup(entry->d_name);
|
||||
if (!entries[used].name) {
|
||||
ok = false;
|
||||
break;
|
||||
}
|
||||
entries[used].is_dir = S_ISDIR(st.st_mode);
|
||||
used++;
|
||||
}
|
||||
closedir(dir);
|
||||
if (!ok) {
|
||||
delete_dir_entries_free(entries, used);
|
||||
return false;
|
||||
}
|
||||
*out = entries;
|
||||
*count = used;
|
||||
return true;
|
||||
}
|
||||
|
||||
void delete_dir_entries_free(DeleteDirEntry* entries, size_t count) {
|
||||
if (!entries)
|
||||
return;
|
||||
for (size_t i = 0; i < count; i++)
|
||||
free(entries[i].name);
|
||||
free(entries);
|
||||
}
|
||||
|
||||
/* rsync's extraneous-entry order: subdirectories before files, each group in
|
||||
descending name order. */
|
||||
int delete_dir_entry_cmp_desc(const void* a, const void* b) {
|
||||
const DeleteDirEntry* ea = a;
|
||||
const DeleteDirEntry* eb = b;
|
||||
if (ea->is_dir != eb->is_dir)
|
||||
return ea->is_dir ? -1 : 1;
|
||||
return -delete_name_cmp(ea->name, eb->name);
|
||||
}
|
||||
|
||||
/* rsync's kept-subdirectory order: plain ascending name. */
|
||||
int delete_dir_entry_cmp_asc(const void* a, const void* b) {
|
||||
const DeleteDirEntry* ea = a;
|
||||
const DeleteDirEntry* eb = b;
|
||||
return delete_name_cmp(ea->name, eb->name);
|
||||
}
|
||||
|
||||
/* How the shared classification/descent walk disposes of an extra it has
|
||||
identified. LIST records the destination-relative path without touching disk
|
||||
(the -n/--dry-run would-delete enumeration); DELETE unlinks/rmdirs it, charges
|
||||
the shared --max-delete budget and notifies the observer. Both modes classify
|
||||
and traverse identically, so the dry-run enumeration and the real deletion
|
||||
cannot drift. */
|
||||
typedef enum { DELETE_WALK_MODE_DELETE, DELETE_WALK_MODE_LIST } DeleteWalkMode;
|
||||
|
||||
typedef struct {
|
||||
DeleteWalkMode mode;
|
||||
DeleteBudget* budget; /* DELETE mode */
|
||||
ArrayList* out; /* LIST mode: receives strdup'd relative paths */
|
||||
size_t* recorded; /* LIST mode */
|
||||
DeletePathObserver observer; /* DELETE mode */
|
||||
void* observer_context; /* DELETE mode */
|
||||
} DeleteWalkState;
|
||||
|
||||
/* Remove the extras directly inside the directory open on `dirfd` (DELETE mode)
|
||||
or record the paths that WOULD be removed (LIST mode), recursing into every
|
||||
child directory so kept content below a synchronized prefix is reached.
|
||||
`all_removed` reports whether every child entry was removed (so the caller may
|
||||
rmdir this directory). A child directory is never removed when it is itself a
|
||||
synchronized directory or holds kept content; with a dirs index supplied,
|
||||
direct children of a non-synchronized directory are never extras at all (they
|
||||
are left in place but still descended into). Symlinks are unlinked like any
|
||||
other non-directory extra (never followed).
|
||||
|
||||
Entries are processed in rsync's order (extraneous subdirectories in
|
||||
descending name order, then extraneous files, then kept subdirectories in
|
||||
ascending order) rather than readdir() order, so `--max-delete` leaves the
|
||||
same survivors and the `--info=del`/dry-run line order matches rsync. */
|
||||
static bool delete_walk_fd(int dirfd, const char* rel_path, const PathIndex* keep,
|
||||
const PathIndex* dirs, DeleteWalkState* state,
|
||||
const DeleteSkipEntry* skips, int skip_count,
|
||||
const FilterRuleList* protect_rules, bool parent_deletable,
|
||||
bool* all_removed) {
|
||||
DeleteDirEntry* entries = NULL;
|
||||
size_t count = 0;
|
||||
bool collect_ok = true;
|
||||
if (!delete_dir_entries_collect(dirfd, &entries, &count, &collect_ok))
|
||||
return false;
|
||||
bool operation_ok = collect_ok;
|
||||
bool local_survives = false;
|
||||
bool* shielded = calloc(count ? count : 1, sizeof(bool));
|
||||
bool* is_extra = calloc(count ? count : 1, sizeof(bool));
|
||||
if (!shielded || !is_extra) {
|
||||
free(shielded);
|
||||
free(is_extra);
|
||||
delete_dir_entries_free(entries, count);
|
||||
return false;
|
||||
}
|
||||
/* A directory is deletable when it or ANY ancestor is synchronized; the
|
||||
`parent_deletable` flag carries that down the recursion so dest-only
|
||||
directories below a synchronized root are removed wholesale. */
|
||||
bool deletable = parent_deletable || is_synced_dir(dirs, rel_path);
|
||||
bool at_root = rel_path[0] == '\0';
|
||||
|
||||
/* Reproduce rsync's traversal order: extraneous subdirectories in descending
|
||||
name order, then extraneous files in descending name order, and kept
|
||||
subdirectories only afterwards (ascending). Sorting up front also fixes the
|
||||
identity of the survivors under a partial --max-delete. */
|
||||
if (count > 1)
|
||||
qsort(entries, count, sizeof(*entries), delete_dir_entry_cmp_desc);
|
||||
size_t dir_count = 0;
|
||||
while (dir_count < count && entries[dir_count].is_dir)
|
||||
dir_count++;
|
||||
|
||||
/* Classify every entry up front (the verdict does not depend on processing
|
||||
order) so the ordered passes below can act on it. */
|
||||
for (size_t i = 0; i < count; i++) {
|
||||
char* child_rel = path_cat((char*)rel_path, entries[i].name);
|
||||
if (!child_rel) {
|
||||
operation_ok = false;
|
||||
continue;
|
||||
}
|
||||
/* A --delay-updates run keeps its staging directory as a direct child of
|
||||
the receive root, and basis-dir snapshots live below it too. Their
|
||||
contents are not manifest entries, so descending into them would delete
|
||||
every staged / basis file as an "extra". Only the staging name (a
|
||||
top-level-only prefix) and the basis prefixes are protected: a nested
|
||||
destination directory that happens to be called .fastsync-stage is
|
||||
ordinary content. */
|
||||
if (path_under_skip_prefix(child_rel, at_root, skips, skip_count)) {
|
||||
shielded[i] = true;
|
||||
local_survives = true;
|
||||
} else if (protect_rules &&
|
||||
filter_rules_apply_side(protect_rules, child_rel, entries[i].name, entries[i].is_dir,
|
||||
FILTER_SIDE_RECEIVER) == FILTER_ACTION_PROTECT) {
|
||||
/* A first-match protect rule shields the extra; for a directory the whole
|
||||
subtree is shielded (rsync prunes an excluded directory), so do not
|
||||
descend. */
|
||||
shielded[i] = true;
|
||||
local_survives = true;
|
||||
} else if (entries[i].is_dir) {
|
||||
bool child_synced = dirs && path_index_contains(dirs, child_rel);
|
||||
is_extra[i] = deletable && !child_synced && !keep_is_dir(keep, child_rel);
|
||||
if (!is_extra[i])
|
||||
local_survives = true;
|
||||
} else {
|
||||
is_extra[i] = deletable && !keep_is_file(keep, child_rel);
|
||||
if (!is_extra[i])
|
||||
local_survives = true;
|
||||
}
|
||||
free(child_rel);
|
||||
}
|
||||
|
||||
/* Pass 1: extraneous subdirectories, descending. */
|
||||
for (size_t i = 0; i < dir_count; i++) {
|
||||
if (!is_extra[i])
|
||||
continue;
|
||||
char* child_rel = path_cat((char*)rel_path, entries[i].name);
|
||||
if (!child_rel) {
|
||||
operation_ok = false;
|
||||
continue;
|
||||
}
|
||||
int childfd = openat(dirfd, entries[i].name, O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC);
|
||||
bool child_all_removed = false;
|
||||
if (childfd >= 0) {
|
||||
if (!delete_walk_fd(childfd, child_rel, keep, dirs, state, skips, skip_count, protect_rules,
|
||||
deletable, &child_all_removed))
|
||||
operation_ok = false;
|
||||
close(childfd);
|
||||
} else if (errno != ENOENT) {
|
||||
operation_ok = false;
|
||||
}
|
||||
if (child_all_removed && deletable) {
|
||||
if (state->mode == DELETE_WALK_MODE_LIST) {
|
||||
/* Record the directory with rsync's trailing slash. */
|
||||
size_t len = strlen(child_rel);
|
||||
char* copy = malloc(len + 2);
|
||||
if (!copy) {
|
||||
operation_ok = false;
|
||||
} else {
|
||||
memcpy(copy, child_rel, len);
|
||||
copy[len] = '/';
|
||||
copy[len + 1] = '\0';
|
||||
if (!array_list_add(state->out, copy)) {
|
||||
free(copy);
|
||||
operation_ok = false;
|
||||
} else {
|
||||
(*state->recorded)++;
|
||||
}
|
||||
}
|
||||
} else if (state->budget->deleted >= state->budget->max_delete) {
|
||||
state->budget->limit_hit = true;
|
||||
state->budget->skipped++;
|
||||
local_survives = true;
|
||||
} else if (unlinkat(dirfd, entries[i].name, AT_REMOVEDIR) != 0) {
|
||||
/* ENOENT: already gone (fine). ENOTEMPTY/EEXIST: the directory still
|
||||
holds entries the walker leaves in place (a protected excluded
|
||||
prefix, a kept file the manifest protects, a symlink); rsync leaves
|
||||
such a directory behind, so this is not an error. Only genuine I/O
|
||||
failures abort the deletion. */
|
||||
if (errno != ENOENT && errno != ENOTEMPTY && errno != EEXIST)
|
||||
operation_ok = false;
|
||||
local_survives = true;
|
||||
} else {
|
||||
state->budget->deleted++;
|
||||
/* rsync reports a removed directory with a trailing slash. */
|
||||
if (state->observer) {
|
||||
size_t len = strlen(child_rel);
|
||||
char* with_slash = malloc(len + 2);
|
||||
if (with_slash) {
|
||||
memcpy(with_slash, child_rel, len);
|
||||
with_slash[len] = '/';
|
||||
with_slash[len + 1] = '\0';
|
||||
state->observer(state->observer_context, with_slash);
|
||||
free(with_slash);
|
||||
} else {
|
||||
state->observer(state->observer_context, child_rel);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
local_survives = true;
|
||||
}
|
||||
free(child_rel);
|
||||
}
|
||||
|
||||
/* Pass 2: extraneous files, descending. */
|
||||
for (size_t i = dir_count; i < count; i++) {
|
||||
if (!is_extra[i])
|
||||
continue;
|
||||
if (state->mode == DELETE_WALK_MODE_LIST) {
|
||||
char* child_rel = path_cat((char*)rel_path, entries[i].name);
|
||||
if (!child_rel) {
|
||||
operation_ok = false;
|
||||
continue;
|
||||
}
|
||||
char* copy = str_dup(child_rel);
|
||||
if (!copy || !array_list_add(state->out, copy)) {
|
||||
free(copy);
|
||||
operation_ok = false;
|
||||
} else {
|
||||
(*state->recorded)++;
|
||||
}
|
||||
free(child_rel);
|
||||
} else if (state->budget->deleted >= state->budget->max_delete) {
|
||||
state->budget->limit_hit = true;
|
||||
state->budget->skipped++;
|
||||
local_survives = true;
|
||||
} else if (unlinkat(dirfd, entries[i].name, 0) != 0) {
|
||||
if (errno != ENOENT)
|
||||
operation_ok = false;
|
||||
local_survives = true;
|
||||
} else {
|
||||
state->budget->deleted++;
|
||||
char* child_rel = path_cat((char*)rel_path, entries[i].name);
|
||||
if (child_rel) {
|
||||
if (state->observer)
|
||||
state->observer(state->observer_context, child_rel);
|
||||
char* escaped_path = output_escape(child_rel, log_get_8_bit_output());
|
||||
fprintf(stderr, " Deleted: %s\n", escaped_path ? escaped_path : "<allocation failed>");
|
||||
free(escaped_path);
|
||||
}
|
||||
free(child_rel);
|
||||
}
|
||||
}
|
||||
|
||||
/* Pass 3: kept subdirectories, ascending (rsync descends into these only
|
||||
after the parent's own extras have been handled). */
|
||||
for (size_t i = dir_count; i-- > 0;) {
|
||||
if (is_extra[i] || shielded[i])
|
||||
continue;
|
||||
char* child_rel = path_cat((char*)rel_path, entries[i].name);
|
||||
if (!child_rel) {
|
||||
operation_ok = false;
|
||||
continue;
|
||||
}
|
||||
int childfd = openat(dirfd, entries[i].name, O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC);
|
||||
bool child_all_removed = false;
|
||||
if (childfd >= 0) {
|
||||
if (!delete_walk_fd(childfd, child_rel, keep, dirs, state, skips, skip_count, protect_rules,
|
||||
deletable, &child_all_removed))
|
||||
operation_ok = false;
|
||||
close(childfd);
|
||||
} else if (errno != ENOENT) {
|
||||
operation_ok = false;
|
||||
}
|
||||
/* A kept/synchronized directory is never removed. */
|
||||
local_survives = true;
|
||||
free(child_rel);
|
||||
}
|
||||
|
||||
free(shielded);
|
||||
free(is_extra);
|
||||
delete_dir_entries_free(entries, count);
|
||||
*all_removed = !local_survives;
|
||||
return operation_ok;
|
||||
}
|
||||
|
||||
/* Open the receive root following the same authorized-root confinement the
|
||||
walker uses, or dest_root directly when no authorized root is installed. */
|
||||
static int open_destination_root(const char* dest_root) {
|
||||
int root_fd = utils_get_authorized_root_fd();
|
||||
if (root_fd >= 0) {
|
||||
if (utils_get_authorized_root_path())
|
||||
return utils_open_authorized_destination(dest_root);
|
||||
if (dest_root == NULL)
|
||||
return dup(root_fd);
|
||||
return -1;
|
||||
}
|
||||
return open(dest_root, O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC);
|
||||
}
|
||||
|
||||
bool delete_extras_list(const char* dest_root, const ArrayList* manifest,
|
||||
const ArrayList* synced_dirs, const DeleteSkipEntry* skips, int skip_count,
|
||||
const FilterRuleList* protect_rules, ArrayList* out, size_t* count_out) {
|
||||
if (count_out)
|
||||
*count_out = 0;
|
||||
if (!manifest || !out)
|
||||
return false;
|
||||
PathIndex keep;
|
||||
if (!build_keep_index(manifest, &keep))
|
||||
return false;
|
||||
PathIndex dirs;
|
||||
bool have_dirs = synced_dirs != NULL;
|
||||
if (have_dirs &&
|
||||
!path_index_build(&dirs, (const char* const*)synced_dirs->items, (size_t)synced_dirs->size)) {
|
||||
path_index_free(&keep);
|
||||
return false;
|
||||
}
|
||||
int rootfd = open_destination_root(dest_root);
|
||||
if (rootfd < 0) {
|
||||
path_index_free(&keep);
|
||||
if (have_dirs)
|
||||
path_index_free(&dirs);
|
||||
return false;
|
||||
}
|
||||
bool all_removed = false;
|
||||
size_t recorded = 0;
|
||||
DeleteWalkState state = {.mode = DELETE_WALK_MODE_LIST,
|
||||
.budget = NULL,
|
||||
.out = out,
|
||||
.recorded = &recorded,
|
||||
.observer = NULL,
|
||||
.observer_context = NULL};
|
||||
bool ok = delete_walk_fd(rootfd, "", &keep, have_dirs ? &dirs : NULL, &state, skips, skip_count,
|
||||
protect_rules, false, &all_removed);
|
||||
if (close(rootfd) != 0)
|
||||
ok = false;
|
||||
path_index_free(&keep);
|
||||
if (have_dirs)
|
||||
path_index_free(&dirs);
|
||||
if (count_out)
|
||||
*count_out = recorded;
|
||||
return ok;
|
||||
}
|
||||
|
||||
DeleteWalkResult delete_extras_limited_observed(const char* dest_root, const ArrayList* manifest,
|
||||
const ArrayList* synced_dirs, size_t max_delete,
|
||||
const DeleteSkipEntry* skips, int skip_count,
|
||||
const FilterRuleList* protect_rules,
|
||||
size_t* deleted_out, size_t* skipped_out,
|
||||
DeletePathObserver observer,
|
||||
void* observer_context) {
|
||||
if (deleted_out)
|
||||
*deleted_out = 0;
|
||||
if (skipped_out)
|
||||
*skipped_out = 0;
|
||||
if (!manifest)
|
||||
return DELETE_WALK_ERROR;
|
||||
/* Index the keep-set (and the synchronized-dir set, when supplied) once so
|
||||
membership is answered in O(path length) instead of scanning every entry
|
||||
for every destination entry. */
|
||||
PathIndex keep;
|
||||
if (!build_keep_index(manifest, &keep))
|
||||
return DELETE_WALK_ERROR;
|
||||
PathIndex dirs;
|
||||
bool have_dirs = synced_dirs != NULL;
|
||||
if (have_dirs &&
|
||||
!path_index_build(&dirs, (const char* const*)synced_dirs->items, (size_t)synced_dirs->size)) {
|
||||
path_index_free(&keep);
|
||||
return DELETE_WALK_ERROR;
|
||||
}
|
||||
int rootfd = open_destination_root(dest_root);
|
||||
if (rootfd < 0) {
|
||||
path_index_free(&keep);
|
||||
if (have_dirs)
|
||||
path_index_free(&dirs);
|
||||
return DELETE_WALK_ERROR;
|
||||
}
|
||||
DeleteBudget budget = {.max_delete = max_delete, .deleted = 0, .skipped = 0, .limit_hit = false};
|
||||
bool all_removed = false;
|
||||
DeleteWalkState state = {.mode = DELETE_WALK_MODE_DELETE,
|
||||
.budget = &budget,
|
||||
.out = NULL,
|
||||
.recorded = NULL,
|
||||
.observer = observer,
|
||||
.observer_context = observer_context};
|
||||
bool ok = delete_walk_fd(rootfd, "", &keep, have_dirs ? &dirs : NULL, &state, skips, skip_count,
|
||||
protect_rules, false, &all_removed);
|
||||
if (close(rootfd) != 0)
|
||||
ok = false;
|
||||
path_index_free(&keep);
|
||||
if (have_dirs)
|
||||
path_index_free(&dirs);
|
||||
if (deleted_out)
|
||||
*deleted_out = budget.deleted;
|
||||
if (skipped_out)
|
||||
*skipped_out = budget.skipped;
|
||||
if (!ok)
|
||||
return DELETE_WALK_ERROR;
|
||||
return budget.limit_hit ? DELETE_WALK_LIMIT_REACHED : DELETE_WALK_OK;
|
||||
}
|
||||
|
||||
DeleteWalkResult delete_extras_limited(const char* dest_root, const ArrayList* manifest,
|
||||
const ArrayList* synced_dirs, size_t max_delete,
|
||||
const DeleteSkipEntry* skips, int skip_count,
|
||||
const FilterRuleList* protect_rules, size_t* deleted_out,
|
||||
size_t* skipped_out) {
|
||||
return delete_extras_limited_observed(dest_root, manifest, synced_dirs, max_delete, skips,
|
||||
skip_count, protect_rules, deleted_out, skipped_out, NULL,
|
||||
NULL);
|
||||
}
|
||||
|
||||
bool delete_extras(const char* dest_root, const ArrayList* manifest) {
|
||||
return delete_extras_limited(dest_root, manifest, NULL, SIZE_MAX, NULL, 0, NULL, NULL, NULL) ==
|
||||
DELETE_WALK_OK;
|
||||
}
|
||||
|
||||
/* Build the delete-walk protection prefix for one basis directory. The walker
|
||||
compares paths relative to the receive root, so a relative entry is already
|
||||
in the right form; an absolute entry that lies below the root is converted to
|
||||
its root-relative form, and one outside the root returns NULL (the walk
|
||||
cannot reach it, and it is not protected data beneath the root). Exposed so
|
||||
tests can exercise the root-of-"/" child mapping directly. */
|
||||
char* delete_basis_relative(const Config* config, const char* path) {
|
||||
if (!path)
|
||||
return NULL;
|
||||
if (path[0] != '/')
|
||||
return str_dup(path);
|
||||
const char* root = config->receive_root_directory;
|
||||
if (!root || root[0] != '/')
|
||||
return NULL;
|
||||
size_t root_len = strlen(root);
|
||||
while (root_len > 1 && root[root_len - 1] == '/')
|
||||
root_len--;
|
||||
if (strncmp(path, root, root_len) != 0)
|
||||
return NULL;
|
||||
if (root_len == 1) {
|
||||
/* `root` is "/" (the only single-character absolute root): every absolute
|
||||
path is below it, and the child relative form is everything after the
|
||||
leading '/'. */
|
||||
if (path[1] == '\0')
|
||||
return NULL; /* identical to the root, not a child */
|
||||
return str_dup(path + 1);
|
||||
}
|
||||
if (path[root_len] != '/')
|
||||
return NULL; /* identical or a sibling sharing a name prefix */
|
||||
return str_dup(path + root_len + 1);
|
||||
}
|
||||
|
||||
bool delete_skips_build(const Config* config, const ArrayList* protected_paths,
|
||||
const ArrayList* size_skipped, bool basis_root_relative,
|
||||
DeleteSkipSet* out) {
|
||||
if (!out)
|
||||
return false;
|
||||
out->entries = NULL;
|
||||
out->owned_prefixes = NULL;
|
||||
out->count = 0;
|
||||
out->owned_count = 0;
|
||||
if (!config)
|
||||
return false;
|
||||
int protected_count = protected_paths ? protected_paths->size : 0;
|
||||
int size_skipped_count = size_skipped ? size_skipped->size : 0;
|
||||
int count =
|
||||
(config->delay_updates ? 1 : 0) + config->basis_count + protected_count + size_skipped_count;
|
||||
if (count == 0)
|
||||
return true;
|
||||
out->entries = calloc((size_t)count, sizeof(DeleteSkipEntry));
|
||||
if (!out->entries)
|
||||
return false;
|
||||
if (basis_root_relative && config->basis_count > 0) {
|
||||
out->owned_prefixes = calloc((size_t)config->basis_count, sizeof(char*));
|
||||
if (!out->owned_prefixes) {
|
||||
free(out->entries);
|
||||
out->entries = NULL;
|
||||
return false;
|
||||
}
|
||||
out->owned_count = config->basis_count;
|
||||
}
|
||||
int idx = 0;
|
||||
if (config->delay_updates) {
|
||||
out->entries[idx].prefix = DELAY_UPDATES_STAGING_DIR;
|
||||
out->entries[idx].top_level_only = true;
|
||||
idx++;
|
||||
}
|
||||
for (int i = 0; i < config->basis_count; i++) {
|
||||
const char* prefix = config->basis_dirs[i].path;
|
||||
if (basis_root_relative) {
|
||||
/* An absolute basis outside the receive root is unreachable by this walk,
|
||||
so it contributes no protection prefix (and no slot). */
|
||||
char* relative = delete_basis_relative(config, config->basis_dirs[i].path);
|
||||
if (!relative)
|
||||
continue;
|
||||
out->owned_prefixes[i] = relative;
|
||||
prefix = relative;
|
||||
}
|
||||
out->entries[idx].prefix = prefix;
|
||||
out->entries[idx].top_level_only = false;
|
||||
idx++;
|
||||
}
|
||||
for (int i = 0; i < protected_count; i++) {
|
||||
out->entries[idx].prefix = (const char*)protected_paths->items[i];
|
||||
out->entries[idx].top_level_only = false;
|
||||
idx++;
|
||||
}
|
||||
for (int i = 0; i < size_skipped_count; i++) {
|
||||
out->entries[idx].prefix = (const char*)size_skipped->items[i];
|
||||
out->entries[idx].top_level_only = false;
|
||||
idx++;
|
||||
}
|
||||
out->count = idx;
|
||||
return true;
|
||||
}
|
||||
|
||||
void delete_skips_free(DeleteSkipSet* set) {
|
||||
if (!set)
|
||||
return;
|
||||
if (set->owned_prefixes) {
|
||||
for (int i = 0; i < set->owned_count; i++)
|
||||
free(set->owned_prefixes[i]);
|
||||
}
|
||||
free(set->owned_prefixes);
|
||||
free(set->entries);
|
||||
set->entries = NULL;
|
||||
set->owned_prefixes = NULL;
|
||||
set->count = 0;
|
||||
set->owned_count = 0;
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
#ifndef DELETE_H
|
||||
#define DELETE_H
|
||||
|
||||
#include "array_list.h"
|
||||
#include "config.h"
|
||||
#include <stdbool.h>
|
||||
#include <stddef.h>
|
||||
|
||||
/* Delete engine.
|
||||
*
|
||||
* This module owns destination-relative delete traversal: the ordered directory
|
||||
* walker that reproduces rsync's extraneous-entry order, the skip-prefix
|
||||
* protection set shared by every delete pass, and the read-only enumeration
|
||||
* that mirrors the walker for -n/--dry-run. The budgeted manifest commit
|
||||
* (delete_commit.c) and the per-directory delete plans (delete_plan.c) are
|
||||
* built on the primitives exported here. */
|
||||
|
||||
/* Result of a bounded extra-file deletion run. */
|
||||
typedef enum {
|
||||
/* Every extra entry was removed (or there were none). */
|
||||
DELETE_WALK_OK = 0,
|
||||
/* The numeric cap for this run was reached before every extra was removed.
|
||||
The walker removed exactly the entries the cap allowed and skipped (without
|
||||
removing) the rest, matching rsync's partial --max-delete behavior. */
|
||||
DELETE_WALK_LIMIT_REACHED,
|
||||
/* A traversal or unlink failure aborted the deletion (partial removal is
|
||||
possible, mirroring the delete pass). */
|
||||
DELETE_WALK_ERROR
|
||||
} DeleteWalkResult;
|
||||
|
||||
/* One protected entry for the delete walker. When top_level_only is true the
|
||||
prefix is skipped only as a DIRECT child of dest_root (the --delay-updates
|
||||
staging directory, which must not hide genuine extras inside a nested
|
||||
destination directory that happens to share the staging name); otherwise the
|
||||
prefix is skipped at any depth (the --compare-dest/--copy-dest/--link-dest
|
||||
basis trees, and the sender-side protected filter-excluded prefixes, which
|
||||
are never destination content). */
|
||||
typedef struct {
|
||||
const char* prefix;
|
||||
bool top_level_only;
|
||||
} DeleteSkipEntry;
|
||||
|
||||
/* A built skip-prefix set. `entries`/`count` are what path_under_skip_prefix()
|
||||
consumes. `owned_prefixes` holds any prefix strings the builder had to
|
||||
allocate (root-relative basis-dir conversions); it is NULL when every prefix
|
||||
is borrowed from the config or the caller's lists. Release with
|
||||
delete_skips_free(). */
|
||||
typedef struct {
|
||||
DeleteSkipEntry* entries;
|
||||
char** owned_prefixes;
|
||||
int count;
|
||||
int owned_count;
|
||||
} DeleteSkipSet;
|
||||
|
||||
/* True when child_rel is, or lies below, one of the protected entries (a prefix
|
||||
"a" protects "a" and "a/b/c" but not "ab"; top_level_only entries protect
|
||||
only DIRECT children of the destination root, i.e. child_rel has no '/'). */
|
||||
bool path_under_skip_prefix(const char* child_rel, bool at_root, const DeleteSkipEntry* skips,
|
||||
int skip_count);
|
||||
|
||||
/* One destination-directory entry collected up front so the delete walkers can
|
||||
reproduce rsync's traversal order instead of readdir() order. rsync processes
|
||||
a directory's extraneous subdirectories first (descending name, depth-first),
|
||||
then its extraneous files (descending name), and only afterwards descends into
|
||||
its kept subdirectories (ascending name). */
|
||||
typedef struct {
|
||||
char* name;
|
||||
bool is_dir;
|
||||
} DeleteDirEntry;
|
||||
/* Collect the entries of the directory open on `dirfd` (excluding "." and ".."),
|
||||
stat'ing each with AT_SYMLINK_NOFOLLOW. On success *out is a malloc'd array of
|
||||
*count entries whose names the caller frees with delete_dir_entries_free().
|
||||
Returns false on an allocation/readdir failure; a vanished entry (ENOENT) is
|
||||
skipped, any other stat failure is reported through *operation_ok while the
|
||||
walk continues. */
|
||||
bool delete_dir_entries_collect(int dirfd, DeleteDirEntry** out, size_t* count, bool* operation_ok);
|
||||
void delete_dir_entries_free(DeleteDirEntry* entries, size_t count);
|
||||
/* Sort comparators: `_desc` orders subdirectories before files and each group by
|
||||
descending name (rsync's extraneous-entry order); `_asc` orders plain ascending
|
||||
name (rsync's kept-subdirectory order). */
|
||||
int delete_dir_entry_cmp_desc(const void* a, const void* b);
|
||||
int delete_dir_entry_cmp_asc(const void* a, const void* b);
|
||||
|
||||
/* Remove files/dirs/symlinks under dest_root that are not listed in manifest
|
||||
without ever descending into a protected prefix (see DeleteSkipEntry). When
|
||||
`synced_dirs` is non-NULL, extras are only removed directly inside a directory
|
||||
whose destination-relative path is an exact entry in that list (the receive
|
||||
root is the "." sentinel); directories outside the synchronized set are still
|
||||
descended into so kept content below a listed directory is preserved, but
|
||||
nothing in them is removed. A NULL `synced_dirs` keeps the legacy behavior of
|
||||
treating the whole destination tree as deletable. `max_delete` caps the
|
||||
number of removed entries (SIZE_MAX = unlimited): the walker removes up to the
|
||||
cap and returns DELETE_WALK_LIMIT_REACHED when more extras remained.
|
||||
`deleted_out`/`skipped_out` optionally receive the number of entries removed
|
||||
and the number skipped because of the cap. */
|
||||
DeleteWalkResult delete_extras_limited(const char* dest_root, const ArrayList* manifest,
|
||||
const ArrayList* synced_dirs, size_t max_delete,
|
||||
const DeleteSkipEntry* skips, int skip_count,
|
||||
const FilterRuleList* protect_rules, size_t* deleted_out,
|
||||
size_t* skipped_out);
|
||||
|
||||
/* Optional per-deletion observer: called for each destination-relative path
|
||||
actually removed (a file, symlink, or directory), in removal order, so the
|
||||
receiver can stream rsync's `--info=del`/`--info=remove` lines. */
|
||||
typedef void (*DeletePathObserver)(void* context, const char* rel_path);
|
||||
|
||||
/* `delete_extras_limited_observed` is delete_extras_limited with an optional
|
||||
* observer; the observer is invoked only for entries truly removed. When
|
||||
* `protect_rules` is non-NULL its receiver-side verdict is evaluated for every
|
||||
* candidate extra: a first-match PROTECT leaves the entry (and, for a
|
||||
* directory, its whole subtree) in place, while RISK/NONE fall through to the
|
||||
* ordinary skip-prefix/keep-set logic. */
|
||||
DeleteWalkResult delete_extras_limited_observed(const char* dest_root, const ArrayList* manifest,
|
||||
const ArrayList* synced_dirs, size_t max_delete,
|
||||
const DeleteSkipEntry* skips, int skip_count,
|
||||
const FilterRuleList* protect_rules,
|
||||
size_t* deleted_out, size_t* skipped_out,
|
||||
DeletePathObserver observer,
|
||||
void* observer_context);
|
||||
/* Read-only companion to delete_extras_limited: walk the destination exactly as
|
||||
the delete pass would and APPEND (strdup'd) destination-relative paths that
|
||||
WOULD be removed, without touching disk. Used for -n/--dry-run --delete
|
||||
would-delete reporting. Returns true on a clean walk; the caller owns the
|
||||
strings appended to `out` and receives their count in *count_out. */
|
||||
bool delete_extras_list(const char* dest_root, const ArrayList* manifest,
|
||||
const ArrayList* synced_dirs, const DeleteSkipEntry* skips, int skip_count,
|
||||
const FilterRuleList* protect_rules, ArrayList* out, size_t* count_out);
|
||||
bool delete_extras(const char* dest_root, const ArrayList* manifest);
|
||||
|
||||
/* Build the delete walk's skip-prefix set from the config's --delay-updates
|
||||
staging directory, its --compare-dest/--copy-dest/--link-dest basis dirs, and
|
||||
the caller-supplied protection lists, in that order. `protected_paths` and
|
||||
`size_skipped` are borrowed (may be NULL); every entry in them is protected at
|
||||
any depth. The staging directory is protected only as a DIRECT child of the
|
||||
receive root. `basis_root_relative` selects how a basis path becomes a
|
||||
prefix: true converts an absolute path under the receive root to its
|
||||
root-relative form (the whole-tree commit walk; an unreachable path
|
||||
contributes no slot), false keeps the configured path verbatim (the
|
||||
per-directory plan walk). On success the caller releases `*out` with
|
||||
delete_skips_free(); returns false on allocation failure. */
|
||||
bool delete_skips_build(const Config* config, const ArrayList* protected_paths,
|
||||
const ArrayList* size_skipped, bool basis_root_relative,
|
||||
DeleteSkipSet* out);
|
||||
void delete_skips_free(DeleteSkipSet* set);
|
||||
|
||||
/* Convert one basis-directory path to the receive-root-relative protection
|
||||
prefix the delete walker uses (NULL when it lies outside the root). Exposed
|
||||
for unit tests of the root-of-"/" and normalization edge cases. */
|
||||
char* delete_basis_relative(const Config* config, const char* path);
|
||||
|
||||
#endif
|
||||
+40
-187
@@ -126,38 +126,6 @@ typedef struct {
|
||||
bool limit_hit;
|
||||
} DeleteBudgetState;
|
||||
|
||||
/* Build the delete-walk protection prefix for one basis directory. The walker
|
||||
compares paths relative to the receive root, so a relative entry is already
|
||||
in the right form; an absolute entry that lies below the root is converted to
|
||||
its root-relative form, and one outside the root returns NULL (the walk
|
||||
cannot reach it, and it is not protected data beneath the root). Exposed so
|
||||
tests can exercise the root-of-"/" child mapping directly. */
|
||||
char* file_receive_basis_delete_relative(const Config* config, const char* path) {
|
||||
if (!path)
|
||||
return NULL;
|
||||
if (path[0] != '/')
|
||||
return str_dup(path);
|
||||
const char* root = config->receive_root_directory;
|
||||
if (!root || root[0] != '/')
|
||||
return NULL;
|
||||
size_t root_len = strlen(root);
|
||||
while (root_len > 1 && root[root_len - 1] == '/')
|
||||
root_len--;
|
||||
if (strncmp(path, root, root_len) != 0)
|
||||
return NULL;
|
||||
if (root_len == 1) {
|
||||
/* `root` is "/" (the only single-character absolute root): every absolute
|
||||
path is below it, and the child relative form is everything after the
|
||||
leading '/'. */
|
||||
if (path[1] == '\0')
|
||||
return NULL; /* identical to the root, not a child */
|
||||
return str_dup(path + 1);
|
||||
}
|
||||
if (path[root_len] != '/')
|
||||
return NULL; /* identical or a sibling sharing a name prefix */
|
||||
return str_dup(path + root_len + 1);
|
||||
}
|
||||
|
||||
/* Remove every destination entry under the receive root that is not in the
|
||||
keep-set, bounded by the shared budget (a smaller client --max-delete=NUM
|
||||
replaces the server hard bound; rsync deletes up to the bound and skips the
|
||||
@@ -168,61 +136,19 @@ char* file_receive_basis_delete_relative(const Config* config, const char* path)
|
||||
alternate basis directories are never destination content and are skipped at
|
||||
any depth. Returns true unless a traversal/unlink error aborted the walk;
|
||||
the budget's limit_hit/skipped fields report a cap-stopped run. */
|
||||
static bool delete_extras_budgeted_observed(const Config* config, DeleteManifest* manifest,
|
||||
static bool delete_extras_budgeted_observed(const Config* config, const DeleteManifest* manifest,
|
||||
DeleteBudgetState* budget, DeletePathObserver observer,
|
||||
void* observer_context) {
|
||||
if (!config || !manifest || !manifest->keeps)
|
||||
return false;
|
||||
fprintf(stderr, "Deleting files not in manifest...\n");
|
||||
/* Protected entries:
|
||||
- the --delay-updates staging name, protected only as a DIRECT child of the
|
||||
receive root (a nested destination directory that happens to be named
|
||||
.fastsync-stage is ordinary content);
|
||||
- alternate basis directories (--compare-dest / --copy-dest / --link-dest)
|
||||
at any depth: they are extra comparison snapshots the user pointed at,
|
||||
not destination content, and deleting them would destroy the very files a
|
||||
--link-dest run just linked into place;
|
||||
- the sender-side protected prefixes (source paths excluded by filters and
|
||||
paths pruned by --max-size/--min-size), at any depth, so their destination
|
||||
mirror survives --delete unless --delete-excluded opts back into removing
|
||||
the filter-excluded ones (size-pruned entries are always protected). */
|
||||
int skip_count = (config->delay_updates ? 1 : 0) + config->basis_count +
|
||||
(manifest->protected ? manifest->protected->size : 0);
|
||||
DeleteSkipEntry* skips = NULL;
|
||||
char** owned_prefixes = NULL;
|
||||
int used = 0;
|
||||
if (skip_count > 0) {
|
||||
skips = calloc((size_t)skip_count, sizeof(DeleteSkipEntry));
|
||||
owned_prefixes = calloc((size_t)config->basis_count, sizeof(char*));
|
||||
if (!skips || (config->basis_count > 0 && !owned_prefixes)) {
|
||||
free(skips);
|
||||
free(owned_prefixes);
|
||||
return false;
|
||||
}
|
||||
int idx = 0;
|
||||
if (config->delay_updates) {
|
||||
skips[idx].prefix = DELAY_UPDATES_STAGING_DIR;
|
||||
skips[idx].top_level_only = true;
|
||||
idx++;
|
||||
}
|
||||
for (int i = 0; i < config->basis_count; i++) {
|
||||
/* An absolute basis outside the receive root is unreachable by this walk,
|
||||
so it contributes no protection prefix (and no slot). */
|
||||
char* prefix = file_receive_basis_delete_relative(config, config->basis_dirs[i].path);
|
||||
if (!prefix)
|
||||
continue;
|
||||
owned_prefixes[i] = prefix;
|
||||
skips[idx].prefix = prefix;
|
||||
skips[idx].top_level_only = false;
|
||||
idx++;
|
||||
}
|
||||
for (int i = 0; i < manifest->protected->size; i++) {
|
||||
skips[idx].prefix = (const char*)manifest->protected->items[i];
|
||||
skips[idx].top_level_only = false;
|
||||
idx++;
|
||||
}
|
||||
used = idx;
|
||||
}
|
||||
/* Protected entries: the --delay-updates staging name (only as a DIRECT child
|
||||
of the receive root), the alternate basis directories and the sender-side
|
||||
protected prefixes (filter-excluded and size-pruned source mirrors), all at
|
||||
any depth. See delete_skips_build(). */
|
||||
DeleteSkipSet skips;
|
||||
if (!delete_skips_build(config, manifest->protected, NULL, true, &skips))
|
||||
return false;
|
||||
/* Clamp rather than subtract: an accounting bug where deleted already exceeds
|
||||
max_delete must never underflow into an effectively unlimited budget. */
|
||||
size_t remaining;
|
||||
@@ -235,14 +161,9 @@ static bool delete_extras_budgeted_observed(const Config* config, DeleteManifest
|
||||
size_t deleted = 0;
|
||||
size_t skipped = 0;
|
||||
DeleteWalkResult result = delete_extras_limited_observed(
|
||||
config->receive_root_directory, manifest->keeps, manifest->dirs, remaining, skips, used,
|
||||
config->protect_rules, &deleted, &skipped, observer, observer_context);
|
||||
if (owned_prefixes) {
|
||||
for (int i = 0; i < config->basis_count; i++)
|
||||
free(owned_prefixes[i]);
|
||||
}
|
||||
free(owned_prefixes);
|
||||
free(skips);
|
||||
config->receive_root_directory, manifest->keeps, manifest->dirs, remaining, skips.entries,
|
||||
skips.count, config->protect_rules, &deleted, &skipped, observer, observer_context);
|
||||
delete_skips_free(&skips);
|
||||
budget->deleted += deleted;
|
||||
budget->skipped += skipped;
|
||||
if (result == DELETE_WALK_LIMIT_REACHED) {
|
||||
@@ -256,7 +177,7 @@ static bool delete_extras_budgeted_observed(const Config* config, DeleteManifest
|
||||
return true;
|
||||
}
|
||||
|
||||
static bool delete_extras_budgeted(const Config* config, DeleteManifest* manifest,
|
||||
static bool delete_extras_budgeted(const Config* config, const DeleteManifest* manifest,
|
||||
DeleteBudgetState* budget) {
|
||||
return delete_extras_budgeted_observed(config, manifest, budget, NULL, NULL);
|
||||
}
|
||||
@@ -294,7 +215,8 @@ static void prefixed_delete_observer(void* context, const char* rel) {
|
||||
--max-delete budget: once it is exhausted the remaining requests are skipped
|
||||
and counted. Returns false only on a genuine error (a confinement failure on
|
||||
a validated path or an I/O error), which fails the run. */
|
||||
static bool delete_missing_args_budgeted_observed(const Config* config, DeleteManifest* manifest,
|
||||
static bool delete_missing_args_budgeted_observed(const Config* config,
|
||||
const DeleteManifest* manifest,
|
||||
DeleteBudgetState* budget,
|
||||
DeletePathObserver observer,
|
||||
void* observer_context) {
|
||||
@@ -303,35 +225,12 @@ static bool delete_missing_args_budgeted_observed(const Config* config, DeleteMa
|
||||
if (!manifest->missing || manifest->missing->size == 0)
|
||||
return true;
|
||||
fprintf(stderr, "Deleting destination mirrors of missing source arguments...\n");
|
||||
int skip_count = (config->delay_updates ? 1 : 0) + config->basis_count;
|
||||
DeleteSkipEntry* skips = NULL;
|
||||
char** owned_prefixes = NULL;
|
||||
int used = 0;
|
||||
if (skip_count > 0) {
|
||||
skips = calloc((size_t)skip_count, sizeof(DeleteSkipEntry));
|
||||
owned_prefixes = calloc((size_t)config->basis_count, sizeof(char*));
|
||||
if (!skips || (config->basis_count > 0 && !owned_prefixes)) {
|
||||
free(skips);
|
||||
free(owned_prefixes);
|
||||
return false;
|
||||
}
|
||||
int idx = 0;
|
||||
if (config->delay_updates) {
|
||||
skips[idx].prefix = DELAY_UPDATES_STAGING_DIR;
|
||||
skips[idx].top_level_only = true;
|
||||
idx++;
|
||||
}
|
||||
for (int i = 0; i < config->basis_count; i++) {
|
||||
char* prefix = file_receive_basis_delete_relative(config, config->basis_dirs[i].path);
|
||||
if (!prefix)
|
||||
continue;
|
||||
owned_prefixes[i] = prefix;
|
||||
skips[idx].prefix = prefix;
|
||||
skips[idx].top_level_only = false;
|
||||
idx++;
|
||||
}
|
||||
used = idx;
|
||||
}
|
||||
/* The staging directory and basis snapshots stay protected exactly as in the
|
||||
extras walker (the missing-args path overrides the ordinary protected
|
||||
prefixes, so those are not passed here). */
|
||||
DeleteSkipSet skips;
|
||||
if (!delete_skips_build(config, NULL, NULL, true, &skips))
|
||||
return false;
|
||||
bool ok = true;
|
||||
for (int i = 0; i < manifest->missing->size; i++) {
|
||||
const char* rel = (const char*)manifest->missing->items[i];
|
||||
@@ -343,7 +242,7 @@ static bool delete_missing_args_budgeted_observed(const Config* config, DeleteMa
|
||||
continue;
|
||||
}
|
||||
bool at_root = strchr(rel, '/') == NULL;
|
||||
if (path_under_skip_prefix(rel, at_root, skips, used)) {
|
||||
if (path_under_skip_prefix(rel, at_root, skips.entries, skips.count)) {
|
||||
char* escaped = output_escape(rel, log_get_8_bit_output());
|
||||
log_message(LOG_LEVEL_WARNING,
|
||||
"missing-args path '%s' is protected (staging directory or basis snapshot); "
|
||||
@@ -472,96 +371,49 @@ static bool delete_missing_args_budgeted_observed(const Config* config, DeleteMa
|
||||
if (!ok)
|
||||
break;
|
||||
}
|
||||
if (owned_prefixes) {
|
||||
for (int i = 0; i < config->basis_count; i++)
|
||||
free(owned_prefixes[i]);
|
||||
}
|
||||
free(owned_prefixes);
|
||||
free(skips);
|
||||
delete_skips_free(&skips);
|
||||
return ok;
|
||||
}
|
||||
|
||||
/* Public wrappers used outside the commit path (and by unit tests): no
|
||||
--max-delete budget. */
|
||||
bool manifest_would_delete_list(const Config* config, DeleteManifest* manifest, ArrayList* out,
|
||||
size_t* count_out) {
|
||||
bool manifest_would_delete_list(const Config* config, const DeleteManifest* manifest,
|
||||
ArrayList* out, size_t* count_out) {
|
||||
if (count_out)
|
||||
*count_out = 0;
|
||||
if (!config || !manifest || !manifest->keeps || !out)
|
||||
return false;
|
||||
int skip_count = (config->delay_updates ? 1 : 0) + config->basis_count +
|
||||
(manifest->protected ? manifest->protected->size : 0);
|
||||
DeleteSkipEntry* skips = NULL;
|
||||
char** owned_prefixes = NULL;
|
||||
int used = 0;
|
||||
if (skip_count > 0) {
|
||||
skips = calloc((size_t)skip_count, sizeof(DeleteSkipEntry));
|
||||
owned_prefixes = calloc((size_t)config->basis_count, sizeof(char*));
|
||||
if (!skips || (config->basis_count > 0 && !owned_prefixes)) {
|
||||
free(skips);
|
||||
free(owned_prefixes);
|
||||
return false;
|
||||
}
|
||||
int idx = 0;
|
||||
if (config->delay_updates) {
|
||||
skips[idx].prefix = DELAY_UPDATES_STAGING_DIR;
|
||||
skips[idx].top_level_only = true;
|
||||
idx++;
|
||||
}
|
||||
for (int i = 0; i < config->basis_count; i++) {
|
||||
/* Normalize exactly like the real commit path: a relative entry is
|
||||
already root-relative, an absolute one inside the receive root is
|
||||
converted, and one outside contributes no protection prefix. */
|
||||
char* prefix = file_receive_basis_delete_relative(config, config->basis_dirs[i].path);
|
||||
if (!prefix)
|
||||
continue;
|
||||
owned_prefixes[i] = prefix;
|
||||
skips[idx].prefix = prefix;
|
||||
skips[idx].top_level_only = false;
|
||||
idx++;
|
||||
}
|
||||
for (int i = 0; i < manifest->protected->size; i++) {
|
||||
skips[idx].prefix = (const char*)manifest->protected->items[i];
|
||||
skips[idx].top_level_only = false;
|
||||
idx++;
|
||||
}
|
||||
used = idx;
|
||||
}
|
||||
DeleteSkipSet skips;
|
||||
if (!delete_skips_build(config, manifest->protected, NULL, true, &skips))
|
||||
return false;
|
||||
bool ok = delete_extras_list(config->receive_root_directory, manifest->keeps, manifest->dirs,
|
||||
skips, used, config->protect_rules, out, count_out);
|
||||
if (owned_prefixes) {
|
||||
for (int i = 0; i < config->basis_count; i++)
|
||||
free(owned_prefixes[i]);
|
||||
}
|
||||
free(owned_prefixes);
|
||||
free(skips);
|
||||
skips.entries, skips.count, config->protect_rules, out, count_out);
|
||||
delete_skips_free(&skips);
|
||||
return ok;
|
||||
}
|
||||
|
||||
bool manifest_delete_extras(const Config* config, DeleteManifest* manifest) {
|
||||
bool manifest_delete_extras(const Config* config, const DeleteManifest* manifest) {
|
||||
DeleteBudgetState budget = {
|
||||
.max_delete = SIZE_MAX, .deleted = 0, .skipped = 0, .limit_hit = false};
|
||||
return delete_extras_budgeted(config, manifest, &budget);
|
||||
}
|
||||
|
||||
bool manifest_delete_missing_args(const Config* config, DeleteManifest* manifest) {
|
||||
bool manifest_delete_missing_args(const Config* config, const DeleteManifest* manifest) {
|
||||
DeleteBudgetState budget = {
|
||||
.max_delete = SIZE_MAX, .deleted = 0, .skipped = 0, .limit_hit = false};
|
||||
return delete_missing_args_budgeted_observed(config, manifest, &budget, NULL, NULL);
|
||||
}
|
||||
|
||||
bool manifest_delete_missing_args_limited(const Config* config, DeleteManifest* manifest,
|
||||
bool manifest_delete_missing_args_limited(const Config* config, const DeleteManifest* manifest,
|
||||
size_t max_delete, size_t* deleted, size_t* skipped,
|
||||
bool* limit_hit) {
|
||||
return manifest_delete_missing_args_limited_observed(config, manifest, max_delete, deleted,
|
||||
skipped, limit_hit, NULL, NULL);
|
||||
}
|
||||
|
||||
bool manifest_delete_missing_args_limited_observed(const Config* config, DeleteManifest* manifest,
|
||||
size_t max_delete, size_t* deleted,
|
||||
size_t* skipped, bool* limit_hit,
|
||||
DeletePathObserver observer,
|
||||
void* observer_context) {
|
||||
bool manifest_delete_missing_args_limited_observed(
|
||||
const Config* config, const DeleteManifest* manifest, size_t max_delete, size_t* deleted,
|
||||
size_t* skipped, bool* limit_hit, DeletePathObserver observer, void* observer_context) {
|
||||
DeleteBudgetState budget = {
|
||||
.max_delete = max_delete, .deleted = 0, .skipped = 0, .limit_hit = false};
|
||||
bool ok =
|
||||
@@ -582,17 +434,18 @@ bool manifest_delete_missing_args_limited_observed(const Config* config, DeleteM
|
||||
removal fail). The ordinary extras walk then runs when --delete is active.
|
||||
Both draw from one --max-delete budget; the result reports a cap-stopped
|
||||
(partial) commit distinctly so the client can exit 25 like rsync. */
|
||||
DeleteCommitResult manifest_delete_all(const Config* config, DeleteManifest* manifest) {
|
||||
DeleteCommitResult manifest_delete_all(const Config* config, const DeleteManifest* manifest) {
|
||||
return manifest_delete_all_counted(config, manifest, NULL);
|
||||
}
|
||||
|
||||
DeleteCommitResult manifest_delete_all_counted(const Config* config, DeleteManifest* manifest,
|
||||
DeleteCommitResult manifest_delete_all_counted(const Config* config, const DeleteManifest* manifest,
|
||||
size_t* deleted) {
|
||||
return manifest_delete_all_observed(config, manifest, deleted, NULL, NULL);
|
||||
}
|
||||
|
||||
DeleteCommitResult manifest_delete_all_observed(const Config* config, DeleteManifest* manifest,
|
||||
size_t* deleted, DeletePathObserver observer,
|
||||
DeleteCommitResult manifest_delete_all_observed(const Config* config,
|
||||
const DeleteManifest* manifest, size_t* deleted,
|
||||
DeletePathObserver observer,
|
||||
void* observer_context) {
|
||||
if (deleted)
|
||||
*deleted = 0;
|
||||
|
||||
+14
-19
@@ -3,7 +3,7 @@
|
||||
|
||||
#include "array_list.h"
|
||||
#include "config.h"
|
||||
#include "utils.h"
|
||||
#include "delete.h"
|
||||
#include <stdbool.h>
|
||||
|
||||
/* Delete-commit module: delete-manifest receive plus the budgeted extras and
|
||||
@@ -44,7 +44,7 @@ DeleteManifest* receive_manifest_entries(int fd);
|
||||
protected-prefix skips). `--max-delete` and `--force` are honored here. The
|
||||
caller decides WHEN to run it based on the negotiated delete timing. Returns
|
||||
false (and the transfer fails) when the deletion cannot be committed. */
|
||||
bool manifest_delete_extras(const Config* config, DeleteManifest* manifest);
|
||||
bool manifest_delete_extras(const Config* config, const DeleteManifest* manifest);
|
||||
/* --delete-missing-args exact-path deletions: remove each destination mirror
|
||||
in `manifest->missing` (never blocked by the protected prefixes, staging dir
|
||||
and basis dirs excluded). A regular file/symlink is unlinked; an empty
|
||||
@@ -53,22 +53,20 @@ bool manifest_delete_extras(const Config* config, DeleteManifest* manifest);
|
||||
parity). A missing path is a no-op. Returns false only on a genuine
|
||||
confinement or I/O error (the run then fails); tolerated per-path cases are
|
||||
reported and skipped. */
|
||||
bool manifest_delete_missing_args(const Config* config, DeleteManifest* manifest);
|
||||
bool manifest_delete_missing_args(const Config* config, const DeleteManifest* manifest);
|
||||
/* Budgeted form of manifest_delete_missing_args for the per-directory delete
|
||||
session: each removed mirror draws from `max_delete` (SIZE_MAX = unlimited)
|
||||
and the tallies are accumulated into `*deleted`/`*skipped`. `*limit_hit` is set
|
||||
when the budget stopped the pass with entries left over. Returns false only
|
||||
on a genuine deletion error. */
|
||||
bool manifest_delete_missing_args_limited(const Config* config, DeleteManifest* manifest,
|
||||
bool manifest_delete_missing_args_limited(const Config* config, const DeleteManifest* manifest,
|
||||
size_t max_delete, size_t* deleted, size_t* skipped,
|
||||
bool* limit_hit);
|
||||
/* Observer-aware form of manifest_delete_missing_args_limited: `observer` (may
|
||||
be NULL) is invoked for every destination-relative path truly removed. */
|
||||
bool manifest_delete_missing_args_limited_observed(const Config* config, DeleteManifest* manifest,
|
||||
size_t max_delete, size_t* deleted,
|
||||
size_t* skipped, bool* limit_hit,
|
||||
DeletePathObserver observer,
|
||||
void* observer_context);
|
||||
bool manifest_delete_missing_args_limited_observed(
|
||||
const Config* config, const DeleteManifest* manifest, size_t max_delete, size_t* deleted,
|
||||
size_t* skipped, bool* limit_hit, DeletePathObserver observer, void* observer_context);
|
||||
/* Outcome of committing a delete manifest. LIMIT_REACHED reports rsync's
|
||||
partial --max-delete result: the budget allowed some deletions and the rest
|
||||
were skipped (the run still stores all file data but the client exits 25). */
|
||||
@@ -84,15 +82,16 @@ typedef enum {
|
||||
share one --max-delete budget. Returns DELETE_COMMIT_OK when nothing was to
|
||||
do or everything committed, DELETE_COMMIT_LIMIT_REACHED when the budget
|
||||
stopped part of the work, or DELETE_COMMIT_ERROR on a genuine failure. */
|
||||
DeleteCommitResult manifest_delete_all(const Config* config, DeleteManifest* manifest);
|
||||
DeleteCommitResult manifest_delete_all(const Config* config, const DeleteManifest* manifest);
|
||||
/* Like manifest_delete_all, but reports how many destination entries the commit
|
||||
removed (for the end-of-transfer wire stats). `deleted` may be NULL. */
|
||||
DeleteCommitResult manifest_delete_all_counted(const Config* config, DeleteManifest* manifest,
|
||||
DeleteCommitResult manifest_delete_all_counted(const Config* config, const DeleteManifest* manifest,
|
||||
size_t* deleted);
|
||||
/* Observer-aware form of manifest_delete_all_counted: `observer` (may be NULL)
|
||||
is invoked for every destination-relative path truly removed. */
|
||||
DeleteCommitResult manifest_delete_all_observed(const Config* config, DeleteManifest* manifest,
|
||||
size_t* deleted, DeletePathObserver observer,
|
||||
DeleteCommitResult manifest_delete_all_observed(const Config* config,
|
||||
const DeleteManifest* manifest, size_t* deleted,
|
||||
DeletePathObserver observer,
|
||||
void* observer_context);
|
||||
|
||||
/* -n/--dry-run --delete would-delete reporting: walk the destination exactly as
|
||||
@@ -100,11 +99,7 @@ DeleteCommitResult manifest_delete_all_observed(const Config* config, DeleteMani
|
||||
WOULD be removed to `out`, without touching disk. Uses the same staging-dir,
|
||||
basis-dir and protected-prefix skips as the real commit. Returns true on a
|
||||
clean walk; `*count_out` receives the number of paths appended. */
|
||||
bool manifest_would_delete_list(const Config* config, DeleteManifest* manifest, ArrayList* out,
|
||||
size_t* count_out);
|
||||
/* Convert one basis-directory path to the receive-root-relative protection
|
||||
prefix the delete walker uses (NULL when it lies outside the root). Exposed
|
||||
for unit tests of the root-of-"/" and normalization edge cases. */
|
||||
char* file_receive_basis_delete_relative(const Config* config, const char* path);
|
||||
bool manifest_would_delete_list(const Config* config, const DeleteManifest* manifest,
|
||||
ArrayList* out, size_t* count_out);
|
||||
|
||||
#endif
|
||||
|
||||
+11
-38
@@ -2,6 +2,7 @@
|
||||
|
||||
#include "charset.h"
|
||||
#include "delay_updates.h"
|
||||
#include "delete.h"
|
||||
#include "file.h"
|
||||
#include "log.h"
|
||||
#include "utils.h"
|
||||
@@ -601,9 +602,8 @@ static int open_plan_dir(const Config* config, const char* dir) {
|
||||
return fd;
|
||||
}
|
||||
|
||||
typedef struct PlanSkips {
|
||||
DeleteSkipEntry* entries;
|
||||
int count;
|
||||
typedef struct {
|
||||
DeleteSkipSet set;
|
||||
/* Receiver-side delete-protection rules received on the config frame (NULL
|
||||
when the sender sent none). Evaluated per extra so a protect/risk rule is
|
||||
honored under --delete-during/--delete-delay exactly like the whole-tree
|
||||
@@ -613,39 +613,12 @@ typedef struct PlanSkips {
|
||||
|
||||
static bool build_plan_skips(const Config* config, const DeletePlanSession* session,
|
||||
PlanSkips* out) {
|
||||
out->entries = NULL;
|
||||
out->count = 0;
|
||||
out->protect_rules = config->protect_rules;
|
||||
int count = (config->delay_updates ? 1 : 0) + config->basis_count +
|
||||
session->protected_prefixes->size + session->size_skipped->size;
|
||||
if (count == 0)
|
||||
return true;
|
||||
out->entries = calloc((size_t)count, sizeof(DeleteSkipEntry));
|
||||
if (!out->entries)
|
||||
return false;
|
||||
int idx = 0;
|
||||
if (config->delay_updates) {
|
||||
out->entries[idx].prefix = DELAY_UPDATES_STAGING_DIR;
|
||||
out->entries[idx].top_level_only = true;
|
||||
idx++;
|
||||
}
|
||||
for (int i = 0; i < config->basis_count; i++) {
|
||||
out->entries[idx].prefix = config->basis_dirs[i].path;
|
||||
out->entries[idx].top_level_only = false;
|
||||
idx++;
|
||||
}
|
||||
for (int i = 0; i < session->protected_prefixes->size; i++) {
|
||||
out->entries[idx].prefix = (const char*)session->protected_prefixes->items[i];
|
||||
out->entries[idx].top_level_only = false;
|
||||
idx++;
|
||||
}
|
||||
for (int i = 0; i < session->size_skipped->size; i++) {
|
||||
out->entries[idx].prefix = (const char*)session->size_skipped->items[i];
|
||||
out->entries[idx].top_level_only = false;
|
||||
idx++;
|
||||
}
|
||||
out->count = idx;
|
||||
return true;
|
||||
/* The per-directory plan walk keeps each basis path verbatim (it does not
|
||||
convert an absolute under-root path to its root-relative form, unlike the
|
||||
whole-tree commit walk). */
|
||||
return delete_skips_build(config, session->protected_prefixes, session->size_skipped, false,
|
||||
&out->set);
|
||||
}
|
||||
|
||||
static bool budget_available(const DeletePlanSession* session) {
|
||||
@@ -796,7 +769,7 @@ static bool process_children(int dirfd, const char* dir_rel, const ArrayList* ke
|
||||
operation_ok = false;
|
||||
continue;
|
||||
}
|
||||
if (path_under_skip_prefix(child_rel, at_root, skips->entries, skips->count)) {
|
||||
if (path_under_skip_prefix(child_rel, at_root, skips->set.entries, skips->set.count)) {
|
||||
shielded[i] = true;
|
||||
local_survives = true;
|
||||
free(child_rel);
|
||||
@@ -883,7 +856,7 @@ static bool apply_plan_dir(DeletePlanSession* session, const Config* config, con
|
||||
bool survives = false;
|
||||
bool ok = process_children(dirfd, dir, dirs, files, strcmp(dir, ".") == 0, false, &skips, session,
|
||||
&survives);
|
||||
free(skips.entries);
|
||||
delete_skips_free(&skips.set);
|
||||
close(dirfd);
|
||||
if (!ok)
|
||||
log_message(LOG_LEVEL_ERROR, "deletion failed while removing extraneous files");
|
||||
@@ -1024,7 +997,7 @@ static bool apply_deferred_path(DeletePlanSession* session, const Config* config
|
||||
}
|
||||
bool survives = false;
|
||||
bool ok = process_children(dirfd, rel, NULL, NULL, false, true, &skips, session, &survives);
|
||||
free(skips.entries);
|
||||
delete_skips_free(&skips.set);
|
||||
close(dirfd);
|
||||
if (!ok) {
|
||||
close(parent_fd);
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
|
||||
#include "array_list.h"
|
||||
#include "config.h"
|
||||
#include "delete.h"
|
||||
#include "file_receive.h"
|
||||
#include "protocol.h"
|
||||
#include "utils.h"
|
||||
|
||||
@@ -185,7 +185,7 @@ bool file_send_sendfile_with_skip(File* file, int file_descriptor, bool use_meta
|
||||
return false;
|
||||
}
|
||||
protocol_note_bytes_written((unsigned long long)sent);
|
||||
protocol_throttle_bytes((size_t)sent);
|
||||
protocol_throttle_bytes(file_descriptor, (size_t)sent);
|
||||
}
|
||||
|
||||
close(fd);
|
||||
|
||||
@@ -302,11 +302,14 @@ static ProtocolSession* legacy_session(int read_fd, int write_fd) {
|
||||
}
|
||||
|
||||
/* Pace an out-of-band write that bypassed protocol_send_n_data (the plaintext
|
||||
* sendfile fast path). The bound/legacy session is resolved exactly as
|
||||
* send_n_data resolves it, so the same token-bucket state is throttled and the
|
||||
* TLS and plaintext transports share identical --bwlimit semantics. */
|
||||
void protocol_throttle_bytes(size_t bytes) {
|
||||
bw_throttle_session(legacy_session(-1, -1), bytes);
|
||||
* sendfile fast path). The bound/legacy session is resolved exactly as the
|
||||
* preceding send_n_data(fd, ...) resolved it, so the same token-bucket state is
|
||||
* throttled and the TLS and plaintext transports share identical --bwlimit
|
||||
* semantics. Passing the wire fd (rather than -1) is essential: the sendfile
|
||||
* send left legacy_io_session.write_fd bound to it, so resolving with -1 would
|
||||
* mismatch, re-initialize the session and hand out a second first-call burst. */
|
||||
void protocol_throttle_bytes(int file_descriptor, size_t bytes) {
|
||||
bw_throttle_session(legacy_session(-1, file_descriptor), bytes);
|
||||
}
|
||||
|
||||
bool send_n_data(int file_descriptor, const void* data, size_t data_size) {
|
||||
|
||||
@@ -225,11 +225,14 @@ unsigned long long protocol_bytes_written(void);
|
||||
unsigned long long protocol_bytes_read(void);
|
||||
void protocol_note_bytes_written(unsigned long long bytes);
|
||||
/* Apply --bwlimit pacing to bytes written outside protocol_send_n_data (the
|
||||
* plaintext zero-copy sendfile fast path). Resolves the bound/legacy session
|
||||
* exactly as send_n_data does and runs the same token-bucket throttle, so the
|
||||
* sendfile transport is paced identically to the buffered/TLS paths. A no-op
|
||||
* when the effective session has no bandwidth limit. */
|
||||
void protocol_throttle_bytes(size_t bytes);
|
||||
* plaintext zero-copy sendfile fast path). `file_descriptor` is the wire fd
|
||||
* the bytes were written to, so the legacy session is resolved exactly as the
|
||||
* preceding send_n_data call resolved it (the bound TLS session still wins when
|
||||
* set); resolving with the same fd avoids re-initializing the legacy session
|
||||
* and granting a second first-call burst. Runs the same token-bucket throttle,
|
||||
* so the sendfile transport is paced identically to the buffered/TLS paths. A
|
||||
* no-op when the effective session has no bandwidth limit. */
|
||||
void protocol_throttle_bytes(int file_descriptor, size_t bytes);
|
||||
|
||||
void protocol_session_init(ProtocolSession* session, int read_fd, int write_fd);
|
||||
/* Transitional bridge for helpers whose signatures still carry only an fd. */
|
||||
|
||||
@@ -625,641 +625,6 @@ bool format_human_bytes(unsigned long long bytes, char* buffer, size_t buffer_si
|
||||
return written >= 0 && (size_t)written < buffer_size;
|
||||
}
|
||||
|
||||
/* Build the keep-set index from the exact manifest entries only. A lookup of
|
||||
`rel` succeeds iff `rel` is a kept entry, a kept directory, or an ancestor
|
||||
directory of kept content (the old is_dir_in_manifest predicate); the sorted
|
||||
view answers "is an ancestor of kept content" without materializing any
|
||||
per-component prefix copy, so the index is O(manifest size) memory. */
|
||||
static bool build_keep_index(const ArrayList* manifest, PathIndex* index) {
|
||||
if (!manifest || manifest->size <= 0)
|
||||
return path_index_build(index, NULL, 0);
|
||||
return path_index_build(index, (const char* const*)manifest->items, (size_t)manifest->size);
|
||||
}
|
||||
|
||||
static bool keep_is_dir(const PathIndex* index, const char* rel_path) {
|
||||
return path_index_contains(index, rel_path) || path_index_has_descendant(index, rel_path);
|
||||
}
|
||||
|
||||
static bool keep_is_file(const PathIndex* index, const char* rel_path) {
|
||||
return path_index_contains(index, rel_path);
|
||||
}
|
||||
|
||||
/* True when child_rel is, or lies below, a protected entry. A prefix "a"
|
||||
therefore protects "a" and "a/b/c" but not "ab". Entries with top_level_only
|
||||
set only protect DIRECT children of the receive root (at_root); nested
|
||||
directories that share such a name stay ordinary destination content. */
|
||||
bool path_under_skip_prefix(const char* child_rel, bool at_root, const DeleteSkipEntry* skips,
|
||||
int skip_count) {
|
||||
for (int i = 0; i < skip_count; i++) {
|
||||
if (skips[i].top_level_only && !at_root)
|
||||
continue;
|
||||
size_t prefix_len = strlen(skips[i].prefix);
|
||||
if (strncmp(child_rel, skips[i].prefix, prefix_len) == 0 &&
|
||||
(child_rel[prefix_len] == '\0' || child_rel[prefix_len] == '/'))
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/* Per-run deletion budget and tallies. `max_delete` is the cap on the number
|
||||
of entries the walker may remove (SIZE_MAX = unlimited); once it is reached
|
||||
the remaining extras are counted in `skipped` and left in place, matching
|
||||
rsync's partial --max-delete behavior. */
|
||||
typedef struct {
|
||||
size_t max_delete;
|
||||
size_t deleted;
|
||||
size_t skipped;
|
||||
bool limit_hit;
|
||||
} DeleteBudget;
|
||||
|
||||
/* True when direct children of the directory named by `rel` may be removed.
|
||||
With no synchronization info (dirs == NULL) the whole tree is deletable; when
|
||||
a dirs index is supplied only its exact entries are (the receive root is the
|
||||
"." sentinel). */
|
||||
static bool is_synced_dir(const PathIndex* dirs, const char* rel) {
|
||||
if (!dirs)
|
||||
return true;
|
||||
return path_index_contains(dirs, rel[0] == '\0' ? "." : rel);
|
||||
}
|
||||
|
||||
/* Unsigned byte-wise string compare, matching rsync's u_strcmp (a signed
|
||||
strcmp would order bytes >= 0x80 differently). */
|
||||
static int delete_name_cmp(const char* a, const char* b) {
|
||||
const unsigned char* pa = (const unsigned char*)a;
|
||||
const unsigned char* pb = (const unsigned char*)b;
|
||||
while (*pa != '\0' && *pa == *pb) {
|
||||
pa++;
|
||||
pb++;
|
||||
}
|
||||
return (int)*pa - (int)*pb;
|
||||
}
|
||||
|
||||
bool delete_dir_entries_collect(int dirfd, DeleteDirEntry** out, size_t* count,
|
||||
bool* operation_ok) {
|
||||
*out = NULL;
|
||||
*count = 0;
|
||||
if (operation_ok)
|
||||
*operation_ok = true;
|
||||
int scanfd = openat(dirfd, ".", O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC);
|
||||
if (scanfd < 0)
|
||||
return false;
|
||||
DIR* dir = fdopendir(scanfd);
|
||||
if (!dir) {
|
||||
close(scanfd);
|
||||
return false;
|
||||
}
|
||||
DeleteDirEntry* entries = NULL;
|
||||
size_t used = 0;
|
||||
size_t capacity = 0;
|
||||
bool ok = true;
|
||||
const struct dirent* entry;
|
||||
while ((entry = readdir(dir)) != NULL) {
|
||||
if (strcmp(entry->d_name, ".") == 0 || strcmp(entry->d_name, "..") == 0)
|
||||
continue;
|
||||
struct stat st;
|
||||
if (fstatat(dirfd, entry->d_name, &st, AT_SYMLINK_NOFOLLOW) != 0) {
|
||||
if (errno != ENOENT && operation_ok)
|
||||
*operation_ok = false;
|
||||
continue;
|
||||
}
|
||||
if (used == capacity) {
|
||||
size_t next = capacity == 0 ? 16 : capacity * 2;
|
||||
DeleteDirEntry* grown = realloc(entries, next * sizeof(*grown));
|
||||
if (!grown) {
|
||||
ok = false;
|
||||
break;
|
||||
}
|
||||
entries = grown;
|
||||
capacity = next;
|
||||
}
|
||||
entries[used].name = str_dup(entry->d_name);
|
||||
if (!entries[used].name) {
|
||||
ok = false;
|
||||
break;
|
||||
}
|
||||
entries[used].is_dir = S_ISDIR(st.st_mode);
|
||||
used++;
|
||||
}
|
||||
closedir(dir);
|
||||
if (!ok) {
|
||||
delete_dir_entries_free(entries, used);
|
||||
return false;
|
||||
}
|
||||
*out = entries;
|
||||
*count = used;
|
||||
return true;
|
||||
}
|
||||
|
||||
void delete_dir_entries_free(DeleteDirEntry* entries, size_t count) {
|
||||
if (!entries)
|
||||
return;
|
||||
for (size_t i = 0; i < count; i++)
|
||||
free(entries[i].name);
|
||||
free(entries);
|
||||
}
|
||||
|
||||
/* rsync's extraneous-entry order: subdirectories before files, each group in
|
||||
descending name order. */
|
||||
int delete_dir_entry_cmp_desc(const void* a, const void* b) {
|
||||
const DeleteDirEntry* ea = a;
|
||||
const DeleteDirEntry* eb = b;
|
||||
if (ea->is_dir != eb->is_dir)
|
||||
return ea->is_dir ? -1 : 1;
|
||||
return -delete_name_cmp(ea->name, eb->name);
|
||||
}
|
||||
|
||||
/* rsync's kept-subdirectory order: plain ascending name. */
|
||||
int delete_dir_entry_cmp_asc(const void* a, const void* b) {
|
||||
const DeleteDirEntry* ea = a;
|
||||
const DeleteDirEntry* eb = b;
|
||||
return delete_name_cmp(ea->name, eb->name);
|
||||
}
|
||||
|
||||
/* Remove the extras directly inside the directory open on `dirfd`, recursing
|
||||
into every child directory so kept content below a synchronized prefix is
|
||||
reached. `all_removed` reports whether every child entry was removed (so the
|
||||
caller may rmdir this directory). A child directory is never removed when it
|
||||
is itself a synchronized directory or holds kept content; with a dirs index
|
||||
supplied, direct children of a non-synchronized directory are never extras at
|
||||
all (they are left in place but still descended into). Symlinks are unlinked
|
||||
like any other non-directory extra (never followed).
|
||||
|
||||
Entries are processed in rsync's order (extraneous subdirectories in
|
||||
descending name order, then extraneous files, then kept subdirectories in
|
||||
ascending order) rather than readdir() order, so `--max-delete` leaves the
|
||||
same survivors and the `--info=del`/dry-run line order matches rsync. */
|
||||
static bool delete_extras_fd(int dirfd, const char* rel_path, const PathIndex* keep,
|
||||
const PathIndex* dirs, DeleteBudget* budget,
|
||||
const DeleteSkipEntry* skips, int skip_count,
|
||||
const FilterRuleList* protect_rules, bool parent_deletable,
|
||||
bool* all_removed, DeletePathObserver observer,
|
||||
void* observer_context) {
|
||||
DeleteDirEntry* entries = NULL;
|
||||
size_t count = 0;
|
||||
bool collect_ok = true;
|
||||
if (!delete_dir_entries_collect(dirfd, &entries, &count, &collect_ok))
|
||||
return false;
|
||||
bool operation_ok = collect_ok;
|
||||
bool local_survives = false;
|
||||
bool* shielded = calloc(count ? count : 1, sizeof(bool));
|
||||
bool* is_extra = calloc(count ? count : 1, sizeof(bool));
|
||||
if (!shielded || !is_extra) {
|
||||
free(shielded);
|
||||
free(is_extra);
|
||||
delete_dir_entries_free(entries, count);
|
||||
return false;
|
||||
}
|
||||
/* A directory is deletable when it or ANY ancestor is synchronized; the
|
||||
`parent_deletable` flag carries that down the recursion so dest-only
|
||||
directories below a synchronized root are removed wholesale. */
|
||||
bool deletable = parent_deletable || is_synced_dir(dirs, rel_path);
|
||||
bool at_root = rel_path[0] == '\0';
|
||||
|
||||
/* Reproduce rsync's traversal order: extraneous subdirectories in descending
|
||||
name order, then extraneous files in descending name order, and kept
|
||||
subdirectories only afterwards (ascending). Sorting up front also fixes the
|
||||
identity of the survivors under a partial --max-delete. */
|
||||
if (count > 1)
|
||||
qsort(entries, count, sizeof(*entries), delete_dir_entry_cmp_desc);
|
||||
size_t dir_count = 0;
|
||||
while (dir_count < count && entries[dir_count].is_dir)
|
||||
dir_count++;
|
||||
|
||||
/* Classify every entry up front (the verdict does not depend on processing
|
||||
order) so the ordered passes below can act on it. */
|
||||
for (size_t i = 0; i < count; i++) {
|
||||
char* child_rel = path_cat((char*)rel_path, entries[i].name);
|
||||
if (!child_rel) {
|
||||
operation_ok = false;
|
||||
continue;
|
||||
}
|
||||
/* A --delay-updates run keeps its staging directory as a direct child of
|
||||
the receive root, and basis-dir snapshots live below it too. Their
|
||||
contents are not manifest entries, so descending into them would delete
|
||||
every staged / basis file as an "extra". Only the staging name (a
|
||||
top-level-only prefix) and the basis prefixes are protected: a nested
|
||||
destination directory that happens to be called .fastsync-stage is
|
||||
ordinary content. */
|
||||
if (path_under_skip_prefix(child_rel, at_root, skips, skip_count)) {
|
||||
shielded[i] = true;
|
||||
local_survives = true;
|
||||
} else if (protect_rules &&
|
||||
filter_rules_apply_side(protect_rules, child_rel, entries[i].name, entries[i].is_dir,
|
||||
FILTER_SIDE_RECEIVER) == FILTER_ACTION_PROTECT) {
|
||||
/* A first-match protect rule shields the extra; for a directory the whole
|
||||
subtree is shielded (rsync prunes an excluded directory), so do not
|
||||
descend. */
|
||||
shielded[i] = true;
|
||||
local_survives = true;
|
||||
} else if (entries[i].is_dir) {
|
||||
bool child_synced = dirs && path_index_contains(dirs, child_rel);
|
||||
is_extra[i] = deletable && !child_synced && !keep_is_dir(keep, child_rel);
|
||||
if (!is_extra[i])
|
||||
local_survives = true;
|
||||
} else {
|
||||
is_extra[i] = deletable && !keep_is_file(keep, child_rel);
|
||||
if (!is_extra[i])
|
||||
local_survives = true;
|
||||
}
|
||||
free(child_rel);
|
||||
}
|
||||
|
||||
/* Pass 1: extraneous subdirectories, descending. */
|
||||
for (size_t i = 0; i < dir_count; i++) {
|
||||
if (!is_extra[i])
|
||||
continue;
|
||||
char* child_rel = path_cat((char*)rel_path, entries[i].name);
|
||||
if (!child_rel) {
|
||||
operation_ok = false;
|
||||
continue;
|
||||
}
|
||||
int childfd = openat(dirfd, entries[i].name, O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC);
|
||||
bool child_all_removed = false;
|
||||
if (childfd >= 0) {
|
||||
if (!delete_extras_fd(childfd, child_rel, keep, dirs, budget, skips, skip_count,
|
||||
protect_rules, deletable, &child_all_removed, observer,
|
||||
observer_context))
|
||||
operation_ok = false;
|
||||
close(childfd);
|
||||
} else if (errno != ENOENT) {
|
||||
operation_ok = false;
|
||||
}
|
||||
if (child_all_removed && deletable) {
|
||||
if (budget->deleted >= budget->max_delete) {
|
||||
budget->limit_hit = true;
|
||||
budget->skipped++;
|
||||
local_survives = true;
|
||||
} else if (unlinkat(dirfd, entries[i].name, AT_REMOVEDIR) != 0) {
|
||||
/* ENOENT: already gone (fine). ENOTEMPTY/EEXIST: the directory still
|
||||
holds entries the walker leaves in place (a protected excluded
|
||||
prefix, a kept file the manifest protects, a symlink); rsync leaves
|
||||
such a directory behind, so this is not an error. Only genuine I/O
|
||||
failures abort the deletion. */
|
||||
if (errno != ENOENT && errno != ENOTEMPTY && errno != EEXIST)
|
||||
operation_ok = false;
|
||||
local_survives = true;
|
||||
} else {
|
||||
budget->deleted++;
|
||||
/* rsync reports a removed directory with a trailing slash. */
|
||||
if (observer) {
|
||||
size_t len = strlen(child_rel);
|
||||
char* with_slash = malloc(len + 2);
|
||||
if (with_slash) {
|
||||
memcpy(with_slash, child_rel, len);
|
||||
with_slash[len] = '/';
|
||||
with_slash[len + 1] = '\0';
|
||||
observer(observer_context, with_slash);
|
||||
free(with_slash);
|
||||
} else {
|
||||
observer(observer_context, child_rel);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
local_survives = true;
|
||||
}
|
||||
free(child_rel);
|
||||
}
|
||||
|
||||
/* Pass 2: extraneous files, descending. */
|
||||
for (size_t i = dir_count; i < count; i++) {
|
||||
if (!is_extra[i])
|
||||
continue;
|
||||
if (budget->deleted >= budget->max_delete) {
|
||||
budget->limit_hit = true;
|
||||
budget->skipped++;
|
||||
local_survives = true;
|
||||
} else if (unlinkat(dirfd, entries[i].name, 0) != 0) {
|
||||
if (errno != ENOENT)
|
||||
operation_ok = false;
|
||||
local_survives = true;
|
||||
} else {
|
||||
budget->deleted++;
|
||||
char* child_rel = path_cat((char*)rel_path, entries[i].name);
|
||||
if (child_rel) {
|
||||
if (observer)
|
||||
observer(observer_context, child_rel);
|
||||
char* escaped_path = output_escape(child_rel, log_get_8_bit_output());
|
||||
fprintf(stderr, " Deleted: %s\n", escaped_path ? escaped_path : "<allocation failed>");
|
||||
free(escaped_path);
|
||||
}
|
||||
free(child_rel);
|
||||
}
|
||||
}
|
||||
|
||||
/* Pass 3: kept subdirectories, ascending (rsync descends into these only
|
||||
after the parent's own extras have been handled). */
|
||||
for (size_t i = dir_count; i-- > 0;) {
|
||||
if (is_extra[i] || shielded[i])
|
||||
continue;
|
||||
char* child_rel = path_cat((char*)rel_path, entries[i].name);
|
||||
if (!child_rel) {
|
||||
operation_ok = false;
|
||||
continue;
|
||||
}
|
||||
int childfd = openat(dirfd, entries[i].name, O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC);
|
||||
bool child_all_removed = false;
|
||||
if (childfd >= 0) {
|
||||
if (!delete_extras_fd(childfd, child_rel, keep, dirs, budget, skips, skip_count,
|
||||
protect_rules, deletable, &child_all_removed, observer,
|
||||
observer_context))
|
||||
operation_ok = false;
|
||||
close(childfd);
|
||||
} else if (errno != ENOENT) {
|
||||
operation_ok = false;
|
||||
}
|
||||
/* A kept/synchronized directory is never removed. */
|
||||
local_survives = true;
|
||||
free(child_rel);
|
||||
}
|
||||
|
||||
free(shielded);
|
||||
free(is_extra);
|
||||
delete_dir_entries_free(entries, count);
|
||||
*all_removed = !local_survives;
|
||||
return operation_ok;
|
||||
}
|
||||
|
||||
/* Read-only mirror of delete_extras_fd: records the paths that WOULD be removed
|
||||
without unlinking anything. A child directory is reported after its own
|
||||
reportable children (depth-first), matching the delete pass's ordering. */
|
||||
static bool list_extras_fd(int dirfd, const char* rel_path, const PathIndex* keep,
|
||||
const PathIndex* dirs, ArrayList* out, size_t* recorded,
|
||||
const DeleteSkipEntry* skips, int skip_count,
|
||||
const FilterRuleList* protect_rules, bool parent_deletable,
|
||||
bool* all_removed) {
|
||||
DeleteDirEntry* entries = NULL;
|
||||
size_t count = 0;
|
||||
bool collect_ok = true;
|
||||
if (!delete_dir_entries_collect(dirfd, &entries, &count, &collect_ok))
|
||||
return false;
|
||||
bool operation_ok = collect_ok;
|
||||
bool local_survives = false;
|
||||
bool* shielded = calloc(count ? count : 1, sizeof(bool));
|
||||
bool* is_extra = calloc(count ? count : 1, sizeof(bool));
|
||||
if (!shielded || !is_extra) {
|
||||
free(shielded);
|
||||
free(is_extra);
|
||||
delete_dir_entries_free(entries, count);
|
||||
return false;
|
||||
}
|
||||
bool deletable = parent_deletable || is_synced_dir(dirs, rel_path);
|
||||
bool at_root = rel_path[0] == '\0';
|
||||
|
||||
/* Mirror the delete walk's rsync order (extraneous subdirectories descending,
|
||||
then extraneous files descending, then kept subdirectories ascending). */
|
||||
if (count > 1)
|
||||
qsort(entries, count, sizeof(*entries), delete_dir_entry_cmp_desc);
|
||||
size_t dir_count = 0;
|
||||
while (dir_count < count && entries[dir_count].is_dir)
|
||||
dir_count++;
|
||||
|
||||
for (size_t i = 0; i < count; i++) {
|
||||
char* child_rel = path_cat((char*)rel_path, entries[i].name);
|
||||
if (!child_rel) {
|
||||
operation_ok = false;
|
||||
continue;
|
||||
}
|
||||
if (path_under_skip_prefix(child_rel, at_root, skips, skip_count)) {
|
||||
shielded[i] = true;
|
||||
local_survives = true;
|
||||
} else if (protect_rules &&
|
||||
filter_rules_apply_side(protect_rules, child_rel, entries[i].name, entries[i].is_dir,
|
||||
FILTER_SIDE_RECEIVER) == FILTER_ACTION_PROTECT) {
|
||||
/* Mirror the delete walk: a protected entry is never reported as a
|
||||
would-delete and a protected directory's subtree is not enumerated. */
|
||||
shielded[i] = true;
|
||||
local_survives = true;
|
||||
} else if (entries[i].is_dir) {
|
||||
bool child_synced = dirs && path_index_contains(dirs, child_rel);
|
||||
is_extra[i] = deletable && !child_synced && !keep_is_dir(keep, child_rel);
|
||||
if (!is_extra[i])
|
||||
local_survives = true;
|
||||
} else {
|
||||
is_extra[i] = deletable && !keep_is_file(keep, child_rel);
|
||||
if (!is_extra[i])
|
||||
local_survives = true;
|
||||
}
|
||||
free(child_rel);
|
||||
}
|
||||
|
||||
/* Pass 1: extraneous subdirectories, descending (recorded after contents). */
|
||||
for (size_t i = 0; i < dir_count; i++) {
|
||||
if (!is_extra[i])
|
||||
continue;
|
||||
char* child_rel = path_cat((char*)rel_path, entries[i].name);
|
||||
if (!child_rel) {
|
||||
operation_ok = false;
|
||||
continue;
|
||||
}
|
||||
int childfd = openat(dirfd, entries[i].name, O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC);
|
||||
bool child_all_removed = false;
|
||||
if (childfd >= 0) {
|
||||
if (!list_extras_fd(childfd, child_rel, keep, dirs, out, recorded, skips, skip_count,
|
||||
protect_rules, deletable, &child_all_removed))
|
||||
operation_ok = false;
|
||||
close(childfd);
|
||||
} else if (errno != ENOENT) {
|
||||
operation_ok = false;
|
||||
}
|
||||
if (child_all_removed && deletable) {
|
||||
size_t len = strlen(child_rel);
|
||||
char* copy = malloc(len + 2);
|
||||
if (!copy) {
|
||||
operation_ok = false;
|
||||
} else {
|
||||
memcpy(copy, child_rel, len);
|
||||
copy[len] = '/';
|
||||
copy[len + 1] = '\0';
|
||||
if (!array_list_add(out, copy)) {
|
||||
free(copy);
|
||||
operation_ok = false;
|
||||
} else {
|
||||
(*recorded)++;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
local_survives = true;
|
||||
}
|
||||
free(child_rel);
|
||||
}
|
||||
|
||||
/* Pass 2: extraneous files, descending. */
|
||||
for (size_t i = dir_count; i < count; i++) {
|
||||
if (!is_extra[i])
|
||||
continue;
|
||||
char* child_rel = path_cat((char*)rel_path, entries[i].name);
|
||||
if (!child_rel) {
|
||||
operation_ok = false;
|
||||
continue;
|
||||
}
|
||||
char* copy = str_dup(child_rel);
|
||||
if (!copy || !array_list_add(out, copy)) {
|
||||
free(copy);
|
||||
operation_ok = false;
|
||||
} else {
|
||||
(*recorded)++;
|
||||
}
|
||||
free(child_rel);
|
||||
}
|
||||
|
||||
/* Pass 3: kept subdirectories, ascending. */
|
||||
for (size_t i = dir_count; i-- > 0;) {
|
||||
if (is_extra[i] || shielded[i])
|
||||
continue;
|
||||
char* child_rel = path_cat((char*)rel_path, entries[i].name);
|
||||
if (!child_rel) {
|
||||
operation_ok = false;
|
||||
continue;
|
||||
}
|
||||
int childfd = openat(dirfd, entries[i].name, O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC);
|
||||
bool child_all_removed = false;
|
||||
if (childfd >= 0) {
|
||||
if (!list_extras_fd(childfd, child_rel, keep, dirs, out, recorded, skips, skip_count,
|
||||
protect_rules, deletable, &child_all_removed))
|
||||
operation_ok = false;
|
||||
close(childfd);
|
||||
} else if (errno != ENOENT) {
|
||||
operation_ok = false;
|
||||
}
|
||||
local_survives = true;
|
||||
free(child_rel);
|
||||
}
|
||||
|
||||
free(shielded);
|
||||
free(is_extra);
|
||||
delete_dir_entries_free(entries, count);
|
||||
*all_removed = !local_survives;
|
||||
return operation_ok;
|
||||
}
|
||||
|
||||
bool delete_extras_list(const char* dest_root, const ArrayList* manifest,
|
||||
const ArrayList* synced_dirs, const DeleteSkipEntry* skips, int skip_count,
|
||||
const FilterRuleList* protect_rules, ArrayList* out, size_t* count_out) {
|
||||
if (count_out)
|
||||
*count_out = 0;
|
||||
if (!manifest || !out)
|
||||
return false;
|
||||
PathIndex keep;
|
||||
if (!build_keep_index(manifest, &keep))
|
||||
return false;
|
||||
PathIndex dirs;
|
||||
bool have_dirs = synced_dirs != NULL;
|
||||
if (have_dirs &&
|
||||
!path_index_build(&dirs, (const char* const*)synced_dirs->items, (size_t)synced_dirs->size)) {
|
||||
path_index_free(&keep);
|
||||
return false;
|
||||
}
|
||||
int rootfd;
|
||||
int root_fd = utils_get_authorized_root_fd();
|
||||
if (root_fd >= 0) {
|
||||
if (utils_get_authorized_root_path())
|
||||
rootfd = utils_open_authorized_destination(dest_root);
|
||||
else if (dest_root == NULL)
|
||||
rootfd = dup(root_fd);
|
||||
else
|
||||
rootfd = -1;
|
||||
} else {
|
||||
rootfd = open(dest_root, O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC);
|
||||
}
|
||||
if (rootfd < 0) {
|
||||
path_index_free(&keep);
|
||||
if (have_dirs)
|
||||
path_index_free(&dirs);
|
||||
return false;
|
||||
}
|
||||
bool all_removed = false;
|
||||
size_t recorded = 0;
|
||||
bool ok = list_extras_fd(rootfd, "", &keep, have_dirs ? &dirs : NULL, out, &recorded, skips,
|
||||
skip_count, protect_rules, false, &all_removed);
|
||||
if (close(rootfd) != 0)
|
||||
ok = false;
|
||||
path_index_free(&keep);
|
||||
if (have_dirs)
|
||||
path_index_free(&dirs);
|
||||
if (count_out)
|
||||
*count_out = recorded;
|
||||
return ok;
|
||||
}
|
||||
|
||||
DeleteWalkResult delete_extras_limited_observed(const char* dest_root, const ArrayList* manifest,
|
||||
const ArrayList* synced_dirs, size_t max_delete,
|
||||
const DeleteSkipEntry* skips, int skip_count,
|
||||
const FilterRuleList* protect_rules,
|
||||
size_t* deleted_out, size_t* skipped_out,
|
||||
DeletePathObserver observer,
|
||||
void* observer_context) {
|
||||
if (deleted_out)
|
||||
*deleted_out = 0;
|
||||
if (skipped_out)
|
||||
*skipped_out = 0;
|
||||
if (!manifest)
|
||||
return DELETE_WALK_ERROR;
|
||||
/* Index the keep-set (and the synchronized-dir set, when supplied) once so
|
||||
membership is answered in O(path length) instead of scanning every entry
|
||||
for every destination entry. */
|
||||
PathIndex keep;
|
||||
if (!build_keep_index(manifest, &keep))
|
||||
return DELETE_WALK_ERROR;
|
||||
PathIndex dirs;
|
||||
bool have_dirs = synced_dirs != NULL;
|
||||
if (have_dirs &&
|
||||
!path_index_build(&dirs, (const char* const*)synced_dirs->items, (size_t)synced_dirs->size)) {
|
||||
path_index_free(&keep);
|
||||
return DELETE_WALK_ERROR;
|
||||
}
|
||||
int rootfd;
|
||||
int root_fd = utils_get_authorized_root_fd();
|
||||
if (root_fd >= 0) {
|
||||
if (utils_get_authorized_root_path())
|
||||
rootfd = utils_open_authorized_destination(dest_root);
|
||||
else if (dest_root == NULL)
|
||||
rootfd = dup(root_fd);
|
||||
else
|
||||
rootfd = -1;
|
||||
} else {
|
||||
rootfd = open(dest_root, O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC);
|
||||
}
|
||||
if (rootfd < 0) {
|
||||
path_index_free(&keep);
|
||||
if (have_dirs)
|
||||
path_index_free(&dirs);
|
||||
return DELETE_WALK_ERROR;
|
||||
}
|
||||
DeleteBudget budget = {.max_delete = max_delete, .deleted = 0, .skipped = 0, .limit_hit = false};
|
||||
bool all_removed = false;
|
||||
bool ok =
|
||||
delete_extras_fd(rootfd, "", &keep, have_dirs ? &dirs : NULL, &budget, skips, skip_count,
|
||||
protect_rules, false, &all_removed, observer, observer_context);
|
||||
if (close(rootfd) != 0)
|
||||
ok = false;
|
||||
path_index_free(&keep);
|
||||
if (have_dirs)
|
||||
path_index_free(&dirs);
|
||||
if (deleted_out)
|
||||
*deleted_out = budget.deleted;
|
||||
if (skipped_out)
|
||||
*skipped_out = budget.skipped;
|
||||
if (!ok)
|
||||
return DELETE_WALK_ERROR;
|
||||
return budget.limit_hit ? DELETE_WALK_LIMIT_REACHED : DELETE_WALK_OK;
|
||||
}
|
||||
|
||||
DeleteWalkResult delete_extras_limited(const char* dest_root, const ArrayList* manifest,
|
||||
const ArrayList* synced_dirs, size_t max_delete,
|
||||
const DeleteSkipEntry* skips, int skip_count,
|
||||
const FilterRuleList* protect_rules, size_t* deleted_out,
|
||||
size_t* skipped_out) {
|
||||
return delete_extras_limited_observed(dest_root, manifest, synced_dirs, max_delete, skips,
|
||||
skip_count, protect_rules, deleted_out, skipped_out, NULL,
|
||||
NULL);
|
||||
}
|
||||
|
||||
bool delete_extras(const char* dest_root, const ArrayList* manifest) {
|
||||
return delete_extras_limited(dest_root, manifest, NULL, SIZE_MAX, NULL, 0, NULL, NULL, NULL) ==
|
||||
DELETE_WALK_OK;
|
||||
}
|
||||
|
||||
bool has_path_traversal(const char* path) {
|
||||
if (!path)
|
||||
return true;
|
||||
|
||||
@@ -106,101 +106,7 @@ int env_choice_first(const char* env_name, int (*resolve)(const char*), bool* sp
|
||||
ssize_t utils_getdelim_bounded(FILE* stream, char** line, size_t* cap, int delim, size_t max_len);
|
||||
char* path_cat(const char* path1, const char* path2);
|
||||
bool glob_match(const char* pattern, const char* str);
|
||||
/* Result of a bounded extra-file deletion run. */
|
||||
typedef enum {
|
||||
/* Every extra entry was removed (or there were none). */
|
||||
DELETE_WALK_OK = 0,
|
||||
/* The numeric cap for this run was reached before every extra was removed.
|
||||
The walker removed exactly the entries the cap allowed and skipped (without
|
||||
removing) the rest, matching rsync's partial --max-delete behavior. */
|
||||
DELETE_WALK_LIMIT_REACHED,
|
||||
/* A traversal or unlink failure aborted the deletion (partial removal is
|
||||
possible, mirroring the delete pass). */
|
||||
DELETE_WALK_ERROR
|
||||
} DeleteWalkResult;
|
||||
/* One protected entry for the delete walker. When top_level_only is true the
|
||||
prefix is skipped only as a DIRECT child of dest_root (the --delay-updates
|
||||
staging directory, which must not hide genuine extras inside a nested
|
||||
destination directory that happens to share the staging name); otherwise the
|
||||
prefix is skipped at any depth (the --compare-dest/--copy-dest/--link-dest
|
||||
basis trees, and the sender-side protected filter-excluded prefixes, which
|
||||
are never destination content). */
|
||||
typedef struct {
|
||||
const char* prefix;
|
||||
bool top_level_only;
|
||||
} DeleteSkipEntry;
|
||||
/* True when child_rel is, or lies below, one of the protected entries (a prefix
|
||||
"a" protects "a" and "a/b/c" but not "ab"; top_level_only entries protect
|
||||
only DIRECT children of the destination root, i.e. child_rel has no '/'). */
|
||||
bool path_under_skip_prefix(const char* child_rel, bool at_root, const DeleteSkipEntry* skips,
|
||||
int skip_count);
|
||||
/* One destination-directory entry collected up front so the delete walkers can
|
||||
reproduce rsync's traversal order instead of readdir() order. rsync processes
|
||||
a directory's extraneous subdirectories first (descending name, depth-first),
|
||||
then its extraneous files (descending name), and only afterwards descends into
|
||||
its kept subdirectories (ascending name). */
|
||||
typedef struct {
|
||||
char* name;
|
||||
bool is_dir;
|
||||
} DeleteDirEntry;
|
||||
/* Collect the entries of the directory open on `dirfd` (excluding "." and ".."),
|
||||
stat'ing each with AT_SYMLINK_NOFOLLOW. On success *out is a malloc'd array of
|
||||
*count entries whose names the caller frees with delete_dir_entries_free().
|
||||
Returns false on an allocation/readdir failure; a vanished entry (ENOENT) is
|
||||
skipped, any other stat failure is reported through *operation_ok while the
|
||||
walk continues. */
|
||||
bool delete_dir_entries_collect(int dirfd, DeleteDirEntry** out, size_t* count, bool* operation_ok);
|
||||
void delete_dir_entries_free(DeleteDirEntry* entries, size_t count);
|
||||
/* Sort comparators: `_desc` orders subdirectories before files and each group by
|
||||
descending name (rsync's extraneous-entry order); `_asc` orders plain ascending
|
||||
name (rsync's kept-subdirectory order). */
|
||||
int delete_dir_entry_cmp_desc(const void* a, const void* b);
|
||||
int delete_dir_entry_cmp_asc(const void* a, const void* b);
|
||||
/* Remove files/dirs/symlinks under dest_root that are not listed in manifest
|
||||
without ever descending into a protected prefix (see DeleteSkipEntry). When
|
||||
`synced_dirs` is non-NULL, extras are only removed directly inside a directory
|
||||
whose destination-relative path is an exact entry in that list (the receive
|
||||
root is the "." sentinel); directories outside the synchronized set are still
|
||||
descended into so kept content below a listed directory is preserved, but
|
||||
nothing in them is removed. A NULL `synced_dirs` keeps the legacy behavior of
|
||||
treating the whole destination tree as deletable. `max_delete` caps the
|
||||
number of removed entries (SIZE_MAX = unlimited): the walker removes up to the
|
||||
cap and returns DELETE_WALK_LIMIT_REACHED when more extras remained.
|
||||
`deleted_out`/`skipped_out` optionally receive the number of entries removed
|
||||
and the number skipped because of the cap. */
|
||||
DeleteWalkResult delete_extras_limited(const char* dest_root, const ArrayList* manifest,
|
||||
const ArrayList* synced_dirs, size_t max_delete,
|
||||
const DeleteSkipEntry* skips, int skip_count,
|
||||
const FilterRuleList* protect_rules, size_t* deleted_out,
|
||||
size_t* skipped_out);
|
||||
|
||||
/* Optional per-deletion observer: called for each destination-relative path
|
||||
actually removed (a file, symlink, or directory), in removal order, so the
|
||||
receiver can stream rsync's `--info=del`/`--info=remove` lines. */
|
||||
typedef void (*DeletePathObserver)(void* context, const char* rel_path);
|
||||
|
||||
/* `delete_extras_limited_observed` is delete_extras_limited with an optional
|
||||
* observer; the observer is invoked only for entries truly removed. When
|
||||
* `protect_rules` is non-NULL its receiver-side verdict is evaluated for every
|
||||
* candidate extra: a first-match PROTECT leaves the entry (and, for a
|
||||
* directory, its whole subtree) in place, while RISK/NONE fall through to the
|
||||
* ordinary skip-prefix/keep-set logic. */
|
||||
DeleteWalkResult delete_extras_limited_observed(const char* dest_root, const ArrayList* manifest,
|
||||
const ArrayList* synced_dirs, size_t max_delete,
|
||||
const DeleteSkipEntry* skips, int skip_count,
|
||||
const FilterRuleList* protect_rules,
|
||||
size_t* deleted_out, size_t* skipped_out,
|
||||
DeletePathObserver observer,
|
||||
void* observer_context);
|
||||
/* Read-only companion to delete_extras_limited: walk the destination exactly as
|
||||
the delete pass would and APPEND (strdup'd) destination-relative paths that
|
||||
WOULD be removed, without touching disk. Used for -n/--dry-run --delete
|
||||
would-delete reporting. Returns true on a clean walk; the caller owns the
|
||||
strings appended to `out` and receives their count in *count_out. */
|
||||
bool delete_extras_list(const char* dest_root, const ArrayList* manifest,
|
||||
const ArrayList* synced_dirs, const DeleteSkipEntry* skips, int skip_count,
|
||||
const FilterRuleList* protect_rules, ArrayList* out, size_t* count_out);
|
||||
bool delete_extras(const char* dest_root, const ArrayList* manifest);
|
||||
/* Open the existing destination directory at `dest_root`, confined to the
|
||||
authorized root with an O_NOFOLLOW component walk (the same confinement the
|
||||
deletion walker uses for its root). Returns a new fd the caller owns, or -1
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
"""End-to-end coverage for the `--temp-dir` EXDEV (cross-filesystem) fallback.
|
||||
|
||||
`file_to_disk_secure_impl` installs a completed temp file with `renameat(2)`;
|
||||
when the scratch dir lives on a different filesystem the rename fails with
|
||||
`EXDEV` and the engine retries with no scratch dir, writing the file directly in
|
||||
the destination directory (a non-atomic copy), matching rsync.
|
||||
|
||||
The daemon receiver confines `--temp-dir` to the authorized receive root, so a
|
||||
genuine cross-fs scratch there would require an in-root mount point. Bind/tmpfs
|
||||
mounting is not permitted in the CI container (no `CAP_SYS_ADMIN`, and
|
||||
unprivileged user namespaces are disabled), so this test reaches the exact same
|
||||
code path through the local `--read-batch` apply instead: it has no
|
||||
authorized-root confinement, so a relative `--temp-dir` that is a symlink to a
|
||||
tmpfs (`/dev/shm`) is accepted and the final install then crosses filesystems.
|
||||
"""
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, os.path.dirname(__file__))
|
||||
from common import CLIENT_CMD, get_dest_received_dir
|
||||
|
||||
TMPFS = "/dev/shm"
|
||||
|
||||
|
||||
def _run(args):
|
||||
return subprocess.run(CLIENT_CMD + args, capture_output=True, text=True, timeout=180)
|
||||
|
||||
|
||||
def _read(path):
|
||||
with open(path, "rb") as fh:
|
||||
return fh.read()
|
||||
|
||||
|
||||
def test_read_batch_temp_dir_cross_filesystem_fallback(tmp_path):
|
||||
if not os.path.isdir(TMPFS):
|
||||
pytest.skip("no /dev/shm tmpfs available to force a cross-filesystem install")
|
||||
|
||||
source = tmp_path / "src"
|
||||
dest = tmp_path / "dst"
|
||||
source.mkdir()
|
||||
dest.mkdir()
|
||||
files = {
|
||||
"payload.bin": bytes(range(256)) * 64,
|
||||
"sub/nested.txt": b"nested exdev fallback\n" * 8,
|
||||
}
|
||||
for rel, data in files.items():
|
||||
full = source / rel
|
||||
full.parent.mkdir(parents=True, exist_ok=True)
|
||||
full.write_bytes(data)
|
||||
|
||||
batch = tmp_path / "tree.batch"
|
||||
r = _run(["--only-write-batch", str(batch), str(source)])
|
||||
assert r.returncode == 0, (r.stdout, r.stderr)
|
||||
|
||||
# A cross-filesystem scratch dir, reached through a relative --temp-dir
|
||||
# symlink (the local batch apply performs no authorized-root confinement).
|
||||
scratch = os.path.join(TMPFS, "fastsync_exdev_%d" % os.getpid())
|
||||
shutil.rmtree(scratch, ignore_errors=True)
|
||||
os.makedirs(scratch)
|
||||
os.symlink(scratch, dest / "scratch")
|
||||
try:
|
||||
assert os.stat(scratch).st_dev != os.stat(dest).st_dev, (
|
||||
"scratch and destination share a filesystem; EXDEV cannot be exercised"
|
||||
)
|
||||
r = _run(["--read-batch", str(batch), str(dest), "--temp-dir=scratch"])
|
||||
assert r.returncode == 0, (r.stdout, r.stderr)
|
||||
# The engine must report the non-atomic cross-fs fallback rather than
|
||||
# silently claiming an atomic install.
|
||||
assert "different filesystem" in (r.stdout + r.stderr), (r.stdout, r.stderr)
|
||||
# The tree is still byte-exact and the scratch dir is left clean.
|
||||
received = get_dest_received_dir(str(dest), str(source))
|
||||
for rel, data in files.items():
|
||||
assert _read(os.path.join(received, rel)) == data, f"content mismatch for {rel}"
|
||||
assert os.listdir(scratch) == [], "cross-fs temp file was not cleaned up"
|
||||
finally:
|
||||
shutil.rmtree(scratch, ignore_errors=True)
|
||||
@@ -195,7 +195,7 @@ static void test_format_C_padding_uses_transfer_algo() {
|
||||
{CHECKSUM_ALGO_NONE, 2},
|
||||
};
|
||||
for (size_t i = 0; i < sizeof(cases) / sizeof(cases[0]); i++) {
|
||||
config->checksum_transfer_algo = cases[i].algo;
|
||||
config->cli.checksum_transfer_algo = cases[i].algo;
|
||||
char expected[64];
|
||||
size_t n = 0;
|
||||
expected[n++] = '[';
|
||||
|
||||
+24
-21
@@ -730,7 +730,7 @@ static void test_parse_args_port_alias() {
|
||||
EXPECT_EQ_INT(cfg->server_port, 9000);
|
||||
/* The default port is 8080; the explicit bit is what lets --dry-run tell an
|
||||
explicit remote target from the default and route to the server. */
|
||||
EXPECT_TRUE(cfg->server_port_set);
|
||||
EXPECT_TRUE(cfg->cli.server_port_set);
|
||||
config_delete(cfg);
|
||||
|
||||
cfg = config_create();
|
||||
@@ -738,7 +738,7 @@ static void test_parse_args_port_alias() {
|
||||
positional_count = 0;
|
||||
EXPECT_EQ_INT(parse_args(cfg, 4, argv_inline, positional_args, &positional_count), 0);
|
||||
EXPECT_EQ_INT(cfg->server_port, 9001);
|
||||
EXPECT_TRUE(cfg->server_port_set);
|
||||
EXPECT_TRUE(cfg->cli.server_port_set);
|
||||
config_delete(cfg);
|
||||
|
||||
cfg = config_create();
|
||||
@@ -746,7 +746,7 @@ static void test_parse_args_port_alias() {
|
||||
positional_count = 0;
|
||||
EXPECT_EQ_INT(parse_args(cfg, 4, argv_long, positional_args, &positional_count), 0);
|
||||
EXPECT_EQ_INT(cfg->server_port, 9002);
|
||||
EXPECT_TRUE(cfg->server_port_set);
|
||||
EXPECT_TRUE(cfg->cli.server_port_set);
|
||||
config_delete(cfg);
|
||||
}
|
||||
|
||||
@@ -757,18 +757,18 @@ static void test_parse_args_server_host_sets_routing_bit() {
|
||||
Config* cfg = config_create();
|
||||
int positional_args[2];
|
||||
int positional_count = 0;
|
||||
EXPECT_FALSE(cfg->server_host_set);
|
||||
EXPECT_FALSE(cfg->cli.server_host_set);
|
||||
char* argv_space[] = {"fastsync", "--server-host", "example.test", "/src", "/dst"};
|
||||
EXPECT_EQ_INT(parse_args(cfg, 5, argv_space, positional_args, &positional_count), 0);
|
||||
EXPECT_EQ_STR(cfg->server_host, "example.test");
|
||||
EXPECT_TRUE(cfg->server_host_set);
|
||||
EXPECT_TRUE(cfg->cli.server_host_set);
|
||||
config_delete(cfg);
|
||||
|
||||
cfg = config_create();
|
||||
char* argv_inline[] = {"fastsync", "--server-host=example.test", "/src", "/dst"};
|
||||
positional_count = 0;
|
||||
EXPECT_EQ_INT(parse_args(cfg, 4, argv_inline, positional_args, &positional_count), 0);
|
||||
EXPECT_TRUE(cfg->server_host_set);
|
||||
EXPECT_TRUE(cfg->cli.server_host_set);
|
||||
config_delete(cfg);
|
||||
}
|
||||
|
||||
@@ -1726,7 +1726,7 @@ static void test_parse_args_no_preserve_blocks_implicit_metadata() {
|
||||
|
||||
EXPECT_EQ_INT(parse_args(cfg, 5, argv, positional_args, &positional_count), 0);
|
||||
EXPECT_FALSE(cfg->use_metadata);
|
||||
EXPECT_TRUE(cfg->metadata_explicitly_disabled);
|
||||
EXPECT_TRUE(cfg->cli.metadata_explicitly_disabled);
|
||||
config_delete(cfg);
|
||||
}
|
||||
}
|
||||
@@ -1777,7 +1777,7 @@ static void test_parse_args_checksum_choice_rejects_unsupported() {
|
||||
int positional_args[2];
|
||||
int positional_count = 0;
|
||||
EXPECT_EQ_INT(parse_args(cfg, 5, argv, positional_args, &positional_count), -1);
|
||||
EXPECT_EQ_INT(cfg->cli_exit_code, 4);
|
||||
EXPECT_EQ_INT(cfg->cli.cli_exit_code, 4);
|
||||
config_delete(cfg);
|
||||
}
|
||||
}
|
||||
@@ -1817,7 +1817,7 @@ static void test_parse_args_checksum_choice_new_algos() {
|
||||
positional_count = 0;
|
||||
EXPECT_EQ_INT(parse_args(cfg, 5, argv4, positional_args, &positional_count), 0);
|
||||
EXPECT_EQ_INT(cfg->checksum_algo, single[i]);
|
||||
EXPECT_EQ_INT(cfg->checksum_transfer_algo, single[i]);
|
||||
EXPECT_EQ_INT(cfg->cli.checksum_transfer_algo, single[i]);
|
||||
config_delete(cfg);
|
||||
}
|
||||
|
||||
@@ -1827,7 +1827,7 @@ static void test_parse_args_checksum_choice_new_algos() {
|
||||
char* argv5[] = {"fastsync", "--cc=sha1,md4", "/checksum/src", "/dst"};
|
||||
positional_count = 0;
|
||||
EXPECT_EQ_INT(parse_args(cfg, 4, argv5, positional_args, &positional_count), 0);
|
||||
EXPECT_EQ_INT(cfg->checksum_transfer_algo, (int)CHECKSUM_ALGO_SHA1);
|
||||
EXPECT_EQ_INT(cfg->cli.checksum_transfer_algo, (int)CHECKSUM_ALGO_SHA1);
|
||||
EXPECT_EQ_INT(cfg->checksum_algo, (int)CHECKSUM_ALGO_MD4);
|
||||
config_delete(cfg);
|
||||
|
||||
@@ -1849,14 +1849,14 @@ static void test_parse_args_checksum_none_with_checksum_rejected() {
|
||||
int positional_args[2];
|
||||
int positional_count = 0;
|
||||
EXPECT_EQ_INT(parse_args(cfg, 5, argv, positional_args, &positional_count), -1);
|
||||
EXPECT_EQ_INT(cfg->cli_exit_code, 4);
|
||||
EXPECT_EQ_INT(cfg->cli.cli_exit_code, 4);
|
||||
config_delete(cfg);
|
||||
|
||||
cfg = config_create();
|
||||
char* argv2[] = {"fastsync", "--checksum", "--cc=md5,none", "/checksum/src", "/dst"};
|
||||
positional_count = 0;
|
||||
EXPECT_EQ_INT(parse_args(cfg, 5, argv2, positional_args, &positional_count), -1);
|
||||
EXPECT_EQ_INT(cfg->cli_exit_code, 4);
|
||||
EXPECT_EQ_INT(cfg->cli.cli_exit_code, 4);
|
||||
config_delete(cfg);
|
||||
|
||||
/* "none" as the TRANSFER checksum with a real pre-transfer checksum is
|
||||
@@ -1958,7 +1958,7 @@ static void test_parse_args_compress_choice_parity() {
|
||||
int positional_args[2];
|
||||
int positional_count = 0;
|
||||
EXPECT_EQ_INT(parse_args(cfg, 5, argv, positional_args, &positional_count), -1);
|
||||
EXPECT_EQ_INT(cfg->cli_exit_code, 4);
|
||||
EXPECT_EQ_INT(cfg->cli.cli_exit_code, 4);
|
||||
config_delete(cfg);
|
||||
}
|
||||
}
|
||||
@@ -2078,6 +2078,9 @@ static void test_parse_args_temp_dir() {
|
||||
config_delete(cfg);
|
||||
}
|
||||
|
||||
/* --old-args is accepted for rsync CLI compatibility as a documented no-op (the
|
||||
* remote server path is always safely quoted); it stores no Config field, so
|
||||
* parsing it must simply succeed and leave the positional arguments intact. */
|
||||
static void test_parse_args_old_args() {
|
||||
Config* cfg = config_create();
|
||||
char* argv[] = {"fastsync", "--old-args", "/src", "/dst"};
|
||||
@@ -2085,7 +2088,7 @@ static void test_parse_args_old_args() {
|
||||
int positional_count = 0;
|
||||
|
||||
EXPECT_EQ_INT(parse_args(cfg, 4, argv, positional_args, &positional_count), 0);
|
||||
EXPECT_TRUE(cfg->old_args);
|
||||
EXPECT_EQ_INT(positional_count, 2);
|
||||
config_delete(cfg);
|
||||
}
|
||||
|
||||
@@ -2719,7 +2722,7 @@ static void test_parse_args_compression_env_list() {
|
||||
cfg = config_create();
|
||||
positional_count = 0;
|
||||
EXPECT_EQ_INT(parse_args(cfg, 4, argv, positional_args, &positional_count), -1);
|
||||
EXPECT_EQ_INT(cfg->cli_exit_code, 4);
|
||||
EXPECT_EQ_INT(cfg->cli.cli_exit_code, 4);
|
||||
config_delete(cfg);
|
||||
unsetenv("RSYNC_COMPRESS_LIST");
|
||||
}
|
||||
@@ -2734,7 +2737,7 @@ static void test_parse_args_checksum_env_list() {
|
||||
setenv("RSYNC_CHECKSUM_LIST", "md5", 1);
|
||||
EXPECT_EQ_INT(parse_args(cfg, 4, argv, positional_args, &positional_count), 0);
|
||||
EXPECT_EQ_INT(cfg->checksum_algo, (int)CHECKSUM_ALGO_MD5);
|
||||
EXPECT_EQ_INT(cfg->checksum_transfer_algo, (int)CHECKSUM_ALGO_MD5);
|
||||
EXPECT_EQ_INT(cfg->cli.checksum_transfer_algo, (int)CHECKSUM_ALGO_MD5);
|
||||
config_delete(cfg);
|
||||
|
||||
/* An explicit --cc wins. */
|
||||
@@ -2750,7 +2753,7 @@ static void test_parse_args_checksum_env_list() {
|
||||
cfg = config_create();
|
||||
positional_count = 0;
|
||||
EXPECT_EQ_INT(parse_args(cfg, 4, argv, positional_args, &positional_count), -1);
|
||||
EXPECT_EQ_INT(cfg->cli_exit_code, 4);
|
||||
EXPECT_EQ_INT(cfg->cli.cli_exit_code, 4);
|
||||
config_delete(cfg);
|
||||
unsetenv("RSYNC_CHECKSUM_LIST");
|
||||
}
|
||||
@@ -4385,7 +4388,7 @@ static void test_parse_args_preserve_long_form() {
|
||||
}
|
||||
|
||||
/* --no-perms/--no-times/--no-owner/--no-group (long and short) clear only
|
||||
* their own attribute bit; they never set metadata_explicitly_disabled. */
|
||||
* their own attribute bit; they never set cli.metadata_explicitly_disabled. */
|
||||
static void test_parse_args_preserve_negations() {
|
||||
struct {
|
||||
const char* arg;
|
||||
@@ -4413,7 +4416,7 @@ static void test_parse_args_preserve_negations() {
|
||||
bool expected = all[j] != cases[i].offset;
|
||||
EXPECT_TRUE(*(bool*)((char*)cfg + all[j]) == expected);
|
||||
}
|
||||
EXPECT_FALSE(cfg->metadata_explicitly_disabled);
|
||||
EXPECT_FALSE(cfg->cli.metadata_explicitly_disabled);
|
||||
/* -a's devices/specials keep the metadata frame on. */
|
||||
EXPECT_TRUE(cfg->use_metadata);
|
||||
config_delete(cfg);
|
||||
@@ -4456,7 +4459,7 @@ static void test_parse_args_no_preserve_disables_bundle() {
|
||||
EXPECT_FALSE(cfg->preserve_times);
|
||||
EXPECT_FALSE(cfg->preserve_owner);
|
||||
EXPECT_FALSE(cfg->preserve_group);
|
||||
EXPECT_TRUE(cfg->metadata_explicitly_disabled);
|
||||
EXPECT_TRUE(cfg->cli.metadata_explicitly_disabled);
|
||||
EXPECT_TRUE(cfg->use_incremental);
|
||||
EXPECT_FALSE(cfg->use_metadata);
|
||||
config_delete(cfg);
|
||||
@@ -4509,7 +4512,7 @@ static void test_parse_args_incremental_implies_preserve() {
|
||||
EXPECT_EQ_INT(parse_args(cfg, 5, argv4, positional_args, &positional_count), 0);
|
||||
EXPECT_FALSE(cfg->preserve_perms);
|
||||
EXPECT_FALSE(cfg->preserve_times);
|
||||
EXPECT_TRUE(cfg->metadata_explicitly_disabled);
|
||||
EXPECT_TRUE(cfg->cli.metadata_explicitly_disabled);
|
||||
EXPECT_FALSE(cfg->use_metadata);
|
||||
config_delete(cfg);
|
||||
}
|
||||
|
||||
+4
-4
@@ -2533,15 +2533,15 @@ static void test_config_derived_use_metadata() {
|
||||
/* Incremental/delta imply metadata unless --no-preserve disabled it. */
|
||||
c->use_incremental = true;
|
||||
EXPECT_TRUE(config_derived_use_metadata(c));
|
||||
c->metadata_explicitly_disabled = true;
|
||||
c->cli.metadata_explicitly_disabled = true;
|
||||
EXPECT_FALSE(config_derived_use_metadata(c));
|
||||
c->metadata_explicitly_disabled = false;
|
||||
c->cli.metadata_explicitly_disabled = false;
|
||||
c->use_incremental = false;
|
||||
c->use_delta = true;
|
||||
EXPECT_TRUE(config_derived_use_metadata(c));
|
||||
c->metadata_explicitly_disabled = true;
|
||||
c->cli.metadata_explicitly_disabled = true;
|
||||
EXPECT_FALSE(config_derived_use_metadata(c));
|
||||
c->metadata_explicitly_disabled = false;
|
||||
c->cli.metadata_explicitly_disabled = false;
|
||||
c->use_delta = false;
|
||||
|
||||
/* Flags that must NOT imply metadata on their own. */
|
||||
|
||||
+6
-6
@@ -2255,26 +2255,26 @@ static void test_basis_delete_relative_root_slash() {
|
||||
EXPECT_NOT_NULL(cfg);
|
||||
cfg->receive_root_directory = str_dup("/");
|
||||
|
||||
char* rel = file_receive_basis_delete_relative(cfg, "/a");
|
||||
char* rel = delete_basis_relative(cfg, "/a");
|
||||
EXPECT_NOT_NULL(rel);
|
||||
EXPECT_EQ_STR(rel, "a");
|
||||
free(rel);
|
||||
rel = file_receive_basis_delete_relative(cfg, "/a/b");
|
||||
rel = delete_basis_relative(cfg, "/a/b");
|
||||
EXPECT_NOT_NULL(rel);
|
||||
EXPECT_EQ_STR(rel, "a/b");
|
||||
free(rel);
|
||||
/* The root itself is not a child. */
|
||||
EXPECT_NULL(file_receive_basis_delete_relative(cfg, "/"));
|
||||
EXPECT_NULL(delete_basis_relative(cfg, "/"));
|
||||
/* A relative entry is already root-relative. */
|
||||
rel = file_receive_basis_delete_relative(cfg, "x/y");
|
||||
rel = delete_basis_relative(cfg, "x/y");
|
||||
EXPECT_NOT_NULL(rel);
|
||||
EXPECT_EQ_STR(rel, "x/y");
|
||||
free(rel);
|
||||
/* An absolute path outside a non-"/" root is unreachable. */
|
||||
free(cfg->receive_root_directory);
|
||||
cfg->receive_root_directory = str_dup("/root");
|
||||
EXPECT_NULL(file_receive_basis_delete_relative(cfg, "/other/a"));
|
||||
rel = file_receive_basis_delete_relative(cfg, "/root/a");
|
||||
EXPECT_NULL(delete_basis_relative(cfg, "/other/a"));
|
||||
rel = delete_basis_relative(cfg, "/root/a");
|
||||
EXPECT_NOT_NULL(rel);
|
||||
EXPECT_EQ_STR(rel, "a");
|
||||
free(rel);
|
||||
|
||||
+43
-2
@@ -1,6 +1,8 @@
|
||||
#include "protocol.h"
|
||||
#include "test_utils.h"
|
||||
#include <fcntl.h>
|
||||
#include <limits.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <time.h>
|
||||
#include <unistd.h>
|
||||
@@ -715,7 +717,7 @@ static void test_protocol_throttle_bytes_paces() {
|
||||
|
||||
struct timespec start;
|
||||
clock_gettime(CLOCK_MONOTONIC, &start);
|
||||
protocol_throttle_bytes(150000);
|
||||
protocol_throttle_bytes(-1, 150000);
|
||||
struct timespec now;
|
||||
clock_gettime(CLOCK_MONOTONIC, &now);
|
||||
long long elapsed_ms =
|
||||
@@ -736,7 +738,7 @@ static void test_protocol_throttle_bytes_unlimited() {
|
||||
|
||||
struct timespec start;
|
||||
clock_gettime(CLOCK_MONOTONIC, &start);
|
||||
protocol_throttle_bytes(100000000ULL);
|
||||
protocol_throttle_bytes(-1, 100000000ULL);
|
||||
struct timespec now;
|
||||
clock_gettime(CLOCK_MONOTONIC, &now);
|
||||
long long elapsed_ms =
|
||||
@@ -746,6 +748,44 @@ static void test_protocol_throttle_bytes_unlimited() {
|
||||
protocol_session_unbind();
|
||||
}
|
||||
|
||||
/* Regression for the plaintext sendfile path: it calls protocol_throttle_bytes()
|
||||
* immediately after send_n_data(), which already bound legacy_io_session.write_fd
|
||||
* to the wire fd. Resolving the throttle session with (read=-1, write=-1)
|
||||
* mismatched that fd and re-initialized the legacy session, granting a *second*
|
||||
* first-call burst and discarding the accumulated debt. This drives the same
|
||||
* sequence and asserts the debt from send_n_data carries into the throttle. */
|
||||
static void test_protocol_throttle_bytes_legacy_same_session() {
|
||||
const size_t payload = 150000; /* 1.5x the 100 KB burst at --bwlimit=1 MB/s */
|
||||
unsigned char* buffer = malloc(payload);
|
||||
EXPECT_TRUE(buffer != NULL);
|
||||
memset(buffer, 0, payload);
|
||||
|
||||
io_set_fds(-1, -1);
|
||||
io_set_bwlimit(1000000ULL);
|
||||
|
||||
int fd = open("/dev/null", O_WRONLY);
|
||||
EXPECT_TRUE(fd >= 0);
|
||||
|
||||
struct timespec start;
|
||||
clock_gettime(CLOCK_MONOTONIC, &start);
|
||||
/* send_n_data() consumes the whole 100 KB burst and sleeps ~50 ms. */
|
||||
EXPECT_TRUE(send_n_data(fd, buffer, payload));
|
||||
/* The throttle must share that session, so the 150 KB is all debt and sleeps
|
||||
~150 ms (total ~200 ms). A re-initialized session would hand out a fresh
|
||||
100 KB burst and sleep only ~50 ms (total ~100 ms). */
|
||||
protocol_throttle_bytes(fd, payload);
|
||||
struct timespec now;
|
||||
clock_gettime(CLOCK_MONOTONIC, &now);
|
||||
long long elapsed_ms =
|
||||
(now.tv_sec - start.tv_sec) * 1000LL + (now.tv_nsec - start.tv_nsec) / 1000000LL;
|
||||
EXPECT_TRUE(elapsed_ms >= 150);
|
||||
|
||||
close(fd);
|
||||
free(buffer);
|
||||
io_set_bwlimit(0);
|
||||
io_set_fds(-1, -1);
|
||||
}
|
||||
|
||||
void test_protocol() {
|
||||
test_send_receive_n_data();
|
||||
test_send_receive_n_data_zero();
|
||||
@@ -778,4 +818,5 @@ void test_protocol() {
|
||||
test_data_create_starts_uncharged_and_unowned();
|
||||
test_protocol_throttle_bytes_paces();
|
||||
test_protocol_throttle_bytes_unlimited();
|
||||
test_protocol_throttle_bytes_legacy_same_session();
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
#include "test_shared_utils.h"
|
||||
#include "delete.h"
|
||||
#include "utils.h"
|
||||
#include "protocol.h"
|
||||
#include "test_utils.h"
|
||||
|
||||
Reference in New Issue
Block a user