diff --git a/RSYNC_COMPAT.md b/RSYNC_COMPAT.md index 1dd8959..2c727e8 100644 --- a/RSYNC_COMPAT.md +++ b/RSYNC_COMPAT.md @@ -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 diff --git a/src/client/client_cli.c b/src/client/client_cli.c index b380ef6..2f375d5 100644 --- a/src/client/client_cli.c +++ b/src/client/client_cli.c @@ -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)}, diff --git a/src/client/client_send.c b/src/client/client_send.c index d5c4717..ab13476 100644 --- a/src/client/client_send.c +++ b/src/client/client_send.c @@ -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); diff --git a/src/client/scanner.c b/src/client/scanner.c index 70fbbf9..fe8bd43 100644 --- a/src/client/scanner.c +++ b/src/client/scanner.c @@ -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; diff --git a/src/client/scanner.h b/src/client/scanner.h index 3122cad..566daf3 100644 --- a/src/client/scanner.h +++ b/src/client/scanner.h @@ -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); diff --git a/src/shared/file_list.c b/src/shared/file_list.c index ee2f7e7..8e40c78 100644 --- a/src/shared/file_list.c +++ b/src/shared/file_list.c @@ -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. */ - while (len > 0 && (raw[len - 1] == '\n' || raw[len - 1] == '\r')) - len--; + * -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; diff --git a/src/shared/filter.c b/src/shared/filter.c index 666d8da..8e7ae77 100644 --- a/src/shared/filter.c +++ b/src/shared/filter.c @@ -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++; } - 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; diff --git a/src/shared/filter.h b/src/shared/filter.h index cf7b5c0..8c43f27 100644 --- a/src/shared/filter.h +++ b/src/shared/filter.h @@ -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 { diff --git a/tests/integration/test_features.py b/tests/integration/test_features.py index 00a27c4..b03901d 100644 --- a/tests/integration/test_features.py +++ b/tests/integration/test_features.py @@ -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") diff --git a/tests/test_client_cli.c b/tests/test_client_cli.c index 576337d..285420e 100644 --- a/tests/test_client_cli.c +++ b/tests/test_client_cli.c @@ -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; diff --git a/tests/test_scanner.c b/tests/test_scanner.c index 4627ffb..e31f5e5 100644 --- a/tests/test_scanner.c +++ b/tests/test_scanner.c @@ -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); }