diff --git a/src/client/client_cli.c b/src/client/client_cli.c index 781bb76..6680bcb 100644 --- a/src/client/client_cli.c +++ b/src/client/client_cli.c @@ -480,6 +480,36 @@ static bool split_flag_level(const char* token, char* name, size_t name_size, in 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) { if (!value || value[0] == '\0' || value[0] == ',' || value[strlen(value) - 1] == ',' || strstr(value, ",,")) { @@ -522,6 +552,8 @@ static int parse_debug_flags(const char* value, Config* config) { flag = LOG_DEBUG_PACK; } else if (strcmp(name, "util") == 0) { flag = LOG_DEBUG_UTIL; + } else if (is_accepted_debug_category(name)) { + continue; } else { log_message(LOG_LEVEL_ERROR, "unsupported --debug flag: %s", token); free(flags); @@ -584,6 +616,8 @@ static int parse_info_flags(const char* value, Config* config) { flag = LOG_INFO_SKIP; else if (strcmp(name, "stats") == 0) flag = LOG_INFO_STATS; + else if (is_accepted_info_category(name)) + continue; else { log_message(LOG_LEVEL_ERROR, "unsupported --info flag: %s", token); free(flags); diff --git a/src/client/usage.c b/src/client/usage.c index e8f3e65..03db058 100644 --- a/src/client/usage.c +++ b/src/client/usage.c @@ -341,15 +341,20 @@ void print_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("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) { - 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("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"); } diff --git a/tests/integration/test_parity_quickwins.py b/tests/integration/test_parity_quickwins.py index 86faaec..9e7a0dc 100644 --- a/tests/integration/test_parity_quickwins.py +++ b/tests/integration/test_parity_quickwins.py @@ -887,12 +887,33 @@ class TestFakeSuper: assert fh.read() == b"fake-super-data\n" -class TestInfoDebugFlagParity: - """#01/#02: rsync's info/debug spellings are either mapped to real output or - rejected by name (never silently ignored).""" +# rsync 3.4.1's full --info/--debug vocabularies (from `rsync --info=help` / +# `--debug=help`). FastSync must accept every one of them; only the categories +# 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") dest = os.path.join(TEST_DATA_DIR, "qw_flags_dst") rdst = os.path.join(TEST_DATA_DIR, "qw_flags_rdst") @@ -901,54 +922,78 @@ class TestInfoDebugFlagParity: clean_dir(rdst) with open(os.path.join(source, "a.txt"), "wb") as fh: fh.write(b"a\n") - for cat in ("stats2", "name", "copy", "misc", "skip", "STATS2"): - assert _rsync(["-a", "--info=" + cat, source + "/", rdst + "/"]).returncode == 0 + return source, dest, rdst + + @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) result, _ = run_client(source, dest, flags=["--info=" + cat], port=shared_server.port) assert result.returncode == 0, ( - f"--info={cat} must be accepted: {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]}" + f"--info={cat} must be accepted like rsync: {result.stderr[:200]}" ) @requires_rsync @pytest.mark.ci - def test_unmapped_categories_rejected_by_name(self, shared_server): - """rsync accepts del/filter; fastsync has no mapping so it must refuse - loudly, naming the category, rather than silently ignoring it.""" - source = os.path.join(TEST_DATA_DIR, "qw_umap_src") - dest = os.path.join(TEST_DATA_DIR, "qw_umap_dst") - clean_dir(source) - clean_dir(dest) - with open(os.path.join(source, "a.txt"), "wb") as fh: - fh.write(b"a\n") - # rsync accepts these (so they are valid rsync invocations). - 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")): + def test_debug_vocabulary_accepted_like_rsync(self, shared_server): + source, dest, rdst = self._tree() + for cat in RSYNC_DEBUG_CATEGORIES: + rs = _rsync(["-a", "--debug=" + cat, source + "/", rdst + "/"]) + assert rs.returncode == 0, f"rsync rejected --debug={cat}: {rs.stderr}" + 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 like rsync: {result.stderr[:200]}" + ) + + @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], port=shared_server.port) 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]}" + ) class TestStopAtParity: diff --git a/tests/test_client_cli.c b/tests/test_client_cli.c index d3ef21a..2972a8a 100644 --- a/tests/test_client_cli.c +++ b/tests/test_client_cli.c @@ -776,7 +776,7 @@ static void test_parse_args_debug_help() { } 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++) { Config* cfg = config_create(); char option[64]; @@ -1345,6 +1345,24 @@ static void test_parse_args_info_name_and_help() { 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 */ /* rsync accepts a trailing level digit on --debug/--info items (e.g. io2, * all4); level 0 silences the item. */ @@ -4441,6 +4459,7 @@ void test_client_cli() { test_parse_args_debug_flags(); test_parse_args_debug_help(); test_parse_args_debug_flags_validation(); + test_parse_args_rsync_flag_vocabulary_accepted(); test_parse_args_debug_info_levels(); test_parse_args_modify_window(); test_parse_args_rejects_invalid_modify_window();