From 0494116999d25ad7f22b585fb904880792f7ea4f Mon Sep 17 00:00:00 2001 From: TapTap Date: Thu, 3 Sep 2026 17:28:46 +0200 Subject: [PATCH] Add rsync-compatible ignore-existing option --- src/client/client_cli.c | 1 + src/client/usage.c | 1 + src/shared/config.c | 27 +++++++++++++----------- src/shared/config.h | 1 + src/shared/file_receive.c | 15 ++++++++++++++ tests/integration/test_features.py | 23 +++++++++++++++++++++ tests/test_client_cli.c | 14 +++++++++++++ tests/test_config.c | 3 +++ tests/test_file.c | 33 ++++++++++++++++++++++++++++++ 9 files changed, 106 insertions(+), 12 deletions(-) diff --git a/src/client/client_cli.c b/src/client/client_cli.c index 191cc07..1d6b126 100644 --- a/src/client/client_cli.c +++ b/src/client/client_cli.c @@ -153,6 +153,7 @@ static const OptionEntry OPTION_TABLE[] = { {"--sparse", "-S", OPT_FLAG, offsetof(Config, preserve_sparse)}, {"--inplace", NULL, OPT_FLAG, offsetof(Config, inplace)}, {"--checksum", NULL, OPT_FLAG, offsetof(Config, checksum)}, + {"--ignore-existing", NULL, OPT_FLAG, offsetof(Config, ignore_existing)}, {"--source-dir", NULL, OPT_STRING, offsetof(Config, send_directory)}, {"--dest-dir", NULL, OPT_STRING, offsetof(Config, receive_root_directory)}, diff --git a/src/client/usage.c b/src/client/usage.c index 2f800af..2e60c8c 100644 --- a/src/client/usage.c +++ b/src/client/usage.c @@ -21,6 +21,7 @@ void print_usage(void) { printf(" -p SSH port (default: 22)\n"); printf(" --progress Show transfer progress\n"); printf(" --delete Delete files on receiver not in source\n"); + printf(" --ignore-existing Skip files that already exist on receiver\n"); printf(" --exclude Exclude files matching pattern\n"); printf(" --include Only include files matching pattern\n"); printf(" --exclude-from Read exclude patterns from file\n"); diff --git a/src/shared/config.c b/src/shared/config.c index efc2708..227dd50 100644 --- a/src/shared/config.c +++ b/src/shared/config.c @@ -68,6 +68,7 @@ static void config_set_defaults(Config* config) { config->debug_level = 0; config->list_only = false; config->human_readable = false; + config->ignore_existing = false; config->update = false; config->inplace = false; config->append = false; @@ -123,12 +124,13 @@ static bool validate_received_config(const Config* config) { valid_wire_bool(config->copy_unsafe_links) && valid_wire_bool(config->preserve_hard_links) && valid_wire_bool(config->preserve_acls) && valid_wire_bool(config->preserve_xattrs) && valid_wire_bool(config->preserve_devices) && - valid_wire_bool(config->preserve_sparse) && valid_wire_bool(config->update) && - valid_wire_bool(config->inplace) && valid_wire_bool(config->append) && - valid_wire_bool(config->append_verify) && valid_wire_bool(config->delete_excluded) && - 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) && + valid_wire_bool(config->preserve_sparse) && valid_wire_bool(config->ignore_existing) && + valid_wire_bool(config->update) && valid_wire_bool(config->inplace) && + valid_wire_bool(config->append) && valid_wire_bool(config->append_verify) && + valid_wire_bool(config->delete_excluded) && 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->use_compression || (config->compression_level >= 1 && config->compression_level <= 22)) && config->chunk_size > 0 && config->chunk_size <= MAX_CHUNK_SIZE && @@ -243,10 +245,11 @@ static bool send_file_options(int fd, const Config* c) { } static bool send_selection_options(int fd, const Config* c) { - return send_int(fd, c->update) && send_int(fd, c->inplace) && send_int(fd, c->append) && - send_int(fd, c->append_verify) && send_int(fd, c->delete_excluded) && - send_int(fd, c->delete_after) && send_n_data(fd, &c->max_delete, sizeof(c->max_delete)) && - send_int(fd, c->relative) && send_int(fd, c->prune_empty_dirs); + return send_int(fd, c->ignore_existing) && send_int(fd, c->update) && send_int(fd, c->inplace) && + send_int(fd, c->append) && send_int(fd, c->append_verify) && + send_int(fd, c->delete_excluded) && send_int(fd, c->delete_after) && + send_n_data(fd, &c->max_delete, sizeof(c->max_delete)) && send_int(fd, c->relative) && + send_int(fd, c->prune_empty_dirs); } static bool send_resume_options(int fd, const Config* c) { @@ -304,8 +307,8 @@ static bool receive_file_options(int fd, Config* c) { } static bool receive_selection_options(int fd, Config* c) { - bool* flags[] = {&c->update, &c->inplace, &c->append, - &c->append_verify, &c->delete_excluded, &c->delete_after}; + bool* flags[] = {&c->ignore_existing, &c->update, &c->inplace, &c->append, + &c->append_verify, &c->delete_excluded, &c->delete_after}; for (size_t i = 0; i < sizeof(flags) / sizeof(flags[0]); i++) { if (!receive_wire_bool(fd, flags[i])) return false; diff --git a/src/shared/config.h b/src/shared/config.h index 4a218b0..7061b57 100644 --- a/src/shared/config.h +++ b/src/shared/config.h @@ -76,6 +76,7 @@ typedef struct Config { bool human_readable; // Issue #127: Transfer modes + bool ignore_existing; bool update; bool inplace; bool append; diff --git a/src/shared/file_receive.c b/src/shared/file_receive.c index a610d67..6ef8a8d 100644 --- a/src/shared/file_receive.c +++ b/src/shared/file_receive.c @@ -61,6 +61,21 @@ bool file_save_to_disk(const char* root_directory, const File* file, const Confi return false; } + /* --ignore-existing checks the final destination before partial files or + overwrite policies can modify it. */ + if (config && config->ignore_existing) { + struct stat destination_stat; + char* final_path = path_cat(root_directory, file->path); + bool exists = final_path && file_stat_secure(final_path, &destination_stat); + free(final_path); + if (exists) { + free(confined_backup); + free(confined_partial); + free(disk_path); + return true; + } + } + /* --update is receiver-side policy: never replace a newer destination. */ if (config && config->update) { struct stat destination_stat; diff --git a/tests/integration/test_features.py b/tests/integration/test_features.py index 312add4..ec974d3 100644 --- a/tests/integration/test_features.py +++ b/tests/integration/test_features.py @@ -214,6 +214,29 @@ class TestIncremental: assert f.read() == b"hello world\n" +class TestIgnoreExisting: + def test_ignore_existing_preserves_existing_and_transfers_new(self, shared_server): + clean_dir(DEST_DIR) + result, _ = run_client(SOURCE_DIR, DEST_DIR, port=shared_server.port) + assert result.returncode == 0 + + received = get_dest_received_dir(DEST_DIR, SOURCE_DIR) + existing_file = os.path.join(received, "small.txt") + with open(existing_file, "wb") as f: + f.write(b"destination content\n") + new_source = os.path.join(SOURCE_DIR, "new.txt") + with open(new_source, "wb") as f: + f.write(b"new file\n") + + result, _ = run_client(SOURCE_DIR, DEST_DIR, + flags=["--ignore-existing"], port=shared_server.port) + assert result.returncode == 0, f"Sync failed: {(result.stderr or result.stdout)[:200]}" + with open(existing_file, "rb") as f: + assert f.read() == b"destination content\n" + with open(os.path.join(received, "new.txt"), "rb") as f: + assert f.read() == b"new file\n" + + class TestDelete: def test_delete_removes_extra_files(self, shared_server): clean_dir(DEST_DIR) diff --git a/tests/test_client_cli.c b/tests/test_client_cli.c index 656fd40..5e89307 100644 --- a/tests/test_client_cli.c +++ b/tests/test_client_cli.c @@ -181,6 +181,19 @@ static void test_parse_args_valid_port() { config_delete(cfg); } +static void test_parse_args_ignore_existing() { + Config* cfg = config_create(); + char* argv[] = {"fastsync", "--ignore-existing", "/src", "/dst"}; + int positional_args[2]; + int positional_count = 0; + + int ret = parse_args(cfg, 4, argv, positional_args, &positional_count); + EXPECT_EQ_INT(ret, 0); + EXPECT_TRUE(cfg->ignore_existing); + + config_delete(cfg); +} + /* Test parse_args rejects port > 65535 */ static void test_parse_args_invalid_port() { Config* cfg = config_create(); @@ -352,6 +365,7 @@ void test_client_cli() { test_parse_args_help(); test_parse_args_version(); test_parse_args_valid_port(); + test_parse_args_ignore_existing(); test_parse_args_invalid_port(); test_parse_args_non_numeric_port(); test_parse_args_invalid_server_port(); diff --git a/tests/test_config.c b/tests/test_config.c index 4f124db..ea62295 100644 --- a/tests/test_config.c +++ b/tests/test_config.c @@ -126,6 +126,7 @@ static void test_config_send_receive() { send_cfg->use_metadata = true; send_cfg->compression_level = 5; send_cfg->chunk_size = 1024; + send_cfg->ignore_existing = true; /* Use socketpair for bidirectional communication */ int p[2]; @@ -160,6 +161,8 @@ static void test_config_send_receive() { ok = false; if (recv_cfg->chunk_size != 1024) ok = false; + if (!recv_cfg->ignore_existing) + ok = false; } config_delete(recv_cfg); close(p[0]); diff --git a/tests/test_file.c b/tests/test_file.c index 6b8469e..cdb5385 100644 --- a/tests/test_file.c +++ b/tests/test_file.c @@ -88,6 +88,38 @@ static void test_file_save_to_disk() { rmdir("test_save_tmp"); } +static void test_file_save_to_disk_ignore_existing() { + const char* path = "test_ignore_existing_tmp/existing.txt"; + EXPECT_TRUE(file_write_to_disk(path, "old", 3, false, false)); + + File* file = file_create("existing.txt"); + EXPECT_NOT_NULL(file); + file->data->data = malloc(3); + EXPECT_NOT_NULL(file->data->data); + memcpy(file->data->data, "new", 3); + file->data->size = 3; + + Config* config = config_create(); + EXPECT_NOT_NULL(config); + config->ignore_existing = true; + EXPECT_TRUE(file_save_to_disk("test_ignore_existing_tmp", file, config)); + + FILE* stream = fopen(path, "rb"); + char content[4] = {0}; + EXPECT_NOT_NULL(stream); + // cppcheck-suppress knownConditionTrueFalse + if (stream) { + EXPECT_EQ_INT((int)fread(content, 1, 3, stream), 3); + fclose(stream); + } + EXPECT_EQ_STR(content, "old"); + + file_destroy(file); + config_delete(config); + unlink(path); + rmdir("test_ignore_existing_tmp"); +} + static void test_file_write_to_disk_basic() { const char* content = "Basic file_write_to_disk test"; EXPECT_TRUE(file_write_to_disk("test_file_write_to_disk_basic.txt", content, strlen(content), @@ -458,6 +490,7 @@ void test_file() { test_file_load_data(); test_file_load_data_missing_file(); test_file_save_to_disk(); + test_file_save_to_disk_ignore_existing(); test_file_write_to_disk_basic(); test_file_write_to_disk_creates_dirs(); test_file_write_to_disk_does_not_follow_symlink();