diff --git a/RSYNC_COMPAT.md b/RSYNC_COMPAT.md index e6f52d3..f60d539 100644 --- a/RSYNC_COMPAT.md +++ b/RSYNC_COMPAT.md @@ -235,7 +235,7 @@ This document maps rsync's full feature set to FastSync's current implementation | `--iconv=CONVERT_SPEC` | Charset conversion | ❌ Not Implemented | | | `--checksum-seed=NUM` | Set checksum seed | ❌ Not Implemented | | | `--secluded-args` | Use protocol to send args | 🔄 Compatibility No-op | Accepted for CLI compatibility; it does not change FastSync transport or protocol behavior. `-s` remains chunk serialization. | -| `--no-OPTION` | Turn off implied option | ❌ Not Implemented | | +| `--no-OPTION` | Turn off implied option | ✅ Supported | Supported boolean FastSync options and archive-implied options; unsafe or value-taking options are rejected. | --- diff --git a/src/client/client_cli.c b/src/client/client_cli.c index 7c7b68c..d32488d 100644 --- a/src/client/client_cli.c +++ b/src/client/client_cli.c @@ -345,6 +345,13 @@ typedef struct { } OptionEntry; /* Options parsed directly into Config, plus compatibility options with no effect. */ +typedef struct { + const char* name; + const char* alias; + size_t offset; /* offsetof of the boolean target field in Config */ +} NegatableOption; + +/* Options that map directly onto a Config field with no side effects. */ static const OptionEntry OPTION_TABLE[] = { {"--dry-run", "-n", OPT_FLAG, offsetof(Config, dry_run)}, {"--remove-source-files", NULL, OPT_FLAG, offsetof(Config, remove_source_files)}, @@ -398,6 +405,35 @@ static const OptionEntry OPTION_TABLE[] = { {"--min-size", NULL, OPT_ULL, offsetof(Config, min_size)}, }; +/* Only boolean options with no required argument are safe to negate. */ +static const NegatableOption NEGATABLE_OPTIONS[] = { + {"dry-run", "n", offsetof(Config, dry_run)}, + {"delete", NULL, offsetof(Config, use_delete)}, + {"incremental", NULL, offsetof(Config, use_incremental)}, + {"delta", NULL, offsetof(Config, use_delta)}, + {"save-to-disk", NULL, offsetof(Config, save_to_disk)}, + {"progress", NULL, offsetof(Config, show_progress)}, + {"tls", NULL, offsetof(Config, use_tls)}, + {"backup", NULL, offsetof(Config, backup)}, + {"stats", NULL, offsetof(Config, stats)}, + {"partial", NULL, offsetof(Config, partial)}, + {"links", "l", offsetof(Config, follow_symlinks)}, + {"copy-links", NULL, offsetof(Config, copy_links)}, + {"safe-links", NULL, offsetof(Config, safe_links)}, + {"copy-unsafe-links", NULL, offsetof(Config, copy_unsafe_links)}, + {"sparse", "S", offsetof(Config, preserve_sparse)}, + {"inplace", NULL, offsetof(Config, inplace)}, + {"checksum", NULL, offsetof(Config, checksum)}, + + /* These options are also implied by --archive or handled outside the table. */ + {"compress", "c", offsetof(Config, use_compression)}, + {"compress", "z", offsetof(Config, use_compression)}, + {"multithreading", "m", offsetof(Config, use_multithreading)}, + {"preserve", "M", offsetof(Config, use_metadata)}, + {"sendfile", "f", offsetof(Config, use_sendfile)}, + {"chunk-serialization", "s", offsetof(Config, use_chunk_serialization)}, +}; + static bool opt_is(const char* arg, const char* name, const char* alias) { return strcmp(arg, name) == 0 || (alias && strcmp(arg, alias) == 0); } @@ -429,6 +465,31 @@ static const OptionEntry* find_table_option_with_equals(const char* arg, const c return NULL; } +static const NegatableOption* find_negatable_option(const char* name) { + for (size_t i = 0; i < sizeof(NEGATABLE_OPTIONS) / sizeof(NEGATABLE_OPTIONS[0]); i++) + if (strcmp(name, NEGATABLE_OPTIONS[i].name) == 0 || + (NEGATABLE_OPTIONS[i].alias && strcmp(name, NEGATABLE_OPTIONS[i].alias) == 0)) + return &NEGATABLE_OPTIONS[i]; + return NULL; +} + +static int apply_negation(Config* config, const char* arg) { + const char* name = arg + strlen("--no-"); + if (*name == '\0') { + fprintf(stderr, "Cannot negate an empty option name: %s\n", arg); + return -1; + } + const NegatableOption* entry = find_negatable_option(name); + if (!entry) { + fprintf(stderr, "Cannot negate unsupported or unsafe option: %s\n", arg); + return -1; + } + *(bool*)((char*)config + entry->offset) = false; + if (entry->offset == offsetof(Config, use_metadata)) + config->metadata_explicitly_disabled = true; + return 0; +} + static int apply_table_option(Config* config, const OptionEntry* entry, const char* value) { if (entry->kind == OPT_NOOP) return 0; @@ -479,6 +540,11 @@ int parse_args(Config* config, int argc, char* argv[], int* positional_args, } for (int i = 1; i < argc; i++) { + if (strncmp(argv[i], "--no-", strlen("--no-")) == 0) { + if (apply_negation(config, argv[i]) != 0) + return -1; + continue; + } const char* modify_window_prefix = "--modify-window="; if (strncmp(argv[i], modify_window_prefix, strlen(modify_window_prefix)) == 0) { if (set_nonneg_int_option(&config->modify_window, argv[i] + strlen(modify_window_prefix), @@ -759,9 +825,16 @@ int parse_args(Config* config, int argc, char* argv[], int* positional_args, } } } - set_log_level(config->quiet ? LOG_LEVEL_ERROR : (verbose ? LOG_LEVEL_DEBUG : LOG_LEVEL_WARNING)); +set_log_level(config->quiet ? LOG_LEVEL_ERROR : (verbose ? LOG_LEVEL_DEBUG : LOG_LEVEL_WARNING)); if (config->compress_choice) config->use_compression = strcmp(config->compress_choice, "zstd") == 0; + + /* Incremental and delta transfers need metadata unless the user disabled it. */ + if ((config->use_incremental || config->use_delta) && !config->use_metadata && + !config->metadata_explicitly_disabled) { + log_message(LOG_LEVEL_INFO, "Enabling metadata preservation for incremental/delta transfer"); + config->use_metadata = true; + } return 0; } @@ -871,15 +944,6 @@ int main(int argc, char* argv[]) { goto cleanup; } - /* Enable implicit flags */ - if (config->use_incremental && !config->use_metadata) { - log_info_message(LOG_INFO_MISC, "Enabling metadata preservation for --incremental"); - config->use_metadata = true; - } - if (config->use_delta && !config->use_metadata) { - log_info_message(LOG_INFO_MISC, "Enabling metadata preservation for --delta"); - config->use_metadata = true; - } /* Initialize TLS if needed */ if (config->use_tls) tls_global_init(); diff --git a/src/client/usage.c b/src/client/usage.c index 4eb7179..3dc3182 100644 --- a/src/client/usage.c +++ b/src/client/usage.c @@ -91,9 +91,10 @@ void print_usage(void) { printf(" --inplace Update files in-place (no temp+rename)\n"); printf(" --fsync Fsync every written file before publication\n"); printf(" --compress-level Compression level (default: 5)\n"); - printf(" --zl Alias for --compress-level\n"); +printf(" --zl Alias for --compress-level\n"); printf(" --skip-compress=LIST Skip compression for comma-separated suffixes\n"); printf(" --compress-threads Compression worker threads (requires zstd threaded support)\n"); + printf(" --no-OPTION Disable a supported boolean option\n"); printf(" --help Show this help\n"); printf(" -V, --version Show version\n"); } diff --git a/src/shared/config.c b/src/shared/config.c index 1a0e317..1eec716 100644 --- a/src/shared/config.c +++ b/src/shared/config.c @@ -19,6 +19,7 @@ static void config_set_defaults(Config* config) { config->use_compression = false; config->use_metadata = false; config->use_executability = false; + config->metadata_explicitly_disabled = false; config->show_progress = false; config->dry_run = false; config->remove_source_files = false; diff --git a/src/shared/config.h b/src/shared/config.h index 84994e1..8a84ecd 100644 --- a/src/shared/config.h +++ b/src/shared/config.h @@ -19,6 +19,7 @@ typedef struct Config { bool use_sendfile; bool use_metadata; bool use_executability; + bool metadata_explicitly_disabled; bool show_progress; bool dry_run; bool remove_source_files; diff --git a/tests/integration/test_features.py b/tests/integration/test_features.py index 40812b4..1d8a5c8 100644 --- a/tests/integration/test_features.py +++ b/tests/integration/test_features.py @@ -169,6 +169,20 @@ class TestArchiveMode: assert not missing, f"Missing: {missing}" assert not mismatches, f"Mismatch: {mismatches}" + def test_archive_implied_options_can_be_negated(self, shared_server): + clean_dir(DEST_DIR) + result, dur = run_client( + SOURCE_DIR, DEST_DIR, + flags=["--archive", "--no-compress", "--no-m", "--no-preserve"], + port=shared_server.port, + ) + if result.returncode != 0: + pytest.fail(f"Exit {result.returncode}: {(result.stderr or result.stdout)[:200]}") + received = get_dest_received_dir(DEST_DIR, SOURCE_DIR) + mismatches, missing = verify_transfer(SOURCE_DIR, received) + assert not missing, f"Missing: {missing}" + assert not mismatches, f"Mismatch: {mismatches}" + class TestExecutability: def test_preserves_only_executable_bits(self, shared_server): diff --git a/tests/test_client_cli.c b/tests/test_client_cli.c index 4f48539..97d1455 100644 --- a/tests/test_client_cli.c +++ b/tests/test_client_cli.c @@ -703,6 +703,69 @@ static void test_parse_args_archive() { config_delete(cfg); } +/* Negations must override archive's implied options in argument order. */ +static void test_parse_args_negations() { + Config* cfg = config_create(); + char* argv[] = {"fastsync", "--archive", "--no-compress", "--no-m", + "--no-preserve", "--no-dry-run", "/src", "/dst"}; + int positional_args[2]; + int positional_count = 0; + + EXPECT_EQ_INT(parse_args(cfg, 8, argv, positional_args, &positional_count), 0); + EXPECT_FALSE(cfg->use_compression); + EXPECT_FALSE(cfg->use_multithreading); + EXPECT_FALSE(cfg->use_metadata); + EXPECT_FALSE(cfg->dry_run); + EXPECT_EQ_INT(positional_count, 2); + config_delete(cfg); +} + +static void test_parse_args_negation_order() { + Config* cfg = config_create(); + char* argv[] = {"fastsync", "--no-z", "-c", "/src", "/dst"}; + int positional_args[2]; + int positional_count = 0; + + EXPECT_EQ_INT(parse_args(cfg, 5, argv, positional_args, &positional_count), 0); + EXPECT_TRUE(cfg->use_compression); + config_delete(cfg); +} + +static void test_parse_args_no_preserve_blocks_implicit_metadata() { + static const char* const options[][3] = { + {"--incremental", "--no-preserve", "/src"}, + {"--no-preserve", "--incremental", "/src"}, + {"--delta", "--no-preserve", "/src"}, + {"--no-preserve", "--delta", "/src"}, + }; + + for (size_t i = 0; i < sizeof(options) / sizeof(options[0]); i++) { + Config* cfg = config_create(); + char* argv[] = {"fastsync", (char*)options[i][0], (char*)options[i][1], (char*)options[i][2], + "/dst"}; + int positional_args[2]; + int positional_count = 0; + + EXPECT_EQ_INT(parse_args(cfg, 5, argv, positional_args, &positional_count), 0); + EXPECT_FALSE(cfg->use_metadata); + EXPECT_TRUE(cfg->metadata_explicitly_disabled); + config_delete(cfg); + } +} + +static void test_parse_args_rejects_unsafe_negation() { + static const char* const options[] = {"--no-archive", "--no-timeout", "--no-unknown"}; + for (size_t i = 0; i < sizeof(options) / sizeof(options[0]); i++) { + Config* cfg = config_create(); + char* argv[] = {"fastsync", (char*)options[i], "/src", "/dst"}; + int positional_args[2]; + int positional_count = 0; + + EXPECT_EQ_INT(parse_args(cfg, 4, argv, positional_args, &positional_count), -1); + config_delete(cfg); + } +} + static void test_parse_args_fsync() { Config* cfg = config_create(); char* argv[] = {"fastsync", "--fsync", "/src", "/dst"}; @@ -969,6 +1032,10 @@ void test_client_cli() { test_parse_args_info_verbose_order(); test_parse_args_rejects_invalid_info_flag(); test_parse_args_archive(); + test_parse_args_negations(); + test_parse_args_negation_order(); + test_parse_args_no_preserve_blocks_implicit_metadata(); + test_parse_args_rejects_unsafe_negation(); test_parse_args_fsync(); test_parse_args_existing(); test_parse_args_ignore_times();