From 4ac37c4d8af5b111b723c8ab3bb713b4033f0909 Mon Sep 17 00:00:00 2001 From: TapTap Date: Sat, 12 Sep 2026 20:42:47 +0200 Subject: [PATCH 1/5] refactor(dir-times): extract dir_times_should_capture predicate Deduplicate the repeated directory-time capture gate (`config->use_metadata && !config->omit_dir_times`) used by the sender-side (multiprocessing.c) and receiver-side (receiver.c) sinks into a single predicate declared next to the DirTimeList machinery in file_receive.h and defined in file_receive.c. Behavior preserved: identical short-circuit condition and semantics, no signature or protocol changes. --- src/server/receiver.c | 2 +- src/shared/file_receive.c | 4 ++++ src/shared/file_receive.h | 6 ++++++ src/shared/multiprocessing.c | 3 ++- 4 files changed, 13 insertions(+), 2 deletions(-) diff --git a/src/server/receiver.c b/src/server/receiver.c index 936fc1f..4b84831 100644 --- a/src/server/receiver.c +++ b/src/server/receiver.c @@ -353,7 +353,7 @@ static bool receiver_save_file(File* file, void* context_pointer) { metadata now and apply it at the end. -O/--omit-dir-times is honored by dir_time_list_apply's caller (see receiver_send_success_frame). */ if (result != FILE_SAVE_ERROR && file->is_dir && file->metadata && - context->config->use_metadata && !context->config->omit_dir_times && + dir_times_should_capture(context->config) && !dir_time_list_add(&context->dir_times, file->path, file->metadata)) { file_destroy(file); return false; diff --git a/src/shared/file_receive.c b/src/shared/file_receive.c index d29bf77..ffb72df 100644 --- a/src/shared/file_receive.c +++ b/src/shared/file_receive.c @@ -2236,6 +2236,10 @@ File* file_receive(const Config* config, int file_descriptor) { /* ---- P7 Wave D: deferred directory times ---- */ +bool dir_times_should_capture(const Config* config) { + return config->use_metadata && !config->omit_dir_times; +} + void dir_time_list_init(DirTimeList* list) { if (!list) return; diff --git a/src/shared/file_receive.h b/src/shared/file_receive.h index 555a342..37dc15a 100644 --- a/src/shared/file_receive.h +++ b/src/shared/file_receive.h @@ -30,6 +30,12 @@ typedef struct { size_t capacity; } DirTimeList; +/* Capture gate shared by the sender-side and receiver-side sinks: directory + * metadata is accumulated only when --times/--metadata is in effect and + * -O/--omit-dir-times does not suppress it. Kept here, next to the accumulator + * it guards, so both call sites express the same condition. */ +bool dir_times_should_capture(const Config* config); + void dir_time_list_init(DirTimeList* list); void dir_time_list_free(DirTimeList* list); /* Deep-copy one directory's path + metadata into the list. Returns false on diff --git a/src/shared/multiprocessing.c b/src/shared/multiprocessing.c index 57af92a..b93c9f0 100644 --- a/src/shared/multiprocessing.c +++ b/src/shared/multiprocessing.c @@ -6,6 +6,7 @@ #include "config.h" #include "data.h" #include "file.h" +#include "file_receive.h" #include "log.h" #include "protocol.h" #include "queue.h" @@ -331,7 +332,7 @@ int write_thread(void* pipeline_context) { write would clobber them); accumulate the metadata here and let the caller apply it once every writer has drained. */ if (result != FILE_SAVE_ERROR && file->is_dir && file->metadata && - context->config->use_metadata && !context->config->omit_dir_times && + dir_times_should_capture(context->config) && !dir_time_list_add(&context->dir_times, file->path, file->metadata)) { file_destroy(file); pipeline_context_receiver_note_bytes_released(context, file_bytes); From 1fd462cca8b0f4843abce8156b72e490828fcf2f Mon Sep 17 00:00:00 2001 From: TapTap Date: Sat, 12 Sep 2026 20:43:24 +0200 Subject: [PATCH 2/5] refactor(config): replace SUPER_MODE_* macros with SuperMode enum Type Config.super_mode as SuperMode (a proper C enum) instead of a bare int. The wire boundary still carries the mode as an int: send casts the enum explicitly and receive reads a temporary int, validates the AUTO..OFF range, then casts. Emitted bytes and accepted values are unchanged. ModuleGateContext.super_mode_override keeps its -1 sentinel as int with an explicit cast at the apply site. Behavior preserved. --- src/server/server.c | 2 +- src/shared/config.c | 4 ++-- src/shared/config.h | 18 ++++++++---------- src/shared/identity.c | 4 ++-- src/shared/identity.h | 2 +- tests/test_config.c | 2 +- 6 files changed, 15 insertions(+), 17 deletions(-) diff --git a/src/server/server.c b/src/server/server.c index dce8fee..066b817 100644 --- a/src/server/server.c +++ b/src/server/server.c @@ -458,7 +458,7 @@ void handler(int file_descriptor) { * device-node creation) sees SUPER_MODE_OFF. The gate never mutated the * received config. */ if (gate_ctx.super_mode_override != -1) - config->super_mode = gate_ctx.super_mode_override; + config->super_mode = (SuperMode)gate_ctx.super_mode_override; protocol_set_8_bit_output(config->eight_bit_output); if (!authorized_root) { log_message(LOG_LEVEL_ERROR, "No server-side destination root configured"); diff --git a/src/shared/config.c b/src/shared/config.c index 61ea67c..4bd72c9 100644 --- a/src/shared/config.c +++ b/src/shared/config.c @@ -1293,14 +1293,14 @@ static bool receive_iconv_spec(int fd, Config* c) { * validated to the SUPER_MODE_AUTO..SUPER_MODE_OFF range (also re-checked by * validate_received_config). */ static bool send_privilege_options(int fd, const Config* c) { - return send_int(fd, c->super_mode); + return send_int(fd, (int)c->super_mode); } static bool receive_privilege_options(int fd, Config* c) { int mode; if (!receive_int(fd, &mode) || mode < SUPER_MODE_AUTO || mode > SUPER_MODE_OFF) return false; - c->super_mode = mode; + c->super_mode = (SuperMode)mode; return true; } diff --git a/src/shared/config.h b/src/shared/config.h index da3770e..66fd5eb 100644 --- a/src/shared/config.h +++ b/src/shared/config.h @@ -68,6 +68,13 @@ typedef struct { int value; /* 0/1 for booleans, byte count for SO_RCVBUF/SO_SNDBUF */ } SockOptEntry; +/* --super / --no-super tri-state (Config->super_mode). AUTO (default) and ON + * both permit a confined super-user attempt (AUTO preserves FastSync's + * historical best-effort behavior; an unprivileged attempt is refused by the + * kernel and skipped per entry); OFF forbids the attempt even for root. See + * privilege_super_mode_permitted() in identity.h. */ +typedef enum SuperMode { SUPER_MODE_AUTO = 0, SUPER_MODE_ON = 1, SUPER_MODE_OFF = 2 } SuperMode; + typedef struct Config { char* version; char* send_directory; @@ -416,7 +423,7 @@ typedef struct Config { * as a trailing int so the receiver can enforce the policy. See * privilege_super_permitted() and identity_ownership_requested() in * identity.h. */ - int super_mode; + SuperMode super_mode; // Receiver-side runtime staging registry for --delay-updates. Never sent // over the wire and never set on the sender side. @@ -644,15 +651,6 @@ typedef struct Config { #define IDENTITY_CURRENT (-1) #define MAX_IDENTITY_MAP 128 -/* --super / --no-super tri-state (Config->super_mode). AUTO (default) and ON - * both permit a confined super-user attempt (AUTO preserves FastSync's - * historical best-effort behavior; an unprivileged attempt is refused by the - * kernel and skipped per entry); OFF forbids the attempt even for root. See - * privilege_super_mode_permitted() in identity.h. */ -#define SUPER_MODE_AUTO 0 -#define SUPER_MODE_ON 1 -#define SUPER_MODE_OFF 2 - Config* config_create(void); void config_delete(Config* config); diff --git a/src/shared/identity.c b/src/shared/identity.c index 5a39f0e..a43c84b 100644 --- a/src/shared/identity.c +++ b/src/shared/identity.c @@ -30,7 +30,7 @@ typedef struct { /* --super / --no-super tri-state (SUPER_MODE_AUTO when unset). Snapshotted * per connection so privilege_super_permitted() can gate super-user * activities without a Config argument. */ - int super_mode; + SuperMode super_mode; /* --copy-as=USER[:GROUP]: snapshotted so the ownership resolver can force the * target ids without a Config argument. */ bool copy_as_set; @@ -128,7 +128,7 @@ bool privilege_super_permitted(void) { return privilege_super_mode_permitted(g_identity.super_mode); } -bool privilege_super_mode_permitted(int mode) { +bool privilege_super_mode_permitted(SuperMode mode) { /* AUTO and ON both attempt the confined operation; OFF forbids it even for a * root receiver. AUTO is the historical FastSync behavior (always attempt * and let the kernel refuse an unprivileged call, which the caller skips), so diff --git a/src/shared/identity.h b/src/shared/identity.h index 592c5e7..e01c674 100644 --- a/src/shared/identity.h +++ b/src/shared/identity.h @@ -128,6 +128,6 @@ bool identity_wire_valid(const Config* config); * best-effort behavior where an unprivileged attempt is refused by the kernel * and skipped. Neither EVER elevates privileges. */ bool privilege_super_permitted(void); -bool privilege_super_mode_permitted(int mode); +bool privilege_super_mode_permitted(SuperMode mode); #endif \ No newline at end of file diff --git a/tests/test_config.c b/tests/test_config.c index f70d99d..4ffc49f 100644 --- a/tests/test_config.c +++ b/tests/test_config.c @@ -1672,7 +1672,7 @@ static void test_config_receive_rejects_invalid_iconv_spec() { static void test_config_super_mode_wire_roundtrip() { if (is_running_under_valgrind()) return; - int modes[] = {SUPER_MODE_AUTO, SUPER_MODE_ON, SUPER_MODE_OFF}; + SuperMode modes[] = {SUPER_MODE_AUTO, SUPER_MODE_ON, SUPER_MODE_OFF}; for (size_t i = 0; i < sizeof(modes) / sizeof(modes[0]); i++) { int p[2]; EXPECT_EQ_INT(socketpair(AF_UNIX, SOCK_STREAM, 0, p), 0); From 921472b8b3d99a1bcc3db467b0ed2660f669f4ab Mon Sep 17 00:00:00 2001 From: TapTap Date: Sat, 12 Sep 2026 20:55:04 +0200 Subject: [PATCH 3/5] refactor(client-cli): split parse_args into focused option handlers Break the ~700-line parse_args god function into cohesive static helpers grouped by concern: output controls, pre-negation, range/time options, the OPTION_TABLE dispatcher, flag/meta handlers, IO/network options, filter and logging options, checksum/socket options, remote/basis/identity options, positional handling, and a final lowering step. A file-local CliParseCtx carries the config, cursor, positional buffers and the mutable parse flags, so each handler stays focused. The dispatcher calls the handlers in the original recognition order and preserves the exact return contract (0/1/negative), error messages, log levels and control flow. Behavior preserved; no functional changes. --- src/client/client_cli.c | 1620 ++++++++++++++++++++++++--------------- 1 file changed, 991 insertions(+), 629 deletions(-) diff --git a/src/client/client_cli.c b/src/client/client_cli.c index 9c10610..d1676da 100644 --- a/src/client/client_cli.c +++ b/src/client/client_cli.c @@ -819,18 +819,25 @@ static int apply_table_option(Config* config, const OptionEntry* entry, const ch return -1; } -/* Parse CLI arguments into config. Returns 0 on success, -1 on error, 1 for help/clean-exit. */ -int parse_args(Config* config, int argc, char* argv[], int* positional_args, - int* positional_count) { - bool verbose = false; - /* Explicit --no-delta / --no-incremental seen on the command line: the user - switched part of the delta machinery off, so the --fuzzy implication must - not silently turn it back on. */ - bool no_delta = false; - bool no_incremental = false; - protocol_set_8_bit_output(config->eight_bit_output); +/* Shared state for the parse_args helper functions. Keeping the cursor and the + * mutable parse flags here avoids threading a long parameter list through every + * option handler while preserving the original single-pass control flow. */ +typedef struct { + Config* config; + int argc; + char** argv; + int* positional_args; + int* positional_count; + int i; /* index of the argument currently being examined */ + int exit_code; /* nonzero when a matched handler wants parse_args to return */ + bool verbose; /* "-v"/"--verbose" seen (drives the final log level) */ + bool no_delta; /* explicit "--no-delta" seen */ + bool no_incremental; /* explicit "--no-incremental" seen */ +} CliParseCtx; - /* Apply output controls before processing other options so their order is irrelevant. */ +/* Apply output controls before processing other options so their order is + * irrelevant. Returns 0 on success, -1 on error. */ +static int cli_apply_output_controls(Config* config, int argc, char* argv[]) { for (int i = 1; i < argc; i++) { if (strcmp(argv[i], "-v") == 0 || strcmp(argv[i], "--verbose") == 0) { set_log_level(LOG_LEVEL_DEBUG); @@ -842,626 +849,924 @@ int parse_args(Config* config, int argc, char* argv[], int* positional_args, return -1; } } + return 0; +} - for (int i = 1; i < argc; i++) { - if (strcmp(argv[i], "-P") == 0) { - config->partial = true; - config->show_progress = true; - continue; - } - /* "--no-implied-dirs" is a real rsync option name, not a negation of - * "--implied-dirs", so it must be handled before the generic --no-* - * negation branch. */ - if (strcmp(argv[i], "--no-implied-dirs") == 0) { - config->no_implied_dirs = true; - continue; - } - /* "--no-motd" is a real rsync option name (client-side daemon MOTD display - * suppression), not a negation of a "--motd" flag, so it is handled before - * the generic --no-* negation branch. */ - if (strcmp(argv[i], "--no-motd") == 0) { - config->no_motd = true; - continue; - } - /* "--super" / "--no-super" are real rsync option names controlling the - * receiver's super-user activity policy (ownership, device nodes), not a - * Boolean pair for the generic --no-* negation branch: both map onto the - * Config->super_mode tri-state. Handle them explicitly (exact match only, - * so a malformed "--super=x" still falls through to the unknown-option - * error) before the generic negation branch would mis-reject "--no-super". */ - if (strcmp(argv[i], "--super") == 0) { - config->super_mode = SUPER_MODE_ON; - continue; - } - if (strcmp(argv[i], "--no-super") == 0) { - config->super_mode = SUPER_MODE_OFF; - continue; - } - if (strncmp(argv[i], "--no-", strlen("--no-")) == 0) { - if (strcmp(argv[i], "--no-delta") == 0) - no_delta = true; - else if (strcmp(argv[i], "--no-incremental") == 0) - no_incremental = true; - if (apply_negation(config, argv[i]) != 0) - return -1; - continue; - } - const char* modify_window_prefix = "--modify-window="; - if (strncmp(argv[i], modify_window_prefix, strlen(modify_window_prefix)) == 0) { - if (set_nonneg_int_option(&config->modify_window, argv[i] + strlen(modify_window_prefix), - "--modify-window") != 0) - return -1; - continue; - } - if (strncmp(argv[i], "-@", 2) == 0 && argv[i][2] != '\0') { - if (set_nonneg_int_option(&config->modify_window, argv[i] + 2, "-@") != 0) - return -1; - continue; - } - /* --stop-after/--stop-at are client-only sender-side stop deadlines. They - * are parsed by stop_condition (so the unit tests exercise the same validate - * that production uses) and never serialized into the config frame. */ - if (strncmp(argv[i], "--stop-after=", 13) == 0) { - if (!stop_parse_after_minutes(argv[i] + 13, &config->stop_after_mins)) { - log_message(LOG_LEVEL_ERROR, "--stop-after must be a positive number of minutes"); - return -1; - } - continue; - } - if (strcmp(argv[i], "--stop-after") == 0) { - if (i + 1 >= argc) { - log_message(LOG_LEVEL_ERROR, "missing argument for --stop-after"); - return -1; - } - if (!stop_parse_after_minutes(argv[++i], &config->stop_after_mins)) { - log_message(LOG_LEVEL_ERROR, "--stop-after must be a positive number of minutes"); - return -1; - } - continue; - } - if (strncmp(argv[i], "--stop-at=", 10) == 0) { - if (!stop_parse_at_time(argv[i] + 10, time(NULL), &config->stop_at)) { - log_message(LOG_LEVEL_ERROR, "--stop-at must be HH:MM[:SS] or now+N[smhd]"); - return -1; - } - config->stop_at_set = true; - continue; - } - if (strcmp(argv[i], "--stop-at") == 0) { - if (i + 1 >= argc) { - log_message(LOG_LEVEL_ERROR, "missing argument for --stop-at"); - return -1; - } - if (!stop_parse_at_time(argv[++i], time(NULL), &config->stop_at)) { - log_message(LOG_LEVEL_ERROR, "--stop-at must be HH:MM[:SS] or now+N[smhd]"); - return -1; - } - config->stop_at_set = true; - continue; - } - const char* threads_prefix = "--compress-threads="; - if (strncmp(argv[i], threads_prefix, strlen(threads_prefix)) == 0) { - if (set_compression_threads_option(&config->compression_threads, - argv[i] + strlen(threads_prefix)) != 0) - return -1; - continue; - } - if (strncmp(argv[i], "--max-alloc=", 12) == 0 || strcmp(argv[i], "--max-alloc") == 0) { - const char* value = strcmp(argv[i], "--max-alloc") == 0 ? "" : argv[i] + 12; - if (*value == '\0') { - if (i + 1 >= argc) { - log_message(LOG_LEVEL_ERROR, "missing argument for --max-alloc"); - return -1; - } - value = argv[++i]; - } - if (parse_size_arg(value, &config->max_alloc) != 0) { - log_message(LOG_LEVEL_ERROR, - "--max-alloc must be a positive size (B, K, M, G, T, P, or E)"); - return -1; - } - continue; - } - - const OptionEntry* entry = find_table_option(argv[i]); - const char* inline_value = NULL; - if (!entry) - entry = find_table_option_with_equals(argv[i], &inline_value); - if (entry) { - const char* value = NULL; - if (entry->kind != OPT_FLAG) { - value = inline_value; - if (!value && i + 1 < argc) - value = argv[++i]; - if (!value) { - log_message(LOG_LEVEL_ERROR, "missing argument for %s", entry->name); - return -1; - } - if (strcmp(entry->name, "--compress-choice") == 0) { - if (set_compression_choice(config, value) != 0) - return -1; - } else { - if (apply_table_option(config, entry, value) != 0) - return -1; - if (strcmp(entry->name, "--compress-level") == 0 && - (config->compression_level < 1 || config->compression_level > 22)) { - log_message(LOG_LEVEL_ERROR, "--compress-level must be between 1 and 22"); - return -1; - } - if (entry->offset == offsetof(Config, chmod_spec)) { - mode_t ignored; - if (!chmod_apply(0, config->chmod_spec, &ignored)) { - log_message(LOG_LEVEL_ERROR, "--chmod has invalid permission changes"); - return -1; - } - config->use_metadata = true; - } - } - } else if (apply_table_option(config, entry, NULL) != 0) { - return -1; - } - if (entry->offset == offsetof(Config, eight_bit_output)) - protocol_set_8_bit_output(true); - /* A delete-timing flag selects when --delete removes extras, so it - implies --delete exactly like the rsync options do. */ - if (entry->offset == offsetof(Config, delete_before) || - entry->offset == offsetof(Config, delete_during) || - entry->offset == offsetof(Config, delete_delay) || - entry->offset == offsetof(Config, delete_after)) - config->use_delete = true; - /* --delete-missing-args implies --ignore-missing-args (missing entries - are skipped for deletion instead of failing the run). The implication - is order-independent because it is applied over the final parsed - config. */ - if (entry->offset == offsetof(Config, delete_missing_args)) - config->ignore_missing_args = true; - /* -U/--atimes and -N/--crtimes carry their times inside the metadata - payload, which is only transmitted when use_metadata is set, so either - one implies metadata transmission. This is FastSync's broad -M bundle - (mode/mtime travel too); it does NOT enable ownership application, - which stays opt-in via the identity flags. */ - if (entry->offset == offsetof(Config, preserve_atimes) || - entry->offset == offsetof(Config, preserve_crtimes)) - config->use_metadata = true; - if (entry->offset == offsetof(Config, preserve_xattrs) || - entry->offset == offsetof(Config, preserve_acls)) { - config->use_metadata = true; - config->use_xattrs = config->preserve_acls || config->preserve_xattrs; - } - if (entry->offset == offsetof(Config, fake_super)) - config->use_metadata = true; - continue; - } - - if (strncmp(argv[i], "--chmod=", 8) == 0) { - if (set_string_option(&config->chmod_spec, argv[i] + 8, "--chmod") != 0) - return -1; - mode_t ignored; - if (!chmod_apply(0, config->chmod_spec, &ignored)) { - log_message(LOG_LEVEL_ERROR, "--chmod has invalid permission changes"); - return -1; - } - config->use_metadata = true; - continue; - } - - if (opt_is(argv[i], "--help", NULL)) { - print_usage(); - return 1; - } else if (opt_is(argv[i], "-V", "--version")) { - printf("fastsync version %s\n", PROTOCOL_VERSION); - return 1; - } else if (opt_is(argv[i], "-D", NULL)) { - /* rsync -D == --devices --specials. -D is otherwise unassigned in - FastSync (verified: no collision), so it is free to imply both. */ - config->preserve_devices = true; - config->preserve_specials = true; - log_info_message(LOG_INFO_MISC, "Enabled preservation of device and special files (-D)"); - } else if (opt_is(argv[i], "-a", "--archive")) { - /* Real rsync archive (-rlptgoD). FastSync is always recursive and always - * preserves hard-link/other transfer semantics per its own flags, so -a - * implies links, full metadata (perms/times/group/owner as FastSync's - * broad bundle), devices and specials. Compression and multithreading - * are NOT implied (they are no longer part of archive mode). */ - config->follow_symlinks = true; - config->use_metadata = true; - config->preserve_devices = true; - config->preserve_specials = true; - log_info_message(LOG_INFO_MISC, - "Enabled archive mode (-rlptgoD: links, metadata, devices, specials)"); - } else if (opt_is(argv[i], "-p", "--perms")) { - /* rsync -p/--perms: preserve permission bits. Folded into FastSync's - * broad metadata bundle (mode/mtime travel together). */ - config->use_metadata = true; - log_info_message(LOG_INFO_MISC, "Enabled permission preservation"); - } else if (opt_is(argv[i], "--ssh-port", NULL)) { - if (i + 1 >= argc) { - log_message(LOG_LEVEL_ERROR, "missing argument for %s", argv[i]); - return -1; - } - if (set_positive_int_option(&config->ssh_port, argv[++i], "--ssh-port") != 0) - return -1; - if (config->ssh_port > 65535) { - log_message(LOG_LEVEL_ERROR, "SSH port must be 1-65535"); - return -1; - } - } else if (strncmp(argv[i], "--ssh-port=", 11) == 0) { - if (set_positive_int_option(&config->ssh_port, argv[i] + 11, "--ssh-port") != 0) - return -1; - if (config->ssh_port > 65535) { - log_message(LOG_LEVEL_ERROR, "SSH port must be 1-65535"); - return -1; - } - } else if (opt_is(argv[i], "--exclude", NULL)) { - if (i + 1 >= argc) { - log_message(LOG_LEVEL_ERROR, "missing argument for %s", argv[i]); - return -1; - } - if (config_add_pattern(&config->exclude_patterns, &config->exclude_count, argv[++i], - "--exclude") != 0) - return -1; - } else if (opt_is(argv[i], "--include", NULL)) { - if (i + 1 >= argc) { - log_message(LOG_LEVEL_ERROR, "missing argument for %s", argv[i]); - return -1; - } - if (config_add_pattern(&config->include_patterns, &config->include_count, argv[++i], - "--include") != 0) - return -1; - } else if (strncmp(argv[i], "--delta-block=", 14) == 0) { - if (set_delta_block_size(config, argv[i] + 14) != 0) - return -1; - } else if (strncmp(argv[i], "--block-size=", 13) == 0) { - if (set_delta_block_size(config, argv[i] + 13) != 0) - return -1; - } else if (opt_is(argv[i], "--delta-block", "--block-size")) { - if (i + 1 >= argc) { - log_message(LOG_LEVEL_ERROR, "missing argument for %s", argv[i]); - return -1; - } - if (set_delta_block_size(config, argv[++i]) != 0) - return -1; - } else if (opt_is(argv[i], "--delta-max", NULL)) { - if (i + 1 >= argc) { - log_message(LOG_LEVEL_ERROR, "missing argument for %s", argv[i]); - return -1; - } - unsigned long long val; - if (parse_ull_arg(argv[++i], &val, "--delta-max") != 0) - return -1; - if (val >= DELTA_MIN_FILE_SIZE) - config->delta_max_file_size = val; - else - log_message(LOG_LEVEL_WARNING, "--delta-max value %llu too small, using default", val); - } else if (opt_is(argv[i], "-z", "--compress")) { - config->use_compression = - !config->compress_choice || strcmp(config->compress_choice, "zstd") == 0; - log_info_message(LOG_INFO_MISC, "Enabled Compression"); - if (i + 1 < argc) { - char* end_ptr; - long level = strtol(argv[i + 1], &end_ptr, 10); - if (*end_ptr == '\0') { - if (level < 1 || level > 22) { - log_message(LOG_LEVEL_ERROR, "compression level must be 1-22"); - return -1; - } - config->compression_level = (int)level; - log_info_message(LOG_INFO_MISC, "Set Compression level to %ld", level); - i++; - } - } - } else if (opt_is(argv[i], "--preserve", NULL)) { - config->use_metadata = true; - log_info_message(LOG_INFO_MISC, "Enabled metadata preservation"); - } else if (opt_is(argv[i], "-E", "--executability")) { - config->use_metadata = true; - config->use_executability = true; - log_info_message(LOG_INFO_MISC, "Enabled executable permission preservation"); - } else if (opt_is(argv[i], "--sendfile", NULL)) { - config->use_sendfile = true; - log_info_message(LOG_INFO_MISC, "Enabled sendfile"); - } else if (opt_is(argv[i], "-j", "--threads")) { - config->use_multithreading = true; - log_info_message(LOG_INFO_MISC, "Enabled Multithreading"); - } else if (opt_is(argv[i], "--chunk-serialization", NULL)) { - config->use_chunk_serialization = true; - log_info_message(LOG_INFO_MISC, "Enabled Chunk Serialization"); - } else if (opt_is(argv[i], "--server-port", NULL)) { - if (i + 1 >= argc) { - log_message(LOG_LEVEL_ERROR, "missing argument for %s", argv[i]); - return -1; - } - if (!parse_positive_int(argv[++i], &config->server_port)) { - char* escaped = output_escape(argv[i], false); - log_message(LOG_LEVEL_ERROR, "invalid --server-port value: %s", - escaped ? escaped : ""); - free(escaped); - return -1; - } - if (config->server_port > 65535) { - log_message(LOG_LEVEL_ERROR, "server port must be 1-65535"); - return -1; - } - } else if (opt_is(argv[i], "--bwlimit", NULL)) { - if (i + 1 >= argc) { - log_message(LOG_LEVEL_ERROR, "missing argument for %s", argv[i]); - return -1; - } - unsigned long long kbps; - if (parse_ull_arg(argv[++i], &kbps, "--bwlimit") != 0) - return -1; - if (kbps == 0) { - log_message(LOG_LEVEL_ERROR, "--bwlimit must be a positive integer"); - return -1; - } - if (kbps > ULLONG_MAX / 1024) { - log_message(LOG_LEVEL_ERROR, "--bwlimit value too large"); - return -1; - } - io_set_bwlimit(kbps * 1024); - log_info_message(LOG_INFO_MISC, "Set bandwidth limit to %llu KB/s", kbps); - } else if (opt_is(argv[i], "--chunk-size", NULL)) { - if (i + 1 >= argc) { - log_message(LOG_LEVEL_ERROR, "missing argument for %s", argv[i]); - return -1; - } - unsigned long long val; - if (parse_ull_arg(argv[++i], &val, "--chunk-size") != 0) - return -1; - if (val == 0) { - log_message(LOG_LEVEL_ERROR, "--chunk-size must be a positive integer"); - return -1; - } - config->chunk_size = val; - } else if (opt_is(argv[i], "--log-file", NULL)) { - if (i + 1 >= argc) { - log_message(LOG_LEVEL_ERROR, "missing argument for %s", argv[i]); - return -1; - } - if (config->log_file) { - fclose(config->log_file); - config->log_file = NULL; - log_set_file(NULL); - } - FILE* lf = fopen(argv[++i], "a"); - if (!lf) { - char* escaped = output_escape(argv[i], false); - log_message(LOG_LEVEL_ERROR, "could not open log file '%s': %s", - escaped ? escaped : "", strerror(errno)); - free(escaped); - return -1; - } - config->log_file = lf; - log_set_file(lf); - } else if (strncmp(argv[i], "--stderr=", 9) == 0) { - if (set_stderr_mode(argv[i] + 9) != 0) - return -1; - } else if (opt_is(argv[i], "--stderr", NULL)) { - if (i + 1 >= argc || set_stderr_mode(argv[++i]) != 0) - return -1; - } else if (opt_is(argv[i], "--exclude-from", NULL)) { - if (i + 1 >= argc) { - log_message(LOG_LEVEL_ERROR, "missing argument for %s", argv[i]); - return -1; - } - if (read_patterns_from_file(argv[++i], &config->exclude_patterns, &config->exclude_count) != - 0) - return -1; - } else if (opt_is(argv[i], "--include-from", NULL)) { - if (i + 1 >= argc) { - log_message(LOG_LEVEL_ERROR, "missing argument for %s", argv[i]); - return -1; - } - if (read_patterns_from_file(argv[++i], &config->include_patterns, &config->include_count) != - 0) - return -1; - } else if (strncmp(argv[i], "--filter=", 9) == 0) { - if (config_add_filter(config, argv[i] + 9) != 0) - return -1; - } else if (strncmp(argv[i], "-f=", 3) == 0) { - if (config_add_filter(config, argv[i] + 3) != 0) - return -1; - } else if (opt_is(argv[i], "--filter", "-f")) { - if (i + 1 >= argc) { - log_message(LOG_LEVEL_ERROR, "missing argument for %s", argv[i]); - return -1; - } - if (config_add_filter(config, argv[++i]) != 0) - return -1; - } else if (strncmp(argv[i], "--files-from=", 13) == 0) { - if (set_string_option(&config->files_from, argv[i] + 13, "--files-from") != 0) - return -1; - } else if (opt_is(argv[i], "--files-from", NULL)) { - if (i + 1 >= argc) { - log_message(LOG_LEVEL_ERROR, "missing argument for %s", argv[i]); - return -1; - } - if (set_string_option(&config->files_from, argv[++i], "--files-from") != 0) - return -1; - } else if (opt_is(argv[i], "-v", "--verbose")) { - verbose = true; - set_log_level(LOG_LEVEL_DEBUG); - } else if (opt_is(argv[i], "-q", "--quiet")) { - config->quiet = true; - } else if (strncmp(argv[i], "--debug=", 8) == 0) { - int debug_ret = parse_debug_flags(argv[i] + 8, config); - if (debug_ret != 0) - return debug_ret; - } else if (opt_is(argv[i], "--debug", NULL)) { - if (i + 1 >= argc) - return parse_debug_flags(NULL, config); - int debug_ret = parse_debug_flags(argv[++i], config); - if (debug_ret != 0) - return debug_ret; - } else if (strncmp(argv[i], "--info=", 7) == 0) { - if (parse_info_flags(argv[i] + 7, config) != 0) - return -1; - } else if (opt_is(argv[i], "--info", NULL)) { - if (i + 1 >= argc || parse_info_flags(argv[++i], config) != 0) - return -1; - } else if (strncmp(argv[i], "--skip-compress=", 16) == 0) { - if (parse_skip_compress(config, argv[i] + 16) != 0) - return -1; - } else if (opt_is(argv[i], "--skip-compress", NULL)) { - if (i + 1 >= argc) { - log_message(LOG_LEVEL_ERROR, "missing argument for %s", argv[i]); - return -1; - } - if (parse_skip_compress(config, argv[++i]) != 0) - return -1; - } else if (opt_is(argv[i], "--compress-threads", NULL)) { - if (i + 1 >= argc) { - log_message(LOG_LEVEL_ERROR, "missing argument for %s", argv[i]); - return -1; - } - if (set_compression_threads_option(&config->compression_threads, argv[++i]) != 0) - return -1; - } else if (opt_is(argv[i], "--checksum-choice", "--cc")) { - if (i + 1 >= argc) { - log_message(LOG_LEVEL_ERROR, "missing argument for %s", argv[i]); - return -1; - } - if (set_checksum_choice(config, argv[++i]) != 0) - return -1; - } else if (strncmp(argv[i], "--checksum-choice=", 18) == 0) { - if (set_checksum_choice(config, argv[i] + 18) != 0) - return -1; - } else if (strncmp(argv[i], "--cc=", 5) == 0) { - if (set_checksum_choice(config, argv[i] + 5) != 0) - return -1; - } else if (strncmp(argv[i], "--checksum-seed=", 16) == 0) { - if (set_checksum_seed(config, argv[i] + 16) != 0) - return -1; - } else if (opt_is(argv[i], "--checksum-seed", NULL)) { - if (i + 1 >= argc) { - log_message(LOG_LEVEL_ERROR, "missing argument for --checksum-seed"); - return -1; - } - if (set_checksum_seed(config, argv[++i]) != 0) - return -1; - } else if (strncmp(argv[i], "--sockopts=", 11) == 0) { - if (set_sockopts_option(config, argv[i] + 11) != 0) - return -1; - } else if (opt_is(argv[i], "--sockopts", NULL)) { - if (i + 1 >= argc) { - log_message(LOG_LEVEL_ERROR, "missing argument for --sockopts"); - return -1; - } - if (set_sockopts_option(config, argv[++i]) != 0) - return -1; - } else if (strncmp(argv[i], "--remote-option=", 16) == 0) { - if (config_add_remote_option(config, argv[i] + 16, "--remote-option") != 0) - return -1; - } else if (strncmp(argv[i], "-M=", 3) == 0) { - if (config_add_remote_option(config, argv[i] + 3, "-M") != 0) - return -1; - } else if (opt_is(argv[i], "--remote-option", "-M")) { - if (i + 1 >= argc) { - log_message(LOG_LEVEL_ERROR, "missing argument for --remote-option"); - return -1; - } - if (config_add_remote_option(config, argv[++i], "--remote-option") != 0) - return -1; - } else if (strncmp(argv[i], "--compare-dest=", 15) == 0) { - if (set_basis_dest_option(config, BASIS_DEST_COMPARE, argv[i] + 15, "--compare-dest") != 0) - return -1; - } else if (opt_is(argv[i], "--compare-dest", NULL)) { - if (i + 1 >= argc) { - log_message(LOG_LEVEL_ERROR, "missing argument for %s", argv[i]); - return -1; - } - if (set_basis_dest_option(config, BASIS_DEST_COMPARE, argv[++i], "--compare-dest") != 0) - return -1; - } else if (strncmp(argv[i], "--copy-dest=", 12) == 0) { - if (set_basis_dest_option(config, BASIS_DEST_COPY, argv[i] + 12, "--copy-dest") != 0) - return -1; - } else if (opt_is(argv[i], "--copy-dest", NULL)) { - if (i + 1 >= argc) { - log_message(LOG_LEVEL_ERROR, "missing argument for %s", argv[i]); - return -1; - } - if (set_basis_dest_option(config, BASIS_DEST_COPY, argv[++i], "--copy-dest") != 0) - return -1; - } else if (strncmp(argv[i], "--link-dest=", 12) == 0) { - if (set_basis_dest_option(config, BASIS_DEST_LINK, argv[i] + 12, "--link-dest") != 0) - return -1; - } else if (opt_is(argv[i], "--link-dest", NULL)) { - if (i + 1 >= argc) { - log_message(LOG_LEVEL_ERROR, "missing argument for %s", argv[i]); - return -1; - } - if (set_basis_dest_option(config, BASIS_DEST_LINK, argv[++i], "--link-dest") != 0) - return -1; - } else if (strncmp(argv[i], "--usermap=", 10) == 0) { - if (identity_parse_map(config, argv[i] + 10, false) != 0) - return -1; - config->use_metadata = true; - } else if (opt_is(argv[i], "--usermap", NULL)) { - if (i + 1 >= argc) { - log_message(LOG_LEVEL_ERROR, "missing argument for %s", argv[i]); - return -1; - } - if (identity_parse_map(config, argv[++i], false) != 0) - return -1; - config->use_metadata = true; - } else if (strncmp(argv[i], "--groupmap=", 11) == 0) { - if (identity_parse_map(config, argv[i] + 11, true) != 0) - return -1; - config->use_metadata = true; - } else if (opt_is(argv[i], "--groupmap", NULL)) { - if (i + 1 >= argc) { - log_message(LOG_LEVEL_ERROR, "missing argument for %s", argv[i]); - return -1; - } - if (identity_parse_map(config, argv[++i], true) != 0) - return -1; - config->use_metadata = true; - } else if (strncmp(argv[i], "--chown=", 8) == 0) { - if (identity_parse_chown(config, argv[i] + 8) != 0) - return -1; - config->use_metadata = true; - } else if (opt_is(argv[i], "--chown", NULL)) { - if (i + 1 >= argc) { - log_message(LOG_LEVEL_ERROR, "missing argument for %s", argv[i]); - return -1; - } - if (identity_parse_chown(config, argv[++i]) != 0) - return -1; - config->use_metadata = true; - } else if (strncmp(argv[i], "--copy-as=", 10) == 0) { - if (identity_parse_copy_as(config, argv[i] + 10) != 0) - return -1; - } else if (opt_is(argv[i], "--copy-as", NULL)) { - if (i + 1 >= argc) { - log_message(LOG_LEVEL_ERROR, "missing argument for %s", argv[i]); - return -1; - } - if (identity_parse_copy_as(config, argv[++i]) != 0) - return -1; - } else if (strncmp(argv[i], "--outbuf=", 9) == 0) { - if (set_outbuf_option(config, argv[i] + 9) != 0) - return -1; - } else if (opt_is(argv[i], "--outbuf", NULL)) { - if (i + 1 >= argc || set_outbuf_option(config, argv[++i]) != 0) - return -1; - } else if (argv[i][0] == '-') { - char* escaped = output_escape(argv[i], false); - fprintf(stderr, "Unknown option: %s\n", escaped ? escaped : ""); - free(escaped); - print_usage(); - return -1; - } else { - if (*positional_count < 2) - positional_args[(*positional_count)++] = i; - else { - char* escaped = output_escape(argv[i], false); - fprintf(stderr, "Unexpected argument: %s\n", escaped ? escaped : ""); - free(escaped); - print_usage(); - return -1; - } - } +/* Options handled before the generic --no-* negation branch: -P and the real + * rsync option names that merely start with "--no-" (--no-implied-dirs, + * --no-motd, --no-super), plus the generic negation itself. Returns true when + * the argument was consumed. */ +static bool cli_handle_pre_negation(CliParseCtx* ctx) { + Config* config = ctx->config; + const char* arg = ctx->argv[ctx->i]; + if (strcmp(arg, "-P") == 0) { + config->partial = true; + config->show_progress = true; + return true; } + /* "--no-implied-dirs" is a real rsync option name, not a negation of + * "--implied-dirs", so it must be handled before the generic --no-* + * negation branch. */ + if (strcmp(arg, "--no-implied-dirs") == 0) { + config->no_implied_dirs = true; + return true; + } + /* "--no-motd" is a real rsync option name (client-side daemon MOTD display + * suppression), not a negation of a "--motd" flag, so it is handled before + * the generic --no-* negation branch. */ + if (strcmp(arg, "--no-motd") == 0) { + config->no_motd = true; + return true; + } + /* "--super" / "--no-super" are real rsync option names controlling the + * receiver's super-user activity policy (ownership, device nodes), not a + * Boolean pair for the generic --no-* negation branch: both map onto the + * Config->super_mode tri-state. Handle them explicitly (exact match only, + * so a malformed "--super=x" still falls through to the unknown-option + * error) before the generic negation branch would mis-reject "--no-super". */ + if (strcmp(arg, "--super") == 0) { + config->super_mode = SUPER_MODE_ON; + return true; + } + if (strcmp(arg, "--no-super") == 0) { + config->super_mode = SUPER_MODE_OFF; + return true; + } + if (strncmp(arg, "--no-", strlen("--no-")) == 0) { + if (strcmp(arg, "--no-delta") == 0) + ctx->no_delta = true; + else if (strcmp(arg, "--no-incremental") == 0) + ctx->no_incremental = true; + if (apply_negation(config, arg) != 0) { + ctx->exit_code = -1; + return true; + } + return true; + } + return false; +} + +/* Numeric/range/time options with dedicated prefixes: --modify-window, -@, + * --stop-after, --stop-at, --compress-threads and --max-alloc. Returns true + * when the argument was consumed. */ +static bool cli_handle_range_time_options(CliParseCtx* ctx) { + Config* config = ctx->config; + const char* arg = ctx->argv[ctx->i]; + const char* modify_window_prefix = "--modify-window="; + if (strncmp(arg, modify_window_prefix, strlen(modify_window_prefix)) == 0) { + if (set_nonneg_int_option(&config->modify_window, arg + strlen(modify_window_prefix), + "--modify-window") != 0) + ctx->exit_code = -1; + return true; + } + if (strncmp(arg, "-@", 2) == 0 && arg[2] != '\0') { + if (set_nonneg_int_option(&config->modify_window, arg + 2, "-@") != 0) + ctx->exit_code = -1; + return true; + } + /* --stop-after/--stop-at are client-only sender-side stop deadlines. They + * are parsed by stop_condition (so the unit tests exercise the same validate + * that production uses) and never serialized into the config frame. */ + if (strncmp(arg, "--stop-after=", 13) == 0) { + if (!stop_parse_after_minutes(arg + 13, &config->stop_after_mins)) { + log_message(LOG_LEVEL_ERROR, "--stop-after must be a positive number of minutes"); + ctx->exit_code = -1; + } + return true; + } + if (strcmp(arg, "--stop-after") == 0) { + if (ctx->i + 1 >= ctx->argc) { + log_message(LOG_LEVEL_ERROR, "missing argument for --stop-after"); + ctx->exit_code = -1; + return true; + } + if (!stop_parse_after_minutes(ctx->argv[++ctx->i], &config->stop_after_mins)) { + log_message(LOG_LEVEL_ERROR, "--stop-after must be a positive number of minutes"); + ctx->exit_code = -1; + } + return true; + } + if (strncmp(arg, "--stop-at=", 10) == 0) { + if (!stop_parse_at_time(arg + 10, time(NULL), &config->stop_at)) { + log_message(LOG_LEVEL_ERROR, "--stop-at must be HH:MM[:SS] or now+N[smhd]"); + ctx->exit_code = -1; + return true; + } + config->stop_at_set = true; + return true; + } + if (strcmp(arg, "--stop-at") == 0) { + if (ctx->i + 1 >= ctx->argc) { + log_message(LOG_LEVEL_ERROR, "missing argument for --stop-at"); + ctx->exit_code = -1; + return true; + } + if (!stop_parse_at_time(ctx->argv[++ctx->i], time(NULL), &config->stop_at)) { + log_message(LOG_LEVEL_ERROR, "--stop-at must be HH:MM[:SS] or now+N[smhd]"); + ctx->exit_code = -1; + return true; + } + config->stop_at_set = true; + return true; + } + const char* threads_prefix = "--compress-threads="; + if (strncmp(arg, threads_prefix, strlen(threads_prefix)) == 0) { + if (set_compression_threads_option(&config->compression_threads, + arg + strlen(threads_prefix)) != 0) + ctx->exit_code = -1; + return true; + } + if (strncmp(arg, "--max-alloc=", 12) == 0 || strcmp(arg, "--max-alloc") == 0) { + const char* value = strcmp(arg, "--max-alloc") == 0 ? "" : arg + 12; + if (*value == '\0') { + if (ctx->i + 1 >= ctx->argc) { + log_message(LOG_LEVEL_ERROR, "missing argument for --max-alloc"); + ctx->exit_code = -1; + return true; + } + value = ctx->argv[++ctx->i]; + } + if (parse_size_arg(value, &config->max_alloc) != 0) { + log_message(LOG_LEVEL_ERROR, "--max-alloc must be a positive size (B, K, M, G, T, P, or E)"); + ctx->exit_code = -1; + } + return true; + } + return false; +} + +/* Options that map directly onto a Config field through OPTION_TABLE, plus the + * derived implications those options trigger. Returns true when an entry + * matched. */ +static bool cli_handle_table_option(CliParseCtx* ctx) { + Config* config = ctx->config; + const char* arg = ctx->argv[ctx->i]; + const OptionEntry* entry = find_table_option(arg); + const char* inline_value = NULL; + if (!entry) + entry = find_table_option_with_equals(arg, &inline_value); + if (!entry) + return false; + const char* value = NULL; + if (entry->kind != OPT_FLAG) { + value = inline_value; + if (!value && ctx->i + 1 < ctx->argc) + value = ctx->argv[++ctx->i]; + if (!value) { + log_message(LOG_LEVEL_ERROR, "missing argument for %s", entry->name); + ctx->exit_code = -1; + return true; + } + if (strcmp(entry->name, "--compress-choice") == 0) { + if (set_compression_choice(config, value) != 0) { + ctx->exit_code = -1; + return true; + } + } else { + if (apply_table_option(config, entry, value) != 0) { + ctx->exit_code = -1; + return true; + } + if (strcmp(entry->name, "--compress-level") == 0 && + (config->compression_level < 1 || config->compression_level > 22)) { + log_message(LOG_LEVEL_ERROR, "--compress-level must be between 1 and 22"); + ctx->exit_code = -1; + return true; + } + if (entry->offset == offsetof(Config, chmod_spec)) { + mode_t ignored; + if (!chmod_apply(0, config->chmod_spec, &ignored)) { + log_message(LOG_LEVEL_ERROR, "--chmod has invalid permission changes"); + ctx->exit_code = -1; + return true; + } + config->use_metadata = true; + } + } + } else if (apply_table_option(config, entry, NULL) != 0) { + ctx->exit_code = -1; + return true; + } + if (entry->offset == offsetof(Config, eight_bit_output)) + protocol_set_8_bit_output(true); + /* A delete-timing flag selects when --delete removes extras, so it + implies --delete exactly like the rsync options do. */ + if (entry->offset == offsetof(Config, delete_before) || + entry->offset == offsetof(Config, delete_during) || + entry->offset == offsetof(Config, delete_delay) || + entry->offset == offsetof(Config, delete_after)) + config->use_delete = true; + /* --delete-missing-args implies --ignore-missing-args (missing entries + are skipped for deletion instead of failing the run). The implication + is order-independent because it is applied over the final parsed + config. */ + if (entry->offset == offsetof(Config, delete_missing_args)) + config->ignore_missing_args = true; + /* -U/--atimes and -N/--crtimes carry their times inside the metadata + payload, which is only transmitted when use_metadata is set, so either + one implies metadata transmission. This is FastSync's broad -M bundle + (mode/mtime travel too); it does NOT enable ownership application, + which stays opt-in via the identity flags. */ + if (entry->offset == offsetof(Config, preserve_atimes) || + entry->offset == offsetof(Config, preserve_crtimes)) + config->use_metadata = true; + if (entry->offset == offsetof(Config, preserve_xattrs) || + entry->offset == offsetof(Config, preserve_acls)) { + config->use_metadata = true; + config->use_xattrs = config->preserve_acls || config->preserve_xattrs; + } + if (entry->offset == offsetof(Config, fake_super)) + config->use_metadata = true; + return true; +} + +/* The inline "--chmod=SPEC" form (kept as its own handler because it bypasses + * the table's OPT_STRING storage). Returns true when the argument was + * consumed. */ +static bool cli_handle_inline_chmod(CliParseCtx* ctx) { + Config* config = ctx->config; + const char* arg = ctx->argv[ctx->i]; + if (strncmp(arg, "--chmod=", 8) != 0) + return false; + if (set_string_option(&config->chmod_spec, arg + 8, "--chmod") != 0) { + ctx->exit_code = -1; + return true; + } + mode_t ignored; + if (!chmod_apply(0, config->chmod_spec, &ignored)) { + log_message(LOG_LEVEL_ERROR, "--chmod has invalid permission changes"); + ctx->exit_code = -1; + return true; + } + config->use_metadata = true; + return true; +} + +/* Help/version and the short archive-style flags. Returns true when the + * argument was consumed. */ +static bool cli_handle_meta_flags(CliParseCtx* ctx) { + Config* config = ctx->config; + const char* arg = ctx->argv[ctx->i]; + if (opt_is(arg, "--help", NULL)) { + print_usage(); + ctx->exit_code = 1; + return true; + } + if (opt_is(arg, "-V", "--version")) { + printf("fastsync version %s\n", PROTOCOL_VERSION); + ctx->exit_code = 1; + return true; + } + if (opt_is(arg, "-D", NULL)) { + /* rsync -D == --devices --specials. -D is otherwise unassigned in + FastSync (verified: no collision), so it is free to imply both. */ + config->preserve_devices = true; + config->preserve_specials = true; + log_info_message(LOG_INFO_MISC, "Enabled preservation of device and special files (-D)"); + return true; + } + if (opt_is(arg, "-a", "--archive")) { + /* Real rsync archive (-rlptgoD). FastSync is always recursive and always + * preserves hard-link/other transfer semantics per its own flags, so -a + * implies links, full metadata (perms/times/group/owner as FastSync's + * broad bundle), devices and specials. Compression and multithreading + * are NOT implied (they are no longer part of archive mode). */ + config->follow_symlinks = true; + config->use_metadata = true; + config->preserve_devices = true; + config->preserve_specials = true; + log_info_message(LOG_INFO_MISC, + "Enabled archive mode (-rlptgoD: links, metadata, devices, specials)"); + return true; + } + if (opt_is(arg, "-p", "--perms")) { + /* rsync -p/--perms: preserve permission bits. Folded into FastSync's + * broad metadata bundle (mode/mtime travel together). */ + config->use_metadata = true; + log_info_message(LOG_INFO_MISC, "Enabled permission preservation"); + return true; + } + return false; +} + +/* SSH port and pattern/block-size options. Returns true when the argument was + * consumed. */ +static bool cli_handle_ssh_and_pattern_options(CliParseCtx* ctx) { + Config* config = ctx->config; + const char* arg = ctx->argv[ctx->i]; + if (opt_is(arg, "--ssh-port", NULL)) { + if (ctx->i + 1 >= ctx->argc) { + log_message(LOG_LEVEL_ERROR, "missing argument for %s", arg); + ctx->exit_code = -1; + return true; + } + if (set_positive_int_option(&config->ssh_port, ctx->argv[++ctx->i], "--ssh-port") != 0) { + ctx->exit_code = -1; + return true; + } + if (config->ssh_port > 65535) { + log_message(LOG_LEVEL_ERROR, "SSH port must be 1-65535"); + ctx->exit_code = -1; + } + return true; + } + if (strncmp(arg, "--ssh-port=", 11) == 0) { + if (set_positive_int_option(&config->ssh_port, arg + 11, "--ssh-port") != 0) { + ctx->exit_code = -1; + return true; + } + if (config->ssh_port > 65535) { + log_message(LOG_LEVEL_ERROR, "SSH port must be 1-65535"); + ctx->exit_code = -1; + } + return true; + } + if (opt_is(arg, "--exclude", NULL)) { + if (ctx->i + 1 >= ctx->argc) { + log_message(LOG_LEVEL_ERROR, "missing argument for %s", arg); + ctx->exit_code = -1; + return true; + } + if (config_add_pattern(&config->exclude_patterns, &config->exclude_count, ctx->argv[++ctx->i], + "--exclude") != 0) + ctx->exit_code = -1; + return true; + } + if (opt_is(arg, "--include", NULL)) { + if (ctx->i + 1 >= ctx->argc) { + log_message(LOG_LEVEL_ERROR, "missing argument for %s", arg); + ctx->exit_code = -1; + return true; + } + if (config_add_pattern(&config->include_patterns, &config->include_count, ctx->argv[++ctx->i], + "--include") != 0) + ctx->exit_code = -1; + return true; + } + if (strncmp(arg, "--delta-block=", 14) == 0) { + if (set_delta_block_size(config, arg + 14) != 0) + ctx->exit_code = -1; + return true; + } + if (strncmp(arg, "--block-size=", 13) == 0) { + if (set_delta_block_size(config, arg + 13) != 0) + ctx->exit_code = -1; + return true; + } + if (opt_is(arg, "--delta-block", "--block-size")) { + if (ctx->i + 1 >= ctx->argc) { + log_message(LOG_LEVEL_ERROR, "missing argument for %s", arg); + ctx->exit_code = -1; + return true; + } + if (set_delta_block_size(config, ctx->argv[++ctx->i]) != 0) + ctx->exit_code = -1; + return true; + } + if (opt_is(arg, "--delta-max", NULL)) { + if (ctx->i + 1 >= ctx->argc) { + log_message(LOG_LEVEL_ERROR, "missing argument for %s", arg); + ctx->exit_code = -1; + return true; + } + unsigned long long val; + if (parse_ull_arg(ctx->argv[++ctx->i], &val, "--delta-max") != 0) { + ctx->exit_code = -1; + return true; + } + if (val >= DELTA_MIN_FILE_SIZE) + config->delta_max_file_size = val; + else + log_message(LOG_LEVEL_WARNING, "--delta-max value %llu too small, using default", val); + return true; + } + return false; +} + +/* Transfer-behavior flags that only toggle a Config field (plus their info + * log lines). Returns true when the argument was consumed. */ +static bool cli_handle_transfer_flags(CliParseCtx* ctx) { + Config* config = ctx->config; + const char* arg = ctx->argv[ctx->i]; + if (opt_is(arg, "-z", "--compress")) { + config->use_compression = + !config->compress_choice || strcmp(config->compress_choice, "zstd") == 0; + log_info_message(LOG_INFO_MISC, "Enabled Compression"); + if (ctx->i + 1 < ctx->argc) { + char* end_ptr; + long level = strtol(ctx->argv[ctx->i + 1], &end_ptr, 10); + if (*end_ptr == '\0') { + if (level < 1 || level > 22) { + log_message(LOG_LEVEL_ERROR, "compression level must be 1-22"); + ctx->exit_code = -1; + return true; + } + config->compression_level = (int)level; + log_info_message(LOG_INFO_MISC, "Set Compression level to %ld", level); + ctx->i++; + } + } + return true; + } + if (opt_is(arg, "--preserve", NULL)) { + config->use_metadata = true; + log_info_message(LOG_INFO_MISC, "Enabled metadata preservation"); + return true; + } + if (opt_is(arg, "-E", "--executability")) { + config->use_metadata = true; + config->use_executability = true; + log_info_message(LOG_INFO_MISC, "Enabled executable permission preservation"); + return true; + } + if (opt_is(arg, "--sendfile", NULL)) { + config->use_sendfile = true; + log_info_message(LOG_INFO_MISC, "Enabled sendfile"); + return true; + } + if (opt_is(arg, "-j", "--threads")) { + config->use_multithreading = true; + log_info_message(LOG_INFO_MISC, "Enabled Multithreading"); + return true; + } + if (opt_is(arg, "--chunk-serialization", NULL)) { + config->use_chunk_serialization = true; + log_info_message(LOG_INFO_MISC, "Enabled Chunk Serialization"); + return true; + } + return false; +} + +/* Network/IO options: --server-port, --bwlimit, --chunk-size, --log-file and + * --stderr. Returns true when the argument was consumed. */ +static bool cli_handle_io_options(CliParseCtx* ctx) { + Config* config = ctx->config; + const char* arg = ctx->argv[ctx->i]; + if (opt_is(arg, "--server-port", NULL)) { + if (ctx->i + 1 >= ctx->argc) { + log_message(LOG_LEVEL_ERROR, "missing argument for %s", arg); + ctx->exit_code = -1; + return true; + } + if (!parse_positive_int(ctx->argv[++ctx->i], &config->server_port)) { + char* escaped = output_escape(ctx->argv[ctx->i], false); + log_message(LOG_LEVEL_ERROR, "invalid --server-port value: %s", + escaped ? escaped : ""); + free(escaped); + ctx->exit_code = -1; + return true; + } + if (config->server_port > 65535) { + log_message(LOG_LEVEL_ERROR, "server port must be 1-65535"); + ctx->exit_code = -1; + } + return true; + } + if (opt_is(arg, "--bwlimit", NULL)) { + if (ctx->i + 1 >= ctx->argc) { + log_message(LOG_LEVEL_ERROR, "missing argument for %s", arg); + ctx->exit_code = -1; + return true; + } + unsigned long long kbps; + if (parse_ull_arg(ctx->argv[++ctx->i], &kbps, "--bwlimit") != 0) { + ctx->exit_code = -1; + return true; + } + if (kbps == 0) { + log_message(LOG_LEVEL_ERROR, "--bwlimit must be a positive integer"); + ctx->exit_code = -1; + return true; + } + if (kbps > ULLONG_MAX / 1024) { + log_message(LOG_LEVEL_ERROR, "--bwlimit value too large"); + ctx->exit_code = -1; + return true; + } + io_set_bwlimit(kbps * 1024); + log_info_message(LOG_INFO_MISC, "Set bandwidth limit to %llu KB/s", kbps); + return true; + } + if (opt_is(arg, "--chunk-size", NULL)) { + if (ctx->i + 1 >= ctx->argc) { + log_message(LOG_LEVEL_ERROR, "missing argument for %s", arg); + ctx->exit_code = -1; + return true; + } + unsigned long long val; + if (parse_ull_arg(ctx->argv[++ctx->i], &val, "--chunk-size") != 0) { + ctx->exit_code = -1; + return true; + } + if (val == 0) { + log_message(LOG_LEVEL_ERROR, "--chunk-size must be a positive integer"); + ctx->exit_code = -1; + return true; + } + config->chunk_size = val; + return true; + } + if (opt_is(arg, "--log-file", NULL)) { + if (ctx->i + 1 >= ctx->argc) { + log_message(LOG_LEVEL_ERROR, "missing argument for %s", arg); + ctx->exit_code = -1; + return true; + } + if (config->log_file) { + fclose(config->log_file); + config->log_file = NULL; + log_set_file(NULL); + } + FILE* lf = fopen(ctx->argv[++ctx->i], "a"); + if (!lf) { + char* escaped = output_escape(ctx->argv[ctx->i], false); + log_message(LOG_LEVEL_ERROR, "could not open log file '%s': %s", + escaped ? escaped : "", strerror(errno)); + free(escaped); + ctx->exit_code = -1; + return true; + } + config->log_file = lf; + log_set_file(lf); + return true; + } + if (strncmp(arg, "--stderr=", 9) == 0) { + if (set_stderr_mode(arg + 9) != 0) + ctx->exit_code = -1; + return true; + } + if (opt_is(arg, "--stderr", NULL)) { + if (ctx->i + 1 >= ctx->argc || set_stderr_mode(ctx->argv[++ctx->i]) != 0) + ctx->exit_code = -1; + return true; + } + return false; +} + +/* Filter / files-from options. Returns true when the argument was consumed. */ +static bool cli_handle_filter_options(CliParseCtx* ctx) { + Config* config = ctx->config; + const char* arg = ctx->argv[ctx->i]; + if (opt_is(arg, "--exclude-from", NULL)) { + if (ctx->i + 1 >= ctx->argc) { + log_message(LOG_LEVEL_ERROR, "missing argument for %s", arg); + ctx->exit_code = -1; + return true; + } + if (read_patterns_from_file(ctx->argv[++ctx->i], &config->exclude_patterns, + &config->exclude_count) != 0) + ctx->exit_code = -1; + return true; + } + if (opt_is(arg, "--include-from", NULL)) { + if (ctx->i + 1 >= ctx->argc) { + log_message(LOG_LEVEL_ERROR, "missing argument for %s", arg); + ctx->exit_code = -1; + return true; + } + if (read_patterns_from_file(ctx->argv[++ctx->i], &config->include_patterns, + &config->include_count) != 0) + ctx->exit_code = -1; + return true; + } + if (strncmp(arg, "--filter=", 9) == 0) { + if (config_add_filter(config, arg + 9) != 0) + ctx->exit_code = -1; + return true; + } + if (strncmp(arg, "-f=", 3) == 0) { + if (config_add_filter(config, arg + 3) != 0) + ctx->exit_code = -1; + return true; + } + if (opt_is(arg, "--filter", "-f")) { + if (ctx->i + 1 >= ctx->argc) { + log_message(LOG_LEVEL_ERROR, "missing argument for %s", arg); + ctx->exit_code = -1; + return true; + } + if (config_add_filter(config, ctx->argv[++ctx->i]) != 0) + ctx->exit_code = -1; + return true; + } + if (strncmp(arg, "--files-from=", 13) == 0) { + if (set_string_option(&config->files_from, arg + 13, "--files-from") != 0) + ctx->exit_code = -1; + return true; + } + if (opt_is(arg, "--files-from", NULL)) { + if (ctx->i + 1 >= ctx->argc) { + log_message(LOG_LEVEL_ERROR, "missing argument for %s", arg); + ctx->exit_code = -1; + return true; + } + if (set_string_option(&config->files_from, ctx->argv[++ctx->i], "--files-from") != 0) + ctx->exit_code = -1; + return true; + } + return false; +} + +/* Logging/verbosity options: -v/--verbose, -q/--quiet, --debug, --info and + * --skip-compress. Returns true when the argument was consumed. */ +static bool cli_handle_logging_options(CliParseCtx* ctx) { + Config* config = ctx->config; + const char* arg = ctx->argv[ctx->i]; + if (opt_is(arg, "-v", "--verbose")) { + ctx->verbose = true; + set_log_level(LOG_LEVEL_DEBUG); + return true; + } + if (opt_is(arg, "-q", "--quiet")) { + config->quiet = true; + return true; + } + if (strncmp(arg, "--debug=", 8) == 0) { + int debug_ret = parse_debug_flags(arg + 8, config); + if (debug_ret != 0) + ctx->exit_code = debug_ret; + return true; + } + if (opt_is(arg, "--debug", NULL)) { + if (ctx->i + 1 >= ctx->argc) { + ctx->exit_code = parse_debug_flags(NULL, config); + return true; + } + int debug_ret = parse_debug_flags(ctx->argv[++ctx->i], config); + if (debug_ret != 0) + ctx->exit_code = debug_ret; + return true; + } + if (strncmp(arg, "--info=", 7) == 0) { + if (parse_info_flags(arg + 7, config) != 0) + ctx->exit_code = -1; + return true; + } + if (opt_is(arg, "--info", NULL)) { + if (ctx->i + 1 >= ctx->argc || parse_info_flags(ctx->argv[++ctx->i], config) != 0) + ctx->exit_code = -1; + return true; + } + if (strncmp(arg, "--skip-compress=", 16) == 0) { + if (parse_skip_compress(config, arg + 16) != 0) + ctx->exit_code = -1; + return true; + } + if (opt_is(arg, "--skip-compress", NULL)) { + if (ctx->i + 1 >= ctx->argc) { + log_message(LOG_LEVEL_ERROR, "missing argument for %s", arg); + ctx->exit_code = -1; + return true; + } + if (parse_skip_compress(config, ctx->argv[++ctx->i]) != 0) + ctx->exit_code = -1; + return true; + } + return false; +} + +/* Checksum/socket options: --compress-threads, --checksum-choice, --cc, + * --checksum-seed and --sockopts. Returns true when the argument was + * consumed. */ +static bool cli_handle_checksum_options(CliParseCtx* ctx) { + Config* config = ctx->config; + const char* arg = ctx->argv[ctx->i]; + if (opt_is(arg, "--compress-threads", NULL)) { + if (ctx->i + 1 >= ctx->argc) { + log_message(LOG_LEVEL_ERROR, "missing argument for %s", arg); + ctx->exit_code = -1; + return true; + } + if (set_compression_threads_option(&config->compression_threads, ctx->argv[++ctx->i]) != 0) + ctx->exit_code = -1; + return true; + } + if (opt_is(arg, "--checksum-choice", "--cc")) { + if (ctx->i + 1 >= ctx->argc) { + log_message(LOG_LEVEL_ERROR, "missing argument for %s", arg); + ctx->exit_code = -1; + return true; + } + if (set_checksum_choice(config, ctx->argv[++ctx->i]) != 0) + ctx->exit_code = -1; + return true; + } + if (strncmp(arg, "--checksum-choice=", 18) == 0) { + if (set_checksum_choice(config, arg + 18) != 0) + ctx->exit_code = -1; + return true; + } + if (strncmp(arg, "--cc=", 5) == 0) { + if (set_checksum_choice(config, arg + 5) != 0) + ctx->exit_code = -1; + return true; + } + if (strncmp(arg, "--checksum-seed=", 16) == 0) { + if (set_checksum_seed(config, arg + 16) != 0) + ctx->exit_code = -1; + return true; + } + if (opt_is(arg, "--checksum-seed", NULL)) { + if (ctx->i + 1 >= ctx->argc) { + log_message(LOG_LEVEL_ERROR, "missing argument for --checksum-seed"); + ctx->exit_code = -1; + return true; + } + if (set_checksum_seed(config, ctx->argv[++ctx->i]) != 0) + ctx->exit_code = -1; + return true; + } + if (strncmp(arg, "--sockopts=", 11) == 0) { + if (set_sockopts_option(config, arg + 11) != 0) + ctx->exit_code = -1; + return true; + } + if (opt_is(arg, "--sockopts", NULL)) { + if (ctx->i + 1 >= ctx->argc) { + log_message(LOG_LEVEL_ERROR, "missing argument for --sockopts"); + ctx->exit_code = -1; + return true; + } + if (set_sockopts_option(config, ctx->argv[++ctx->i]) != 0) + ctx->exit_code = -1; + return true; + } + return false; +} + +/* Remote-option, basis-directory and identity-mapping options. Returns true + * when the argument was consumed. */ +static bool cli_handle_remote_basis_options(CliParseCtx* ctx) { + Config* config = ctx->config; + const char* arg = ctx->argv[ctx->i]; + if (strncmp(arg, "--remote-option=", 16) == 0) { + if (config_add_remote_option(config, arg + 16, "--remote-option") != 0) + ctx->exit_code = -1; + return true; + } + if (strncmp(arg, "-M=", 3) == 0) { + if (config_add_remote_option(config, arg + 3, "-M") != 0) + ctx->exit_code = -1; + return true; + } + if (opt_is(arg, "--remote-option", "-M")) { + if (ctx->i + 1 >= ctx->argc) { + log_message(LOG_LEVEL_ERROR, "missing argument for --remote-option"); + ctx->exit_code = -1; + return true; + } + if (config_add_remote_option(config, ctx->argv[++ctx->i], "--remote-option") != 0) + ctx->exit_code = -1; + return true; + } + if (strncmp(arg, "--compare-dest=", 15) == 0) { + if (set_basis_dest_option(config, BASIS_DEST_COMPARE, arg + 15, "--compare-dest") != 0) + ctx->exit_code = -1; + return true; + } + if (opt_is(arg, "--compare-dest", NULL)) { + if (ctx->i + 1 >= ctx->argc) { + log_message(LOG_LEVEL_ERROR, "missing argument for %s", arg); + ctx->exit_code = -1; + return true; + } + if (set_basis_dest_option(config, BASIS_DEST_COMPARE, ctx->argv[++ctx->i], "--compare-dest") != + 0) + ctx->exit_code = -1; + return true; + } + if (strncmp(arg, "--copy-dest=", 12) == 0) { + if (set_basis_dest_option(config, BASIS_DEST_COPY, arg + 12, "--copy-dest") != 0) + ctx->exit_code = -1; + return true; + } + if (opt_is(arg, "--copy-dest", NULL)) { + if (ctx->i + 1 >= ctx->argc) { + log_message(LOG_LEVEL_ERROR, "missing argument for %s", arg); + ctx->exit_code = -1; + return true; + } + if (set_basis_dest_option(config, BASIS_DEST_COPY, ctx->argv[++ctx->i], "--copy-dest") != 0) + ctx->exit_code = -1; + return true; + } + if (strncmp(arg, "--link-dest=", 12) == 0) { + if (set_basis_dest_option(config, BASIS_DEST_LINK, arg + 12, "--link-dest") != 0) + ctx->exit_code = -1; + return true; + } + if (opt_is(arg, "--link-dest", NULL)) { + if (ctx->i + 1 >= ctx->argc) { + log_message(LOG_LEVEL_ERROR, "missing argument for %s", arg); + ctx->exit_code = -1; + return true; + } + if (set_basis_dest_option(config, BASIS_DEST_LINK, ctx->argv[++ctx->i], "--link-dest") != 0) + ctx->exit_code = -1; + return true; + } + if (strncmp(arg, "--usermap=", 10) == 0) { + if (identity_parse_map(config, arg + 10, false) != 0) { + ctx->exit_code = -1; + return true; + } + config->use_metadata = true; + return true; + } + if (opt_is(arg, "--usermap", NULL)) { + if (ctx->i + 1 >= ctx->argc) { + log_message(LOG_LEVEL_ERROR, "missing argument for %s", arg); + ctx->exit_code = -1; + return true; + } + if (identity_parse_map(config, ctx->argv[++ctx->i], false) != 0) { + ctx->exit_code = -1; + return true; + } + config->use_metadata = true; + return true; + } + if (strncmp(arg, "--groupmap=", 11) == 0) { + if (identity_parse_map(config, arg + 11, true) != 0) { + ctx->exit_code = -1; + return true; + } + config->use_metadata = true; + return true; + } + if (opt_is(arg, "--groupmap", NULL)) { + if (ctx->i + 1 >= ctx->argc) { + log_message(LOG_LEVEL_ERROR, "missing argument for %s", arg); + ctx->exit_code = -1; + return true; + } + if (identity_parse_map(config, ctx->argv[++ctx->i], true) != 0) { + ctx->exit_code = -1; + return true; + } + config->use_metadata = true; + return true; + } + if (strncmp(arg, "--chown=", 8) == 0) { + if (identity_parse_chown(config, arg + 8) != 0) { + ctx->exit_code = -1; + return true; + } + config->use_metadata = true; + return true; + } + if (opt_is(arg, "--chown", NULL)) { + if (ctx->i + 1 >= ctx->argc) { + log_message(LOG_LEVEL_ERROR, "missing argument for %s", arg); + ctx->exit_code = -1; + return true; + } + if (identity_parse_chown(config, ctx->argv[++ctx->i]) != 0) { + ctx->exit_code = -1; + return true; + } + config->use_metadata = true; + return true; + } + if (strncmp(arg, "--copy-as=", 10) == 0) { + if (identity_parse_copy_as(config, arg + 10) != 0) + ctx->exit_code = -1; + return true; + } + if (opt_is(arg, "--copy-as", NULL)) { + if (ctx->i + 1 >= ctx->argc) { + log_message(LOG_LEVEL_ERROR, "missing argument for %s", arg); + ctx->exit_code = -1; + return true; + } + if (identity_parse_copy_as(config, ctx->argv[++ctx->i]) != 0) + ctx->exit_code = -1; + return true; + } + return false; +} + +/* --outbuf. Returns true when the argument was consumed. */ +static bool cli_handle_outbuf_option(CliParseCtx* ctx) { + Config* config = ctx->config; + const char* arg = ctx->argv[ctx->i]; + if (strncmp(arg, "--outbuf=", 9) == 0) { + if (set_outbuf_option(config, arg + 9) != 0) + ctx->exit_code = -1; + return true; + } + if (opt_is(arg, "--outbuf", NULL)) { + if (ctx->i + 1 >= ctx->argc || set_outbuf_option(config, ctx->argv[++ctx->i]) != 0) + ctx->exit_code = -1; + return true; + } + return false; +} + +/* Post-parse lowering: derive implied options over the final parsed config and + * load --files-from once every argument has been seen. Returns 0 on success, + * -1 on error. */ +static int cli_finalize_config(Config* config, bool verbose, bool no_delta, bool no_incremental) { set_log_level(config->quiet ? LOG_LEVEL_ERROR : (verbose ? LOG_LEVEL_DEBUG : LOG_LEVEL_WARNING)); if (config->compress_choice) config->use_compression = strcmp(config->compress_choice, "zstd") == 0; @@ -1538,6 +1843,63 @@ int parse_args(Config* config, int argc, char* argv[], int* positional_args, return 0; } +/* Parse CLI arguments into config. Returns 0 on success, -1 on error, 1 for help/clean-exit. */ +int parse_args(Config* config, int argc, char* argv[], int* positional_args, + int* positional_count) { + protocol_set_8_bit_output(config->eight_bit_output); + + if (cli_apply_output_controls(config, argc, argv) != 0) + return -1; + + CliParseCtx ctx = { + .config = config, + .argc = argc, + .argv = argv, + .positional_args = positional_args, + .positional_count = positional_count, + .i = 1, + .exit_code = 0, + .verbose = false, + .no_delta = false, + .no_incremental = false, + }; + + for (ctx.i = 1; ctx.i < argc; ctx.i++) { + ctx.exit_code = 0; + bool handled = cli_handle_pre_negation(&ctx) || cli_handle_range_time_options(&ctx) || + cli_handle_table_option(&ctx) || cli_handle_inline_chmod(&ctx) || + cli_handle_meta_flags(&ctx) || cli_handle_ssh_and_pattern_options(&ctx) || + cli_handle_transfer_flags(&ctx) || cli_handle_io_options(&ctx) || + cli_handle_filter_options(&ctx) || cli_handle_logging_options(&ctx) || + cli_handle_checksum_options(&ctx) || cli_handle_remote_basis_options(&ctx) || + cli_handle_outbuf_option(&ctx); + if (handled) { + if (ctx.exit_code != 0) + return ctx.exit_code; + continue; + } + + if (argv[ctx.i][0] == '-') { + char* escaped = output_escape(argv[ctx.i], false); + fprintf(stderr, "Unknown option: %s\n", escaped ? escaped : ""); + free(escaped); + print_usage(); + return -1; + } + if (*positional_count < 2) + positional_args[(*positional_count)++] = ctx.i; + else { + char* escaped = output_escape(argv[ctx.i], false); + fprintf(stderr, "Unexpected argument: %s\n", escaped ? escaped : ""); + free(escaped); + print_usage(); + return -1; + } + } + + return cli_finalize_config(config, ctx.verbose, ctx.no_delta, ctx.no_incremental); +} + static int read_patterns_from_file(const char* filepath, char*** patterns, int* count) { FILE* fp = fopen(filepath, "r"); if (!fp) { From 84b7cb0de3620f614955669db8397bf0616c1997 Mon Sep 17 00:00:00 2001 From: TapTap Date: Sat, 12 Sep 2026 20:56:33 +0200 Subject: [PATCH 4/5] refactor(server): split server_module_gate into ordered helper stages --- src/server/server.c | 273 ++++++++++++++++++++++++++------------------ 1 file changed, 165 insertions(+), 108 deletions(-) diff --git a/src/server/server.c b/src/server/server.c index 066b817..77b1e8b 100644 --- a/src/server/server.c +++ b/src/server/server.c @@ -251,6 +251,155 @@ static bool configure_authorization(const char* root) { return true; } +/* Discriminates the outcome of the A7 auth gate so the dispatcher can map it + * back to the config_receive_with_validate contract: accepted (including + * "module needs no auth"), a config-level refusal carrying an error string, or + * a handshake that already wrote its own terminal status frame. */ +typedef enum { + MODULE_AUTH_ACCEPTED = 0, + MODULE_AUTH_REFUSED, + MODULE_AUTH_TERMINATED, +} ModuleAuthResult; + +/* Looks up the daemon module selected by the client's config frame and rejects + * a `read only` one (every FastSync network transfer writes; there is no + * read-only wire operation yet). Returns the module, or NULL with *error set + * to the caller-facing rejection message. */ +static const DaemonModule* module_gate_lookup_module(const Config* config, const char** error) { + const DaemonModule* module = daemon_conf_find_module(g_daemon_conf, config->module); + if (module == NULL) { + char* escaped_module = output_escape(config->module, config->eight_bit_output); + log_message(LOG_LEVEL_ERROR, "unknown daemon module '%s' requested", + escaped_module ? escaped_module : ""); + free(escaped_module); + *error = "requested daemon module does not exist"; + return NULL; + } + if (module->read_only) { + log_message(LOG_LEVEL_ERROR, "daemon module '%s' is read only; refusing write transfer", + config->module); + *error = "requested daemon module is read only"; + return NULL; + } + return module; +} + +/* Per-module client-chosen ownership / super-user policy (P7 Wave E hardening): + * a daemon module refuses EVERY ownership-affecting request (--numeric-ids, + * --chown, --usermap/--groupmap, --fake-super, --copy-as, explicit --super) + * unless the operator opted THIS module in with `client owner = yes`. + * Otherwise any client could force arbitrary ownership inside the module root. + * The ownership check is evaluated against the ORIGINAL config so an explicit + * --super is refused even when an operator --no-super veto already forced the + * effective copy to OFF (the veto must not silently convert a refusal into an + * accept); when no ownership flag is present, super-user DEVICE activities are + * forced off for this connection instead. Returns an error string on refusal, + * NULL on acceptance. */ +static const char* module_gate_check_ownership(const Config* config, const DaemonModule* module, + ModuleGateContext* gate_ctx) { + if (module->client_owner) + return NULL; + /* Ownership: refuse the whole transfer up front (a clear failure). */ + if (identity_ownership_requested(config)) { + log_message(LOG_LEVEL_ERROR, + "daemon module '%s' refuses client-chosen ownership/super-user activities " + "(no `client owner = yes` opt-in); refusing", + config->module); + return "client-chosen ownership is not permitted by this daemon module"; + } + /* Super-user DEVICE activities (char/block mknod and --write-devices) are + permitted under the default AUTO mode, so without this override a root + daemon would still let a non-opted module create arbitrary device nodes + and write raw devices. Force them off for this connection: those entries + are skipped (never mknod'ed) while an ordinary `-a` push still succeeds + without device nodes, matching the operator's least-privilege choice. + The operator-level --no-super veto is already folded into this. */ + if (gate_ctx) + gate_ctx->super_mode_override = SUPER_MODE_OFF; + return NULL; +} + +/* A7 auth gate: runs the SCRAM challenge/response for an auth-required module + * BEFORE the module root is installed and before any data moves. Returns + * MODULE_AUTH_ACCEPTED when the module needs no auth or the handshake succeeds, + * MODULE_AUTH_REFUSED with *error set on a config-level rejection, or + * MODULE_AUTH_TERMINATED when the handshake already wrote a terminal status. */ +static ModuleAuthResult module_gate_authenticate(const Config* config, const DaemonModule* module, + ModuleGateContext* gate_ctx, const char** error) { + if (module->auth_user_count == 0) + return MODULE_AUTH_ACCEPTED; + /* Fail closed: no store -> refuse (server misconfiguration, STATUS_ERROR). */ + if (g_credentials == NULL) { + log_message(LOG_LEVEL_ERROR, + "daemon module '%s' requires authentication but no credential store is " + "configured (--password-file/--early-input); refusing", + config->module); + *error = "requested daemon module requires authentication and no credential " + "store is configured"; + return MODULE_AUTH_REFUSED; + } + /* Transport policy (A7-3/S1): an auth-required module only accepts + * credentials over (a) an encrypted, verified TLS connection whose client + * certificate matches --client-cn, or (b) an actual PLAINTEXT connection + * from a loopback peer that the operator explicitly opted into with + * --allow-unauthenticated. A remote plaintext peer, an un-flagged loopback + * plaintext peer, and a loopback TLS peer whose certificate does not match + * --client-cn are all refused HERE, before the challenge is sent, so an + * unverified client never receives a nonce: the loopback allowance requires + * !gate_ctx->ssl, so --tls + --allow-unauthenticated can never be used to + * bypass the client-CN check. The operator flag never permits REMOTE + * plaintext auth: remote peers still require verified TLS regardless. */ + bool tls_ok = gate_ctx && gate_ctx->ssl && SSL_get_verify_result(gate_ctx->ssl) == X509_V_OK && + tls_client_identity_allowed(gate_ctx->ssl); + bool local_ok = allow_unauthenticated && gate_ctx && !gate_ctx->ssl && gate_ctx->fd >= 0 && + utils_fd_peer_is_local(gate_ctx->fd); + if (!tls_ok && !local_ok) { + log_message(LOG_LEVEL_ERROR, + "daemon module '%s' requires authentication over an encrypted, verified TLS " + "connection (or an opted-in loopback plaintext transport); refusing", + config->module); + *error = "daemon module requires authentication over an encrypted, verified TLS " + "connection"; + return MODULE_AUTH_REFUSED; + } + /* Belt-and-braces: the transport policy above already guarantees a context + * with a usable socket (verified TLS implies a live SSL object and loopback + * allowance requires gate_ctx->fd >= 0), so this is unreachable today; keep + * the guard so the handshake can never be driven over an invalid fd. */ + if (!gate_ctx || gate_ctx->fd < 0) { + log_message(LOG_LEVEL_ERROR, "daemon module '%s': no auth transport available", config->module); + *error = "authentication failed for the requested daemon module"; + return MODULE_AUTH_REFUSED; + } + /* The handshake writes exactly one terminal status on failure and signals so + * via MODULE_AUTH_TERMINATED; the username may be logged (never the password + * or any derived proof). */ + if (!server_auth_handshake(gate_ctx->fd, config, module)) { + char* escaped_user = + config->auth_user ? output_escape(config->auth_user, config->eight_bit_output) : NULL; + log_message(LOG_LEVEL_ERROR, "daemon module '%s': authentication failed for user '%s'", + config->module, escaped_user ? escaped_user : "(none)"); + free(escaped_user); + return MODULE_AUTH_TERMINATED; + } + char* escaped_user = output_escape(config->auth_user, config->eight_bit_output); + log_message(LOG_LEVEL_INFO, "daemon module '%s': user '%s' authenticated", config->module, + escaped_user ? escaped_user : ""); + free(escaped_user); + return MODULE_AUTH_ACCEPTED; +} + +/* Installs the module's configured path as the connection's authorized root. + * Returns an error string when the root is unusable, NULL on success. */ +static const char* module_gate_install_root(const Config* config, const DaemonModule* module) { + if (!configure_authorization(module->path)) { + log_message(LOG_LEVEL_ERROR, "daemon module '%s' path '%s' is not usable", config->module, + module->path ? module->path : "(null)"); + return "requested daemon module root is not usable"; + } + return NULL; +} + /* Config-frame gate (runs inside config_receive_with_validate, BEFORE the * STATUS_OK ack, so a rejected connection is refused at the config handshake * and no file data is ever exchanged). @@ -324,115 +473,23 @@ static const char* server_module_gate(const Config* config, void* context) { return "daemon connection did not select a module (expected a " "host::module/path destination)"; - const DaemonModule* module = daemon_conf_find_module(g_daemon_conf, config->module); - if (module == NULL) { - char* escaped_module = output_escape(config->module, config->eight_bit_output); - log_message(LOG_LEVEL_ERROR, "unknown daemon module '%s' requested", - escaped_module ? escaped_module : ""); - free(escaped_module); - return "requested daemon module does not exist"; + const char* error = NULL; + const DaemonModule* module = module_gate_lookup_module(config, &error); + if (!module) + return error; + error = module_gate_check_ownership(config, module, gate_ctx); + if (error) + return error; + switch (module_gate_authenticate(config, module, gate_ctx, &error)) { + case MODULE_AUTH_REFUSED: + return error; + case MODULE_AUTH_TERMINATED: + return CONFIG_VALIDATE_ALREADY_TERMINATED; + case MODULE_AUTH_ACCEPTED: + break; } - if (module->read_only) { - log_message(LOG_LEVEL_ERROR, "daemon module '%s' is read only; refusing write transfer", - config->module); - return "requested daemon module is read only"; - } - /* Client-chosen ownership / super-user policy (P7 Wave E hardening): a daemon - module refuses EVERY ownership-affecting request (--numeric-ids, --chown, - --usermap/--groupmap, --fake-super, --copy-as, explicit --super) unless the - operator opted THIS module in with `client owner = yes`. Otherwise any - client could force arbitrary ownership inside the module root. The - standalone/SSH server has a single operator-authorized root and keeps - honoring these. */ - if (!module->client_owner) { - /* Ownership: refuse the whole transfer up front (a clear failure). - Evaluated against the ORIGINAL config so an explicit --super is refused - even when an operator --no-super veto already forced the effective copy - to OFF (the veto must not silently convert a refusal into an accept). */ - if (identity_ownership_requested(config)) { - log_message(LOG_LEVEL_ERROR, - "daemon module '%s' refuses client-chosen ownership/super-user activities " - "(no `client owner = yes` opt-in); refusing", - config->module); - return "client-chosen ownership is not permitted by this daemon module"; - } - /* Super-user DEVICE activities (char/block mknod and --write-devices) are - permitted under the default AUTO mode, so without this override a root - daemon would still let a non-opted module create arbitrary device nodes - and write raw devices. Force them off for this connection: those entries - are skipped (never mknod'ed) while an ordinary `-a` push still succeeds - without device nodes, matching the operator's least-privilege choice. - The operator-level --no-super veto is already folded into this. */ - if (gate_ctx) - gate_ctx->super_mode_override = SUPER_MODE_OFF; - } - if (module->auth_user_count > 0) { - /* Auth-required module (A7, protocol 2.19.0): run the SCRAM challenge/ - * response BEFORE the module root is installed and before any data moves. - * Fail closed: no store -> refuse (server misconfiguration, STATUS_ERROR); - * a handshake that fails before the success response writes exactly one - * STATUS_AUTH_FAILED before signalling ALREADY_TERMINATED (a failure while - * writing the success signature instead just drops the broken connection). - * The username may be logged (never the password or any derived proof). */ - if (g_credentials == NULL) { - log_message(LOG_LEVEL_ERROR, - "daemon module '%s' requires authentication but no credential store is " - "configured (--password-file/--early-input); refusing", - config->module); - return "requested daemon module requires authentication and no credential " - "store is configured"; - } - /* Transport policy (A7-3/S1): an auth-required module only accepts - * credentials over (a) an encrypted, verified TLS connection whose client - * certificate matches --client-cn, or (b) an actual PLAINTEXT connection - * from a loopback peer that the operator explicitly opted into with - * --allow-unauthenticated. A remote plaintext peer, an un-flagged loopback - * plaintext peer, and a loopback TLS peer whose certificate does not match - * --client-cn are all refused HERE, before the challenge is sent, so an - * unverified client never receives a nonce: the loopback allowance requires - * !gate_ctx->ssl, so --tls + --allow-unauthenticated can never be used to - * bypass the client-CN check. The operator flag never permits REMOTE - * plaintext auth: remote peers still require verified TLS regardless. */ - bool tls_ok = gate_ctx && gate_ctx->ssl && SSL_get_verify_result(gate_ctx->ssl) == X509_V_OK && - tls_client_identity_allowed(gate_ctx->ssl); - bool local_ok = allow_unauthenticated && gate_ctx && !gate_ctx->ssl && gate_ctx->fd >= 0 && - utils_fd_peer_is_local(gate_ctx->fd); - if (!tls_ok && !local_ok) { - log_message(LOG_LEVEL_ERROR, - "daemon module '%s' requires authentication over an encrypted, verified TLS " - "connection (or an opted-in loopback plaintext transport); refusing", - config->module); - return "daemon module requires authentication over an encrypted, verified TLS " - "connection"; - } - /* Belt-and-braces: the transport policy above already guarantees a context - * with a usable socket (verified TLS implies a live SSL object and loopback - * allowance requires gate_ctx->fd >= 0), so this is unreachable today; keep - * the guard so the handshake can never be driven over an invalid fd. */ - if (!gate_ctx || gate_ctx->fd < 0) { - log_message(LOG_LEVEL_ERROR, "daemon module '%s': no auth transport available", - config->module); - return "authentication failed for the requested daemon module"; - } - if (!server_auth_handshake(gate_ctx->fd, config, module)) { - char* escaped_user = - config->auth_user ? output_escape(config->auth_user, config->eight_bit_output) : NULL; - log_message(LOG_LEVEL_ERROR, "daemon module '%s': authentication failed for user '%s'", - config->module, escaped_user ? escaped_user : "(none)"); - free(escaped_user); - return CONFIG_VALIDATE_ALREADY_TERMINATED; - } - char* escaped_user = output_escape(config->auth_user, config->eight_bit_output); - log_message(LOG_LEVEL_INFO, "daemon module '%s': user '%s' authenticated", config->module, - escaped_user ? escaped_user : ""); - free(escaped_user); - } - if (!configure_authorization(module->path)) { - log_message(LOG_LEVEL_ERROR, "daemon module '%s' path '%s' is not usable", config->module, - module->path ? module->path : "(null)"); - return "requested daemon module root is not usable"; - } - return NULL; /* accepted; authorized root is now the module's path */ + /* accepted; the authorized root is now the module's path */ + return module_gate_install_root(config, module); } void handler(int file_descriptor) { From 37037a6ee7b73c101bd196bf0a6cb6c6432e542a Mon Sep 17 00:00:00 2001 From: TapTap Date: Sat, 12 Sep 2026 21:07:14 +0200 Subject: [PATCH 5/5] refactor(client-cli): drop unused CliParseCtx positional fields (cppcheck) --- src/client/client_cli.c | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/client/client_cli.c b/src/client/client_cli.c index d1676da..400be1b 100644 --- a/src/client/client_cli.c +++ b/src/client/client_cli.c @@ -826,8 +826,6 @@ typedef struct { Config* config; int argc; char** argv; - int* positional_args; - int* positional_count; int i; /* index of the argument currently being examined */ int exit_code; /* nonzero when a matched handler wants parse_args to return */ bool verbose; /* "-v"/"--verbose" seen (drives the final log level) */ @@ -1855,8 +1853,6 @@ int parse_args(Config* config, int argc, char* argv[], int* positional_args, .config = config, .argc = argc, .argv = argv, - .positional_args = positional_args, - .positional_count = positional_count, .i = 1, .exit_code = 0, .verbose = false,