rsync 3.4.1 drop-in parity (#285-#297) + parity completion (protocol 2.26.0) #298

Merged
TapTap merged 77 commits from feat/rsync-parity into dev 2026-09-17 20:33:17 +02:00
9 changed files with 885 additions and 336 deletions
Showing only changes of commit 394a9aae22 - Show all commits
+18 -4
View File
@@ -656,16 +656,25 @@ static int config_add_pattern(char*** patterns, int* count, const char* value,
/* Validate and append one --filter=RULE string. Returns 0 on success, -1 on error. */ /* Validate and append one --filter=RULE string. Returns 0 on success, -1 on error. */
static int config_add_filter(Config* config, const char* rule) { static int config_add_filter(Config* config, const char* rule) {
char err[160]; char err[256];
FilterRule* parsed = filter_rule_parse(rule, err, sizeof(err)); /* Validate through the full list parser so clear/merge/dir-merge and the rule
if (!parsed) { modifiers are accepted (and a merge file is readable) at parse time. */
FilterParseOptions opts = {.delete_excluded = config->delete_excluded,
.cvs_exclude = config->cvs_exclude};
FilterRuleList* probe = filter_rule_list_create();
if (!probe) {
log_message(LOG_LEVEL_ERROR, "memory allocation failed for --filter");
return -1;
}
bool ok = filter_rule_list_parse_append(probe, rule, &opts, NULL, err, sizeof(err));
filter_rule_list_free(probe);
if (!ok) {
char* escaped = output_escape(rule, log_get_8_bit_output()); char* escaped = output_escape(rule, log_get_8_bit_output());
log_message(LOG_LEVEL_ERROR, "invalid --filter rule '%s': %s", log_message(LOG_LEVEL_ERROR, "invalid --filter rule '%s': %s",
escaped ? escaped : "<allocation failed>", err); escaped ? escaped : "<allocation failed>", err);
free(escaped); free(escaped);
return -1; return -1;
} }
filter_rule_free(parsed);
if (!config->filters) { if (!config->filters) {
config->filters = array_list_create(free); config->filters = array_list_create(free);
if (!config->filters) { if (!config->filters) {
@@ -1324,6 +1333,11 @@ static bool cli_handle_table_option(CliParseCtx* ctx) {
} }
if (entry->offset == offsetof(Config, eight_bit_output)) if (entry->offset == offsetof(Config, eight_bit_output))
protocol_set_8_bit_output(true); protocol_set_8_bit_output(true);
/* -F is repeatable: rsync's single -F transfers .rsync-filter files, a
repeated -FF excludes them. Count the occurrences so the scanner can
distinguish the two. */
if (entry->offset == offsetof(Config, per_dir_filter) && config->per_dir_filter_count < INT_MAX)
config->per_dir_filter_count++;
/* A delete-timing flag selects when --delete removes extras, so it /* A delete-timing flag selects when --delete removes extras, so it
implies --delete exactly like the rsync options do. */ implies --delete exactly like the rsync options do. */
if (entry->offset == offsetof(Config, delete_before) || if (entry->offset == offsetof(Config, delete_before) ||
+4 -1
View File
@@ -159,7 +159,8 @@ static bool prepare_scanner(const Config* config, int num_threads, PreparedScann
} }
if (rule_count > 0 || config->cvs_exclude) { if (rule_count > 0 || config->cvs_exclude) {
char err[160]; char err[160];
out->base_filters = filter_base_build(texts, rule_count, config->cvs_exclude, err, sizeof(err)); out->base_filters = filter_base_build(texts, rule_count, config->cvs_exclude,
config->delete_excluded, err, sizeof(err));
free(texts); free(texts);
if (!out->base_filters) { if (!out->base_filters) {
log_message(LOG_LEVEL_ERROR, "invalid filter rule: %s", err); log_message(LOG_LEVEL_ERROR, "invalid filter rule: %s", err);
@@ -204,6 +205,8 @@ static bool prepare_scanner(const Config* config, int num_threads, PreparedScann
options->file_list = (const FileListSet*)config->files_from_set; options->file_list = (const FileListSet*)config->files_from_set;
options->base_filters = out->base_filters; options->base_filters = out->base_filters;
options->per_dir_filters = config->per_dir_filter; options->per_dir_filters = config->per_dir_filter;
options->delete_excluded = config->delete_excluded;
options->exclude_per_dir_filter_files = config->per_dir_filter_count >= 2;
options->dirs = config->dirs; options->dirs = config->dirs;
options->relative = config->relative; options->relative = config->relative;
options->prune_empty_dirs = config->prune_empty_dirs; options->prune_empty_dirs = config->prune_empty_dirs;
+156 -54
View File
@@ -51,29 +51,64 @@ static FilterNode* filter_node_alloc(FilterNode* parent, FilterRuleList* own) {
return node; return node;
} }
/* Evaluate a rule chain for an entry inside the directory whose content /* Evaluate a rule chain for one entry. rsync precedence, highest first: the
* context is `node`. rsync precedence, highest first: the innermost (current) * innermost (current) directory's .rsync-filter rules, then each ancestor's,
* directory's .rsync-filter rules, then each ancestor's, then the root's, and * then the root's, and finally the command-line base rules (--filter/-C). The
* finally the command-line base rules (--filter/-C). A deeper per-directory * sender-side verdict decides whether the entry is hidden from the transfer;
* file therefore overrides a shallower one, and per-directory files override * the receiver-side verdict decides whether its destination mirror is protected
* the base rules by default. Returns FILTER_ACTION_NONE when nothing matched. */ * from --delete. Each side takes the FIRST matching rule independently. */
static FilterAction chain_rules_apply(const FilterRuleList* base, const FilterNode* node, typedef struct {
const char* rel, const char* leaf, bool is_dir) { bool hide; /* sender-side exclude matched */
if (node) { bool protect; /* receiver-side exclude matched */
FilterAction own_action = filter_rules_apply(node->own, rel, leaf, is_dir); } FilterOutcome;
if (own_action != FILTER_ACTION_NONE)
return own_action; static void chain_rules_outcome(const FilterRuleList* base, const FilterNode* node,
return chain_rules_apply(base, node->parent, rel, leaf, is_dir); const char* rel, const char* leaf, bool is_dir,
FilterOutcome* out) {
memset(out, 0, sizeof(*out));
bool sender_decided = false;
bool receiver_decided = false;
const FilterNode* n = node;
while (!sender_decided || !receiver_decided) {
const FilterRuleList* list = n ? n->own : base;
if (list) {
if (!sender_decided) {
FilterAction action = filter_rules_apply_side(list, rel, leaf, is_dir, FILTER_SIDE_SENDER);
if (action != FILTER_ACTION_NONE) {
out->hide = action == FILTER_ACTION_EXCLUDE;
sender_decided = true;
}
}
if (!receiver_decided) {
FilterAction action =
filter_rules_apply_side(list, rel, leaf, is_dir, FILTER_SIDE_RECEIVER);
if (action != FILTER_ACTION_NONE) {
out->protect = action == FILTER_ACTION_PROTECT;
receiver_decided = true;
}
}
}
if (!n)
break;
n = n->parent;
} }
return base ? filter_rules_apply(base, rel, leaf, is_dir) : FILTER_ACTION_NONE;
} }
static bool entry_allowed(const FilterRuleList* base, const FilterNode* node, const char* rel, static bool entry_allowed(const FilterRuleList* base, const FilterNode* node, const char* rel,
const char* leaf, bool is_dir, bool per_dir_filters) { const char* leaf, bool is_dir, bool exclude_filter_files,
/* -F: per-directory .rsync-filter files are never transferred. */ bool* protect_out) {
if (per_dir_filters && !is_dir && strcmp(leaf, ".rsync-filter") == 0) /* -FF: per-directory .rsync-filter files are never transferred (single -F
transfers them, matching rsync). */
if (exclude_filter_files && !is_dir && strcmp(leaf, ".rsync-filter") == 0) {
if (protect_out)
*protect_out = false;
return false; return false;
return chain_rules_apply(base, node, rel, leaf, is_dir) != FILTER_ACTION_EXCLUDE; }
FilterOutcome outcome;
chain_rules_outcome(base, node, rel, leaf, is_dir, &outcome);
if (protect_out)
*protect_out = outcome.protect;
return !outcome.hide;
} }
static void dir_entry_destroy(void* item) { static void dir_entry_destroy(void* item) {
@@ -227,14 +262,19 @@ static char* child_rel_path(const char* parent_rel, const char* name) {
return path_cat(parent_rel, name); return path_cat(parent_rel, name);
} }
/* Apply the --files-from allow-set and the filter layer to one entry. */ /* Apply the --files-from allow-set and the filter layer to one entry. On
* return `*protect_out` is true when a receiver-side rule protects the entry's
* destination mirror from deletion. */
static bool entry_passes_selection(const FileListSet* file_list, const FilterRuleList* base, static bool entry_passes_selection(const FileListSet* file_list, const FilterRuleList* base,
const FilterNode* node, const char* rel, const char* leaf, const FilterNode* node, const char* rel, const char* leaf,
bool is_dir, bool per_dir_filters) { bool is_dir, bool per_dir_filters, bool exclude_filter_files,
bool* protect_out) {
if (protect_out)
*protect_out = false;
if (file_list && !file_list_affects(file_list, rel)) if (file_list && !file_list_affects(file_list, rel))
return false; return false;
if (base || per_dir_filters) if (base || per_dir_filters)
return entry_allowed(base, node, rel, leaf, is_dir, per_dir_filters); return entry_allowed(base, node, rel, leaf, is_dir, exclude_filter_files, protect_out);
return true; return true;
} }
@@ -375,28 +415,77 @@ static bool scanner_record_synced_dir(const ScannerOptions* options, const char*
return excluded_sink_append(options->synced_dirs, options->excluded_mutex, dest); return excluded_sink_append(options->synced_dirs, options->excluded_mutex, dest);
} }
/* Merge the open directory's own .rsync-filter rules into the inherited /* Read every per-directory filter file that applies to `dir_path` (its
* context, returning the context used for this directory's entries. On a parse * .rsync-filter when -F is active, plus each registered "dir-merge NAME") into a
* error the scanner is marked failed. Returns 0 on success, -1 on failure. */ * fresh list. Returns NULL on allocation/parse failure (message in `err`);
* returns an empty list (and *any_exists=false) when no file exists. */
static FilterRuleList* read_dir_filters(const ScannerOptions* options, const char* dir_path,
const char* rel, bool* any_exists, char* err,
size_t err_size) {
if (err && err_size > 0)
err[0] = '\0';
const FilterRuleList* base = options->base_filters;
bool have_names = options->per_dir_filters || (base && base->dir_merge_count > 0);
if (any_exists)
*any_exists = false;
if (!have_names)
return NULL;
FilterRuleList* own = filter_rule_list_create();
if (!own) {
snprintf(err, err_size, "memory allocation failed");
return NULL;
}
FilterParseOptions opts = {.delete_excluded = options->delete_excluded, .cvs_exclude = false};
bool exists = false;
if (options->per_dir_filters) {
if (!filter_file_append(own, dir_path, ".rsync-filter", rel, &opts, &exists, err, err_size))
goto fail;
if (exists && any_exists)
*any_exists = true;
}
if (base) {
for (int i = 0; i < base->dir_merge_count; i++) {
if (!filter_file_append(own, dir_path, base->dir_merge_names[i], rel, &opts, &exists, err,
err_size))
goto fail;
if (exists && any_exists)
*any_exists = true;
}
}
return own;
fail:
filter_rule_list_free(own);
return NULL;
}
/* Merge the open directory's own per-directory filter files (the default
* .rsync-filter when -F is active, plus every "dir-merge NAME" registered on the
* base rule list) into the inherited context, returning the context used for
* this directory's entries. On a parse error the scanner is marked failed.
* Returns 0 on success, -1 on failure. */
static int open_directory_filter_context(DirectoryScanner* scanner, const FilterNode* inherited) { static int open_directory_filter_context(DirectoryScanner* scanner, const FilterNode* inherited) {
if (!scanner->options.per_dir_filters) { char err[256];
bool any_exists = false;
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; scanner->current_node = (FilterNode*)inherited;
return 0; return 0;
} }
char err[256];
bool exists = false;
FilterRuleList* own =
filter_file_read(scanner->current_path, scanner->current_rel ? scanner->current_rel : "",
&exists, err, sizeof(err));
if (!own) { if (!own) {
if (err[0] == '\0') {
scanner->current_node = (FilterNode*)inherited;
return 0;
}
char* escaped_path = output_escape(scanner->current_path, log_get_8_bit_output()); char* escaped_path = output_escape(scanner->current_path, log_get_8_bit_output());
log_message(LOG_LEVEL_ERROR, "invalid .rsync-filter in %s: %s", log_message(LOG_LEVEL_ERROR, "invalid per-directory filter in %s: %s",
escaped_path ? escaped_path : "<allocation failed>", err); escaped_path ? escaped_path : "<allocation failed>", err);
free(escaped_path); free(escaped_path);
scanner->failed = true; scanner->failed = true;
return -1; return -1;
} }
if (exists && own->count > 0) { if (any_exists && (own->count > 0 || own->dir_merge_count > 0)) {
FilterNode* node = filter_node_alloc((FilterNode*)inherited, own); FilterNode* node = filter_node_alloc((FilterNode*)inherited, own);
if (!node || !array_list_add(scanner->filter_nodes, node)) { if (!node || !array_list_add(scanner->filter_nodes, node)) {
filter_node_destroy(node); filter_node_destroy(node);
@@ -1164,10 +1253,15 @@ Chunk* directory_scanner_next(DirectoryScanner* scanner) {
scanner->failed = true; scanner->failed = true;
break; break;
} }
bool protect = false;
bool passes_selection = entry_passes_selection( bool passes_selection = entry_passes_selection(
scanner->options.file_list, scanner->options.base_filters, scanner->current_node, rel, scanner->options.file_list, scanner->options.base_filters, scanner->current_node, rel,
entry->d_name, is_dir, scanner->options.per_dir_filters); entry->d_name, is_dir, scanner->options.per_dir_filters,
if (!passes_selection) { scanner->options.exclude_per_dir_filter_files, &protect);
/* A sender-side hide leaves the entry out of the transfer; an independent
receiver-side protect rule keeps a transferred entry's destination mirror
from being deleted. Both are recorded in the same protection set. */
if (!passes_selection || protect) {
/* --files-from subset pruning is not a filter exclusion: its delete /* --files-from subset pruning is not a filter exclusion: its delete
semantics stay keep-set-only (an unlisted source path is treated as semantics stay keep-set-only (an unlisted source path is treated as
absent, so its destination mirror is a deletable extra). A rule-based absent, so its destination mirror is a deletable extra). A rule-based
@@ -1559,22 +1653,27 @@ static void scan_root_entry(const ScannerOptions* options, const FilterNode* roo
ps->failed = true; ps->failed = true;
return; return;
} }
bool protect = false;
bool passes = entry_passes_selection(options->file_list, options->base_filters, root_node, rel, bool passes = entry_passes_selection(options->file_list, options->base_filters, root_node, rel,
entry->d_name, is_dir, options->per_dir_filters); entry->d_name, is_dir, options->per_dir_filters,
options->exclude_per_dir_filter_files, &protect);
/* -R + --files-from: root-level files keep their bare relative send path. */ /* -R + --files-from: root-level files keep their bare relative send path. */
bool use_rel = options->relative && options->file_list != NULL; bool use_rel = options->relative && options->file_list != NULL;
if (!passes) { if (!passes || protect) {
/* --files-from subset pruning is not a filter exclusion; -R bare-wire-path /* --files-from subset pruning is not a filter exclusion; -R bare-wire-path
exclusions are never recorded (see ScannerOptions.excluded_paths). */ exclusions are never recorded (see ScannerOptions.excluded_paths). */
bool files_from_prune = options->file_list && !file_list_affects(options->file_list, rel); bool files_from_prune = options->file_list && !file_list_affects(options->file_list, rel);
if (!files_from_prune && !use_rel && options->excluded_paths) { if ((!files_from_prune && !use_rel) || protect) {
const char* rel_path = *cur_path == '/' ? cur_path + 1 : cur_path; const char* rel_path = *cur_path == '/' ? cur_path + 1 : cur_path;
if (!excluded_sink_append(options->excluded_paths, options->excluded_mutex, rel_path)) if (options->excluded_paths &&
!excluded_sink_append(options->excluded_paths, options->excluded_mutex, rel_path))
ps->failed = true; ps->failed = true;
} }
free(rel); if (!passes) {
free(cur_path); free(rel);
return; free(cur_path);
return;
}
} }
if (is_dir) { if (is_dir) {
free(rel); free(rel);
@@ -1815,22 +1914,25 @@ ParallelScanner* parallel_scanner_create_with_options(const char* root_directory
root_dev = root_stats.st_dev; root_dev = root_stats.st_dev;
} }
/* Build the root directory's .rsync-filter context once; workers seed their /* Build the root directory's per-directory filter context once; workers seed
* scanners with it so per-dir rules behave identically to the sequential * their scanners with it so per-dir rules behave identically to the sequential
* scanner. */ * scanner. */
FilterNode* root_node = NULL; FilterNode* root_node = NULL;
if (options->per_dir_filters) { {
char err[256]; char err[256];
bool exists = false; bool any_exists = false;
FilterRuleList* own = filter_file_read(root_directory, "", &exists, err, sizeof(err)); FilterRuleList* own = read_dir_filters(options, root_directory, "", &any_exists, err, sizeof(err));
if (!own) { if (!own && any_exists) {
log_message(LOG_LEVEL_ERROR, "invalid .rsync-filter in %s: %s", root_directory, err); /* no files exist: leave root_node NULL */
array_list_delete(root_files); } else if (!own) {
array_list_delete(subdirs); if (err[0] != '\0') {
parallel_scanner_destroy(ps); log_message(LOG_LEVEL_ERROR, "invalid per-directory filter in %s: %s", root_directory, err);
return NULL; array_list_delete(root_files);
} array_list_delete(subdirs);
if (exists && own->count > 0) { parallel_scanner_destroy(ps);
return NULL;
}
} else if (any_exists && (own->count > 0 || own->dir_merge_count > 0)) {
root_node = filter_node_alloc(NULL, own); root_node = filter_node_alloc(NULL, own);
if (!root_node) { if (!root_node) {
filter_rule_list_free(own); filter_rule_list_free(own);
+6
View File
@@ -63,6 +63,12 @@ typedef struct {
const FileListSet* file_list; /* --files-from allow-set, or NULL */ const FileListSet* file_list; /* --files-from allow-set, or NULL */
const FilterRuleList* base_filters; /* command-line + -C rules, or NULL */ const FilterRuleList* base_filters; /* command-line + -C rules, or NULL */
bool per_dir_filters; /* -F: read .rsync-filter per directory */ bool per_dir_filters; /* -F: read .rsync-filter per directory */
/* --delete-excluded: per-directory plain rules become sender-only, so they no
longer protect the receiver from deletion. */
bool delete_excluded;
/* -FF: also exclude the per-directory filter files themselves from the
transfer (single -F transfers them). */
bool exclude_per_dir_filter_files;
bool dirs; /* -d/--dirs: transfer dir entries, no recursion */ bool dirs; /* -d/--dirs: transfer dir entries, no recursion */
bool relative; /* -R/--relative (dest rel paths, with --files-from) */ bool relative; /* -R/--relative (dest rel paths, with --files-from) */
/* --list-only: emit an is_dir File for every traversed directory (the listing /* --list-only: emit an is_dir File for every traversed directory (the listing
+4
View File
@@ -377,6 +377,10 @@ typedef struct Config {
bool from0; /* -0/--from0: NUL-delimited *-from files */ bool from0; /* -0/--from0: NUL-delimited *-from files */
bool cvs_exclude; /* -C/--cvs-exclude: standard CVS ignore set */ bool cvs_exclude; /* -C/--cvs-exclude: standard CVS ignore set */
bool per_dir_filter; /* -F: apply per-directory .rsync-filter files */ bool per_dir_filter; /* -F: apply per-directory .rsync-filter files */
/* -F click count. rsync's single -F means --filter='dir-merge
* /.rsync-filter' (the .rsync-filter files themselves are transferred); a
* repeated -F adds --filter='- .rsync-filter' so they are excluded too. */
int per_dir_filter_count;
bool one_file_system; /* -x/--one-file-system: do not cross filesystem boundaries */ bool one_file_system; /* -x/--one-file-system: do not cross filesystem boundaries */
/* --no-implied-dirs: client-only. With -R + --files-from, refuse to place a /* --no-implied-dirs: client-only. With -R + --files-from, refuse to place a
* listed file whose ancestor directory is not itself explicitly listed. */ * listed file whose ancestor directory is not itself explicitly listed. */
+568 -224
View File
@@ -1,163 +1,14 @@
#include "filter.h" #include "filter.h"
#include "log.h" #include "log.h"
#include "utils.h" #include "utils.h"
#include <ctype.h>
#include <errno.h> #include <errno.h>
#include <limits.h> #include <limits.h>
#include <stdio.h> #include <stdio.h>
#include <stdlib.h> #include <stdlib.h>
#include <string.h> #include <string.h>
/* ---- Single rule parsing ---- */ /* ---- Ordered rule lists ---- */
static bool rule_text_is_unsupported_word(const char* p, size_t len) {
static const char* const words[] = {"merge", "dir-merge", "hide", "show",
"protect", "risk", "clear"};
for (size_t i = 0; i < sizeof(words) / sizeof(words[0]); i++) {
size_t wl = strlen(words[i]);
if (len == wl && strncmp(p, words[i], wl) == 0)
return true;
}
return false;
}
/* rsync include/exclude rule modifiers we do NOT implement. A rule whose +/- is
* immediately followed by one of these is rejected instead of being silently
* parsed as a literal pattern. */
static bool is_unsupported_rule_modifier(char c) {
return c == '!' || c == 'C' || c == 's' || c == 'r' || c == 'p' || c == 'x';
}
FilterRule* filter_rule_parse(const char* line, char* err, size_t err_size) {
if (err && err_size > 0)
err[0] = '\0';
if (!line)
return NULL;
char* text = str_dup(line);
if (!text) {
if (err)
snprintf(err, err_size, "memory allocation failed");
return NULL;
}
size_t len = strlen(text);
while (len > 0 && (text[len - 1] == '\n' || text[len - 1] == '\r'))
text[--len] = '\0';
const char* p = text;
while (*p == ' ' || *p == '\t')
p++;
if (*p == '\0') {
snprintf(err, err_size, "empty filter rule");
free(text);
return NULL;
}
FilterAction action = FILTER_ACTION_EXCLUDE;
if (*p == '+' || *p == '-') {
action = *p == '+' ? FILTER_ACTION_INCLUDE : FILTER_ACTION_EXCLUDE;
p++;
/* rsync attaches rule modifiers directly to the +/- (e.g. "-s foo"). Only
* the '/' anchor modifier is supported; anything else is a clear error
* rather than a silently-ignored literal. */
if (*p != ' ' && *p != '\t' && *p != '\0' && is_unsupported_rule_modifier(*p)) {
snprintf(err, err_size,
"filter rule modifier '%c' is not supported (only the '/' anchor after +/- "
"is implemented; put a space between +/- and the pattern)",
*p);
free(text);
return NULL;
}
while (*p == ' ' || *p == '\t')
p++;
} else {
/* ':' (dir-merge) and '.' (merge) are rsync filter-rule shorthands. At the
* start of a rule they mean "merge this file", so reject them instead of
* silently turning them into inert exclude patterns. */
if (*p == ':' || *p == '.' || *p == '!') {
snprintf(err, err_size,
"filter rule starting with '%c' is not supported (merge/dir-merge/list-clear "
"shorthands are not implemented; use +/- include/exclude rules)",
*p);
free(text);
return NULL;
}
const char* sp = p;
while (*sp != '\0' && *sp != ' ' && *sp != '\t')
sp++;
size_t word_len = (size_t)(sp - p);
if (rule_text_is_unsupported_word(p, word_len)) {
snprintf(err, err_size,
"'%.*s' filter directives are not supported (only +/- include/exclude rules "
"with an optional '/' anchor and trailing '/' dir marker)",
(int)word_len, p);
free(text);
return NULL;
}
if (word_len == strlen("include") && strncmp(p, "include", word_len) == 0) {
action = FILTER_ACTION_INCLUDE;
p = sp;
} else if (word_len == strlen("exclude") && strncmp(p, "exclude", word_len) == 0) {
action = FILTER_ACTION_EXCLUDE;
p = sp;
}
while (*p == ' ' || *p == '\t')
p++;
}
if (*p == '\0') {
snprintf(err, err_size, "filter rule has no pattern");
free(text);
return NULL;
}
/* A pattern beginning with '/' is anchored (either as "-/foo" or "- /foo"). */
bool anchored = false;
if (*p == '/') {
anchored = true;
p++;
while (*p == ' ' || *p == '\t')
p++;
}
if (*p == '\0') {
snprintf(err, err_size, "filter rule has no pattern after '/' anchor");
free(text);
return NULL;
}
/* Pattern runs to the end of the rule; a single trailing '/' marks dir-only. */
size_t pat_len = strlen(p);
bool dir_only = false;
if (pat_len > 1 && p[pat_len - 1] == '/') {
dir_only = true;
pat_len--;
} else if (pat_len == 1 && p[0] == '/') {
/* "//" anchored with nothing after: meaningless. */
snprintf(err, err_size, "filter rule has no pattern");
free(text);
return NULL;
}
FilterRule* rule = calloc(1, sizeof(FilterRule));
if (!rule) {
snprintf(err, err_size, "memory allocation failed");
free(text);
return NULL;
}
rule->pattern = malloc(pat_len + 1);
if (!rule->pattern) {
free(rule);
snprintf(err, err_size, "memory allocation failed");
free(text);
return NULL;
}
memcpy(rule->pattern, p, pat_len);
rule->pattern[pat_len] = '\0';
rule->action = action;
rule->anchored = anchored;
rule->dir_only = dir_only;
rule->owner = NULL;
free(text);
return rule;
}
void filter_rule_free(FilterRule* rule) { void filter_rule_free(FilterRule* rule) {
if (!rule) if (!rule)
@@ -167,8 +18,6 @@ void filter_rule_free(FilterRule* rule) {
free(rule); free(rule);
} }
/* ---- Ordered rule lists ---- */
FilterRuleList* filter_rule_list_create(void) { FilterRuleList* filter_rule_list_create(void) {
return calloc(1, sizeof(FilterRuleList)); return calloc(1, sizeof(FilterRuleList));
} }
@@ -190,28 +39,42 @@ bool filter_rule_list_add(FilterRuleList* list, FilterRule* rule) {
return true; return true;
} }
bool filter_rule_list_parse_append(FilterRuleList* list, const char* line, char* err,
size_t err_size) {
FilterRule* rule = filter_rule_parse(line, err, err_size);
if (!rule)
return false;
if (!filter_rule_list_add(list, rule)) {
filter_rule_free(rule);
snprintf(err, err_size, "memory allocation failed");
return false;
}
return true;
}
void filter_rule_list_free(FilterRuleList* list) { void filter_rule_list_free(FilterRuleList* list) {
if (!list) if (!list)
return; return;
for (int i = 0; i < list->count; i++) for (int i = 0; i < list->count; i++)
filter_rule_free(list->items[i]); filter_rule_free(list->items[i]);
for (int i = 0; i < list->dir_merge_count; i++)
free(list->dir_merge_names[i]);
free(list->dir_merge_names);
free(list->items); free(list->items);
free(list); free(list);
} }
/* Register a per-directory merge-file basename (for "dir-merge NAME"/": NAME"
* and -F's .rsync-filter). Duplicate names are ignored. */
bool filter_rule_list_add_dir_merge(FilterRuleList* list, const char* name) {
if (!list || !name || name[0] == '\0')
return false;
for (int i = 0; i < list->dir_merge_count; i++) {
if (strcmp(list->dir_merge_names[i], name) == 0)
return true;
}
if (list->dir_merge_count == list->dir_merge_capacity) {
int new_cap = list->dir_merge_capacity > 0 ? list->dir_merge_capacity * 2 : 4;
char** grown = realloc(list->dir_merge_names, (size_t)new_cap * sizeof(char*));
if (!grown)
return false;
list->dir_merge_names = grown;
list->dir_merge_capacity = new_cap;
}
char* dup = str_dup(name);
if (!dup)
return false;
list->dir_merge_names[list->dir_merge_count++] = dup;
return true;
}
static bool set_rule_owner(FilterRule* rule, const char* owner) { static bool set_rule_owner(FilterRule* rule, const char* owner) {
char* dup = str_dup(owner ? owner : ""); char* dup = str_dup(owner ? owner : "");
if (!dup) if (!dup)
@@ -221,7 +84,311 @@ static bool set_rule_owner(FilterRule* rule, const char* owner) {
return true; return true;
} }
/* ---- CVS default excludes (-C) ---- */ /* ---- Rule parsing ---- */
/* A short rule prefix is a single character; a long rule name is alphabetic
* (with '-'). `is_short` distinguishes the modifier-attachment rules. */
typedef enum {
RULE_KIND_EXCLUDE,
RULE_KIND_INCLUDE,
RULE_KIND_HIDE,
RULE_KIND_SHOW,
RULE_KIND_PROTECT,
RULE_KIND_RISK,
RULE_KIND_MERGE,
RULE_KIND_DIR_MERGE,
RULE_KIND_CLEAR,
RULE_KIND_UNKNOWN,
} RuleKind;
static bool short_rule_char(char c, RuleKind* kind) {
switch (c) {
case '-':
*kind = RULE_KIND_EXCLUDE;
return true;
case '+':
*kind = RULE_KIND_INCLUDE;
return true;
case 'H':
*kind = RULE_KIND_HIDE;
return true;
case 'S':
*kind = RULE_KIND_SHOW;
return true;
case 'P':
*kind = RULE_KIND_PROTECT;
return true;
case 'R':
*kind = RULE_KIND_RISK;
return true;
case '.':
*kind = RULE_KIND_MERGE;
return true;
case ':':
*kind = RULE_KIND_DIR_MERGE;
return true;
case '!':
*kind = RULE_KIND_CLEAR;
return true;
default:
return false;
}
}
static bool long_rule_name(const char* name, size_t len, RuleKind* kind) {
struct {
const char* word;
RuleKind kind;
} table[] = {
{"exclude", RULE_KIND_EXCLUDE}, {"include", RULE_KIND_INCLUDE},
{"hide", RULE_KIND_HIDE}, {"show", RULE_KIND_SHOW},
{"protect", RULE_KIND_PROTECT}, {"risk", RULE_KIND_RISK},
{"merge", RULE_KIND_MERGE}, {"dir-merge", RULE_KIND_DIR_MERGE},
{"clear", RULE_KIND_CLEAR},
};
for (size_t i = 0; i < sizeof(table) / sizeof(table[0]); i++) {
if (strlen(table[i].word) == len && strncmp(name, table[i].word, len) == 0) {
*kind = table[i].kind;
return true;
}
}
return false;
}
static bool is_modifier_char(char c) {
return c == 's' || c == 'r' || c == 'p' || c == 'x' || c == '/' || c == '!' || c == 'C';
}
/* Parse "RULE[,MODIFIERS] [PATTERN]". On success `kind`, `sides`,
* `sides_explicit`, `negate`, `anchored_mod`, `perishable`, `xattr`,
* `cvs_inject` and the pattern span (`pat_start`/`pat_len`, possibly 0 for
* merge/clear) are filled. Returns true on success. */
static bool parse_rule_syntax(const char* text, RuleKind* kind, unsigned* sides,
bool* sides_explicit, bool* negate, bool* anchored_mod,
bool* perishable, bool* xattr, bool* cvs_inject,
const char** pat_start, size_t* pat_len) {
const char* p = text;
*sides = FILTER_SIDE_SENDER | FILTER_SIDE_RECEIVER;
*sides_explicit = false;
*negate = false;
*anchored_mod = false;
*perishable = false;
*xattr = false;
*cvs_inject = false;
*pat_start = NULL;
*pat_len = 0;
bool is_short = false;
if (short_rule_char(*p, kind)) {
is_short = true;
p++;
} else {
const char* name_start = p;
while (isalpha((unsigned char)*p) || *p == '-')
p++;
size_t name_len = (size_t)(p - name_start);
if (name_len == 0 || !long_rule_name(name_start, name_len, kind))
return false;
/* A long name must be followed by a separator, a comma or the end. */
if (*p != '\0' && *p != ',' && *p != ' ' && *p != '_')
return false;
}
/* Modifiers: long names require a comma; short names may attach directly.
Only commit a modifier run that terminates at a separator or the end, so a
pattern such as "*.tmp" written as "-*.tmp" is not mistaken for modifiers. */
const char* mod_start = p;
const char* mod_end = p;
if (*p == ',') {
p++;
mod_start = p;
while (is_modifier_char(*p))
p++;
mod_end = p;
} else if (is_short) {
const char* scan = p;
while (is_modifier_char(*scan))
scan++;
if (*scan == '\0' || *scan == ' ' || *scan == '_') {
mod_start = p;
mod_end = scan;
p = scan;
}
}
for (const char* m = mod_start; m < mod_end; m++) {
switch (*m) {
case 's':
*sides = FILTER_SIDE_SENDER;
*sides_explicit = true;
break;
case 'r':
*sides = FILTER_SIDE_RECEIVER;
*sides_explicit = true;
break;
case '!':
*negate = true;
break;
case '/':
*anchored_mod = true;
break;
case 'p':
*perishable = true;
break;
case 'x':
*xattr = true;
break;
case 'C':
*cvs_inject = true;
break;
default:
break;
}
}
/* A single space or underscore separates the rule/modifiers from the
pattern; further spaces/underscores belong to the pattern. */
const char* pat = p;
if (*pat == ' ' || *pat == '_')
pat++;
/* Trim a trailing newline/CR (the caller may pass a raw file line). */
*pat_start = pat;
*pat_len = strlen(pat);
while (*pat_len > 0 && (pat[*pat_len - 1] == '\n' || pat[*pat_len - 1] == '\r'))
(*pat_len)--;
return true;
}
FilterRule* filter_rule_parse(const char* line, const FilterParseOptions* opts, char* err,
size_t err_size) {
if (err && err_size > 0)
err[0] = '\0';
if (!line)
return NULL;
const char* p = line;
while (*p == ' ' || *p == '\t')
p++;
if (*p == '\0' || *p == '\n' || *p == '\r') {
snprintf(err, err_size, "empty filter rule");
return NULL;
}
RuleKind kind = RULE_KIND_UNKNOWN;
unsigned sides;
bool sides_explicit, negate, anchored_mod, perishable, xattr, cvs_inject;
const char* pat;
size_t pat_len;
if (!parse_rule_syntax(p, &kind, &sides, &sides_explicit, &negate, &anchored_mod, &perishable,
&xattr, &cvs_inject, &pat, &pat_len)) {
snprintf(err, err_size, "unrecognized filter rule syntax");
return NULL;
}
if (cvs_inject) {
/* The C modifier expands to the CVS defaults in place; the rule itself
carries no pattern and is handled by the caller. */
snprintf(err, err_size, "the C modifier is handled by the rule-list parser");
return NULL;
}
if (kind == RULE_KIND_MERGE || kind == RULE_KIND_DIR_MERGE) {
snprintf(err, err_size, "merge/dir-merge rules are handled by the rule-list parser");
return NULL;
}
if (kind == RULE_KIND_CLEAR) {
if (pat_len != 0) {
snprintf(err, err_size, "clear takes no pattern");
return NULL;
}
FilterRule* rule = calloc(1, sizeof(FilterRule));
if (!rule) {
snprintf(err, err_size, "memory allocation failed");
return NULL;
}
rule->action = FILTER_ACTION_NONE; /* clear marker: no pattern */
rule->sides = 0;
return rule;
}
FilterAction action;
switch (kind) {
case RULE_KIND_INCLUDE:
case RULE_KIND_SHOW:
case RULE_KIND_RISK:
action = FILTER_ACTION_INCLUDE;
break;
default:
action = FILTER_ACTION_EXCLUDE;
break;
}
if (kind == RULE_KIND_HIDE)
sides = FILTER_SIDE_SENDER;
else if (kind == RULE_KIND_SHOW)
sides = FILTER_SIDE_SENDER;
else if (kind == RULE_KIND_PROTECT)
sides = FILTER_SIDE_RECEIVER;
else if (kind == RULE_KIND_RISK)
sides = FILTER_SIDE_RECEIVER;
if (kind == RULE_KIND_HIDE || kind == RULE_KIND_SHOW || kind == RULE_KIND_PROTECT ||
kind == RULE_KIND_RISK)
sides_explicit = true;
/* --delete-excluded turns an unqualified (no explicit s/r) rule into a
sender-side-only rule, so it no longer protects the receiver. */
if (opts && opts->delete_excluded && !sides_explicit)
sides = FILTER_SIDE_SENDER;
if (pat_len == 0) {
snprintf(err, err_size, "filter rule has no pattern");
return NULL;
}
bool anchored = anchored_mod;
const char* pat_begin = pat;
if (*pat_begin == '/') {
anchored = true;
pat_begin++;
/* Drop the spaces that could follow the anchor in the "-/ foo" form. */
while (*pat_begin == ' ' || *pat_begin == '\t')
pat_begin++;
pat_len = strlen(pat_begin);
while (pat_len > 0 && (pat_begin[pat_len - 1] == '\n' || pat_begin[pat_len - 1] == '\r'))
pat_len--;
}
if (pat_len == 0) {
snprintf(err, err_size, "filter rule has no pattern after '/' anchor");
return NULL;
}
bool dir_only = false;
if (pat_len > 1 && pat_begin[pat_len - 1] == '/') {
dir_only = true;
pat_len--;
}
if (pat_len == 0) {
snprintf(err, err_size, "filter rule has no pattern");
return NULL;
}
FilterRule* rule = calloc(1, sizeof(FilterRule));
if (!rule) {
snprintf(err, err_size, "memory allocation failed");
return NULL;
}
rule->pattern = malloc(pat_len + 1);
if (!rule->pattern) {
free(rule);
snprintf(err, err_size, "memory allocation failed");
return NULL;
}
memcpy(rule->pattern, pat_begin, pat_len);
rule->pattern[pat_len] = '\0';
rule->action = action;
rule->sides = sides;
rule->anchored = anchored;
rule->dir_only = dir_only;
rule->negate = negate;
rule->perishable = perishable;
(void)xattr; /* xattr-name rules never match file/dir names; accepted/ignored */
return rule;
}
/* ---- CVS default excludes (-C and the C modifier) ---- */
typedef struct { typedef struct {
const char* pattern; const char* pattern;
@@ -240,12 +407,13 @@ static const CvsDefaultRule CVS_DEFAULTS[] = {
{".svn/", true}, {".git/", true}, {".hg/", true}, {".bzr/", true}, {".svn/", true}, {".git/", true}, {".hg/", true}, {".bzr/", true},
}; };
static bool cvs_rule_list_append(FilterRuleList* list) { static bool filter_list_append_cvs(FilterRuleList* list, unsigned sides) {
for (size_t i = 0; i < sizeof(CVS_DEFAULTS) / sizeof(CVS_DEFAULTS[0]); i++) { for (size_t i = 0; i < sizeof(CVS_DEFAULTS) / sizeof(CVS_DEFAULTS[0]); i++) {
FilterRule* rule = calloc(1, sizeof(FilterRule)); FilterRule* rule = calloc(1, sizeof(FilterRule));
if (!rule) if (!rule)
return false; return false;
rule->action = FILTER_ACTION_EXCLUDE; rule->action = FILTER_ACTION_EXCLUDE;
rule->sides = sides;
rule->dir_only = CVS_DEFAULTS[i].dir_only; rule->dir_only = CVS_DEFAULTS[i].dir_only;
size_t plen = strlen(CVS_DEFAULTS[i].pattern); size_t plen = strlen(CVS_DEFAULTS[i].pattern);
if (rule->dir_only && plen > 0 && CVS_DEFAULTS[i].pattern[plen - 1] == '/') if (rule->dir_only && plen > 0 && CVS_DEFAULTS[i].pattern[plen - 1] == '/')
@@ -269,8 +437,166 @@ static bool cvs_rule_list_append(FilterRuleList* list) {
return true; return true;
} }
#define FILTER_MAX_MERGE_DEPTH 16
static bool filter_list_parse_append_depth(FilterRuleList* list, const char* line,
const FilterParseOptions* opts, const char* base_dir,
int depth, char* err, size_t err_size);
/* Read a merge file and splice its rules into `list`. A relative path is
* resolved below `base_dir` when given, else used as-is (rsync resolves a
* command-line merge file relative to the current directory). */
static bool filter_list_merge_file(FilterRuleList* list, const char* name,
const FilterParseOptions* opts, const char* base_dir, int depth,
char* err, size_t err_size) {
if (name[0] == '\0') {
snprintf(err, err_size, "merge requires a filename");
return false;
}
char* path = (base_dir && base_dir[0] && name[0] != '/') ? path_cat(base_dir, name) : str_dup(name);
if (!path) {
snprintf(err, err_size, "memory allocation failed");
return false;
}
FILE* fp = fopen(path, "r");
if (!fp) {
snprintf(err, err_size, "could not read merge file '%s': %s", path, strerror(errno));
free(path);
return false;
}
char* line = NULL;
size_t cap = 0;
bool ok = true;
while (true) {
ssize_t n = utils_getdelim_bounded(fp, &line, &cap, '\n', UTILS_MAX_LINE_LEN);
if (n < 0) {
snprintf(err, err_size, "error reading merge file '%s'", path);
ok = false;
break;
}
if (n == 0)
break;
const char* lp = line;
while (*lp == ' ' || *lp == '\t')
lp++;
if (*lp == '\0' || *lp == '\n' || *lp == '\r' || *lp == '#')
continue;
if (!filter_list_parse_append_depth(list, lp, opts, base_dir, depth + 1, err, err_size)) {
ok = false;
break;
}
}
free(line);
fclose(fp);
free(path);
return ok;
}
/* Parse one line and append/merge it into `list`. Handles clear, merge and
* dir-merge at the list level. */
static bool filter_list_parse_append_depth(FilterRuleList* list, const char* line,
const FilterParseOptions* opts, const char* base_dir,
int depth, char* err, size_t err_size) {
if (depth > FILTER_MAX_MERGE_DEPTH) {
snprintf(err, err_size, "merge files nested too deeply");
return false;
}
const char* p = line;
while (*p == ' ' || *p == '\t')
p++;
if (*p == '\0' || *p == '\n' || *p == '\r')
return true;
RuleKind kind = RULE_KIND_UNKNOWN;
unsigned sides;
bool sides_explicit, negate, anchored_mod, perishable, xattr, cvs_inject;
const char* pat;
size_t pat_len;
if (!parse_rule_syntax(p, &kind, &sides, &sides_explicit, &negate, &anchored_mod, &perishable,
&xattr, &cvs_inject, &pat, &pat_len)) {
snprintf(err, err_size, "unrecognized filter rule syntax: %s", p);
return false;
}
(void)sides_explicit;
(void)negate;
(void)anchored_mod;
(void)perishable;
(void)xattr;
if (cvs_inject) {
/* "C" injects the CVS defaults in place; no pattern is expected. */
return filter_list_append_cvs(list, sides);
}
if (kind == RULE_KIND_CLEAR) {
if (pat_len != 0) {
snprintf(err, err_size, "clear takes no pattern");
return false;
}
for (int i = 0; i < list->count; i++)
filter_rule_free(list->items[i]);
list->count = 0;
return true;
}
if (kind == RULE_KIND_MERGE) {
if (pat_len == 0) {
snprintf(err, err_size, "merge requires a filename");
return false;
}
char* name = malloc(pat_len + 1);
if (!name) {
snprintf(err, err_size, "memory allocation failed");
return false;
}
memcpy(name, pat, pat_len);
name[pat_len] = '\0';
bool ok = filter_list_merge_file(list, name, opts, base_dir, depth, err, err_size);
free(name);
return ok;
}
if (kind == RULE_KIND_DIR_MERGE) {
if (pat_len == 0) {
snprintf(err, err_size, "dir-merge requires a filename");
return false;
}
char* name = malloc(pat_len + 1);
if (!name) {
snprintf(err, err_size, "memory allocation failed");
return false;
}
memcpy(name, pat, pat_len);
name[pat_len] = '\0';
bool ok = filter_rule_list_add_dir_merge(list, name);
free(name);
if (!ok) {
snprintf(err, err_size, "memory allocation failed");
return false;
}
return true;
}
FilterRule* rule = filter_rule_parse(p, opts, err, err_size);
if (!rule)
return false;
if (!filter_rule_list_add(list, rule)) {
filter_rule_free(rule);
snprintf(err, err_size, "memory allocation failed");
return false;
}
return true;
}
bool filter_rule_list_parse_append(FilterRuleList* list, const char* line,
const FilterParseOptions* opts, const char* merge_base_dir,
char* err, size_t err_size) {
if (err && err_size > 0)
err[0] = '\0';
if (!list)
return false;
return filter_list_parse_append_depth(list, line, opts, merge_base_dir, 0, err, err_size);
}
FilterRuleList* filter_base_build(const char* const* rule_texts, int rule_count, bool cvs_exclude, FilterRuleList* filter_base_build(const char* const* rule_texts, int rule_count, bool cvs_exclude,
char* err, size_t err_size) { bool delete_excluded, char* err, size_t err_size) {
if (err && err_size > 0) if (err && err_size > 0)
err[0] = '\0'; err[0] = '\0';
FilterRuleList* list = filter_rule_list_create(); FilterRuleList* list = filter_rule_list_create();
@@ -278,28 +604,16 @@ FilterRuleList* filter_base_build(const char* const* rule_texts, int rule_count,
snprintf(err, err_size, "memory allocation failed"); snprintf(err, err_size, "memory allocation failed");
return NULL; return NULL;
} }
FilterParseOptions opts = {.delete_excluded = delete_excluded, .cvs_exclude = cvs_exclude};
for (int i = 0; i < rule_count; i++) { for (int i = 0; i < rule_count; i++) {
if (!rule_texts || !rule_texts[i]) if (!rule_texts || !rule_texts[i])
continue; continue;
FilterRule* rule = filter_rule_parse(rule_texts[i], err, err_size); if (!filter_rule_list_parse_append(list, rule_texts[i], &opts, NULL, err, err_size)) {
if (!rule) {
filter_rule_list_free(list); filter_rule_list_free(list);
return NULL; return NULL;
} }
if (!set_rule_owner(rule, "")) {
filter_rule_free(rule);
filter_rule_list_free(list);
snprintf(err, err_size, "memory allocation failed");
return NULL;
}
if (!filter_rule_list_add(list, rule)) {
filter_rule_free(rule);
filter_rule_list_free(list);
snprintf(err, err_size, "memory allocation failed");
return NULL;
}
} }
if (cvs_exclude && !cvs_rule_list_append(list)) { if (cvs_exclude && !filter_list_append_cvs(list, FILTER_SIDE_SENDER | FILTER_SIDE_RECEIVER)) {
filter_rule_list_free(list); filter_rule_list_free(list);
snprintf(err, err_size, "memory allocation failed"); snprintf(err, err_size, "memory allocation failed");
return NULL; return NULL;
@@ -307,38 +621,36 @@ FilterRuleList* filter_base_build(const char* const* rule_texts, int rule_count,
return list; return list;
} }
/* ---- Per-directory .rsync-filter files ---- */ /* ---- Per-directory merge files ---- */
FilterRuleList* filter_file_read(const char* dir_path, const char* owner_rel, bool* exists, bool filter_file_append(FilterRuleList* list, const char* dir_path, const char* name,
char* err, size_t err_size) { const char* owner_rel, const FilterParseOptions* opts, bool* exists,
char* err, size_t err_size) {
if (err && err_size > 0) if (err && err_size > 0)
err[0] = '\0'; err[0] = '\0';
if (exists) if (exists)
*exists = false; *exists = false;
char* filter_path = path_cat(dir_path, ".rsync-filter"); if (!list)
return false;
char* filter_path = path_cat(dir_path, name);
if (!filter_path) { if (!filter_path) {
snprintf(err, err_size, "memory allocation failed"); snprintf(err, err_size, "memory allocation failed");
return NULL; return false;
} }
FILE* fp = fopen(filter_path, "r"); FILE* fp = fopen(filter_path, "r");
free(filter_path); free(filter_path);
if (!fp) { if (!fp) {
if (errno == ENOENT || errno == ENOTDIR) if (errno == ENOENT || errno == ENOTDIR)
return filter_rule_list_create(); return true;
char* escaped_dir = output_escape(dir_path, log_get_8_bit_output()); char* escaped_dir = output_escape(dir_path, log_get_8_bit_output());
log_message(LOG_LEVEL_WARNING, "Could not read .rsync-filter in %s: %s", log_message(LOG_LEVEL_WARNING, "Could not read %s in %s: %s", name,
escaped_dir ? escaped_dir : "<allocation failed>", strerror(errno)); escaped_dir ? escaped_dir : "<allocation failed>", strerror(errno));
free(escaped_dir); free(escaped_dir);
return filter_rule_list_create(); return true;
} }
if (exists) if (exists)
*exists = true; *exists = true;
FilterRuleList* list = filter_rule_list_create(); int rules_before = list->count;
if (!list) {
fclose(fp);
snprintf(err, err_size, "memory allocation failed");
return NULL;
}
char* line = NULL; char* line = NULL;
size_t line_cap = 0; size_t line_cap = 0;
bool ok = true; bool ok = true;
@@ -346,9 +658,9 @@ FilterRuleList* filter_file_read(const char* dir_path, const char* owner_rel, bo
ssize_t n = utils_getdelim_bounded(fp, &line, &line_cap, '\n', UTILS_MAX_LINE_LEN); ssize_t n = utils_getdelim_bounded(fp, &line, &line_cap, '\n', UTILS_MAX_LINE_LEN);
if (n < 0) { if (n < 0) {
if (errno == EFBIG) { if (errno == EFBIG) {
snprintf(err, err_size, "line in .rsync-filter exceeds %d bytes", (int)UTILS_MAX_LINE_LEN); snprintf(err, err_size, "line in %s exceeds %d bytes", name, (int)UTILS_MAX_LINE_LEN);
} else { } else {
snprintf(err, err_size, "error reading .rsync-filter: %s", strerror(errno)); snprintf(err, err_size, "error reading %s: %s", name, strerror(errno));
} }
ok = false; ok = false;
break; break;
@@ -360,20 +672,9 @@ FilterRuleList* filter_file_read(const char* dir_path, const char* owner_rel, bo
p++; p++;
if (*p == '\0' || *p == '\n' || *p == '\r' || *p == '#') if (*p == '\0' || *p == '\n' || *p == '\r' || *p == '#')
continue; continue;
FilterRule* rule = filter_rule_parse(p, err, err_size); /* Merge files inside a per-directory file resolve relative to that
if (!rule) { directory. */
ok = false; if (!filter_list_parse_append_depth(list, p, opts, dir_path, 0, err, err_size)) {
break;
}
if (!set_rule_owner(rule, owner_rel)) {
filter_rule_free(rule);
snprintf(err, err_size, "memory allocation failed");
ok = false;
break;
}
if (!filter_rule_list_add(list, rule)) {
filter_rule_free(rule);
snprintf(err, err_size, "memory allocation failed");
ok = false; ok = false;
break; break;
} }
@@ -381,12 +682,43 @@ FilterRuleList* filter_file_read(const char* dir_path, const char* owner_rel, bo
free(line); free(line);
fclose(fp); fclose(fp);
if (!ok) { if (!ok) {
/* Drop only the rules this file appended, leaving the caller's earlier
content untouched. */
for (int i = rules_before; i < list->count; i++)
filter_rule_free(list->items[i]);
list->count = rules_before;
return false;
}
for (int i = rules_before; i < list->count; i++) {
if (!set_rule_owner(list->items[i], owner_rel)) {
snprintf(err, err_size, "memory allocation failed");
return false;
}
}
return true;
}
FilterRuleList* filter_file_read_named(const char* dir_path, const char* name, const char* owner_rel,
const FilterParseOptions* opts, bool* exists, char* err,
size_t err_size) {
FilterRuleList* list = filter_rule_list_create();
if (!list) {
if (err && err_size > 0)
snprintf(err, err_size, "memory allocation failed");
return NULL;
}
if (!filter_file_append(list, dir_path, name, owner_rel, opts, exists, err, err_size)) {
filter_rule_list_free(list); filter_rule_list_free(list);
return NULL; return NULL;
} }
return list; return list;
} }
FilterRuleList* filter_file_read(const char* dir_path, const char* owner_rel, bool* exists,
char* err, size_t err_size) {
return filter_file_read_named(dir_path, ".rsync-filter", owner_rel, NULL, exists, err, err_size);
}
/* ---- Rule matching ---- */ /* ---- Rule matching ---- */
/* Match a pattern that contains '/' (non-anchored) against the end of the /* Match a pattern that contains '/' (non-anchored) against the end of the
@@ -402,10 +734,10 @@ static bool glob_suffix_match(const char* pattern, const char* str) {
} }
static FilterAction rule_matches(const FilterRule* rule, const char* rel_path, const char* leaf, static FilterAction rule_matches(const FilterRule* rule, const char* rel_path, const char* leaf,
bool is_dir) { bool is_dir, unsigned side) {
if (!rule || !rule->pattern) if (!rule || !rule->pattern)
return FILTER_ACTION_NONE; return FILTER_ACTION_NONE;
if (rule->dir_only && !is_dir) if (!(rule->sides & side))
return FILTER_ACTION_NONE; return FILTER_ACTION_NONE;
/* A rule applies only to entries below its owner directory. */ /* A rule applies only to entries below its owner directory. */
const char* rel2 = rel_path; const char* rel2 = rel_path;
@@ -420,24 +752,36 @@ static FilterAction rule_matches(const FilterRule* rule, const char* rel_path, c
if (rel2[0] == '\0') if (rel2[0] == '\0')
return FILTER_ACTION_NONE; return FILTER_ACTION_NONE;
bool matched; bool matched;
if (rule->anchored) { if (rule->dir_only && !is_dir)
matched = false;
else if (rule->anchored)
matched = glob_match(rule->pattern, rel2); matched = glob_match(rule->pattern, rel2);
} else if (strchr(rule->pattern, '/') != NULL) { else if (strchr(rule->pattern, '/') != NULL)
matched = glob_suffix_match(rule->pattern, rel2); matched = glob_suffix_match(rule->pattern, rel2);
} else { else
matched = glob_match(rule->pattern, leaf); matched = glob_match(rule->pattern, leaf);
} if (rule->negate)
return matched ? rule->action : FILTER_ACTION_NONE; matched = !matched;
if (!matched)
return FILTER_ACTION_NONE;
if (side == FILTER_SIDE_RECEIVER)
return rule->action == FILTER_ACTION_EXCLUDE ? FILTER_ACTION_PROTECT : FILTER_ACTION_RISK;
return rule->action;
} }
FilterAction filter_rules_apply(const FilterRuleList* list, const char* rel_path, const char* leaf, FilterAction filter_rules_apply_side(const FilterRuleList* list, const char* rel_path,
bool is_dir) { const char* leaf, bool is_dir, unsigned side) {
if (!list) if (!list)
return FILTER_ACTION_NONE; return FILTER_ACTION_NONE;
for (int i = 0; i < list->count; i++) { for (int i = 0; i < list->count; i++) {
FilterAction action = rule_matches(list->items[i], rel_path, leaf, is_dir); FilterAction action = rule_matches(list->items[i], rel_path, leaf, is_dir, side);
if (action != FILTER_ACTION_NONE) if (action != FILTER_ACTION_NONE)
return action; return action;
} }
return FILTER_ACTION_NONE; return FILTER_ACTION_NONE;
} }
FilterAction filter_rules_apply(const FilterRuleList* list, const char* rel_path, const char* leaf,
bool is_dir) {
return filter_rules_apply_side(list, rel_path, leaf, is_dir, FILTER_SIDE_SENDER);
}
+95 -41
View File
@@ -4,79 +4,133 @@
#include <stdbool.h> #include <stdbool.h>
#include <stddef.h> #include <stddef.h>
/* rsync-style filter rule engine (client-side file selection). /* rsync-style filter rule engine (client-side file selection and the
* receiver-side protection set it feeds).
* *
* Supported rule syntax (documented subset): * Rule syntax (see the rsync man page FILTER RULES section):
* [+|-] [anchored '/' prefix] pattern [trailing '/' for dir-only] * RULE [PATTERN_OR_FILENAME]
* * RULE,MODIFIERS [PATTERN_OR_FILENAME]
* "+ PATTERN" include rule (first match wins) * Short RULE names may attach MODIFIERS directly ("-sr foo"); the long name
* "- PATTERN" exclude rule * form requires the comma. The pattern/filename is separated from the rule by
* "PATTERN" implicit exclude rule (rsync default) * one space or underscore. Rule names:
* "include PATTERN" / "exclude PATTERN" word forms * exclude/- exclude (by default both sender-hide and receiver-protect)
* leading '/' after the +/- anchors the pattern to its owner directory * include/+ include (by default both sender-show and receiver-risk)
* (the transfer root for command-line/-C rules, the directory that * hide/H sender-only exclude
* contains a .rsync-filter file for per-directory rules) * show/S sender-only include
* a trailing '/' makes the rule match directories only * protect/P receiver-only exclude (protect from deletion)
* * risk/R receiver-only include (allow deletion)
* Rejected explicitly (no silent no-ops): the rsync merge/dir-merge/list-clear * merge/. read a client-side merge file for more rules
* shorthands written as a rule that starts with ':' or '.' or '!', the * dir-merge/: per-directory merge file (registered for the scanner)
* merge/dir-merge/hide/show/protect/risk/clear words, and every include/exclude * clear/! clear the current rule list (takes no argument)
* rule modifier other than '/' (! C s r p x). The pattern must be separated * Modifiers: '/' absolute anchor, '!' negate match, 'C' inject CVS defaults,
* from +/- by a space (or a single '/' anchor), exactly like rsync's * 's' sender side, 'r' receiver side, 'p' perishable, 'x' xattr name rule.
* "-s foo"/"-p ..." modifier syntax is refused. * A trailing '/' makes a pattern match directories only. A leading '/' anchors
* the pattern to its owner directory.
*/ */
typedef enum { typedef enum {
FILTER_ACTION_NONE = 0, /* no rule matched */ FILTER_ACTION_NONE = 0, /* no rule matched */
FILTER_ACTION_EXCLUDE = -1, FILTER_ACTION_EXCLUDE = -1,
FILTER_ACTION_INCLUDE = 1 FILTER_ACTION_INCLUDE = 1,
/* Receiver-side-only verdicts: the entry is transferred but its destination
* mirror is protected from --delete (PROTECT) or explicitly left at risk
* (RISK). */
FILTER_ACTION_PROTECT = 2,
FILTER_ACTION_RISK = 3,
} FilterAction; } FilterAction;
#define FILTER_SIDE_SENDER 1u
#define FILTER_SIDE_RECEIVER 2u
typedef struct { typedef struct {
FilterAction action; FilterAction action; /* EXCLUDE or INCLUDE (the base pattern action) */
bool anchored; /* pattern anchored to the rule's owner directory */ unsigned sides; /* FILTER_SIDE_SENDER | FILTER_SIDE_RECEIVER */
bool dir_only; /* pattern had a trailing '/': matches directories only */ bool anchored; /* pattern anchored to the rule's owner directory */
char* owner; /* owning directory rel path ("" == transfer root) */ bool dir_only; /* pattern had a trailing '/': matches directories only */
char* pattern; /* cleaned glob pattern (no leading '/', no trailing '/') */ bool negate; /* '!' modifier: match succeeds when the pattern does not */
bool perishable; /* 'p' modifier (ignored in deleted directories) */
char* owner; /* owning directory rel path ("" == transfer root) */
char* pattern; /* cleaned glob pattern (no leading '/', no trailing '/') */
} FilterRule; } FilterRule;
typedef struct { typedef struct {
FilterRule** items; /* owned array of rule pointers */ FilterRule** items; /* owned array of rule pointers */
int count; int count;
int capacity; int capacity;
/* Per-directory merge-file basenames registered by "dir-merge NAME"/": NAME"
* or by -F (.rsync-filter). Owned strings; the scanner reads each name in
* every directory it traverses. */
char** dir_merge_names;
int dir_merge_count;
int dir_merge_capacity;
} FilterRuleList; } FilterRuleList;
/* Context needed while parsing a rule list (merge files, --delete-excluded). */
typedef struct {
bool delete_excluded; /* --delete-excluded: default sides become sender-only */
bool cvs_exclude; /* -C: expand the CVS default excludes */
} FilterParseOptions;
/* Parse a single filter-rule line (no trailing newline required). Returns an /* Parse a single filter-rule line (no trailing newline required). Returns an
* owned rule, or NULL on unsupported/invalid syntax with a message in `err`. */ * owned rule, or NULL on unsupported/invalid syntax with a message in `err`.
FilterRule* filter_rule_parse(const char* line, char* err, size_t err_size); * `opts` may be NULL (no merge expansion / no delete-excluded). */
FilterRule* filter_rule_parse(const char* line, const FilterParseOptions* opts, char* err,
size_t err_size);
void filter_rule_free(FilterRule* rule); void filter_rule_free(FilterRule* rule);
FilterRuleList* filter_rule_list_create(void); FilterRuleList* filter_rule_list_create(void);
/* Append a fully-parsed rule (takes ownership). Returns false on OOM. */ /* Append a fully-parsed rule (takes ownership). Returns false on OOM. */
bool filter_rule_list_add(FilterRuleList* list, FilterRule* rule); bool filter_rule_list_add(FilterRuleList* list, FilterRule* rule);
/* Parse `line` and append it. Returns false and fills `err` on bad syntax. */ /* Register a per-directory merge-file basename (idempotent). Returns false on
bool filter_rule_list_parse_append(FilterRuleList* list, const char* line, char* err, * OOM. Used by the scanner to read custom "dir-merge" files. */
size_t err_size); bool filter_rule_list_add_dir_merge(FilterRuleList* list, const char* name);
/* Parse `line` and append it. Handles "clear"/"!" (resets the list), "merge
* FILE"/". FILE" (splices the file's rules) and "dir-merge NAME"/": NAME"
* (registers a per-directory filename). Returns false and fills `err` on bad
* syntax or an unreadable merge file. `merge_base_dir` resolves a relative
* merge-file path (NULL means the process working directory). */
bool filter_rule_list_parse_append(FilterRuleList* list, const char* line,
const FilterParseOptions* opts, const char* merge_base_dir,
char* err, size_t err_size);
void filter_rule_list_free(FilterRuleList* list); void filter_rule_list_free(FilterRuleList* list);
/* Build the command-line filter set: `rule_texts` (--filter=RULE in the order /* Build the command-line filter set: `rule_texts` (--filter=RULE in the order
* given, 0..rule_count) followed by the -C CVS default excludes when * given, 0..rule_count) followed by the -C CVS default excludes when
* cvs_exclude is true. All rules are owned by "" (the transfer root). * cvs_exclude is true. All rules are owned by "" (the transfer root).
* Returns NULL on unsupported rule text (message in `err`). */ * Returns NULL on unsupported rule text (message in `err`). */
FilterRuleList* filter_base_build(const char* const* rule_texts, int rule_count, bool cvs_exclude, FilterRuleList* filter_base_build(const char* const* rule_texts, int rule_count, bool cvs_exclude,
char* err, size_t err_size); bool delete_excluded, char* err, size_t err_size);
/* Read "<dir_path>/.rsync-filter" and return its rules, each owned by /* Read "<dir_path>/<name>" and return its rules, each owned by `owner_rel`. A
* `owner_rel`. A missing file yields an empty list with *exists=false; an * missing file yields an empty list with *exists=false; an unreadable file is
* unreadable file is treated as missing. Returns NULL only on parse or * treated as missing. Returns NULL only on parse or allocation failure
* allocation failure (message in `err`). */ * (message in `err`). `opts` may be NULL. */
FilterRuleList* filter_file_read_named(const char* dir_path, const char* name, const char* owner_rel,
const FilterParseOptions* opts, bool* exists, char* err,
size_t err_size);
/* Append the rules of "<dir_path>/<name>" into an existing list (each owned by
* `owner_rel`). A missing file yields *exists=false and no error. Returns
* false only on parse/allocation failure (message in `err`). */
bool filter_file_append(FilterRuleList* list, const char* dir_path, const char* name,
const char* owner_rel, const FilterParseOptions* opts, bool* exists,
char* err, size_t err_size);
/* filter_file_read_named with the default ".rsync-filter" name. */
FilterRuleList* filter_file_read(const char* dir_path, const char* owner_rel, bool* exists, FilterRuleList* filter_file_read(const char* dir_path, const char* owner_rel, bool* exists,
char* err, size_t err_size); char* err, size_t err_size);
/* Evaluate an entry against one ordered rule list. Returns FILTER_ACTION_NONE /* Evaluate an entry against one ordered rule list for one side. Returns
* when no rule matched, otherwise the first matching rule's action. * FILTER_ACTION_NONE when no rule matched, otherwise the first matching rule's
* `rel_path` is the entry's path relative to the transfer root ("" == root), * action (for the receiver side an EXCLUDE is reported as
* `leaf` its final name, `is_dir` whether it is a directory. */ * FILTER_ACTION_PROTECT and an INCLUDE as FILTER_ACTION_RISK). `rel_path` is
* the entry's path relative to the transfer root ("" == root), `leaf` its final
* name, `is_dir` whether it is a directory. */
FilterAction filter_rules_apply_side(const FilterRuleList* list, const char* rel_path,
const char* leaf, bool is_dir, unsigned side);
/* Sender-side convenience wrapper (kept for callers/tests that only need the
* transfer decision). */
FilterAction filter_rules_apply(const FilterRuleList* list, const char* rel_path, const char* leaf, FilterAction filter_rules_apply(const FilterRuleList* list, const char* rel_path, const char* leaf,
bool is_dir); bool is_dir);
+26 -6
View File
@@ -2561,15 +2561,35 @@ static void test_parse_args_filter_rules() {
EXPECT_EQ_INT(parse_args(cfg, 4, missing_argv, positional_args, &positional_count), -1); EXPECT_EQ_INT(parse_args(cfg, 4, missing_argv, positional_args, &positional_count), -1);
config_delete(cfg); config_delete(cfg);
/* rsync shorthands/modifiers we do not support are rejected instead of being /* Full rsync grammar (rule words, modifiers, clear) is supported. */
* silently parsed as literal patterns. */ cfg = config_create();
static const char* const unsupported[] = { positional_count = 0;
": .rsync-filter", ". /tmp/rules", "-s foo", "-p bar", "-C", "-! *.o", "!", char* grammar_argv[] = {"fastsync",
"--filter=hide *.tmp",
"--filter=show *.txt",
"--filter=protect *.bak",
"--filter=risk *.o",
"--filter=-s foo",
"--filter=-p bar",
"--filter=-! *.o",
"--filter=dir-merge .rules",
"--filter=!",
"/src",
"/dst"};
EXPECT_EQ_INT(parse_args(cfg, 11, grammar_argv, positional_args, &positional_count), 0);
config_delete(cfg);
/* Genuinely malformed rules are still rejected. */
static const char* const malformed[] = {
"merge", /* merge requires a filename */
"dir-merge", /* dir-merge requires a filename */
"clear extra", /* clear takes no pattern */
"no-such-rule x", /* unknown rule word */
}; };
for (size_t i = 0; i < sizeof(unsupported) / sizeof(unsupported[0]); i++) { for (size_t i = 0; i < sizeof(malformed) / sizeof(malformed[0]); i++) {
cfg = config_create(); cfg = config_create();
positional_count = 0; positional_count = 0;
char* rule_argv[] = {"fastsync", "--filter", (char*)unsupported[i], "/src", "/dst"}; char* rule_argv[] = {"fastsync", "--filter", (char*)malformed[i], "/src", "/dst"};
EXPECT_EQ_INT(parse_args(cfg, 5, rule_argv, positional_args, &positional_count), -1); EXPECT_EQ_INT(parse_args(cfg, 5, rule_argv, positional_args, &positional_count), -1);
config_delete(cfg); config_delete(cfg);
} }
+8 -6
View File
@@ -855,7 +855,7 @@ static void test_filter_rules(bool parallel) {
/* - *.tmp excludes only the tmp file; other files remain (default include). */ /* - *.tmp excludes only the tmp file; other files remain (default include). */
const char* exclude_only[] = {"- *.tmp"}; const char* exclude_only[] = {"- *.tmp"};
char err[160]; char err[160];
FilterRuleList* base = filter_base_build(exclude_only, 1, false, err, sizeof(err)); FilterRuleList* base = filter_base_build(exclude_only, 1, false, false, err, sizeof(err));
EXPECT_NOT_NULL(base); EXPECT_NOT_NULL(base);
ScannerOptions options = {0}; ScannerOptions options = {0};
options.base_filters = base; options.base_filters = base;
@@ -875,7 +875,7 @@ static void test_filter_rules(bool parallel) {
/* Anchored include then exclude-all: only root-level keep* survives. */ /* Anchored include then exclude-all: only root-level keep* survives. */
const char* anchored[] = {"+ /a.txt", "- *"}; const char* anchored[] = {"+ /a.txt", "- *"};
base = filter_base_build(anchored, 2, false, err, sizeof(err)); base = filter_base_build(anchored, 2, false, false, err, sizeof(err));
EXPECT_NOT_NULL(base); EXPECT_NOT_NULL(base);
options.base_filters = base; options.base_filters = base;
rc = parallel ? collect_files_parallel(root, &options, &paths, &count) rc = parallel ? collect_files_parallel(root, &options, &paths, &count)
@@ -889,7 +889,7 @@ static void test_filter_rules(bool parallel) {
/* The common include idiom (the exact rule order the CLI compiles from /* The common include idiom (the exact rule order the CLI compiles from
* --include='*.txt' --exclude='*'): only .txt files survive. */ * --include='*.txt' --exclude='*'): only .txt files survive. */
const char* idiom[] = {"+ *.txt", "- *"}; const char* idiom[] = {"+ *.txt", "- *"};
base = filter_base_build(idiom, 2, false, err, sizeof(err)); base = filter_base_build(idiom, 2, false, false, err, sizeof(err));
EXPECT_NOT_NULL(base); EXPECT_NOT_NULL(base);
options.base_filters = base; options.base_filters = base;
rc = parallel ? collect_files_parallel(root, &options, &paths, &count) rc = parallel ? collect_files_parallel(root, &options, &paths, &count)
@@ -905,7 +905,7 @@ static void test_filter_rules(bool parallel) {
/* An include rule alone is NOT a mandatory whitelist (rsync semantics): only /* An include rule alone is NOT a mandatory whitelist (rsync semantics): only
* the matching file is affected, everything else is still transferred. */ * the matching file is affected, everything else is still transferred. */
const char* include_alone[] = {"+ *.txt"}; const char* include_alone[] = {"+ *.txt"};
base = filter_base_build(include_alone, 1, false, err, sizeof(err)); base = filter_base_build(include_alone, 1, false, false, err, sizeof(err));
EXPECT_NOT_NULL(base); EXPECT_NOT_NULL(base);
options.base_filters = base; options.base_filters = base;
rc = parallel ? collect_files_parallel(root, &options, &paths, &count) rc = parallel ? collect_files_parallel(root, &options, &paths, &count)
@@ -932,7 +932,7 @@ static void test_filter_dir_only_and_anchored(bool parallel) {
const char* rules[] = {"- /sub/"}; const char* rules[] = {"- /sub/"};
char err[160]; char err[160];
FilterRuleList* base = filter_base_build(rules, 1, false, err, sizeof(err)); FilterRuleList* base = filter_base_build(rules, 1, false, false, err, sizeof(err));
EXPECT_NOT_NULL(base); EXPECT_NOT_NULL(base);
ScannerOptions options = {0}; ScannerOptions options = {0};
options.base_filters = base; options.base_filters = base;
@@ -966,7 +966,7 @@ static void test_cvs_defaults(bool parallel) {
create_test_file("test_scan_cvs/keep.txt", "keep"); create_test_file("test_scan_cvs/keep.txt", "keep");
char err[160]; char err[160];
FilterRuleList* base = filter_base_build(NULL, 0, true, err, sizeof(err)); FilterRuleList* base = filter_base_build(NULL, 0, true, false, err, sizeof(err));
EXPECT_NOT_NULL(base); EXPECT_NOT_NULL(base);
ScannerOptions options = {0}; ScannerOptions options = {0};
options.base_filters = base; options.base_filters = base;
@@ -1005,6 +1005,7 @@ static void test_per_dir_filter(bool parallel) {
ScannerOptions options = {0}; ScannerOptions options = {0};
options.per_dir_filters = true; options.per_dir_filters = true;
options.exclude_per_dir_filter_files = true; /* -FF */
if (parallel) if (parallel)
options.num_threads = 2; options.num_threads = 2;
char** paths = NULL; char** paths = NULL;
@@ -1084,6 +1085,7 @@ static void test_per_dir_filter_override(bool parallel) {
ScannerOptions options = {0}; ScannerOptions options = {0};
options.per_dir_filters = true; options.per_dir_filters = true;
options.exclude_per_dir_filter_files = true; /* -FF */
if (parallel) if (parallel)
options.num_threads = 2; options.num_threads = 2;
char** paths = NULL; char** paths = NULL;