diff --git a/README.md b/README.md index 5d5af9d..4ff3647 100644 --- a/README.md +++ b/README.md @@ -104,6 +104,7 @@ partial, alternate, and planned behavior. | `--max-size ` | Skip files larger than n bytes | | `--min-size ` | Skip files smaller than n bytes | | `--incremental` | Skip files unchanged since last transfer (size + mtime). Auto-enables `--preserve`. Incompatible with `-s`. | +| `--existing` | Skip files not already present at the destination; update existing files normally. | | `--bwlimit ` | Bandwidth limit in kilobytes per second | | `--chunk-size ` | Chunk size in bytes (default: 10485760) | | `--timeout ` | I/O timeout in seconds (default: 30) | diff --git a/RSYNC_COMPAT.md b/RSYNC_COMPAT.md index b78d8c4..97eceea 100644 --- a/RSYNC_COMPAT.md +++ b/RSYNC_COMPAT.md @@ -10,7 +10,7 @@ This document maps rsync's full feature set to FastSync's current implementation | 🔀 Alt Arg | 4 | Functionality exists but under different flag/semantics | | ⚠️ Partial | 5 | Flag parsed/stored but behavior incomplete | | 🔄 Compatibility No-op | 1 | Flag is accepted for CLI compatibility but has no effect | -| ❌ Not Implemented | 96 | Flag not recognized or no behavior | +| ❌ Not Implemented | 95 | Flag not recognized or no behavior | | **Total** | **141** | | --- @@ -61,7 +61,7 @@ This document maps rsync's full feature set to FastSync's current implementation | `-I`, `--ignore-times` | Don't skip files matching size+time | ❌ Not Implemented | | | `--size-only` | Skip based on size only | ✅ Implemented | With `--incremental`, ignores mtime | | `-@`, `--modify-window=NUM` | Mod-time comparison accuracy | ✅ Implemented | Whole-second tolerance with nanosecond-aware comparisons | -| `--existing` | Skip creating new files on receiver | ❌ Not Implemented | | +| `--existing` | Skip creating new files on receiver | ✅ Implemented | Existing destination files continue through normal update handling | | `--ignore-existing` | Skip updating existing files | ❌ Not Implemented | | | `--remove-source-files` | Sender removes regular files after confirmed transfer | ✅ Implemented | | @@ -248,7 +248,7 @@ Ranked by user demand, implementation complexity, and interoperability impact: | 1 | `--whole-file` / `-W` | Low | High — users expect opt-out of delta | | 2 | `--ignore-times` / `-I` | Low | Medium — useful for forcing re-transfer | | 3 | `--size-only` | Low | Medium — common migration scenario | -| 4 | `--existing` / `--ignore-existing` | Low | Medium — common sync patterns | +| 4 | `--ignore-existing` | Low | Medium — common sync patterns | | 5 | `--delete-during` | Medium | High — performance improvement | | 6 | `--delay-updates` | Medium | High — atomic updates | | 7 | `--chmod` | Low | Medium — permission flexibility | diff --git a/src/client/client_cli.c b/src/client/client_cli.c index 2b685ec..cf5890f 100644 --- a/src/client/client_cli.c +++ b/src/client/client_cli.c @@ -229,6 +229,7 @@ static const OptionEntry OPTION_TABLE[] = { {"--fsync", NULL, OPT_FLAG, offsetof(Config, use_fsync)}, {"--checksum", NULL, OPT_FLAG, offsetof(Config, checksum)}, {"--8-bit-output", "-8", OPT_FLAG, offsetof(Config, eight_bit_output)}, + {"--existing", NULL, OPT_FLAG, offsetof(Config, 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 f8574f6..c6fe693 100644 --- a/src/client/usage.c +++ b/src/client/usage.c @@ -34,6 +34,7 @@ void print_usage(void) { printf(" -I, --ignore-times Transfer files even when size and mtime match\n"); printf(" -@, --modify-window Modification time tolerance\n"); printf(" -u, --update Skip files newer than the source on receiver\n"); + printf(" --existing Skip files not already present at destination\n"); printf(" --delta Delta transfer for changed files (requires --incremental)\n"); printf(" -W, --whole-file Transfer changed files without delta processing\n"); printf(" --delta-block Delta block size in bytes (default: %d)\n", diff --git a/src/shared/config.c b/src/shared/config.c index 8dd089d..ee6150e 100644 --- a/src/shared/config.c +++ b/src/shared/config.c @@ -74,6 +74,7 @@ static void config_set_defaults(Config* config) { config->list_only = false; config->human_readable = false; config->eight_bit_output = false; + config->existing = false; config->update = false; config->inplace = false; config->use_fsync = false; @@ -130,9 +131,10 @@ static bool validate_received_config(const Config* config) { valid_wire_bool(config->safe_links) && 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->use_fsync) && valid_wire_bool(config->append_verify) && + valid_wire_bool(config->preserve_sparse) && valid_wire_bool(config->existing) && + valid_wire_bool(config->update) && valid_wire_bool(config->inplace) && + valid_wire_bool(config->append) && valid_wire_bool(config->use_fsync) && + 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) && @@ -255,8 +257,9 @@ 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->use_fsync) && send_int(fd, c->append_verify) && + return send_int(fd, c->existing) && send_int(fd, c->update) && send_int(fd, c->inplace) && + send_int(fd, c->append) && send_int(fd, c->use_fsync) && + 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); @@ -325,7 +328,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->use_fsync, + bool* flags[] = {&c->existing, &c->update, &c->inplace, &c->append, + &c->use_fsync, &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])) diff --git a/src/shared/config.h b/src/shared/config.h index 2147061..5cb850d 100644 --- a/src/shared/config.h +++ b/src/shared/config.h @@ -82,6 +82,7 @@ typedef struct Config { bool eight_bit_output; // Issue #127: Transfer modes + bool existing; bool update; bool inplace; bool use_fsync; diff --git a/src/shared/file_receive.c b/src/shared/file_receive.c index 42eb14c..9609885 100644 --- a/src/shared/file_receive.c +++ b/src/shared/file_receive.c @@ -29,6 +29,7 @@ bool file_save_to_disk(const char* root_directory, const File* file, const Confi const char* backup_dir = (config && config->backup_dir) ? config->backup_dir : NULL; const char* partial_dir = (config && config->partial_dir) ? config->partial_dir : NULL; char *confined_backup = NULL, *confined_partial = NULL, *disk_path = NULL; + char* destination_path = NULL; char *backup_path = NULL, *parent_copy = NULL; if (!file || !file->path || !file->data || (file->data->size != 0 && !file->data->data) || @@ -54,13 +55,27 @@ bool file_save_to_disk(const char* root_directory, const File* file, const Confi const char* actual_root = (partial_dir && config && config->partial) ? confined_partial : root_directory; + destination_path = path_cat(root_directory, file->path); disk_path = path_cat(actual_root, file->path); - if (disk_path == NULL) { + if (destination_path == NULL || disk_path == NULL) { free(confined_backup); free(confined_partial); + free(destination_path); + free(disk_path); return false; } + /* --existing checks the final destination, not a temporary partial path. */ + if (config && config->existing && !file_path_exists_secure(destination_path)) { + free(confined_backup); + free(confined_partial); + free(destination_path); + free(disk_path); + return true; + } + free(destination_path); + destination_path = NULL; + /* --update is receiver-side policy: never replace a newer destination. The secure stat does not require read permission on the destination. */ if (config && config->update && file_destination_is_newer_secure(disk_path, file->metadata)) { @@ -110,6 +125,7 @@ bool file_save_to_disk(const char* root_directory, const File* file, const Confi free(backup_path); free(confined_backup); free(confined_partial); + free(destination_path); free(disk_path); return ok; @@ -118,6 +134,7 @@ fail: free(backup_path); free(confined_backup); free(confined_partial); + free(destination_path); free(disk_path); return false; } diff --git a/tests/integration/test_features.py b/tests/integration/test_features.py index bb9d7d2..8e80ece 100644 --- a/tests/integration/test_features.py +++ b/tests/integration/test_features.py @@ -412,66 +412,6 @@ class TestIncremental: mismatches, missing = verify_transfer(SOURCE_DIR, received) assert not missing, f"Missing: {missing}" assert not mismatches, f"Mismatch: {mismatches}" - clean_dir(DEST_DIR) - result, _ = run_client(SOURCE_DIR, DEST_DIR, flags=["-M"], port=shared_server.port) - assert result.returncode == 0 - - received = get_dest_received_dir(DEST_DIR, SOURCE_DIR) - source_file = os.path.join(SOURCE_DIR, "small.txt") - received_file = os.path.join(received, "small.txt") - source_stat = os.stat(source_file) - with open(received_file, "wb") as f: - f.write(b"stale data!\n") - os.utime(received_file, (source_stat.st_atime, source_stat.st_mtime)) - - result, _ = run_client(SOURCE_DIR, DEST_DIR, - flags=["-M", "--incremental", "--ignore-times"], - port=shared_server.port) - assert result.returncode == 0, f"Ignore-times sync failed: {result.stderr[:200]}" - with open(received_file, "rb") as f: - assert f.read() == b"hello world\n" - - def test_modify_window_allows_subsecond_mtime_difference(self, shared_server): - clean_dir(DEST_DIR) - result, _ = run_client(SOURCE_DIR, DEST_DIR, flags=["-M"], port=shared_server.port) - assert result.returncode == 0 - - received = get_dest_received_dir(DEST_DIR, SOURCE_DIR) - source_file = os.path.join(SOURCE_DIR, "small.txt") - received_file = os.path.join(received, "small.txt") - source_stat = os.stat(source_file) - with open(received_file, "wb") as f: - f.write(b"modified!!!\n") - os.utime(received_file, ns=(source_stat.st_atime_ns, - source_stat.st_mtime_ns - 1500000000)) - - result, _ = run_client(SOURCE_DIR, DEST_DIR, - flags=["-M", "--incremental", "--modify-window=2"], - port=shared_server.port) - assert result.returncode == 0, f"Modify-window sync failed: {result.stderr[:200]}" - with open(received_file, "rb") as f: - assert f.read() == b"modified!!!\n" - - def test_whole_file_disables_delta_and_keeps_compression(self, shared_server): - clean_dir(DEST_DIR) - result, _ = run_client(SOURCE_DIR, DEST_DIR, flags=["-M"], port=shared_server.port) - assert result.returncode == 0 - - source_file = os.path.join(SOURCE_DIR, "medium.txt") - with open(source_file, "wb") as f: - f.write(b"whole-file replacement\n" * 5000) - - result, _ = run_client( - SOURCE_DIR, - DEST_DIR, - flags=["-M", "--incremental", "--delta", "-W", "-c"], - port=shared_server.port, - ) - assert result.returncode == 0, f"Whole-file 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 TestUpdate: @@ -529,6 +469,34 @@ class TestUpdate: assert f.read() == b"hello world\n" +class TestExisting: + def test_existing_updates_existing_and_skips_new(self, shared_server): + clean_dir(DEST_DIR) + result, _ = run_client(SOURCE_DIR, DEST_DIR, flags=["-M"], port=shared_server.port) + assert result.returncode == 0, f"Initial sync failed: {(result.stderr or result.stdout)[:200]}" + + received = get_dest_received_dir(DEST_DIR, SOURCE_DIR) + source_file = os.path.join(SOURCE_DIR, "small.txt") + new_source_file = os.path.join(SOURCE_DIR, "new-existing-test.txt") + with open(source_file, "wb") as f: + f.write(b"updated existing content\n") + with open(new_source_file, "wb") as f: + f.write(b"this file must not be created\n") + + try: + result, _ = run_client(SOURCE_DIR, DEST_DIR, + flags=["-M", "--existing"], port=shared_server.port) + assert result.returncode == 0, f"--existing sync failed: {(result.stderr or result.stdout)[:200]}" + + with open(os.path.join(received, "small.txt"), "rb") as f: + assert f.read() == b"updated existing content\n" + assert not os.path.exists(os.path.join(received, "new-existing-test.txt")) + finally: + os.unlink(new_source_file) + with open(source_file, "wb") as f: + f.write(b"hello world\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 5f8f0e8..a082461 100644 --- a/tests/test_client_cli.c +++ b/tests/test_client_cli.c @@ -504,6 +504,17 @@ static void test_parse_args_fsync() { config_delete(cfg); } +static void test_parse_args_existing() { + Config* cfg = config_create(); + char* argv[] = {"fastsync", "--existing", "/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->existing); + config_delete(cfg); +} + static void test_parse_args_ignore_times() { Config* cfg = config_create(); char* argv[] = {"fastsync", "-I", "/src", "/dst"}; @@ -649,6 +660,7 @@ void test_client_cli() { test_parse_args_update(); test_parse_args_archive(); test_parse_args_fsync(); + test_parse_args_existing(); test_parse_args_ignore_times(); test_parse_args_8_bit_output(); test_parse_args_stderr_modes(); diff --git a/tests/test_config.c b/tests/test_config.c index 650eaad..e2fa306 100644 --- a/tests/test_config.c +++ b/tests/test_config.c @@ -132,6 +132,7 @@ static void test_config_send_receive() { send_cfg->chunk_size = 1024; send_cfg->eight_bit_output = true; send_cfg->modify_window = 4; + send_cfg->existing = true; /* Use socketpair for bidirectional communication */ int p[2]; @@ -178,6 +179,8 @@ static void test_config_send_receive() { ok = false; if (recv_cfg->modify_window != 4) ok = false; + if (!recv_cfg->existing) + ok = false; } config_delete(recv_cfg); close(p[0]); diff --git a/tests/test_file.c b/tests/test_file.c index d9e492b..4fa793a 100644 --- a/tests/test_file.c +++ b/tests/test_file.c @@ -113,6 +113,50 @@ static void test_file_save_to_disk_with_fsync_config() { rmdir("test_save_fsync_tmp"); } +static void test_file_save_to_disk_existing() { + const char* root = "test_existing_tmp"; + const char* existing_path = "test_existing_tmp/existing.txt"; + const char* missing_path = "test_existing_tmp/missing.txt"; + EXPECT_TRUE(file_write_to_disk(existing_path, "old", 3, false, false)); + + Config* cfg = config_create(); + EXPECT_NOT_NULL(cfg); + cfg->existing = true; + + File* existing = file_create("existing.txt"); + EXPECT_NOT_NULL(existing); + existing->data->data = malloc(3); + EXPECT_NOT_NULL(existing->data->data); + memcpy(existing->data->data, "new", 3); + existing->data->size = 3; + EXPECT_TRUE(file_save_to_disk(root, existing, cfg)); + file_destroy(existing); + + File* missing = file_create("missing.txt"); + EXPECT_NOT_NULL(missing); + missing->data->data = malloc(7); + EXPECT_NOT_NULL(missing->data->data); + memcpy(missing->data->data, "skipped", 7); + missing->data->size = 7; + EXPECT_TRUE(file_save_to_disk(root, missing, cfg)); + file_destroy(missing); + + FILE* fp = fopen(existing_path, "rb"); + char content[4] = {0}; + EXPECT_NOT_NULL(fp); + // cppcheck-suppress knownConditionTrueFalse + if (fp) { + EXPECT_EQ_INT((int)fread(content, 1, 3, fp), 3); + fclose(fp); + } + EXPECT_EQ_STR(content, "new"); + EXPECT_EQ_INT(access(missing_path, F_OK), -1); + + config_delete(cfg); + unlink(existing_path); + 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), @@ -495,6 +539,7 @@ void test_file() { test_file_load_data_missing_file(); test_file_save_to_disk(); test_file_save_to_disk_with_fsync_config(); + test_file_save_to_disk_existing(); test_file_write_to_disk_basic(); test_file_write_to_disk_with_fsync(); test_file_write_to_disk_creates_dirs();