diff --git a/src/client/client_cli.c b/src/client/client_cli.c index 191cc07..1a98aae 100644 --- a/src/client/client_cli.c +++ b/src/client/client_cli.c @@ -146,6 +146,7 @@ static const OptionEntry OPTION_TABLE[] = { {"--backup", NULL, OPT_FLAG, offsetof(Config, backup)}, {"--stats", NULL, OPT_FLAG, offsetof(Config, stats)}, {"--partial", NULL, OPT_FLAG, offsetof(Config, partial)}, + {"--update", "-u", OPT_FLAG, offsetof(Config, update)}, {"--links", "-l", OPT_FLAG, offsetof(Config, follow_symlinks)}, {"--copy-links", NULL, OPT_FLAG, offsetof(Config, copy_links)}, {"--safe-links", NULL, OPT_FLAG, offsetof(Config, safe_links)}, @@ -189,6 +190,8 @@ static int apply_table_option(Config* config, const OptionEntry* entry, const ch switch (entry->kind) { case OPT_FLAG: *(bool*)field = true; + if (entry->offset == offsetof(Config, update)) + config->use_metadata = true; return 0; case OPT_STRING: return set_string_option((char**)field, value, entry->name); diff --git a/src/client/usage.c b/src/client/usage.c index 2f800af..d16718c 100644 --- a/src/client/usage.c +++ b/src/client/usage.c @@ -28,6 +28,7 @@ void print_usage(void) { printf(" --max-size Skip files larger than n bytes\n"); printf(" --min-size Skip files smaller than n bytes\n"); printf(" --incremental Skip files unchanged since last transfer\n"); + printf(" -u, --update Skip files newer than the source on receiver\n"); printf(" --delta Delta transfer for changed files (requires --incremental)\n"); printf(" --delta-block Delta block size in bytes (default: %d)\n", DELTA_BLOCK_SIZE_DEFAULT); diff --git a/src/shared/file_receive.c b/src/shared/file_receive.c index a610d67..93edb1e 100644 --- a/src/shared/file_receive.c +++ b/src/shared/file_receive.c @@ -21,6 +21,17 @@ #define MAX_SERVER_DELETE_COUNT 100000U #define MAX_FILE_DATA_SIZE MAX_RECEIVE_FILE_SIZE +static bool destination_is_newer(const char* path, const FileMetadata* source_metadata) { + struct stat destination_stat; + if (!source_metadata || !file_stat_secure(path, &destination_stat)) + return false; + time_t destination_sec = destination_stat.st_mtim.tv_sec; + long destination_nsec = destination_stat.st_mtim.tv_nsec; + return destination_sec > source_metadata->mtime_sec || + (destination_sec == source_metadata->mtime_sec && + destination_nsec > source_metadata->mtime_nsec); +} + bool file_save_to_disk(const char* root_directory, const File* file, const Config* config) { bool backup_enabled = config && config->backup; bool inplace = config && config->inplace; @@ -62,15 +73,11 @@ bool file_save_to_disk(const char* root_directory, const File* file, const Confi } /* --update is receiver-side policy: never replace a newer destination. */ - if (config && config->update) { - struct stat destination_stat; - if (file_stat_secure(disk_path, &destination_stat) && file->metadata && - destination_stat.st_mtime > file->metadata->mtime_sec) { - free(confined_backup); - free(confined_partial); - free(disk_path); - return true; - } + if (config && config->update && destination_is_newer(disk_path, file->metadata)) { + free(confined_backup); + free(confined_partial); + free(disk_path); + return true; } if (backup_enabled) { diff --git a/tests/integration/test_features.py b/tests/integration/test_features.py index 312add4..60d4427 100644 --- a/tests/integration/test_features.py +++ b/tests/integration/test_features.py @@ -214,6 +214,40 @@ class TestIncremental: assert f.read() == b"hello world\n" +class TestUpdate: + def test_update_skips_older_destination_and_allows_equal_or_newer_source(self, shared_server): + clean_dir(DEST_DIR) + result, _ = run_client(SOURCE_DIR, DEST_DIR, flags=["-u"], 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"newer destination\n") + os.utime(received_file, (source_stat.st_mtime + 10, source_stat.st_mtime + 10)) + result, _ = run_client(SOURCE_DIR, DEST_DIR, flags=["-u"], port=shared_server.port) + assert result.returncode == 0 + with open(received_file, "rb") as f: + assert f.read() == b"newer destination\n" + + os.utime(received_file, (source_stat.st_atime, source_stat.st_mtime)) + result, _ = run_client(SOURCE_DIR, DEST_DIR, flags=["-u"], port=shared_server.port) + assert result.returncode == 0 + with open(received_file, "rb") as f: + assert f.read() == b"hello world\n" + + with open(received_file, "wb") as f: + f.write(b"older destination\n") + os.utime(received_file, (source_stat.st_atime, source_stat.st_mtime - 10)) + result, _ = run_client(SOURCE_DIR, DEST_DIR, flags=["-u"], port=shared_server.port) + assert result.returncode == 0 + with open(received_file, "rb") as f: + assert f.read() == 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 656fd40..03fb769 100644 --- a/tests/test_client_cli.c +++ b/tests/test_client_cli.c @@ -282,8 +282,6 @@ static void test_parse_args_rejects_unimplemented_options() { "--list-only", "-h", "--human-readable", - "-u", - "--update", "--append", "--append-verify", "--delete-excluded", @@ -323,6 +321,20 @@ static void test_parse_args_rejects_unimplemented_options() { } } +static void test_parse_args_update() { + Config* cfg = config_create(); + char* argv[] = {"fastsync", "-u", "/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->update); + EXPECT_TRUE(cfg->use_metadata); + EXPECT_EQ_INT(positional_count, 2); + + config_delete(cfg); +} + /* Test parse_args with --archive flag */ static void test_parse_args_archive() { Config* cfg = config_create(); @@ -359,5 +371,6 @@ void test_client_cli() { test_parse_args_valid_compression_level(); test_parse_args_unknown_option(); test_parse_args_rejects_unimplemented_options(); + test_parse_args_update(); test_parse_args_archive(); }