fix: filter precedence, files-from errors, NUL/CRLF and filter-rule rejections

Address an independent c-review of the files-from/filter feature:

- .rsync-filter precedence now matches rsync: evaluate the innermost
  (current) directory's rules first, then ancestors, then the command-line
  base (--filter/-C), so a deeper file's '+' can re-include what a shallower
  '-' excluded (regression tests in both scan modes; single-thread and -m).
- --files-from: a listed entry missing on disk and an empty list are now hard
  errors surfaced pre-transfer in send_files, send_files_multithreaded,
  dry-run and --list-only; '.' (whole tree) and empty listed dirs stay valid.
- scanner_path_relative now handles a transfer root of / (previously the
  scanner aborted on children of /).
- Reject unsupported rsync filter syntax explicitly (no silent no-ops):
  +/- modifiers other than '/' (! C s r p x) and rules beginning with ':'/'.'
  /'!' (merge/dir-merge/list-clear shorthands). Docs updated.
- -0/--from0 NUL mode preserves entry bytes (no CR/LF trimming); only newline
  mode trims. Absolute-entry error message no longer includes the newline.
- --no-from0/--no-cvs-exclude registered as negatable booleans.
- RSYNC_COMPAT rows updated for the precedence, rejection list, NUL-mode
  detail and the documented O(entries x files) scalability bound of the
  allow-set (Summary unchanged: 62/3/5/1/76 = 147).
