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:
+4
-4
@@ -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
|
||||
|
||||
|
||||
@@ -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)},
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user