test(fuzz): add config-frame receive and identity parser fuzz targets

Add two libFuzzer harnesses (GLOBbed from tests/fuzz/*.c) and deterministic
P8 config-frame receive tests:

- fuzz_config_receive.c drives config_receive() from arbitrary bytes. It
  captures one canonical valid frame with the production sender and feeds the
  receiver four shapes: raw bytes, valid-version-prefix + fuzz bytes, valid
  frame minus the P8 tail (super_mode + copy-as) + fuzz bytes, and valid frame
  minus the usermap count + fuzz bytes. This reaches the --super/--copy-as and
  huge/negative map-count paths that random bytes cannot get through the
  preceding wire-bool gate.
- fuzz_identity_parse.c fuzzes identity_parse_copy_as/map/chown plus the
  identity_wire_valid/identity_ownership_requested predicates on a fresh
  config per input.
- test_fuzz_smoke.c gains deterministic malformed-frame cases: out-of-range
  super_mode, negative/extreme copy-as ids, non-bool copy-as presence, tail
  truncation, huge/negative usermap counts, version mismatch and a
  wrong-order field after the version gate.

Unit build (STRICT_WARNINGS) and the fuzz build are clean; both targets run
3000+ iterations with no crash. No production code changed.
This commit is contained in:
2026-09-12 15:21:33 +02:00
parent b8db810ee5
commit d97e3982b4
3 changed files with 576 additions and 0 deletions
+277
View File
@@ -1,16 +1,24 @@
#include "test_fuzz_smoke.h"
#include "chunk.h"
#include "compression.h"
#include "config.h"
#include "data.h"
#include "delta.h"
#include "metadata.h"
#include "protocol.h"
#include "test_utils.h"
#include "utils.h"
#include <errno.h>
#include <limits.h>
#include <stdint.h>
#include <stdlib.h>
#include <string.h>
#include <sys/socket.h>
#include <unistd.h>
/* P8 config-frame tail: super_mode (4) + copy-as presence (4) + uid (4) + gid (4). */
#define P8_TAIL_BYTES 16
/* Smoke test for chunk_deserialize fuzz target */
static void test_fuzz_chunk_deserialize() {
/* Create a minimal valid chunk to serialize and deserialize */
@@ -170,6 +178,272 @@ static void test_fuzz_glob_match() {
EXPECT_FALSE(glob_match("*.md", "readme.txt"));
}
/* ---- Deterministic config-frame receive hardening (P8) ----
*
* The P8 tail (--super / --copy-as) and the identity-map count only parse after
* the entire preceding frame validates, which random bytes almost never reach.
* These tests capture one valid frame with the production sender and then
* mutate/truncate the exact tail bytes. */
/* Serialize cfg with the production sender into a heap buffer. The frame is
* written into a pipe (64 KiB kernel buffer, far larger than one config frame)
* whose read end is drained afterwards; the required STATUS_OK ack is
* pre-loaded into a second pipe, so a single thread suffices. */
static bool capture_config_frame(const Config* cfg, unsigned char** out, size_t* out_len) {
*out = NULL;
*out_len = 0;
int frame_pipe[2];
int status_pipe[2];
if (pipe(frame_pipe) != 0)
return false;
if (pipe(status_pipe) != 0) {
close(frame_pipe[0]);
close(frame_pipe[1]);
return false;
}
int ack = STATUS_OK;
bool ok = write(status_pipe[1], &ack, sizeof(ack)) == (ssize_t)sizeof(ack);
if (ok) {
io_set_fds(status_pipe[0], frame_pipe[1]);
io_set_bwlimit(0);
ok = config_send(frame_pipe[1], cfg);
}
close(frame_pipe[1]);
close(status_pipe[0]);
close(status_pipe[1]);
unsigned char* buf = NULL;
if (ok) {
size_t cap = 4096;
size_t len = 0;
buf = malloc(cap);
if (!buf) {
ok = false;
}
while (ok) {
if (len == cap) {
size_t grown = cap * 2;
unsigned char* bigger = realloc(buf, grown);
if (!bigger) {
ok = false;
break;
}
buf = bigger;
cap = grown;
}
ssize_t n = read(frame_pipe[0], buf + len, cap - len);
if (n > 0) {
len += (size_t)n;
continue;
}
if (n < 0 && errno == EINTR)
continue;
break;
}
if (ok && len > 0) {
*out = buf;
*out_len = len;
buf = NULL;
}
}
close(frame_pipe[0]);
free(buf);
return *out != NULL;
}
/* Feed a raw config frame to config_receive over a socketpair. The write half
* is shut down (not closed) after the data so the receiver sees EOF but its
* STATUS_ERROR replies do not hit a closed peer. */
static bool receive_config_frame(const unsigned char* buf, size_t len) {
int sv[2];
if (socketpair(AF_UNIX, SOCK_STREAM, 0, sv) != 0)
return false;
size_t off = 0;
while (off < len) {
ssize_t n = write(sv[0], buf + off, len - off);
if (n > 0) {
off += (size_t)n;
continue;
}
if (n < 0 && errno == EINTR)
continue;
break;
}
shutdown(sv[0], SHUT_WR);
io_set_fds(sv[1], sv[1]);
io_set_bwlimit(0);
Config* cfg = config_receive(sv[1]);
bool accepted = cfg != NULL;
config_delete(cfg);
close(sv[0]);
close(sv[1]);
return accepted;
}
static void put_i32(unsigned char* buf, size_t off, int32_t value) {
memcpy(buf + off, &value, sizeof(value));
}
static size_t find_bytes(const unsigned char* haystack, size_t haystack_len,
const unsigned char* needle, size_t needle_len) {
if (needle_len == 0 || haystack_len < needle_len)
return SIZE_MAX;
for (size_t i = 0; i + needle_len <= haystack_len; i++) {
if (memcmp(haystack + i, needle, needle_len) == 0)
return i;
}
return SIZE_MAX;
}
static Config* make_copy_as_config(void) {
Config* c = config_create();
if (!c)
return NULL;
c->send_directory = str_dup("/src");
c->receive_root_directory = str_dup("/dst");
c->copy_as_set = true;
c->copy_as_uid = 0;
c->copy_as_gid = 0;
c->use_metadata = true; /* --copy-as requires the metadata path */
return c;
}
/* The P8 tail must reject an out-of-range super_mode, a negative copy-as id and
* any truncation inside the tail, while the untouched frame is accepted. */
static void test_fuzz_config_receive_p8_tail() {
Config* c = make_copy_as_config();
EXPECT_NOT_NULL(c);
unsigned char* frame = NULL;
size_t len = 0;
bool captured = capture_config_frame(c, &frame, &len);
config_delete(c);
if (!captured || len <= P8_TAIL_BYTES) {
free(frame);
EXPECT_TRUE(false);
return;
}
/* Baseline: the untouched frame is accepted. */
EXPECT_TRUE(receive_config_frame(frame, len));
unsigned char* mut = malloc(len);
EXPECT_NOT_NULL(mut);
/* super_mode outside the 0..2 tri-state is refused. */
memcpy(mut, frame, len);
put_i32(mut, len - P8_TAIL_BYTES, 99);
EXPECT_FALSE(receive_config_frame(mut, len));
put_i32(mut, len - P8_TAIL_BYTES, -1);
EXPECT_FALSE(receive_config_frame(mut, len));
/* A negative (sentinel) and an extreme copy-as uid/gid are refused. */
memcpy(mut, frame, len);
put_i32(mut, len - P8_TAIL_BYTES, SUPER_MODE_AUTO);
put_i32(mut, len - P8_TAIL_BYTES + 4, 1);
put_i32(mut, len - P8_TAIL_BYTES + 8, -1);
put_i32(mut, len - P8_TAIL_BYTES + 12, 0);
EXPECT_FALSE(receive_config_frame(mut, len));
put_i32(mut, len - P8_TAIL_BYTES + 8, 0);
put_i32(mut, len - P8_TAIL_BYTES + 12, INT32_MIN);
EXPECT_FALSE(receive_config_frame(mut, len));
/* A presence int that is not a wire bool is refused. */
memcpy(mut, frame, len);
put_i32(mut, len - P8_TAIL_BYTES, SUPER_MODE_AUTO);
put_i32(mut, len - P8_TAIL_BYTES + 4, 2);
EXPECT_FALSE(receive_config_frame(mut, len));
/* Truncating anywhere inside the P8 tail is refused. */
EXPECT_FALSE(receive_config_frame(frame, len - 2));
EXPECT_FALSE(receive_config_frame(frame, len - P8_TAIL_BYTES));
free(mut);
free(frame);
}
/* A huge or negative --usermap count must be refused up front, never driving a
* giant allocation. The count is located by searching for a sentinel entry. */
static void test_fuzz_config_receive_huge_map_count() {
Config* c = make_copy_as_config();
EXPECT_NOT_NULL(c);
int32_t sentinel_from = 0x11223344;
int32_t sentinel_to = 0x55667788;
c->usermap = malloc(sizeof(IdentityMap));
if (!c->usermap) {
config_delete(c);
EXPECT_TRUE(false);
return;
}
c->usermap_count = 1;
c->usermap[0].from = sentinel_from;
c->usermap[0].to = sentinel_to;
unsigned char* frame = NULL;
size_t len = 0;
bool captured = capture_config_frame(c, &frame, &len);
config_delete(c);
if (!captured) {
EXPECT_TRUE(false);
return;
}
unsigned char pattern[8];
memcpy(pattern, &sentinel_from, sizeof(sentinel_from));
memcpy(pattern + sizeof(sentinel_from), &sentinel_to, sizeof(sentinel_to));
size_t entry_off = find_bytes(frame, len, pattern, sizeof(pattern));
if (entry_off == SIZE_MAX || entry_off < sizeof(int32_t)) {
free(frame);
EXPECT_TRUE(false);
return;
}
size_t count_off = entry_off - sizeof(int32_t);
/* Baseline accepted. */
EXPECT_TRUE(receive_config_frame(frame, len));
unsigned char* mut = malloc(len);
EXPECT_NOT_NULL(mut);
memcpy(mut, frame, len);
put_i32(mut, count_off, INT_MAX);
EXPECT_FALSE(receive_config_frame(mut, len));
put_i32(mut, count_off, -1);
EXPECT_FALSE(receive_config_frame(mut, len));
put_i32(mut, count_off, MAX_IDENTITY_MAP + 1);
EXPECT_FALSE(receive_config_frame(mut, len));
free(mut);
free(frame);
}
/* A mismatched version and a matching version followed by a wrong-order field
* (an int that is not a wire bool) are both refused at/just after the gate. */
static void test_fuzz_config_receive_version_gate() {
unsigned char buf[64];
size_t off = 0;
const char* bad_version = "1.2.3";
size_t bad_len = strlen(bad_version);
memcpy(buf + off, &bad_len, sizeof(bad_len));
off += sizeof(bad_len);
memcpy(buf + off, bad_version, bad_len);
off += bad_len;
EXPECT_FALSE(receive_config_frame(buf, off));
off = 0;
size_t good_len = strlen(PROTOCOL_VERSION);
memcpy(buf + off, &good_len, sizeof(good_len));
off += sizeof(good_len);
memcpy(buf + off, PROTOCOL_VERSION, good_len);
off += good_len;
put_i32(buf, off, -1);
off += sizeof(int32_t);
EXPECT_FALSE(receive_config_frame(buf, off));
}
void test_fuzz_smoke() {
test_fuzz_chunk_deserialize();
test_fuzz_compress_decompress();
@@ -177,4 +451,7 @@ void test_fuzz_smoke() {
test_fuzz_metadata_from_buf();
test_fuzz_delta_signature_deserialize();
test_fuzz_glob_match();
test_fuzz_config_receive_p8_tail();
test_fuzz_config_receive_huge_map_count();
test_fuzz_config_receive_version_gate();
}