test(config): add receive-side golden oracle and sharpen fixture

Address low-severity review findings on the X-macro config refactor:

1. The golden test only hashed config_send_wire_block(), so a
   receive-side KIND that reads a different width/order could still
   round-trip symmetrically.  Add test_config_wire_golden_receive():
   capture the same hash-pinned 633-byte frame and feed it through
   config_receive(), asserting every field (config_wire_equal) plus the
   derived use_delta/use_xattrs bits and representative bounded kinds.
   Add test_config_wire_receive_bounds() for bounds the symmetric
   round-trip cannot reach: an out-of-range BOOL (hand-built frame),
   RAW_MAXALLOC zero, a malformed STR_MODULE, an over-cap
   INT_IDMAPCOUNT, and an out-of-range INT_IDENTITY chown_uid.

2. golden_config_populate() set long runs of booleans to all-1, so an
   adjacent swap within a run produced identical bytes.  Alternate the
   boolean values and make the fixture receiver-valid (chmod grammar
   "u=rwx,go=rx" is the same 11 bytes; delta_max_file_size inside the
   bound).  Re-pin the golden: len stays 633, hash is now
   9160991280011164139 (computed, not guessed).

3. Document in config.h and client_cli.c that the CLI option tables
   remain hand-maintained and are deliberately not generated from the
   wire-field X-macro (client-only fields, flag/alias/negation
   semantics).  No CLI-table rewrite.

