fix(receiver): non-blocking receiver opens, inplace type gate, dry-run/B4/B5/B6

Address confirmed receiver security findings B1-B6:

B1 (HIGH): add O_NONBLOCK to the three receiver read-opens that opened an
existing destination/basis entry before the S_ISREG gate
(incremental_check_open_destination, basis_open_regular, hardlink_read_source)
so a client-planted FIFO can no longer block the receive thread forever while
the post-open type gate still rejects it.

B2 (HIGH/MED): --inplace now fstatat(AT_SYMLINK_NOFOLLOW)-probes the target and
refuses any existing non-regular entry, opens with O_NONBLOCK, and re-checks
S_ISREG on the opened fd.  This stops a FIFO from hanging the open and stops a
char/block device from being written directly (bypassing --write-devices).

B3 (MED): under --dry-run the incremental quick-skip no longer reads/hashes the
destination file for --checksum/--delta; it decides from metadata only and
reports would-transfer when the comparison is inconclusive, closing the
read-only-module content-hash oracle.

B4 (LOW): xattr_name_appliable() now gates the two system.posix_acl_* names on
preserve_acls (--acls), not the derived use_xattrs (--xattrs OR --acls).  The
receiver drops (never applies) ACL entries when -A was not negotiated while
keeping user.* working for -X.

B5 (INFO): receive_manifest_section() charges a per-entry overhead against
MAX_MANIFEST_BYTES and the aggregate entry count across all three sections is
capped at MAX_MANIFEST_ENTRIES.

B6 (MED): data_charge_session() reserves decompressed/chunk-copy bytes against
the owning ProtocolSession (MAX_CONNECTION_MEMORY) and records them on the Data
so data_destroy() releases them via the Data.owner path.  Applied to the
whole-file/append/delta decompression sites and chunk_deserialize() per-file
copies; a missing session owner degrades to the previous uncharged behavior.

Tests: FIFO destination/basis non-hang (with alarm), --inplace FIFO/device
refusal, dry-run no-read oracle test plus updated metadata-only dry-run tests,
ACL-without--acls drop, manifest total-entry cap, and chunk session charging.
This commit is contained in:
2026-09-14 16:19:26 +02:00
parent df887c73b1
commit a2370433b2
12 changed files with 635 additions and 63 deletions
+48
View File
@@ -1,8 +1,10 @@
#include "chunk.h"
#include "protocol.h"
#include "test_utils.h"
#include "utils.h"
#include <string.h>
#include <sys/socket.h>
#include <sys/stat.h>
#include <unistd.h>
@@ -279,6 +281,51 @@ static void test_chunk_special_rdev_out_of_range_rejected() {
chunk_destroy(chunk);
}
/* B6: chunk_deserialize() charges each retained per-file copy to the owning
* session's connection budget (MAX_CONNECTION_MEMORY) so queued chunk payloads
* are not held outside the per-connection ceiling; destroying the chunk returns
* the charge through the Data.owner path. */
static void test_chunk_deserialize_charges_session_budget() {
const char* path = "temp_chunk_charge.txt";
const char* content = "charge me to the connection budget";
unlink(path);
file_write_to_disk(path, content, strlen(content), false, false);
struct stat st;
EXPECT_EQ_INT(stat(path, &st), 0);
int p[2];
EXPECT_EQ_INT(socketpair(AF_UNIX, SOCK_STREAM, 0, p), 0);
ProtocolSession session;
protocol_session_init(&session, p[0], p[1]);
protocol_session_set_max_alloc(&session, 4ULL * 1024 * 1024);
File* f = file_create(path);
EXPECT_NOT_NULL(f);
f->data->size = (unsigned long long)st.st_size;
EXPECT_TRUE(file_load_data(f));
File* files[1] = {f};
Chunk* chunk = chunk_create(files, 1);
EXPECT_NOT_NULL(chunk);
Data* serialized = chunk_serialize(chunk, false);
EXPECT_NOT_NULL(serialized);
/* Simulate a received buffer carrying its owning session. */
serialized->owner = &session;
Chunk* deserialized = chunk_deserialize(serialized, false);
EXPECT_NOT_NULL(deserialized);
unsigned long long charged = atomic_load(&session.total_allocated_bytes);
EXPECT_EQ_INT((int)charged, (int)strlen(content));
chunk_destroy(deserialized);
/* The copy's charge is released with the File/Data on destroy. */
EXPECT_EQ_INT((int)atomic_load(&session.total_allocated_bytes), 0);
data_destroy(serialized);
chunk_destroy(chunk);
close(p[0]);
close(p[1]);
unlink(path);
}
void test_chunk() {
test_file_operations();
test_chunk_operations();
@@ -286,4 +333,5 @@ void test_chunk() {
test_chunk_symlink_roundtrip();
test_chunk_special_rdev_roundtrip();
test_chunk_special_rdev_out_of_range_rejected();
test_chunk_deserialize_charges_session_budget();
}