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
+4 -4
View File
@@ -53,9 +53,9 @@ This document maps rsync's full feature set to FastSync's current implementation
|------|-------------------|-----------------|-------|
| `--exclude-from=FILE` | Read exclude patterns from file | ✅ Implemented | Reads patterns from file |
| `--include-from=FILE` | Read include patterns from file | ✅ Implemented | Reads patterns from file |
| `--filter=RULE` | Add file-filtering rule | ✅ Implemented | Long option only: rsync's short `-f` conflicts with FastSync sendfile (see FastSync-specific list), so `-f` is not reassigned. Supported subset: `+`/`-` include/exclude, implicit-exclude patterns, `include`/`exclude` word forms, a leading `/` anchor (to the transfer root, or to a `.rsync-filter` file's directory), and a trailing `/` for dir-only rules; first match wins with a default of include inside the filter layer. `merge`/`dir-merge`/`hide`/`show`/`protect`/`risk`/`clear` and rule modifiers are rejected with a clear error. Filters are an independent layer from `--exclude`/`--include` (an entry must pass both) |
| `--files-from=FILE` | Read source file list from file | ✅ Implemented | Entries are paths relative to the source root (leading `./` stripped, `..`/absolute entries rejected at parse time, blank lines ignored). A listed regular file is transferred; a listed directory transfers its whole subtree (FastSync recursion is always on, unlike rsync's non-recursive default). Non-listed paths and their subtrees are pruned by the scanner. The delete manifest still derives from what was actually sent, so `--delete` stays consistent with the subset |
| `-0`, `--from0` | Delimit *-from files with NULs | ✅ Implemented | `--files-from` entries become NUL-delimited; the flag may appear before or after `--files-from` on the command line |
| `--filter=RULE` | Add file-filtering rule | ✅ Implemented | Long option only: rsync's short `-f` conflicts with FastSync sendfile (see FastSync-specific list), so `-f` is not reassigned. Supported subset: `+`/`-` include/exclude, implicit-exclude patterns, `include`/`exclude` word forms, a leading `/` anchor (to the transfer root, or to a `.rsync-filter` file's directory), and a trailing `/` for dir-only rules; first match wins with a default of include inside the filter layer. Filters are an independent layer from `--exclude`/`--include` (an entry must pass both). Rejected with a clear error (no silent no-ops): `merge`/`dir-merge`/`hide`/`show`/`protect`/`risk`/`clear` words, rules that begin with `:`/`.`/`!` (merge/dir-merge/list-clear shorthands), and include/exclude modifiers other than `/` (`! C s r p x`) |
| `--files-from=FILE` | Read source file list from file | ✅ Implemented | Entries are paths relative to the source root (leading `./` stripped, `..`/absolute entries rejected at parse time, blank lines ignored; NUL-delimited with `-0`). A listed regular file is transferred; a listed directory transfers its whole subtree (FastSync recursion is always on, unlike rsync's non-recursive default). Non-listed paths and their subtrees are pruned by the scanner. A listed entry that does not exist under the source (and an empty list) is a hard error reported before any transfer; listing `.` (whole tree) and empty listed directories are fine. Scalability note: `file_list_affects` is O(list size) per scanned entry, so a very large `--files-from` list against a huge tree is quadratic; lists are typically small enough that this is acceptable, but it is the documented bound. The delete manifest still derives from what was actually sent, so `--delete` stays consistent with the subset |
| `-0`, `--from0` | Delimit *-from files with NULs | ✅ Implemented | `--files-from` entries become NUL-delimited; the flag may appear before or after `--files-from` on the command line. NUL mode preserves entry bytes exactly (trailing CR/LF are part of the name; only newline mode trims them) |
| `--max-size=SIZE` | Skip files larger than SIZE | ✅ Implemented | `max_size` in scanner |
| `--min-size=SIZE` | Skip files smaller than SIZE | ✅ Implemented | `min_size` in scanner |
| `-I`, `--ignore-times` | Don't skip files matching size+time | ❌ Not Implemented | |
@@ -65,7 +65,7 @@ This document maps rsync's full feature set to FastSync's current implementation
| `--ignore-existing` | Skip updating existing files | ❌ Not Implemented | |
| `--remove-source-files` | Sender removes regular files after confirmed transfer | ✅ Implemented | |
| `-x`, `--one-file-system` | Do not cross filesystem boundaries | ✅ Implemented | Sender scanner captures the root device and skips descending into mount-point crossings (`st_dev` differs); cross-filesystem mount-point subdirectories are dropped entirely, matching rsync |
| `-F` | Add the default `.rsync-filter` rules | ✅ Implemented | Reads one filter rule per line from each directory's `.rsync-filter` file during traversal and applies it to that directory's subtree (rules inherit into deeper directories and are overridable by deeper files); `.rsync-filter` files are never transferred. The rsync `-FF` behavior (also `.cvsignore`) is out of scope; unsupported rule types inside the file abort with a clear error |
| `-F` | Add the default `.rsync-filter` rules | ✅ Implemented | Reads one filter rule per line from each directory's `.rsync-filter` file during traversal and applies it to that directory's subtree; the current directory's rules are evaluated before its ancestors', so deeper files override shallower ones and per-directory files override the command-line `--filter`/`-C` base by default (matching rsync's first-match-wins precedence); `.rsync-filter` files are never transferred. The rsync `-FF` behavior (also `.cvsignore`) is out of scope; unsupported rule types inside the file abort with a clear error |
## 4. Directory Options
+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);
+9 -6
View File
@@ -39,16 +39,19 @@ static bool string_list_add(StringList* list, const char* text) {
/* Validate and normalize one entry. Returns:
* 1 -> added to `out`
* 0 -> blank entry, skip
* -1 -> invalid (message set in `err`) */
static int normalize_entry(const char* raw, size_t len, StringList* out, char* err,
size_t err_size) {
/* Trim the trailing newline/carriage-return from line mode. */
* -1 -> invalid (message set in `err`)
* `strip_line_endings` trims a trailing CR/LF (line mode only); NUL mode keeps
* the entry bytes verbatim so names ending in CR/LF survive. */
static int normalize_entry(const char* raw, size_t len, bool strip_line_endings, StringList* out,
char* err, size_t err_size) {
if (strip_line_endings) {
while (len > 0 && (raw[len - 1] == '\n' || raw[len - 1] == '\r'))
len--;
}
if (len == 0)
return 0;
if (raw[0] == '/') {
snprintf(err, err_size, "absolute path entries are not allowed: '%s'", raw);
snprintf(err, err_size, "absolute path entries are not allowed: '%.*s'", (int)len, raw);
return -1;
}
/* Reject NUL bytes inside a token defensively (NUL-delimited mode splits on
@@ -134,7 +137,7 @@ FileListSet* file_list_load(const char* path, bool null_separated, char* err, si
bool ok = true;
char delim = null_separated ? '\0' : '\n';
while (ok && (n = getdelim(&line, &line_cap, delim, fp)) != -1) {
int r = normalize_entry(line, (size_t)n, &raw, err, err_size);
int r = normalize_entry(line, (size_t)n, !null_separated, &raw, err, err_size);
if (r < 0) {
ok = false;
break;
+35 -4
View File
@@ -19,6 +19,13 @@ static bool rule_text_is_unsupported_word(const char* p, size_t len) {
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';
@@ -34,7 +41,6 @@ FilterRule* filter_rule_parse(const char* line, char* err, size_t err_size) {
while (len > 0 && (text[len - 1] == '\n' || text[len - 1] == '\r'))
text[--len] = '\0';
FilterAction action = FILTER_ACTION_EXCLUDE;
const char* p = text;
while (*p == ' ' || *p == '\t')
p++;
@@ -44,11 +50,35 @@ FilterRule* filter_rule_parse(const char* line, char* err, size_t err_size) {
return NULL;
}
FilterAction action = FILTER_ACTION_EXCLUDE;
if (*p == '+' || *p == '-') {
action = *p == '+' ? FILTER_ACTION_INCLUDE : FILTER_ACTION_EXCLUDE;
p++;
/* Accept the rsync word forms include/exclude. */
/* 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++;
@@ -68,16 +98,17 @@ FilterRule* filter_rule_parse(const char* line, char* err, size_t err_size) {
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;
+6 -2
View File
@@ -18,8 +18,12 @@
* contains a .rsync-filter file for per-directory rules)
* a trailing '/' makes the rule match directories only
*
* Unsupported rsync rule types (merge/dir-merge/hide/show/protect/risk/clear,
* rule modifiers other than '/') are rejected with a clear error.
* Rejected explicitly (no silent no-ops): the rsync merge/dir-merge/list-clear
* shorthands written as a rule that starts with ':' or '.' or '!', the
* merge/dir-merge/hide/show/protect/risk/clear words, and every include/exclude
* rule modifier other than '/' (! C s r p x). The pattern must be separated
* from +/- by a space (or a single '/' anchor), exactly like rsync's
* "-s foo"/"-p ..." modifier syntax is refused.
*/
typedef enum {
+67
View File
@@ -1489,6 +1489,43 @@ class TestFilesFrom:
assert result.returncode != 0, "missing --files-from file must be rejected"
assert "--files-from" in result.stderr
def test_files_from_missing_entry_rejected(self):
source = self._make_source("ff_missing_src")
dest = os.path.join(TEST_DATA_DIR, "ff_missing_dst")
clean_dir(dest)
lst = self._write_list(b"top.txt\nno_such_file.txt\n")
result, _ = run_client(source, dest, flags=["--files-from", lst])
assert result.returncode != 0, "a listed-but-missing file must be a hard error"
assert "not found" in result.stderr
def test_files_from_missing_entry_rejected_dry_run(self):
source = self._make_source("ff_missing_dry_src")
dest = os.path.join(TEST_DATA_DIR, "ff_missing_dry_dst")
clean_dir(dest)
lst = self._write_list(b"gone.bin\n")
result, _ = run_client(source, dest, flags=["--files-from", lst, "--dry-run"])
assert result.returncode != 0, "dry-run must also reject a listed-but-missing file"
assert "not found" in result.stderr
def test_files_from_empty_list_rejected(self):
source = self._make_source("ff_empty_src")
dest = os.path.join(TEST_DATA_DIR, "ff_empty_dst")
clean_dir(dest)
lst = self._write_list(b"")
result, _ = run_client(source, dest, flags=["--files-from", lst])
assert result.returncode != 0, "an empty --files-from list must be rejected"
assert "no entries" in result.stderr
def test_files_from_empty_directory_listed_is_not_an_error(self, shared_server):
source = self._make_source("ff_emptydir_src")
os.makedirs(os.path.join(source, "emptydir"), exist_ok=True)
dest = os.path.join(TEST_DATA_DIR, "ff_emptydir_dst")
clean_dir(dest)
lst = self._write_list(b"emptydir\n")
result, _ = run_client(source, dest, flags=["--files-from", lst],
port=shared_server.port)
assert result.returncode == 0, f"empty listed directory errored: {result.stderr[:200]}"
def test_files_from_delete_deletes_unlisted(self):
source = self._make_source("ff_delete_src")
dest = os.path.join(TEST_DATA_DIR, "ff_delete_dst")
@@ -1620,6 +1657,36 @@ class TestFilters:
assert not os.path.exists(os.path.join(received, ".rsync-filter")), \
".rsync-filter must not be transferred"
@pytest.mark.parametrize("mt", [False, True])
def test_per_dir_filter_deeper_file_overrides_outer(self, shared_server, mt):
source = os.path.join(TEST_DATA_DIR, "filter_ovr_src")
clean_dir(source)
entries = {
"top.tmp": b"outer excludes me\n",
"keep.txt": b"kept\n",
"sub/inside.tmp": b"inner re-includes me\n",
".rsync-filter": b"- *.tmp\n",
"sub/.rsync-filter": b"+ *.tmp\n",
}
for rel, content in entries.items():
full = os.path.join(source, rel)
os.makedirs(os.path.dirname(full), exist_ok=True)
with open(full, "wb") as fh:
fh.write(content)
dest = os.path.join(TEST_DATA_DIR, "filter_ovr_dst")
clean_dir(dest)
flags = ["-F"] + (["-m"] if mt else [])
result, _ = run_client(source, dest, flags=flags, port=shared_server.port)
assert result.returncode == 0, f"-F override sync failed: {result.stderr[:200]}"
received = get_dest_received_dir(dest, source)
assert os.path.isfile(os.path.join(received, "keep.txt"))
assert os.path.isfile(os.path.join(received, "sub", "inside.tmp")), \
"inner + *.tmp must re-include what the root - *.tmp excluded"
assert not os.path.exists(os.path.join(received, "top.tmp")), \
"outer - *.tmp still excludes root-level tmp files"
assert not os.path.exists(os.path.join(received, ".rsync-filter"))
assert not os.path.exists(os.path.join(received, "sub", ".rsync-filter"))
def test_filter_leaves_default_behavior_unchanged(self, shared_server):
source = self._make_tree("filter_default_src")
dest = os.path.join(TEST_DATA_DIR, "filter_default_dst")
+53 -1
View File
@@ -1289,6 +1289,29 @@ static void test_parse_args_filter_rules() {
char* missing_argv[] = {"fastsync", "/src", "/dst", "--filter"};
EXPECT_EQ_INT(parse_args(cfg, 4, missing_argv, positional_args, &positional_count), -1);
config_delete(cfg);
/* rsync shorthands/modifiers we do not support are rejected instead of being
* silently parsed as literal patterns. */
static const char* const unsupported[] = {
": .rsync-filter", ". /tmp/rules", "-s foo", "-p bar", "-C", "-! *.o", "!",
};
for (size_t i = 0; i < sizeof(unsupported) / sizeof(unsupported[0]); i++) {
cfg = config_create();
positional_count = 0;
char* rule_argv[] = {"fastsync", "--filter", (char*)unsupported[i], "/src", "/dst"};
EXPECT_EQ_INT(parse_args(cfg, 5, rule_argv, positional_args, &positional_count), -1);
config_delete(cfg);
}
/* Supported spellings still parse: space- or slash-separated, attached
* wildcards, and anchored rules. */
cfg = config_create();
positional_count = 0;
char* ok_argv[] = {"fastsync", "--filter=-*.o", "--filter=- /foo",
"--filter=+ /bar/", "/src", "/dst"};
EXPECT_EQ_INT(parse_args(cfg, 6, ok_argv, positional_args, &positional_count), 0);
EXPECT_EQ_INT(cfg->filters->size, 3);
config_delete(cfg);
}
/* -0/--from0, -C/--cvs-exclude and -F wire into their config flags. */
@@ -1316,6 +1339,19 @@ static void test_parse_args_from0_cvs_filter_file_flags() {
EXPECT_EQ_INT(cfg->per_dir_filter, cases[i].per_dir);
config_delete(cfg);
}
/* The plain booleans are negatable (--no-* simply clears the flag). */
static const char* const on[][2] = {{"--from0", "--no-from0"}, {"-C", "--no-cvs-exclude"}};
for (size_t i = 0; i < sizeof(on) / sizeof(on[0]); i++) {
Config* cfg = config_create();
int positional_args[2];
int positional_count = 0;
char* argv[] = {"fastsync", (char*)on[i][0], (char*)on[i][1], "/src", "/dst"};
EXPECT_EQ_INT(parse_args(cfg, 5, argv, positional_args, &positional_count), 0);
EXPECT_FALSE(cfg->from0);
EXPECT_FALSE(cfg->cvs_exclude);
config_delete(cfg);
}
}
static void write_file_bytes(const char* path, const char* bytes, size_t len) {
@@ -1347,7 +1383,8 @@ static void test_parse_args_files_from() {
config_delete(cfg);
remove(list_path);
/* -0 switches the separator to NUL regardless of argument order. */
/* -0 switches the separator to NUL regardless of argument order, and NUL
* mode preserves entry bytes exactly (a trailing CR/LF is part of the name). */
write_file_bytes(list_path, "x.txt\0y/z.bin\0", 14);
cfg = config_create();
positional_count = 0;
@@ -1365,6 +1402,21 @@ static void test_parse_args_files_from() {
config_delete(cfg);
remove(list_path);
write_file_bytes(list_path, "crlf\n\0tail\0", 11);
cfg = config_create();
positional_count = 0;
char* nul_nl_argv[] = {"fastsync",
"--files-from="
"cli_files_from_list.txt",
"-0", "/src", "/dst"};
EXPECT_EQ_INT(parse_args(cfg, 5, nul_nl_argv, positional_args, &positional_count), 0);
set = (FileListSet*)cfg->files_from_set;
EXPECT_NOT_NULL(set);
EXPECT_TRUE(file_list_affects(set, "crlf\n"));
EXPECT_TRUE(file_list_affects(set, "tail"));
config_delete(cfg);
remove(list_path);
/* A missing list file is a hard parse-time error. */
cfg = config_create();
positional_count = 0;
+86
View File
@@ -995,6 +995,89 @@ static void test_per_dir_filter(bool parallel) {
rmdir(root);
}
/* scanner_path_relative maps an on-disk path to its transfer-relative path,
* including the "/" transfer-root edge case (regression: children of "/" used
* to abort the scan because the suffix was mis-read). */
static void test_scanner_path_relative() {
char* rel = NULL;
rel = scanner_path_relative("/", "/");
EXPECT_NOT_NULL(rel);
EXPECT_EQ_STR(rel, "");
free(rel);
rel = scanner_path_relative("/", "/etc");
EXPECT_NOT_NULL(rel);
EXPECT_EQ_STR(rel, "etc");
free(rel);
rel = scanner_path_relative("/", "/etc/passwd");
EXPECT_NOT_NULL(rel);
EXPECT_EQ_STR(rel, "etc/passwd");
free(rel);
/* Normal roots: with and without a trailing slash on the root. */
rel = scanner_path_relative("/tmp/foo", "/tmp/foo");
EXPECT_NOT_NULL(rel);
EXPECT_EQ_STR(rel, "");
free(rel);
rel = scanner_path_relative("/tmp/foo", "/tmp/foo/bar");
EXPECT_NOT_NULL(rel);
EXPECT_EQ_STR(rel, "bar");
free(rel);
rel = scanner_path_relative("/tmp/foo/", "/tmp/foo/bar/baz.txt");
EXPECT_NOT_NULL(rel);
EXPECT_EQ_STR(rel, "bar/baz.txt");
free(rel);
/* A path outside the root maps to NULL. */
EXPECT_NULL(scanner_path_relative("/tmp/foo", "/tmp"));
EXPECT_NULL(scanner_path_relative("/tmp/foo", "/tmp/foobar"));
}
/* rsync precedence: a deeper .rsync-filter overrides a shallower one, so an
* inner "+ *.tmp" re-includes what the outer "- *.tmp" excluded. */
static void test_per_dir_filter_override(bool parallel) {
const char* root = "test_scan_perdir_ovr";
const char* sub = "test_scan_perdir_ovr/sub";
EXPECT_EQ_INT(mkdir(root, 0755), 0);
EXPECT_EQ_INT(mkdir(sub, 0755), 0);
create_test_file("test_scan_perdir_ovr/.rsync-filter", "- *.tmp\n");
create_test_file("test_scan_perdir_ovr/sub/.rsync-filter", "+ *.tmp\n");
create_test_file("test_scan_perdir_ovr/top.tmp", "x");
create_test_file("test_scan_perdir_ovr/keep.txt", "keep");
create_test_file("test_scan_perdir_ovr/sub/inside.tmp", "x");
ScannerOptions options = {0};
options.per_dir_filters = true;
if (parallel)
options.num_threads = 2;
char** paths = NULL;
int count = 0;
int rc = parallel ? collect_files_parallel(root, &options, &paths, &count)
: collect_files(root, &options, &paths, &count);
EXPECT_EQ_INT(rc, 0);
/* top.tmp is still excluded by the root file; inside.tmp is re-included by
* the subdir file; .rsync-filter files are never transferred. */
EXPECT_EQ_INT(count, 2);
EXPECT_TRUE(has_path(paths, count, "keep.txt"));
EXPECT_TRUE(has_path(paths, count, "sub/inside.tmp"));
EXPECT_FALSE(has_path(paths, count, "top.tmp"));
EXPECT_FALSE(has_path(paths, count, ".rsync-filter"));
EXPECT_FALSE(has_path(paths, count, "sub/.rsync-filter"));
free_paths(paths, count);
unlink("test_scan_perdir_ovr/top.tmp");
unlink("test_scan_perdir_ovr/keep.txt");
unlink("test_scan_perdir_ovr/sub/inside.tmp");
unlink("test_scan_perdir_ovr/.rsync-filter");
unlink("test_scan_perdir_ovr/sub/.rsync-filter");
rmdir(sub);
rmdir(root);
}
void test_scanner() {
test_scanner_single_file();
test_scanner_multiple_files();
@@ -1024,4 +1107,7 @@ void test_scanner() {
test_cvs_defaults(true);
test_per_dir_filter(false);
test_per_dir_filter(true);
test_scanner_path_relative();
test_per_dir_filter_override(false);
test_per_dir_filter_override(true);
}