From 0494116999d25ad7f22b585fb904880792f7ea4f Mon Sep 17 00:00:00 2001 From: TapTap Date: Thu, 3 Sep 2026 17:28:46 +0200 Subject: [PATCH 1/3] 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(); -- 2.54.0 From 5607ec6a882352d9e8ddb85e2c062ab27f106238 Mon Sep 17 00:00:00 2001 From: TapTap Date: Thu, 3 Sep 2026 22:07:14 +0200 Subject: [PATCH 2/3] fix: address ignore-existing review findings --- src/shared/config.h | 2 +- src/shared/file.c | 43 ++++++++++++++++++++++++++---- src/shared/file.h | 3 +++ src/shared/file_receive.c | 7 +++-- tests/integration/test_features.py | 22 ++++++++------- tests/test_config.c | 4 +-- 6 files changed, 62 insertions(+), 19 deletions(-) diff --git a/src/shared/config.h b/src/shared/config.h index 7061b57..eccede1 100644 --- a/src/shared/config.h +++ b/src/shared/config.h @@ -129,7 +129,7 @@ typedef struct Config { char* compress_choice; } Config; -#define PROTOCOL_VERSION "2.2.0" +#define PROTOCOL_VERSION "2.3.0" #define DEFAULT_CHUNK_SIZE (10 * 1024 * 1024) Config* config_create(void); diff --git a/src/shared/file.c b/src/shared/file.c index 9514e87..5884491 100644 --- a/src/shared/file.c +++ b/src/shared/file.c @@ -172,8 +172,17 @@ bool file_set_authorized_root(int fd, const char* canonical_path) { } bool file_path_exists_secure(const char* path) { + if (!path) + return false; + char* leaf = NULL; + int parent_fd = file_open_secure_parent(path, &leaf, false); + if (parent_fd < 0) + return false; struct stat st; - return file_stat_secure(path, &st); + bool exists = fstatat(parent_fd, leaf, &st, AT_SYMLINK_NOFOLLOW) == 0; + close(parent_fd); + free(leaf); + return exists; } bool file_stat_secure(const char* path, struct stat* st) { @@ -302,8 +311,9 @@ bool file_rename_secure(const char* old_path, const char* new_path) { return ok; } -bool file_to_disk_secure(const char* path, const void* data, unsigned long long data_size, - bool inplace, bool sparse, const FileMetadata* metadata) { +static bool file_to_disk_secure_impl(const char* path, const void* data, + unsigned long long data_size, bool inplace, bool sparse, + const FileMetadata* metadata, bool no_replace) { char* leaf = NULL; int dirfd = file_open_secure_parent(path, &leaf, true); if (dirfd < 0) @@ -334,8 +344,20 @@ bool file_to_disk_secure(const char* path, const void* data, unsigned long long if (close(fd) != 0) ok = false; fd = -1; - if (ok && renameat(dirfd, tmp, dirfd, leaf) != 0) - ok = false; + if (ok) { + if (no_replace) { + /* The probe and commit cannot be one operation. A concurrent + creator may win; EEXIST is then the requested skip. */ + if (linkat(dirfd, tmp, dirfd, leaf, 0) == 0 || errno == EEXIST) { + if (unlinkat(dirfd, tmp, 0) != 0 && errno != ENOENT) + ok = false; + } else { + ok = false; + } + } else if (renameat(dirfd, tmp, dirfd, leaf) != 0) { + ok = false; + } + } if (!ok) unlinkat(dirfd, tmp, 0); } @@ -347,6 +369,17 @@ bool file_to_disk_secure(const char* path, const void* data, unsigned long long return ok; } +bool file_to_disk_secure(const char* path, const void* data, unsigned long long data_size, + bool inplace, bool sparse, const FileMetadata* metadata) { + return file_to_disk_secure_impl(path, data, data_size, inplace, sparse, metadata, false); +} + +bool file_to_disk_secure_no_replace(const char* path, const void* data, + unsigned long long data_size, bool sparse, + const FileMetadata* metadata) { + return file_to_disk_secure_impl(path, data, data_size, false, sparse, metadata, true); +} + bool file_write_to_disk(const char* path, const void* data, unsigned long long data_size, bool inplace, bool sparse) { if (!path || (!data && data_size != 0) || has_path_traversal(path)) diff --git a/src/shared/file.h b/src/shared/file.h index 23dac26..d6ff3d4 100644 --- a/src/shared/file.h +++ b/src/shared/file.h @@ -32,5 +32,8 @@ bool file_ensure_directory_secure(const char* path); bool file_rename_secure(const char* old_path, const char* new_path); bool file_to_disk_secure(const char* path, const void* data, unsigned long long data_size, bool inplace, bool sparse, const FileMetadata* metadata); +bool file_to_disk_secure_no_replace(const char* path, const void* data, + unsigned long long data_size, bool sparse, + const FileMetadata* metadata); #endif diff --git a/src/shared/file_receive.c b/src/shared/file_receive.c index 6ef8a8d..a371ff3 100644 --- a/src/shared/file_receive.c +++ b/src/shared/file_receive.c @@ -118,8 +118,11 @@ bool file_save_to_disk(const char* root_directory, const File* file, const Confi } } - bool ok = file_to_disk_secure(disk_path, file->data->data, file->data->size, inplace, sparse, - file->metadata); + bool ok = config && config->ignore_existing + ? file_to_disk_secure_no_replace(disk_path, file->data->data, file->data->size, + sparse, file->metadata) + : file_to_disk_secure(disk_path, file->data->data, file->data->size, inplace, + sparse, file->metadata); free(parent_copy); free(backup_path); free(confined_backup); diff --git a/tests/integration/test_features.py b/tests/integration/test_features.py index ec974d3..1d96678 100644 --- a/tests/integration/test_features.py +++ b/tests/integration/test_features.py @@ -225,16 +225,20 @@ class TestIgnoreExisting: 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") + try: + 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" + 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" + finally: + if os.path.lexists(new_source): + os.unlink(new_source) class TestDelete: diff --git a/tests/test_config.c b/tests/test_config.c index ea62295..2f68d66 100644 --- a/tests/test_config.c +++ b/tests/test_config.c @@ -188,11 +188,11 @@ static void test_config_send_receive() { } static void test_config_send_receive_version_mismatch() { - /* Create a config with a different protocol version */ + /* A peer using the previous wire format must be rejected. */ Config* cfg = config_create(); EXPECT_NOT_NULL(cfg); free(cfg->version); - cfg->version = str_dup("0.0"); + cfg->version = str_dup("2.2.0"); cfg->send_directory = str_dup("/src"); cfg->receive_root_directory = str_dup("/dst"); -- 2.54.0 From d86029e57035ed1cc452aa7dbf1eb838dc437cbe Mon Sep 17 00:00:00 2001 From: TapTap Date: Thu, 3 Sep 2026 23:18:07 +0200 Subject: [PATCH 3/3] fix: complete ignore-existing destination protection --- README.md | 2 +- src/shared/file_receive.c | 7 +++-- tests/test_file.c | 62 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 67 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index fdbd9f3..6962093 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.3.0` 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/src/shared/file_receive.c b/src/shared/file_receive.c index a371ff3..c18272d 100644 --- a/src/shared/file_receive.c +++ b/src/shared/file_receive.c @@ -22,7 +22,9 @@ #define MAX_FILE_DATA_SIZE MAX_RECEIVE_FILE_SIZE bool file_save_to_disk(const char* root_directory, const File* file, const Config* config) { - bool backup_enabled = config && config->backup; + /* Backups are incompatible with ignore-existing: moving the entry first + would make a concurrent no-replace commit overwrite its old name. */ + bool backup_enabled = config && config->backup && !config->ignore_existing; bool inplace = config && config->inplace; bool sparse = config && config->preserve_sparse; const char* backup_suffix = (config && config->suffix) ? config->suffix : "~"; @@ -64,9 +66,8 @@ bool file_save_to_disk(const char* root_directory, const File* file, const Confi /* --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); + bool exists = final_path && file_path_exists_secure(final_path); free(final_path); if (exists) { free(confined_backup); diff --git a/tests/test_file.c b/tests/test_file.c index cdb5385..aae542b 100644 --- a/tests/test_file.c +++ b/tests/test_file.c @@ -120,6 +120,67 @@ static void test_file_save_to_disk_ignore_existing() { rmdir("test_ignore_existing_tmp"); } +static void test_file_save_to_disk_ignore_existing_entry_types() { + const char* root = "test_ignore_existing_entries_tmp"; + const char* directory = "test_ignore_existing_entries_tmp/directory"; + const char* link = "test_ignore_existing_entries_tmp/link"; + const char* target = "test_ignore_existing_entries_tmp/target"; + const char* backup = "test_ignore_existing_entries_tmp/backup.txt~"; + const char* backup_file = "test_ignore_existing_entries_tmp/backup.txt"; + Config* config = config_create(); + File* file = file_create("unused"); + + unlink(link); + unlink(target); + unlink(backup); + unlink(backup_file); + rmdir(directory); + rmdir(root); + EXPECT_NOT_NULL(config); + EXPECT_NOT_NULL(file); + // cppcheck-suppress knownConditionTrueFalse + if (!config || !file) + return; + config->ignore_existing = true; + config->backup = true; + file->data->data = malloc(3); + EXPECT_NOT_NULL(file->data->data); + // cppcheck-suppress knownConditionTrueFalse + if (!file->data->data) { + file_destroy(file); + config_delete(config); + return; + } + memcpy(file->data->data, "new", 3); + file->data->size = 3; + + EXPECT_EQ_INT(mkdir(root, 0755), 0); + EXPECT_EQ_INT(mkdir(directory, 0755), 0); + EXPECT_TRUE(file_write_to_disk(target, "old", 3, false, false)); + EXPECT_EQ_INT(symlink("target", link), 0); + free(file->path); + file->path = str_dup("directory"); + EXPECT_TRUE(file_save_to_disk(root, file, config)); + free(file->path); + file->path = str_dup("link"); + EXPECT_TRUE(file_save_to_disk(root, file, config)); + + free(file->path); + file->path = str_dup("backup.txt"); + EXPECT_TRUE(file_write_to_disk(backup_file, "old", 3, false, false)); + EXPECT_TRUE(file_save_to_disk(root, file, config)); + EXPECT_TRUE(file_path_exists_secure(backup_file)); + EXPECT_FALSE(file_path_exists_secure(backup)); + + file_destroy(file); + config_delete(config); + unlink(link); + unlink(target); + unlink(backup_file); + rmdir(directory); + rmdir(root); +} + 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), @@ -491,6 +552,7 @@ void test_file() { test_file_load_data_missing_file(); test_file_save_to_disk(); test_file_save_to_disk_ignore_existing(); + test_file_save_to_disk_ignore_existing_entry_types(); test_file_write_to_disk_basic(); test_file_write_to_disk_creates_dirs(); test_file_write_to_disk_does_not_follow_symlink(); -- 2.54.0