rsync 3.4.1 drop-in parity (#285-#297) + parity completion (protocol 2.26.0) #298

Merged
TapTap merged 77 commits from feat/rsync-parity into dev 2026-09-17 20:33:17 +02:00
4 changed files with 149 additions and 46 deletions
Showing only changes of commit 36375010d3 - Show all commits
+34
View File
@@ -480,6 +480,36 @@ static bool split_flag_level(const char* token, char* name, size_t name_size, in
return true; return true;
} }
/* rsync --debug/--info categories that FastSync accepts for CLI parity but has
* no output wired to (yet). They must parse successfully so a valid rsync
* invocation is not rejected up front; only categories with a FastSync
* counterpart set a log flag. `pack`/`util` are FastSync-specific (packed
* metadata / general utility logging). `syms`, `hl`, and `owner` are aliases
* of rsync's `symsafe`, `hlink`, and `own`. */
static bool is_accepted_debug_category(const char* name) {
static const char* const categories[] = {
"acl", "backup", "bind", "chdir", "cmd", "connect", "del", "deltasum",
"dup", "exit", "filter", "flist", "fuzzy", "genr", "hash", "hl",
"hlink", "iconv", "nstr", "own", "owner", "recv", "send", "time",
};
for (size_t i = 0; i < sizeof(categories) / sizeof(categories[0]); i++) {
if (strcmp(name, categories[i]) == 0)
return true;
}
return false;
}
static bool is_accepted_info_category(const char* name) {
static const char* const categories[] = {
"backup", "del", "flist", "mount", "nonreg", "progress", "remove", "syms", "symsafe",
};
for (size_t i = 0; i < sizeof(categories) / sizeof(categories[0]); i++) {
if (strcmp(name, categories[i]) == 0)
return true;
}
return false;
}
static int parse_debug_flags(const char* value, Config* config) { static int parse_debug_flags(const char* value, Config* config) {
if (!value || value[0] == '\0' || value[0] == ',' || value[strlen(value) - 1] == ',' || if (!value || value[0] == '\0' || value[0] == ',' || value[strlen(value) - 1] == ',' ||
strstr(value, ",,")) { strstr(value, ",,")) {
@@ -522,6 +552,8 @@ static int parse_debug_flags(const char* value, Config* config) {
flag = LOG_DEBUG_PACK; flag = LOG_DEBUG_PACK;
} else if (strcmp(name, "util") == 0) { } else if (strcmp(name, "util") == 0) {
flag = LOG_DEBUG_UTIL; flag = LOG_DEBUG_UTIL;
} else if (is_accepted_debug_category(name)) {
continue;
} else { } else {
log_message(LOG_LEVEL_ERROR, "unsupported --debug flag: %s", token); log_message(LOG_LEVEL_ERROR, "unsupported --debug flag: %s", token);
free(flags); free(flags);
@@ -584,6 +616,8 @@ static int parse_info_flags(const char* value, Config* config) {
flag = LOG_INFO_SKIP; flag = LOG_INFO_SKIP;
else if (strcmp(name, "stats") == 0) else if (strcmp(name, "stats") == 0)
flag = LOG_INFO_STATS; flag = LOG_INFO_STATS;
else if (is_accepted_info_category(name))
continue;
else { else {
log_message(LOG_LEVEL_ERROR, "unsupported --info flag: %s", token); log_message(LOG_LEVEL_ERROR, "unsupported --info flag: %s", token);
free(flags); free(flags);
+9 -4
View File
@@ -341,15 +341,20 @@ void print_usage(void) {
} }
void print_debug_usage(void) { void print_debug_usage(void) {
printf("Supported debug flags: IO,PROTO,PACK,UTIL,ALL,NONE\n"); printf("Emitting debug flags: IO,PROTO,PACK,UTIL,ALL,NONE\n");
printf("Also accepted for rsync CLI parity (silent): ACL,BACKUP,BIND,CHDIR,\n");
printf("CONNECT,CMD,DEL,DELTASUM,DUP,EXIT,FILTER,FLIST,FUZZY,GENR,HASH,HLINK,\n");
printf("ICONV,NSTR,OWN,RECV,SEND,TIME.\n");
printf("Flags may be comma-separated, for example: --debug=io,proto\n"); printf("Flags may be comma-separated, for example: --debug=io,proto\n");
printf("An optional level suffix is accepted (e.g. --debug=io2); level 0\n"); printf("An optional level suffix is accepted (e.g. --debug=io2); level 0\n");
printf("silences that item. Other rsync debug flags are unsupported and rejected.\n"); printf("silences that item. Unknown names are rejected.\n");
} }
void print_info_usage(void) { void print_info_usage(void) {
printf("Supported info flags: COPY,NAME,MISC,SKIP,STATS,ALL,NONE\n"); printf("Emitting info flags: COPY,NAME,MISC,SKIP,STATS,ALL,NONE\n");
printf("Also accepted for rsync CLI parity (silent): BACKUP,DEL,FLIST,MOUNT,\n");
printf("NONREG,PROGRESS,REMOVE,SYMSAFE.\n");
printf("Flags may be comma-separated, for example: --info=name,stats\n"); printf("Flags may be comma-separated, for example: --info=name,stats\n");
printf("An optional level suffix is accepted (e.g. --info=stats2); level 0\n"); printf("An optional level suffix is accepted (e.g. --info=stats2); level 0\n");
printf("silences that item. Other rsync info flags are unsupported and rejected.\n"); printf("silences that item. Unknown names are rejected.\n");
} }
+86 -41
View File
@@ -887,12 +887,33 @@ class TestFakeSuper:
assert fh.read() == b"fake-super-data\n" assert fh.read() == b"fake-super-data\n"
class TestInfoDebugFlagParity: # rsync 3.4.1's full --info/--debug vocabularies (from `rsync --info=help` /
"""#01/#02: rsync's info/debug spellings are either mapped to real output or # `--debug=help`). FastSync must accept every one of them; only the categories
rejected by name (never silently ignored).""" # with an existing FastSync counterpart emit output, the rest are accepted but
# currently silent.
RSYNC_INFO_CATEGORIES = (
"backup", "copy", "del", "flist", "misc", "mount", "name", "nonreg",
"progress", "remove", "skip", "stats", "symsafe", "all", "none",
)
RSYNC_DEBUG_CATEGORIES = (
"acl", "backup", "bind", "chdir", "connect", "cmd", "del", "deltasum",
"dup", "exit", "filter", "flist", "fuzzy", "genr", "hash", "hlink",
"iconv", "io", "nstr", "own", "proto", "recv", "send", "time", "all",
"none",
)
# Extra categories FastSync also accepts: rsync's own help spells these
# `symsafe`/`hlink`/`own`, but the historical aliases are kept working, and
# `pack`/`util` are FastSync-specific debug channels.
FASTSYNC_INFO_ALIASES = ("syms",)
FASTSYNC_DEBUG_ALIASES = ("hl", "owner", "pack", "util")
@requires_rsync
def test_mapped_info_categories_accepted_like_rsync(self, shared_server): class TestInfoDebugFlagParity:
"""#01/#02: FastSync accepts rsync 3.4.1's full --info/--debug vocabulary
(with level suffixes) so a valid rsync invocation is never rejected up
front. Unknown names are still refused by name."""
def _tree(self):
source = os.path.join(TEST_DATA_DIR, "qw_flags_src") source = os.path.join(TEST_DATA_DIR, "qw_flags_src")
dest = os.path.join(TEST_DATA_DIR, "qw_flags_dst") dest = os.path.join(TEST_DATA_DIR, "qw_flags_dst")
rdst = os.path.join(TEST_DATA_DIR, "qw_flags_rdst") rdst = os.path.join(TEST_DATA_DIR, "qw_flags_rdst")
@@ -901,54 +922,78 @@ class TestInfoDebugFlagParity:
clean_dir(rdst) clean_dir(rdst)
with open(os.path.join(source, "a.txt"), "wb") as fh: with open(os.path.join(source, "a.txt"), "wb") as fh:
fh.write(b"a\n") fh.write(b"a\n")
for cat in ("stats2", "name", "copy", "misc", "skip", "STATS2"): return source, dest, rdst
assert _rsync(["-a", "--info=" + cat, source + "/", rdst + "/"]).returncode == 0
@requires_rsync
@pytest.mark.ci
def test_info_vocabulary_accepted_like_rsync(self, shared_server):
source, dest, rdst = self._tree()
for cat in RSYNC_INFO_CATEGORIES:
rs = _rsync(["-a", "--info=" + cat, source + "/", rdst + "/"])
assert rs.returncode == 0, f"rsync rejected --info={cat}: {rs.stderr}"
clean_dir(rdst) clean_dir(rdst)
result, _ = run_client(source, dest, flags=["--info=" + cat], result, _ = run_client(source, dest, flags=["--info=" + cat],
port=shared_server.port) port=shared_server.port)
assert result.returncode == 0, ( assert result.returncode == 0, (
f"--info={cat} must be accepted: {result.stderr[:200]}" f"--info={cat} must be accepted like rsync: {result.stderr[:200]}"
)
@requires_rsync
def test_mapped_debug_categories_accepted_like_rsync(self, shared_server):
source = os.path.join(TEST_DATA_DIR, "qw_dflags_src")
dest = os.path.join(TEST_DATA_DIR, "qw_dflags_dst")
rdst = os.path.join(TEST_DATA_DIR, "qw_dflags_rdst")
clean_dir(source)
clean_dir(dest)
clean_dir(rdst)
with open(os.path.join(source, "a.txt"), "wb") as fh:
fh.write(b"a\n")
for cat in ("io2", "proto0", "all"):
assert _rsync(["-a", "--debug=" + cat, source + "/", rdst + "/"]).returncode == 0
clean_dir(rdst)
result, _ = run_client(source, dest, flags=["--debug=" + cat],
port=shared_server.port)
assert result.returncode == 0, (
f"--debug={cat} must be accepted: {result.stderr[:200]}"
) )
@requires_rsync @requires_rsync
@pytest.mark.ci @pytest.mark.ci
def test_unmapped_categories_rejected_by_name(self, shared_server): def test_debug_vocabulary_accepted_like_rsync(self, shared_server):
"""rsync accepts del/filter; fastsync has no mapping so it must refuse source, dest, rdst = self._tree()
loudly, naming the category, rather than silently ignoring it.""" for cat in RSYNC_DEBUG_CATEGORIES:
source = os.path.join(TEST_DATA_DIR, "qw_umap_src") rs = _rsync(["-a", "--debug=" + cat, source + "/", rdst + "/"])
dest = os.path.join(TEST_DATA_DIR, "qw_umap_dst") assert rs.returncode == 0, f"rsync rejected --debug={cat}: {rs.stderr}"
clean_dir(source) clean_dir(rdst)
clean_dir(dest) result, _ = run_client(source, dest, flags=["--debug=" + cat],
with open(os.path.join(source, "a.txt"), "wb") as fh: port=shared_server.port)
fh.write(b"a\n") assert result.returncode == 0, (
# rsync accepts these (so they are valid rsync invocations). f"--debug={cat} must be accepted like rsync: {result.stderr[:200]}"
assert _rsync(["-a", "--info=del", source + "/", dest + "/"]).returncode == 0 )
assert _rsync(["-a", "--debug=filter", source + "/", dest + "/"]).returncode == 0
for flag, name in (("--info=del", "del"), ("--debug=filter", "filter")): @requires_rsync
@pytest.mark.ci
def test_level_suffixes_accepted_like_rsync(self, shared_server):
source, dest, rdst = self._tree()
for flag in ("--info=stats2", "--info=copy0", "--info=all0",
"--debug=io2", "--debug=proto0", "--debug=all4"):
rs = _rsync(["-a", flag, source + "/", rdst + "/"])
assert rs.returncode == 0, f"rsync rejected {flag}: {rs.stderr}"
clean_dir(rdst)
result, _ = run_client(source, dest, flags=[flag],
port=shared_server.port)
assert result.returncode == 0, (
f"{flag} must be accepted like rsync: {result.stderr[:200]}"
)
def test_fastsync_alias_categories_accepted(self, shared_server):
"""FastSync-specific/alias spellings: accepted (rsync spells them
symsafe/hlink/own) but not emitted."""
source, dest, _ = self._tree()
for flag in (["--info=" + c for c in FASTSYNC_INFO_ALIASES] +
["--debug=" + c for c in FASTSYNC_DEBUG_ALIASES]):
result, _ = run_client(source, dest, flags=[flag],
port=shared_server.port)
assert result.returncode == 0, (
f"{flag} must be accepted: {result.stderr[:200]}"
)
@requires_rsync
@pytest.mark.ci
def test_unknown_categories_rejected_by_name(self, shared_server):
"""Truly unknown names are refused by name, exactly like rsync."""
source, dest, rdst = self._tree()
for flag, name in (("--info=bogus", "bogus"),
("--debug=bogus", "bogus")):
rs = _rsync(["-a", flag, source + "/", rdst + "/"])
assert rs.returncode != 0, f"rsync unexpectedly accepted {flag}"
result, _ = run_client(source, dest, flags=[flag], result, _ = run_client(source, dest, flags=[flag],
port=shared_server.port) port=shared_server.port)
assert result.returncode != 0, f"{flag} must be rejected" assert result.returncode != 0, f"{flag} must be rejected"
assert name in (result.stderr or ""), \ assert name in (result.stderr or ""), (
f"{flag} must be rejected by name, got: {result.stderr[:200]}" f"{flag} must be rejected by name, got: {result.stderr[:200]}"
)
class TestStopAtParity: class TestStopAtParity:
+20 -1
View File
@@ -776,7 +776,7 @@ static void test_parse_args_debug_help() {
} }
static void test_parse_args_debug_flags_validation() { static void test_parse_args_debug_flags_validation() {
static const char* const values[] = {"", "io,", ",io", "io,,proto", "acl", "tls", "unknown"}; static const char* const values[] = {"", "io,", ",io", "io,,proto", "tls", "unknown"};
for (size_t i = 0; i < sizeof(values) / sizeof(values[0]); i++) { for (size_t i = 0; i < sizeof(values) / sizeof(values[0]); i++) {
Config* cfg = config_create(); Config* cfg = config_create();
char option[64]; char option[64];
@@ -1345,6 +1345,24 @@ static void test_parse_args_info_name_and_help() {
config_delete(cfg); config_delete(cfg);
} }
/* rsync 3.4.1's remaining --info/--debug categories parse successfully but
* have no FastSync output wired to them, so they must not set any log flag. */
static void test_parse_args_rsync_flag_vocabulary_accepted() {
Config* cfg = config_create();
char* argv[] = {"fastsync", "--info=backup,del,flist,mount,nonreg,progress,remove,symsafe,syms",
"--debug=acl,backup,bind,chdir,cmd,connect,del,deltasum,dup,exit,"
"filter,flist,fuzzy,genr,hash,hlink,iconv,nstr,own,recv,send,time,"
"hl,owner",
"/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->info_level, 0);
EXPECT_EQ_INT(cfg->debug_level, 0);
config_delete(cfg);
}
/* Test parse_args with --archive flag */ /* Test parse_args with --archive flag */
/* rsync accepts a trailing level digit on --debug/--info items (e.g. io2, /* rsync accepts a trailing level digit on --debug/--info items (e.g. io2,
* all4); level 0 silences the item. */ * all4); level 0 silences the item. */
@@ -4441,6 +4459,7 @@ void test_client_cli() {
test_parse_args_debug_flags(); test_parse_args_debug_flags();
test_parse_args_debug_help(); test_parse_args_debug_help();
test_parse_args_debug_flags_validation(); test_parse_args_debug_flags_validation();
test_parse_args_rsync_flag_vocabulary_accepted();
test_parse_args_debug_info_levels(); test_parse_args_debug_info_levels();
test_parse_args_modify_window(); test_parse_args_modify_window();
test_parse_args_rejects_invalid_modify_window(); test_parse_args_rejects_invalid_modify_window();