This commit is contained in:
2026-09-06 14:16:12 +02:00
parent f4c15a7b78
commit 7f2180ef90
11 changed files with 339 additions and 29 deletions
+2
View File
@@ -473,6 +473,8 @@ static const NegatableOption NEGATABLE_OPTIONS[] = {
{"sparse", "S", offsetof(Config, preserve_sparse)},
{"inplace", NULL, offsetof(Config, inplace)},
{"checksum", NULL, offsetof(Config, checksum)},
{"from0", NULL, offsetof(Config, from0)},
{"cvs-exclude", NULL, offsetof(Config, cvs_exclude)},
/* These options are also implied by --archive or handled outside the table. */
{"compress", "c", offsetof(Config, use_compression)},
+48
View File
@@ -110,6 +110,46 @@ static void prepared_scanner_destroy(PreparedScanner* prepared) {
prepared->base_filters = NULL;
}
/* --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. Runs before any transfer so the
* failure is surfaced uniformly in the single-threaded, -m, dry-run and
* --list-only paths. */
static bool files_from_list_valid(const Config* config) {
const FileListSet* set = (const FileListSet*)config->files_from_set;
if (!set)
return true;
if (!config->send_directory) {
log_message(LOG_LEVEL_ERROR, "--files-from requires a source directory");
return false;
}
if (set->count == 0) {
log_message(LOG_LEVEL_ERROR, "--files-from file '%s' contains no entries; nothing to transfer",
config->files_from ? config->files_from : "");
return false;
}
for (int i = 0; i < set->count; i++) {
const char* entry = set->entries[i];
if (entry[0] == '\0')
continue; /* "." == list the whole tree */
char* full = path_cat(config->send_directory, entry);
if (!full) {
log_message(LOG_LEVEL_ERROR, "memory allocation failed while validating --files-from");
return false;
}
struct stat st;
if (lstat(full, &st) != 0) {
log_message(LOG_LEVEL_ERROR, "--files-from entry '%s' not found in source '%s'", entry,
config->send_directory);
free(full);
return false;
}
free(full);
}
return true;
}
/* Select the configured transport for both transfer execution paths. */
static Client* connect_transfer_client(const Config* config) {
if (config->transport == TRANSPORT_SSH) {
@@ -307,6 +347,8 @@ static void pipeline_cancel(PipelineContextSender* context) {
/* Print dry-run manifest showing files that would be transferred. Returns 0 on success. */
static int send_dry_run_manifest(const Config* config) {
if (!files_from_list_valid(config))
return -1;
PreparedScanner prepared;
if (!prepare_scanner(config, 0, &prepared))
return -1;
@@ -383,6 +425,8 @@ static int compare_list_entries(const void* left, const void* right) {
* Directory lines are not printed because the scanner only yields regular
* transfer candidates. Returns 0 on success, 1 on error. */
static int send_list_only(const Config* config) {
if (!files_from_list_valid(config))
return 1;
PreparedScanner prepared;
if (!prepare_scanner(config, 0, &prepared))
return 1;
@@ -1002,6 +1046,8 @@ int send_files(Config* config) {
return send_list_only(config);
if (config->dry_run)
return send_dry_run_manifest(config);
if (!files_from_list_valid(config))
return 1;
Client* client = connect_transfer_client(config);
if (!client) {
@@ -1139,6 +1185,8 @@ int send_files_multithreaded(Config** config_ptr) {
return send_list_only(config);
if (config->dry_run)
return send_dry_run_manifest(config);
if (!files_from_list_valid(config))
return 1;
long pages = sysconf(_SC_AVPHYS_PAGES);
long page_size = sysconf(_SC_PAGE_SIZE);
+22 -10
View File
@@ -22,7 +22,9 @@ typedef struct {
/* A chain node: `own` holds the .rsync-filter rules of one directory, `parent`
* the context that directory inherited (nearest ancestor with a filter file).
* Rules are evaluated base-first, then from the outermost node inward. */
* The chain for a directory's contents runs from that directory's own node up
* to the root; the command-line base rules are evaluated after the whole
* chain. */
struct FilterNode {
FilterNode* parent;
FilterRuleList* own;
@@ -46,15 +48,19 @@ static FilterNode* filter_node_alloc(FilterNode* parent, FilterRuleList* own) {
return node;
}
/* Evaluate a rule chain (base rules, then per-directory nodes outermost
* first). Returns FILTER_ACTION_NONE when nothing matched. */
/* Evaluate a rule chain for an entry inside the directory whose content
* context is `node`. rsync precedence, highest first: the innermost (current)
* directory's .rsync-filter rules, then each ancestor's, then the root's, and
* finally the command-line base rules (--filter/-C). A deeper per-directory
* file therefore overrides a shallower one, and per-directory files override
* the base rules by default. Returns FILTER_ACTION_NONE when nothing matched. */
static FilterAction chain_rules_apply(const FilterRuleList* base, const FilterNode* node,
const char* rel, const char* leaf, bool is_dir) {
if (node) {
FilterAction parent_action = chain_rules_apply(base, node->parent, rel, leaf, is_dir);
if (parent_action != FILTER_ACTION_NONE)
return parent_action;
return filter_rules_apply(node->own, rel, leaf, is_dir);
FilterAction own_action = filter_rules_apply(node->own, rel, leaf, is_dir);
if (own_action != FILTER_ACTION_NONE)
return own_action;
return chain_rules_apply(base, node->parent, rel, leaf, is_dir);
}
return base ? filter_rules_apply(base, rel, leaf, is_dir) : FILTER_ACTION_NONE;
}
@@ -117,13 +123,19 @@ bool scanner_same_filesystem(bool one_file_system, dev_t root_device, dev_t entr
/* Relative path of an on-disk path below `root`. The transfer root may be
* given with a trailing slash; the returned rel path never has one and is ""
* for the root itself. */
static char* rel_for_fs_path(const char* root, const char* fs_path) {
* for the root itself. A root of "/" is handled (its children start at "/").
* Exposed so tests can exercise the mapping directly. */
char* scanner_path_relative(const char* root, const char* fs_path) {
size_t root_len = strlen(root);
while (root_len > 1 && root[root_len - 1] == '/')
root_len--;
if (strncmp(root, fs_path, root_len) != 0)
return NULL;
if (root_len == 1 && root[0] == '/') {
if (fs_path[1] == '\0')
return str_dup("");
return str_dup(fs_path + 1);
}
if (fs_path[root_len] == '\0')
return str_dup("");
if (fs_path[root_len] != '/')
@@ -413,7 +425,7 @@ static int open_next_directory(DirectoryScanner* scanner) {
free(de);
free(scanner->current_rel);
scanner->current_rel = rel_for_fs_path(scanner->root_path, scanner->current_path);
scanner->current_rel = scanner_path_relative(scanner->root_path, scanner->current_path);
if (!scanner->current_rel) {
log_message(LOG_LEVEL_ERROR, "Could not compute relative path under %s", scanner->root_path);
scanner->failed = true;
+5
View File
@@ -111,6 +111,11 @@ void directory_scanner_destroy(DirectoryScanner* scanner);
* the transfer root. Exposed so tests can exercise the rule directly. */
bool scanner_same_filesystem(bool one_file_system, dev_t root_device, dev_t entry_device);
/* Relative path of an on-disk path below `root` ("" == the root itself, NULL
* when `fs_path` is not under `root`). Handles trailing slashes and a root of
* "/". Exposed so tests can exercise the mapping directly. */
char* scanner_path_relative(const char* root, const char* fs_path);
ParallelScanner* parallel_scanner_create_with_options(const char* root_directory,
const ScannerOptions* options,
ProtocolSession* allocation_session);