From 2a08a7ba5cc25653c80377d9aaae98dd1add2d9b Mon Sep 17 00:00:00 2001 From: TapTap Date: Thu, 3 Sep 2026 17:56:27 +0200 Subject: [PATCH 1/3] feat: add skip-compress option --- RSYNC_COMPAT.md | 2 +- src/client/client_cli.c | 29 +++++++++++++++++++++++ src/client/client_send.c | 37 +++++++++++++++++++---------- src/client/usage.c | 1 + src/shared/compression.c | 18 ++++++++++---- src/shared/compression.h | 1 + src/shared/config.c | 38 +++++++++++++++++++++++++++--- src/shared/config.h | 4 ++++ src/shared/file_receive.c | 15 +++++++++--- src/shared/file_send.c | 21 ++++++++++++++--- src/shared/file_send.h | 6 +++++ tests/integration/test_features.py | 29 +++++++++++++++++++++++ tests/test_client_cli.c | 28 ++++++++++++++++++++++ tests/test_compression.c | 9 +++++++ 14 files changed, 211 insertions(+), 27 deletions(-) diff --git a/RSYNC_COMPAT.md b/RSYNC_COMPAT.md index 4b236ec..9aa4a21 100644 --- a/RSYNC_COMPAT.md +++ b/RSYNC_COMPAT.md @@ -174,7 +174,7 @@ This document maps rsync's full feature set to FastSync's current implementation | `--compress-choice=STR` | Choose compression algorithm | ❌ Not Implemented | Removed because it had no effect; FastSync always uses zstd | | `--compress-level=NUM` | Set compression level | ✅ Implemented | 1-22, default 5 | | `--compress-threads=NUM` | Set compression threads | ❌ Not Implemented | | -| `--skip-compress=LIST` | Skip compress for suffixes | ❌ Not Implemented | Internal skip for hardcoded types; not user-configurable | +| `--skip-compress=LIST` | Skip compress for suffixes | ✅ Implemented | Comma-separated, case-insensitive suffix list; empty list skips none | ## 13. Connectivity diff --git a/src/client/client_cli.c b/src/client/client_cli.c index 191cc07..22229c6 100644 --- a/src/client/client_cli.c +++ b/src/client/client_cli.c @@ -119,6 +119,29 @@ static int config_add_pattern(char*** patterns, int* count, const char* value, return 0; } +static int parse_skip_compress(Config* config, const char* value) { + char* list = str_dup(value); + if (!list) + return -1; + config->skip_compress_set = true; + for (char* token = strtok(list, ","); token; token = strtok(NULL, ",")) { + while (*token == ' ' || *token == '\t') + token++; + size_t len = strlen(token); + while (len > 0 && (token[len - 1] == ' ' || token[len - 1] == '\t')) + token[--len] = '\0'; + if (len == 0) + continue; + if (config_add_pattern(&config->skip_compress_suffixes, &config->skip_compress_count, token, + "--skip-compress") != 0) { + free(list); + return -1; + } + } + free(list); + return 0; +} + typedef enum { OPT_FLAG, OPT_STRING, @@ -361,6 +384,12 @@ int parse_args(Config* config, int argc, char* argv[], int* positional_args, log_message(LOG_LEVEL_ERROR, "--compress-level must be between 1 and 22"); return -1; } + } else if (strncmp(argv[i], "--skip-compress=", 16) == 0) { + if (parse_skip_compress(config, argv[i] + 16) != 0) + return -1; + } else if (opt_is(argv[i], "--skip-compress", NULL) && i + 1 < argc) { + if (parse_skip_compress(config, argv[++i]) != 0) + return -1; } else if (argv[i][0] == '-') { fprintf(stderr, "Unknown option: %s\n", argv[i]); print_usage(); diff --git a/src/client/client_send.c b/src/client/client_send.c index 0b4ca5c..d016d9a 100644 --- a/src/client/client_send.c +++ b/src/client/client_send.c @@ -234,7 +234,9 @@ static int send_delta(Client* client, File* file, DeltaSignature* sig, Config* c return send_status(client->file_descriptor, STATUS_NEXT) ? 1 : -1; Data* to_send = delta_data; - if (config->use_compression) { + int skip_count = config->skip_compress_set ? config->skip_compress_count : -1; + if (config->use_compression && !compression_should_skip_with_suffixes( + file->path, config->skip_compress_suffixes, skip_count)) { to_send = data_compress(delta_data, config->compression_level); data_destroy(delta_data); if (!to_send) @@ -251,20 +253,23 @@ static int send_delta(Client* client, File* file, DeltaSignature* sig, Config* c return ok ? 0 : -1; } -typedef bool (*file_send_fn)(File*, int, bool, int, bool); - // Send a single file directly (non-incremental path). -static bool send_file_direct(File* file, int fd, bool use_metadata, int compression_level) { +static bool send_file_direct(File* file, int fd, bool use_metadata, int compression_level, + const Config* config) { if (!send_status(fd, STATUS_NEXT)) return false; - return file_send_single_calls(file, fd, use_metadata, compression_level, true); + int skip_count = config->skip_compress_set ? config->skip_compress_count : -1; + return file_send_single_calls_with_skip(file, fd, use_metadata, compression_level, true, + config->skip_compress_suffixes, skip_count); } // Send a single file directly via sendfile (non-incremental path). -static bool send_file_direct_sendfile(File* file, int fd, bool use_metadata) { +static bool send_file_direct_sendfile(File* file, int fd, bool use_metadata, const Config* config) { if (!send_status(fd, STATUS_NEXT)) return false; - return file_send_sendfile(file, fd, use_metadata, 0, true); + int skip_count = config->skip_compress_set ? config->skip_compress_count : -1; + return file_send_sendfile_with_skip(file, fd, use_metadata, 0, true, + config->skip_compress_suffixes, skip_count); } // Process one file in a chunk: either via incremental check or direct send. @@ -275,10 +280,12 @@ static int send_single_file(Client* client, File* file, Config* config, bool use if (!use_incremental) { if (use_sendfile) { - return send_file_direct_sendfile(file, client->file_descriptor, config->use_metadata) ? 0 - : -1; + return send_file_direct_sendfile(file, client->file_descriptor, config->use_metadata, config) + ? 0 + : -1; } - return send_file_direct(file, client->file_descriptor, config->use_metadata, compression_level) + return send_file_direct(file, client->file_descriptor, config->use_metadata, compression_level, + config) ? 0 : -1; } @@ -304,13 +311,14 @@ static int send_single_file(Client* client, File* file, Config* config, bool use return -1; } // Fall through: send full file via sendfile (pass 0 for compression_level) - if (!file_send_sendfile(file, client->file_descriptor, config->use_metadata, 0, false)) + int skip_count = config->skip_compress_set ? config->skip_compress_count : -1; + if (!file_send_sendfile_with_skip(file, client->file_descriptor, config->use_metadata, 0, false, + config->skip_compress_suffixes, skip_count)) return -1; return 0; } // Incremental path with single_calls (supports compression and delta) - file_send_fn send_fn = (file_send_fn)file_send_single_calls; DeltaSignature* sig = NULL; int rc = incremental_check(client, file, config, &sig); if (rc < 0) { @@ -338,7 +346,10 @@ static int send_single_file(Client* client, File* file, Config* config, bool use return -1; } } - if (!send_fn(file, client->file_descriptor, config->use_metadata, compression_level, false)) + int skip_count = config->skip_compress_set ? config->skip_compress_count : -1; + if (!file_send_single_calls_with_skip(file, client->file_descriptor, config->use_metadata, + compression_level, false, config->skip_compress_suffixes, + skip_count)) return -1; return 0; } diff --git a/src/client/usage.c b/src/client/usage.c index 2f800af..1c58786 100644 --- a/src/client/usage.c +++ b/src/client/usage.c @@ -69,6 +69,7 @@ void print_usage(void) { printf(" -S, --sparse Handle sparse files efficiently\n"); printf(" --inplace Update files in-place (no temp+rename)\n"); printf(" --compress-level Compression level (default: 5)\n"); + printf(" --skip-compress=LIST Skip compression for comma-separated suffixes\n"); printf(" --help Show this help\n"); printf(" -V, --version Show version\n"); } diff --git a/src/shared/compression.c b/src/shared/compression.c index 1a42ba5..cda999a 100644 --- a/src/shared/compression.c +++ b/src/shared/compression.c @@ -11,17 +11,27 @@ #define INITIAL_DECOMPRESS_BUF_SIZE (1024 * 1024) #define MAX_DECOMPRESSED_SIZE (100ULL * 1024 * 1024) /* 100 MB hard ceiling */ -static const char* SKIP_COMPRESSION_EXTENSIONS[] = {".jpg", ".jpeg", ".png", ".gif", ".mp4", ".mkv", - ".zip", ".gz", ".xz", ".zst", NULL}; +static char* SKIP_COMPRESSION_EXTENSIONS[] = {".jpg", ".jpeg", ".png", ".gif", ".mp4", ".mkv", + ".zip", ".gz", ".xz", ".zst", NULL}; bool compression_should_skip(const char* path) { + return compression_should_skip_with_suffixes(path, NULL, -1); +} + +bool compression_should_skip_with_suffixes(const char* path, char* const* suffixes, int count) { if (!path) return false; const char* dot = strrchr(path, '.'); if (!dot) return false; - for (int i = 0; SKIP_COMPRESSION_EXTENSIONS[i]; i++) { - if (strcasecmp(dot, SKIP_COMPRESSION_EXTENSIONS[i]) == 0) + if (count < 0) { + suffixes = SKIP_COMPRESSION_EXTENSIONS; + count = 0; + while (SKIP_COMPRESSION_EXTENSIONS[count]) + count++; + } + for (int i = 0; i < count; i++) { + if (strcasecmp(dot, suffixes[i]) == 0) return true; } return false; diff --git a/src/shared/compression.h b/src/shared/compression.h index 5322abe..2cbb17a 100644 --- a/src/shared/compression.h +++ b/src/shared/compression.h @@ -8,5 +8,6 @@ Data* data_compress(Data* data_to_compress, int compression_level); Data* data_decompress(Data* compressed_data); Data* data_decompress_limited(Data* compressed_data, size_t maximum_size); bool compression_should_skip(const char* path); +bool compression_should_skip_with_suffixes(const char* path, char* const* suffixes, int count); #endif diff --git a/src/shared/config.c b/src/shared/config.c index efc2708..e078ca0 100644 --- a/src/shared/config.c +++ b/src/shared/config.c @@ -98,6 +98,9 @@ static void config_set_defaults(Config* config) { config->server_mode = false; config->checksum = false; config->compress_choice = NULL; + config->skip_compress_suffixes = NULL; + config->skip_compress_count = 0; + config->skip_compress_set = false; } static bool valid_wire_bool(int value) { @@ -134,7 +137,8 @@ static bool validate_received_config(const Config* config) { config->chunk_size > 0 && config->chunk_size <= MAX_CHUNK_SIZE && config->delta_block_size >= DELTA_BLOCK_SIZE_MIN && config->delta_block_size <= DELTA_BLOCK_SIZE_MAX && - config->delta_max_file_size <= DELTA_MAX_FILE_SIZE && config->max_delete >= 0; + config->delta_max_file_size <= DELTA_MAX_FILE_SIZE && config->max_delete >= 0 && + config->skip_compress_count >= 0 && config->skip_compress_count <= 10000; } Config* config_create(void) { @@ -208,6 +212,9 @@ void config_delete(Config* config) { free(config->bind_address); free(config->daemon_config); free(config->compress_choice); + for (int i = 0; i < config->skip_compress_count; i++) + free(config->skip_compress_suffixes[i]); + free(config->skip_compress_suffixes); if (config->filters) { array_list_delete(config->filters); } @@ -249,11 +256,22 @@ static bool send_selection_options(int fd, const Config* c) { send_int(fd, c->relative) && send_int(fd, c->prune_empty_dirs); } +static bool send_skip_compress_options(int fd, const Config* c) { + if (!send_int(fd, c->skip_compress_set) || !send_int(fd, c->skip_compress_count)) + return false; + for (int i = 0; i < c->skip_compress_count; i++) { + if (!send_str(fd, c->skip_compress_suffixes[i])) + return false; + } + return true; +} + static bool send_resume_options(int fd, const Config* c) { return send_str(fd, c->temp_dir ? c->temp_dir : "") && send_int(fd, c->partial) && send_str(fd, c->partial_dir ? c->partial_dir : "") && send_str(fd, c->suffix ? c->suffix : "") && send_int(fd, c->delete_before) && - send_int(fd, c->checksum) && send_str(fd, c->compress_choice ? c->compress_choice : ""); + send_int(fd, c->checksum) && send_str(fd, c->compress_choice ? c->compress_choice : "") && + send_skip_compress_options(fd, c); } static bool receive_core_fields(int fd, Config* c) { @@ -330,7 +348,21 @@ static bool receive_resume_options(int fd, Config* c) { if (!receive_wire_bool(fd, &c->checksum)) return false; c->compress_choice = receive_str(fd); - return c->compress_choice != NULL; + if (!c->compress_choice || !receive_wire_bool(fd, &c->skip_compress_set) || + !receive_int(fd, &c->skip_compress_count) || c->skip_compress_count < 0 || + c->skip_compress_count > 10000) + return false; + if (c->skip_compress_count > 0) { + c->skip_compress_suffixes = calloc((size_t)c->skip_compress_count, sizeof(char*)); + if (!c->skip_compress_suffixes) + return false; + for (int i = 0; i < c->skip_compress_count; i++) { + c->skip_compress_suffixes[i] = receive_str(fd); + if (!c->skip_compress_suffixes[i]) + return false; + } + } + return true; } bool config_send(int file_descriptor, const Config* config) { diff --git a/src/shared/config.h b/src/shared/config.h index 4a218b0..6ce70eb 100644 --- a/src/shared/config.h +++ b/src/shared/config.h @@ -126,6 +126,10 @@ typedef struct Config { // PR #184: Compression algorithm negotiation char* compress_choice; + + char** skip_compress_suffixes; + int skip_compress_count; + bool skip_compress_set; } Config; #define PROTOCOL_VERSION "2.2.0" diff --git a/src/shared/file_receive.c b/src/shared/file_receive.c index a610d67..980545a 100644 --- a/src/shared/file_receive.c +++ b/src/shared/file_receive.c @@ -169,7 +169,10 @@ static File* receive_delta_file(int fd, const Config* config, const char* check_ } Data* raw_delta = delta_data; - if (config->use_compression) { + if (config->use_compression && + !compression_should_skip_with_suffixes( + check_path, config->skip_compress_suffixes, + config->skip_compress_set ? config->skip_compress_count : -1)) { raw_delta = data_decompress_limited(delta_data, MAX_RECEIVE_FILE_SIZE); data_destroy(delta_data); if (!raw_delta) { @@ -272,7 +275,10 @@ static File* receive_delta_file(int fd, const Config* config, const char* check_ return NULL; } - if (config->use_compression) { + if (config->use_compression && + !compression_should_skip_with_suffixes( + file->path, config->skip_compress_suffixes, + config->skip_compress_set ? config->skip_compress_count : -1)) { Data* uncompressed = data_decompress_limited(file_data, MAX_RECEIVE_FILE_SIZE); data_destroy(file_data); if (uncompressed == NULL) { @@ -500,7 +506,10 @@ File* file_receive(const Config* config, int file_descriptor) { file_destroy(file); return NULL; } - if (config->use_compression && !compression_should_skip(file->path)) { + if (config->use_compression && + !compression_should_skip_with_suffixes(file->path, config->skip_compress_suffixes, + config->skip_compress_set ? config->skip_compress_count + : -1)) { Data* file_data_uncompressed = data_decompress_limited(file_data, MAX_RECEIVE_FILE_SIZE); data_destroy(file_data); if (file_data_uncompressed == NULL) { diff --git a/src/shared/file_send.c b/src/shared/file_send.c index 5757ce1..28bfca6 100644 --- a/src/shared/file_send.c +++ b/src/shared/file_send.c @@ -19,11 +19,19 @@ bool file_send_single_calls(File* file, int file_descriptor, bool use_metadata, int compression_level, bool send_path) { + return file_send_single_calls_with_skip(file, file_descriptor, use_metadata, compression_level, + send_path, NULL, -1); +} + +bool file_send_single_calls_with_skip(File* file, int file_descriptor, bool use_metadata, + int compression_level, bool send_path, + char* const* skip_suffixes, int skip_count) { if (!file || !file->path || !file->data || (file->data->size != 0 && !file->data->data)) return false; const Data* data_to_send = file->data; Data* compressed_data = NULL; - if (compression_level > 0 && !compression_should_skip(file->path)) { + if (compression_level > 0 && + !compression_should_skip_with_suffixes(file->path, skip_suffixes, skip_count)) { compressed_data = data_compress(file->data, compression_level); if (compressed_data == NULL) { log_message(LOG_LEVEL_ERROR, "Failed to compress file data"); @@ -49,11 +57,18 @@ bool file_send_single_calls(File* file, int file_descriptor, bool use_metadata, bool file_send_sendfile(File* file, int file_descriptor, bool use_metadata, int compression_level, bool send_path) { + return file_send_sendfile_with_skip(file, file_descriptor, use_metadata, compression_level, + send_path, NULL, -1); +} + +bool file_send_sendfile_with_skip(File* file, int file_descriptor, bool use_metadata, + int compression_level, bool send_path, char* const* skip_suffixes, + int skip_count) { if (!file || !file->path || !file->data) return false; if (compression_level > 0) - return file_send_single_calls(file, file_descriptor, use_metadata, compression_level, - send_path); + return file_send_single_calls_with_skip(file, file_descriptor, use_metadata, compression_level, + send_path, skip_suffixes, skip_count); if (send_path && !send_str(file_descriptor, file->path)) return false; diff --git a/src/shared/file_send.h b/src/shared/file_send.h index 86c17ad..093195c 100644 --- a/src/shared/file_send.h +++ b/src/shared/file_send.h @@ -8,7 +8,13 @@ bool file_send_single_calls(File* file, int file_descriptor, bool use_metadata, int compression_level, bool send_path); +bool file_send_single_calls_with_skip(File* file, int file_descriptor, bool use_metadata, + int compression_level, bool send_path, + char* const* skip_suffixes, int skip_count); bool file_send_sendfile(File* file, int file_descriptor, bool use_metadata, int compression_level, bool send_path); +bool file_send_sendfile_with_skip(File* file, int file_descriptor, bool use_metadata, + int compression_level, bool send_path, char* const* skip_suffixes, + int skip_count); #endif diff --git a/tests/integration/test_features.py b/tests/integration/test_features.py index 312add4..6ed9454 100644 --- a/tests/integration/test_features.py +++ b/tests/integration/test_features.py @@ -52,6 +52,35 @@ class TestArchiveMode: assert not mismatches, f"Mismatch: {mismatches}" +class TestSkipCompress: + def test_skip_compress_case_insensitive(self, shared_server): + clean_dir(DEST_DIR) + with open(os.path.join(SOURCE_DIR, "skip-case.TXT"), "wb") as f: + f.write((b"skip compression case test\n" * 100)) + result, _ = run_client( + SOURCE_DIR, DEST_DIR, + flags=["-c", "--skip-compress=.txt"], + port=shared_server.port, + ) + assert result.returncode == 0, f"Skip-compress sync failed: {(result.stderr or result.stdout)[:200]}" + received = get_dest_received_dir(DEST_DIR, SOURCE_DIR) + with open(os.path.join(received, "skip-case.TXT"), "rb") as f: + assert f.read() == b"skip compression case test\n" * 100 + + def test_skip_compress_empty_list(self, shared_server): + clean_dir(DEST_DIR) + result, _ = run_client( + SOURCE_DIR, DEST_DIR, + flags=["-c", "--skip-compress="], + port=shared_server.port, + ) + assert result.returncode == 0, f"Empty skip-compress sync failed: {(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 TestExclude: def test_exclude_single(self, shared_server): clean_dir(DEST_DIR) diff --git a/tests/test_client_cli.c b/tests/test_client_cli.c index 656fd40..31261b7 100644 --- a/tests/test_client_cli.c +++ b/tests/test_client_cli.c @@ -247,6 +247,32 @@ static void test_parse_args_valid_compression_level() { config_delete(cfg); } +static void test_parse_args_skip_compress() { + Config* cfg = config_create(); + char* argv[] = {"fastsync", "--skip-compress=.ZIP, .GZ", "/src", "/dst"}; + int positional_args[2]; + int positional_count = 0; + + EXPECT_EQ_INT(parse_args(cfg, 4, argv, positional_args, &positional_count), 0); + EXPECT_TRUE(cfg->skip_compress_set); + EXPECT_EQ_INT(cfg->skip_compress_count, 2); + EXPECT_EQ_STR(cfg->skip_compress_suffixes[0], ".ZIP"); + EXPECT_EQ_STR(cfg->skip_compress_suffixes[1], ".GZ"); + config_delete(cfg); +} + +static void test_parse_args_empty_skip_compress() { + Config* cfg = config_create(); + char* argv[] = {"fastsync", "--skip-compress=", "/src", "/dst"}; + int positional_args[2]; + int positional_count = 0; + + EXPECT_EQ_INT(parse_args(cfg, 4, argv, positional_args, &positional_count), 0); + EXPECT_TRUE(cfg->skip_compress_set); + EXPECT_EQ_INT(cfg->skip_compress_count, 0); + config_delete(cfg); +} + /* Test parse_args unknown option returns error */ static void test_parse_args_unknown_option() { Config* cfg = config_create(); @@ -357,6 +383,8 @@ void test_client_cli() { test_parse_args_invalid_server_port(); test_parse_args_invalid_compression_level(); test_parse_args_valid_compression_level(); + test_parse_args_skip_compress(); + test_parse_args_empty_skip_compress(); test_parse_args_unknown_option(); test_parse_args_rejects_unimplemented_options(); test_parse_args_archive(); diff --git a/tests/test_compression.c b/tests/test_compression.c index a234e00..8877f13 100644 --- a/tests/test_compression.c +++ b/tests/test_compression.c @@ -55,6 +55,14 @@ static void test_data_compress_decompress_large() { data_destroy(decompressed); } +static void test_skip_compress_suffix_matching() { + char* suffixes[] = {".ZIP", ".GZ"}; + EXPECT_TRUE(compression_should_skip_with_suffixes("archive.zip", suffixes, 2)); + EXPECT_TRUE(compression_should_skip_with_suffixes("backup.TAR.GZ", suffixes, 2)); + EXPECT_FALSE(compression_should_skip_with_suffixes("notes.txt", suffixes, 2)); + EXPECT_FALSE(compression_should_skip_with_suffixes("archive.zip", suffixes, 0)); +} + static void test_chunk_compress_decompress_roundtrip() { char* path1 = "temp_comp_test_1.txt"; char* content1 = "chunk compression test file 1"; @@ -115,5 +123,6 @@ static void test_chunk_compress_decompress_roundtrip() { void test_compression() { test_data_compress_decompress_roundtrip(); test_data_compress_decompress_large(); + test_skip_compress_suffix_matching(); test_chunk_compress_decompress_roundtrip(); } From 98cbf3495dfbf545a191d569cc4c45e1baba3b0c Mon Sep 17 00:00:00 2001 From: TapTap Date: Thu, 3 Sep 2026 22:19:00 +0200 Subject: [PATCH 2/3] fix: complete skip-compress protocol handling --- README.md | 2 +- RSYNC_COMPAT.md | 3 +-- src/client/client_validation.c | 5 +++++ src/shared/config.c | 1 + src/shared/config.h | 2 +- src/shared/file_receive.c | 5 ++++- tests/integration/test_features.py | 29 +++++++++++++++++++++++++++++ tests/test_client_cli.c | 3 +++ tests/test_config.c | 11 +++++++++-- 9 files changed, 54 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index fdbd9f3..ec1d079 100644 --- a/README.md +++ b/README.md @@ -465,7 +465,7 @@ defaults to the current directory. | ## Protocol and Security -FastSync protocol version `2.2.0` is shared by the client and server. The +FastSync protocol version `2.2.1` is shared by the client and server. The current protocol is sender-driven and includes configuration negotiation, incremental checks, checksums, manifests, keep-alives, abort handling, and FastSync-native delta messages. Client and server versions must currently diff --git a/RSYNC_COMPAT.md b/RSYNC_COMPAT.md index 9aa4a21..3976f21 100644 --- a/RSYNC_COMPAT.md +++ b/RSYNC_COMPAT.md @@ -174,7 +174,7 @@ This document maps rsync's full feature set to FastSync's current implementation | `--compress-choice=STR` | Choose compression algorithm | ❌ Not Implemented | Removed because it had no effect; FastSync always uses zstd | | `--compress-level=NUM` | Set compression level | ✅ Implemented | 1-22, default 5 | | `--compress-threads=NUM` | Set compression threads | ❌ Not Implemented | | -| `--skip-compress=LIST` | Skip compress for suffixes | ✅ Implemented | Comma-separated, case-insensitive suffix list; empty list skips none | +| `--skip-compress=LIST` | Skip compress for suffixes | ✅ Implemented | Comma-separated, case-insensitive suffix list; empty list skips none; incompatible with FastSync chunk serialization (`-s`) | ## 13. Connectivity @@ -253,7 +253,6 @@ Ranked by user demand, implementation complexity, and interoperability impact: | 7 | `--delay-updates` | Medium | High — atomic updates | | 8 | `--chmod` | Low | Medium — permission flexibility | | 9 | `--executability` / `-E` | Low | Low — simple flag | -| 10 | `--skip-compress` | Low | Medium — performance tuning | --- diff --git a/src/client/client_validation.c b/src/client/client_validation.c index 106580f..665c939 100644 --- a/src/client/client_validation.c +++ b/src/client/client_validation.c @@ -23,6 +23,11 @@ bool validate_config(const Config* config) { log_message(LOG_LEVEL_ERROR, "--incremental is not supported with -s (chunk serialization)"); return false; } + if (config->skip_compress_set && config->use_chunk_serialization) { + log_message(LOG_LEVEL_ERROR, + "--skip-compress cannot be combined with -s (chunk serialization)"); + return false; + } if (config->use_delta && !config->use_incremental) { log_message(LOG_LEVEL_ERROR, "--delta requires --incremental"); return false; diff --git a/src/shared/config.c b/src/shared/config.c index e078ca0..1422ca8 100644 --- a/src/shared/config.c +++ b/src/shared/config.c @@ -132,6 +132,7 @@ static bool validate_received_config(const Config* config) { valid_wire_bool(config->delete_after) && valid_wire_bool(config->relative) && valid_wire_bool(config->prune_empty_dirs) && valid_wire_bool(config->partial) && valid_wire_bool(config->delete_before) && valid_wire_bool(config->checksum) && + !(config->skip_compress_set && config->use_chunk_serialization) && (!config->use_compression || (config->compression_level >= 1 && config->compression_level <= 22)) && config->chunk_size > 0 && config->chunk_size <= MAX_CHUNK_SIZE && diff --git a/src/shared/config.h b/src/shared/config.h index 6ce70eb..e42223e 100644 --- a/src/shared/config.h +++ b/src/shared/config.h @@ -132,7 +132,7 @@ typedef struct Config { bool skip_compress_set; } Config; -#define PROTOCOL_VERSION "2.2.0" +#define PROTOCOL_VERSION "2.2.1" #define DEFAULT_CHUNK_SIZE (10 * 1024 * 1024) Config* config_create(void); diff --git a/src/shared/file_receive.c b/src/shared/file_receive.c index 980545a..fff2dac 100644 --- a/src/shared/file_receive.c +++ b/src/shared/file_receive.c @@ -459,7 +459,10 @@ File* receive_incremental_check(int fd, const Config* config, bool* skipped) { return NULL; } - if (config->use_compression) { + if (config->use_compression && + !compression_should_skip_with_suffixes(file->path, config->skip_compress_suffixes, + config->skip_compress_set ? config->skip_compress_count + : -1)) { Data* uncompressed = data_decompress_limited(file_data, MAX_RECEIVE_FILE_SIZE); data_destroy(file_data); if (uncompressed == NULL) { diff --git a/tests/integration/test_features.py b/tests/integration/test_features.py index 6ed9454..43a7765 100644 --- a/tests/integration/test_features.py +++ b/tests/integration/test_features.py @@ -80,6 +80,35 @@ class TestSkipCompress: assert not missing, f"Missing: {missing}" assert not mismatches, f"Mismatch: {mismatches}" + def test_skip_compress_incremental_full_fallback(self, shared_server): + clean_dir(DEST_DIR) + path = os.path.join(SOURCE_DIR, "incremental-skip.TXT") + with open(path, "wb") as f: + f.write(b"original skipped content\n") + flags = ["-c", "-M", "--skip-compress=.txt"] + result, _ = run_client(SOURCE_DIR, DEST_DIR, flags=flags, port=shared_server.port) + assert result.returncode == 0, f"Initial sync failed: {(result.stderr or result.stdout)[:200]}" + with open(path, "wb") as f: + f.write(b"updated skipped content\n") + result, _ = run_client( + SOURCE_DIR, DEST_DIR, + flags=flags + ["--incremental"], + port=shared_server.port, + ) + assert result.returncode == 0, f"Incremental sync failed: {(result.stderr or result.stdout)[:200]}" + received = get_dest_received_dir(DEST_DIR, SOURCE_DIR) + with open(os.path.join(received, "incremental-skip.TXT"), "rb") as f: + assert f.read() == b"updated skipped content\n" + + def test_skip_compress_rejects_chunk_serialization(self, shared_server): + result, _ = run_client( + SOURCE_DIR, DEST_DIR, + flags=["-c", "-s", "--skip-compress=.txt"], + port=shared_server.port, + ) + assert result.returncode != 0 + assert "cannot be combined" in (result.stderr or result.stdout) + class TestExclude: def test_exclude_single(self, shared_server): diff --git a/tests/test_client_cli.c b/tests/test_client_cli.c index 31261b7..88f5519 100644 --- a/tests/test_client_cli.c +++ b/tests/test_client_cli.c @@ -36,6 +36,9 @@ static void test_validate_config_incompatible_options() { cfg->use_incremental = true; cfg->use_chunk_serialization = true; EXPECT_FALSE(validate_config(cfg)); + cfg->use_incremental = false; + cfg->skip_compress_set = true; + EXPECT_FALSE(validate_config(cfg)); config_delete(cfg); } diff --git a/tests/test_config.c b/tests/test_config.c index 4f124db..1f0f953 100644 --- a/tests/test_config.c +++ b/tests/test_config.c @@ -121,11 +121,15 @@ static void test_config_send_receive() { send_cfg->receive_root_directory = str_dup("/send/dst"); send_cfg->save_to_disk = true; send_cfg->use_multithreading = true; - send_cfg->use_chunk_serialization = true; + send_cfg->use_chunk_serialization = false; send_cfg->use_compression = true; send_cfg->use_metadata = true; send_cfg->compression_level = 5; send_cfg->chunk_size = 1024; + send_cfg->skip_compress_set = true; + send_cfg->skip_compress_count = 1; + send_cfg->skip_compress_suffixes = calloc(1, sizeof(char*)); + send_cfg->skip_compress_suffixes[0] = str_dup(".zip"); /* Use socketpair for bidirectional communication */ int p[2]; @@ -154,12 +158,15 @@ static void test_config_send_receive() { ok = false; if (!recv_cfg->use_multithreading) ok = false; - if (!recv_cfg->use_chunk_serialization) + if (recv_cfg->use_chunk_serialization) ok = false; if (recv_cfg->compression_level != 5) ok = false; if (recv_cfg->chunk_size != 1024) ok = false; + if (!recv_cfg->skip_compress_set || recv_cfg->skip_compress_count != 1 || + strcmp(recv_cfg->skip_compress_suffixes[0], ".zip") != 0) + ok = false; } config_delete(recv_cfg); close(p[0]); From 552c19d3fe34762285768017253b77702c08a4a1 Mon Sep 17 00:00:00 2001 From: TapTap Date: Fri, 4 Sep 2026 01:49:38 +0200 Subject: [PATCH 3/3] fix: guard skip-compress cleanup --- README.md | 1 + src/shared/config.c | 8 +++++--- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index ec1d079..45b9514 100644 --- a/README.md +++ b/README.md @@ -339,6 +339,7 @@ features without changing the meaning of ordinary compatibility options. | `-m` | Enable the multithreaded scanner/loader/sender pipeline. | | `-c [level]`, `-z [level]` | Enable streaming zstd compression, levels 1-22. | | `--compress-level ` | Set the zstd compression level. | +| `--skip-compress ` | Skip compression for comma-separated suffixes; incompatible with `-s`. | | `--chunk-size ` | Set the transfer chunk size. | | `-s` | Enable FastSync chunk serialization. | | `-f`, `--sendfile` | Use TCP `sendfile()` zero-copy transfer. Incompatible with compression and chunk serialization. | diff --git a/src/shared/config.c b/src/shared/config.c index 1422ca8..5ac4bba 100644 --- a/src/shared/config.c +++ b/src/shared/config.c @@ -213,9 +213,11 @@ void config_delete(Config* config) { free(config->bind_address); free(config->daemon_config); free(config->compress_choice); - for (int i = 0; i < config->skip_compress_count; i++) - free(config->skip_compress_suffixes[i]); - free(config->skip_compress_suffixes); + if (config->skip_compress_suffixes) { + for (int i = 0; i < config->skip_compress_count; i++) + free(config->skip_compress_suffixes[i]); + free(config->skip_compress_suffixes); + } if (config->filters) { array_list_delete(config->filters); }