diff --git a/src/client/client_cli.c b/src/client/client_cli.c index e537e5e..fbdb8fe 100644 --- a/src/client/client_cli.c +++ b/src/client/client_cli.c @@ -114,6 +114,27 @@ static int set_nonneg_int_option(int* dest, const char* value, const char* optio return 0; } +/* Validate and append one --compare-dest/--copy-dest/--link-dest directory. + * The path is interpreted on the receiver relative to the destination root, + * so it must be a non-empty relative path with no "." / ".." components (an + * absolute or escaping path is rejected up front instead of failing on the + * server). Returns 0 on success, -1 on error. */ +static int set_basis_dest_option(Config* config, BasisDestType type, const char* value, + const char* option_name) { + if (!value || !value[0]) { + log_message(LOG_LEVEL_ERROR, "missing argument for %s", option_name); + return -1; + } + if (config_basis_append(config, type, value) != 0) { + log_message(LOG_LEVEL_ERROR, + "%s requires a non-empty relative directory name with no '.', '..', or absolute " + "path (resolved below the destination root)", + option_name); + return -1; + } + return 0; +} + static int set_stderr_mode(const char* value) { if (strcmp(value, "errors") == 0 || strcmp(value, "e") == 0) log_set_stderr_mode(LOG_STDERR_ERRORS); @@ -974,6 +995,36 @@ int parse_args(Config* config, int argc, char* argv[], int* positional_args, } log_message(LOG_LEVEL_ERROR, "%s is not supported yet (xxHash64 is used)", argv[i]); 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 (argv[i][0] == '-') { char* escaped = output_escape(argv[i], false); fprintf(stderr, "Unknown option: %s\n", escaped ? escaped : ""); @@ -1010,6 +1061,13 @@ int parse_args(Config* config, int argc, char* argv[], int* positional_args, config->files_from_set = set; } + /* The "unchanged" decision for --compare-dest/--copy-dest/--link-dest must + * be made on the receiver against the basis directories, which requires the + * per-file STATUS_CHECK handshake: basis-dir options therefore imply + * --incremental (and, via the block below, metadata) on the sender. */ + if (config_has_basis(config)) + config->use_incremental = true; + /* Incremental and delta transfers need metadata unless the user disabled it. */ if ((config->use_incremental || config->use_delta) && !config->use_metadata && !config->metadata_explicitly_disabled) { diff --git a/src/client/client_validation.c b/src/client/client_validation.c index 22ac977..a891132 100644 --- a/src/client/client_validation.c +++ b/src/client/client_validation.c @@ -11,6 +11,12 @@ bool validate_config(const Config* config) { print_usage(); return false; } + if (config_has_basis(config) && config->use_chunk_serialization) { + log_message(LOG_LEVEL_ERROR, + "--compare-dest/--copy-dest/--link-dest require per-file incremental checks and " + "cannot be combined with -s (chunk serialization)"); + return false; + } if (config->use_sendfile && (config->use_chunk_serialization || config->use_compression)) { log_message(LOG_LEVEL_ERROR, "-f/--sendfile cannot be combined with -c (compression) or -s " "(chunk serialization)"); diff --git a/src/client/usage.c b/src/client/usage.c index e9579ba..3beda0d 100644 --- a/src/client/usage.c +++ b/src/client/usage.c @@ -58,6 +58,13 @@ void print_usage(void) { printf(" -@, --modify-window Modification time tolerance\n"); printf(" -u, --update Skip files newer than the source on receiver\n"); printf(" --existing Skip files not already present at destination\n"); + printf(" --compare-dest Treat DIR (relative to destination root) as an extra\n"); + printf(" comparison basis: unchanged files are not transferred\n"); + printf(" (requires --incremental, which is implied)\n"); + printf(" --copy-dest Like --compare-dest, but copies the unchanged file from DIR\n"); + printf(" into the destination instead of transferring its data\n"); + printf(" --link-dest Like --copy-dest, but hard-links the unchanged file from DIR\n"); + printf(" into the destination (repeatable; earlier DIRs win)\n"); printf(" --checksum-choice, --cc Checksum algorithm (not supported yet; xxHash64 is " "used)\n"); printf(" --delta Delta transfer for changed files (requires --incremental)\n"); diff --git a/src/shared/config.c b/src/shared/config.c index ed5fdfe..3031f58 100644 --- a/src/shared/config.c +++ b/src/shared/config.c @@ -109,9 +109,8 @@ static void config_set_defaults(Config* config) { config->rsync_path = NULL; config->old_args = false; config->temp_dir = NULL; - config->compare_dest = NULL; - config->copy_dest = NULL; - config->link_dest = NULL; + config->basis_dirs = NULL; + config->basis_count = 0; config->partial_dir = NULL; config->suffix = NULL; config->delete_before = false; @@ -188,6 +187,63 @@ Config* config_create(void) { return config; } +bool config_has_basis(const Config* config) { + return config && config->basis_count > 0; +} + +/* A basis-dir path travels from the client to the receiver and is resolved + * below the destination root, so it must be a non-empty relative path with no + * "." or ".." component and no traversal: an absolute or escaping path would + * make the receiver read or link files outside its authorized root. */ +bool config_basis_path_valid(const char* path) { + if (!path || path[0] == '\0' || path[0] == '/' || has_path_traversal(path)) + return false; + if (strcmp(path, ".") == 0) + return false; + char* dup = str_dup(path); + if (!dup) + return false; + bool ok = true; + char* saveptr = NULL; + for (char* part = strtok_r(dup, "/", &saveptr); part; part = strtok_r(NULL, "/", &saveptr)) { + if (strcmp(part, ".") == 0) { + ok = false; + break; + } + if (strcmp(part, "..") == 0) { + ok = false; + break; + } + } + free(dup); + return ok; +} + +int config_basis_append(Config* config, BasisDestType type, const char* path) { + if (!config || !config_basis_path_valid(path) || + (type != BASIS_DEST_COMPARE && type != BASIS_DEST_COPY && type != BASIS_DEST_LINK) || + config->basis_count >= MAX_BASIS_DIRS) + return -1; + BasisDest* grown = realloc(config->basis_dirs, (config->basis_count + 1) * sizeof(BasisDest)); + if (!grown) + return -1; + config->basis_dirs = grown; + /* Normalize a user-supplied trailing slash away so the stored path matches + the delete-walker prefix form exactly. */ + size_t len = strlen(path); + while (len > 1 && path[len - 1] == '/') + len--; + char* dup = malloc(len + 1); + if (!dup) + return -1; + memcpy(dup, path, len); + dup[len] = '\0'; + config->basis_dirs[config->basis_count].type = type; + config->basis_dirs[config->basis_count].path = dup; + config->basis_count++; + return 0; +} + bool config_is_remote_dest(const char* s) { if (s == NULL) return false; @@ -244,9 +300,13 @@ void config_delete(Config* config) { free(config->rsh_command); free(config->rsync_path); free(config->temp_dir); - free(config->compare_dest); - free(config->copy_dest); - free(config->link_dest); + for (int i = 0; i < config->basis_count; i++) { + free(config->basis_dirs[i].path); + config->basis_dirs[i].path = NULL; + } + free(config->basis_dirs); + config->basis_dirs = NULL; + config->basis_count = 0; free(config->partial_dir); free(config->suffix); free(config->address); @@ -333,6 +393,17 @@ static bool send_resume_options(int fd, const Config* c) { send_str(fd, c->chmod_spec ? c->chmod_spec : "") && send_skip_compress_options(fd, c); } +static bool send_basis_options(int fd, const Config* c) { + if (!send_int(fd, c->basis_count)) + return false; + for (int i = 0; i < c->basis_count; i++) { + if (!send_int(fd, (int)c->basis_dirs[i].type) || + !send_str(fd, c->basis_dirs[i].path ? c->basis_dirs[i].path : "")) + return false; + } + return true; +} + static bool receive_core_fields(int fd, Config* c) { int value; if (!receive_wire_bool(fd, &c->eight_bit_output)) @@ -479,12 +550,35 @@ static bool receive_resume_options(int fd, Config* c) { return true; } +static bool receive_basis_options(int fd, Config* c) { + int count; + if (!receive_int(fd, &count)) + return false; + if (count < 0 || count > MAX_BASIS_DIRS) + return false; + for (int i = 0; i < count; i++) { + int type; + if (!receive_int(fd, &type) || type <= BASIS_DEST_NONE || type > BASIS_DEST_LINK) + return false; + char* path = receive_str(fd); + if (!path) + return false; + bool ok = config_basis_path_valid(path); + if (ok) + ok = config_basis_append(c, (BasisDestType)type, path) == 0; + free(path); + if (!ok) + return false; + } + return true; +} + bool config_send(int file_descriptor, const Config* config) { protocol_session_set_max_alloc(NULL, config->max_alloc); if (!send_core_fields(file_descriptor, config) || !send_delta_fields(file_descriptor, config) || !send_file_options(file_descriptor, config) || !send_selection_options(file_descriptor, config) || - !send_resume_options(file_descriptor, config)) + !send_resume_options(file_descriptor, config) || !send_basis_options(file_descriptor, config)) return false; Status status; if (!receive_status(file_descriptor, &status)) @@ -516,7 +610,8 @@ Config* config_receive(int file_descriptor) { !receive_delta_fields(file_descriptor, config) || !receive_file_options(file_descriptor, config) || !receive_selection_options(file_descriptor, config) || - !receive_resume_options(file_descriptor, config)) + !receive_resume_options(file_descriptor, config) || + !receive_basis_options(file_descriptor, config)) goto error; if (config->compress_choice[0] != '\0' && strcmp(config->compress_choice, "zstd") != 0 && strcmp(config->compress_choice, "none") != 0) { diff --git a/src/shared/config.h b/src/shared/config.h index 1b86c9c..e05a3db 100644 --- a/src/shared/config.h +++ b/src/shared/config.h @@ -12,6 +12,22 @@ typedef enum { TRANSPORT_TCP, TRANSPORT_SSH } TransportType; Config can carry it; the concrete type lives in delay_updates.h. */ typedef struct DelayUpdatesContext DelayUpdatesContext; +/* Alternate basis-directory modes (--compare-dest / --copy-dest / + * --link-dest). Each flag adds one entry to the ordered Config->basis_dirs + * list; the receiver consults entries in command-line order and stops at the + * first exact match, mirroring rsync's basis-dir priority rules. */ +typedef enum { + BASIS_DEST_NONE = 0, + BASIS_DEST_COMPARE, /* compare only: never copies, never materializes */ + BASIS_DEST_COPY, /* local copy of the matched basis file */ + BASIS_DEST_LINK /* hard link to the matched basis file */ +} BasisDestType; + +typedef struct BasisDest { + BasisDestType type; + char* path; /* relative to the destination root (receiver-confined) */ +} BasisDest; + typedef struct Config { char* version; char* send_directory; @@ -134,9 +150,12 @@ typedef struct Config { char* rsync_path; bool old_args; char* temp_dir; - char* compare_dest; - char* copy_dest; - char* link_dest; + /* Alternate basis directories, ordered by command-line appearance. Each + * entry's type selects compare/copy/link behavior on an exact match. These + * cross the wire so the receiver can consult them; they are interpreted + * relative to the destination root and confined there. */ + BasisDest* basis_dirs; + int basis_count; // PR #174: Partial transfer resumption char* partial_dir; @@ -174,8 +193,10 @@ typedef struct Config { DelayUpdatesContext* delay_context; } Config; -#define PROTOCOL_VERSION "2.7.0" +#define PROTOCOL_VERSION "2.8.0" #define DEFAULT_CHUNK_SIZE (10 * 1024 * 1024) +/* Upper bound on total basis-dir entries (rsync caps --link-dest at 20). */ +#define MAX_BASIS_DIRS 64 Config* config_create(void); void config_delete(Config* config); @@ -184,4 +205,11 @@ Config* config_receive(int file_descriptor); bool config_is_remote_dest(const char* s); void config_parse_ssh_dest(Config* config); +/* True when at least one --compare-dest/--copy-dest/--link-dest was set. */ +bool config_has_basis(const Config* config); +/* Append one basis-dir entry. Returns 0 on success, -1 on allocation failure. */ +int config_basis_append(Config* config, BasisDestType type, const char* path); +/* Validate a client-provided basis-dir path (relative, confined, non-empty). */ +bool config_basis_path_valid(const char* path); + #endif