fix(filter): reject the x modifier in the --filter list parser

The standalone filter_rule_parse() already rejected the rsync xattr-name
'x' modifier, but the list parser used by --filter/-f silently dropped the
flag for merge/dir-merge rules (and relied on a second parse for plain
rules).  Reject it explicitly in filter_list_parse_append_depth() with the
same diagnostic, so '-x', 'merge,x' and 'dir-merge,x' all fail cleanly.

Also reject the unimplemented rsync merge modifiers 'e', 'n' and 'w'
instead of folding them into the pattern, which previously produced
misleading errors such as "could not read merge file 'n file'".  Only a
token made up solely of modifier characters is treated as a modifier run,
so glued patterns ('-newfile', '-e2e') and mixed tokens ("H,!secret")
keep their historical parsing.

Adds tests/test_filter.c with focused rejection and supported-syntax
cases.
This commit is contained in:
2026-09-21 19:19:06 +02:00
parent 3799200f71
commit 0f40e747f0
6 changed files with 301 additions and 9 deletions
+67 -8
View File
@@ -172,14 +172,50 @@ static bool is_modifier_char(char c) {
return c == 's' || c == 'r' || c == 'p' || c == 'x' || c == '/' || c == '!' || c == 'C';
}
/* Modifiers rsync defines but FastSync does not implement. They must still be
* consumed as part of the modifier run so they are rejected explicitly instead
* of leaking into the pattern (which produced misleading failures such as
* "could not read merge file 'n file'"). */
static bool is_unsupported_modifier_char(char c) {
return c == 'e' || c == 'n' || c == 'w';
}
/* Characters that are part of a modifier run, whether supported or not. */
static bool is_modifier_scan_char(char c) {
return is_modifier_char(c) || is_unsupported_modifier_char(c);
}
/* Inspect the token that follows a rule name (up to the first space/underscore
* or the end). If the token is composed *solely* of modifier characters and
* includes one FastSync does not implement, it is unambiguously a modifier run:
* return that character so the caller can reject it. A token that contains any
* non-modifier character is a pattern (e.g. "-newfile") and returns '\0', which
* keeps the historical parsing of mixed tokens such as "H,!secret" intact. */
static char unsupported_modifier_in_token(const char* tok) {
if (*tok == '\0' || *tok == ' ' || *tok == '_')
return '\0';
char bad = '\0';
for (const char* q = tok; *q != '\0' && *q != ' ' && *q != '_'; q++) {
if (!is_modifier_scan_char(*q))
return '\0';
if (is_unsupported_modifier_char(*q))
bad = *q;
}
return bad;
}
/* 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. */
* merge/clear) are filled. Returns true on success.
*
* On failure `*bad_mod` is set to the offending modifier character when the
* rule carried a modifier FastSync does not implement, and left '\0' for a
* generic syntax error so callers can emit a precise diagnostic. */
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** pat_start, size_t* pat_len, char* bad_mod) {
const char* p = text;
*sides = FILTER_SIDE_SENDER | FILTER_SIDE_RECEIVER;
*sides_explicit = false;
@@ -190,6 +226,7 @@ static bool parse_rule_syntax(const char* text, RuleKind* kind, unsigned* sides,
*cvs_inject = false;
*pat_start = NULL;
*pat_len = 0;
*bad_mod = '\0';
bool is_short = false;
if (short_rule_char(*p, kind)) {
@@ -210,6 +247,14 @@ static bool parse_rule_syntax(const char* text, RuleKind* kind, unsigned* sides,
/* 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. */
if (*p == ',') {
*bad_mod = unsupported_modifier_in_token(p + 1);
} else if (is_short) {
*bad_mod = unsupported_modifier_in_token(p);
}
if (*bad_mod != '\0')
return false;
const char* mod_start = p;
const char* mod_end = p;
if (*p == ',') {
@@ -290,9 +335,13 @@ FilterRule* filter_rule_parse(const char* line, const FilterParseOptions* opts,
bool sides_explicit, negate, anchored_mod, perishable, xattr, cvs_inject;
const char* pat;
size_t pat_len;
char bad_mod;
if (!parse_rule_syntax(p, &kind, &sides, &sides_explicit, &negate, &anchored_mod, &perishable,
&xattr, &cvs_inject, &pat, &pat_len)) {
filter_set_error(err, err_size, "unrecognized filter rule syntax");
&xattr, &cvs_inject, &pat, &pat_len, &bad_mod)) {
if (bad_mod != '\0')
filter_set_error(err, err_size, "unsupported filter modifier '%c'", bad_mod);
else
filter_set_error(err, err_size, "unrecognized filter rule syntax");
return NULL;
}
if (cvs_inject) {
@@ -401,7 +450,6 @@ FilterRule* filter_rule_parse(const char* line, const FilterParseOptions* opts,
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;
}
@@ -530,16 +578,27 @@ static bool filter_list_parse_append_depth(FilterRuleList* list, const char* lin
bool sides_explicit, negate, anchored_mod, perishable, xattr, cvs_inject;
const char* pat;
size_t pat_len;
char bad_mod;
if (!parse_rule_syntax(p, &kind, &sides, &sides_explicit, &negate, &anchored_mod, &perishable,
&xattr, &cvs_inject, &pat, &pat_len)) {
filter_set_error(err, err_size, "unrecognized filter rule syntax: %s", p);
&xattr, &cvs_inject, &pat, &pat_len, &bad_mod)) {
if (bad_mod != '\0')
filter_set_error(err, err_size, "unsupported filter modifier '%c': %s", bad_mod, p);
else
filter_set_error(err, err_size, "unrecognized filter rule syntax: %s", p);
return false;
}
(void)sides_explicit;
(void)negate;
(void)anchored_mod;
(void)perishable;
(void)xattr;
/* xattr-name rules are not implemented; reject them everywhere (including on
* merge/dir-merge, where the flag would otherwise be silently dropped) with
* the same diagnostic the standalone parser gives. */
if (xattr) {
filter_set_error(err, err_size, "xattr-name filter rules (the x modifier) are not supported");
return false;
}
if (cvs_inject) {
/* "C" injects the CVS defaults in place; no pattern is expected. */