PROTOCOL_VERSION stays "2.20.0"; src/shared/config.c is untouched and
the wire bytes are unchanged apart from the fixture's own new values.
This commit is contained in:
2026-09-13 10:56:34 +02:00
parent 4e918a1b69
commit 6ea966781f
3 changed files with 289 additions and 52 deletions
+9 -1
View File
@@ -608,7 +608,15 @@ typedef struct {
size_t offset; /* offsetof of the boolean target field in Config */
} NegatableOption;
/* Options that map directly onto a Config field with no side effects. */
/* Options that map directly onto a Config field with no side effects.
*
* NOTE: these CLI tables are intentionally NOT generated from the wire-field
* X-macro table in config.h. The two sets only overlap partially: the CLI
* surface also carries client-only fields that never cross the wire (rsh,
* outbuf, remote-option, batch paths, trust-sender, ...) and needs flag/alias/
* negation semantics that the wire table does not model. Keeping them
* hand-maintained is deliberate; the shared contract is enforced at the wire
* boundary by config.[ch] and the golden test. */
static const OptionEntry OPTION_TABLE[] = {
{"--dry-run", "-n", OPT_FLAG, offsetof(Config, dry_run)},
{"--remove-source-files", NULL, OPT_FLAG, offsetof(Config, remove_source_files)},
+6
View File
@@ -100,6 +100,12 @@ typedef enum SuperMode { SUPER_MODE_AUTO = 0, SUPER_MODE_ON = 1, SUPER_MODE_OFF
* (STR_MODULE), repeated count+array blocks (BLOCK_*), --copy-as presence
* (COPY_AS_*), and the derived --delta / use_xattrs bits (DERIVED_DELTA,
* BOOL_XATTR_DERIVE).
*
* SCOPE: this table covers ONLY the serialized wire frame. The client CLI
* option tables in client_cli.c (OPTION_TABLE / NEGATABLE_OPTIONS) are still
* hand-maintained and are deliberately NOT generated from this table: the CLI
* surface carries client-only fields and flag/alias/negation semantics that
* have no wire representation. Do not assume the two are folded together.
* =========================================================================== */
#define CONFIG_WIRE_HEADER_FIELDS(X) X(version, char*, str_dup(PROTOCOL_VERSION), STR)
+274 -51
View File
@@ -2320,75 +2320,74 @@ static void test_config_wire_roundtrip_all_fields() {
Config* populated = config_create();
EXPECT_NOT_NULL(populated);
/* The golden fixture is already receiver-valid, so the same fully-populated
* config that backs the byte-exact golden also round-trips unchanged. */
golden_config_populate(populated);
/* Keep the populated config within the server-side validation bounds. */
populated->delta_max_file_size = DELTA_MAX_FILE_SIZE;
populated->whole_file = false;
/* "X" is not part of FastSync's chmod grammar (see parse_clause), so use a
* spec the receiver-side validator accepts. */
free(populated->chmod_spec);
populated->chmod_spec = str_dup("u=rw,go=r");
EXPECT_TRUE(roundtrip_and_compare(populated));
config_delete(populated);
}
/* Populate every serialized field with a non-default value so the wire frame
* exercises each table entry. The values are deterministic. */
* exercises each table entry. Boolean runs deliberately alternate true/false:
* a run of identical booleans would make an adjacent swap (same KIND) produce
* the same byte stream, hiding a table reorder from the golden hash. The whole
* frame stays receiver-valid so the receive-side golden can feed it straight
* through config_receive() (hence the valid chmod grammar and delta bound). */
static void golden_config_populate(Config* c) {
c->eight_bit_output = true;
c->max_alloc = 123456789ULL;
c->send_directory = str_dup("/golden/src");
c->receive_root_directory = str_dup("/golden/dst");
c->save_to_disk = true;
c->use_multithreading = true;
c->use_multithreading = false;
c->use_chunk_serialization = false;
c->use_compression = false;
c->use_compression = true;
c->use_metadata = true;
c->use_executability = true;
c->use_executability = false;
c->compression_level = 7;
c->chunk_size = 65536;
c->use_sendfile = false;
c->use_delete = true;
c->use_incremental = true;
c->size_only = true;
c->size_only = false;
c->ignore_times = true;
c->use_delta = true;
c->whole_file = false;
c->delta_block_size = 4096;
c->delta_max_file_size = 987654321ULL;
c->delta_max_file_size = 200000000ULL;
c->backup = true;
c->backup_dir = str_dup("/golden/backup");
c->remove_source_files = true;
c->remove_source_files = false;
c->follow_symlinks = true;
c->copy_links = true;
c->copy_links = false;
c->safe_links = true;
c->copy_unsafe_links = true;
c->copy_unsafe_links = false;
c->preserve_hard_links = true;
c->preserve_acls = true;
c->preserve_acls = false;
c->preserve_xattrs = true;
c->preserve_devices = true;
c->preserve_devices = false;
c->preserve_sparse = true;
c->preserve_specials = true;
c->preserve_specials = false;
c->copy_devices = true;
c->write_devices = true;
c->write_devices = false;
c->ignore_existing = true;
c->existing = true;
c->existing = false;
c->update = true;
c->inplace = false;
c->delay_updates = false;
c->delay_updates = true;
c->append = false;
c->use_fsync = true;
c->append_verify = false;
c->delete_excluded = true;
c->force_delete = true;
c->force_delete = false;
c->delete_missing_args = true;
c->delete_after = true;
c->delete_after = false;
c->preallocate = true;
c->max_delete = 42;
c->relative = true;
c->relative = false;
c->prune_empty_dirs = true;
c->mkpath = true;
c->delete_during = false;
c->mkpath = false;
c->delete_during = true;
c->delete_delay = false;
c->temp_dir = str_dup("/golden/tmp");
c->partial = true;
@@ -2398,7 +2397,10 @@ static void golden_config_populate(Config* c) {
c->checksum = true;
c->modify_window = 3;
c->compress_choice = str_dup("zstd");
c->chmod_spec = str_dup("u=rwX,go=rX");
/* "u=rwx,go=rx" is the same 11 bytes as the original "u=rwX,go=rX" (so the
* frame stays 633 bytes) but X is not in FastSync's chmod grammar, and the
* receive-side golden validates the frame. */
c->chmod_spec = str_dup("u=rwx,go=rx");
c->skip_compress_set = true;
c->skip_compress_count = 2;
c->skip_compress_suffixes = calloc(2, sizeof(char*));
@@ -2410,7 +2412,7 @@ static void golden_config_populate(Config* c) {
c->checksum_algo = CHECKSUM_ALGO_MD5;
c->checksum_seed = 0x1122334455667788ULL;
c->numeric_ids = true;
c->chown_uid_set = true;
c->chown_uid_set = false;
c->chown_uid = 1234;
c->chown_gid_set = true;
c->chown_gid = 5678;
@@ -2425,11 +2427,11 @@ static void golden_config_populate(Config* c) {
c->groupmap[0].from = 7;
c->groupmap[0].to = 8;
c->preserve_atimes = true;
c->preserve_crtimes = true;
c->preserve_crtimes = false;
c->omit_dir_times = true;
c->omit_link_times = true;
c->omit_link_times = false;
c->munge_links = true;
c->keep_dirlinks = true;
c->keep_dirlinks = false;
c->fake_super = true;
c->module = str_dup("goldenmod");
c->auth_user = str_dup("goldenuser");
@@ -2441,13 +2443,27 @@ static void golden_config_populate(Config* c) {
c->copy_as_gid = 222;
}
/* FNV-1a 64 over the exact config-frame bytes emitted by
* config_send_wire_block(). This pins field order and width: any reorder or
* resize changes the hash. */
static unsigned long long capture_wire_hash(const Config* cfg, size_t* out_len) {
/* The pinned golden frame (protocol 2.20.0). The values below are the only
* thing that ties the generated table to the historical wire format; update
* them ONLY with a PROTOCOL_VERSION bump and a documented reason. */
#define GOLDEN_WIRE_LEN 633
#define GOLDEN_WIRE_HASH 9160991280011164139ULL
static unsigned long long fnv1a_64(const unsigned char* buf, size_t len) {
unsigned long long h = 1469598103934665603ULL;
for (size_t i = 0; i < len; i++) {
h ^= (unsigned long long)buf[i];
h *= 1099511628211ULL;
}
return h;
}
/* Capture the exact config-frame body emitted by config_send_wire_block() into
* a heap buffer. Returns NULL on any failure. */
static unsigned char* capture_wire_bytes(const Config* cfg, size_t* out_len) {
int p[2];
if (socketpair(AF_UNIX, SOCK_STREAM, 0, p) != 0)
return 0;
return NULL;
pid_t pid = fork();
if (pid == 0) {
close(p[1]);
@@ -2458,29 +2474,63 @@ static unsigned long long capture_wire_hash(const Config* cfg, size_t* out_len)
_exit(ok ? 0 : 1);
}
close(p[0]);
unsigned long long h = 1469598103934665603ULL;
unsigned char buf[4096];
ssize_t n;
size_t capacity = 1024;
size_t total = 0;
while ((n = read(p[1], buf, sizeof(buf))) > 0) {
for (ssize_t i = 0; i < n; i++) {
h ^= (unsigned long long)buf[i];
h *= 1099511628211ULL;
unsigned char* bytes = malloc(capacity);
if (!bytes) {
close(p[1]);
waitpid(pid, NULL, 0);
return NULL;
}
for (;;) {
if (total == capacity) {
size_t grown_capacity = capacity * 2;
unsigned char* grown = realloc(bytes, grown_capacity);
if (!grown) {
free(bytes);
close(p[1]);
waitpid(pid, NULL, 0);
return NULL;
}
bytes = grown;
capacity = grown_capacity;
}
ssize_t n = read(p[1], bytes + total, capacity - total);
if (n < 0) {
free(bytes);
close(p[1]);
waitpid(pid, NULL, 0);
return NULL;
}
if (n == 0)
break;
total += (size_t)n;
}
close(p[1]);
int status = 0;
waitpid(pid, &status, 0);
if (!WIFEXITED(status) || WEXITSTATUS(status) != 0)
return 0;
if (!WIFEXITED(status) || WEXITSTATUS(status) != 0) {
free(bytes);
return NULL;
}
*out_len = total;
return bytes;
}
/* FNV-1a 64 over the exact config-frame bytes emitted by
* config_send_wire_block(). This pins field order and width: any reorder or
* resize changes the hash. */
static unsigned long long capture_wire_hash(const Config* cfg, size_t* out_len) {
unsigned char* bytes = capture_wire_bytes(cfg, out_len);
if (!bytes)
return 0;
unsigned long long h = fnv1a_64(bytes, *out_len);
free(bytes);
return h;
}
/* Byte-for-byte wire compatibility guard (protocol 2.20.0). The expected hash
* was captured from the pre-X-macro implementation; the refactor MUST NOT
* change it. */
* pins the pre-X-macro byte stream; the refactor MUST NOT change it. */
static void test_config_wire_golden() {
if (is_running_under_valgrind())
return;
@@ -2490,9 +2540,180 @@ static void test_config_wire_golden() {
size_t len = 0;
unsigned long long h = capture_wire_hash(c, &len);
printf(" wire golden: len=%zu hash=%llu\n", len, h);
/* Captured from the pre-X-macro (protocol 2.20.0) implementation. */
EXPECT_TRUE(len == 633);
EXPECT_TRUE(h == 6163263374908258816ULL);
EXPECT_TRUE(len == GOLDEN_WIRE_LEN);
EXPECT_TRUE(h == GOLDEN_WIRE_HASH);
config_delete(c);
}
/* Receive-side oracle. Hashing the sender alone cannot catch a RECV KIND that
* reads a different width/order yet still round-trips symmetrically, so feed
* the SAME hash-pinned golden bytes through config_receive() and assert both
* the decoded struct fields and the derived bits. Because the bytes are
* anchored to the send golden, a divergence on either side fails here. */
static void test_config_wire_golden_receive() {
if (is_running_under_valgrind())
return;
Config* c = config_create();
EXPECT_NOT_NULL(c);
golden_config_populate(c);
size_t len = 0;
unsigned char* bytes = capture_wire_bytes(c, &len);
EXPECT_NOT_NULL(bytes);
EXPECT_TRUE(len == GOLDEN_WIRE_LEN);
EXPECT_TRUE(fnv1a_64(bytes, len) == GOLDEN_WIRE_HASH);
int p[2];
EXPECT_EQ_INT(socketpair(AF_UNIX, SOCK_STREAM, 0, p), 0);
io_set_fds(p[0], p[1]);
io_set_bwlimit(0);
pid_t pid = fork();
if (pid == 0) {
close(p[1]);
io_set_fds(p[0], p[0]);
io_set_bwlimit(0);
Config* recv = config_receive(p[0]);
bool ok = recv != NULL;
if (ok) {
/* Full field-by-field comparison (generated from CONFIG_WIRE_FIELDS). */
ok = config_wire_equal(c, recv);
/* Explicit spot checks of the decoded struct, including derived bits. */
ok = ok && recv->eight_bit_output && recv->use_compression && recv->use_metadata &&
!recv->use_multithreading;
ok = ok && recv->compression_level == 7 && recv->chunk_size == 65536;
ok = ok && recv->use_delta && !recv->whole_file && recv->use_xattrs;
/* Bounded/validated KINDs decoded from the pinned bytes. */
ok = ok && recv->checksum_algo == CHECKSUM_ALGO_MD5;
ok = ok && recv->super_mode == SUPER_MODE_ON;
ok = ok && recv->chown_uid == 1234 && recv->chown_gid == 5678;
ok = ok && recv->usermap_count == 2 && recv->usermap[0].from == IDENTITY_MATCH_ANY &&
recv->usermap[0].to == 1000 && recv->usermap[1].from == 5 && recv->usermap[1].to == 6;
ok = ok && recv->basis_count == 2 && recv->basis_dirs[0].type == BASIS_DEST_COMPARE &&
recv->basis_dirs[1].type == BASIS_DEST_LINK;
ok = ok && recv->module != NULL && strcmp(recv->module, "goldenmod") == 0;
ok = ok && recv->copy_as_set && recv->copy_as_uid == 111 && recv->copy_as_gid == 222;
}
config_delete(recv);
close(p[0]);
_exit(ok ? 0 : 1);
}
close(p[0]);
io_set_fds(p[1], p[1]);
io_set_bwlimit(0);
size_t written = 0;
bool wrote = true;
while (written < len) {
ssize_t n = write(p[1], bytes + written, len - written);
if (n <= 0) {
wrote = false;
break;
}
written += (size_t)n;
}
Status status = STATUS_ERROR;
bool got_status = wrote && receive_status(p[1], &status);
close(p[1]);
free(bytes);
int child_status = 0;
waitpid(pid, &child_status, 0);
EXPECT_TRUE(got_status && status == STATUS_OK);
EXPECT_TRUE(WIFEXITED(child_status) && WEXITSTATUS(child_status) == 0);
config_delete(c);
}
/* Hand-build a frame that is valid up to the first core BOOL, then write an
* out-of-range boolean (2): a BOOL receiver must reject anything but 0/1. */
static void write_frame_with_invalid_bool(int fd) {
send_str(fd, PROTOCOL_VERSION);
send_int(fd, 1); /* eight_bit_output */
unsigned long long max_alloc = DEFAULT_MAX_ALLOC;
send_n_data(fd, &max_alloc, sizeof(max_alloc));
send_str(fd, "/src");
send_str(fd, "/dst");
send_int(fd, 2); /* save_to_disk: not 0/1 */
}
/* Feed a caller-built frame into config_receive() and report whether the
* receiver rejected it. The writer runs in a child (SIGPIPE ignored) so a
* mid-frame rejection cannot kill the test process. */
static bool receive_hand_built_frame_rejected(void (*write_frame)(int fd)) {
int p[2];
if (socketpair(AF_UNIX, SOCK_STREAM, 0, p) != 0)
return false;
pid_t pid = fork();
if (pid == 0) {
(void)signal(SIGPIPE, SIG_IGN);
close(p[0]);
io_set_fds(p[1], p[1]);
io_set_bwlimit(0);
write_frame(p[1]);
close(p[1]);
_exit(0);
}
close(p[1]);
io_set_fds(p[0], p[0]);
io_set_bwlimit(0);
Config* recv = config_receive(p[0]);
bool rejected = recv == NULL;
config_delete(recv);
close(p[0]);
int status = 0;
waitpid(pid, &status, 0);
return rejected;
}
/* Receive-side bounds for the bounded/validated KINDs that the round-trip
* helper cannot exercise (an illegal value has no symmetric sender). */
static void test_config_wire_receive_bounds() {
if (is_running_under_valgrind())
return;
/* BOOL: only 0/1 is a legal wire value. */
EXPECT_TRUE(receive_hand_built_frame_rejected(write_frame_with_invalid_bool));
/* RAW_MAXALLOC: zero is rejected before it can become the session ceiling. */
Config* c = config_create();
EXPECT_NOT_NULL(c);
c->send_directory = str_dup("/src");
c->receive_root_directory = str_dup("/dst");
c->max_alloc = 0;
EXPECT_TRUE(roundtrip_config_rejected(c));
config_delete(c);
/* STR_MODULE: a name outside [A-Za-z0-9._-] is refused. */
c = config_create();
EXPECT_NOT_NULL(c);
c->send_directory = str_dup("/src");
c->receive_root_directory = str_dup("/dst");
c->module = str_dup("bad module");
EXPECT_TRUE(roundtrip_config_rejected(c));
config_delete(c);
/* INT_IDMAPCOUNT: one past the identity-map cap is refused at the count. */
c = config_create();
EXPECT_NOT_NULL(c);
c->send_directory = str_dup("/src");
c->receive_root_directory = str_dup("/dst");
c->usermap_count = MAX_IDENTITY_MAP + 1;
c->usermap = calloc((size_t)c->usermap_count, sizeof(IdentityMap));
if (c->usermap) {
for (int i = 0; i < c->usermap_count; i++) {
c->usermap[i].from = 0;
c->usermap[i].to = 0;
}
}
EXPECT_TRUE(roundtrip_config_rejected(c));
config_delete(c);
/* INT_IDENTITY: an out-of-range chown_uid (below IDENTITY_MATCH_ANY) is
* refused by the identity validator. */
c = config_create();
EXPECT_NOT_NULL(c);
c->send_directory = str_dup("/src");
c->receive_root_directory = str_dup("/dst");
c->chown_uid_set = true;
c->chown_uid = IDENTITY_MATCH_ANY - 1;
EXPECT_TRUE(roundtrip_config_rejected(c));
config_delete(c);
}
@@ -2552,6 +2773,8 @@ void test_config() {
test_config_invariants_error_all_combinations();
test_config_receive_rejects_unified_invariants();
test_config_wire_golden();
test_config_wire_golden_receive();
test_config_wire_receive_bounds();
test_config_wire_roundtrip_all_fields();
}
test_identity_copy_as_refused();