fix(parity): empty-dir delete, per-dir filter errors, -R protect, stats parser
Blockers addressed together (shared scanner/delete-plan plumbing): * #10: an empty in-scope source directory produced no plan keep entry, so the receiver deleted the destination directory itself. The scanner now records every traversed directory into a delete-plan sink, the plan sender keeps them, and any directory whose plan the data stream never triggered is emitted after the data so its extras are still removed. Differential tests cover --delete-during and --delete-delay. * #8: an invalid per-directory filter file was silently ignored when an earlier merge file in the same directory existed; key the failure off the error text (both sequential and parallel scanners) and fail the scan. * #9: -R + --files-from receiver-protect rules recorded the source-relative path; record the bare relative wire path in both scanners so the protected destination mirror survives --delete. * #5: the STATUS_STATS would-delete parser now validates each retained path and enforces the shared MAX_MANIFEST_BYTES budget, and the --out-format dry-run delete line is escaped like the itemize line. * #11: drop the unused DELETE_PLAN_MAX_NAMES macro, log the delete-limit warning once per session, roll back dir-merge names from a per-directory file that fails to parse, and guard every filter error snprintf against err==NULL. #10 leaves the empty directory itself kept and its extras removed, matching rsync's final state on both per-directory timings.
This commit is contained in:
+64
-10
@@ -923,15 +923,27 @@ static bool receive_stats_record(int fd, ReceiverStats* stats, ArrayList* would_
|
||||
int count = 0;
|
||||
if (!receive_int(fd, &count) || count < 0 || count > MAX_MANIFEST_ENTRIES)
|
||||
return false;
|
||||
/* Mirror the delete-plan parser: every retained path must be a valid
|
||||
destination-relative path, and the whole list shares one MAX_MANIFEST_BYTES
|
||||
budget so a hostile peer cannot make the client retain unbounded memory. */
|
||||
size_t bytes = 0;
|
||||
for (int i = 0; i < count; i++) {
|
||||
char* path = receive_wire_str(fd);
|
||||
if (!path)
|
||||
return false;
|
||||
if (would_delete) {
|
||||
char* copy = str_dup(path);
|
||||
if (path[0] == '\0' || path[0] == '/' || has_path_traversal(path)) {
|
||||
free(path);
|
||||
if (!copy || !array_list_add(would_delete, copy)) {
|
||||
free(copy);
|
||||
return false;
|
||||
}
|
||||
if (would_delete) {
|
||||
size_t entry_size = strlen(path) + sizeof(char*) + 16;
|
||||
if (entry_size > MAX_MANIFEST_BYTES - bytes) {
|
||||
free(path);
|
||||
return false;
|
||||
}
|
||||
bytes += entry_size;
|
||||
if (!array_list_add(would_delete, path)) {
|
||||
free(path);
|
||||
return false;
|
||||
}
|
||||
} else {
|
||||
@@ -1455,6 +1467,19 @@ static bool scan_paths_only(const Config* config, const ScannerOptions* options,
|
||||
}
|
||||
chunk_destroy(chunk);
|
||||
}
|
||||
if (ok) {
|
||||
/* Keep every traversed source directory, including empty ones, so a plan
|
||||
no longer removes the destination directory itself. Their own plans are
|
||||
emitted after the data stream (no file frame triggers them). */
|
||||
if (plans && options->plan_dirs) {
|
||||
for (int i = 0; i < options->plan_dirs->size; i++) {
|
||||
if (!delete_plan_sender_add(plans, (const char*)options->plan_dirs->items[i], true)) {
|
||||
ok = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (ok && directory_scanner_failed(scanner))
|
||||
ok = false;
|
||||
if (io_error_out)
|
||||
@@ -1929,7 +1954,12 @@ static int send_dry_run_remote(Config* config) {
|
||||
event.path = path;
|
||||
char* line = change_render_format(config->out_format, config, &event);
|
||||
if (line) {
|
||||
printf("%s\n", line);
|
||||
/* Escape the whole rendered line, exactly like change_emit() does
|
||||
for a real transfer, so a control byte in the peer-supplied path
|
||||
cannot forge output. */
|
||||
char* escaped = output_escape(line, config->eight_bit_output);
|
||||
printf("%s\n", escaped ? escaped : line);
|
||||
free(escaped);
|
||||
free(line);
|
||||
}
|
||||
} else {
|
||||
@@ -2474,6 +2504,13 @@ static int send_chunks_multithreaded(void* pipeline_context) {
|
||||
NULL) != 0)
|
||||
goto send_fail;
|
||||
}
|
||||
/* Emit the plans for source directories the data stream never triggered
|
||||
(empty directories): their extras are still cleared while the directory
|
||||
itself is kept. */
|
||||
if (!context->scan_stopped_early && context->delete_plans && context->plan_dirs &&
|
||||
delete_plan_send_remaining(client->file_descriptor, context->delete_plans,
|
||||
context->plan_dirs) != 0)
|
||||
goto send_fail;
|
||||
/* P7 Wave D: transmit the captured directory times last. The scanner thread
|
||||
(and all parallel workers) has been joined before scanner_done was set, so
|
||||
the list is complete and race-free; on an early stop the list may be
|
||||
@@ -2808,6 +2845,8 @@ int send_files(Config* config) {
|
||||
/* Size-pruned prefixes (always protected) and synchronized directories. */
|
||||
ArrayList* size_skipped = NULL;
|
||||
ArrayList* synced_dirs = NULL;
|
||||
/* Traversed source directories for the per-directory delete keep set. */
|
||||
ArrayList* plan_dirs = NULL;
|
||||
bool delete_early = config->use_delete && config_delete_timing_early(config);
|
||||
/* -d/--dirs does not recurse, so a per-directory plan would carry no child
|
||||
information and could delete the contents of an untraversed directory;
|
||||
@@ -2908,13 +2947,16 @@ int send_files(Config* config) {
|
||||
receive root's extras are handled exactly like rsync's first generator
|
||||
directory. The remaining plans are streamed with the data below. */
|
||||
plan_sender = delete_plan_sender_create();
|
||||
if (!plan_sender)
|
||||
plan_dirs = array_list_create(free);
|
||||
if (!plan_sender || !plan_dirs)
|
||||
goto send_fail;
|
||||
prepared.options.plan_dirs = plan_dirs;
|
||||
bool prescan_ok = scan_paths_only(config, &prepared.options, NULL, plan_sender, &had_scan_io);
|
||||
bool plans_ok = false;
|
||||
if (prescan_ok) {
|
||||
const char* walk_root = delete_plan_walk_root(config, synced_dirs);
|
||||
const ArrayList* scope = config->files_from_set ? synced_dirs : (walk_root ? synced_dirs : NULL);
|
||||
const ArrayList* scope =
|
||||
config->files_from_set ? synced_dirs : (walk_root ? synced_dirs : NULL);
|
||||
delete_plan_sender_finalize(plan_sender, scope, walk_root);
|
||||
delete_plan_sender_set_config(plan_sender, excluded, size_skipped, missing_args);
|
||||
if (had_scan_io && delete_plan_sender_empty(plan_sender)) {
|
||||
@@ -2929,6 +2971,7 @@ int send_files(Config* config) {
|
||||
prepared.options.excluded_paths = NULL;
|
||||
prepared.options.size_skipped_paths = NULL;
|
||||
prepared.options.synced_dirs = NULL;
|
||||
prepared.options.plan_dirs = NULL;
|
||||
if (!prescan_ok || !plans_ok)
|
||||
goto send_fail;
|
||||
} else if (config->use_delete) {
|
||||
@@ -3085,6 +3128,12 @@ int send_files(Config* config) {
|
||||
}
|
||||
}
|
||||
}
|
||||
/* Emit the plans for any source directories the data stream never triggered
|
||||
(an empty directory has no file frame). Sending them now still clears that
|
||||
directory's destination extras while keeping the directory itself. */
|
||||
if (!scan_stopped_early && plan_sender && plan_dirs &&
|
||||
delete_plan_send_remaining(client->file_descriptor, plan_sender, plan_dirs) != 0)
|
||||
goto send_fail;
|
||||
/* P7 Wave D: every directory has now been traversed (or the scan stopped
|
||||
early), so transmit the captured directory times last. The receiver defers
|
||||
applying them until after its own deletion/publication phase. */
|
||||
@@ -3127,6 +3176,8 @@ send_fail:
|
||||
array_list_delete(size_skipped);
|
||||
if (synced_dirs)
|
||||
array_list_delete(synced_dirs);
|
||||
if (plan_dirs)
|
||||
array_list_delete(plan_dirs);
|
||||
if (missing_args)
|
||||
array_list_delete(missing_args);
|
||||
if (remove_sources)
|
||||
@@ -3268,7 +3319,10 @@ int send_files_multithreaded(Config** config_ptr) {
|
||||
}
|
||||
if (per_dir) {
|
||||
context->delete_plans = delete_plan_sender_create();
|
||||
prepared_ok = prepared_ok && context->delete_plans != NULL;
|
||||
context->plan_dirs = array_list_create(free);
|
||||
prepared_ok = prepared_ok && context->delete_plans != NULL && context->plan_dirs != NULL;
|
||||
if (prepared_ok)
|
||||
prepared.options.plan_dirs = context->plan_dirs;
|
||||
} else {
|
||||
context->manifest = array_list_create(free);
|
||||
prepared_ok = prepared_ok && context->manifest != NULL;
|
||||
@@ -3279,8 +3333,8 @@ int send_files_multithreaded(Config** config_ptr) {
|
||||
prepared_scanner_destroy(&prepared);
|
||||
if (per_dir && prebuilt) {
|
||||
const char* walk_root = delete_plan_walk_root(config, context->synced_dirs);
|
||||
const ArrayList* scope =
|
||||
config->files_from_set ? context->synced_dirs : (walk_root ? context->synced_dirs : NULL);
|
||||
const ArrayList* scope = config->files_from_set ? context->synced_dirs
|
||||
: (walk_root ? context->synced_dirs : NULL);
|
||||
delete_plan_sender_finalize(context->delete_plans, scope, walk_root);
|
||||
delete_plan_sender_set_config(context->delete_plans, context->excluded_paths,
|
||||
context->size_skipped_paths, context->missing_args);
|
||||
|
||||
+31
-12
@@ -449,7 +449,7 @@ static void scanner_record_size_skipped(DirectoryScanner* scanner, const char* f
|
||||
Returns false on allocation failure. */
|
||||
static bool scanner_record_synced_dir(const ScannerOptions* options, const char* fs_path,
|
||||
const char* rel, bool relative_mode) {
|
||||
if (!options->synced_dirs)
|
||||
if (!options->synced_dirs && !options->plan_dirs)
|
||||
return true;
|
||||
if (!file_list_dir_in_scope(options->file_list, rel))
|
||||
return true;
|
||||
@@ -469,7 +469,14 @@ static bool scanner_record_synced_dir(const ScannerOptions* options, const char*
|
||||
dest++;
|
||||
if (dest[0] == '\0')
|
||||
dest = ".";
|
||||
bool ok = excluded_sink_append(options->synced_dirs, options->excluded_mutex, dest);
|
||||
bool ok = true;
|
||||
if (options->synced_dirs)
|
||||
ok = excluded_sink_append(options->synced_dirs, options->excluded_mutex, dest);
|
||||
/* The delete-plan keep set needs an entry for every traversed source
|
||||
directory, including empty ones, so its destination mirror is kept rather
|
||||
than deleted as an extra; the receive root (".") is implicit. */
|
||||
if (ok && options->plan_dirs && strcmp(dest, ".") != 0)
|
||||
ok = excluded_sink_append(options->plan_dirs, options->excluded_mutex, dest);
|
||||
free(prefixed);
|
||||
return ok;
|
||||
}
|
||||
@@ -528,11 +535,11 @@ static int open_directory_filter_context(DirectoryScanner* scanner, const Filter
|
||||
FilterRuleList* own = read_dir_filters(&scanner->options, scanner->current_path,
|
||||
scanner->current_rel ? scanner->current_rel : "",
|
||||
&any_exists, err, sizeof(err));
|
||||
if (!own && any_exists) {
|
||||
scanner->current_node = (FilterNode*)inherited;
|
||||
return 0;
|
||||
}
|
||||
if (!own) {
|
||||
/* read_dir_filters() leaves `err` set on a parse/allocation failure even
|
||||
when an earlier merge file in the same directory existed (any_exists true);
|
||||
key off the error text rather than any_exists so an invalid per-directory
|
||||
filter file can never be silently ignored. */
|
||||
if (err[0] == '\0') {
|
||||
scanner->current_node = (FilterNode*)inherited;
|
||||
return 0;
|
||||
@@ -1429,7 +1436,12 @@ Chunk* directory_scanner_next(DirectoryScanner* scanner) {
|
||||
wire paths are never recorded (see ScannerOptions.excluded_paths). */
|
||||
bool files_from_prune =
|
||||
scanner->options.file_list && !file_list_affects(scanner->options.file_list, rel);
|
||||
if (!files_from_prune && !scanner->relative_mode) {
|
||||
if (protect && scanner->relative_mode) {
|
||||
/* -R + --files-from: the destination/wire path is the bare relative
|
||||
name, so the protected mirror prefix must be `rel` (not the source
|
||||
path) for the delete walker to match it. */
|
||||
scanner_record_excluded(scanner, rel);
|
||||
} else if (!files_from_prune && !scanner->relative_mode) {
|
||||
if (scanner->options.relative_prefix) {
|
||||
char* wrel = scanner_prefix_send_path(scanner->options.relative_prefix, rel);
|
||||
if (!wrel) {
|
||||
@@ -1856,9 +1868,13 @@ static void scan_root_entry(const ScannerOptions* options, const FilterNode* roo
|
||||
exclusions are never recorded (see ScannerOptions.excluded_paths). */
|
||||
bool files_from_prune = options->file_list && !file_list_affects(options->file_list, rel);
|
||||
if ((!files_from_prune && !use_rel) || protect) {
|
||||
const char* rel_path = *cur_path == '/' ? cur_path + 1 : cur_path;
|
||||
const char* rel_path;
|
||||
char* prefixed = NULL;
|
||||
if (options->relative_prefix) {
|
||||
if (use_rel) {
|
||||
/* -R + --files-from: the destination/wire path is the bare relative
|
||||
name, not the source path. */
|
||||
rel_path = rel;
|
||||
} else if (options->relative_prefix) {
|
||||
prefixed = scanner_prefix_send_path(options->relative_prefix, entry->d_name);
|
||||
if (!prefixed) {
|
||||
free(rel);
|
||||
@@ -1867,6 +1883,8 @@ static void scan_root_entry(const ScannerOptions* options, const FilterNode* roo
|
||||
return;
|
||||
}
|
||||
rel_path = prefixed;
|
||||
} else {
|
||||
rel_path = *cur_path == '/' ? cur_path + 1 : cur_path;
|
||||
}
|
||||
if (options->excluded_paths &&
|
||||
!excluded_sink_append(options->excluded_paths, options->excluded_mutex, rel_path))
|
||||
@@ -2148,9 +2166,9 @@ ParallelScanner* parallel_scanner_create_with_options(const char* root_directory
|
||||
bool any_exists = false;
|
||||
FilterRuleList* own =
|
||||
read_dir_filters(options, root_directory, "", &any_exists, err, sizeof(err));
|
||||
if (!own && any_exists) {
|
||||
/* no files exist: leave root_node NULL */
|
||||
} else if (!own) {
|
||||
if (!own) {
|
||||
/* A parse/allocation failure must fail the scan even when an earlier
|
||||
merge file in the same directory existed (see the sequential scanner). */
|
||||
if (err[0] != '\0') {
|
||||
log_message(LOG_LEVEL_ERROR, "invalid per-directory filter in %s: %s", root_directory, err);
|
||||
array_list_delete(root_files);
|
||||
@@ -2158,6 +2176,7 @@ ParallelScanner* parallel_scanner_create_with_options(const char* root_directory
|
||||
parallel_scanner_destroy(ps);
|
||||
return NULL;
|
||||
}
|
||||
/* no files exist: leave root_node NULL */
|
||||
} else if (any_exists && (own->count > 0 || own->dir_merge_count > 0)) {
|
||||
root_node = filter_node_alloc(NULL, own);
|
||||
if (!root_node) {
|
||||
|
||||
@@ -114,6 +114,13 @@ typedef struct {
|
||||
* directories, exactly like rsync; the receive root is the "." sentinel.
|
||||
* Guarded by `excluded_mutex`. */
|
||||
ArrayList* synced_dirs;
|
||||
/* Delete-plan directory sink (optional): when non-NULL the scanner appends
|
||||
* the destination-relative path of every directory it traverses (except the
|
||||
* receive root). The per-directory --delete-during/--delete-delay plan
|
||||
* builder uses this to keep an empty in-scope source directory (rsync keeps
|
||||
* it) and to emit its plan after the data stream, when no file frame would
|
||||
* otherwise trigger it. Guarded by `excluded_mutex`. */
|
||||
ArrayList* plan_dirs;
|
||||
/* --ignore-errors: an unreadable directory during the scan is recorded as an
|
||||
* I/O error and skipped instead of aborting the scan. Client-only. */
|
||||
bool ignore_io_errors;
|
||||
|
||||
Reference in New Issue
Block a user