diff --git a/RSYNC_COMPAT.md b/RSYNC_COMPAT.md index 153afb5..02679d8 100644 --- a/RSYNC_COMPAT.md +++ b/RSYNC_COMPAT.md @@ -121,7 +121,7 @@ This document maps rsync's full feature set to FastSync's current implementation | `-g`, `--group` | Preserve group | ✅ Implemented | Part of -M | | `-t`, `--times` | Preserve modification times | ✅ Implemented | Part of -M | | `-E`, `--executability` | Preserve executability | ✅ Implemented | Preserves executable permission bits (implies metadata preservation) | -| `--chmod=CHMOD` | Affect file permissions | ❌ Not Implemented | | +| `--chmod=CHMOD` | Affect file permissions | ✅ Implemented | Supports numeric and symbolic `ugo` `rwx` changes; retains receiver safety masking | | `-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 | | `-H`, `--hard-links` | Preserve hard links | ❌ Not Implemented | Removed because it had no effect | diff --git a/src/client/client_cli.c b/src/client/client_cli.c index bdd9c11..571c685 100644 --- a/src/client/client_cli.c +++ b/src/client/client_cli.c @@ -1,5 +1,6 @@ #include "client_send.h" #include "client_validation.h" +#include "chmod.h" #include "config.h" #include "delta.h" #include "log.h" @@ -231,6 +232,7 @@ static const OptionEntry OPTION_TABLE[] = { {"--8-bit-output", "-8", OPT_FLAG, offsetof(Config, eight_bit_output)}, {"--existing", NULL, OPT_FLAG, offsetof(Config, existing)}, {"--ignore-existing", NULL, OPT_FLAG, offsetof(Config, ignore_existing)}, + {"--chmod", NULL, OPT_STRING, offsetof(Config, chmod_spec)}, {"--source-dir", NULL, OPT_STRING, offsetof(Config, send_directory)}, {"--dest-dir", NULL, OPT_STRING, offsetof(Config, receive_root_directory)}, @@ -319,6 +321,14 @@ int parse_args(Config* config, int argc, char* argv[], int* positional_args, } if (apply_table_option(config, entry, argv[++i]) != 0) return -1; + if (entry->offset == offsetof(Config, chmod_spec)) { + mode_t ignored; + if (!chmod_apply(0, config->chmod_spec, &ignored)) { + log_message(LOG_LEVEL_ERROR, "--chmod has invalid permission changes"); + return -1; + } + config->use_metadata = true; + } } else if (apply_table_option(config, entry, NULL) != 0) { return -1; } @@ -327,6 +337,18 @@ int parse_args(Config* config, int argc, char* argv[], int* positional_args, continue; } + if (strncmp(argv[i], "--chmod=", 8) == 0) { + if (set_string_option(&config->chmod_spec, argv[i] + 8, "--chmod") != 0) + return -1; + mode_t ignored; + if (!chmod_apply(0, config->chmod_spec, &ignored)) { + log_message(LOG_LEVEL_ERROR, "--chmod has invalid permission changes"); + return -1; + } + config->use_metadata = true; + continue; + } + if (opt_is(argv[i], "--help", NULL)) { print_usage(); return 1; diff --git a/src/client/usage.c b/src/client/usage.c index acc26b6..35e8a2f 100644 --- a/src/client/usage.c +++ b/src/client/usage.c @@ -51,6 +51,7 @@ void print_usage(void) { 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(" --chmod Modify transferred permissions (rsync syntax)\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/chmod.c b/src/shared/chmod.c new file mode 100644 index 0000000..bfea77b --- /dev/null +++ b/src/shared/chmod.c @@ -0,0 +1,90 @@ +#include "chmod.h" +#include +#include + +static bool parse_clause(mode_t* mode, const char* begin, const char* end) { + const char* p = begin; + unsigned who = 0; + while (p < end && strchr("ugoa", *p)) { + if (*p == 'a') + who = 7; + else + who |= *p == 'u' ? 1U : (*p == 'g' ? 2U : 4U); + p++; + } + if (who == 0) + who = 7; + if (p == end || (*p != '+' && *p != '-' && *p != '=')) + return false; + char operation = *p++; + mode_t bits = 0; + while (p < end) { + mode_t bit; + switch (*p++) { + case 'r': + bit = 4; + break; + case 'w': + bit = 2; + break; + case 'x': + bit = 1; + break; + default: + return false; + } + bits |= bit; + } + for (unsigned class_index = 0; class_index < 3; class_index++) { + unsigned class_bit = 1U << class_index; + if (!(who & class_bit)) + continue; + mode_t shift = (mode_t)((2U - class_index) * 3U); + mode_t mask = (mode_t)(7U << shift); + mode_t class_bits = (mode_t)(bits << shift); + if (operation == '+') + *mode |= class_bits; + else if (operation == '-') + *mode &= ~class_bits; + else + *mode = (*mode & ~mask) | class_bits; + } + return true; +} + +bool chmod_apply(mode_t mode, const char* spec, mode_t* result) { + if (!spec || !*spec || !result) + return false; + bool numeric = true; + size_t length = strlen(spec); + if (length > 4) + numeric = false; + for (size_t i = 0; i < length && numeric; i++) + numeric = spec[i] >= '0' && spec[i] <= '7'; + if (numeric) { + if (length == 0 || length > 4) + return false; + mode_t parsed = 0; + for (size_t i = 0; i < length; i++) + parsed = (mode_t)((parsed << 3) | (spec[i] - '0')); + *result = parsed; + return true; + } + + mode_t changed = mode; + const char* begin = spec; + while (*begin) { + const char* end = strchr(begin, ','); + if (!end) + end = begin + strlen(begin); + if (!parse_clause(&changed, begin, end)) + return false; + if (*end == '\0') + break; + begin = end + 1; + if (!*begin) + return false; + } + *result = changed; + return true; +} diff --git a/src/shared/chmod.h b/src/shared/chmod.h new file mode 100644 index 0000000..c3b259c --- /dev/null +++ b/src/shared/chmod.h @@ -0,0 +1,10 @@ +#ifndef CHMOD_H +#define CHMOD_H + +#include +#include + +/* Apply the supported rsync --chmod syntax to a permission mode. */ +bool chmod_apply(mode_t mode, const char* spec, mode_t* result); + +#endif diff --git a/src/shared/config.c b/src/shared/config.c index 7de2075..6854d4c 100644 --- a/src/shared/config.c +++ b/src/shared/config.c @@ -1,4 +1,5 @@ #include "config.h" +#include "chmod.h" #include "delta.h" #include "log.h" #include "protocol.h" @@ -108,6 +109,7 @@ static void config_set_defaults(Config* config) { config->server_mode = false; config->checksum = false; config->compress_choice = NULL; + config->chmod_spec = NULL; } static bool valid_wire_bool(int value) { @@ -149,7 +151,9 @@ static bool validate_received_config(const Config* config) { config->delta_block_size >= DELTA_BLOCK_SIZE_MIN && config->delta_block_size <= DELTA_BLOCK_SIZE_MAX && config->delta_max_file_size <= DELTA_MAX_FILE_SIZE && config->modify_window >= 0 && - config->max_delete >= 0; + config->max_delete >= 0 && + (!config->chmod_spec || !*config->chmod_spec || + chmod_apply(0, config->chmod_spec, &(mode_t){0})); } Config* config_create(void) { @@ -223,6 +227,7 @@ void config_delete(Config* config) { free(config->bind_address); free(config->daemon_config); free(config->compress_choice); + free(config->chmod_spec); if (config->filters) { array_list_delete(config->filters); } @@ -275,7 +280,8 @@ static bool send_resume_options(int fd, const Config* c) { send_str(fd, c->partial_dir ? c->partial_dir : "") && send_str(fd, c->suffix ? c->suffix : "") && send_int(fd, c->delete_before) && send_int(fd, c->checksum) && send_int(fd, c->modify_window) && - send_str(fd, c->compress_choice ? c->compress_choice : ""); + send_str(fd, c->compress_choice ? c->compress_choice : "") && + send_str(fd, c->chmod_spec ? c->chmod_spec : ""); } static bool receive_core_fields(int fd, Config* c) { @@ -363,7 +369,10 @@ static bool receive_resume_options(int fd, Config* c) { if (!receive_n_data(fd, &c->modify_window, sizeof(c->modify_window))) return false; c->compress_choice = receive_str(fd); - return c->compress_choice != NULL; + if (!c->compress_choice) + return false; + c->chmod_spec = receive_str(fd); + return c->chmod_spec != NULL; } bool config_send(int file_descriptor, const Config* config) { diff --git a/src/shared/config.h b/src/shared/config.h index 798408e..306877a 100644 --- a/src/shared/config.h +++ b/src/shared/config.h @@ -136,6 +136,7 @@ typedef struct Config { // PR #184: Compression algorithm negotiation char* compress_choice; + char* chmod_spec; } Config; #define PROTOCOL_VERSION "2.3.0" diff --git a/src/shared/file_receive.c b/src/shared/file_receive.c index 4fed839..a8c60f1 100644 --- a/src/shared/file_receive.c +++ b/src/shared/file_receive.c @@ -8,6 +8,7 @@ #include #include "array_list.h" +#include "chmod.h" #include "compression.h" #include "config.h" #include "data.h" @@ -131,17 +132,25 @@ bool file_save_to_disk(const char* root_directory, const File* file, const Confi } } + FileMetadata adjusted_metadata; + const FileMetadata* metadata = file->metadata; + if (metadata && config && config->chmod_spec && *config->chmod_spec) { + adjusted_metadata = *metadata; + if (!chmod_apply(adjusted_metadata.mode, config->chmod_spec, &adjusted_metadata.mode)) + goto fail; + metadata = &adjusted_metadata; + } bool ok = config && config->ignore_existing ? file_to_disk_secure_no_replace(disk_path, file->data->data, file->data->size, - sparse, file->metadata, preserve_executability) + sparse, metadata, preserve_executability) : config && config->update ? file_to_disk_secure_update(disk_path, file->data->data, file->data->size, - inplace, sparse, file->metadata, - preserve_executability) + inplace, sparse, metadata, + preserve_executability) : file_to_disk_secure_with_fsync(disk_path, file->data->data, file->data->size, - inplace, sparse, file->metadata, - preserve_executability, - config && config->use_fsync); + inplace, sparse, metadata, + preserve_executability, + config && config->use_fsync); free(parent_copy); free(backup_path); free(confined_backup); diff --git a/tests/integration/test_features.py b/tests/integration/test_features.py index 0862bd7..ec1e6d5 100644 --- a/tests/integration/test_features.py +++ b/tests/integration/test_features.py @@ -190,6 +190,22 @@ class TestExecutability: assert received_mode & 0o077 == 0o011 +class TestChmod: + def test_chmod_applies_to_transferred_files(self, shared_server): + clean_dir(DEST_DIR) + source_file = os.path.join(SOURCE_DIR, "small.txt") + os.chmod(source_file, 0o777) + result, dur = run_client( + SOURCE_DIR, DEST_DIR, + flags=["--chmod=u=rw,go=r"], + port=shared_server.port, + ) + if result.returncode != 0: + pytest.fail(f"Exit {result.returncode}: {(result.stderr or result.stdout)[:200]}") + received = get_dest_received_dir(DEST_DIR, SOURCE_DIR) + assert (os.stat(os.path.join(received, "small.txt")).st_mode & 0o777) == 0o644 + + 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 0cd5e07..218f538 100644 --- a/tests/test_client_cli.c +++ b/tests/test_client_cli.c @@ -1,5 +1,6 @@ #include "test_client_cli.h" #include "client_validation.h" +#include "chmod.h" #include "config.h" #include "log.h" #include "test_utils.h" @@ -250,6 +251,40 @@ static void test_parse_args_executability() { config_delete(cfg); } +static void test_parse_args_chmod() { + Config* cfg = config_create(); + char* argv[] = {"fastsync", "--chmod=u=rw,go=r", "/src", "/dst"}; + int positional_args[2]; + int positional_count = 0; + EXPECT_EQ_INT(parse_args(cfg, 4, argv, positional_args, &positional_count), 0); + EXPECT_EQ_STR(cfg->chmod_spec, "u=rw,go=r"); + EXPECT_TRUE(cfg->use_metadata); + mode_t result; + EXPECT_TRUE(chmod_apply(0777, cfg->chmod_spec, &result)); + EXPECT_EQ_INT(result, 0644); + config_delete(cfg); +} + +static void test_parse_args_numeric_chmod() { + Config* cfg = config_create(); + char* argv[] = {"fastsync", "--chmod", "7777", "/src", "/dst"}; + int positional_args[2]; + int positional_count = 0; + EXPECT_EQ_INT(parse_args(cfg, 5, argv, positional_args, &positional_count), 0); + EXPECT_EQ_STR(cfg->chmod_spec, "7777"); + EXPECT_TRUE(cfg->use_metadata); + config_delete(cfg); +} + +static void test_parse_args_rejects_invalid_chmod() { + Config* cfg = config_create(); + char* argv[] = {"fastsync", "--chmod=a+X", "/src", "/dst"}; + int positional_args[2]; + int positional_count = 0; + EXPECT_EQ_INT(parse_args(cfg, 4, argv, positional_args, &positional_count), -1); + config_delete(cfg); +} + /* Test parse_args rejects port > 65535 */ static void test_parse_args_invalid_port() { Config* cfg = config_create(); @@ -671,6 +706,9 @@ void test_client_cli() { test_parse_args_size_only(); test_parse_args_ignore_existing(); test_parse_args_executability(); + test_parse_args_chmod(); + test_parse_args_numeric_chmod(); + test_parse_args_rejects_invalid_chmod(); test_parse_args_invalid_port(); test_parse_args_non_numeric_port(); test_parse_args_invalid_server_port(); diff --git a/tests/test_metadata.c b/tests/test_metadata.c index ce9fe46..cf323f7 100644 --- a/tests/test_metadata.c +++ b/tests/test_metadata.c @@ -1,4 +1,5 @@ #include "test_metadata.h" +#include "chmod.h" #include "metadata.h" #include "protocol.h" #include "test_utils.h" @@ -185,6 +186,27 @@ static void test_directory_restore_executability_only() { rmdir(path); } +static void test_chmod_changes() { + mode_t result; + EXPECT_TRUE(chmod_apply(0777, "u=rw,go=r", &result)); + EXPECT_EQ_INT(result, 0644); + EXPECT_TRUE(chmod_apply(0644, "a+x", &result)); + EXPECT_EQ_INT(result, 0755); + result = 0777; + EXPECT_TRUE(chmod_apply(0777, "0000", &result)); + EXPECT_EQ_INT(result, 0000); + result = 0777; + EXPECT_TRUE(chmod_apply(0777, "7777", &result)); + EXPECT_EQ_INT(result, 07777); + result = 0777; + EXPECT_TRUE(chmod_apply(0777, "755", &result)); + EXPECT_EQ_INT(result, 0755); + EXPECT_FALSE(chmod_apply(0777, "888", &result)); + EXPECT_FALSE(chmod_apply(0777, "10000", &result)); + EXPECT_FALSE(chmod_apply(0777, "a+X", &result)); + EXPECT_FALSE(chmod_apply(0777, "a+r,", &result)); +} + void test_metadata() { test_metadata_to_from_buf_roundtrip(); test_metadata_to_buf_null(); @@ -196,4 +218,5 @@ void test_metadata() { test_file_restore_metadata(); test_file_restore_executability_only(); test_directory_restore_executability_only(); + test_chmod_changes(); }