diff --git a/RSYNC_COMPAT.md b/RSYNC_COMPAT.md index 97eceea..153afb5 100644 --- a/RSYNC_COMPAT.md +++ b/RSYNC_COMPAT.md @@ -120,7 +120,7 @@ This document maps rsync's full feature set to FastSync's current implementation | `-o`, `--owner` | Preserve owner | ✅ Implemented | Part of -M | | `-g`, `--group` | Preserve group | ✅ Implemented | Part of -M | | `-t`, `--times` | Preserve modification times | ✅ Implemented | Part of -M | -| `-E`, `--executability` | Preserve executability | ❌ Not Implemented | | +| `-E`, `--executability` | Preserve executability | ✅ Implemented | Preserves executable permission bits (implies metadata preservation) | | `--chmod=CHMOD` | Affect file permissions | ❌ Not Implemented | | | `-A`, `--acls` | Preserve ACLs | ❌ Not Implemented | Removed because it had no effect | | `-X`, `--xattrs` | Preserve extended attributes | ❌ Not Implemented | Removed because it had no effect | diff --git a/src/client/client_cli.c b/src/client/client_cli.c index 956995d..bdd9c11 100644 --- a/src/client/client_cli.c +++ b/src/client/client_cli.c @@ -388,6 +388,10 @@ int parse_args(Config* config, int argc, char* argv[], int* positional_args, } else if (opt_is(argv[i], "-M", "--preserve")) { config->use_metadata = true; log_message(LOG_LEVEL_INFO, "Enabled metadata preservation"); + } else if (opt_is(argv[i], "-E", "--executability")) { + config->use_metadata = true; + config->use_executability = true; + log_message(LOG_LEVEL_INFO, "Enabled executable permission preservation"); } else if (opt_is(argv[i], "-f", "--sendfile")) { config->use_sendfile = true; log_message(LOG_LEVEL_INFO, "Enabled sendfile"); @@ -623,7 +627,6 @@ int main(int argc, char* argv[]) { log_message(LOG_LEVEL_INFO, "Enabling metadata preservation for --delta"); config->use_metadata = true; } - /* Initialize TLS if needed */ if (config->use_tls) tls_global_init(); diff --git a/src/client/usage.c b/src/client/usage.c index 754a57e..acc26b6 100644 --- a/src/client/usage.c +++ b/src/client/usage.c @@ -50,6 +50,7 @@ void print_usage(void) { printf(" -q, --quiet Suppress non-error output\n"); printf(" --debug=FLAGS Fine-grained debug logging (use --debug=help for flags)\n"); printf(" -M, --preserve Preserve file metadata\n"); + printf(" -E, --executability Preserve executable permission bits\n"); printf(" --chunk-size Chunk size in bytes (default: %d)\n", DEFAULT_CHUNK_SIZE); printf(" --source-dir Source directory\n"); printf(" --dest-dir Destination directory\n"); diff --git a/src/shared/config.c b/src/shared/config.c index 26bff0a..7de2075 100644 --- a/src/shared/config.c +++ b/src/shared/config.c @@ -17,6 +17,7 @@ static void config_set_defaults(Config* config) { config->use_chunk_serialization = false; config->use_compression = false; config->use_metadata = false; + config->use_executability = false; config->show_progress = false; config->dry_run = false; config->remove_source_files = false; @@ -125,9 +126,10 @@ static bool validate_received_config(const Config* config) { return valid_wire_bool(config->save_to_disk) && valid_wire_bool(config->use_multithreading) && valid_wire_bool(config->use_chunk_serialization) && valid_wire_bool(config->use_compression) && valid_wire_bool(config->use_metadata) && - valid_wire_bool(config->use_sendfile) && valid_wire_bool(config->use_delete) && - valid_wire_bool(config->use_incremental) && valid_wire_bool(config->size_only) && - valid_wire_bool(config->ignore_times) && valid_wire_bool(config->use_delta) && + valid_wire_bool(config->use_executability) && valid_wire_bool(config->use_sendfile) && + valid_wire_bool(config->use_delete) && valid_wire_bool(config->use_incremental) && + valid_wire_bool(config->size_only) && valid_wire_bool(config->ignore_times) && + valid_wire_bool(config->use_delta) && valid_wire_bool(config->backup) && valid_wire_bool(config->follow_symlinks) && valid_wire_bool(config->copy_links) && 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) && @@ -237,7 +239,8 @@ static bool send_core_fields(int fd, const Config* c) { return send_str(fd, c->send_directory) && send_str(fd, c->receive_root_directory) && send_int(fd, c->save_to_disk) && send_int(fd, c->use_multithreading) && send_int(fd, c->use_chunk_serialization) && send_int(fd, c->use_compression) && - send_int(fd, c->use_metadata) && send_int(fd, c->compression_level) && + send_int(fd, c->use_metadata) && send_int(fd, c->use_executability) && + send_int(fd, c->compression_level) && send_n_data(fd, &c->chunk_size, sizeof(c->chunk_size)) && send_int(fd, c->use_sendfile); } @@ -286,7 +289,8 @@ static bool receive_core_fields(int fd, Config* c) { return false; if (!receive_wire_bool(fd, &c->save_to_disk) || !receive_wire_bool(fd, &c->use_multithreading) || !receive_wire_bool(fd, &c->use_chunk_serialization) || - !receive_wire_bool(fd, &c->use_compression) || !receive_wire_bool(fd, &c->use_metadata)) + !receive_wire_bool(fd, &c->use_compression) || !receive_wire_bool(fd, &c->use_metadata) || + !receive_wire_bool(fd, &c->use_executability)) return false; if (!receive_int(fd, &value)) return false; diff --git a/src/shared/config.h b/src/shared/config.h index 31e4f7d..798408e 100644 --- a/src/shared/config.h +++ b/src/shared/config.h @@ -18,6 +18,7 @@ typedef struct Config { bool use_compression; bool use_sendfile; bool use_metadata; + bool use_executability; bool show_progress; bool dry_run; bool remove_source_files; diff --git a/src/shared/file.c b/src/shared/file.c index 09e369b..60fe37a 100644 --- a/src/shared/file.c +++ b/src/shared/file.c @@ -327,8 +327,8 @@ bool file_rename_secure(const char* old_path, const char* new_path) { 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 update, bool no_replace, - bool use_fsync) { + const FileMetadata* metadata, bool preserve_executability, + bool update, bool no_replace, bool use_fsync) { char* leaf = NULL; int dirfd = file_open_secure_parent(path, &leaf, true); if (dirfd < 0) @@ -350,7 +350,7 @@ static bool file_to_disk_secure_impl(const char* path, const void* data, if (!sparse || data_size == 0 || ftruncate(fd, (off_t)data_size) == 0) ok = write_all(fd, data, data_size); if (ok && metadata) - ok = file_restore_metadata_fd(fd, metadata); + ok = file_restore_metadata_fd(fd, metadata, preserve_executability); if (ok && use_fsync) ok = fsync(fd) == 0; } @@ -378,7 +378,7 @@ static bool file_to_disk_secure_impl(const char* path, const void* data, if (ok || (!sparse || data_size == 0)) ok = write_all(fd, data, data_size); if (ok && metadata) - ok = file_restore_metadata_fd(fd, metadata); + ok = file_restore_metadata_fd(fd, metadata, preserve_executability); if (ok && use_fsync) ok = fsync(fd) == 0; if (close(fd) != 0) @@ -410,31 +410,37 @@ static bool file_to_disk_secure_impl(const char* path, const void* data, } 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, false, false); + bool inplace, bool sparse, const FileMetadata* metadata, + bool preserve_executability) { + return file_to_disk_secure_impl(path, data, data_size, inplace, sparse, metadata, + preserve_executability, false, false, false); } bool file_to_disk_secure_update(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, true, false, false); + bool inplace, bool sparse, const FileMetadata* metadata, + bool preserve_executability) { + return file_to_disk_secure_impl(path, data, data_size, inplace, sparse, metadata, + preserve_executability, true, false, false); } bool file_to_disk_secure_with_fsync(const char* path, const void* data, unsigned long long data_size, bool inplace, bool sparse, - const FileMetadata* metadata, bool use_fsync) { - return file_to_disk_secure_impl(path, data, data_size, inplace, sparse, metadata, false, false, - use_fsync); + const FileMetadata* metadata, bool preserve_executability, + bool use_fsync) { + return file_to_disk_secure_impl(path, data, data_size, inplace, sparse, metadata, + preserve_executability, false, false, use_fsync); } 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, false, true, false); + const FileMetadata* metadata, bool preserve_executability) { + return file_to_disk_secure_impl(path, data, data_size, false, sparse, metadata, + preserve_executability, false, true, false); } 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)) return false; - return file_to_disk_secure(path, data, data_size, inplace, sparse, NULL); + return file_to_disk_secure(path, data, data_size, inplace, sparse, NULL, false); } diff --git a/src/shared/file.h b/src/shared/file.h index ad54fb9..b6038d0 100644 --- a/src/shared/file.h +++ b/src/shared/file.h @@ -32,17 +32,20 @@ int file_open_secure_parent(const char* path, char** leaf_out, bool create_dirs) 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 inplace, bool sparse, const FileMetadata* metadata, + bool preserve_executability); bool file_to_disk_secure_with_fsync(const char* path, const void* data, unsigned long long data_size, bool inplace, bool sparse, - const FileMetadata* metadata, bool use_fsync); + const FileMetadata* metadata, bool preserve_executability, + bool use_fsync); /* With update enabled, an existing newer destination is left untouched. The check is descriptor-based for inplace writes; atomic replacement still has an unavoidable final rename race without filesystem locking. */ bool file_to_disk_secure_update(const char* path, const void* data, unsigned long long data_size, - bool inplace, bool sparse, const FileMetadata* metadata); + bool inplace, bool sparse, const FileMetadata* metadata, + bool preserve_executability); bool file_to_disk_secure_no_replace(const char* path, const void* data, unsigned long long data_size, bool sparse, - const FileMetadata* metadata); + const FileMetadata* metadata, bool preserve_executability); #endif diff --git a/src/shared/file_receive.c b/src/shared/file_receive.c index 90010d9..4fed839 100644 --- a/src/shared/file_receive.c +++ b/src/shared/file_receive.c @@ -27,6 +27,7 @@ bool file_save_to_disk(const char* root_directory, const File* file, const Confi bool backup_enabled = config && config->backup && !config->ignore_existing; bool inplace = config && config->inplace; bool sparse = config && config->preserve_sparse; + bool preserve_executability = config && config->use_executability; const char* backup_suffix = (config && config->suffix) ? config->suffix : "~"; const char* backup_dir = (config && config->backup_dir) ? config->backup_dir : NULL; const char* partial_dir = (config && config->partial_dir) ? config->partial_dir : NULL; @@ -132,13 +133,15 @@ bool file_save_to_disk(const char* root_directory, const File* file, const Confi bool ok = config && config->ignore_existing ? file_to_disk_secure_no_replace(disk_path, file->data->data, file->data->size, - sparse, file->metadata) + sparse, file->metadata, preserve_executability) : config && config->update ? file_to_disk_secure_update(disk_path, file->data->data, file->data->size, - inplace, sparse, file->metadata) + inplace, sparse, file->metadata, + preserve_executability) : file_to_disk_secure_with_fsync(disk_path, file->data->data, file->data->size, - inplace, sparse, file->metadata, - config && config->use_fsync); + inplace, sparse, file->metadata, + preserve_executability, + config && config->use_fsync); free(parent_copy); free(backup_path); free(confined_backup); diff --git a/src/shared/file_store.c b/src/shared/file_store.c index 57f23bb..8bd637d 100644 --- a/src/shared/file_store.c +++ b/src/shared/file_store.c @@ -140,7 +140,8 @@ static bool write_all(int fd, const void* data, unsigned long long size) { } bool file_store_write_secure(const char* path, const void* data, unsigned long long data_size, - bool inplace, bool sparse, const FileMetadata* metadata) { + bool inplace, bool sparse, const FileMetadata* metadata, + bool preserve_executability) { char* leaf = NULL; int dirfd = file_store_open_secure_parent(path, &leaf); if (dirfd < 0) @@ -153,7 +154,7 @@ bool file_store_write_secure(const char* path, const void* data, unsigned long l if (!sparse || data_size == 0 || ftruncate(fd, (off_t)data_size) == 0) ok = write_all(fd, data, data_size); if (ok && metadata) - ok = file_restore_metadata_fd(fd, metadata); + ok = file_restore_metadata_fd(fd, metadata, preserve_executability); } } else { int tmp_size = snprintf(NULL, 0, ".%s.tmp.%ld.%u", leaf, (long)getpid(), 99U); @@ -178,7 +179,7 @@ bool file_store_write_secure(const char* path, const void* data, unsigned long l if (ok || (!sparse || data_size == 0)) ok = write_all(fd, data, data_size); if (ok && metadata) - ok = file_restore_metadata_fd(fd, metadata); + ok = file_restore_metadata_fd(fd, metadata, preserve_executability); if (close(fd) != 0) ok = false; fd = -1; diff --git a/src/shared/file_store.h b/src/shared/file_store.h index ed1c908..743cc96 100644 --- a/src/shared/file_store.h +++ b/src/shared/file_store.h @@ -8,6 +8,7 @@ bool file_store_set_authorized_root(int fd, const char* canonical_path); int file_store_open_secure_parent(const char* path, char** leaf_out); bool file_store_rename_secure(const char* old_path, const char* new_path); bool file_store_write_secure(const char* path, const void* data, unsigned long long data_size, - bool inplace, bool sparse, const FileMetadata* metadata); + bool inplace, bool sparse, const FileMetadata* metadata, + bool preserve_executability); #endif diff --git a/src/shared/metadata.c b/src/shared/metadata.c index 150770e..6f91abd 100644 --- a/src/shared/metadata.c +++ b/src/shared/metadata.c @@ -201,10 +201,21 @@ FileMetadata* metadata_receive(int file_descriptor, int* ok) { return m; } -void file_restore_metadata(const char* path, const FileMetadata* metadata) { +static mode_t metadata_mode(const FileMetadata* metadata, mode_t current_mode, + bool preserve_executability) { + const mode_t execute_bits = S_IXUSR | S_IXGRP | S_IXOTH; + if (preserve_executability) + return (current_mode & 0777 & ~execute_bits) | (metadata->mode & execute_bits); + return metadata->mode & 0777 & ~(S_IWGRP | S_IWOTH); +} + +void file_restore_metadata(const char* path, const FileMetadata* metadata, + bool preserve_executability) { if (metadata == NULL) return; - mode_t safe_mode = metadata->mode & 0777 & ~(S_IWGRP | S_IWOTH); + struct stat current; + mode_t current_mode = stat(path, ¤t) == 0 ? current.st_mode : 0; + mode_t safe_mode = metadata_mode(metadata, current_mode, preserve_executability); if (chmod(path, safe_mode) != 0) { char* escaped_path = output_escape(path, log_get_8_bit_output()); log_message(LOG_LEVEL_WARNING, "Failed to chmod %s: %s", @@ -226,11 +237,14 @@ void file_restore_metadata(const char* path, const FileMetadata* metadata) { } } -bool file_restore_metadata_fd(int fd, const FileMetadata* metadata) { +bool file_restore_metadata_fd(int fd, const FileMetadata* metadata, bool preserve_executability) { if (fd < 0 || metadata == NULL) return metadata == NULL; bool ok = true; - mode_t safe_mode = metadata->mode & 0777 & ~(S_IWGRP | S_IWOTH); + struct stat current; + if (fstat(fd, ¤t) != 0) + return false; + mode_t safe_mode = metadata_mode(metadata, current.st_mode, preserve_executability); if (fchmod(fd, safe_mode) != 0) ok = false; /* Client uid/gid values are deliberately not authoritative. */ diff --git a/src/shared/metadata.h b/src/shared/metadata.h index 8353724..b95b87a 100644 --- a/src/shared/metadata.h +++ b/src/shared/metadata.h @@ -30,8 +30,9 @@ void metadata_to_buf(char** buf, const FileMetadata* m); FileMetadata* metadata_from_buf(char** buf); bool metadata_send(int file_descriptor, const FileMetadata* m); FileMetadata* metadata_receive(int file_descriptor, int* ok); -void file_restore_metadata(const char* path, const FileMetadata* metadata); -bool file_restore_metadata_fd(int fd, const FileMetadata* metadata); +void file_restore_metadata(const char* path, const FileMetadata* metadata, + bool preserve_executability); +bool file_restore_metadata_fd(int fd, const FileMetadata* metadata, bool preserve_executability); /* Compare timestamps using rsync's whole-second modification window. */ bool metadata_mtime_matches(time_t left_sec, long left_nsec, time_t right_sec, long right_nsec, diff --git a/tests/integration/test_features.py b/tests/integration/test_features.py index 68a5a92..0862bd7 100644 --- a/tests/integration/test_features.py +++ b/tests/integration/test_features.py @@ -170,6 +170,26 @@ class TestArchiveMode: assert not mismatches, f"Mismatch: {mismatches}" +class TestExecutability: + def test_preserves_only_executable_bits(self, shared_server): + source = os.path.join(TEST_DATA_DIR, "executability_source") + dest = os.path.join(TEST_DATA_DIR, "executability_dest") + clean_dir(source) + clean_dir(dest) + source_file = os.path.join(source, "tool.sh") + with open(source_file, "w") as f: + f.write("#!/bin/sh\necho test\n") + os.chmod(source_file, 0o751) + + result, _ = run_client(source, dest, flags=["-E"], port=shared_server.port) + assert result.returncode == 0, f"Executability sync failed: {result.stderr[:200]}" + received_file = os.path.join(get_dest_received_dir(dest, source), "tool.sh") + received_mode = os.stat(received_file).st_mode + assert received_mode & 0o111 == 0o111 + assert received_mode & 0o600 == 0o600 + assert received_mode & 0o077 == 0o011 + + 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 a458cef..0cd5e07 100644 --- a/tests/test_client_cli.c +++ b/tests/test_client_cli.c @@ -237,6 +237,19 @@ static void test_parse_args_ignore_existing() { config_delete(cfg); } +static void test_parse_args_executability() { + Config* cfg = config_create(); + char* argv[] = {"fastsync", "-E", "/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->use_executability); + EXPECT_TRUE(cfg->use_metadata); + + config_delete(cfg); +} + /* Test parse_args rejects port > 65535 */ static void test_parse_args_invalid_port() { Config* cfg = config_create(); @@ -657,6 +670,7 @@ void test_client_cli() { test_parse_args_valid_port(); test_parse_args_size_only(); test_parse_args_ignore_existing(); + test_parse_args_executability(); 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 efe989d..33ddc21 100644 --- a/tests/test_config.c +++ b/tests/test_config.c @@ -124,6 +124,7 @@ static void test_config_send_receive() { send_cfg->use_chunk_serialization = true; send_cfg->use_compression = true; send_cfg->use_metadata = true; + send_cfg->use_executability = true; send_cfg->use_delta = true; send_cfg->whole_file = true; send_cfg->ignore_times = true; @@ -168,6 +169,8 @@ static void test_config_send_receive() { ok = false; if (recv_cfg->chunk_size != 1024) ok = false; + if (!recv_cfg->use_executability) + ok = false; if (!recv_cfg->size_only) ok = false; if (!recv_cfg->ignore_times) diff --git a/tests/test_file.c b/tests/test_file.c index c5d7358..9b4019f 100644 --- a/tests/test_file.c +++ b/tests/test_file.c @@ -274,7 +274,7 @@ static void test_file_write_to_disk_with_fsync() { const char* path = "test_file_write_to_disk_fsync.txt"; const char* content = "fsync file content"; EXPECT_TRUE( - file_to_disk_secure_with_fsync(path, content, strlen(content), false, false, NULL, true)); + file_to_disk_secure_with_fsync(path, content, strlen(content), false, false, NULL, false, true)); struct stat st; EXPECT_EQ_INT(stat(path, &st), 0); EXPECT_EQ_INT((int)st.st_size, (int)strlen(content)); diff --git a/tests/test_metadata.c b/tests/test_metadata.c index 1c8f5df..ce9fe46 100644 --- a/tests/test_metadata.c +++ b/tests/test_metadata.c @@ -146,7 +146,7 @@ static void test_file_restore_metadata() { m.mtime_sec = 1234567890; m.mtime_nsec = 0; - file_restore_metadata(path, &m); + file_restore_metadata(path, &m, false); struct stat st; EXPECT_EQ_INT(stat(path, &st), 0); @@ -156,6 +156,35 @@ static void test_file_restore_metadata() { unlink(path); } +static void test_file_restore_executability_only() { + const char* path = "temp_exec_restore_test.txt"; + EXPECT_TRUE(file_write_to_disk(path, "x", 1, false, false)); + EXPECT_EQ_INT(chmod(path, 0644), 0); + + FileMetadata m = { + .mode = 0751, .uid = getuid(), .gid = getgid(), .mtime_sec = 0, .mtime_nsec = 0}; + file_restore_metadata(path, &m, true); + + struct stat st; + EXPECT_EQ_INT(stat(path, &st), 0); + EXPECT_EQ_INT(st.st_mode & 0777, 0755); + unlink(path); +} + +static void test_directory_restore_executability_only() { + const char* path = "temp_exec_restore_test_dir"; + EXPECT_EQ_INT(mkdir(path, 0700), 0); + + FileMetadata m = { + .mode = 0755, .uid = getuid(), .gid = getgid(), .mtime_sec = 0, .mtime_nsec = 0}; + file_restore_metadata(path, &m, true); + + struct stat st; + EXPECT_EQ_INT(stat(path, &st), 0); + EXPECT_EQ_INT(st.st_mode & 0777, 0711); + rmdir(path); +} + void test_metadata() { test_metadata_to_from_buf_roundtrip(); test_metadata_to_buf_null(); @@ -165,4 +194,6 @@ void test_metadata() { test_metadata_rejects_invalid_values(); test_metadata_mtime_window(); test_file_restore_metadata(); + test_file_restore_executability_only(); + test_directory_restore_executability_only(); }