fix(parity): client quick wins for rsync 3.4.1 (copy-links exit 23, info/debug flags, empty files-from, -F ordering, delete edges)

This commit is contained in:
2026-09-16 22:21:45 +02:00
parent 1116da9f64
commit e674b25213
9 changed files with 1108 additions and 51 deletions
+97 -25
View File
@@ -395,6 +395,39 @@ static void apply_output_buffering(const Config* config) {
static int read_patterns_from_file(const char* filepath, char*** patterns, int* count,
Config* config, char sign, const char* optname);
/* Split one --debug/--info item into its category name and an optional rsync
* verbosity level suffix (e.g. "io2", "all4", "none0"). The output `name` is
* NUL-terminated and `level` is >= 0 (0 silences the item). Returns false for
* an empty token or a token that is all digits. */
static bool split_flag_level(const char* token, char* name, size_t name_size, int* level) {
size_t len = strlen(token);
if (len == 0 || name_size == 0)
return false;
size_t end = len;
while (end > 0 && token[end - 1] >= '0' && token[end - 1] <= '9')
end--;
if (end == 0)
return false; /* all digits: not a category name */
size_t name_len = end < name_size - 1 ? end : name_size - 1;
for (size_t i = 0; i < name_len; i++) {
char c = token[i];
name[i] = (c >= 'A' && c <= 'Z') ? (char)(c - 'A' + 'a') : c;
}
name[name_len] = '\0';
int lvl = 1;
if (end < len) {
lvl = 0;
for (size_t i = end; i < len; i++) {
int digit = token[i] - '0';
if (lvl > (1000 - digit) / 10)
return false;
lvl = lvl * 10 + digit;
}
}
*level = lvl;
return true;
}
static int parse_debug_flags(const char* value, Config* config) {
if (!value || value[0] == '\0' || value[0] == ',' || value[strlen(value) - 1] == ',' ||
strstr(value, ",,")) {
@@ -412,30 +445,40 @@ static int parse_debug_flags(const char* value, Config* config) {
for (char* token = strtok_r(flags, ",", &saveptr); token != NULL;
token = strtok_r(NULL, ",", &saveptr)) {
uint32_t flag = 0;
if (strcmp(token, "help") == 0) {
char name[32];
int level = 1;
if (!split_flag_level(token, name, sizeof(name), &level)) {
log_message(LOG_LEVEL_ERROR, "unsupported --debug flag: %s", token);
free(flags);
return -1;
}
if (strcmp(name, "help") == 0) {
print_debug_usage();
free(flags);
return 1;
} else if (strcmp(token, "all") == 0) {
parsed = LOG_DEBUG_ALL;
} else if (strcmp(name, "all") == 0) {
parsed = level == 0 ? 0 : LOG_DEBUG_ALL;
continue;
} else if (strcmp(token, "none") == 0) {
} else if (strcmp(name, "none") == 0) {
parsed = 0;
continue;
} else if (strcmp(token, "io") == 0) {
} else if (strcmp(name, "io") == 0) {
flag = LOG_DEBUG_IO;
} else if (strcmp(token, "proto") == 0) {
} else if (strcmp(name, "proto") == 0) {
flag = LOG_DEBUG_PROTO;
} else if (strcmp(token, "pack") == 0) {
} else if (strcmp(name, "pack") == 0) {
flag = LOG_DEBUG_PACK;
} else if (strcmp(token, "util") == 0) {
} else if (strcmp(name, "util") == 0) {
flag = LOG_DEBUG_UTIL;
} else {
log_message(LOG_LEVEL_ERROR, "unsupported --debug flag: %s", token);
free(flags);
return -1;
}
parsed |= flag;
if (level == 0)
parsed &= ~flag;
else
parsed |= flag;
}
free(flags);
config->debug_level = (int)parsed;
@@ -461,28 +504,43 @@ static int parse_info_flags(const char* value, Config* config) {
for (char* token = strtok_r(flags, ",", &saveptr); token != NULL;
token = strtok_r(NULL, ",", &saveptr)) {
uint32_t flag = 0;
if (strcmp(token, "all") == 0) {
parsed = LOG_INFO_ALL;
char name[32];
int level = 1;
if (!split_flag_level(token, name, sizeof(name), &level)) {
log_message(LOG_LEVEL_ERROR, "unsupported --info flag: %s", token);
free(flags);
return -1;
}
if (strcmp(name, "all") == 0) {
parsed = level == 0 ? 0 : LOG_INFO_ALL;
continue;
}
if (strcmp(token, "none") == 0) {
if (strcmp(name, "none") == 0) {
parsed = 0;
continue;
}
if (strcmp(token, "copy") == 0)
if (strcmp(name, "help") == 0) {
print_info_usage();
free(flags);
return 1;
}
if (strcmp(name, "copy") == 0 || strcmp(name, "name") == 0)
flag = LOG_INFO_COPY;
else if (strcmp(token, "misc") == 0)
else if (strcmp(name, "misc") == 0)
flag = LOG_INFO_MISC;
else if (strcmp(token, "skip") == 0)
else if (strcmp(name, "skip") == 0)
flag = LOG_INFO_SKIP;
else if (strcmp(token, "stats") == 0)
else if (strcmp(name, "stats") == 0)
flag = LOG_INFO_STATS;
else {
log_message(LOG_LEVEL_ERROR, "unsupported --info flag: %s", token);
free(flags);
return -1;
}
parsed |= flag;
if (level == 0)
parsed &= ~flag;
else
parsed |= flag;
}
free(flags);
config->info_level = (int)parsed;
@@ -1027,17 +1085,22 @@ typedef struct {
} CliParseCtx;
/* Apply output controls before processing other options so their order is
* irrelevant. Returns 0 on success, -1 on error. */
* irrelevant. Returns 0 on success, a positive code for a help request
* (parse_args returns it verbatim), or -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);
} else if (strncmp(argv[i], "--info=", 7) == 0) {
if (parse_info_flags(argv[i] + 7, config) != 0)
return -1;
int ret = parse_info_flags(argv[i] + 7, config);
if (ret != 0)
return ret;
} else if (strcmp(argv[i], "--info") == 0) {
if (i + 1 >= argc || parse_info_flags(argv[++i], config) != 0)
if (i + 1 >= argc)
return -1;
int ret = parse_info_flags(argv[++i], config);
if (ret != 0)
return ret;
}
}
return 0;
@@ -1842,13 +1905,19 @@ static bool cli_handle_logging_options(CliParseCtx* ctx) {
return true;
}
if (strncmp(arg, "--info=", 7) == 0) {
if (parse_info_flags(arg + 7, config) != 0)
ctx->exit_code = -1;
int info_ret = parse_info_flags(arg + 7, config);
if (info_ret != 0)
ctx->exit_code = info_ret;
return true;
}
if (opt_is(arg, "--info", NULL)) {
if (ctx->i + 1 >= ctx->argc || parse_info_flags(ctx->argv[++ctx->i], config) != 0)
if (ctx->i + 1 >= ctx->argc) {
ctx->exit_code = -1;
return true;
}
int info_ret = parse_info_flags(ctx->argv[++ctx->i], config);
if (info_ret != 0)
ctx->exit_code = info_ret;
return true;
}
if (strncmp(arg, "--skip-compress=", 16) == 0) {
@@ -2436,8 +2505,11 @@ int parse_args(Config* config, int argc, char* argv[], int* positional_args,
}
int result = -1;
if (cli_apply_output_controls(config, exp_argc, exp_argv) != 0)
int output_ret = cli_apply_output_controls(config, exp_argc, exp_argv);
if (output_ret != 0) {
result = output_ret;
goto done;
}
CliParseCtx ctx = {
.config = config,
+23 -18
View File
@@ -328,15 +328,15 @@ static char* files_from_missing_dest_path(const Config* config, const char* entr
/* --files-from semantics: every listed entry must resolve under the source
* root, otherwise rsync reports a hard error instead of silently transferring
* nothing. An empty list is also an error. An entry of "." (the whole tree)
* and listed-but-empty directories are valid. With --ignore-missing-args
* nothing. An entry of "." (the whole tree) and listed-but-empty directories
* are valid. An empty list is valid too: rsync transfers nothing and exits 0.
* With --ignore-missing-args
* (implied by --delete-missing-args) a listed-but-missing entry is instead
* skipped: nothing is transferred for it, it never enters the keep-set and the
* run succeeds for the rest (an all-missing non-empty list succeeds
* transferring nothing, matching rsync). With --delete-missing-args
* `missing_dest` (when non-NULL) collects the entry's destination-relative
* mirror for the receiver's exact-deletion request. An empty list stays a
* hard error in every mode (nothing was requested at all). Runs before any
* mirror for the receiver's exact-deletion request. Runs before any
* transfer so the failure/skip is surfaced uniformly in the single-threaded,
* -m, dry-run and --list-only paths. */
static bool files_from_list_check(const Config* config, ArrayList* missing_dest, int* skipped_out) {
@@ -349,12 +349,11 @@ static bool files_from_list_check(const Config* config, ArrayList* missing_dest,
return false;
}
if (set->count == 0) {
char* escaped_list =
output_escape(config->files_from ? config->files_from : "", log_get_8_bit_output());
log_message(LOG_LEVEL_ERROR, "--files-from file '%s' contains no entries; nothing to transfer",
escaped_list ? escaped_list : "<allocation failed>");
free(escaped_list);
return false;
/* rsync treats an empty --files-from list as "nothing to transfer" and
exits 0 (the source directory is still a valid source arg), so this is
not an error. Nothing passes the (empty) allow-set, so no file is sent
and no keep-set entry is produced. */
return true;
}
bool ignore = config->ignore_missing_args || config->delete_missing_args;
for (int i = 0; i < set->count; i++) {
@@ -2679,12 +2678,15 @@ int send_files(Config* config) {
report_transfer_stats(config, total_files, total_bytes, start);
log_info_message(LOG_INFO_STATS, "Transfer summary: %d files, %.1f MB", total_files,
(double)total_bytes / (double)BYTES_PER_MIB);
/* --ignore-errors: an unreadable source directory was skipped but the run
still completed (and deleted); report the run as errored like rsync does.
A --max-delete-capped commit is a successful transfer that rsync reports
/* A skipped source entry (--ignore-errors past an unreadable directory, or a
dereferenced symlink with no referent) makes rsync report a partial
transfer (exit 23) even though the rest of the run succeeded. A
--max-delete-capped commit is a successful transfer that rsync reports
with exit code 25. */
if (!ok || had_scan_io)
if (!ok)
ret = 1;
else if (had_scan_io)
ret = 23;
else
ret = delete_limit ? 25 : 0;
@@ -2921,12 +2923,15 @@ int send_files_multithreaded(Config** config_ptr) {
mtx_unlock(&context->mutex_scanner);
bool sender_ok = sender_result == thrd_success;
bool delete_limit = context->delete_limit;
/* --ignore-errors: the run completed (and deleted) past an unreadable source
directory; report it as errored like rsync does. A --max-delete-capped
commit is a successful transfer that rsync reports with exit code 25. */
/* A skipped source entry (--ignore-errors past an unreadable directory, or a
dereferenced symlink with no referent) makes rsync report a partial
transfer (exit 23). A --max-delete-capped commit is a successful transfer
that rsync reports with exit code 25. */
pipeline_context_sender_destroy(context);
client_set_abort_armed(false);
if (!sender_ok || scan_io)
if (!sender_ok)
return 1;
if (scan_io)
return 23;
return delete_limit ? 25 : 0;
}
+16 -2
View File
@@ -163,6 +163,12 @@ typedef struct {
Size pruning protects the destination mirror even under --delete-excluded,
so it is recorded into a separate sink from `excluded`. */
bool size_excluded;
/* True when a symlink selected for dereferencing (-L/--copy-links or an
unsafe target under --copy-unsafe-links) had no usable referent (a broken
link or a stat() failure). rsync still reports this as a partial transfer
(exit 23) even though the entry is skipped, so the scanner records it as a
non-fatal I/O error. */
bool referent_error;
} ScannerEntry;
/* --one-file-system (-x) decision. Only directories can carry a different
@@ -412,6 +418,7 @@ static int scanner_inspect_entry(const ScannerOptions* options, const char* cont
const char* link_rel, const char* name, ScannerEntry* entry) {
entry->excluded = false;
entry->size_excluded = false;
entry->referent_error = false;
entry->is_symlink = false;
entry->link_target = NULL;
entry->path = path_cat(containing_dir, name);
@@ -438,12 +445,13 @@ static int scanner_inspect_entry(const ScannerOptions* options, const char* cont
goto skip;
case LINK_ACTION_DEREF:
if (stat(entry->path, &entry->stats) != 0) {
/* rsync reports "symlink has no referent" and continues (exit 23); we
surface the same condition rather than silently dropping the entry. */
/* rsync reports "symlink has no referent" and continues with a partial
transfer (exit 23); record the error so the run exits 23 too. */
char* escaped = output_escape(entry->path, log_get_8_bit_output());
log_message(LOG_LEVEL_WARNING, "symlink has no referent: %s",
escaped ? escaped : "<allocation failed>");
free(escaped);
entry->referent_error = true;
goto skip;
}
entry->is_directory = S_ISDIR(entry->stats.st_mode);
@@ -1118,6 +1126,10 @@ Chunk* directory_scanner_next(DirectoryScanner* scanner) {
break;
}
if (inspection == 0) {
/* A dereferenced symlink with no referent is a partial-transfer error
(rsync exit 23): record it as a non-fatal scan I/O error. */
if (inspected.referent_error)
scanner->io_error = true;
/* A user-selection exclude protects its destination mirror from --delete
unless --delete-excluded; a size prune is always protected. Other
skips (unreadable, symlink policy) protect nothing. Under -R +
@@ -1511,6 +1523,8 @@ static void scan_root_entry(const ScannerOptions* options, const FilterNode* roo
return;
}
if (inspection == 0) {
if (inspected.referent_error)
ps->io_error = true;
ArrayList* sink = NULL;
if (inspected.excluded)
sink = inspected.size_excluded ? options->size_skipped_paths : options->excluded_paths;
+11 -3
View File
@@ -171,8 +171,8 @@ void print_usage(void) {
printf(" -v, --verbose Enable debug logging\n");
printf(" -q, --quiet Suppress non-error output\n");
printf(" --debug=FLAGS Fine-grained debug logging (use --debug=help for flags)\n");
printf(" --info=FLAGS Fine-grained info: copy,misc,skip,stats,all,none\n");
printf(" none suppresses info even with --verbose\n");
printf(" --info=FLAGS Fine-grained info: copy,name,misc,skip,stats,all,none\n");
printf(" (use --info=help for flags; none suppresses --verbose)\n");
printf(" --preserve Preserve permissions and times (= -pt; long form only)\n");
printf(" --no-perms Negate -p/--perms\n");
printf(" --no-times Negate -t/--times\n");
@@ -340,5 +340,13 @@ void print_usage(void) {
void print_debug_usage(void) {
printf("Supported debug flags: IO,PROTO,PACK,UTIL,ALL,NONE\n");
printf("Flags may be comma-separated, for example: --debug=io,proto\n");
printf("Other rsync debug flags are unsupported and rejected.\n");
printf("An optional level suffix is accepted (e.g. --debug=io2); level 0\n");
printf("silences that item. Other rsync debug flags are unsupported and rejected.\n");
}
void print_info_usage(void) {
printf("Supported info flags: COPY,NAME,MISC,SKIP,STATS,ALL,NONE\n");
printf("Flags may be comma-separated, for example: --info=name,stats\n");
printf("An optional level suffix is accepted (e.g. --info=stats2); level 0\n");
printf("silences that item. Other rsync info flags are unsupported and rejected.\n");
}
+1
View File
@@ -3,5 +3,6 @@
void print_usage(void);
void print_debug_usage(void);
void print_info_usage(void);
#endif