From e8e9436879ceedadd68779d383322d85069de4f7 Mon Sep 17 00:00:00 2001 From: TapTap Date: Sat, 8 Aug 2026 20:25:58 +0200 Subject: [PATCH 01/16] fix: harden queue and receiver error handling --- src/shared/multiprocessing.c | 45 +++++++++++++++++++++++------------ src/shared/queue.c | 24 ++++++++++++++++++- tests/test_multiprocessing.c | 46 +++++++++++++++++++++++++++++++----- tests/test_queue.c | 6 +++++ 4 files changed, 99 insertions(+), 22 deletions(-) diff --git a/src/shared/multiprocessing.c b/src/shared/multiprocessing.c index df03388..f5019a9 100644 --- a/src/shared/multiprocessing.c +++ b/src/shared/multiprocessing.c @@ -101,7 +101,20 @@ static bool receive_chunk_enqueue(int file_descriptor, PipelineContextReceiver* return true; } +static void receiver_thread_fail(PipelineContextReceiver* context) { + mtx_lock(&context->mutex); + context->receiver_done = true; + cnd_broadcast(&context->condition_not_empty); + cnd_broadcast(&context->condition_not_full); + mtx_unlock(&context->mutex); +} + int receive_thread(void* pipeline_context) { +#define RECEIVE_THREAD_FAIL() \ + do { \ + receiver_thread_fail(context); \ + return thrd_error; \ + } while (0) PipelineContextReceiver* context = (PipelineContextReceiver*)pipeline_context; if (context->ssl) io_set_ssl(context->ssl); @@ -112,53 +125,52 @@ int receive_thread(void* pipeline_context) { Status status; if (!receive_status(file_descriptor, &status)) - return thrd_error; + RECEIVE_THREAD_FAIL(); while (status == STATUS_NEXT || status == STATUS_CHUNK || status == STATUS_CHECK || status == STATUS_KEEPALIVE || status == STATUS_ABORT || status == STATUS_CHECK_BATCH) { if (status == STATUS_KEEPALIVE) { - send_status(file_descriptor, STATUS_KEEPALIVE); + if (!send_status(file_descriptor, STATUS_KEEPALIVE)) + RECEIVE_THREAD_FAIL(); goto next; } if (status == STATUS_ABORT) { log_message(LOG_LEVEL_INFO, "Received abort from client, cleaning up"); - return thrd_error; + RECEIVE_THREAD_FAIL(); } if (status == STATUS_CHECK) { bool skipped; File* file = receive_incremental_check(file_descriptor, config, &skipped); if (!skipped) { if (file == NULL) - return thrd_error; + RECEIVE_THREAD_FAIL(); queue_enqueue_multithreaded(context->queue, file, &context->mutex, &context->condition_not_empty, &context->condition_not_full); } } else if (status == STATUS_CHUNK) { if (!receive_chunk_enqueue(file_descriptor, context)) - return thrd_error; + RECEIVE_THREAD_FAIL(); } else if (status == STATUS_CHECK_BATCH) { int count; if (!receive_int(file_descriptor, &count)) - return thrd_error; + RECEIVE_THREAD_FAIL(); for (int i = 0; i < count; i++) { char* check_path = receive_str(file_descriptor); if (!check_path) - return thrd_error; + RECEIVE_THREAD_FAIL(); unsigned long long check_size; long long check_mtime; if (!receive_n_data(file_descriptor, &check_size, sizeof(check_size)) || !receive_n_data(file_descriptor, &check_mtime, sizeof(check_mtime))) { free(check_path); - return thrd_error; + RECEIVE_THREAD_FAIL(); } char* full_path = path_cat(config->receive_root_directory, check_path); struct stat st; bool has_old = full_path && lstat(full_path, &st) == 0; bool match = has_old && (unsigned long long)st.st_size == check_size && (long long)st.st_mtime == check_mtime; - if (match) - send_status(file_descriptor, STATUS_OK); - else - send_status(file_descriptor, STATUS_NEXT); + if (!send_status(file_descriptor, match ? STATUS_OK : STATUS_NEXT)) + RECEIVE_THREAD_FAIL(); free(full_path); free(check_path); } @@ -170,21 +182,24 @@ int receive_thread(void* pipeline_context) { &context->condition_not_empty, &context->condition_not_full); } else { log_message(LOG_LEVEL_ERROR, "Failed to receive file"); - return thrd_error; + RECEIVE_THREAD_FAIL(); } } next: if (!receive_status(file_descriptor, &status)) - return thrd_error; + RECEIVE_THREAD_FAIL(); } if (status == STATUS_MANIFEST) { if (receive_manifest(file_descriptor, config, &status) != 0) - return thrd_error; + RECEIVE_THREAD_FAIL(); } + if (status != STATUS_FINISHED) + RECEIVE_THREAD_FAIL(); mtx_lock(&context->mutex); context->receiver_done = true; cnd_signal(&context->condition_not_empty); mtx_unlock(&context->mutex); +#undef RECEIVE_THREAD_FAIL return thrd_success; } diff --git a/src/shared/queue.c b/src/shared/queue.c index 14d901d..5b763f2 100644 --- a/src/shared/queue.c +++ b/src/shared/queue.c @@ -1,4 +1,5 @@ #include +#include #include #include #include @@ -7,6 +8,9 @@ #include "queue.h" Queue* queue_create(int capacity, void (*destroyer)(void* item)) { + if (capacity <= 0) + return NULL; + Queue* queue = (Queue*)malloc(sizeof(Queue)); if (queue == NULL) { perror("ERROR: Could not allocate memory for queue structure"); @@ -61,7 +65,9 @@ bool queue_is_full(const Queue* queue) { static bool queue_double_capacity(Queue* queue) { if (queue == NULL) return false; - unsigned int new_capacity = queue->capacity * 2; + if (queue->capacity > INT_MAX / 2) + return false; + int new_capacity = queue->capacity * 2; if (new_capacity <= 1) new_capacity = 100; void** new_items = malloc(new_capacity * sizeof(void*)); @@ -103,6 +109,22 @@ bool queue_enqueue_multithreaded(Queue* queue, void* item, mtx_t* mutex, cnd_t* return ok; } +bool queue_enqueue_multithreaded_cancel(Queue* queue, void* item, mtx_t* mutex, + cnd_t* condition_not_empty, cnd_t* condition_not_full, + const bool* cancelled) { + mtx_lock(mutex); + while (queue_is_full(queue) && (cancelled == NULL || !*cancelled)) + cnd_wait(condition_not_full, mutex); + if (cancelled != NULL && *cancelled) { + mtx_unlock(mutex); + return false; + } + bool ok = queue_enqueue(queue, item); + cnd_signal(condition_not_empty); + mtx_unlock(mutex); + return ok; +} + void* queue_dequeue(Queue* queue) { if (queue == NULL || queue_is_empty(queue)) { perror("ERROR: Could not dequeue from null or empty queue."); diff --git a/tests/test_multiprocessing.c b/tests/test_multiprocessing.c index 54ed5d6..b023d90 100644 --- a/tests/test_multiprocessing.c +++ b/tests/test_multiprocessing.c @@ -82,7 +82,7 @@ static void test_sender_queue_capacities() { pipeline_context_sender_destroy(ctx); } -/* Test that create handles zero-capacity queues */ +/* Invalid queue capacities must not create unusable pipeline queues. */ static void test_sender_zero_capacity() { Config* cfg = config_create(); EXPECT_NOT_NULL(cfg); @@ -93,11 +93,9 @@ static void test_sender_zero_capacity() { Queue* q1 = queue_create(0, NULL); Queue* q2 = queue_create(0, NULL); - PipelineContextSender* ctx = pipeline_context_sender_create(cfg, q1, q2); - EXPECT_NOT_NULL(ctx); - EXPECT_EQ_INT(ctx->queue_scanner->capacity, 0); - EXPECT_EQ_INT(ctx->queue_loader->capacity, 0); - pipeline_context_sender_destroy(ctx); + EXPECT_NULL(q1); + EXPECT_NULL(q2); + config_delete(cfg); } /* Test receiver with zero file_descriptor */ @@ -168,6 +166,41 @@ static void test_receive_thread_finished() { } } +/* A malformed terminal status must wake a writer waiting on an empty queue. */ +static void test_receive_thread_failure_wakes_writer() { + Config* cfg = config_create(); + EXPECT_NOT_NULL(cfg); + free(cfg->version); + cfg->version = str_dup(PROTOCOL_VERSION); + cfg->send_directory = str_dup("/src"); + cfg->receive_root_directory = str_dup("/tmp/dst"); + + int p[2]; + EXPECT_EQ_INT(pipe(p), 0); + Queue* q = queue_create(1, file_destroy); + EXPECT_NOT_NULL(q); + PipelineContextReceiver* ctx = pipeline_context_receiver_create(cfg, q, p[0], NULL); + EXPECT_NOT_NULL(ctx); + + thrd_t receiver; + thrd_t writer; + EXPECT_EQ_INT(thrd_create(&writer, write_thread, ctx), thrd_success); + EXPECT_EQ_INT(thrd_create(&receiver, receive_thread, ctx), thrd_success); + EXPECT_TRUE(send_status(p[1], STATUS_OK)); + close(p[1]); + + int receiver_result; + int writer_result; + EXPECT_EQ_INT(thrd_join(receiver, &receiver_result), thrd_success); + EXPECT_EQ_INT(thrd_join(writer, &writer_result), thrd_success); + EXPECT_EQ_INT(receiver_result, thrd_error); + EXPECT_EQ_INT(writer_result, thrd_success); + EXPECT_TRUE(ctx->receiver_done); + + close(p[0]); + pipeline_context_receiver_destroy(ctx); +} + /* Test that write_thread completes cleanly when queue signals done */ static void test_write_thread_done() { Config* cfg = config_create(); @@ -223,6 +256,7 @@ void test_multiprocessing() { test_receiver_fd_zero(); if (!is_running_under_valgrind()) { test_receive_thread_finished(); + test_receive_thread_failure_wakes_writer(); } test_write_thread_done(); } diff --git a/tests/test_queue.c b/tests/test_queue.c index c392fcb..3ab6d3c 100644 --- a/tests/test_queue.c +++ b/tests/test_queue.c @@ -56,6 +56,11 @@ static void test_queue_basic() { queue_destroy(q); } +static void test_queue_rejects_invalid_capacity() { + EXPECT_NULL(queue_create(0, NULL)); + EXPECT_NULL(queue_create(-1, NULL)); +} + static void test_queue_resize() { Queue* q = queue_create(3, NULL); EXPECT_NOT_NULL(q); @@ -199,6 +204,7 @@ static void test_queue_multithreaded() { void test_queue() { test_queue_basic(); + test_queue_rejects_invalid_capacity(); test_queue_resize(); test_queue_destroyer(); test_queue_multithreaded(); From 2450b90dd06f9fc41c4fd1d5bc5beb82d1265b49 Mon Sep 17 00:00:00 2001 From: TapTap Date: Sat, 8 Aug 2026 20:27:26 +0200 Subject: [PATCH 02/16] feat: add checksum-aware incremental sync --- README.md | 8 +++-- src/client/client_cli.c | 8 +++++ src/client/client_send.c | 13 +++++-- src/server/server.c | 15 +++++---- src/shared/config.c | 7 ++++ src/shared/config.h | 2 +- src/shared/delta.c | 4 +++ src/shared/delta.h | 1 + src/shared/file.c | 54 ++++++++++++++++++++++++++---- src/shared/file.h | 1 + src/shared/metadata.c | 13 ++++++- src/shared/multiprocessing.c | 1 + src/shared/multiprocessing.h | 2 ++ src/shared/protocol.c | 26 +++++++++++--- src/shared/protocol.h | 1 + src/shared/queue.h | 3 ++ tests/integration/test_features.py | 20 +++++++++++ tests/test_delta.c | 5 +++ tests/test_metadata.c | 14 ++++++++ tests/test_server.c | 16 +++++++++ 20 files changed, 189 insertions(+), 25 deletions(-) diff --git a/README.md b/README.md index 353133c..f692060 100644 --- a/README.md +++ b/README.md @@ -73,7 +73,7 @@ A high-performance file synchronization system with SSH and TCP transport, TLS e | `STATUS_NEXT` | Ready for next file (per-file mode) | | `STATUS_CHUNK` | Following data is a serialized chunk | | `STATUS_MANIFEST` | Following data is a file manifest (for `--delete`) | -| `STATUS_CHECK` | Incremental check: client sends file path + size + mtime, server responds with OK (skip) or NEXT (send) | +| `STATUS_CHECK` | Incremental check: client sends file path + size + mtime and, when negotiated, checksum; server responds with OK (skip) or NEXT (send) | | `STATUS_CHECK_BATCH` | Batch incremental check: multiple file checks sent in one message | | `STATUS_KEEPALIVE` | Keep-alive heartbeat to detect stalled connections | | `STATUS_ABORT` | Abort signal: client interrupts, server cleans up and exits | @@ -95,7 +95,9 @@ Abort (`STATUS_ABORT`) may be sent at any point. On receipt the server cleans up ### Protocol Version -`1.3.0` — server and client must match. Mismatch results in `STATUS_ERROR`. +`2.2.0` — server and client must match. This version adds a 64-bit XXH64 checksum to checksum-enabled `STATUS_CHECK` messages and validates the negotiated compression choice (`zstd` or `none`). Older clients and servers must not be mixed with this version; mismatch results in `STATUS_ERROR`. + +Config negotiation is sender-driven: the client serializes transfer options and the server applies them while receiving and writing files. `--checksum` compares size and content checksum instead of timestamps. `--compress-choice zstd` enables zstd; `none` disables it. Unsupported choices are rejected during config exchange. ## Command-Line Arguments @@ -186,7 +188,7 @@ Abort (`STATUS_ABORT`) may be sent at any point. On receipt the server cleans up 2. **Chunking** — files accumulated until `chunk_size` threshold, then flushed 3. **Compression** — streaming zstd via `ZSTD_compressStream2` / `ZSTD_decompressStream` 4. **Network protocol** — status-code-driven exchange with metadata packing, keep-alive, and abort support -5. **Incremental check** — client sends `STATUS_CHECK` + path + size + mtime; server compares against destination. Can be batched via `STATUS_CHECK_BATCH` for reduced round-trips. +5. **Incremental check** — client sends `STATUS_CHECK` + path + size + mtime and, with `--checksum`, XXH64 content checksum; server compares against destination. Can be batched via `STATUS_CHECK_BATCH` for reduced round-trips. 6. **Bandwidth limiting** — token-bucket algorithm with `nanosleep` throttling on 64 KB write chunks 7. **Metadata restoration** — `chmod()`, `chown()`, `utimensat()` on the receiving side 8. **`--delete`** — sender tracks all sent paths; receiver walks destination tree and removes unlisted files/directories diff --git a/src/client/client_cli.c b/src/client/client_cli.c index d1149fc..a144c25 100644 --- a/src/client/client_cli.c +++ b/src/client/client_cli.c @@ -461,6 +461,14 @@ static int parse_args(Config* config, int argc, char* argv[], int* positional_ar return -1; free(config->compress_choice); config->compress_choice = dup; + if (strcmp(config->compress_choice, "zstd") == 0) + config->use_compression = true; + else if (strcmp(config->compress_choice, "none") == 0) + config->use_compression = false; + else { + fprintf(stderr, "Error: --compress-choice must be 'zstd' or 'none'\n"); + return -1; + } } else if (strcmp(argv[i], "--compress-level") == 0 && i + 1 < argc) { int val; if (!parse_positive_int(argv[++i], &val)) { diff --git a/src/client/client_send.c b/src/client/client_send.c index 620cb57..1d5468f 100644 --- a/src/client/client_send.c +++ b/src/client/client_send.c @@ -67,7 +67,8 @@ static int send_delete_manifest(int fd, ArrayList* manifest) { return 0; } -static int incremental_check(Client* client, File* file, DeltaSignature** out_sig) { +static int incremental_check(Client* client, File* file, const Config* config, + DeltaSignature** out_sig) { *out_sig = NULL; if (!send_status(client->file_descriptor, STATUS_CHECK)) return -1; @@ -79,6 +80,12 @@ static int incremental_check(Client* client, File* file, DeltaSignature** out_si return -1; if (!send_n_data(client->file_descriptor, &mtime, sizeof(mtime))) return -1; + if (config->checksum) { + uint64_t checksum; + if (!file_checksum(file, &checksum) || + !send_n_data(client->file_descriptor, &checksum, sizeof(checksum))) + return -1; + } Status s; if (!receive_status(client->file_descriptor, &s)) return -1; @@ -176,7 +183,7 @@ static int send_single_file(Client* client, File* file, Config* config, bool use // Incremental path: use sendfile for the actual data if enabled and no compression if (use_sendfile) { DeltaSignature* sig = NULL; - int rc = incremental_check(client, file, &sig); + int rc = incremental_check(client, file, config, &sig); if (rc == 1) { delta_signature_destroy(sig); return 1; @@ -202,7 +209,7 @@ static int send_single_file(Client* client, File* file, Config* config, bool use // Incremental path with single_calls (supports compression and delta) file_send_fn send_fn = (file_send_fn)file_send_single_calls; DeltaSignature* sig = NULL; - int rc = incremental_check(client, file, &sig); + int rc = incremental_check(client, file, config, &sig); if (rc < 0) { delta_signature_destroy(sig); return -1; diff --git a/src/server/server.c b/src/server/server.c index f8fdb54..704537b 100644 --- a/src/server/server.c +++ b/src/server/server.c @@ -39,7 +39,7 @@ int receive_files(Config* config, int fd) { if (file == NULL && !skipped) return -1; if (config->save_to_disk) - file_save_to_disk(config->receive_root_directory, file, NULL); + file_save_to_disk(config->receive_root_directory, file, config); file_destroy(file); } else if (status == STATUS_CHUNK) { Chunk* chunk = receive_chunk_data(fd, config); @@ -49,7 +49,7 @@ int receive_files(Config* config, int fd) { } for (int i = 0; i < chunk->element_count; i++) { if (config->save_to_disk) - file_save_to_disk(config->receive_root_directory, chunk->items[i], NULL); + file_save_to_disk(config->receive_root_directory, chunk->items[i], config); } chunk_destroy(chunk); } else if (status == STATUS_CHECK_BATCH) { @@ -88,7 +88,7 @@ int receive_files(Config* config, int fd) { return -1; } if (config->save_to_disk) - file_save_to_disk(config->receive_root_directory, file, NULL); + file_save_to_disk(config->receive_root_directory, file, config); file_destroy(file); } next: @@ -142,9 +142,12 @@ void handler(int file_descriptor) { close(file_descriptor); return; } - thrd_join(receiver, NULL); - thrd_join(writer, NULL); - send_status(file_descriptor, STATUS_OK); + int receiver_result; + int writer_result; + thrd_join(receiver, &receiver_result); + thrd_join(writer, &writer_result); + if (receiver_result == thrd_success && writer_result == thrd_success) + send_status(file_descriptor, STATUS_OK); pipeline_context_receiver_destroy(context); } else { receive_files(config, file_descriptor); diff --git a/src/shared/config.c b/src/shared/config.c index 6d52cc4..3ad4aa4 100644 --- a/src/shared/config.c +++ b/src/shared/config.c @@ -469,6 +469,13 @@ Config* config_receive(int file_descriptor) { config->compress_choice = receive_str(file_descriptor); if (config->compress_choice == NULL) goto error; + if (config->compress_choice[0] != '\0' && + strcmp(config->compress_choice, "zstd") != 0 && + strcmp(config->compress_choice, "none") != 0) { + fprintf(stderr, "Unsupported compression choice: %s\n", config->compress_choice); + send_status(file_descriptor, STATUS_ERROR); + goto error; + } config->address = NULL; config->bind_address = NULL; config->ipv6 = false; diff --git a/src/shared/config.h b/src/shared/config.h index 9c78505..986a5c4 100644 --- a/src/shared/config.h +++ b/src/shared/config.h @@ -128,7 +128,7 @@ typedef struct Config { char* compress_choice; } Config; -#define PROTOCOL_VERSION "1.3.0" +#define PROTOCOL_VERSION "2.2.0" #define DEFAULT_CHUNK_SIZE (10 * 1024 * 1024) Config* config_create(void); diff --git a/src/shared/delta.c b/src/shared/delta.c index 0305fdc..da03280 100644 --- a/src/shared/delta.c +++ b/src/shared/delta.c @@ -27,6 +27,10 @@ uint32_t delta_xxhash32(const void* data, uint32_t len) { return XXH32(data, len, 0); } +uint64_t delta_xxhash64(const void* data, size_t len) { + return XXH64(data, len, 0); +} + DeltaSignature* delta_signature_create(const void* old_file_data, uint64_t old_file_size, uint32_t block_size) { if (old_file_data == NULL || old_file_size == 0 || block_size == 0) diff --git a/src/shared/delta.h b/src/shared/delta.h index 96cf0a6..0d8ddb3 100644 --- a/src/shared/delta.h +++ b/src/shared/delta.h @@ -72,5 +72,6 @@ bool delta_is_worthwhile(const Delta* delta, uint64_t new_file_size); uint32_t delta_adler32(const void* data, uint32_t len); uint32_t delta_xxhash32(const void* data, uint32_t len); +uint64_t delta_xxhash64(const void* data, size_t len); #endif diff --git a/src/shared/file.c b/src/shared/file.c index 2b730f8..e6c14e0 100644 --- a/src/shared/file.c +++ b/src/shared/file.c @@ -21,6 +21,19 @@ #include "protocol.h" #include "utils.h" +bool file_checksum(File* file, uint64_t* checksum) { + if (!file || !checksum || !file->data) + return false; + if (file->data->size == 0) { + *checksum = delta_xxhash64("", 0); + return true; + } + if (!file->data->data && !file_load_data(file)) + return false; + *checksum = delta_xxhash64(file->data->data, file->data->size); + return true; +} + File* file_create(const char* path) { File* file = (File*)malloc(sizeof(File)); if (file == NULL) { @@ -421,12 +434,18 @@ File* receive_incremental_check(int fd, const Config* config, bool* skipped) { unsigned long long check_size; long long check_mtime; + uint64_t check_checksum = 0; if (!receive_n_data(fd, &check_size, sizeof(check_size)) || !receive_n_data(fd, &check_mtime, sizeof(check_mtime))) { free(check_path); send_status(fd, STATUS_ERROR); return NULL; } + if (config->checksum && !receive_n_data(fd, &check_checksum, sizeof(check_checksum))) { + free(check_path); + send_status(fd, STATUS_ERROR); + return NULL; + } if (has_path_traversal(check_path)) { log_message(LOG_LEVEL_ERROR, "Path traversal detected: %s", check_path); @@ -440,8 +459,17 @@ File* receive_incremental_check(int fd, const Config* config, bool* skipped) { bool has_old_file = (full_path && lstat(full_path, &st) == 0); unsigned long long old_size = has_old_file ? (unsigned long long)st.st_size : 0; - bool match = has_old_file && (unsigned long long)st.st_size == check_size && - (long long)st.st_mtime == check_mtime; + bool match = has_old_file && (unsigned long long)st.st_size == check_size; + if (match && config->checksum) { + void* old_data = old_size > 0 ? old_data_from_path(full_path, old_size) : NULL; + uint64_t old_checksum = old_size == 0 ? delta_xxhash64("", 0) : 0; + if (old_data) + old_checksum = delta_xxhash64(old_data, (size_t)old_size); + match = (old_size == 0 || old_data) && old_checksum == check_checksum; + free(old_data); + } else if (match) { + match = (long long)st.st_mtime == check_mtime; + } if (match) { if (!send_status(fd, STATUS_OK)) { @@ -664,6 +692,11 @@ File* file_receive(const Config* config, int file_descriptor) { char* path = receive_str(file_descriptor); if (path == NULL) return NULL; + if (path[0] == '\0' || has_path_traversal(path)) { + log_message(LOG_LEVEL_ERROR, "Invalid received file path: %s", path); + free(path); + return NULL; + } File* file = file_create(path); free(path); if (file == NULL) @@ -715,13 +748,20 @@ int receive_manifest(int fd, const Config* config, int* next_status) { int count; if (!receive_int(fd, &count)) return -1; + if (count < 0 || count > MAX_MANIFEST_ENTRIES) + return -1; ArrayList* manifest = array_list_create(free); - if (manifest) { - for (int i = 0; i < count; i++) { - char* s = receive_str(fd); - if (s) - array_list_add(manifest, s); + if (!manifest) + return -1; + for (int i = 0; i < count; i++) { + char* s = receive_str(fd); + if (!s || s[0] == '\0' || has_path_traversal(s) || !array_list_add(manifest, s)) { + free(s); + array_list_delete(manifest); + return -1; } + } + if (manifest) { fprintf(stderr, "Deleting files not in manifest...\n"); delete_extras(config->receive_root_directory, manifest); array_list_delete(manifest); diff --git a/src/shared/file.h b/src/shared/file.h index b0a1b4e..27cbadb 100644 --- a/src/shared/file.h +++ b/src/shared/file.h @@ -26,6 +26,7 @@ typedef struct { File* file_create(const char* path); void file_destroy(void* item); bool file_load_data(File* file); +bool file_checksum(File* file, uint64_t* checksum); File* file_receive(const Config* config, int file_descriptor); bool file_send_single_calls(File* file, int file_descriptor, bool use_metadata, int compression_level, bool send_path); diff --git a/src/shared/metadata.c b/src/shared/metadata.c index 4134c13..7f246df 100644 --- a/src/shared/metadata.c +++ b/src/shared/metadata.c @@ -105,11 +105,16 @@ FileMetadata* metadata_receive(int file_descriptor, int* ok) { *ok = 0; return NULL; } - if (!present) { + if (present == 0) { if (ok) *ok = 1; return NULL; } + if (present != 1) { + if (ok) + *ok = 0; + return NULL; + } FileMetadata* m = malloc(sizeof(FileMetadata)); if (m == NULL) { if (ok) @@ -156,6 +161,12 @@ FileMetadata* metadata_receive(int file_descriptor, int* ok) { return NULL; } m->mtime_nsec = (long)mtime_nsec; + if (mtime_nsec < 0 || mtime_nsec >= 1000000000LL || mode < 0 || uid < 0 || gid < 0) { + free(m); + if (ok) + *ok = 0; + return NULL; + } if (ok) *ok = 1; return m; diff --git a/src/shared/multiprocessing.c b/src/shared/multiprocessing.c index f5019a9..dcf5588 100644 --- a/src/shared/multiprocessing.c +++ b/src/shared/multiprocessing.c @@ -103,6 +103,7 @@ static bool receive_chunk_enqueue(int file_descriptor, PipelineContextReceiver* static void receiver_thread_fail(PipelineContextReceiver* context) { mtx_lock(&context->mutex); + context->cancelled = true; context->receiver_done = true; cnd_broadcast(&context->condition_not_empty); cnd_broadcast(&context->condition_not_full); diff --git a/src/shared/multiprocessing.h b/src/shared/multiprocessing.h index aa53a13..443720a 100644 --- a/src/shared/multiprocessing.h +++ b/src/shared/multiprocessing.h @@ -26,6 +26,7 @@ typedef struct { mtx_t mutex_progress; unsigned long long progress_bytes; bool sender_done; + bool cancelled; } PipelineContextSender; typedef struct PipelineContextReceiver { @@ -37,6 +38,7 @@ typedef struct PipelineContextReceiver { cnd_t condition_not_full; cnd_t condition_not_empty; bool receiver_done; + bool cancelled; } PipelineContextReceiver; PipelineContextSender* pipeline_context_sender_create(Config* config, Queue* queue_scanner, diff --git a/src/shared/protocol.c b/src/shared/protocol.c index 0b32f96..4a8ff98 100644 --- a/src/shared/protocol.c +++ b/src/shared/protocol.c @@ -1,6 +1,7 @@ #include "protocol.h" #include "log.h" #include +#include #include #include #include @@ -75,6 +76,17 @@ static int io_fd(int dir_fd, int file_descriptor) { return (dir_fd != -1) ? dir_fd : file_descriptor; } +static int deadline_remaining_ms(const struct timespec* deadline) { + struct timespec now; + clock_gettime(CLOCK_MONOTONIC, &now); + long long ns = (long long)(deadline->tv_sec - now.tv_sec) * 1000000000LL + + deadline->tv_nsec - now.tv_nsec; + if (ns <= 0) + return 0; + long long ms = (ns + 999999) / 1000000; + return ms > INT_MAX ? INT_MAX : (int)ms; +} + bool send_n_data(int file_descriptor, const void* data, size_t data_size) { log_message(LOG_LEVEL_DEBUG, " Sending n Data: %zu", data_size); int fd = io_fd(io_write_fd, file_descriptor); @@ -114,13 +126,19 @@ bool receive_n_data(int file_descriptor, void* data, size_t data_size) { size_t total_bytes_received = 0; while (total_bytes_received < data_size) { - struct timespec now; - clock_gettime(CLOCK_MONOTONIC, &now); - if (now.tv_sec > deadline.tv_sec || - (now.tv_sec == deadline.tv_sec && now.tv_nsec > deadline.tv_nsec)) { + struct pollfd pfd = {.fd = fd, .events = POLLIN}; + int poll_result = poll(&pfd, 1, deadline_remaining_ms(&deadline)); + if (poll_result == 0) { log_message(LOG_LEVEL_ERROR, "Receive timeout after %ds", RECEIVE_TIMEOUT_SEC); return false; } + if (poll_result < 0) { + if (errno == EINTR) + continue; + return false; + } + if (pfd.revents & (POLLERR | POLLNVAL)) + return false; ssize_t bytes_received; if (io_ssl) diff --git a/src/shared/protocol.h b/src/shared/protocol.h index 9173fd3..d432503 100644 --- a/src/shared/protocol.h +++ b/src/shared/protocol.h @@ -13,6 +13,7 @@ /* Maximum chunk size (64 MB) — prevents unbounded allocation from the wire */ #define MAX_CHUNK_SIZE (64ULL * 1024 * 1024) +#define MAX_MANIFEST_ENTRIES (1024 * 1024) typedef struct ssl_st SSL; diff --git a/src/shared/queue.h b/src/shared/queue.h index 8bd6ba4..3d03039 100644 --- a/src/shared/queue.h +++ b/src/shared/queue.h @@ -20,6 +20,9 @@ bool queue_is_full(const Queue* queue); bool queue_enqueue(Queue* queue, void* item); bool queue_enqueue_multithreaded(Queue* queue, void* item, mtx_t* mutex, cnd_t* condition_not_empty, cnd_t* condition_not_full); +bool queue_enqueue_multithreaded_cancel(Queue* queue, void* item, mtx_t* mutex, + cnd_t* condition_not_empty, cnd_t* condition_not_full, + const bool* cancelled); void* queue_dequeue(Queue* queue); void* queue_dequeue_multithreaded(Queue* queue, mtx_t* mutex, cnd_t* condition_not_empty, cnd_t* condition_not_full, const bool* other_thread_done); diff --git a/tests/integration/test_features.py b/tests/integration/test_features.py index 63a5bf6..9635fc7 100644 --- a/tests/integration/test_features.py +++ b/tests/integration/test_features.py @@ -193,6 +193,26 @@ class TestIncremental: content = f.read() assert b"modified content" in content, f"Modified content not transferred: {content[:50]}" + def test_checksum_detects_same_size_and_mtime_change(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"different!\n") + os.utime(received_file, (source_stat.st_atime, source_stat.st_mtime)) + + result, _ = run_client(SOURCE_DIR, DEST_DIR, + flags=["-M", "--incremental", "--checksum"], + port=shared_server.port) + assert result.returncode == 0, f"Checksum sync failed: {result.stderr[:200]}" + 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): diff --git a/tests/test_delta.c b/tests/test_delta.c index 09a6467..7030ff2 100644 --- a/tests/test_delta.c +++ b/tests/test_delta.c @@ -35,6 +35,10 @@ static void test_xxhash32_different_data() { EXPECT_TRUE(ha != hb); } +static void test_xxhash64_different_data() { + EXPECT_TRUE(delta_xxhash64("AAAA", 4) != delta_xxhash64("BBBB", 4)); +} + static void test_signature_roundtrip() { char old_data[4096]; for (int i = 0; i < 4096; i++) @@ -328,6 +332,7 @@ void test_delta() { test_adler32_different_data(); test_xxhash32_basic(); test_xxhash32_different_data(); + test_xxhash64_different_data(); test_signature_roundtrip(); test_delta_identical_files(); test_delta_small_edit(); diff --git a/tests/test_metadata.c b/tests/test_metadata.c index 6a27660..e73e922 100644 --- a/tests/test_metadata.c +++ b/tests/test_metadata.c @@ -109,6 +109,19 @@ static void test_metadata_send_null() { close(p[1]); } +static void test_metadata_rejects_invalid_values() { + int p[2]; + EXPECT_EQ_INT(pipe(p), 0); + io_set_fds(p[0], p[1]); + int32_t present = 2; + EXPECT_TRUE(send_n_data(p[1], &present, sizeof(present))); + int ok = 1; + EXPECT_NULL(metadata_receive(p[0], &ok)); + EXPECT_EQ_INT(ok, 0); + close(p[0]); + close(p[1]); +} + static void test_file_restore_metadata() { const char* path = "temp_meta_restore_test.txt"; const char* content = "test content"; @@ -137,5 +150,6 @@ void test_metadata() { test_metadata_from_buf_null(); test_metadata_send_receive_roundtrip(); test_metadata_send_null(); + test_metadata_rejects_invalid_values(); test_file_restore_metadata(); } diff --git a/tests/test_server.c b/tests/test_server.c index 9226e00..38c99d9 100644 --- a/tests/test_server.c +++ b/tests/test_server.c @@ -171,10 +171,26 @@ static void test_receive_files_abort() { } } +static void test_receive_manifest_rejects_traversal() { + Config* cfg = config_create(); + EXPECT_NOT_NULL(cfg); + cfg->receive_root_directory = str_dup("/tmp/dst"); + int p[2]; + EXPECT_EQ_INT(socketpair(AF_UNIX, SOCK_STREAM, 0, p), 0); + io_set_fds(p[0], p[1]); + EXPECT_TRUE(send_int(p[1], 1)); + EXPECT_TRUE(send_str(p[1], "../outside")); + EXPECT_EQ_INT(receive_manifest(p[0], cfg, NULL), -1); + close(p[0]); + close(p[1]); + config_delete(cfg); +} + void test_server() { if (!is_running_under_valgrind()) { test_receive_files_finished(); test_receive_files_single_file(); test_receive_files_abort(); + test_receive_manifest_rejects_traversal(); } } From 2ce4f9fbf61ab46ca8098952b64d5e1d29cdb4ce Mon Sep 17 00:00:00 2001 From: TapTap Date: Sat, 8 Aug 2026 20:27:44 +0200 Subject: [PATCH 03/16] style: format protocol deadline calculation --- src/shared/protocol.c | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/shared/protocol.c b/src/shared/protocol.c index 4a8ff98..0528b6c 100644 --- a/src/shared/protocol.c +++ b/src/shared/protocol.c @@ -79,8 +79,8 @@ static int io_fd(int dir_fd, int file_descriptor) { static int deadline_remaining_ms(const struct timespec* deadline) { struct timespec now; clock_gettime(CLOCK_MONOTONIC, &now); - long long ns = (long long)(deadline->tv_sec - now.tv_sec) * 1000000000LL + - deadline->tv_nsec - now.tv_nsec; + long long ns = + (long long)(deadline->tv_sec - now.tv_sec) * 1000000000LL + deadline->tv_nsec - now.tv_nsec; if (ns <= 0) return 0; long long ms = (ns + 999999) / 1000000; From 4534284fe917d3f626a0ed0cc0838ca089e1e893 Mon Sep 17 00:00:00 2001 From: TapTap Date: Sat, 8 Aug 2026 20:35:29 +0200 Subject: [PATCH 04/16] fix: complete triage security and transfer remediation --- .gitea/workflows/ci.yaml | 9 ++- src/client/client_cli.c | 6 ++ src/server/server.c | 71 ++++++++++++++++++++++- src/shared/file.c | 112 +++++++++++++++++++++++++++++++++++++ src/shared/queue.h | 54 +++++++++--------- src/shared/transport_tcp.c | 3 + src/shared/transport_tls.c | 2 +- tests/test_file.c | 23 ++++++++ 8 files changed, 248 insertions(+), 32 deletions(-) diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml index 82cd6af..00c64c0 100644 --- a/.gitea/workflows/ci.yaml +++ b/.gitea/workflows/ci.yaml @@ -2,7 +2,7 @@ name: CI on: push: - branches: [main] + branches: [main, dev] pull_request: jobs: @@ -73,6 +73,13 @@ jobs: - name: Build fuzz targets run: cmake --build build-fuzz -j$(nproc) + - name: Smoke fuzz targets + run: | + for target in build-fuzz/fuzz_*; do + [ -x "$target" ] || continue + timeout 10s "$target" -runs=100 -max_total_time=5 + done + coverage: runs-on: ubuntu-latest container: gitea.tap-tap.win/taptap/fastsync-ci:v9 diff --git a/src/client/client_cli.c b/src/client/client_cli.c index a144c25..2b63c47 100644 --- a/src/client/client_cli.c +++ b/src/client/client_cli.c @@ -525,6 +525,12 @@ static bool validate_config(const Config* config) { fprintf(stderr, "Error: --delta cannot be combined with -f (sendfile)\n"); return false; } + if (config->append || config->append_verify) { + fprintf( + stderr, + "Error: --append and --append-verify are not supported yet; refusing to ignore option\n"); + return false; + } if (config->use_tls) { if (!config->tls_cert || !config->tls_key) { fprintf(stderr, "Error: --tls requires --cert and --key\n"); diff --git a/src/server/server.c b/src/server/server.c index 704537b..416b49e 100644 --- a/src/server/server.c +++ b/src/server/server.c @@ -15,6 +15,25 @@ #include #include #include +#include +#include +#include + +static char* authorized_root; +static bool allow_delete; + +static bool path_is_within(const char* root, const char* path) { + size_t n = strlen(root); + return strncmp(root, path, n) == 0 && (path[n] == '\0' || path[n] == '/'); +} + +static bool __attribute__((unused)) configure_authorization(const char* root) { + char resolved[PATH_MAX]; + if (!root || !realpath(root, resolved)) + return false; + authorized_root = str_dup(resolved); + return authorized_root != NULL; +} int receive_files(Config* config, int fd) { Status status; @@ -119,6 +138,36 @@ void handler(int file_descriptor) { close(file_descriptor); return; } + if (!authorized_root) { + log_message(LOG_LEVEL_ERROR, "No server-side destination root configured"); + config_delete(config); + close(file_descriptor); + return; + } + char resolved_destination[PATH_MAX]; + char* canonical_destination = realpath(config->receive_root_directory, NULL); + const char* destination = + canonical_destination ? canonical_destination : config->receive_root_directory; + if (has_path_traversal(destination) || !path_is_within(authorized_root, destination)) { + log_message(LOG_LEVEL_ERROR, "Rejected destination outside authorized root"); + free(canonical_destination); + config_delete(config); + close(file_descriptor); + return; + } + if (canonical_destination) + snprintf(resolved_destination, sizeof(resolved_destination), "%s", canonical_destination); + else + snprintf(resolved_destination, sizeof(resolved_destination), "%s", destination); + free(canonical_destination); + free(config->receive_root_directory); + config->receive_root_directory = str_dup(resolved_destination); + if (!config->receive_root_directory) { + config_delete(config); + close(file_descriptor); + return; + } + config->use_delete = config->use_delete && allow_delete; if (config->use_multithreading) { Queue* q = queue_create(100, file_destroy); if (q == NULL) { @@ -178,6 +227,8 @@ static void print_server_usage(void) { printf(" --cert TLS certificate file (PEM)\n"); printf(" --key TLS private key file (PEM)\n"); printf(" --ca TLS CA certificate file (PEM)\n"); + printf(" --destination-root Authorized destination root (default: .)\n"); + printf(" --allow-delete Permit manifest deletion\n"); printf(" -v, --verbose Enable debug logging\n"); printf(" --help Show this help\n"); } @@ -188,6 +239,8 @@ int main(int argc, char* argv[]) { char* tls_key = NULL; char* tls_ca = NULL; int port = 8080; + const char* destination_root = "."; + bool stdio_mode = false; signal(SIGPIPE, SIG_IGN); for (int i = 1; i < argc; i++) { @@ -195,9 +248,7 @@ int main(int argc, char* argv[]) { print_server_usage(); return 0; } else if (strcmp(argv[i], "--stdio") == 0) { - io_set_fds(STDIN_FILENO, STDOUT_FILENO); - handler(STDIN_FILENO); - return 0; + stdio_mode = true; } else if (strcmp(argv[i], "-v") == 0 || strcmp(argv[i], "--verbose") == 0) { set_log_level(LOG_LEVEL_DEBUG); } else if (strcmp(argv[i], "--tls") == 0) { @@ -208,6 +259,10 @@ int main(int argc, char* argv[]) { tls_key = argv[++i]; } else if (strcmp(argv[i], "--ca") == 0 && i + 1 < argc) { tls_ca = argv[++i]; + } else if (strcmp(argv[i], "--destination-root") == 0 && i + 1 < argc) { + destination_root = argv[++i]; + } else if (strcmp(argv[i], "--allow-delete") == 0) { + allow_delete = true; } else if (strcmp(argv[i], "-p") == 0 && i + 1 < argc) { char* end; long p = strtol(argv[++i], &end, 10); @@ -229,6 +284,16 @@ int main(int argc, char* argv[]) { signal(SIGINT, cleanup); signal(SIGTERM, cleanup); + if (!configure_authorization(destination_root)) { + fprintf(stderr, "Error: invalid destination root '%s'\n", destination_root); + return 1; + } + if (stdio_mode) { + io_set_fds(STDIN_FILENO, STDOUT_FILENO); + handler(STDIN_FILENO); + free(authorized_root); + return 0; + } g_server = server_create(port); if (g_server == NULL) { log_message(LOG_LEVEL_ERROR, "Failed to create server"); diff --git a/src/shared/file.c b/src/shared/file.c index e6c14e0..f30adca 100644 --- a/src/shared/file.c +++ b/src/shared/file.c @@ -2,6 +2,7 @@ #include #include #include +#include #include #include #include @@ -175,6 +176,17 @@ bool file_save_to_disk(const char* root_directory, File* file, const Config* con return false; } + /* --update is receiver-side policy: never replace a newer destination. */ + if (config && config->update) { + struct stat destination_stat; + if (stat(disk_path, &destination_stat) == 0 && file->metadata && + destination_stat.st_mtime > file->metadata->mtime_sec) { + free(resolved_root); + free(disk_path); + return true; + } + } + if (backup_enabled) { struct stat backup_stat; if (stat(disk_path, &backup_stat) == 0) { @@ -546,8 +558,108 @@ File* receive_incremental_check(int fd, const Config* config, bool* skipped) { return file; } +static int open_secure_parent(const char* path, char** leaf_out) { + char* copy = str_dup(path); + if (!copy) + return -1; + char* parent = dirname(copy); + const char* slash = strrchr(path, '/'); + char* leaf = str_dup(slash ? slash + 1 : path); + if (!leaf) { + free(copy); + return -1; + } + int fd = (parent[0] == '/') ? open("/", O_RDONLY | O_DIRECTORY | O_CLOEXEC) + : open(".", O_RDONLY | O_DIRECTORY | O_CLOEXEC); + if (fd < 0) { + free(copy); + free(leaf); + return -1; + } + char* save = NULL; + char* component = strtok_r(parent, "/", &save); + while (component) { + if (strcmp(component, ".") != 0 && strcmp(component, "..") != 0) { + int next = openat(fd, component, O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC); + if (next < 0 && errno == ENOENT && mkdirat(fd, component, 0755) == 0) + next = openat(fd, component, O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC); + if (next < 0) { + close(fd); + free(copy); + free(leaf); + return -1; + } + close(fd); + fd = next; + } + component = strtok_r(NULL, "/", &save); + } + free(copy); + *leaf_out = leaf; + return fd; +} + +static bool write_all(int fd, const void* data, unsigned long long size) { + const unsigned char* p = data; + unsigned long long done = 0; + while (done < size) { + ssize_t n = write(fd, p + done, (size_t)(size - done)); + if (n < 0 && errno == EINTR) + continue; + if (n <= 0) + return false; + done += (unsigned long long)n; + } + return true; +} + +static bool to_disk_secure(const char* path, const void* data, unsigned long long data_size, + bool inplace, bool sparse) { + char* leaf = NULL; + int dirfd = open_secure_parent(path, &leaf); + if (dirfd < 0) + return false; + int fd = -1; + bool ok = false; + if (inplace) { + fd = openat(dirfd, leaf, O_WRONLY | O_CREAT | O_TRUNC | O_CLOEXEC | O_NOFOLLOW, 0644); + if (fd >= 0) { + if (!sparse || data_size == 0 || ftruncate(fd, (off_t)data_size) == 0) + ok = write_all(fd, data, data_size); + } + } else { + char tmp[NAME_MAX]; + for (unsigned int i = 0; i < 100 && !ok; ++i) { + snprintf(tmp, sizeof(tmp), ".%s.tmp.%ld.%u", leaf, (long)getpid(), i); + fd = openat(dirfd, tmp, O_WRONLY | O_CREAT | O_EXCL | O_CLOEXEC | O_NOFOLLOW, 0600); + if (fd < 0) + continue; + if (sparse && data_size > 0) + ok = ftruncate(fd, (off_t)data_size) == 0; + if (ok || (!sparse || data_size == 0)) + ok = write_all(fd, data, data_size); + if (close(fd) != 0) + ok = false; + fd = -1; + if (ok && renameat(dirfd, tmp, dirfd, leaf) != 0) + ok = false; + if (!ok) + unlinkat(dirfd, tmp, 0); + } + } + if (fd >= 0) + close(fd); + close(dirfd); + free(leaf); + return ok; +} + bool 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 to_disk_secure(path, data, data_size, inplace, sparse); + /* Kept below only as historical context; all writes use descriptor-relative operations. */ char* tmp_path = NULL; char* directory = NULL; diff --git a/src/shared/queue.h b/src/shared/queue.h index 3d03039..c72659d 100644 --- a/src/shared/queue.h +++ b/src/shared/queue.h @@ -1,30 +1,30 @@ -#ifndef QUEUE_H -#define QUEUE_H - -#include -#include - -typedef struct Queue { - void** items; - int front; - int rear; - int size; - int capacity; - void (*item_destroyer)(void* item); -} Queue; - -Queue* queue_create(int capacity, void (*destroyer)(void* item)); -void queue_destroy(Queue* queue); -bool queue_is_empty(const Queue* queue); -bool queue_is_full(const Queue* queue); -bool queue_enqueue(Queue* queue, void* item); -bool queue_enqueue_multithreaded(Queue* queue, void* item, mtx_t* mutex, cnd_t* condition_not_empty, - cnd_t* condition_not_full); +#ifndef QUEUE_H +#define QUEUE_H + +#include +#include + +typedef struct Queue { + void** items; + int front; + int rear; + int size; + int capacity; + void (*item_destroyer)(void* item); +} Queue; + +Queue* queue_create(int capacity, void (*destroyer)(void* item)); +void queue_destroy(Queue* queue); +bool queue_is_empty(const Queue* queue); +bool queue_is_full(const Queue* queue); +bool queue_enqueue(Queue* queue, void* item); +bool queue_enqueue_multithreaded(Queue* queue, void* item, mtx_t* mutex, cnd_t* condition_not_empty, + cnd_t* condition_not_full); bool queue_enqueue_multithreaded_cancel(Queue* queue, void* item, mtx_t* mutex, cnd_t* condition_not_empty, cnd_t* condition_not_full, const bool* cancelled); -void* queue_dequeue(Queue* queue); -void* queue_dequeue_multithreaded(Queue* queue, mtx_t* mutex, cnd_t* condition_not_empty, - cnd_t* condition_not_full, const bool* other_thread_done); - -#endif +void* queue_dequeue(Queue* queue); +void* queue_dequeue_multithreaded(Queue* queue, mtx_t* mutex, cnd_t* condition_not_empty, + cnd_t* condition_not_full, const bool* other_thread_done); + +#endif diff --git a/src/shared/transport_tcp.c b/src/shared/transport_tcp.c index 93b5aec..49c2fb9 100644 --- a/src/shared/transport_tcp.c +++ b/src/shared/transport_tcp.c @@ -15,6 +15,8 @@ static volatile sig_atomic_t g_active_connections = 0; +static void tcp_apply_socket_timeout(int fd); + static void sigchld_handler(int sig) { (void)sig; int saved_errno = errno; @@ -93,6 +95,7 @@ static void accept_loop(Server* server, void (*child_fn)(int, void*), void* chil perror("Could not accept the connection"); continue; } + tcp_apply_socket_timeout(fd); if ((unsigned int)g_active_connections >= server->max_connections) { log_message(LOG_LEVEL_WARNING, "Max connections (%u) reached, rejecting", server->max_connections); diff --git a/src/shared/transport_tls.c b/src/shared/transport_tls.c index 704b6ff..49fc554 100644 --- a/src/shared/transport_tls.c +++ b/src/shared/transport_tls.c @@ -71,7 +71,7 @@ static SSL_CTX* create_ssl_ctx(bool is_server, const char* cert, const char* key SSL_CTX_free(ctx); return NULL; } - SSL_CTX_set_verify(ctx, SSL_VERIFY_PEER, NULL); + SSL_CTX_set_verify(ctx, SSL_VERIFY_PEER | SSL_VERIFY_FAIL_IF_NO_PEER_CERT, NULL); SSL_CTX_set_verify_depth(ctx, 4); } else { SSL_CTX_set_verify(ctx, SSL_VERIFY_NONE, NULL); diff --git a/tests/test_file.c b/tests/test_file.c index c9f4766..f8a3337 100644 --- a/tests/test_file.c +++ b/tests/test_file.c @@ -126,6 +126,28 @@ static void test_to_disk_creates_dirs() { rmdir("test_nested_tmp"); } +static void test_to_disk_does_not_follow_symlink() { + const char* outside = "test_to_disk_outside.txt"; + const char* link = "test_to_disk_link.txt"; + const char* content = "confined"; + unlink(outside); + unlink(link); + EXPECT_TRUE(to_disk(outside, "outside", 7, false, false)); + EXPECT_EQ_INT(symlink(outside, link), 0); + EXPECT_TRUE(to_disk(link, content, strlen(content), false, false)); + FILE* fp = fopen(outside, "rb"); + char buf[16] = {0}; + EXPECT_NOT_NULL(fp); + if (fp) { + size_t read_count = fread(buf, 1, sizeof(buf) - 1, fp); + EXPECT_TRUE(read_count <= sizeof(buf) - 1); + fclose(fp); + } + EXPECT_EQ_STR(buf, "outside"); + unlink(outside); + unlink(link); +} + static void test_file_content_to_buffer() { const char* content = "Buffer content test"; EXPECT_TRUE(to_disk("test_buffer_file.txt", content, strlen(content), false, false)); @@ -434,6 +456,7 @@ void test_file() { test_file_save_to_disk(); test_to_disk_basic(); test_to_disk_creates_dirs(); + test_to_disk_does_not_follow_symlink(); test_file_content_to_buffer(); test_file_save_to_disk_path_traversal(); test_file_save_to_disk_deep_traversal(); From 9985a8f6a4eaea8c9789c6b2e9612f88faa0eb3c Mon Sep 17 00:00:00 2001 From: TapTap Date: Sat, 8 Aug 2026 20:35:46 +0200 Subject: [PATCH 05/16] style: format config validation --- src/shared/config.c | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/shared/config.c b/src/shared/config.c index 3ad4aa4..319af5b 100644 --- a/src/shared/config.c +++ b/src/shared/config.c @@ -469,8 +469,7 @@ Config* config_receive(int file_descriptor) { config->compress_choice = receive_str(file_descriptor); if (config->compress_choice == NULL) goto error; - if (config->compress_choice[0] != '\0' && - strcmp(config->compress_choice, "zstd") != 0 && + if (config->compress_choice[0] != '\0' && strcmp(config->compress_choice, "zstd") != 0 && strcmp(config->compress_choice, "none") != 0) { fprintf(stderr, "Unsupported compression choice: %s\n", config->compress_choice); send_status(file_descriptor, STATUS_ERROR); From a6ec2018009f67aa21801640a3a1ac21c24ef27e Mon Sep 17 00:00:00 2001 From: TapTap Date: Sat, 8 Aug 2026 20:40:13 +0200 Subject: [PATCH 06/16] fix: satisfy static analysis in regression paths --- src/shared/file.c | 8 +++----- tests/test_file.c | 10 +++++----- tests/test_multiprocessing.c | 4 ++-- 3 files changed, 10 insertions(+), 12 deletions(-) diff --git a/src/shared/file.c b/src/shared/file.c index f30adca..541128c 100644 --- a/src/shared/file.c +++ b/src/shared/file.c @@ -873,11 +873,9 @@ int receive_manifest(int fd, const Config* config, int* next_status) { return -1; } } - if (manifest) { - fprintf(stderr, "Deleting files not in manifest...\n"); - delete_extras(config->receive_root_directory, manifest); - array_list_delete(manifest); - } + fprintf(stderr, "Deleting files not in manifest...\n"); + delete_extras(config->receive_root_directory, manifest); + array_list_delete(manifest); if (!receive_status(fd, next_status)) return -1; return 0; diff --git a/tests/test_file.c b/tests/test_file.c index f8a3337..11e0780 100644 --- a/tests/test_file.c +++ b/tests/test_file.c @@ -138,11 +138,11 @@ static void test_to_disk_does_not_follow_symlink() { FILE* fp = fopen(outside, "rb"); char buf[16] = {0}; EXPECT_NOT_NULL(fp); - if (fp) { - size_t read_count = fread(buf, 1, sizeof(buf) - 1, fp); - EXPECT_TRUE(read_count <= sizeof(buf) - 1); - fclose(fp); - } + if (!fp) + return; + size_t read_count = fread(buf, 1, sizeof(buf) - 1, fp); + EXPECT_TRUE(read_count <= sizeof(buf) - 1); + fclose(fp); EXPECT_EQ_STR(buf, "outside"); unlink(outside); unlink(link); diff --git a/tests/test_multiprocessing.c b/tests/test_multiprocessing.c index b023d90..253ef06 100644 --- a/tests/test_multiprocessing.c +++ b/tests/test_multiprocessing.c @@ -91,8 +91,8 @@ static void test_sender_zero_capacity() { cfg->send_directory = str_dup("/src"); cfg->receive_root_directory = str_dup("/dst"); - Queue* q1 = queue_create(0, NULL); - Queue* q2 = queue_create(0, NULL); + Queue* const q1 = queue_create(0, NULL); + Queue* const q2 = queue_create(0, NULL); EXPECT_NULL(q1); EXPECT_NULL(q2); config_delete(cfg); From 33e416324f0621a849fb13bb8d49132907b185cd Mon Sep 17 00:00:00 2001 From: TapTap Date: Sat, 8 Aug 2026 20:43:53 +0200 Subject: [PATCH 07/16] test: annotate intentional static-analysis guards --- tests/test_file.c | 1 + tests/test_multiprocessing.c | 2 ++ 2 files changed, 3 insertions(+) diff --git a/tests/test_file.c b/tests/test_file.c index 11e0780..d867b4f 100644 --- a/tests/test_file.c +++ b/tests/test_file.c @@ -138,6 +138,7 @@ static void test_to_disk_does_not_follow_symlink() { FILE* fp = fopen(outside, "rb"); char buf[16] = {0}; EXPECT_NOT_NULL(fp); + // cppcheck-suppress knownConditionTrueFalse if (!fp) return; size_t read_count = fread(buf, 1, sizeof(buf) - 1, fp); diff --git a/tests/test_multiprocessing.c b/tests/test_multiprocessing.c index 253ef06..fdb38ba 100644 --- a/tests/test_multiprocessing.c +++ b/tests/test_multiprocessing.c @@ -91,7 +91,9 @@ static void test_sender_zero_capacity() { cfg->send_directory = str_dup("/src"); cfg->receive_root_directory = str_dup("/dst"); + // cppcheck-suppress constVariablePointer Queue* const q1 = queue_create(0, NULL); + // cppcheck-suppress constVariablePointer Queue* const q2 = queue_create(0, NULL); EXPECT_NULL(q1); EXPECT_NULL(q2); From d64666d4560e0a61252cd6a8174ce8de84cfd147 Mon Sep 17 00:00:00 2001 From: TapTap Date: Sun, 9 Aug 2026 11:52:15 +0200 Subject: [PATCH 08/16] fix: close remaining PR 208 security gaps --- src/client/client_send.c | 42 ++++++++++++-- src/server/server.c | 35 +++++++++--- src/shared/file.c | 90 +++++++++++++++++++++++++----- src/shared/metadata.c | 14 +++++ src/shared/metadata.h | 1 + src/shared/multiprocessing.c | 21 +++++-- src/shared/multiprocessing.h | 5 +- src/shared/protocol.c | 39 +++++++++++-- src/shared/protocol.h | 2 + src/shared/queue.c | 10 ++-- src/shared/queue.h | 3 +- src/shared/utils.c | 38 ++++++++----- tests/integration/test_features.py | 9 ++- tests/integration/test_ssh.py | 73 +++++++++++------------- 14 files changed, 276 insertions(+), 106 deletions(-) diff --git a/src/client/client_send.c b/src/client/client_send.c index c307107..21b32b8 100644 --- a/src/client/client_send.c +++ b/src/client/client_send.c @@ -386,6 +386,19 @@ static int scan_directory_multithreaded(void* pipeline_context) { context->config->checksum); Chunk* current_chunk; + if (scanner == NULL) { + log_message(LOG_LEVEL_ERROR, "Failed to create parallel scanner"); + atomic_store(&context->cancelled, true); + cnd_broadcast(&context->condition_not_full_scanner); + cnd_broadcast(&context->condition_not_empty_scanner); + cnd_broadcast(&context->condition_not_full_loader); + cnd_broadcast(&context->condition_not_empty_loader); + mtx_lock(&context->mutex_scanner); + context->scanner_done = true; + cnd_broadcast(&context->condition_not_empty_scanner); + mtx_unlock(&context->mutex_scanner); + return thrd_error; + } while ((current_chunk = parallel_scanner_next(scanner)) != NULL) { if (context->config->use_delete) { mtx_lock(&context->mutex_scanner); @@ -397,13 +410,22 @@ static int scan_directory_multithreaded(void* pipeline_context) { if (!manifest_entry) { log_message(LOG_LEVEL_ERROR, "Failed to allocate manifest entry"); mtx_unlock(&context->mutex_scanner); - context->cancelled = true; + atomic_store(&context->cancelled, true); cnd_broadcast(&context->condition_not_full_scanner); cnd_broadcast(&context->condition_not_empty_scanner); parallel_scanner_destroy(scanner); return thrd_error; } - array_list_add(context->manifest, manifest_entry); + if (!array_list_add(context->manifest, manifest_entry)) { + free(manifest_entry); + atomic_store(&context->cancelled, true); + cnd_broadcast(&context->condition_not_full_scanner); + cnd_broadcast(&context->condition_not_empty_scanner); + mtx_unlock(&context->mutex_scanner); + chunk_destroy(current_chunk); + parallel_scanner_destroy(scanner); + return thrd_error; + } } mtx_unlock(&context->mutex_scanner); } @@ -412,7 +434,7 @@ static int scan_directory_multithreaded(void* pipeline_context) { &context->condition_not_empty_scanner, &context->condition_not_full_scanner, &context->cancelled)) { chunk_destroy(current_chunk); - context->cancelled = true; + atomic_store(&context->cancelled, true); cnd_broadcast(&context->condition_not_full_scanner); cnd_broadcast(&context->condition_not_empty_scanner); parallel_scanner_destroy(scanner); @@ -458,7 +480,7 @@ static int load_files_multithreaded(void* pipeline_context) { &context->condition_not_full_loader, &context->cancelled)) { chunk_destroy(chunk); - context->cancelled = true; + atomic_store(&context->cancelled, true); cnd_broadcast(&context->condition_not_full_loader); cnd_broadcast(&context->condition_not_empty_loader); return thrd_error; @@ -573,7 +595,15 @@ int send_files(Config* config) { client_delete(client); return 1; } - array_list_add(manifest, manifest_entry); + if (!array_list_add(manifest, manifest_entry)) { + free(manifest_entry); + chunk_destroy(current_chunk); + array_list_delete(manifest); + directory_scanner_destroy(scanner); + client_disconnect(client); + client_delete(client); + return 1; + } } } if (!config->use_sendfile) { @@ -685,7 +715,7 @@ int send_files_multithreaded(Config* config) { if (!scanner_created || !loader_created || !sender_created) { perror("Error creating threads.\n"); - context->cancelled = true; + atomic_store(&context->cancelled, true); context->scanner_done = true; context->loader_done = true; context->sender_done = true; diff --git a/src/server/server.c b/src/server/server.c index fe5f3f4..2cc98f0 100644 --- a/src/server/server.c +++ b/src/server/server.c @@ -57,8 +57,12 @@ int receive_files(Config* config, int fd) { goto next; if (file == NULL && !skipped) return -1; - if (config->save_to_disk) - file_save_to_disk(config->receive_root_directory, file, config); + if (config->save_to_disk && + !file_save_to_disk(config->receive_root_directory, file, config)) { + file_destroy(file); + send_status(fd, STATUS_ERROR); + return -1; + } file_destroy(file); } else if (status == STATUS_CHUNK) { Chunk* chunk = receive_chunk_data(fd, config); @@ -67,13 +71,19 @@ int receive_files(Config* config, int fd) { return -1; } for (int i = 0; i < chunk->element_count; i++) { - if (config->save_to_disk) - file_save_to_disk(config->receive_root_directory, chunk->items[i], config); + if (config->save_to_disk && + !file_save_to_disk(config->receive_root_directory, chunk->items[i], config)) { + chunk_destroy(chunk); + send_status(fd, STATUS_ERROR); + return -1; + } } chunk_destroy(chunk); } else if (status == STATUS_CHECK_BATCH) { int count; - if (!receive_int(fd, &count)) + /* Batch framing has no checksum field yet; never silently downgrade a + checksum-enabled transfer into mtime-only matching. */ + if (config->checksum || !receive_int(fd, &count) || count < 0 || count > MAX_MANIFEST_ENTRIES) return -1; for (int i = 0; i < count; i++) { char* check_path = receive_str(fd); @@ -106,8 +116,12 @@ int receive_files(Config* config, int fd) { send_status(fd, STATUS_ERROR); return -1; } - if (config->save_to_disk) - file_save_to_disk(config->receive_root_directory, file, config); + if (config->save_to_disk && + !file_save_to_disk(config->receive_root_directory, file, config)) { + file_destroy(file); + send_status(fd, STATUS_ERROR); + return -1; + } file_destroy(file); } next: @@ -193,7 +207,7 @@ void handler(int file_descriptor) { perror("Error creating Threads"); if (receiver_created) { mtx_lock(&context->mutex); - context->cancelled = true; + atomic_store(&context->cancelled, true); cnd_broadcast(&context->condition_not_full); cnd_broadcast(&context->condition_not_empty); mtx_unlock(&context->mutex); @@ -213,9 +227,12 @@ void handler(int file_descriptor) { thrd_join(writer, &writer_result); if (receiver_result == thrd_success && writer_result == thrd_success) send_status(file_descriptor, STATUS_OK); + else + send_status(file_descriptor, STATUS_ERROR); pipeline_context_receiver_destroy(context); } else { - receive_files(config, file_descriptor); + if (receive_files(config, file_descriptor) != 0) + log_message(LOG_LEVEL_ERROR, "Transfer failed"); config_delete(config); } close(file_descriptor); diff --git a/src/shared/file.c b/src/shared/file.c index 451f37c..c4419da 100644 --- a/src/shared/file.c +++ b/src/shared/file.c @@ -145,6 +145,14 @@ bool file_send_single_calls(File* file, int file_descriptor, bool use_metadata, return true; } +static bool to_disk_secure(const char* path, const void* data, unsigned long long data_size, + bool inplace, bool sparse, FileMetadata* metadata); + +static bool path_is_within_root(const char* root, const char* path) { + size_t n = strlen(root); + return strncmp(root, path, n) == 0 && (path[n] == '\0' || path[n] == '/'); +} + bool file_save_to_disk(const char* root_directory, File* file, const Config* config) { bool backup_enabled = config && config->backup; bool inplace = config && config->inplace; @@ -152,15 +160,28 @@ bool file_save_to_disk(const char* root_directory, File* file, const Config* con 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; + char *confined_backup = NULL, *confined_partial = NULL; if (has_path_traversal(file->path)) { log_message(LOG_LEVEL_ERROR, "Path traversal detected in file path: %s", file->path); return false; } + /* These options arrive from the client. They are names below the server + root, never independent filesystem roots. */ + if ((backup_dir && (backup_dir[0] == '/' || has_path_traversal(backup_dir))) || + (partial_dir && (partial_dir[0] == '/' || has_path_traversal(partial_dir)))) + return false; + if (backup_dir && !(confined_backup = path_cat(root_directory, backup_dir))) + return false; + if (partial_dir && !(confined_partial = path_cat(root_directory, partial_dir))) { + free(confined_backup); + return false; + } + char* resolved_root = NULL; const char* actual_root = - (partial_dir && config && config->partial) ? partial_dir : root_directory; + (partial_dir && config && config->partial) ? confined_partial : root_directory; resolved_root = realpath(actual_root, NULL); if (resolved_root == NULL) { if (mkdir_r(actual_root)) { @@ -169,11 +190,24 @@ bool file_save_to_disk(const char* root_directory, File* file, const Config* con } if (resolved_root == NULL) { log_message(LOG_LEVEL_ERROR, "Failed to resolve destination root: %s", actual_root); + free(confined_backup); + free(confined_partial); return false; } + char* resolved_base = realpath(root_directory, NULL); + if (resolved_base == NULL || !path_is_within_root(resolved_base, resolved_root)) { + free(resolved_base); + free(confined_backup); + free(confined_partial); + free(resolved_root); + return false; + } + free(resolved_base); char* disk_path = path_cat(resolved_root, file->path); if (disk_path == NULL) { + free(confined_backup); + free(confined_partial); free(resolved_root); return false; } @@ -184,6 +218,8 @@ bool file_save_to_disk(const char* root_directory, File* file, const Config* con if (stat(disk_path, &destination_stat) == 0 && file->metadata && destination_stat.st_mtime > file->metadata->mtime_sec) { free(resolved_root); + free(confined_backup); + free(confined_partial); free(disk_path); return true; } @@ -194,13 +230,16 @@ bool file_save_to_disk(const char* root_directory, File* file, const Config* con if (stat(disk_path, &backup_stat) == 0) { char* backup_path = NULL; if (backup_dir) { - char* resolved_backup_dir = realpath(backup_dir, NULL); + char* resolved_backup_dir = realpath(confined_backup, NULL); if (!resolved_backup_dir) { - mkdir_r(backup_dir); - resolved_backup_dir = realpath(backup_dir, NULL); + mkdir_r(confined_backup); + resolved_backup_dir = realpath(confined_backup, NULL); } if (resolved_backup_dir) { - backup_path = path_cat(resolved_backup_dir, file->path); + char* backup_base = realpath(root_directory, NULL); + if (backup_base && path_is_within_root(backup_base, resolved_backup_dir)) + backup_path = path_cat(resolved_backup_dir, file->path); + free(backup_base); free(resolved_backup_dir); } } @@ -228,6 +267,8 @@ bool file_save_to_disk(const char* root_directory, File* file, const Config* con char* dir_dup = str_dup(disk_path); if (!dir_dup) { + free(confined_backup); + free(confined_partial); free(resolved_root); free(disk_path); return false; @@ -235,6 +276,8 @@ bool file_save_to_disk(const char* root_directory, File* file, const Config* con char* dir_str = dirname(dir_dup); if (!mkdir_r(dir_str)) { free(dir_dup); + free(confined_backup); + free(confined_partial); free(resolved_root); free(disk_path); return false; @@ -243,6 +286,8 @@ bool file_save_to_disk(const char* root_directory, File* file, const Config* con free(dir_dup); if (resolved_dir == NULL) { log_message(LOG_LEVEL_ERROR, "Failed to resolve directory for: %s", disk_path); + free(confined_backup); + free(confined_partial); free(resolved_root); free(disk_path); return false; @@ -253,6 +298,8 @@ bool file_save_to_disk(const char* root_directory, File* file, const Config* con (resolved_dir[root_len] != '\0' && resolved_dir[root_len] != '/')) { log_message(LOG_LEVEL_ERROR, "Path escape detected: %s is outside %s", disk_path, actual_root); free(resolved_dir); + free(confined_backup); + free(confined_partial); free(resolved_root); free(disk_path); return false; @@ -260,9 +307,10 @@ bool file_save_to_disk(const char* root_directory, File* file, const Config* con free(resolved_dir); free(resolved_root); - bool ok = to_disk(disk_path, file->data->data, file->data->size, inplace, sparse); - if (ok) - file_restore_metadata(disk_path, file->metadata); + bool ok = to_disk_secure(disk_path, file->data->data, file->data->size, inplace, sparse, + file->metadata); + free(confined_backup); + free(confined_partial); free(disk_path); return ok; } @@ -616,7 +664,7 @@ static bool write_all(int fd, const void* data, unsigned long long size) { } static bool to_disk_secure(const char* path, const void* data, unsigned long long data_size, - bool inplace, bool sparse) { + bool inplace, bool sparse, FileMetadata* metadata) { char* leaf = NULL; int dirfd = open_secure_parent(path, &leaf); if (dirfd < 0) @@ -628,6 +676,8 @@ static bool to_disk_secure(const char* path, const void* data, unsigned long lon if (fd >= 0) { if (!sparse || data_size == 0 || ftruncate(fd, (off_t)data_size) == 0) ok = write_all(fd, data, data_size); + if (ok && metadata) + file_restore_metadata_fd(fd, metadata); } } else { char tmp[NAME_MAX]; @@ -640,6 +690,8 @@ static bool to_disk_secure(const char* path, const void* data, unsigned long lon ok = ftruncate(fd, (off_t)data_size) == 0; if (ok || (!sparse || data_size == 0)) ok = write_all(fd, data, data_size); + if (ok && metadata) + file_restore_metadata_fd(fd, metadata); if (close(fd) != 0) ok = false; fd = -1; @@ -660,7 +712,7 @@ bool to_disk(const char* path, const void* data, unsigned long long data_size, b bool sparse) { if (!path || (!data && data_size != 0) || has_path_traversal(path)) return false; - return to_disk_secure(path, data, data_size, inplace, sparse); + return to_disk_secure(path, data, data_size, inplace, sparse, NULL); /* Kept below only as historical context; all writes use descriptor-relative operations. */ char* tmp_path = NULL; char* directory = NULL; @@ -867,18 +919,30 @@ int receive_manifest(int fd, const Config* config, int* next_status) { ArrayList* manifest = array_list_create(free); if (!manifest) return -1; + size_t manifest_bytes = 0; for (int i = 0; i < count; i++) { char* s = receive_str(fd); - if (!s || s[0] == '\0' || has_path_traversal(s) || !array_list_add(manifest, s)) { + size_t entry_size = s ? strlen(s) : 0; + if (!s || s[0] == '\0' || s[0] == '/' || has_path_traversal(s) || + entry_size > MAX_MANIFEST_BYTES - manifest_bytes || + (manifest_bytes += entry_size) > MAX_MANIFEST_BYTES || !array_list_add(manifest, s)) { free(s); array_list_delete(manifest); return -1; } } + if (!receive_status(fd, next_status)) { + array_list_delete(manifest); + return -1; + } + /* Deletion is a commit operation: never perform it until the sender has + completed the manifest frame successfully. */ + if (*next_status != STATUS_FINISHED || !config->use_delete) { + array_list_delete(manifest); + return *next_status == STATUS_FINISHED ? 0 : -1; + } fprintf(stderr, "Deleting files not in manifest...\n"); delete_extras(config->receive_root_directory, manifest); array_list_delete(manifest); - if (!receive_status(fd, next_status)) - return -1; return 0; } diff --git a/src/shared/metadata.c b/src/shared/metadata.c index 7f246df..6502e37 100644 --- a/src/shared/metadata.c +++ b/src/shared/metadata.c @@ -187,3 +187,17 @@ void file_restore_metadata(const char* path, FileMetadata* metadata) { if (utimensat(AT_FDCWD, path, times, 0) != 0) log_message(LOG_LEVEL_WARNING, "Failed to set timestamps on %s: %s", path, strerror(errno)); } + +void file_restore_metadata_fd(int fd, FileMetadata* metadata) { + if (fd < 0 || metadata == NULL) + return; + if (fchmod(fd, metadata->mode & 07777 & ~(S_ISUID | S_ISGID)) != 0) + log_message(LOG_LEVEL_WARNING, "Failed to fchmod received file: %s", strerror(errno)); + if (fchown(fd, metadata->uid, metadata->gid) != 0 && errno != EPERM) + log_message(LOG_LEVEL_WARNING, "Failed to fchown received file: %s", strerror(errno)); + struct timespec times[2] = {{.tv_sec = metadata->mtime_sec, .tv_nsec = metadata->mtime_nsec}, + {.tv_sec = metadata->mtime_sec, .tv_nsec = metadata->mtime_nsec}}; + if (futimens(fd, times) != 0) + log_message(LOG_LEVEL_WARNING, "Failed to restore received file timestamps: %s", + strerror(errno)); +} diff --git a/src/shared/metadata.h b/src/shared/metadata.h index 28312f5..7654dfc 100644 --- a/src/shared/metadata.h +++ b/src/shared/metadata.h @@ -30,5 +30,6 @@ FileMetadata* metadata_from_buf(char** buf); bool metadata_send(int file_descriptor, FileMetadata* m); FileMetadata* metadata_receive(int file_descriptor, int* ok); void file_restore_metadata(const char* path, FileMetadata* metadata); +void file_restore_metadata_fd(int fd, FileMetadata* metadata); #endif diff --git a/src/shared/multiprocessing.c b/src/shared/multiprocessing.c index 9df6674..162add0 100644 --- a/src/shared/multiprocessing.c +++ b/src/shared/multiprocessing.c @@ -26,7 +26,7 @@ PipelineContextSender* pipeline_context_sender_create(Config* config, Queue* que context->manifest = NULL; context->progress_bytes = 0; context->sender_done = false; - context->cancelled = false; + atomic_init(&context->cancelled, false); int init = 0; if (mtx_init(&context->mutex_scanner, mtx_plain) != thrd_success) goto fail; @@ -97,7 +97,7 @@ PipelineContextReceiver* pipeline_context_receiver_create(Config* config, Queue* context->file_descriptor = file_descriptor; context->ssl = ssl; context->receiver_done = false; - context->cancelled = false; + atomic_init(&context->cancelled, false); int init = 0; if (mtx_init(&context->mutex, mtx_plain) != thrd_success) goto fail; @@ -154,7 +154,7 @@ static bool receive_chunk_enqueue(int file_descriptor, PipelineContextReceiver* static void receiver_thread_fail(PipelineContextReceiver* context) { mtx_lock(&context->mutex); - context->cancelled = true; + atomic_store(&context->cancelled, true); context->receiver_done = true; cnd_broadcast(&context->condition_not_empty); cnd_broadcast(&context->condition_not_full); @@ -207,7 +207,8 @@ int receive_thread(void* pipeline_context) { RECEIVE_THREAD_FAIL(); } else if (status == STATUS_CHECK_BATCH) { int count; - if (!receive_int(file_descriptor, &count)) + if (config->checksum || !receive_int(file_descriptor, &count) || count < 0 || + count > MAX_MANIFEST_ENTRIES) RECEIVE_THREAD_FAIL(); for (int i = 0; i < count; i++) { char* check_path = receive_str(file_descriptor); @@ -280,8 +281,16 @@ int write_thread(void* pipeline_context) { free(root_directory); return thrd_success; } - if (save_to_disk) - file_save_to_disk(root_directory, file, context->config); + if (save_to_disk && !file_save_to_disk(root_directory, file, context->config)) { + file_destroy(file); + mtx_lock(&context->mutex); + atomic_store(&context->cancelled, true); + cnd_broadcast(&context->condition_not_full); + cnd_broadcast(&context->condition_not_empty); + mtx_unlock(&context->mutex); + free(root_directory); + return thrd_error; + } file_destroy(file); } } diff --git a/src/shared/multiprocessing.h b/src/shared/multiprocessing.h index 443720a..348b955 100644 --- a/src/shared/multiprocessing.h +++ b/src/shared/multiprocessing.h @@ -2,6 +2,7 @@ #define MULTIPROCESSING_H #include +#include #include "array_list.h" #include "config.h" @@ -26,7 +27,7 @@ typedef struct { mtx_t mutex_progress; unsigned long long progress_bytes; bool sender_done; - bool cancelled; + atomic_bool cancelled; } PipelineContextSender; typedef struct PipelineContextReceiver { @@ -38,7 +39,7 @@ typedef struct PipelineContextReceiver { cnd_t condition_not_full; cnd_t condition_not_empty; bool receiver_done; - bool cancelled; + atomic_bool cancelled; } PipelineContextReceiver; PipelineContextSender* pipeline_context_sender_create(Config* config, Queue* queue_scanner, diff --git a/src/shared/protocol.c b/src/shared/protocol.c index f0dc5cd..fdb8d12 100644 --- a/src/shared/protocol.c +++ b/src/shared/protocol.c @@ -11,7 +11,8 @@ #include #include -#define RECEIVE_TIMEOUT_SEC 60 /* 60 second per-message timeout */ +#define RECEIVE_TIMEOUT_SEC 60 /* 60 second per-message timeout */ +#define SEND_TIMEOUT_SEC 60 #define MAX_CONNECTION_MEMORY (1024ULL * 1024 * 1024) /* 1 GB total per connection */ static __thread int io_read_fd = -1; @@ -29,6 +30,9 @@ static __thread unsigned long long total_allocated_bytes = 0; void io_set_fds(int read_fd, int write_fd) { io_read_fd = read_fd; io_write_fd = write_fd; + /* A descriptor switch starts a new transport; never reuse a TLS object + belonging to a previous connection or test pipe. */ + io_ssl = NULL; } static void bw_mutex_init(void) { @@ -102,11 +106,27 @@ static int deadline_remaining_ms(const struct timespec* deadline) { bool send_n_data(int file_descriptor, const void* data, size_t data_size) { log_message(LOG_LEVEL_DEBUG, " Sending n Data: %zu", data_size); int fd = io_fd(io_write_fd, file_descriptor); + struct timespec deadline; + clock_gettime(CLOCK_MONOTONIC, &deadline); + deadline.tv_sec += SEND_TIMEOUT_SEC; + short wait_events = POLLOUT; ssize_t total_bytes_send = 0; while ((size_t)total_bytes_send < data_size) { size_t chunk = data_size - total_bytes_send; if (io_bwlimit > 0 && chunk > 65536) chunk = 65536; + struct pollfd pfd = {.fd = fd, .events = wait_events}; + int poll_result = poll(&pfd, 1, deadline_remaining_ms(&deadline)); + if (poll_result == 0 || (poll_result < 0 && errno != EINTR)) { + log_message(LOG_LEVEL_ERROR, "Send timeout or poll failure"); + return false; + } + if (poll_result == 0) + return false; + if (poll_result < 0) + continue; + if (pfd.revents & (POLLERR | POLLNVAL)) + return false; ssize_t bytes_send; if (io_ssl) bytes_send = SSL_write(io_ssl, (const char*)data + total_bytes_send, chunk); @@ -115,8 +135,10 @@ bool send_n_data(int file_descriptor, const void* data, size_t data_size) { if (bytes_send <= 0) { if (io_ssl) { int ssl_err = SSL_get_error(io_ssl, (int)bytes_send); - if (ssl_err == SSL_ERROR_WANT_WRITE || ssl_err == SSL_ERROR_WANT_READ) + if (ssl_err == SSL_ERROR_WANT_WRITE || ssl_err == SSL_ERROR_WANT_READ) { + wait_events = ssl_err == SSL_ERROR_WANT_WRITE ? POLLOUT : POLLIN; continue; + } } log_message(LOG_LEVEL_ERROR, "Could not send data"); return false; @@ -137,8 +159,9 @@ bool receive_n_data(int file_descriptor, void* data, size_t data_size) { deadline.tv_sec += RECEIVE_TIMEOUT_SEC; size_t total_bytes_received = 0; + short wait_events = POLLIN; while (total_bytes_received < data_size) { - struct pollfd pfd = {.fd = fd, .events = POLLIN}; + struct pollfd pfd = {.fd = fd, .events = wait_events}; int poll_result = poll(&pfd, 1, deadline_remaining_ms(&deadline)); if (poll_result == 0) { log_message(LOG_LEVEL_ERROR, "Receive timeout after %ds", RECEIVE_TIMEOUT_SEC); @@ -149,6 +172,7 @@ bool receive_n_data(int file_descriptor, void* data, size_t data_size) { continue; return false; } + /* POLLHUP may accompany the final readable bytes on pipes/sockets. */ if (pfd.revents & (POLLERR | POLLNVAL)) return false; @@ -162,8 +186,10 @@ bool receive_n_data(int file_descriptor, void* data, size_t data_size) { if (bytes_received <= 0) { if (io_ssl) { int ssl_err = SSL_get_error(io_ssl, (int)bytes_received); - if (ssl_err == SSL_ERROR_WANT_WRITE || ssl_err == SSL_ERROR_WANT_READ) + if (ssl_err == SSL_ERROR_WANT_WRITE || ssl_err == SSL_ERROR_WANT_READ) { + wait_events = ssl_err == SSL_ERROR_WANT_WRITE ? POLLOUT : POLLIN; continue; + } } if (bytes_received == 0) log_message(LOG_LEVEL_ERROR, "Connection closed while receiving data"); @@ -222,7 +248,8 @@ char* receive_str(int file_descriptor) { size_t size; if (!receive_n_data(file_descriptor, &size, sizeof(size_t))) return NULL; - if (size > MAX_STRING_SIZE) { + if (size > MAX_STRING_SIZE || size > SIZE_MAX - 1 || + total_allocated_bytes > MAX_CONNECTION_MEMORY - (size + 1)) { log_message(LOG_LEVEL_ERROR, "String size %zu exceeds maximum %llu", size, (unsigned long long)MAX_STRING_SIZE); return NULL; @@ -271,7 +298,7 @@ Data* receive_data(int file_descriptor) { free(data); return NULL; } - total_allocated_bytes += size; + total_allocated_bytes += size + 1; log_message(LOG_LEVEL_DEBUG, "Received %lld data", size); return data_create(data, (size_t)size); } diff --git a/src/shared/protocol.h b/src/shared/protocol.h index d432503..ded825f 100644 --- a/src/shared/protocol.h +++ b/src/shared/protocol.h @@ -14,6 +14,8 @@ /* Maximum chunk size (64 MB) — prevents unbounded allocation from the wire */ #define MAX_CHUNK_SIZE (64ULL * 1024 * 1024) #define MAX_MANIFEST_ENTRIES (1024 * 1024) +/* Aggregate bytes retained by one received deletion manifest. */ +#define MAX_MANIFEST_BYTES (16ULL * 1024 * 1024) typedef struct ssl_st SSL; diff --git a/src/shared/queue.c b/src/shared/queue.c index 5b763f2..1d7a0e4 100644 --- a/src/shared/queue.c +++ b/src/shared/queue.c @@ -109,13 +109,13 @@ bool queue_enqueue_multithreaded(Queue* queue, void* item, mtx_t* mutex, cnd_t* return ok; } -bool queue_enqueue_multithreaded_cancel(Queue* queue, void* item, mtx_t* mutex, - cnd_t* condition_not_empty, cnd_t* condition_not_full, - const bool* cancelled) { +bool queue_enqueue_multithreaded_cancel(Queue* queue, void* item, mtx_t* mutex, + cnd_t* condition_not_empty, cnd_t* condition_not_full, + const atomic_bool* cancelled) { mtx_lock(mutex); - while (queue_is_full(queue) && (cancelled == NULL || !*cancelled)) + while (queue_is_full(queue) && (cancelled == NULL || !atomic_load(cancelled))) cnd_wait(condition_not_full, mutex); - if (cancelled != NULL && *cancelled) { + if (cancelled != NULL && atomic_load(cancelled)) { mtx_unlock(mutex); return false; } diff --git a/src/shared/queue.h b/src/shared/queue.h index c72659d..7a8bd98 100644 --- a/src/shared/queue.h +++ b/src/shared/queue.h @@ -2,6 +2,7 @@ #define QUEUE_H #include +#include #include typedef struct Queue { @@ -22,7 +23,7 @@ bool queue_enqueue_multithreaded(Queue* queue, void* item, mtx_t* mutex, cnd_t* cnd_t* condition_not_full); bool queue_enqueue_multithreaded_cancel(Queue* queue, void* item, mtx_t* mutex, cnd_t* condition_not_empty, cnd_t* condition_not_full, - const bool* cancelled); + const atomic_bool* cancelled); void* queue_dequeue(Queue* queue); void* queue_dequeue_multithreaded(Queue* queue, mtx_t* mutex, cnd_t* condition_not_empty, cnd_t* condition_not_full, const bool* other_thread_done); diff --git a/src/shared/utils.c b/src/shared/utils.c index 43ed1e2..e866a45 100644 --- a/src/shared/utils.c +++ b/src/shared/utils.c @@ -3,6 +3,7 @@ #include "libgen.h" #include #include +#include #include #include #include @@ -136,34 +137,36 @@ static bool is_dir_in_manifest(const char* rel_path, ArrayList* manifest) { return false; } -static void delete_extras_walk(const char* abs_path, const char* rel_path, ArrayList* manifest) { - DIR* dir = opendir(abs_path); - if (!dir) +static void delete_extras_fd(int dirfd, const char* rel_path, ArrayList* manifest) { + int scanfd = dup(dirfd); + if (scanfd < 0) return; + DIR* dir = fdopendir(scanfd); + if (!dir) { + close(scanfd); + return; + } bool all_removed = true; const struct dirent* entry; while ((entry = readdir(dir)) != NULL) { if (strcmp(entry->d_name, ".") == 0 || strcmp(entry->d_name, "..") == 0) continue; - char* child_abs = path_cat((char*)abs_path, entry->d_name); char* child_rel = path_cat((char*)rel_path, entry->d_name); struct stat st; - if (lstat(child_abs, &st) != 0) { - free(child_abs); + if (fstatat(dirfd, entry->d_name, &st, AT_SYMLINK_NOFOLLOW) != 0) { free(child_rel); continue; } // Skip symlinks to prevent following them outside the destination tree if (S_ISLNK(st.st_mode)) { - free(child_abs); free(child_rel); continue; } if (S_ISDIR(st.st_mode)) { - delete_extras_walk(child_abs, child_rel, manifest); - // After recursion, try to remove the subdirectory if it's now empty. - // Ignore ENOENT: the recursive call may have already removed it. - if (rmdir(child_abs) != 0 && errno != ENOENT) { + int childfd = openat(dirfd, entry->d_name, O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC); + if (childfd >= 0) + delete_extras_fd(childfd, child_rel, manifest); + if (unlinkat(dirfd, entry->d_name, AT_REMOVEDIR) != 0 && errno != ENOENT) { all_removed = false; } } else { @@ -176,25 +179,30 @@ static void delete_extras_walk(const char* abs_path, const char* rel_path, Array } } if (!found) { - unlink(child_abs); + if (unlinkat(dirfd, entry->d_name, 0) != 0 && errno != ENOENT) + all_removed = false; fprintf(stderr, " Deleted: %s\n", child_rel); } else { all_removed = false; } } - free(child_abs); free(child_rel); } closedir(dir); // Only remove the directory itself if it is not in the manifest // and contained no kept entries. if (all_removed && rel_path[0] != '\0' && !is_dir_in_manifest(rel_path, manifest)) { - rmdir(abs_path); + /* The caller owns the directory fd; removing this name is done by its + parent, so recursive callers perform it in their own frame. */ } } void delete_extras(const char* dest_root, ArrayList* manifest) { - delete_extras_walk(dest_root, "", manifest); + int rootfd = open(dest_root, O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC); + if (rootfd < 0) + return; + delete_extras_fd(rootfd, "", manifest); + close(rootfd); } bool has_path_traversal(const char* path) { diff --git a/tests/integration/test_features.py b/tests/integration/test_features.py index 39cbc9e..312add4 100644 --- a/tests/integration/test_features.py +++ b/tests/integration/test_features.py @@ -240,8 +240,10 @@ class TestDelete: ) assert result.returncode == 0, f"Delete sync failed: {(result.stderr or result.stdout)[:200]}" - assert not os.path.exists(extra_file), "extra_file.txt should be deleted" - assert not os.path.exists(extra_dir), "extra_dir should be deleted" + # The default server policy intentionally refuses client-requested + # deletion unless it is started with --allow-delete. + assert os.path.exists(extra_file), "unauthorized delete removed an extra file" + assert os.path.exists(extra_dir), "unauthorized delete removed an extra directory" mismatches, missing = verify_transfer(SOURCE_DIR, received) assert not missing, f"Missing: {missing}" @@ -258,7 +260,8 @@ class TestProgress: ) assert result.returncode == 0, f"Exit {result.returncode}: {result.stderr[:100]}" output = result.stdout + result.stderr - assert output, "--progress produced no output" + assert "Sent " in output and "MB" in output, "--progress produced no stable byte marker" + assert "Done." in output, "--progress did not report completion" class TestBandwidthLimit: diff --git a/tests/integration/test_ssh.py b/tests/integration/test_ssh.py index bad3790..fa1234d 100644 --- a/tests/integration/test_ssh.py +++ b/tests/integration/test_ssh.py @@ -6,50 +6,45 @@ import sys import pytest sys.path.insert(0, os.path.dirname(__file__)) -from common import ( - PROJECT_ROOT, BUILD_DIR, TEST_DATA_DIR, - CLIENT_CMD, generate_test_files, verify_transfer, clean_dir, make_result, -) +from common import (PROJECT_ROOT, BUILD_DIR, TEST_DATA_DIR, CLIENT_CMD, + generate_test_files, verify_transfer, clean_dir, make_result, + get_dest_received_dir) SOURCE_DIR = os.path.join(TEST_DATA_DIR, "ssh_source") DEST_DIR = os.path.join(TEST_DATA_DIR, "ssh_dest") SSH_AVAILABLE = False +SSH_SKIP_REASON = "SSH localhost probe was not run" def _check_ssh(): - global SSH_AVAILABLE + global SSH_AVAILABLE, SSH_SKIP_REASON + server_path = os.path.join(BUILD_DIR, "server") + if not os.path.isfile(server_path): + SSH_SKIP_REASON = f"current server binary is missing: {server_path}" + return try: - r = subprocess.run( - ["ssh", "-o", "BatchMode=yes", "-o", "ConnectTimeout=5", - "localhost", "which", "fastsync-server"], - capture_output=True, timeout=10, - ) - if r.returncode == 0: - SSH_AVAILABLE = True + path = subprocess.run(["ssh", "-o", "BatchMode=yes", "localhost", "echo", "$PATH"], + capture_output=True, timeout=10, text=True) + if path.returncode != 0: + SSH_SKIP_REASON = "SSH to localhost is unavailable" return - - # Try to install server binary into PATH - server_path = os.path.join(BUILD_DIR, "server") - r = subprocess.run( - ["ssh", "-o", "BatchMode=yes", "localhost", 'echo "$PATH"'], - capture_output=True, timeout=10, text=True, - ) - if r.returncode != 0: - return - for d in r.stdout.strip().split(":"): - d = d.strip() - if not d or "wrappers" in d: + for directory in path.stdout.strip().split(":"): + if not directory or "wrappers" in directory: continue - test = subprocess.run( + probe = subprocess.run( ["ssh", "-o", "BatchMode=yes", "localhost", - f'test -w "{d}" && ln -sf {server_path} "{d}/fastsync-server" && which fastsync-server'], - capture_output=True, timeout=10, - ) - if test.returncode == 0: + f'test -w "{directory}" && ln -sf "{server_path}" ' + f'"{directory}/fastsync-server" && test -x "{directory}/fastsync-server" ' + f'&& "{directory}/fastsync-server" --help'], + capture_output=True, timeout=10) + if probe.returncode == 0 and b"FastSync Server" in probe.stdout: SSH_AVAILABLE = True return + SSH_SKIP_REASON = "SSH setup could not install and validate the current server binary" except FileNotFoundError: - pass + SSH_SKIP_REASON = "ssh executable is unavailable" + except (OSError, subprocess.TimeoutExpired) as exc: + SSH_SKIP_REASON = f"SSH setup failed: {exc}" _check_ssh() @@ -65,18 +60,17 @@ def setup_test_data(): def _run_ssh_test(name, flags, expected_missing=None): - """Run an SSH test case (no server process needed, client spawns SSH).""" ssh_dest = f"localhost:{DEST_DIR}" clean_dir(DEST_DIR) - cmd = CLIENT_CMD + [SOURCE_DIR, ssh_dest, "--save-to-disk"] + flags + cmd = CLIENT_CMD + [SOURCE_DIR, ssh_dest, "--save-to-disk", + "--fastsync-server-path", os.path.join(BUILD_DIR, "server")] + flags start = __import__("time").monotonic() result = subprocess.run(cmd, text=True, capture_output=True) duration = __import__("time").monotonic() - start - if result.returncode != 0: - return make_result(name, False, duration, f"Exit {result.returncode}: {(result.stderr or result.stdout)[:100]}") - - mismatches, missing = verify_transfer(SOURCE_DIR, DEST_DIR) + return make_result(name, False, duration, + f"Exit {result.returncode}: {(result.stderr or result.stdout)[:100]}") + mismatches, missing = verify_transfer(SOURCE_DIR, get_dest_received_dir(DEST_DIR, SOURCE_DIR)) if expected_missing: missing = [m for m in missing if m not in expected_missing] if missing: @@ -90,7 +84,7 @@ class TestSSHStandard: @pytest.fixture(autouse=True) def require_ssh(self): if not SSH_AVAILABLE: - pytest.skip("SSH to localhost not available") + pytest.skip(SSH_SKIP_REASON) def test_standard(self): r = _run_ssh_test("SSH (localhost)", []) @@ -129,7 +123,7 @@ class TestSSHFeatures: @pytest.fixture(autouse=True) def require_ssh(self): if not SSH_AVAILABLE: - pytest.skip("SSH to localhost not available") + pytest.skip(SSH_SKIP_REASON) def test_archive(self): r = _run_ssh_test("SSH Archive (-a)", ["-a"]) @@ -137,6 +131,5 @@ class TestSSHFeatures: def test_exclude(self): r = _run_ssh_test("SSH Exclude (--exclude small.txt)", - ["--exclude", "small.txt"], - expected_missing=["small.txt"]) + ["--exclude", "small.txt"], expected_missing=["small.txt"]) assert r["status"] == "Success", r["error"] From 1a0a7a19a5b0e3b28fff77961eeb0ddb74badc7f Mon Sep 17 00:00:00 2001 From: TapTap Date: Sun, 9 Aug 2026 11:52:51 +0200 Subject: [PATCH 09/16] style: format atomic queue cancellation --- src/shared/queue.c | 298 ++++++++++++++++++++++----------------------- 1 file changed, 149 insertions(+), 149 deletions(-) diff --git a/src/shared/queue.c b/src/shared/queue.c index 1d7a0e4..b9930dd 100644 --- a/src/shared/queue.c +++ b/src/shared/queue.c @@ -1,154 +1,154 @@ -#include -#include -#include -#include -#include -#include - -#include "queue.h" - -Queue* queue_create(int capacity, void (*destroyer)(void* item)) { - if (capacity <= 0) - return NULL; - - Queue* queue = (Queue*)malloc(sizeof(Queue)); - if (queue == NULL) { - perror("ERROR: Could not allocate memory for queue structure"); - return NULL; - } - - queue->items = malloc(capacity * sizeof(void*)); - if (queue->items == NULL) { - free(queue); - return NULL; - } - - for (int i = 0; i < capacity; ++i) { - queue->items[i] = NULL; - } - - queue->capacity = capacity; - queue->front = 0; - queue->rear = 0; - queue->size = 0; - queue->item_destroyer = destroyer; - - return queue; -} - -void queue_destroy(Queue* queue) { - if (queue == NULL) - return; - - if (queue->item_destroyer != NULL) { - for (int i = 0; i < queue->size; ++i) { - int index = (queue->front + i) % queue->capacity; - queue->item_destroyer(queue->items[index]); - } - } - free(queue->items); - free(queue); -} - -bool queue_is_empty(const Queue* queue) { - if (queue == NULL) - return true; - return queue->size == 0; -} - -bool queue_is_full(const Queue* queue) { - if (queue == NULL) - return false; - return queue->size == queue->capacity; -} - -static bool queue_double_capacity(Queue* queue) { - if (queue == NULL) - return false; - if (queue->capacity > INT_MAX / 2) - return false; - int new_capacity = queue->capacity * 2; - if (new_capacity <= 1) - new_capacity = 100; - void** new_items = malloc(new_capacity * sizeof(void*)); - if (new_items == NULL) { - perror("ERROR: Could not allocate memory for doubling capacity of queue."); - return false; - } - for (int i = 0; i < queue->size; i++) - new_items[i] = queue->items[(i + queue->front) % queue->capacity]; - free(queue->items); - queue->items = new_items; - queue->front = 0; - queue->rear = queue->size; - queue->capacity = new_capacity; - return true; -} - -bool queue_enqueue(Queue* queue, void* item) { - if (queue == NULL || item == NULL) - return false; - if (queue_is_full(queue)) { - if (!queue_double_capacity(queue)) - return false; - } - queue->items[queue->rear] = item; - queue->rear = (queue->rear + 1) % queue->capacity; - queue->size++; - return true; -} - -bool queue_enqueue_multithreaded(Queue* queue, void* item, mtx_t* mutex, cnd_t* condition_not_empty, - cnd_t* condition_not_full) { - mtx_lock(mutex); - while (queue_is_full(queue)) - cnd_wait(condition_not_full, mutex); - bool ok = queue_enqueue(queue, item); - cnd_signal(condition_not_empty); - mtx_unlock(mutex); - return ok; -} - +#include +#include +#include +#include +#include +#include + +#include "queue.h" + +Queue* queue_create(int capacity, void (*destroyer)(void* item)) { + if (capacity <= 0) + return NULL; + + Queue* queue = (Queue*)malloc(sizeof(Queue)); + if (queue == NULL) { + perror("ERROR: Could not allocate memory for queue structure"); + return NULL; + } + + queue->items = malloc(capacity * sizeof(void*)); + if (queue->items == NULL) { + free(queue); + return NULL; + } + + for (int i = 0; i < capacity; ++i) { + queue->items[i] = NULL; + } + + queue->capacity = capacity; + queue->front = 0; + queue->rear = 0; + queue->size = 0; + queue->item_destroyer = destroyer; + + return queue; +} + +void queue_destroy(Queue* queue) { + if (queue == NULL) + return; + + if (queue->item_destroyer != NULL) { + for (int i = 0; i < queue->size; ++i) { + int index = (queue->front + i) % queue->capacity; + queue->item_destroyer(queue->items[index]); + } + } + free(queue->items); + free(queue); +} + +bool queue_is_empty(const Queue* queue) { + if (queue == NULL) + return true; + return queue->size == 0; +} + +bool queue_is_full(const Queue* queue) { + if (queue == NULL) + return false; + return queue->size == queue->capacity; +} + +static bool queue_double_capacity(Queue* queue) { + if (queue == NULL) + return false; + if (queue->capacity > INT_MAX / 2) + return false; + int new_capacity = queue->capacity * 2; + if (new_capacity <= 1) + new_capacity = 100; + void** new_items = malloc(new_capacity * sizeof(void*)); + if (new_items == NULL) { + perror("ERROR: Could not allocate memory for doubling capacity of queue."); + return false; + } + for (int i = 0; i < queue->size; i++) + new_items[i] = queue->items[(i + queue->front) % queue->capacity]; + free(queue->items); + queue->items = new_items; + queue->front = 0; + queue->rear = queue->size; + queue->capacity = new_capacity; + return true; +} + +bool queue_enqueue(Queue* queue, void* item) { + if (queue == NULL || item == NULL) + return false; + if (queue_is_full(queue)) { + if (!queue_double_capacity(queue)) + return false; + } + queue->items[queue->rear] = item; + queue->rear = (queue->rear + 1) % queue->capacity; + queue->size++; + return true; +} + +bool queue_enqueue_multithreaded(Queue* queue, void* item, mtx_t* mutex, cnd_t* condition_not_empty, + cnd_t* condition_not_full) { + mtx_lock(mutex); + while (queue_is_full(queue)) + cnd_wait(condition_not_full, mutex); + bool ok = queue_enqueue(queue, item); + cnd_signal(condition_not_empty); + mtx_unlock(mutex); + return ok; +} + bool queue_enqueue_multithreaded_cancel(Queue* queue, void* item, mtx_t* mutex, cnd_t* condition_not_empty, cnd_t* condition_not_full, const atomic_bool* cancelled) { - mtx_lock(mutex); + mtx_lock(mutex); while (queue_is_full(queue) && (cancelled == NULL || !atomic_load(cancelled))) - cnd_wait(condition_not_full, mutex); + cnd_wait(condition_not_full, mutex); if (cancelled != NULL && atomic_load(cancelled)) { - mtx_unlock(mutex); - return false; - } - bool ok = queue_enqueue(queue, item); - cnd_signal(condition_not_empty); - mtx_unlock(mutex); - return ok; -} - -void* queue_dequeue(Queue* queue) { - if (queue == NULL || queue_is_empty(queue)) { - perror("ERROR: Could not dequeue from null or empty queue."); - return NULL; - } - - void* item = queue->items[queue->front]; - queue->items[queue->front] = NULL; - queue->front = (queue->front + 1) % queue->capacity; - queue->size--; - return item; -} - -void* queue_dequeue_multithreaded(Queue* queue, mtx_t* mutex, cnd_t* condition_not_empty, - cnd_t* condition_not_full, const bool* other_thread_done) { - mtx_lock(mutex); - while (queue_is_empty(queue) && !*other_thread_done) - cnd_wait(condition_not_empty, mutex); - if (queue_is_empty(queue) && *other_thread_done) { - mtx_unlock(mutex); - return NULL; - } - void* item = queue_dequeue(queue); - cnd_signal(condition_not_full); - mtx_unlock(mutex); - return item; -} + mtx_unlock(mutex); + return false; + } + bool ok = queue_enqueue(queue, item); + cnd_signal(condition_not_empty); + mtx_unlock(mutex); + return ok; +} + +void* queue_dequeue(Queue* queue) { + if (queue == NULL || queue_is_empty(queue)) { + perror("ERROR: Could not dequeue from null or empty queue."); + return NULL; + } + + void* item = queue->items[queue->front]; + queue->items[queue->front] = NULL; + queue->front = (queue->front + 1) % queue->capacity; + queue->size--; + return item; +} + +void* queue_dequeue_multithreaded(Queue* queue, mtx_t* mutex, cnd_t* condition_not_empty, + cnd_t* condition_not_full, const bool* other_thread_done) { + mtx_lock(mutex); + while (queue_is_empty(queue) && !*other_thread_done) + cnd_wait(condition_not_empty, mutex); + if (queue_is_empty(queue) && *other_thread_done) { + mtx_unlock(mutex); + return NULL; + } + void* item = queue_dequeue(queue); + cnd_signal(condition_not_full); + mtx_unlock(mutex); + return item; +} From 5bc319f6947f20fa9812f3858c5028e70a2277f2 Mon Sep 17 00:00:00 2001 From: TapTap Date: Sun, 9 Aug 2026 11:53:54 +0200 Subject: [PATCH 10/16] fix: remove redundant poll timeout branch --- src/shared/protocol.c | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/shared/protocol.c b/src/shared/protocol.c index fdb8d12..7b6681d 100644 --- a/src/shared/protocol.c +++ b/src/shared/protocol.c @@ -121,8 +121,6 @@ bool send_n_data(int file_descriptor, const void* data, size_t data_size) { log_message(LOG_LEVEL_ERROR, "Send timeout or poll failure"); return false; } - if (poll_result == 0) - return false; if (poll_result < 0) continue; if (pfd.revents & (POLLERR | POLLNVAL)) From 7776a63d3d8d485de7f6febce4de2099b120b41b Mon Sep 17 00:00:00 2001 From: TapTap Date: Mon, 10 Aug 2026 17:26:00 +0200 Subject: [PATCH 11/16] fix: close remaining PR 208 review gaps --- src/client/client_send.c | 75 +++++++++++++------- src/client/scanner.c | 60 ++++++++++++++-- src/client/scanner.h | 6 ++ src/shared/file.c | 134 +++++++++++++++++++++++++++-------- src/shared/metadata.c | 16 ++--- src/shared/metadata.h | 2 +- src/shared/multiprocessing.c | 2 + src/shared/protocol.c | 18 +++-- src/shared/utils.c | 39 +++++----- src/shared/utils.h | 2 +- tests/runner.c | 2 + 11 files changed, 262 insertions(+), 94 deletions(-) diff --git a/src/client/client_send.c b/src/client/client_send.c index 21b32b8..ac2bc09 100644 --- a/src/client/client_send.c +++ b/src/client/client_send.c @@ -28,6 +28,20 @@ /* Forward declaration for progress-reporting thread used in multithreaded send. */ static int progress_thread_fn(void* arg); +static void pipeline_cancel(PipelineContextSender* context) { + mtx_lock(&context->mutex_scanner); + mtx_lock(&context->mutex_loader); + atomic_store(&context->cancelled, true); + context->scanner_done = true; + context->loader_done = true; + cnd_broadcast(&context->condition_not_full_scanner); + cnd_broadcast(&context->condition_not_empty_scanner); + cnd_broadcast(&context->condition_not_full_loader); + cnd_broadcast(&context->condition_not_empty_loader); + mtx_unlock(&context->mutex_loader); + mtx_unlock(&context->mutex_scanner); +} + /* Print dry-run manifest showing files that would be transferred. Returns 0 on success. */ static int send_dry_run_manifest(Config* config) { DirectoryScanner* scanner = directory_scanner_create( @@ -344,6 +358,7 @@ static int send_chunks_multithreaded(void* pipeline_context) { return ok ? thrd_success : thrd_error; send_fail: + pipeline_cancel(context); client_disconnect(client); client_delete(client); mtx_lock(&context->mutex_progress); @@ -354,6 +369,7 @@ static int send_chunks_multithreaded(void* pipeline_context) { if (send_chunk(client, current_chunk, context->config) != 0) { fprintf(stderr, "Error: unexpected error while sending chunk\n"); chunk_destroy(current_chunk); + pipeline_cancel(context); client_disconnect(client); client_delete(client); mtx_lock(&context->mutex_progress); @@ -388,15 +404,7 @@ static int scan_directory_multithreaded(void* pipeline_context) { Chunk* current_chunk; if (scanner == NULL) { log_message(LOG_LEVEL_ERROR, "Failed to create parallel scanner"); - atomic_store(&context->cancelled, true); - cnd_broadcast(&context->condition_not_full_scanner); - cnd_broadcast(&context->condition_not_empty_scanner); - cnd_broadcast(&context->condition_not_full_loader); - cnd_broadcast(&context->condition_not_empty_loader); - mtx_lock(&context->mutex_scanner); - context->scanner_done = true; - cnd_broadcast(&context->condition_not_empty_scanner); - mtx_unlock(&context->mutex_scanner); + pipeline_cancel(context); return thrd_error; } while ((current_chunk = parallel_scanner_next(scanner)) != NULL) { @@ -410,18 +418,14 @@ static int scan_directory_multithreaded(void* pipeline_context) { if (!manifest_entry) { log_message(LOG_LEVEL_ERROR, "Failed to allocate manifest entry"); mtx_unlock(&context->mutex_scanner); - atomic_store(&context->cancelled, true); - cnd_broadcast(&context->condition_not_full_scanner); - cnd_broadcast(&context->condition_not_empty_scanner); + pipeline_cancel(context); parallel_scanner_destroy(scanner); return thrd_error; } if (!array_list_add(context->manifest, manifest_entry)) { free(manifest_entry); - atomic_store(&context->cancelled, true); - cnd_broadcast(&context->condition_not_full_scanner); - cnd_broadcast(&context->condition_not_empty_scanner); mtx_unlock(&context->mutex_scanner); + pipeline_cancel(context); chunk_destroy(current_chunk); parallel_scanner_destroy(scanner); return thrd_error; @@ -434,13 +438,21 @@ static int scan_directory_multithreaded(void* pipeline_context) { &context->condition_not_empty_scanner, &context->condition_not_full_scanner, &context->cancelled)) { chunk_destroy(current_chunk); - atomic_store(&context->cancelled, true); - cnd_broadcast(&context->condition_not_full_scanner); - cnd_broadcast(&context->condition_not_empty_scanner); + pipeline_cancel(context); parallel_scanner_destroy(scanner); return thrd_error; } } + if (parallel_scanner_failed(scanner)) { + parallel_scanner_destroy(scanner); + mtx_lock(&context->mutex_scanner); + context->scanner_done = true; + cnd_broadcast(&context->condition_not_empty_scanner); + cnd_broadcast(&context->condition_not_full_scanner); + mtx_unlock(&context->mutex_scanner); + pipeline_cancel(context); + return thrd_error; + } mtx_lock(&context->mutex_scanner); context->scanner_done = true; cnd_signal(&context->condition_not_empty_scanner); @@ -576,6 +588,15 @@ int send_files(Config* config) { time_t last_progress = 0; time_t start = time(NULL); ArrayList* manifest = config->use_delete ? array_list_create(free) : NULL; + if (!scanner || (config->use_delete && !manifest)) { + if (scanner) + directory_scanner_destroy(scanner); + if (manifest) + array_list_delete(manifest); + client_disconnect(client); + client_delete(client); + return 1; + } while ((current_chunk = directory_scanner_next(scanner)) != NULL) { unsigned long long chunk_bytes = 0; for (int i = 0; i < current_chunk->element_count; i++) { @@ -620,6 +641,9 @@ int send_files(Config* config) { if (send_chunk(client, current_chunk, config) != 0) { log_message(LOG_LEVEL_ERROR, "Failed to send chunk"); chunk_destroy(current_chunk); + if (manifest) + array_list_delete(manifest); + manifest = NULL; break; } if (config->show_progress) { @@ -635,12 +659,15 @@ int send_files(Config* config) { } chunk_destroy(current_chunk); } + if (directory_scanner_failed(scanner) || (config->use_delete && manifest == NULL)) + goto send_fail; if (config->use_delete) { if (send_delete_manifest(client->file_descriptor, manifest) != 0) { array_list_delete(manifest); goto send_fail; } array_list_delete(manifest); + manifest = NULL; } if (!send_status(client->file_descriptor, STATUS_FINISHED)) goto send_fail; @@ -662,6 +689,8 @@ int send_files(Config* config) { return ok ? 0 : 1; send_fail: + if (manifest) + array_list_delete(manifest); directory_scanner_destroy(scanner); client_disconnect(client); client_delete(client); @@ -715,14 +744,10 @@ int send_files_multithreaded(Config* config) { if (!scanner_created || !loader_created || !sender_created) { perror("Error creating threads.\n"); - atomic_store(&context->cancelled, true); - context->scanner_done = true; - context->loader_done = true; + pipeline_cancel(context); + mtx_lock(&context->mutex_progress); context->sender_done = true; - cnd_broadcast(&context->condition_not_full_scanner); - cnd_broadcast(&context->condition_not_empty_scanner); - cnd_broadcast(&context->condition_not_full_loader); - cnd_broadcast(&context->condition_not_empty_loader); + mtx_unlock(&context->mutex_progress); if (sender_created) thrd_join(sender, NULL); if (loader_created) diff --git a/src/client/scanner.c b/src/client/scanner.c index 848f66d..aef3fef 100644 --- a/src/client/scanner.c +++ b/src/client/scanner.c @@ -121,6 +121,7 @@ static int open_next_directory(DirectoryScanner* scanner) { perror("Could not open directory"); free(scanner->current_path); scanner->current_path = NULL; + scanner->failed = true; return -1; } return 1; @@ -128,6 +129,10 @@ static int open_next_directory(DirectoryScanner* scanner) { Chunk* directory_scanner_next(DirectoryScanner* scanner) { ArrayList* chunk_data = array_list_create(file_destroy); + if (!chunk_data) { + scanner->failed = true; + return NULL; + } unsigned long long chunk_data_size = 0; while (1) { @@ -136,7 +141,7 @@ Chunk* directory_scanner_next(DirectoryScanner* scanner) { if (ret == 0) break; if (ret < 0) - continue; + break; } struct dirent* entry = readdir(scanner->current_dir); @@ -259,7 +264,11 @@ Chunk* directory_scanner_next(DirectoryScanner* scanner) { file->data->size = stats.st_size; if (scanner->use_metadata) file->metadata = file_metadata_create(&stats); - array_list_add(chunk_data, file); + if (!array_list_add(chunk_data, file)) { + file_destroy(file); + scanner->failed = true; + break; + } chunk_data_size += file->data->size; if (chunk_data_size > scanner->chunk_size) { free(cur_path); @@ -275,6 +284,10 @@ Chunk* directory_scanner_next(DirectoryScanner* scanner) { return NULL; } +bool directory_scanner_failed(const DirectoryScanner* scanner) { + return scanner == NULL || scanner->failed; +} + typedef struct { ParallelScanner* ps; char** dirs; @@ -302,10 +315,31 @@ static int parallel_worker_thread(void* arg) { wa->dirs[i], wa->use_metadata, wa->chunk_size, wa->exclude_patterns, wa->exclude_count, wa->include_patterns, wa->include_count, wa->max_size, wa->min_size, wa->max_depth, wa->follow_symlinks, wa->copy_links, wa->safe_links, wa->copy_unsafe_links, wa->checksum); + if (!ds) { + mtx_lock(&wa->ps->result_mutex); + wa->ps->failed = true; + atomic_store(&wa->ps->cancelled, true); + cnd_broadcast(&wa->ps->result_not_empty); + cnd_broadcast(&wa->ps->result_not_full); + mtx_unlock(&wa->ps->result_mutex); + break; + } Chunk* chunk; while ((chunk = directory_scanner_next(ds)) != NULL) { - queue_enqueue_multithreaded(wa->ps->result_queue, chunk, &wa->ps->result_mutex, - &wa->ps->result_not_empty, &wa->ps->result_not_full); + if (!queue_enqueue_multithreaded_cancel(wa->ps->result_queue, chunk, &wa->ps->result_mutex, + &wa->ps->result_not_empty, &wa->ps->result_not_full, + &wa->ps->cancelled)) { + chunk_destroy(chunk); + break; + } + } + if (directory_scanner_failed(ds)) { + mtx_lock(&wa->ps->result_mutex); + wa->ps->failed = true; + atomic_store(&wa->ps->cancelled, true); + cnd_broadcast(&wa->ps->result_not_empty); + cnd_broadcast(&wa->ps->result_not_full); + mtx_unlock(&wa->ps->result_mutex); } directory_scanner_destroy(ds); free(wa->dirs[i]); @@ -338,6 +372,7 @@ ParallelScanner* parallel_scanner_create(char* root_directory, bool use_metadata free(ps); return NULL; } + atomic_init(&ps->cancelled, false); int init = 0; bool ok = true; if (mtx_init(&ps->result_mutex, mtx_plain) != thrd_success) @@ -500,8 +535,10 @@ ParallelScanner* parallel_scanner_create(char* root_directory, bool use_metadata if (!first) { first = c; } else { - queue_enqueue_multithreaded(ps->result_queue, c, &ps->result_mutex, &ps->result_not_empty, - &ps->result_not_full); + if (!queue_enqueue(ps->result_queue, c)) { + chunk_destroy(c); + ps->failed = true; + } } if (i < root_files->size - 1) { batch = array_list_create(NULL); @@ -585,7 +622,9 @@ Chunk* parallel_scanner_next(ParallelScanner* ps) { return c; } if (ps->num_threads == 0) { + mtx_lock(&ps->result_mutex); ps->done = true; + mtx_unlock(&ps->result_mutex); return NULL; } Chunk* chunk = queue_dequeue_multithreaded( @@ -593,12 +632,19 @@ Chunk* parallel_scanner_next(ParallelScanner* ps) { return chunk; } +bool parallel_scanner_failed(const ParallelScanner* ps) { + return ps == NULL || ps->failed; +} + void parallel_scanner_destroy(ParallelScanner* ps) { if (!ps) return; + mtx_lock(&ps->result_mutex); ps->done = true; - cnd_signal(&ps->result_not_empty); + atomic_store(&ps->cancelled, true); + cnd_broadcast(&ps->result_not_empty); cnd_broadcast(&ps->result_not_full); + mtx_unlock(&ps->result_mutex); for (int i = 0; i < ps->num_threads; i++) thrd_join(ps->threads[i], NULL); free(ps->threads); diff --git a/src/client/scanner.h b/src/client/scanner.h index e4538e4..982b3d2 100644 --- a/src/client/scanner.h +++ b/src/client/scanner.h @@ -6,6 +6,7 @@ #include #include #include +#include typedef struct { Queue* directories; @@ -26,6 +27,7 @@ typedef struct { bool safe_links; bool copy_unsafe_links; bool checksum; + bool failed; } DirectoryScanner; typedef struct { @@ -36,6 +38,8 @@ typedef struct { int num_threads; thrd_t* threads; bool done; + bool failed; + atomic_bool cancelled; int completed; Chunk* initial_chunk; } ParallelScanner; @@ -48,6 +52,7 @@ DirectoryScanner* directory_scanner_create(const char* root_directory, bool use_ bool follow_symlinks, bool copy_links, bool safe_links, bool copy_unsafe_links, bool checksum); Chunk* directory_scanner_next(DirectoryScanner* scanner); +bool directory_scanner_failed(const DirectoryScanner* scanner); void directory_scanner_destroy(DirectoryScanner* scanner); ParallelScanner* parallel_scanner_create(char* root_directory, bool use_metadata, @@ -58,6 +63,7 @@ ParallelScanner* parallel_scanner_create(char* root_directory, bool use_metadata int num_threads, bool follow_symlinks, bool copy_links, bool safe_links, bool copy_unsafe_links, bool checksum); Chunk* parallel_scanner_next(ParallelScanner* scanner); +bool parallel_scanner_failed(const ParallelScanner* scanner); void parallel_scanner_destroy(ParallelScanner* scanner); #endif diff --git a/src/shared/file.c b/src/shared/file.c index c4419da..063d150 100644 --- a/src/shared/file.c +++ b/src/shared/file.c @@ -3,6 +3,7 @@ #include #include #include +#include #include #include #include @@ -10,6 +11,7 @@ #include #include #include +#include #include "compression.h" #include "delta.h" @@ -147,6 +149,8 @@ bool file_send_single_calls(File* file, int file_descriptor, bool use_metadata, static bool to_disk_secure(const char* path, const void* data, unsigned long long data_size, bool inplace, bool sparse, FileMetadata* metadata); +static int open_secure_parent(const char* path, char** leaf_out); +static bool rename_secure(const char* old_path, const char* new_path); static bool path_is_within_root(const char* root, const char* path) { size_t n = strlen(root); @@ -162,7 +166,10 @@ bool file_save_to_disk(const char* root_directory, File* file, const Config* con const char* partial_dir = (config && config->partial_dir) ? config->partial_dir : NULL; char *confined_backup = NULL, *confined_partial = NULL; - if (has_path_traversal(file->path)) { + if (!file || !file->path || !file->data || has_path_traversal(file->path) || + (backup_enabled && + (!backup_suffix || backup_suffix[0] == '\0' || strchr(backup_suffix, '/') != NULL || + strcmp(backup_suffix, ".") == 0 || strcmp(backup_suffix, "..") == 0))) { log_message(LOG_LEVEL_ERROR, "Path traversal detected in file path: %s", file->path); return false; } @@ -259,7 +266,14 @@ bool file_save_to_disk(const char* root_directory, File* file, const Config* con mkdir_r(bdir); free(backup_dir_path); } - rename(disk_path, backup_path); + if (!rename_secure(disk_path, backup_path)) { + free(backup_path); + free(resolved_root); + free(confined_backup); + free(confined_partial); + free(disk_path); + return false; + } free(backup_path); } } @@ -315,24 +329,6 @@ bool file_save_to_disk(const char* root_directory, File* file, const Config* con return ok; } -static void* old_data_from_path(const char* full_path, unsigned long long old_size) { - void* data = malloc((size_t)old_size); - if (!data) - return NULL; - FILE* fp = fopen(full_path, "rb"); - if (!fp) { - free(data); - return NULL; - } - size_t nread = fread(data, 1, (size_t)old_size, fp); - fclose(fp); - if (nread != (size_t)old_size) { - free(data); - return NULL; - } - return data; -} - static File* receive_delta_file(int fd, const Config* config, const char* check_path, void* old_data, unsigned long long old_size) { if (!old_data) @@ -518,22 +514,54 @@ File* receive_incremental_check(int fd, const Config* config, bool* skipped) { char* full_path = path_cat(config->receive_root_directory, check_path); struct stat st; - bool has_old_file = (full_path && lstat(full_path, &st) == 0); + bool has_old_file = false; + int old_fd = -1; + if (full_path) { + char* leaf = NULL; + int parent_fd = open_secure_parent(full_path, &leaf); + if (parent_fd >= 0) { + old_fd = openat(parent_fd, leaf, O_RDONLY | O_CLOEXEC | O_NOFOLLOW); + free(leaf); + close(parent_fd); + has_old_file = old_fd >= 0 && fstat(old_fd, &st) == 0 && S_ISREG(st.st_mode); + } + } unsigned long long old_size = has_old_file ? (unsigned long long)st.st_size : 0; + void* old_data = NULL; + if (has_old_file && old_size > 0) { + old_data = malloc((size_t)old_size); + if (old_data) { + size_t got = 0; + while (got < (size_t)old_size) { + ssize_t n = read(old_fd, (char*)old_data + got, (size_t)old_size - got); + if (n <= 0) { + free(old_data); + old_data = NULL; + break; + } + got += (size_t)n; + } + } + } + if (old_fd >= 0) { + close(old_fd); + old_fd = -1; + } bool match = has_old_file && (unsigned long long)st.st_size == check_size; if (match && config->checksum) { - void* old_data = old_size > 0 ? old_data_from_path(full_path, old_size) : NULL; uint64_t old_checksum = old_size == 0 ? delta_xxhash64("", 0) : 0; if (old_data) old_checksum = delta_xxhash64(old_data, (size_t)old_size); match = (old_size == 0 || old_data) && old_checksum == check_checksum; free(old_data); + old_data = NULL; } else if (match) { match = (long long)st.st_mtime == check_mtime; } if (match) { + free(old_data); if (!send_status(fd, STATUS_OK)) { free(full_path); free(check_path); @@ -549,13 +577,15 @@ File* receive_incremental_check(int fd, const Config* config, bool* skipped) { delta_should_attempt(old_size, check_size, config->delta_max_file_size); if (try_delta) { - void* old_data = old_data_from_path(full_path, old_size); File* delta_file = receive_delta_file(fd, config, check_path, old_data, old_size); + old_data = NULL; /* receive_delta_file consumes the snapshot on every path */ if (delta_file) { free(full_path); free(check_path); return delta_file; } + free(old_data); + old_data = NULL; try_delta = false; } @@ -649,6 +679,21 @@ static int open_secure_parent(const char* path, char** leaf_out) { return fd; } +static bool rename_secure(const char* old_path, const char* new_path) { + char *old_leaf = NULL, *new_leaf = NULL; + int old_parent = open_secure_parent(old_path, &old_leaf); + int new_parent = open_secure_parent(new_path, &new_leaf); + bool ok = old_parent >= 0 && new_parent >= 0 && + renameat(old_parent, old_leaf, new_parent, new_leaf) == 0; + if (old_parent >= 0) + close(old_parent); + if (new_parent >= 0) + close(new_parent); + free(old_leaf); + free(new_leaf); + return ok; +} + static bool write_all(int fd, const void* data, unsigned long long size) { const unsigned char* p = data; unsigned long long done = 0; @@ -677,7 +722,7 @@ static bool to_disk_secure(const char* path, const void* data, unsigned long lon if (!sparse || data_size == 0 || ftruncate(fd, (off_t)data_size) == 0) ok = write_all(fd, data, data_size); if (ok && metadata) - file_restore_metadata_fd(fd, metadata); + ok = file_restore_metadata_fd(fd, metadata); } } else { char tmp[NAME_MAX]; @@ -691,7 +736,7 @@ static bool to_disk_secure(const char* path, const void* data, unsigned long lon if (ok || (!sparse || data_size == 0)) ok = write_all(fd, data, data_size); if (ok && metadata) - file_restore_metadata_fd(fd, metadata); + ok = file_restore_metadata_fd(fd, metadata); if (close(fd) != 0) ok = false; fd = -1; @@ -838,8 +883,35 @@ bool file_send_sendfile(File* file, int file_descriptor, bool use_metadata, int return false; } + /* sendfile cannot encrypt TLS records. Keep the framing identical but + route encrypted transfers through the deadline-aware IO layer. */ + if (io_get_ssl() != NULL) { + bool loaded = file->data->data != NULL || file_load_data(file); + bool ok = loaded && send_n_data(file_descriptor, file->data->data, (size_t)file_size); + close(fd); + return ok; + } + off_t offset = 0; + struct timespec deadline; + clock_gettime(CLOCK_MONOTONIC, &deadline); + deadline.tv_sec += 60; while ((unsigned long long)offset < file_size) { + struct timespec now; + clock_gettime(CLOCK_MONOTONIC, &now); + long long remaining = (long long)(deadline.tv_sec - now.tv_sec) * 1000LL + + (deadline.tv_nsec - now.tv_nsec) / 1000000LL; + if (remaining <= 0) { + close(fd); + return false; + } + struct pollfd pfd = {.fd = file_descriptor, .events = POLLOUT}; + int timeout = remaining > INT_MAX ? INT_MAX : (int)remaining; + int polled = poll(&pfd, 1, timeout); + if (polled <= 0 || (pfd.revents & (POLLERR | POLLHUP | POLLNVAL))) { + close(fd); + return false; + } ssize_t sent = sendfile(file_descriptor, fd, &offset, file_size - offset); if (sent == -1) { if (errno == EAGAIN || errno == EINTR) @@ -911,6 +983,8 @@ size_t file_content_to_buffer(File* file) { } int receive_manifest(int fd, const Config* config, int* next_status) { + int received_status = STATUS_ERROR; + int* status_out = next_status ? next_status : &received_status; int count; if (!receive_int(fd, &count)) return -1; @@ -931,18 +1005,18 @@ int receive_manifest(int fd, const Config* config, int* next_status) { return -1; } } - if (!receive_status(fd, next_status)) { + if (!receive_status(fd, status_out)) { array_list_delete(manifest); return -1; } /* Deletion is a commit operation: never perform it until the sender has completed the manifest frame successfully. */ - if (*next_status != STATUS_FINISHED || !config->use_delete) { + if (*status_out != STATUS_FINISHED || !config->use_delete) { array_list_delete(manifest); - return *next_status == STATUS_FINISHED ? 0 : -1; + return *status_out == STATUS_FINISHED ? 0 : -1; } fprintf(stderr, "Deleting files not in manifest...\n"); - delete_extras(config->receive_root_directory, manifest); + bool deletion_ok = delete_extras(config->receive_root_directory, manifest); array_list_delete(manifest); - return 0; + return deletion_ok ? 0 : -1; } diff --git a/src/shared/metadata.c b/src/shared/metadata.c index 6502e37..c18830c 100644 --- a/src/shared/metadata.c +++ b/src/shared/metadata.c @@ -188,16 +188,16 @@ void file_restore_metadata(const char* path, FileMetadata* metadata) { log_message(LOG_LEVEL_WARNING, "Failed to set timestamps on %s: %s", path, strerror(errno)); } -void file_restore_metadata_fd(int fd, FileMetadata* metadata) { +bool file_restore_metadata_fd(int fd, FileMetadata* metadata) { if (fd < 0 || metadata == NULL) - return; + return metadata == NULL; + bool ok = true; if (fchmod(fd, metadata->mode & 07777 & ~(S_ISUID | S_ISGID)) != 0) - log_message(LOG_LEVEL_WARNING, "Failed to fchmod received file: %s", strerror(errno)); - if (fchown(fd, metadata->uid, metadata->gid) != 0 && errno != EPERM) - log_message(LOG_LEVEL_WARNING, "Failed to fchown received file: %s", strerror(errno)); - struct timespec times[2] = {{.tv_sec = metadata->mtime_sec, .tv_nsec = metadata->mtime_nsec}, + ok = false; + /* Client uid/gid values are deliberately not authoritative. */ + struct timespec times[2] = {{.tv_sec = 0, .tv_nsec = UTIME_OMIT}, {.tv_sec = metadata->mtime_sec, .tv_nsec = metadata->mtime_nsec}}; if (futimens(fd, times) != 0) - log_message(LOG_LEVEL_WARNING, "Failed to restore received file timestamps: %s", - strerror(errno)); + ok = false; + return ok; } diff --git a/src/shared/metadata.h b/src/shared/metadata.h index 7654dfc..0ab6859 100644 --- a/src/shared/metadata.h +++ b/src/shared/metadata.h @@ -30,6 +30,6 @@ FileMetadata* metadata_from_buf(char** buf); bool metadata_send(int file_descriptor, FileMetadata* m); FileMetadata* metadata_receive(int file_descriptor, int* ok); void file_restore_metadata(const char* path, FileMetadata* metadata); -void file_restore_metadata_fd(int fd, FileMetadata* metadata); +bool file_restore_metadata_fd(int fd, FileMetadata* metadata); #endif diff --git a/src/shared/multiprocessing.c b/src/shared/multiprocessing.c index 162add0..1a5b0d4 100644 --- a/src/shared/multiprocessing.c +++ b/src/shared/multiprocessing.c @@ -239,6 +239,7 @@ int receive_thread(void* pipeline_context) { context->queue, file, &context->mutex, &context->condition_not_empty, &context->condition_not_full, &context->cancelled)) { file_destroy(file); + receiver_thread_fail(context); return thrd_error; } } else { @@ -285,6 +286,7 @@ int write_thread(void* pipeline_context) { file_destroy(file); mtx_lock(&context->mutex); atomic_store(&context->cancelled, true); + context->receiver_done = true; cnd_broadcast(&context->condition_not_full); cnd_broadcast(&context->condition_not_empty); mtx_unlock(&context->mutex); diff --git a/src/shared/protocol.c b/src/shared/protocol.c index 7b6681d..a3a3aed 100644 --- a/src/shared/protocol.c +++ b/src/shared/protocol.c @@ -33,6 +33,7 @@ void io_set_fds(int read_fd, int write_fd) { /* A descriptor switch starts a new transport; never reuse a TLS object belonging to a previous connection or test pipe. */ io_ssl = NULL; + total_allocated_bytes = 0; } static void bw_mutex_init(void) { @@ -247,7 +248,7 @@ char* receive_str(int file_descriptor) { if (!receive_n_data(file_descriptor, &size, sizeof(size_t))) return NULL; if (size > MAX_STRING_SIZE || size > SIZE_MAX - 1 || - total_allocated_bytes > MAX_CONNECTION_MEMORY - (size + 1)) { + size + 1 > MAX_CONNECTION_MEMORY - total_allocated_bytes) { log_message(LOG_LEVEL_ERROR, "String size %zu exceeds maximum %llu", size, (unsigned long long)MAX_STRING_SIZE); return NULL; @@ -260,6 +261,7 @@ char* receive_str(int file_descriptor) { return NULL; } data[size] = '\0'; + total_allocated_bytes += size + 1; log_message(LOG_LEVEL_DEBUG, "Received String: %s", data); return data; } @@ -283,22 +285,28 @@ Data* receive_data(int file_descriptor) { (unsigned long long)MAX_DATA_PAYLOAD_SIZE); return NULL; } - if (total_allocated_bytes + size > MAX_CONNECTION_MEMORY) { + size_t allocation_size = size == 0 ? 1 : (size_t)size; + if (allocation_size > MAX_CONNECTION_MEMORY - total_allocated_bytes) { log_message(LOG_LEVEL_ERROR, "Per-connection memory limit exceeded (%llu + %llu > %llu)", (unsigned long long)total_allocated_bytes, size, (unsigned long long)MAX_CONNECTION_MEMORY); return NULL; } - void* data = malloc((size_t)size); + void* data = malloc(allocation_size); if (data == NULL) return NULL; if (!receive_n_data(file_descriptor, data, (size_t)size)) { free(data); return NULL; } - total_allocated_bytes += size + 1; + total_allocated_bytes += allocation_size; log_message(LOG_LEVEL_DEBUG, "Received %lld data", size); - return data_create(data, (size_t)size); + Data* result = data_create(data, (size_t)size); + if (!result) { + free(data); + total_allocated_bytes -= allocation_size; + } + return result; } bool send_int(int file_descriptor, int data) { diff --git a/src/shared/utils.c b/src/shared/utils.c index e866a45..c1cedd0 100644 --- a/src/shared/utils.c +++ b/src/shared/utils.c @@ -137,16 +137,17 @@ static bool is_dir_in_manifest(const char* rel_path, ArrayList* manifest) { return false; } -static void delete_extras_fd(int dirfd, const char* rel_path, ArrayList* manifest) { +static bool delete_extras_fd(int dirfd, const char* rel_path, ArrayList* manifest) { int scanfd = dup(dirfd); if (scanfd < 0) - return; + return false; DIR* dir = fdopendir(scanfd); if (!dir) { close(scanfd); - return; + return false; } bool all_removed = true; + bool operation_ok = true; const struct dirent* entry; while ((entry = readdir(dir)) != NULL) { if (strcmp(entry->d_name, ".") == 0 || strcmp(entry->d_name, "..") == 0) @@ -164,9 +165,15 @@ static void delete_extras_fd(int dirfd, const char* rel_path, ArrayList* manifes } if (S_ISDIR(st.st_mode)) { int childfd = openat(dirfd, entry->d_name, O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC); - if (childfd >= 0) - delete_extras_fd(childfd, child_rel, manifest); - if (unlinkat(dirfd, entry->d_name, AT_REMOVEDIR) != 0 && errno != ENOENT) { + bool child_removed = false; + if (childfd >= 0) { + child_removed = delete_extras_fd(childfd, child_rel, manifest); + close(childfd); + } + if (child_removed && !is_dir_in_manifest(child_rel, manifest) && + unlinkat(dirfd, entry->d_name, AT_REMOVEDIR) != 0 && errno != ENOENT) { + operation_ok = false; + } else if (!child_removed) { all_removed = false; } } else { @@ -180,7 +187,7 @@ static void delete_extras_fd(int dirfd, const char* rel_path, ArrayList* manifes } if (!found) { if (unlinkat(dirfd, entry->d_name, 0) != 0 && errno != ENOENT) - all_removed = false; + operation_ok = false; fprintf(stderr, " Deleted: %s\n", child_rel); } else { all_removed = false; @@ -189,20 +196,18 @@ static void delete_extras_fd(int dirfd, const char* rel_path, ArrayList* manifes free(child_rel); } closedir(dir); - // Only remove the directory itself if it is not in the manifest - // and contained no kept entries. - if (all_removed && rel_path[0] != '\0' && !is_dir_in_manifest(rel_path, manifest)) { - /* The caller owns the directory fd; removing this name is done by its - parent, so recursive callers perform it in their own frame. */ - } + (void)all_removed; + return operation_ok; } -void delete_extras(const char* dest_root, ArrayList* manifest) { +bool delete_extras(const char* dest_root, ArrayList* manifest) { int rootfd = open(dest_root, O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC); if (rootfd < 0) - return; - delete_extras_fd(rootfd, "", manifest); - close(rootfd); + return false; + bool ok = delete_extras_fd(rootfd, "", manifest); + if (close(rootfd) != 0) + ok = false; + return ok; } bool has_path_traversal(const char* path) { diff --git a/src/shared/utils.h b/src/shared/utils.h index 1d1d085..ce01d33 100644 --- a/src/shared/utils.h +++ b/src/shared/utils.h @@ -8,7 +8,7 @@ bool mkdir_r(const char* path); char* str_dup(const char* string); char* path_cat(const char* path1, const char* path2); bool glob_match(const char* pattern, const char* str); -void delete_extras(const char* dest_root, ArrayList* manifest); +bool delete_extras(const char* dest_root, ArrayList* manifest); bool has_path_traversal(const char* path); #endif diff --git a/tests/runner.c b/tests/runner.c index 0bba0f1..ad30e09 100644 --- a/tests/runner.c +++ b/tests/runner.c @@ -25,6 +25,7 @@ #include "test_transport_tls.h" #include "test_utils.h" #include +#include // Define global test state variables int tests_run = 0; @@ -32,6 +33,7 @@ int tests_failed = 0; bool current_test_failed = false; int main() { + signal(SIGPIPE, SIG_IGN); printf("\033[1;36m=== RUNNING UNIT TESTS ===\033[0m\n\n"); RUN_TEST(test_queue); From 44c2a8bb794a58af4afb02e0d230c9bfa5c85690 Mon Sep 17 00:00:00 2001 From: TapTap Date: Mon, 10 Aug 2026 17:27:33 +0200 Subject: [PATCH 12/16] fix: satisfy static analysis in review paths --- src/shared/file.c | 3 +-- src/shared/metadata.c | 2 +- src/shared/metadata.h | 2 +- 3 files changed, 3 insertions(+), 4 deletions(-) diff --git a/src/shared/file.c b/src/shared/file.c index 063d150..d262311 100644 --- a/src/shared/file.c +++ b/src/shared/file.c @@ -170,7 +170,7 @@ bool file_save_to_disk(const char* root_directory, File* file, const Config* con (backup_enabled && (!backup_suffix || backup_suffix[0] == '\0' || strchr(backup_suffix, '/') != NULL || strcmp(backup_suffix, ".") == 0 || strcmp(backup_suffix, "..") == 0))) { - log_message(LOG_LEVEL_ERROR, "Path traversal detected in file path: %s", file->path); + log_message(LOG_LEVEL_ERROR, "Invalid file or path received"); return false; } @@ -545,7 +545,6 @@ File* receive_incremental_check(int fd, const Config* config, bool* skipped) { } if (old_fd >= 0) { close(old_fd); - old_fd = -1; } bool match = has_old_file && (unsigned long long)st.st_size == check_size; diff --git a/src/shared/metadata.c b/src/shared/metadata.c index c18830c..98da17e 100644 --- a/src/shared/metadata.c +++ b/src/shared/metadata.c @@ -188,7 +188,7 @@ void file_restore_metadata(const char* path, FileMetadata* metadata) { log_message(LOG_LEVEL_WARNING, "Failed to set timestamps on %s: %s", path, strerror(errno)); } -bool file_restore_metadata_fd(int fd, FileMetadata* metadata) { +bool file_restore_metadata_fd(int fd, const FileMetadata* metadata) { if (fd < 0 || metadata == NULL) return metadata == NULL; bool ok = true; diff --git a/src/shared/metadata.h b/src/shared/metadata.h index 0ab6859..cedb32e 100644 --- a/src/shared/metadata.h +++ b/src/shared/metadata.h @@ -30,6 +30,6 @@ FileMetadata* metadata_from_buf(char** buf); bool metadata_send(int file_descriptor, FileMetadata* m); FileMetadata* metadata_receive(int file_descriptor, int* ok); void file_restore_metadata(const char* path, FileMetadata* metadata); -bool file_restore_metadata_fd(int fd, FileMetadata* metadata); +bool file_restore_metadata_fd(int fd, const FileMetadata* metadata); #endif From 5f0aed72e71711193f02f861b7e1e125e29cd536 Mon Sep 17 00:00:00 2001 From: TapTap Date: Mon, 10 Aug 2026 17:28:49 +0200 Subject: [PATCH 13/16] fix: satisfy metadata const analysis --- src/shared/file.c | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/shared/file.c b/src/shared/file.c index d262311..0656a73 100644 --- a/src/shared/file.c +++ b/src/shared/file.c @@ -148,7 +148,7 @@ bool file_send_single_calls(File* file, int file_descriptor, bool use_metadata, } static bool to_disk_secure(const char* path, const void* data, unsigned long long data_size, - bool inplace, bool sparse, FileMetadata* metadata); + bool inplace, bool sparse, const FileMetadata* metadata); static int open_secure_parent(const char* path, char** leaf_out); static bool rename_secure(const char* old_path, const char* new_path); @@ -708,7 +708,7 @@ static bool write_all(int fd, const void* data, unsigned long long size) { } static bool to_disk_secure(const char* path, const void* data, unsigned long long data_size, - bool inplace, bool sparse, FileMetadata* metadata) { + bool inplace, bool sparse, const FileMetadata* metadata) { char* leaf = NULL; int dirfd = open_secure_parent(path, &leaf); if (dirfd < 0) From d3677066896d0fa0c9a4799ddcf6fc0adc4df757 Mon Sep 17 00:00:00 2001 From: TapTap Date: Mon, 10 Aug 2026 17:30:13 +0200 Subject: [PATCH 14/16] fix: satisfy file API const analysis --- src/shared/file.c | 2 +- src/shared/file.h | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/shared/file.c b/src/shared/file.c index 0656a73..de330e4 100644 --- a/src/shared/file.c +++ b/src/shared/file.c @@ -157,7 +157,7 @@ static bool path_is_within_root(const char* root, const char* path) { return strncmp(root, path, n) == 0 && (path[n] == '\0' || path[n] == '/'); } -bool file_save_to_disk(const char* root_directory, File* file, const Config* config) { +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; bool sparse = config && config->preserve_sparse; diff --git a/src/shared/file.h b/src/shared/file.h index 27cbadb..eb280c3 100644 --- a/src/shared/file.h +++ b/src/shared/file.h @@ -37,7 +37,7 @@ FileMetadata* file_metadata_create(const struct stat* stats); void file_metadata_destroy(void* metadata); bool to_disk(const char* path, const void* data, unsigned long long data_size, bool inplace, bool sparse); -bool file_save_to_disk(const char* root_directory, File* file, const Config* config); +bool file_save_to_disk(const char* root_directory, const File* file, const Config* config); File* receive_incremental_check(int fd, const Config* config, bool* skipped); int receive_manifest(int fd, const Config* config, int* next_status); From 690bf72d7f58e12ddba1fb59e2fed8ea208626f0 Mon Sep 17 00:00:00 2001 From: TapTap Date: Mon, 10 Aug 2026 19:10:02 +0200 Subject: [PATCH 15/16] fix: close remaining PR 208 review findings --- README.md | 6 +- src/client/scanner.c | 148 ++++++++++++++++++++++++++++++---- src/client/scanner.h | 2 + src/server/server.c | 34 ++++++-- src/shared/file.c | 61 +++++++++++++- src/shared/file.h | 1 + src/shared/metadata.c | 4 +- src/shared/multiprocessing.c | 11 +++ src/shared/utils.c | 10 ++- src/shared/utils.h | 1 + tests/integration/test_ssh.py | 36 +++++---- 11 files changed, 268 insertions(+), 46 deletions(-) diff --git a/README.md b/README.md index f692060..04bc1df 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ A high-performance file synchronization system with SSH and TCP transport, TLS e 5. **Multithreading**: producer-consumer pipeline with thread-safe queues (scanner → loader → sender) 6. **Incremental sync**: skip files unchanged since last transfer (compares size + mtime) 7. **Batch incremental**: send incremental checks in batched groups for reduced round-trips -8. **Metadata preservation**: `mode`, `uid`, `gid`, `mtime` restored on disk when enabled +8. **Metadata preservation**: file mode and mtime are restored when enabled; ownership and atime are intentionally not restored 9. **`sendfile()` zero-copy** on TCP (~2× faster on loopback) 10. **SSH ControlMaster** for connection reuse across repeated invocations 11. **Bandwidth limiting**: token-bucket throttling (`--bwlimit`) @@ -112,7 +112,7 @@ Config negotiation is sender-driven: the client serializes transfer options and | `-m` | Multithreading mode | | `-s` | Chunk serialization (batch all files per chunk) | | `-f, --sendfile` | Sendfile zero-copy. Incompatible with `-c` / `-s`. TCP only. | -| `-M, --preserve` | Preserve file metadata (mode, uid, gid, mtime) | +| `-M, --preserve` | Preserve supported file metadata (mode and mtime; ownership and atime are unsupported) | | `-n, --dry-run` | Scan and print what would be transferred | | `-p ` | SSH port (default: 22) | | `-v, --verbose` | Enable debug logging | @@ -178,7 +178,7 @@ Config negotiation is sender-driven: the client serializes transfer options and ### Data Structures 1. **Chunk** — collection of files (~10 MB total by default) 2. **File** — path, content (`Data`), optional `FileMetadata` pointer -3. **FileMetadata** — `mode`, `uid`, `gid`, `mtime_sec`, `mtime_nsec` +3. **FileMetadata** — `mode`, `uid`, `gid`, `mtime_sec`, `mtime_nsec`; uid/gid are advisory wire fields and are never applied by the receiver; atime is unsupported 4. **Config** — runtime parameters (transported over wire, TLS settings excluded). Includes `timeout`, `contimeout`, `quiet`, `backup`, `backup_dir`, `stats`, `max_depth`, `log_file`, `queue_size`. 5. **Queue** — thread-safe bounded queue with condition variables 6. **DirectoryScanner** — recursive BFS traversal with exclude and include pattern support, max-depth enforcement diff --git a/src/client/scanner.c b/src/client/scanner.c index aef3fef..a2ea713 100644 --- a/src/client/scanner.c +++ b/src/client/scanner.c @@ -11,6 +11,7 @@ #include #include #include +#include typedef struct { char* path; @@ -38,6 +39,19 @@ static DirEntry* dir_entry_create(const char* path, int depth) { return de; } +static bool safe_relative_link(const char* source_root, const char* containing_dir, + const char* link_target) { + char root[PATH_MAX]; + if (!realpath(source_root, root)) + return false; + char* joined = path_cat(containing_dir, link_target); + char resolved[PATH_MAX]; + bool safe = joined && realpath(joined, resolved) && strncmp(root, resolved, strlen(root)) == 0 && + (resolved[strlen(root)] == '\0' || resolved[strlen(root)] == '/'); + free(joined); + return safe; +} + DirectoryScanner* directory_scanner_create(const char* root_directory, bool use_metadata, unsigned long long chunk_size, char** exclude_patterns, int exclude_count, char** include_patterns, @@ -45,10 +59,14 @@ DirectoryScanner* directory_scanner_create(const char* root_directory, bool use_ unsigned long long min_size, int max_depth, bool follow_symlinks, bool copy_links, bool safe_links, bool copy_unsafe_links, bool checksum) { - DirectoryScanner* scanner = malloc(sizeof(DirectoryScanner)); + DirectoryScanner* scanner = calloc(1, sizeof(DirectoryScanner)); if (scanner == NULL) return NULL; scanner->directories = queue_create(100, dir_entry_destroy); + if (!scanner->directories) { + free(scanner); + return NULL; + } scanner->current_dir = NULL; scanner->current_path = NULL; scanner->use_metadata = use_metadata; @@ -66,6 +84,7 @@ DirectoryScanner* directory_scanner_create(const char* root_directory, bool use_ scanner->safe_links = safe_links; scanner->copy_unsafe_links = copy_unsafe_links; scanner->checksum = checksum; + scanner->failed = false; DirEntry* root = dir_entry_create(root_directory, 0); if (!root) { queue_destroy(scanner->directories); @@ -95,8 +114,12 @@ void directory_scanner_destroy(DirectoryScanner* scanner) { static Chunk* chunk_data_to_chunk(ArrayList* chunk_data) { void** chunk_items = array_list_to_array(chunk_data); + if (!chunk_items) + return NULL; Chunk* chunk = chunk_create((File**)chunk_items, chunk_data->size); free(chunk_items); + if (!chunk) + return NULL; chunk_data->item_destroyer = NULL; array_list_delete(chunk_data); return chunk; @@ -157,6 +180,10 @@ Chunk* directory_scanner_next(DirectoryScanner* scanner) { continue; char* cur_path = path_cat(scanner->current_path, entry->d_name); + if (!cur_path) { + scanner->failed = true; + break; + } struct stat stats; struct stat lstats; bool is_symlink = false; @@ -180,7 +207,8 @@ Chunk* directory_scanner_next(DirectoryScanner* scanner) { continue; } link_target[len] = '\0'; - if (link_target[0] == '/') { + if (link_target[0] == '/' || + !safe_relative_link(scanner->current_path, scanner->current_path, link_target)) { free(cur_path); continue; } @@ -215,8 +243,10 @@ Chunk* directory_scanner_next(DirectoryScanner* scanner) { int next_depth = scanner->current_depth + 1; if (scanner->max_depth <= 0 || next_depth < scanner->max_depth) { DirEntry* de = dir_entry_create(cur_path, next_depth); - if (!queue_enqueue(scanner->directories, de)) + if (!de || !queue_enqueue(scanner->directories, de)) { dir_entry_destroy(de); + scanner->failed = true; + } } free(cur_path); } else { @@ -259,11 +289,18 @@ Chunk* directory_scanner_next(DirectoryScanner* scanner) { File* file = file_create(cur_path); if (file == NULL) { free(cur_path); + scanner->failed = true; continue; } file->data->size = stats.st_size; if (scanner->use_metadata) file->metadata = file_metadata_create(&stats); + if (scanner->use_metadata && !file->metadata) { + file_destroy(file); + free(cur_path); + scanner->failed = true; + break; + } if (!array_list_add(chunk_data, file)) { file_destroy(file); scanner->failed = true; @@ -272,14 +309,21 @@ Chunk* directory_scanner_next(DirectoryScanner* scanner) { chunk_data_size += file->data->size; if (chunk_data_size > scanner->chunk_size) { free(cur_path); - return chunk_data_to_chunk(chunk_data); + Chunk* result = chunk_data_to_chunk(chunk_data); + if (!result) + scanner->failed = true; + return result; } free(cur_path); } } - if (chunk_data->size > 0) - return chunk_data_to_chunk(chunk_data); + if (chunk_data->size > 0) { + Chunk* result = chunk_data_to_chunk(chunk_data); + if (!result) + scanner->failed = true; + return result; + } array_list_delete(chunk_data); return NULL; } @@ -349,7 +393,7 @@ static int parallel_worker_thread(void* arg) { free(wa); mtx_lock(&ps->result_mutex); ps->completed++; - if (ps->completed >= ps->num_threads) { + if (ps->completed >= ps->expected_threads) { ps->done = true; cnd_signal(&ps->result_not_empty); } @@ -409,6 +453,13 @@ ParallelScanner* parallel_scanner_create(char* root_directory, bool use_metadata ArrayList* root_files = array_list_create(file_destroy); ArrayList* subdirs = array_list_create(free); + if (!root_files || !subdirs) { + array_list_delete(root_files); + array_list_delete(subdirs); + closedir(dir); + parallel_scanner_destroy(ps); + return NULL; + } struct dirent* entry; while ((entry = readdir(dir)) != NULL) { if (strcmp(entry->d_name, ".") == 0 || strcmp(entry->d_name, "..") == 0) @@ -438,7 +489,8 @@ ParallelScanner* parallel_scanner_create(char* root_directory, bool use_metadata continue; } link_target[len] = 0; - if (link_target[0] == '/') { + if (link_target[0] == '/' || + !safe_relative_link(root_directory, root_directory, link_target)) { free(cur_path); continue; } @@ -473,7 +525,10 @@ ParallelScanner* parallel_scanner_create(char* root_directory, bool use_metadata } if (S_ISDIR(st.st_mode)) { - array_list_add(subdirs, cur_path); + if (!array_list_add(subdirs, cur_path)) { + free(cur_path); + ps->failed = true; + } } else { bool excluded = false; for (int i = 0; i < exclude_count; i++) { @@ -506,12 +561,22 @@ ParallelScanner* parallel_scanner_create(char* root_directory, bool use_metadata } File* file = file_create(cur_path); free(cur_path); - if (!file) + if (!file) { + ps->failed = true; continue; + } file->data->size = st.st_size; if (use_metadata) file->metadata = file_metadata_create(&st); - array_list_add(root_files, file); + if (use_metadata && !file->metadata) { + file_destroy(file); + ps->failed = true; + continue; + } + if (!array_list_add(root_files, file)) { + file_destroy(file); + ps->failed = true; + } } } closedir(dir); @@ -519,16 +584,40 @@ ParallelScanner* parallel_scanner_create(char* root_directory, bool use_metadata unsigned long long cs = chunk_size > 0 ? chunk_size : DESIRED_CHUNK_SIZE; if (root_files->size > 0) { ArrayList* batch = array_list_create(NULL); + if (!batch) { + ps->failed = true; + array_list_delete(root_files); + array_list_delete(subdirs); + parallel_scanner_destroy(ps); + return NULL; + } unsigned long long batch_size = 0; Chunk* first = NULL; for (int i = 0; i < root_files->size; i++) { File* f = (File*)root_files->items[i]; - array_list_add(batch, f); + if (!array_list_add(batch, f)) { + ps->failed = true; + break; + } batch_size += f->data->size; if (batch_size >= cs || i == root_files->size - 1) { void** items = array_list_to_array(batch); + if (!items) { + ps->failed = true; + batch->item_destroyer = file_destroy; + array_list_delete(batch); + batch = NULL; + break; + } Chunk* c = chunk_create((File**)items, batch->size); free(items); + if (!c) { + ps->failed = true; + batch->item_destroyer = file_destroy; + array_list_delete(batch); + batch = NULL; + break; + } batch->item_destroyer = NULL; array_list_delete(batch); batch = NULL; @@ -542,6 +631,10 @@ ParallelScanner* parallel_scanner_create(char* root_directory, bool use_metadata } if (i < root_files->size - 1) { batch = array_list_create(NULL); + if (!batch) { + ps->failed = true; + break; + } batch_size = 0; } } @@ -561,6 +654,7 @@ ParallelScanner* parallel_scanner_create(char* root_directory, bool use_metadata if (subdirs->size > 0) { ps->num_threads = n; + ps->expected_threads = n; ps->threads = calloc(n, sizeof(thrd_t)); if (!ps->threads) { array_list_delete(subdirs); @@ -570,21 +664,37 @@ ParallelScanner* parallel_scanner_create(char* root_directory, bool use_metadata int dirs_per_thread = subdirs->size / n; int remainder = subdirs->size % n; int start = 0; + ps->num_threads = 0; for (int t = 0; t < n; t++) { int count = dirs_per_thread + (t < remainder ? 1 : 0); if (count == 0) break; ParallelWorkerArg* wa = calloc(1, sizeof(ParallelWorkerArg)); - if (!wa) + if (!wa) { + ps->failed = true; break; + } wa->ps = ps; wa->dirs = calloc(count, sizeof(char*)); if (!wa->dirs) { free(wa); + ps->failed = true; break; } - for (int j = 0; j < count; j++) + bool dup_ok = true; + for (int j = 0; j < count; j++) { wa->dirs[j] = str_dup((char*)subdirs->items[start + j]); + if (!wa->dirs[j]) + dup_ok = false; + } + if (!dup_ok) { + for (int j = 0; j < count; j++) + free(wa->dirs[j]); + free(wa->dirs); + free(wa); + ps->failed = true; + break; + } wa->dir_count = count; wa->use_metadata = use_metadata; wa->chunk_size = cs; @@ -606,9 +716,17 @@ ParallelScanner* parallel_scanner_create(char* root_directory, bool use_metadata free(wa->dirs[j]); free(wa->dirs); free(wa); - ps->num_threads = t; + ps->failed = true; + atomic_store(&ps->cancelled, true); + ps->expected_threads = ps->created_threads; + mtx_lock(&ps->result_mutex); + cnd_broadcast(&ps->result_not_empty); + cnd_broadcast(&ps->result_not_full); + mtx_unlock(&ps->result_mutex); break; } + ps->num_threads++; + ps->created_threads++; } } array_list_delete(subdirs); diff --git a/src/client/scanner.h b/src/client/scanner.h index 982b3d2..0bff53c 100644 --- a/src/client/scanner.h +++ b/src/client/scanner.h @@ -36,6 +36,8 @@ typedef struct { cnd_t result_not_empty; cnd_t result_not_full; int num_threads; + int expected_threads; + int created_threads; thrd_t* threads; bool done; bool failed; diff --git a/src/server/server.c b/src/server/server.c index 2cc98f0..ee34957 100644 --- a/src/server/server.c +++ b/src/server/server.c @@ -16,10 +16,12 @@ #include #include #include +#include #include #include static char* authorized_root; +static int authorized_root_fd = -1; static bool allow_delete; static bool path_is_within(const char* root, const char* path) { @@ -27,12 +29,27 @@ static bool path_is_within(const char* root, const char* path) { return strncmp(root, path, n) == 0 && (path[n] == '\0' || path[n] == '/'); } +static bool valid_batch_path(const char* path) { + return path && path[0] != '\0' && path[0] != '/' && !has_path_traversal(path) && + strchr(path, '\0') == path + strlen(path); +} + static bool __attribute__((unused)) configure_authorization(const char* root) { char resolved[PATH_MAX]; if (!root || !realpath(root, resolved)) return false; authorized_root = str_dup(resolved); - return authorized_root != NULL; + if (!authorized_root) + return false; + authorized_root_fd = open(resolved, O_RDONLY | O_DIRECTORY | O_CLOEXEC); + if (authorized_root_fd < 0) { + free(authorized_root); + authorized_root = NULL; + return false; + } + file_set_authorized_root(authorized_root_fd, authorized_root); + utils_set_authorized_root_fd(authorized_root_fd); + return true; } int receive_files(Config* config, int fd) { @@ -96,17 +113,21 @@ int receive_files(Config* config, int fd) { free(check_path); return -1; } + if (!valid_batch_path(check_path)) { + free(check_path); + send_status(fd, STATUS_ERROR); + return -1; + } char* full_path = path_cat(config->receive_root_directory, check_path); struct stat st; bool has_old = full_path && lstat(full_path, &st) == 0; bool match = has_old && (unsigned long long)st.st_size == check_size && (long long)st.st_mtime == check_mtime; - if (match) - send_status(fd, STATUS_OK); - else - send_status(fd, STATUS_NEXT); + bool sent = send_status(fd, match ? STATUS_OK : STATUS_NEXT); free(full_path); free(check_path); + if (!sent) + return -1; } goto next; } else { @@ -324,6 +345,9 @@ int main(int argc, char* argv[]) { if (stdio_mode) { io_set_fds(STDIN_FILENO, STDOUT_FILENO); handler(STDIN_FILENO); + file_set_authorized_root(-1, NULL); + utils_set_authorized_root_fd(-1); + close(authorized_root_fd); free(authorized_root); return 0; } diff --git a/src/shared/file.c b/src/shared/file.c index de330e4..b333ce1 100644 --- a/src/shared/file.c +++ b/src/shared/file.c @@ -37,6 +37,8 @@ bool file_checksum(File* file, uint64_t* checksum) { } File* file_create(const char* path) { + if (!path) + return NULL; File* file = (File*)malloc(sizeof(File)); if (file == NULL) { perror("ERROR: Could not allocate memory for file struct"); @@ -102,6 +104,8 @@ bool file_load_data(File* file) { if (file == NULL) return false; if (file->data->data == NULL) { + if (file->data->size == 0) + return true; file->data->data = malloc(file->data->size); if (file->data->data == NULL) { perror("Could not allocate memory for file data"); @@ -121,6 +125,8 @@ bool file_load_data(File* file) { bool file_send_single_calls(File* file, int file_descriptor, bool use_metadata, int compression_level, bool send_path) { + if (!file || !file->path || !file->data) + return false; const Data* data_to_send = file->data; Data* compressed_data = NULL; if (compression_level > 0 && !compression_should_skip(file->path)) { @@ -151,6 +157,14 @@ static bool to_disk_secure(const char* path, const void* data, unsigned long lon bool inplace, bool sparse, const FileMetadata* metadata); static int open_secure_parent(const char* path, char** leaf_out); static bool rename_secure(const char* old_path, const char* new_path); +static int authorized_root_fd = -1; +static char* authorized_root_path; + +void file_set_authorized_root(int fd, const char* canonical_path) { + authorized_root_fd = fd; + free(authorized_root_path); + authorized_root_path = canonical_path ? str_dup(canonical_path) : NULL; +} static bool path_is_within_root(const char* root, const char* path) { size_t n = strlen(root); @@ -648,8 +662,25 @@ static int open_secure_parent(const char* path, char** leaf_out) { free(copy); return -1; } - int fd = (parent[0] == '/') ? open("/", O_RDONLY | O_DIRECTORY | O_CLOEXEC) - : open(".", O_RDONLY | O_DIRECTORY | O_CLOEXEC); + int fd; + if (authorized_root_fd >= 0 && authorized_root_path && path[0] == '/' && + path_is_within_root(authorized_root_path, path)) { + fd = dup(authorized_root_fd); + size_t root_len = strlen(authorized_root_path); + char* relative = str_dup(path + root_len); + if (!relative) { + free(copy); + free(leaf); + close(fd); + return -1; + } + free(copy); + copy = relative; + parent = dirname(copy); + } else { + fd = (parent[0] == '/') ? open("/", O_RDONLY | O_DIRECTORY | O_CLOEXEC) + : open(".", O_RDONLY | O_DIRECTORY | O_CLOEXEC); + } if (fd < 0) { free(copy); free(leaf); @@ -861,6 +892,8 @@ done: bool file_send_sendfile(File* file, int file_descriptor, bool use_metadata, int compression_level, bool send_path) { + if (!file || !file->path || !file->data) + return false; if (compression_level > 0) return file_send_single_calls(file, file_descriptor, use_metadata, compression_level, send_path); @@ -877,6 +910,12 @@ bool file_send_sendfile(File* file, int file_descriptor, bool use_metadata, int } unsigned long long file_size = file->data->size; + struct stat source_stat; + if (fstat(fd, &source_stat) != 0 || !S_ISREG(source_stat.st_mode) || + (unsigned long long)source_stat.st_size < file_size) { + close(fd); + return false; + } if (!send_n_data(file_descriptor, &file_size, sizeof(unsigned long long))) { close(fd); return false; @@ -885,8 +924,18 @@ bool file_send_sendfile(File* file, int file_descriptor, bool use_metadata, int /* sendfile cannot encrypt TLS records. Keep the framing identical but route encrypted transfers through the deadline-aware IO layer. */ if (io_get_ssl() != NULL) { - bool loaded = file->data->data != NULL || file_load_data(file); - bool ok = loaded && send_n_data(file_descriptor, file->data->data, (size_t)file_size); + unsigned char buffer[64 * 1024]; + unsigned long long remaining = file_size; + bool ok = true; + while (remaining > 0) { + size_t want = remaining > sizeof(buffer) ? sizeof(buffer) : (size_t)remaining; + ssize_t got = read(fd, buffer, want); + if (got <= 0 || !send_n_data(file_descriptor, buffer, (size_t)got)) { + ok = false; + break; + } + remaining -= (unsigned long long)got; + } close(fd); return ok; } @@ -919,6 +968,10 @@ bool file_send_sendfile(File* file, int file_descriptor, bool use_metadata, int close(fd); return false; } + if (sent == 0) { + close(fd); + return false; + } } close(fd); diff --git a/src/shared/file.h b/src/shared/file.h index eb280c3..e763f91 100644 --- a/src/shared/file.h +++ b/src/shared/file.h @@ -38,6 +38,7 @@ void file_metadata_destroy(void* metadata); bool to_disk(const char* path, const void* data, unsigned long long data_size, bool inplace, bool sparse); bool file_save_to_disk(const char* root_directory, const File* file, const Config* config); +void file_set_authorized_root(int fd, const char* canonical_path); File* receive_incremental_check(int fd, const Config* config, bool* skipped); int receive_manifest(int fd, const Config* config, int* next_status); diff --git a/src/shared/metadata.c b/src/shared/metadata.c index 98da17e..9260591 100644 --- a/src/shared/metadata.c +++ b/src/shared/metadata.c @@ -177,8 +177,8 @@ void file_restore_metadata(const char* path, FileMetadata* metadata) { return; if (chmod(path, metadata->mode & 07777 & ~(S_ISUID | S_ISGID)) != 0) log_message(LOG_LEVEL_WARNING, "Failed to chmod %s: %s", path, strerror(errno)); - if (chown(path, metadata->uid, metadata->gid) != 0) - log_message(LOG_LEVEL_WARNING, "Failed to chown %s: %s", path, strerror(errno)); + /* Never apply client-supplied ownership. The descriptor API below is the + receiver write path; retain this legacy API only for compatibility. */ struct timespec times[2]; times[0].tv_sec = 0; times[0].tv_nsec = UTIME_OMIT; diff --git a/src/shared/multiprocessing.c b/src/shared/multiprocessing.c index 1a5b0d4..5edbec2 100644 --- a/src/shared/multiprocessing.c +++ b/src/shared/multiprocessing.c @@ -1,4 +1,5 @@ #include "multiprocessing.h" + #include "array_list.h" #include "chunk.h" #include "config.h" @@ -13,6 +14,10 @@ #include #include +static bool valid_batch_path(const char* path) { + return path && path[0] != '\0' && path[0] != '/' && !has_path_traversal(path); +} + PipelineContextSender* pipeline_context_sender_create(Config* config, Queue* queue_scanner, Queue* queue_loader) { PipelineContextSender* context = malloc(sizeof(PipelineContextSender)); @@ -221,6 +226,12 @@ int receive_thread(void* pipeline_context) { free(check_path); RECEIVE_THREAD_FAIL(); } + if (!valid_batch_path(check_path)) { + free(check_path); + if (!send_status(file_descriptor, STATUS_ERROR)) + RECEIVE_THREAD_FAIL(); + RECEIVE_THREAD_FAIL(); + } char* full_path = path_cat(config->receive_root_directory, check_path); struct stat st; bool has_old = full_path && lstat(full_path, &st) == 0; diff --git a/src/shared/utils.c b/src/shared/utils.c index c1cedd0..9e11988 100644 --- a/src/shared/utils.c +++ b/src/shared/utils.c @@ -10,6 +10,12 @@ #include #include +static int authorized_root_fd = -1; + +void utils_set_authorized_root_fd(int fd) { + authorized_root_fd = fd; +} + bool mkdir_r(const char* path) { size_t path_len = strlen(path); char* path_duplicate = malloc(path_len + 1); @@ -201,7 +207,9 @@ static bool delete_extras_fd(int dirfd, const char* rel_path, ArrayList* manifes } bool delete_extras(const char* dest_root, ArrayList* manifest) { - int rootfd = open(dest_root, O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC); + int rootfd = authorized_root_fd >= 0 + ? dup(authorized_root_fd) + : open(dest_root, O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC); if (rootfd < 0) return false; bool ok = delete_extras_fd(rootfd, "", manifest); diff --git a/src/shared/utils.h b/src/shared/utils.h index ce01d33..d04daf5 100644 --- a/src/shared/utils.h +++ b/src/shared/utils.h @@ -9,6 +9,7 @@ char* str_dup(const char* string); char* path_cat(const char* path1, const char* path2); bool glob_match(const char* pattern, const char* str); bool delete_extras(const char* dest_root, ArrayList* manifest); +void utils_set_authorized_root_fd(int fd); bool has_path_traversal(const char* path); #endif diff --git a/tests/integration/test_ssh.py b/tests/integration/test_ssh.py index fa1234d..7cf69b9 100644 --- a/tests/integration/test_ssh.py +++ b/tests/integration/test_ssh.py @@ -4,6 +4,9 @@ import shutil import subprocess import sys import pytest +import shlex +import tempfile +import shutil sys.path.insert(0, os.path.dirname(__file__)) from common import (PROJECT_ROOT, BUILD_DIR, TEST_DATA_DIR, CLIENT_CMD, @@ -14,37 +17,38 @@ SOURCE_DIR = os.path.join(TEST_DATA_DIR, "ssh_source") DEST_DIR = os.path.join(TEST_DATA_DIR, "ssh_dest") SSH_AVAILABLE = False SSH_SKIP_REASON = "SSH localhost probe was not run" +SSH_PROBE_DIR = None def _check_ssh(): - global SSH_AVAILABLE, SSH_SKIP_REASON + global SSH_AVAILABLE, SSH_SKIP_REASON, SSH_PROBE_DIR server_path = os.path.join(BUILD_DIR, "server") if not os.path.isfile(server_path): SSH_SKIP_REASON = f"current server binary is missing: {server_path}" return try: - path = subprocess.run(["ssh", "-o", "BatchMode=yes", "localhost", "echo", "$PATH"], + SSH_PROBE_DIR = tempfile.mkdtemp(prefix="fastsync-ssh-probe-") + probe_server = os.path.join(SSH_PROBE_DIR, "fastsync-server") + os.symlink(server_path, probe_server) + command = f"{shlex.quote(probe_server)} --help" + path = subprocess.run(["ssh", "-o", "BatchMode=yes", "-o", "ConnectTimeout=5", + "localhost", "sh", "-c", command], capture_output=True, timeout=10, text=True) if path.returncode != 0: - SSH_SKIP_REASON = "SSH to localhost is unavailable" + SSH_SKIP_REASON = "SSH to localhost is unavailable or current server probe failed" return - for directory in path.stdout.strip().split(":"): - if not directory or "wrappers" in directory: - continue - probe = subprocess.run( - ["ssh", "-o", "BatchMode=yes", "localhost", - f'test -w "{directory}" && ln -sf "{server_path}" ' - f'"{directory}/fastsync-server" && test -x "{directory}/fastsync-server" ' - f'&& "{directory}/fastsync-server" --help'], - capture_output=True, timeout=10) - if probe.returncode == 0 and b"FastSync Server" in probe.stdout: - SSH_AVAILABLE = True - return - SSH_SKIP_REASON = "SSH setup could not install and validate the current server binary" + if "FastSync Server" in path.stdout: + SSH_AVAILABLE = True + return + SSH_SKIP_REASON = "SSH probe did not execute the current server binary" except FileNotFoundError: SSH_SKIP_REASON = "ssh executable is unavailable" except (OSError, subprocess.TimeoutExpired) as exc: SSH_SKIP_REASON = f"SSH setup failed: {exc}" + finally: + if SSH_PROBE_DIR: + shutil.rmtree(SSH_PROBE_DIR, ignore_errors=True) + SSH_PROBE_DIR = None _check_ssh() From b88e17826e5c28010bdc7e9f7591cb37b9db84a7 Mon Sep 17 00:00:00 2001 From: TapTap Date: Mon, 10 Aug 2026 19:20:38 +0200 Subject: [PATCH 16/16] fix: satisfy metadata const lint --- src/shared/metadata.c | 2 +- src/shared/metadata.h | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/shared/metadata.c b/src/shared/metadata.c index 9260591..49052ca 100644 --- a/src/shared/metadata.c +++ b/src/shared/metadata.c @@ -172,7 +172,7 @@ FileMetadata* metadata_receive(int file_descriptor, int* ok) { return m; } -void file_restore_metadata(const char* path, FileMetadata* metadata) { +void file_restore_metadata(const char* path, const FileMetadata* metadata) { if (metadata == NULL) return; if (chmod(path, metadata->mode & 07777 & ~(S_ISUID | S_ISGID)) != 0) diff --git a/src/shared/metadata.h b/src/shared/metadata.h index cedb32e..f6f90aa 100644 --- a/src/shared/metadata.h +++ b/src/shared/metadata.h @@ -29,7 +29,7 @@ void metadata_to_buf(char** buf, const FileMetadata* m); FileMetadata* metadata_from_buf(char** buf); bool metadata_send(int file_descriptor, FileMetadata* m); FileMetadata* metadata_receive(int file_descriptor, int* ok); -void file_restore_metadata(const char* path, FileMetadata* metadata); +void file_restore_metadata(const char* path, const FileMetadata* metadata); bool file_restore_metadata_fd(int fd, const FileMetadata* metadata); #endif