Merge branch 'feat/parity-leftovers' into feat/parity-completion

# Conflicts:
#	src/client/scanner.c
#	src/client/scanner.h
This commit is contained in:
2026-09-16 23:44:13 +02:00
16 changed files with 1413 additions and 533 deletions
+31 -4
View File
@@ -4963,15 +4963,20 @@ class TestFuzzy:
"no-candidate fuzzy run should have sent the whole file"
def test_dissimilar_sibling_is_not_used(self, shared_server):
# The destination holds a large sibling whose basename is too different
# from the incoming name; the name gate must reject it and fall back to
# a whole-file transfer.
# A sibling whose basename is too different from the incoming name is
# rejected by rsync's fuzzy distance window (the length gap exceeds
# 25), so the run falls back to a whole-file transfer. A distinct
# mtime keeps rsync's exact size+mtime first pass from accepting it.
source, dest = self._prepare("dissim")
old_bytes, new_bytes = _random_payloads()
self._seed_dest(source, dest, {"totally-unrelated-notes.bin": old_bytes},
long_name = "totally-unrelated-notes-with-a-very-long-name.bin"
self._seed_dest(source, dest, {long_name: old_bytes},
shared_server.port)
with open(os.path.join(source, self.NEW_NAME), "wb") as fh:
fh.write(new_bytes)
received_dir = get_dest_received_dir(dest, source)
os.utime(os.path.join(received_dir, long_name), (self.TS, self.TS))
os.utime(os.path.join(source, self.NEW_NAME), (self.TS + 100000, self.TS + 100000))
result, proxy = self._run_measured(source, dest, ["--fuzzy"], shared_server.port)
assert result.returncode == 0, \
f"--fuzzy dissimilar-sibling run failed: {(result.stderr or result.stdout)[:300]}"
@@ -4980,6 +4985,28 @@ class TestFuzzy:
assert proxy.client_to_server > len(new_bytes) // 2, \
"a dissimilar-named sibling must not be used as a fuzzy basis"
def test_exact_size_mtime_sibling_is_used(self, shared_server):
# rsync's fuzzy first pass accepts a sibling with an exact size+mtime
# match regardless of how unrelated its name is (its content is almost
# certainly the same).
source, dest = self._prepare("exact")
old_bytes, new_bytes = _random_payloads()
self._seed_dest(source, dest, {"unrelated-blob.bin": old_bytes},
shared_server.port)
with open(os.path.join(source, self.NEW_NAME), "wb") as fh:
fh.write(new_bytes)
received_dir = get_dest_received_dir(dest, source)
ts = 1600000000
os.utime(os.path.join(received_dir, "unrelated-blob.bin"), (ts, ts))
os.utime(os.path.join(source, self.NEW_NAME), (ts, ts))
result, proxy = self._run_measured(source, dest, ["--fuzzy"], shared_server.port)
assert result.returncode == 0, \
f"--fuzzy exact size+mtime run failed: {(result.stderr or result.stdout)[:300]}"
received = get_dest_received_dir(dest, source)
assert _read_file(os.path.join(received, self.NEW_NAME)) == new_bytes
assert proxy.client_to_server < len(new_bytes) // 4, \
"an exact size+mtime sibling should be used as a fuzzy basis"
def test_fuzzy_helps_when_dest_holds_an_unsuitable_file(self, shared_server):
# The destination DOES hold the exact new name, but it is a tiny stale
# file (below the delta engine's minimum, ratio far outside its window),
+60 -10
View File
@@ -1044,10 +1044,11 @@ static void test_parse_args_basis_dirs() {
config_delete(cfg);
}
/* Absolute, escaping, or degenerate basis-dir values must be rejected up
front: they would resolve outside the destination root on the receiver. */
/* Escaping or degenerate basis-dir values must be rejected up front (they would
resolve outside the destination root on the receiver); an absolute path is
accepted (rsync parity) and canonicalized with its leading '/' preserved. */
static void test_parse_args_basis_invalid_paths() {
static const char* const invalid[] = {"/abs", "..", "a/../b", "."};
static const char* const invalid[] = {"..", "a/../b", ".", "/", ""};
for (size_t i = 0; i < sizeof(invalid) / sizeof(invalid[0]); i++) {
Config* cfg = config_create();
char* argv[] = {"fastsync", "--link-dest", (char*)invalid[i], "/src", "/dst"};
@@ -1056,6 +1057,15 @@ static void test_parse_args_basis_invalid_paths() {
EXPECT_EQ_INT(parse_args(cfg, 5, argv, positional_args, &positional_count), -1);
config_delete(cfg);
}
Config* cfg = config_create();
char* argv[] = {"fastsync", "--link-dest=/abs/dir", "/src", "/dst"};
int positional_args[2];
int positional_count = 0;
EXPECT_EQ_INT(parse_args(cfg, 4, argv, positional_args, &positional_count), 0);
EXPECT_EQ_INT(cfg->basis_count, 1);
EXPECT_EQ_STR(cfg->basis_dirs[0].path, "/abs/dir");
config_delete(cfg);
}
/* Basis dirs require the per-file incremental handshake, which -s disables. */
@@ -2551,15 +2561,35 @@ static void test_parse_args_filter_rules() {
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", "!",
/* Full rsync grammar (rule words, modifiers, clear) is supported. */
cfg = config_create();
positional_count = 0;
char* grammar_argv[] = {"fastsync",
"--filter=hide *.tmp",
"--filter=show *.txt",
"--filter=protect *.bak",
"--filter=risk *.o",
"--filter=-s foo",
"--filter=-p bar",
"--filter=-! *.o",
"--filter=dir-merge .rules",
"--filter=!",
"/src",
"/dst"};
EXPECT_EQ_INT(parse_args(cfg, 11, grammar_argv, positional_args, &positional_count), 0);
config_delete(cfg);
/* Genuinely malformed rules are still rejected. */
static const char* const malformed[] = {
"merge", /* merge requires a filename */
"dir-merge", /* dir-merge requires a filename */
"clear extra", /* clear takes no pattern */
"no-such-rule x", /* unknown rule word */
};
for (size_t i = 0; i < sizeof(unsupported) / sizeof(unsupported[0]); i++) {
for (size_t i = 0; i < sizeof(malformed) / sizeof(malformed[0]); i++) {
cfg = config_create();
positional_count = 0;
char* rule_argv[] = {"fastsync", "--filter", (char*)unsupported[i], "/src", "/dst"};
char* rule_argv[] = {"fastsync", "--filter", (char*)malformed[i], "/src", "/dst"};
EXPECT_EQ_INT(parse_args(cfg, 5, rule_argv, positional_args, &positional_count), -1);
config_delete(cfg);
}
@@ -3146,6 +3176,27 @@ static void test_parse_args_chown() {
EXPECT_TRUE(cfg->chown_gid_set);
EXPECT_EQ_INT(cfg->chown_gid, IDENTITY_CURRENT);
config_delete(cfg);
/* A --chown NAME is converted to the equivalent receiver-resolved map rule
* (rsync implements --chown as --usermap=*:USER --groupmap=*:GROUP), so the
* name is carried on the wire as to_name instead of being resolved on the
* sender. A name that does not exist on the sender is accepted and left for
* the receiver to resolve (or warn about), matching rsync. */
cfg = config_create();
positional_count = 0;
char* argv5[] = {"fastsync", "--chown=no_such_user_zzz:no_such_group_zzz", "/src", "/dst"};
EXPECT_EQ_INT(parse_args(cfg, 4, argv5, positional_args, &positional_count), 0);
EXPECT_EQ_INT(cfg->usermap_count, 1);
EXPECT_NOT_NULL(cfg->usermap[0].to_name);
if (cfg->usermap[0].to_name)
EXPECT_EQ_STR(cfg->usermap[0].to_name, "no_such_user_zzz");
EXPECT_FALSE(cfg->chown_uid_set);
EXPECT_EQ_INT(cfg->groupmap_count, 1);
EXPECT_NOT_NULL(cfg->groupmap[0].to_name);
if (cfg->groupmap[0].to_name)
EXPECT_EQ_STR(cfg->groupmap[0].to_name, "no_such_group_zzz");
EXPECT_FALSE(cfg->chown_gid_set);
config_delete(cfg);
}
/* --copy-as=USER[:GROUP] (P7 Wave E): resolve the user/group against the local
@@ -3220,7 +3271,6 @@ static void test_parse_args_rejects_malformed_identity() {
{"--groupmap", "@1"},
{"--groupmap", "no_such_group_qqq:x"},
{"--chown", "a:b:c"},
{"--chown", "no_such_user_zzz:"},
{"--copy-as", ""},
{"--copy-as", ":"},
{"--copy-as", "a:b:c"},
+10 -2
View File
@@ -1192,7 +1192,10 @@ static void test_config_basis_wire_rejects_escaping() {
c->basis_dirs = calloc(1, sizeof(BasisDest));
c->basis_dirs[0].type = BASIS_DEST_LINK;
c->basis_dirs[0].path = str_dup("/abs");
EXPECT_FALSE(roundtrip_config_ok(c));
/* An absolute basis dir is accepted (rsync parity); it is only usable when it
lies within the receiver's authorized root, which file_open_secure_parent
enforces at lookup time. */
EXPECT_TRUE(roundtrip_config_ok(c));
config_delete(c);
/* A well-formed list still round-trips even with a manually built struct. */
@@ -1225,8 +1228,13 @@ static void test_config_basis_normalization() {
/* Degenerate values that normalize away to nothing stay rejected. */
EXPECT_EQ_INT(config_basis_append(c, BASIS_DEST_LINK, "."), -1);
EXPECT_EQ_INT(config_basis_append(c, BASIS_DEST_LINK, ".."), -1);
EXPECT_EQ_INT(config_basis_append(c, BASIS_DEST_LINK, "/abs"), -1);
/* An absolute path is canonicalized (leading '/' preserved) and accepted. */
EXPECT_EQ_INT(config_basis_append(c, BASIS_DEST_LINK, "/abs"), 0);
EXPECT_EQ_STR(c->basis_dirs[c->basis_count - 1].path, "/abs");
EXPECT_EQ_INT(config_basis_append(c, BASIS_DEST_LINK, "/a//b/"), 0);
EXPECT_EQ_STR(c->basis_dirs[c->basis_count - 1].path, "/a/b");
EXPECT_EQ_INT(config_basis_append(c, BASIS_DEST_LINK, "a/../b"), -1);
EXPECT_EQ_INT(config_basis_append(c, BASIS_DEST_LINK, "/"), -1);
EXPECT_EQ_INT(config_basis_append(c, BASIS_DEST_LINK, ""), -1);
config_delete(c);
}
+8 -6
View File
@@ -855,7 +855,7 @@ static void test_filter_rules(bool parallel) {
/* - *.tmp excludes only the tmp file; other files remain (default include). */
const char* exclude_only[] = {"- *.tmp"};
char err[160];
FilterRuleList* base = filter_base_build(exclude_only, 1, false, err, sizeof(err));
FilterRuleList* base = filter_base_build(exclude_only, 1, false, false, err, sizeof(err));
EXPECT_NOT_NULL(base);
ScannerOptions options = {0};
options.base_filters = base;
@@ -875,7 +875,7 @@ static void test_filter_rules(bool parallel) {
/* Anchored include then exclude-all: only root-level keep* survives. */
const char* anchored[] = {"+ /a.txt", "- *"};
base = filter_base_build(anchored, 2, false, err, sizeof(err));
base = filter_base_build(anchored, 2, false, false, err, sizeof(err));
EXPECT_NOT_NULL(base);
options.base_filters = base;
rc = parallel ? collect_files_parallel(root, &options, &paths, &count)
@@ -889,7 +889,7 @@ static void test_filter_rules(bool parallel) {
/* The common include idiom (the exact rule order the CLI compiles from
* --include='*.txt' --exclude='*'): only .txt files survive. */
const char* idiom[] = {"+ *.txt", "- *"};
base = filter_base_build(idiom, 2, false, err, sizeof(err));
base = filter_base_build(idiom, 2, false, false, err, sizeof(err));
EXPECT_NOT_NULL(base);
options.base_filters = base;
rc = parallel ? collect_files_parallel(root, &options, &paths, &count)
@@ -905,7 +905,7 @@ static void test_filter_rules(bool parallel) {
/* An include rule alone is NOT a mandatory whitelist (rsync semantics): only
* the matching file is affected, everything else is still transferred. */
const char* include_alone[] = {"+ *.txt"};
base = filter_base_build(include_alone, 1, false, err, sizeof(err));
base = filter_base_build(include_alone, 1, false, false, err, sizeof(err));
EXPECT_NOT_NULL(base);
options.base_filters = base;
rc = parallel ? collect_files_parallel(root, &options, &paths, &count)
@@ -932,7 +932,7 @@ static void test_filter_dir_only_and_anchored(bool parallel) {
const char* rules[] = {"- /sub/"};
char err[160];
FilterRuleList* base = filter_base_build(rules, 1, false, err, sizeof(err));
FilterRuleList* base = filter_base_build(rules, 1, false, false, err, sizeof(err));
EXPECT_NOT_NULL(base);
ScannerOptions options = {0};
options.base_filters = base;
@@ -966,7 +966,7 @@ static void test_cvs_defaults(bool parallel) {
create_test_file("test_scan_cvs/keep.txt", "keep");
char err[160];
FilterRuleList* base = filter_base_build(NULL, 0, true, err, sizeof(err));
FilterRuleList* base = filter_base_build(NULL, 0, true, false, err, sizeof(err));
EXPECT_NOT_NULL(base);
ScannerOptions options = {0};
options.base_filters = base;
@@ -1005,6 +1005,7 @@ static void test_per_dir_filter(bool parallel) {
ScannerOptions options = {0};
options.per_dir_filters = true;
options.exclude_per_dir_filter_files = true; /* -FF */
if (parallel)
options.num_threads = 2;
char** paths = NULL;
@@ -1137,6 +1138,7 @@ static void test_per_dir_filter_override(bool parallel) {
ScannerOptions options = {0};
options.per_dir_filters = true;
options.exclude_per_dir_filter_files = true; /* -FF */
if (parallel)
options.num_threads = 2;
char** paths = NULL;