diff --git a/src/client/client_cli.c b/src/client/client_cli.c index b142de9..00bc37e 100644 --- a/src/client/client_cli.c +++ b/src/client/client_cli.c @@ -647,17 +647,20 @@ static const OptionEntry OPTION_TABLE[] = { {"--save-to-disk", NULL, OPT_FLAG, offsetof(Config, save_to_disk)}, {"--progress", NULL, OPT_FLAG, offsetof(Config, show_progress)}, {"--tls", NULL, OPT_FLAG, offsetof(Config, use_tls)}, - {"--backup", NULL, OPT_FLAG, offsetof(Config, backup)}, + {"--backup", "-b", OPT_FLAG, offsetof(Config, backup)}, {"--stats", NULL, OPT_FLAG, offsetof(Config, stats)}, {"--human-readable", "-h", OPT_FLAG, offsetof(Config, human_readable)}, {"--partial", NULL, OPT_FLAG, offsetof(Config, partial)}, {"--secluded-args", "-s", OPT_NOOP, 0}, + /* rsync -r/--recursive: FastSync is always recursive, so this is a + * 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)}, {"--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)}, - {"--copy-links", NULL, OPT_FLAG, offsetof(Config, copy_links)}, + {"--copy-links", "-L", OPT_FLAG, offsetof(Config, copy_links)}, {"--safe-links", NULL, OPT_FLAG, offsetof(Config, safe_links)}, {"--copy-unsafe-links", NULL, OPT_FLAG, offsetof(Config, copy_unsafe_links)}, {"--copy-dirlinks", "-k", OPT_FLAG, offsetof(Config, copy_dirlinks)}, @@ -1108,7 +1111,7 @@ static bool cli_handle_table_option(CliParseCtx* ctx) { if (!entry) return false; const char* value = NULL; - if (entry->kind != OPT_FLAG) { + if (entry->kind != OPT_FLAG && entry->kind != OPT_NOOP) { value = inline_value; if (!value && ctx->i + 1 < ctx->argc) value = ctx->argv[++ctx->i]; @@ -1266,6 +1269,26 @@ static bool cli_handle_meta_flags(CliParseCtx* ctx) { return false; } +/* Apply a --delta-max value. Values below the minimum warn and keep the + * default; values above the maximum are a hard error. Returns 0 on success, + * -1 on error. */ +static int set_delta_max_option(Config* config, const char* value) { + unsigned long long val; + if (parse_ull_arg(value, &val, "--delta-max") != 0) + return -1; + if (val >= DELTA_MIN_FILE_SIZE && val <= DELTA_MAX_FILE_SIZE) { + config->delta_max_file_size = val; + return 0; + } + if (val < DELTA_MIN_FILE_SIZE) { + log_message(LOG_LEVEL_WARNING, "--delta-max value %llu too small, using default", val); + return 0; + } + log_message(LOG_LEVEL_ERROR, "--delta-max must not exceed %llu bytes", + (unsigned long long)DELTA_MAX_FILE_SIZE); + return -1; +} + /* SSH port and pattern/block-size options. Returns true when the argument was * consumed. */ static bool cli_handle_ssh_and_pattern_options(CliParseCtx* ctx) { @@ -1298,6 +1321,12 @@ static bool cli_handle_ssh_and_pattern_options(CliParseCtx* ctx) { } return true; } + if (strncmp(arg, "--exclude=", 10) == 0) { + if (config_add_pattern(&config->exclude_patterns, &config->exclude_count, arg + 10, + "--exclude") != 0) + 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); @@ -1309,6 +1338,12 @@ static bool cli_handle_ssh_and_pattern_options(CliParseCtx* ctx) { ctx->exit_code = -1; return true; } + if (strncmp(arg, "--include=", 10) == 0) { + if (config_add_pattern(&config->include_patterns, &config->include_count, arg + 10, + "--include") != 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); @@ -1330,7 +1365,8 @@ static bool cli_handle_ssh_and_pattern_options(CliParseCtx* ctx) { ctx->exit_code = -1; return true; } - if (opt_is(arg, "--delta-block", "--block-size")) { + if (strcmp(arg, "--delta-block") == 0 || strcmp(arg, "--block-size") == 0 || + strcmp(arg, "-B") == 0) { if (ctx->i + 1 >= ctx->argc) { log_message(LOG_LEVEL_ERROR, "missing argument for %s", arg); ctx->exit_code = -1; @@ -1340,26 +1376,19 @@ static bool cli_handle_ssh_and_pattern_options(CliParseCtx* ctx) { ctx->exit_code = -1; return true; } + if (strncmp(arg, "--delta-max=", 12) == 0) { + if (set_delta_max_option(config, arg + 12) != 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) { + if (set_delta_max_option(config, ctx->argv[++ctx->i]) != 0) ctx->exit_code = -1; - return true; - } - if (val >= DELTA_MIN_FILE_SIZE && val <= DELTA_MAX_FILE_SIZE) { - config->delta_max_file_size = val; - } else if (val < DELTA_MIN_FILE_SIZE) { - log_message(LOG_LEVEL_WARNING, "--delta-max value %llu too small, using default", val); - } else { - log_message(LOG_LEVEL_ERROR, "--delta-max must not exceed %llu bytes", - (unsigned long long)DELTA_MAX_FILE_SIZE); - ctx->exit_code = -1; - } return true; } return false; @@ -1451,6 +1480,73 @@ static int set_server_port_option(Config* config, const char* value, const char* return 0; } +/* Open (create/append) a --log-file target and install it in the logger. + * Refuses a symlinked target and never leaks the descriptor across exec: an + * attacker who can plant a symlink in the working directory must not be able to + * redirect (or truncate) an arbitrary file. The log is created with owner-only + * permissions. Returns 0 on success, -1 on error (already logged). */ +static int set_log_file_option(Config* config, const char* log_path) { + if (config->log_file) { + /* Detach the logger before closing: log I/O may be in flight and must + never touch a freed FILE*. */ + log_set_file(NULL); + fclose(config->log_file); + config->log_file = NULL; + } + int log_fd = open(log_path, O_WRONLY | O_CREAT | O_APPEND | O_NOFOLLOW | O_CLOEXEC, 0600); + FILE* lf = log_fd >= 0 ? fdopen(log_fd, "a") : NULL; + if (!lf) { + int open_errno = errno; + if (log_fd >= 0) + close(log_fd); + char* escaped = output_escape(log_path, false); + log_message(LOG_LEVEL_ERROR, "could not open log file '%s': %s", + escaped ? escaped : "", strerror(open_errno)); + free(escaped); + return -1; + } + config->log_file = lf; + log_set_file(lf); + return 0; +} + +/* Apply a --bwlimit value (kilobytes per second). Returns 0 on success, -1 on + * error. */ +static int set_bwlimit_option(const char* value) { + unsigned long long kbps; + if (parse_ull_arg(value, &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); + return 0; +} + +/* Apply a --chunk-size value. Returns 0 on success, -1 on error. */ +static int set_chunk_size_option(Config* config, const char* value) { + unsigned long long val; + if (parse_ull_arg(value, &val, "--chunk-size") != 0) + return -1; + if (val == 0) { + log_message(LOG_LEVEL_ERROR, "--chunk-size must be a positive integer"); + return -1; + } + if (val > MAX_CHUNK_SIZE) { + log_message(LOG_LEVEL_ERROR, "--chunk-size must be between 1 and %llu", + (unsigned long long)MAX_CHUNK_SIZE); + return -1; + } + config->chunk_size = val; + return 0; +} + /* Network/IO options: --server-port/--port, --bwlimit, --chunk-size, --log-file * and --stderr. Returns true when the argument was consumed. */ static bool cli_handle_io_options(CliParseCtx* ctx) { @@ -1475,29 +1571,24 @@ static bool cli_handle_io_options(CliParseCtx* ctx) { ctx->exit_code = -1; return true; } + if (strncmp(arg, "--bwlimit=", 10) == 0) { + if (set_bwlimit_option(arg + 10) != 0) + 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) { + if (set_bwlimit_option(ctx->argv[++ctx->i]) != 0) ctx->exit_code = -1; - return true; - } - if (kbps == 0) { - log_message(LOG_LEVEL_ERROR, "--bwlimit must be a positive integer"); + return true; + } + if (strncmp(arg, "--chunk-size=", 13) == 0) { + if (set_chunk_size_option(config, arg + 13) != 0) 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)) { @@ -1506,23 +1597,13 @@ static bool cli_handle_io_options(CliParseCtx* ctx) { ctx->exit_code = -1; return true; } - unsigned long long val; - if (parse_ull_arg(ctx->argv[++ctx->i], &val, "--chunk-size") != 0) { + if (set_chunk_size_option(config, ctx->argv[++ctx->i]) != 0) ctx->exit_code = -1; - return true; - } - if (val == 0) { - log_message(LOG_LEVEL_ERROR, "--chunk-size must be a positive integer"); + return true; + } + if (strncmp(arg, "--log-file=", 11) == 0) { + if (set_log_file_option(config, arg + 11) != 0) ctx->exit_code = -1; - return true; - } - if (val > MAX_CHUNK_SIZE) { - log_message(LOG_LEVEL_ERROR, "--chunk-size must be between 1 and %llu", - (unsigned long long)MAX_CHUNK_SIZE); - ctx->exit_code = -1; - return true; - } - config->chunk_size = val; return true; } if (opt_is(arg, "--log-file", NULL)) { @@ -1531,33 +1612,8 @@ static bool cli_handle_io_options(CliParseCtx* ctx) { ctx->exit_code = -1; return true; } - if (config->log_file) { - /* Detach the logger before closing: log I/O may be in flight and must - never touch a freed FILE*. */ - log_set_file(NULL); - fclose(config->log_file); - config->log_file = NULL; - } - const char* log_path = ctx->argv[++ctx->i]; - /* Refuse a symlinked target and never leak the descriptor across exec: an - * attacker who can plant a symlink in the working directory must not be - * able to redirect (or truncate) an arbitrary file via --log-file. The log - * is created with owner-only permissions. */ - int log_fd = open(log_path, O_WRONLY | O_CREAT | O_APPEND | O_NOFOLLOW | O_CLOEXEC, 0600); - FILE* lf = log_fd >= 0 ? fdopen(log_fd, "a") : NULL; - if (!lf) { - int open_errno = errno; - if (log_fd >= 0) - close(log_fd); - char* escaped = output_escape(log_path, false); - log_message(LOG_LEVEL_ERROR, "could not open log file '%s': %s", - escaped ? escaped : "", strerror(open_errno)); - free(escaped); + if (set_log_file_option(config, ctx->argv[++ctx->i]) != 0) ctx->exit_code = -1; - return true; - } - config->log_file = lf; - log_set_file(lf); return true; } if (strncmp(arg, "--stderr=", 9) == 0) { @@ -1577,6 +1633,11 @@ static bool cli_handle_io_options(CliParseCtx* ctx) { static bool cli_handle_filter_options(CliParseCtx* ctx) { Config* config = ctx->config; const char* arg = ctx->argv[ctx->i]; + if (strncmp(arg, "--exclude-from=", 15) == 0) { + if (read_patterns_from_file(arg + 15, &config->exclude_patterns, &config->exclude_count) != 0) + ctx->exit_code = -1; + return true; + } if (opt_is(arg, "--exclude-from", NULL)) { if (ctx->i + 1 >= ctx->argc) { log_message(LOG_LEVEL_ERROR, "missing argument for %s", arg); @@ -1588,6 +1649,11 @@ static bool cli_handle_filter_options(CliParseCtx* ctx) { ctx->exit_code = -1; return true; } + if (strncmp(arg, "--include-from=", 15) == 0) { + if (read_patterns_from_file(arg + 15, &config->include_patterns, &config->include_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); @@ -2025,18 +2091,149 @@ static int cli_finalize_config(Config* config, bool verbose, bool no_delta, bool return 0; } +/* True for the rsync short options that take a value: the remainder of the + * cluster is the value (attached form), or the next argv entry when the option + * is written alone. */ +static bool short_takes_value(char c) { + return c == 'e' || c == 'B' || c == 'M' || c == 'f' || c == 'T' || c == '@'; +} + +/* True when the long option consumes the following argv entry as its value + * (i.e. a value-taking option written without an inline "="). The cluster + * expander consults this so a value that happens to start with '-' (e.g. + * --filter "- *.tmp") is copied verbatim instead of being mistaken for a + * short-option cluster. */ +static bool cli_long_takes_separate_value(const char* arg) { + if (strchr(arg, '=')) + return false; + const OptionEntry* entry = find_table_option(arg); + if (entry) + return entry->kind == OPT_STRING || entry->kind == OPT_POS_INT || + entry->kind == OPT_NONNEG_INT || entry->kind == OPT_ULL; + static const char* const extra[] = { + "--ssh-port", "--exclude", "--include", "--exclude-from", + "--include-from", "--files-from", "--filter", "--delta-block", + "--block-size", "--delta-max", "--server-port", "--port", + "--bwlimit", "--chunk-size", "--log-file", "--stderr", + "--stop-after", "--stop-at", "--max-alloc", "--compress-threads", + "--checksum-choice", "--cc", "--checksum-seed", "--sockopts", + "--remote-option", "--compare-dest", "--copy-dest", "--link-dest", + "--usermap", "--groupmap", "--chown", "--copy-as", + "--outbuf", "--debug", "--info", + }; + for (size_t i = 0; i < sizeof(extra) / sizeof(extra[0]); i++) + if (strcmp(arg, extra[i]) == 0) + return true; + return false; +} + +/* Expand rsync-style short-option clusters into one option per token before + * parsing: -av -> -a -v, -rlpt -> -r -l -p -t, -B1048576 -> -B 1048576 and + * -essh -> -e ssh. A value-taking short consumes the remainder of its token + * (an optional leading '=' is dropped) as its value; otherwise a value-taking + * option written alone takes the next argv entry, which is therefore copied + * verbatim. Every expanded token is a copy; *out_orig maps each expanded + * token back to its source argv index so the positional-argument indices + * returned to main() stay valid for the caller's original argv. Returns 0 on + * success, -1 on allocation failure. */ +static int expand_short_clusters(int argc, char* argv[], char*** out_argv, int** out_orig, + int* out_argc) { + size_t cap = 1; + for (int i = 0; i < argc; i++) + cap += strlen(argv[i]) + 2; + char** exp = calloc(cap, sizeof(char*)); + int* orig = calloc(cap, sizeof(int)); + if (!exp || !orig) { + free(exp); + free(orig); + return -1; + } + int n = 0; + bool expect_value = false; + for (int i = 0; i < argc; i++) { + const char* tok = argv[i]; + if (i == 0 || expect_value || tok[0] != '-' || tok[1] == '\0') { + exp[n] = str_dup(tok); + if (!exp[n]) + goto oom; + orig[n] = i; + n++; + expect_value = false; + continue; + } + if (tok[1] == '-') { + exp[n] = str_dup(tok); + if (!exp[n]) + goto oom; + orig[n] = i; + n++; + expect_value = cli_long_takes_separate_value(tok); + continue; + } + size_t len = strlen(tok); + for (size_t j = 1; j < len; j++) { + char flag[3] = {'-', tok[j], '\0'}; + exp[n] = str_dup(flag); + if (!exp[n]) + goto oom; + orig[n] = i; + n++; + if (short_takes_value(tok[j])) { + const char* value = tok + j + 1; + if (*value == '=') + value++; + if (*value != '\0') { + exp[n] = str_dup(value); + if (!exp[n]) + goto oom; + orig[n] = i; + n++; + } else { + expect_value = true; + } + break; + } + } + } + *out_argv = exp; + *out_orig = orig; + *out_argc = n; + return 0; +oom: + for (int k = 0; k < n; k++) + free(exp[k]); + free(exp); + free(orig); + return -1; +} + +static void free_expanded_args(char** exp, int exp_argc) { + for (int i = 0; i < exp_argc; i++) + free(exp[i]); + free(exp); +} + /* 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) + char** exp_argv = NULL; + int* exp_orig = NULL; + int exp_argc = 0; + if (expand_short_clusters(argc, argv, &exp_argv, &exp_orig, &exp_argc) != 0) { + log_message(LOG_LEVEL_ERROR, "memory allocation failed parsing arguments"); return -1; + } + + int result = -1; + if (cli_apply_output_controls(config, exp_argc, exp_argv) != 0) + goto done; CliParseCtx ctx = { .config = config, - .argc = argc, - .argv = argv, + .argc = exp_argc, + .argv = exp_argv, .i = 1, .exit_code = 0, .verbose = false, @@ -2044,7 +2241,7 @@ int parse_args(Config* config, int argc, char* argv[], int* positional_args, .no_incremental = false, }; - for (ctx.i = 1; ctx.i < argc; ctx.i++) { + for (ctx.i = 1; ctx.i < exp_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) || @@ -2054,30 +2251,37 @@ int parse_args(Config* config, int argc, char* argv[], int* positional_args, 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; + if (ctx.exit_code != 0) { + result = ctx.exit_code; + goto done; + } continue; } - if (argv[ctx.i][0] == '-') { - char* escaped = output_escape(argv[ctx.i], false); - fprintf(stderr, "Unknown option: %s\n", escaped ? escaped : ""); + if (ctx.argv[ctx.i][0] == '-') { + char* escaped = output_escape(ctx.argv[ctx.i], false); + fprintf(stderr, "Unknown option: %s (FastSync does not support this option)\n", + escaped ? escaped : ""); free(escaped); print_usage(); - return -1; + goto done; } if (*positional_count < 2) - positional_args[(*positional_count)++] = ctx.i; + positional_args[(*positional_count)++] = exp_orig[ctx.i]; else { - char* escaped = output_escape(argv[ctx.i], false); + char* escaped = output_escape(ctx.argv[ctx.i], false); fprintf(stderr, "Unexpected argument: %s\n", escaped ? escaped : ""); free(escaped); print_usage(); - return -1; + goto done; } } - return cli_finalize_config(config, ctx.verbose, ctx.no_delta, ctx.no_incremental); + result = cli_finalize_config(config, ctx.verbose, ctx.no_delta, ctx.no_incremental); +done: + free_expanded_args(exp_argv, exp_argc); + free(exp_orig); + return result; } static int read_patterns_from_file(const char* filepath, char*** patterns, int* count) { diff --git a/src/client/usage.c b/src/client/usage.c index b320708..b2bd9b4 100644 --- a/src/client/usage.c +++ b/src/client/usage.c @@ -23,6 +23,7 @@ void print_usage(void) { printf(" -a, --archive rsync archive mode (-rlptgoD): links, perms, times,\n"); printf(" owner, group, devices and specials; not\n"); printf(" compression/multithreading\n"); + printf(" -r, --recursive Recurse into directories (FastSync is always recursive)\n"); printf(" -n, --dry-run Show what would be transferred\n"); printf(" --remove-source-files Remove regular source files after successful transfer\n"); printf(" -p, --perms Preserve permission bits\n"); @@ -104,10 +105,10 @@ void print_usage(void) { printf(" parent directory is not itself listed\n"); printf(" --mkpath Create the destination root directory on the server when it\n"); printf(" does not exist yet\n"); - printf(" --exclude Exclude files matching pattern\n"); - printf(" --include Only include files matching pattern\n"); - printf(" --exclude-from Read exclude patterns from file\n"); - printf(" --include-from Read include patterns from file\n"); + printf(" --exclude , --exclude= Exclude files matching pattern\n"); + printf(" --include , --include= Only include files matching pattern\n"); + printf(" --exclude-from , --exclude-from= Read exclude patterns from file\n"); + printf(" --include-from , --include-from= Read include patterns from file\n"); printf(" --files-from Read the source file list from FILE (paths relative to the " "source root)\n"); printf(" -0, --from0 Entries in --files-from are NUL-delimited\n"); @@ -145,7 +146,7 @@ void print_usage(void) { printf(" --incremental and --delta; inert with --whole-file,\n"); printf(" --no-delta, or --no-incremental)\n"); printf(" --no-fuzzy Disable --fuzzy\n"); - printf(" --delta-block , --block-size \n"); + printf(" -B , --block-size , --delta-block \n"); printf(" Delta block size in bytes (default: %d)\n", DELTA_BLOCK_SIZE_DEFAULT); printf(" --delta-max Max file size for delta transfer (default: %llu)\n", DELTA_MAX_FILE_SIZE); @@ -246,7 +247,7 @@ void print_usage(void) { printf(" -6, --ipv6 Force IPv6 for destination resolution\n"); printf(" --sockopts=OPTS Comma-separated OPT=VAL socket options applied before connect:\n"); printf(" TCP_NODELAY, SO_KEEPALIVE, SO_RCVBUF, SO_SNDBUF, SO_REUSEADDR\n"); - printf(" --backup Backup existing files before overwriting\n"); + printf(" -b, --backup Backup existing files before overwriting\n"); printf(" --backup-dir Directory for backups (requires --backup)\n"); printf(" --suffix Backup suffix (default: ~)\n"); printf(" --stats Print transfer statistics at end\n"); @@ -257,7 +258,7 @@ void print_usage(void) { printf(" -h, --human-readable Print byte sizes in human-readable form\n"); printf(" --max-depth Maximum directory depth (0=unlimited)\n"); printf(" -x, --one-file-system Do not cross filesystem boundaries\n"); - printf(" --log-file Write log messages to file\n"); + printf(" --log-file , --log-file= Write log messages to file\n"); printf(" --stderr=MODE Route logging to stderr: errors or all\n"); printf(" --partial Keep partial files on interrupted transfer\n"); printf(" --partial-dir Directory for partial files\n"); @@ -275,7 +276,7 @@ void print_usage(void) { printf(" incoming file list (fewer checks, faster, potentially unsafe).\n"); printf(" Local receiver policy: never sent to the peer, off by default\n"); printf(" -l, --links Copy symlinks as symlinks\n"); - printf(" --copy-links Transform symlinks into referent files\n"); + printf(" -L, --copy-links Transform symlinks into referent files\n"); printf(" --safe-links Skip symlinks that point outside transfer tree\n"); printf(" --copy-unsafe-links Only transform unsafe symlinks into referent files\n"); printf(" -k, --copy-dirlinks Transform symlinks to directories into real dirs\n"); diff --git a/src/server/server.c b/src/server/server.c index fceaf0c..328a05c 100644 --- a/src/server/server.c +++ b/src/server/server.c @@ -1051,7 +1051,7 @@ static void print_server_usage(void) { printf(" --early-input=FILE Second credential store layered over\n"); printf(" --password-file (same format); usually a secrets-\n"); printf(" manager/process-substitution file. Requires --daemon\n"); - printf(" -p TCP port (default: 8080, range: 1-65535)\n"); + printf(" -p, --port TCP port (default: 8080, range: 1-65535)\n"); printf(" --tls Enable TLS encryption\n"); printf(" --cert TLS certificate file (PEM)\n"); printf(" --key TLS private key file (PEM)\n"); diff --git a/src/server/server_cli.c b/src/server/server_cli.c index 4400106..de505ab 100644 --- a/src/server/server_cli.c +++ b/src/server/server_cli.c @@ -192,14 +192,20 @@ int server_cli_parse(int argc, char* argv[], ServerCliOptions* opts, char* err, inline_value = argv[++i]; } opts->iconv_spec = inline_value; - } else if (arg_is(argv[i], "-p")) { - if (i + 1 >= argc) { - set_error(err, err_size, "missing argument for -p"); - return -1; + } else if (arg_is(argv[i], "-p") || arg_has_value(argv[i], "--port", &inline_value)) { + if (inline_value) { + opts->port_set = true; + if (parse_port_arg(inline_value, &opts->port, err, err_size) != 0) + return -1; + } else { + if (i + 1 >= argc) { + set_error(err, err_size, "missing argument for %s", argv[i]); + return -1; + } + opts->port_set = true; + if (parse_port_arg(argv[++i], &opts->port, err, err_size) != 0) + return -1; } - opts->port_set = true; - if (parse_port_arg(argv[++i], &opts->port, err, err_size) != 0) - return -1; } else { if (arg_has_value(argv[i], "--config", &inline_value)) { if (!inline_value) { diff --git a/tests/test_client_cli.c b/tests/test_client_cli.c index aecfb44..60dd58a 100644 --- a/tests/test_client_cli.c +++ b/tests/test_client_cli.c @@ -3632,6 +3632,205 @@ static void test_parse_args_acls_implies_perms_xattrs_does_not() { config_delete(cfg); } +/* rsync short-option clustering: boolean shorts bundle after one dash + * (-av == -a -v, -aAX, -rlpt), including the -r recursive no-op. */ +static void test_parse_args_short_clustering() { + Config* cfg = config_create(); + int positional_args[2]; + int positional_count = 0; + char* argv[] = {"fastsync", "-av", "/src", "/dst"}; + EXPECT_EQ_INT(parse_args(cfg, 4, argv, positional_args, &positional_count), 0); + EXPECT_TRUE(cfg->follow_symlinks); + EXPECT_TRUE(cfg->preserve_perms); + EXPECT_TRUE(cfg->preserve_times); + EXPECT_TRUE(cfg->preserve_owner); + EXPECT_TRUE(cfg->preserve_group); + EXPECT_TRUE(cfg->preserve_devices); + EXPECT_TRUE(cfg->preserve_specials); + EXPECT_EQ_INT(positional_count, 2); + config_delete(cfg); + + cfg = config_create(); + positional_count = 0; + char* argv_ax[] = {"fastsync", "-aAX", "/src", "/dst"}; + EXPECT_EQ_INT(parse_args(cfg, 4, argv_ax, positional_args, &positional_count), 0); + EXPECT_TRUE(cfg->follow_symlinks); + EXPECT_TRUE(cfg->preserve_acls); + EXPECT_TRUE(cfg->preserve_xattrs); + EXPECT_TRUE(cfg->preserve_perms); + EXPECT_EQ_INT(positional_count, 2); + config_delete(cfg); + + cfg = config_create(); + positional_count = 0; + char* argv_rlpt[] = {"fastsync", "-rlpt", "/src", "/dst"}; + EXPECT_EQ_INT(parse_args(cfg, 4, argv_rlpt, positional_args, &positional_count), 0); + EXPECT_TRUE(cfg->follow_symlinks); + EXPECT_TRUE(cfg->preserve_perms); + EXPECT_TRUE(cfg->preserve_times); + EXPECT_EQ_INT(positional_count, 2); + config_delete(cfg); +} + +/* Attached short-option values: a value-taking short consumes the remainder of + * its token as the value (-B32768, -essh, -Mfoo, -B=... also tolerated). */ +static void test_parse_args_attached_short_values() { + Config* cfg = config_create(); + int positional_args[2]; + int positional_count = 0; + + char* argv_b[] = {"fastsync", "-B32768", "/src", "/dst"}; + EXPECT_EQ_INT(parse_args(cfg, 4, argv_b, positional_args, &positional_count), 0); + EXPECT_EQ_INT((int)cfg->delta_block_size, 32768); + config_delete(cfg); + + /* An oversized block size parses (and warns) but keeps the default, exactly + * like the long --block-size form. */ + cfg = config_create(); + positional_count = 0; + char* argv_big[] = {"fastsync", "-B1048576", "/src", "/dst"}; + EXPECT_EQ_INT(parse_args(cfg, 4, argv_big, positional_args, &positional_count), 0); + EXPECT_EQ_INT((int)cfg->delta_block_size, (int)DELTA_BLOCK_SIZE_DEFAULT); + config_delete(cfg); + + /* A separate value still works for a short written alone. */ + cfg = config_create(); + positional_count = 0; + char* argv_sep[] = {"fastsync", "-B", "8192", "/src", "/dst"}; + EXPECT_EQ_INT(parse_args(cfg, 5, argv_sep, positional_args, &positional_count), 0); + EXPECT_EQ_INT((int)cfg->delta_block_size, 8192); + config_delete(cfg); + + cfg = config_create(); + positional_count = 0; + char* argv_e[] = {"fastsync", "-essh", "/src", "/dst"}; + EXPECT_EQ_INT(parse_args(cfg, 4, argv_e, positional_args, &positional_count), 0); + EXPECT_EQ_STR(cfg->rsh_command, "ssh"); + config_delete(cfg); + + cfg = valid_client_config(); + positional_count = 0; + char* argv_m[] = {"fastsync", "-Mfoo=bar", "--source-dir", "/src", "--dest-dir", "/dst"}; + EXPECT_EQ_INT(parse_args(cfg, 7, argv_m, positional_args, &positional_count), 0); + EXPECT_EQ_INT(cfg->remote_option_count, 1); + EXPECT_EQ_STR(cfg->remote_options[0], "foo=bar"); + config_delete(cfg); +} + +/* Inline --opt=value forms for options that previously only accepted a + * separate argument. */ +static void test_parse_args_inline_equals_forms() { + Config* cfg = config_create(); + int positional_args[2]; + int positional_count = 0; + char* argv[] = {"fastsync", "--exclude=*.log", "--include=*.txt", "/src", "/dst"}; + EXPECT_EQ_INT(parse_args(cfg, 5, argv, positional_args, &positional_count), 0); + EXPECT_EQ_INT(cfg->exclude_count, 1); + EXPECT_EQ_STR(cfg->exclude_patterns[0], "*.log"); + EXPECT_EQ_INT(cfg->include_count, 1); + EXPECT_EQ_STR(cfg->include_patterns[0], "*.txt"); + config_delete(cfg); + + const char* list_path = "cli_inline_patterns.txt"; + write_file_bytes(list_path, "*.o\nbuild/\n", 11); + cfg = config_create(); + positional_count = 0; + char arg_excl[64]; + snprintf(arg_excl, sizeof(arg_excl), "--exclude-from=%s", list_path); + char arg_incl[64]; + snprintf(arg_incl, sizeof(arg_incl), "--include-from=%s", list_path); + char* argv2[] = {"fastsync", arg_excl, arg_incl, "/src", "/dst"}; + EXPECT_EQ_INT(parse_args(cfg, 5, argv2, positional_args, &positional_count), 0); + EXPECT_EQ_INT(cfg->exclude_count, 2); + EXPECT_EQ_STR(cfg->exclude_patterns[0], "*.o"); + EXPECT_EQ_STR(cfg->exclude_patterns[1], "build/"); + EXPECT_EQ_INT(cfg->include_count, 2); + remove(list_path); + config_delete(cfg); + + const char* log_path = "cli_inline_log.txt"; + cfg = config_create(); + positional_count = 0; + char arg_log[64]; + snprintf(arg_log, sizeof(arg_log), "--log-file=%s", log_path); + char* argv3[] = {"fastsync", arg_log, "/src", "/dst"}; + EXPECT_EQ_INT(parse_args(cfg, 4, argv3, positional_args, &positional_count), 0); + EXPECT_NOT_NULL(cfg->log_file); + config_delete(cfg); + remove(log_path); + + cfg = config_create(); + positional_count = 0; + char* argv4[] = {"fastsync", "--chmod=u=rw,go=r", "--out-format=%f %l", "/src", "/dst"}; + EXPECT_EQ_INT(parse_args(cfg, 5, argv4, positional_args, &positional_count), 0); + EXPECT_EQ_STR(cfg->chmod_spec, "u=rw,go=r"); + EXPECT_EQ_STR(cfg->out_format, "%f %l"); + config_delete(cfg); + + cfg = config_create(); + positional_count = 0; + char* argv5[] = {"fastsync", "--chunk-size=4096", "--delta-max=1048576", "/src", "/dst"}; + EXPECT_EQ_INT(parse_args(cfg, 5, argv5, positional_args, &positional_count), 0); + EXPECT_TRUE(cfg->chunk_size == 4096ULL); + EXPECT_TRUE(cfg->delta_max_file_size == 1048576ULL); + config_delete(cfg); +} + +/* OPT_NOOP compatibility flags (-s/--secluded-args, -r/--recursive) must never + * swallow the next argv: `fastsync -s SRC DST` keeps both positionals. */ +static void test_parse_args_noop_does_not_consume_argv() { + static const char* const noops[] = {"-s", "--secluded-args", "-r", "--recursive"}; + for (size_t i = 0; i < sizeof(noops) / sizeof(noops[0]); i++) { + Config* cfg = config_create(); + int positional_args[2]; + int positional_count = 0; + char* argv[] = {"fastsync", (char*)noops[i], "/src", "/dst"}; + EXPECT_EQ_INT(parse_args(cfg, 4, argv, positional_args, &positional_count), 0); + EXPECT_EQ_INT(positional_count, 2); + config_delete(cfg); + } + + Config* cfg = config_create(); + int positional_args[2]; + int positional_count = 0; + char* argv[] = {"fastsync", "-sv", "/src", "/dst"}; + EXPECT_EQ_INT(parse_args(cfg, 4, argv, positional_args, &positional_count), 0); + EXPECT_EQ_INT(positional_count, 2); + config_delete(cfg); +} + +/* -b/--backup and -L/--copy-links short aliases behave like their long forms. */ +static void test_parse_args_backup_copy_links_shorts() { + Config* cfg = config_create(); + int positional_args[2]; + int positional_count = 0; + char* argv_b[] = {"fastsync", "-b", "/src", "/dst"}; + EXPECT_EQ_INT(parse_args(cfg, 4, argv_b, positional_args, &positional_count), 0); + EXPECT_TRUE(cfg->backup); + config_delete(cfg); + + cfg = config_create(); + positional_count = 0; + char* argv_l[] = {"fastsync", "-L", "/src", "/dst"}; + EXPECT_EQ_INT(parse_args(cfg, 4, argv_l, positional_args, &positional_count), 0); + EXPECT_TRUE(cfg->copy_links); + config_delete(cfg); +} + +/* An unknown short option (alone or inside a cluster) is rejected, never + * silently ignored. */ +static void test_parse_args_rejects_unsupported_short() { + static const char* const bad[] = {"-Q", "-aQ", "-rZ", "-9"}; + for (size_t i = 0; i < sizeof(bad) / sizeof(bad[0]); i++) { + Config* cfg = config_create(); + int positional_args[2]; + int positional_count = 0; + char* argv[] = {"fastsync", (char*)bad[i], "/src", "/dst"}; + EXPECT_EQ_INT(parse_args(cfg, 4, argv, positional_args, &positional_count), -1); + config_delete(cfg); + } +} + void test_client_cli() { test_validate_config_required_paths(); test_parse_args_numeric_ids(); @@ -3801,4 +4000,10 @@ void test_client_cli() { test_parse_args_pattern_file_oversized_rejected(); test_parse_args_unsigned_options_reject_sign(); test_validate_config_dry_run_rejects_write_batch(); + test_parse_args_short_clustering(); + test_parse_args_attached_short_values(); + test_parse_args_inline_equals_forms(); + test_parse_args_noop_does_not_consume_argv(); + test_parse_args_backup_copy_links_shorts(); + test_parse_args_rejects_unsupported_short(); } diff --git a/tests/test_server_cli.c b/tests/test_server_cli.c index 5c2cca9..6149cb0 100644 --- a/tests/test_server_cli.c +++ b/tests/test_server_cli.c @@ -234,6 +234,31 @@ static void test_server_cli_password_requires_daemon() { server_cli_options_free(&opts); } +/* --port is the rsync-style alias for -p, in both the separate and =value + * spellings; an invalid value is still validated. */ +static void test_server_cli_port_alias() { + const char* a1[] = {"fastsync-server", "--port", "9000"}; + ServerCliOptions opts; + EXPECT_EQ_INT(parse_ok(a1, 3, &opts), 0); + EXPECT_EQ_INT(opts.port, 9000); + EXPECT_TRUE(opts.port_set); + server_cli_options_free(&opts); + + const char* a2[] = {"fastsync-server", "--port=9001"}; + ServerCliOptions opts2; + EXPECT_EQ_INT(parse_ok(a2, 2, &opts2), 0); + EXPECT_EQ_INT(opts2.port, 9001); + EXPECT_TRUE(opts2.port_set); + server_cli_options_free(&opts2); + + char err[128]; + ServerCliOptions opts3; + const char* a3[] = {"fastsync-server", "--port", "notaport"}; + EXPECT_EQ_INT(server_cli_parse(3, (char**)a3, &opts3, err, sizeof(err)), -1); + EXPECT_TRUE(strstr(err, "invalid port") != NULL); + server_cli_options_free(&opts3); +} + static void test_server_cli_help() { char err[256]; const char* a1[] = {"s", "--help"}; @@ -254,5 +279,6 @@ void test_server_cli() { test_server_cli_password_requires_daemon(); test_server_cli_no_super(); test_server_cli_allow_super(); + test_server_cli_port_alias(); test_server_cli_help(); }