From 25767dfa86c5d912811270e7bdb3088395ebc3ec Mon Sep 17 00:00:00 2001 From: Teodor Calin Date: Wed, 27 May 2026 20:25:14 -0700 Subject: [PATCH] cintegration: fake registry lifts PilotEmbeddedStart coverage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PilotEmbeddedStart boots an in-process pkg/daemon that dials a real registry over TCP — without one, the //export short-circuits at daemon.Start() and the harness never reaches the rest of the function (13.6% coverage baseline). Adds cintegration/mockregistry/, a stdlib-only fake registry that speaks length-prefixed JSON and answers register / lookup / heartbeat with canned data. Daemon Start succeeds end-to-end (STUN fails but is non-fatal — discoverWithTempSocket falls back to the local addr). Harness now drives: - embedded_start_with_mock_registry — full boot success path - embedded_info_via_driver — proves the IPC socket is live - embedded_health_via_driver — second IPC round-trip - embedded_start_double_call — already-started guard - embedded_stop_after_start — clean teardown - embedded_start_{null_config,missing_data_dir,missing_socket_path} — defaults() + early-return branches - mock_dial_timeout(_bad_addr) — ParseSocketAddr success/error paths - mock_member_tags_set_bad_json — json.Unmarshal error branch - bad-handle smoke tests for the remaining 71–80% bindings Coverage: - libpilot total: 68.5% → 80.1% - embedded.go PilotEmbeddedStart: 13.6% → 79.5% - embedded.go PilotEmbeddedStop: 46.2% → 84.6% - embedded.go defaults: 0% → 100% - embedded.go unmarshalCString: 66.7% → 100% 89 pass, 0 fail. --- cintegration/.gitignore | 1 + cintegration/Makefile | 17 +- cintegration/harness.c | 453 ++++++++++++++++++++++++++++++ cintegration/mockregistry/go.mod | 3 + cintegration/mockregistry/main.go | 275 ++++++++++++++++++ 5 files changed, 745 insertions(+), 4 deletions(-) create mode 100644 cintegration/mockregistry/go.mod create mode 100644 cintegration/mockregistry/main.go diff --git a/cintegration/.gitignore b/cintegration/.gitignore index ae581da..ccdb2b5 100644 --- a/cintegration/.gitignore +++ b/cintegration/.gitignore @@ -3,3 +3,4 @@ harness.dSYM/ covdata/ coverage.out mockdaemon-bin +mockregistry-bin diff --git a/cintegration/Makefile b/cintegration/Makefile index fb1704c..4cfeb9d 100644 --- a/cintegration/Makefile +++ b/cintegration/Makefile @@ -21,10 +21,11 @@ LIB := ../libpilot.$(EXT) COVDIR := ./covdata HARNESS := harness MOCK := mockdaemon-bin +MOCKREG := mockregistry-bin -.PHONY: build run cover clean mock-daemon +.PHONY: build run cover clean mock-daemon mock-registry -build: $(LIB) $(HARNESS) mock-daemon +build: $(LIB) $(HARNESS) mock-daemon mock-registry $(LIB): cd .. && go build -tags coverflush -cover -covermode=atomic -coverpkg=. -buildmode=c-shared -o libpilot.$(EXT) . @@ -44,7 +45,15 @@ mock-daemon: $(MOCK) $(MOCK): mockdaemon/main.go mockdaemon/go.mod cd mockdaemon && go build -o ../$(MOCK) . -run: $(HARNESS) $(MOCK) +# mock-registry builds a TCP fake-registry the harness spawns to back +# PilotEmbeddedStart end-to-end: the embedded daemon dials this binary +# instead of the production rendezvous. See mockregistry/main.go. +mock-registry: $(MOCKREG) + +$(MOCKREG): mockregistry/main.go mockregistry/go.mod + cd mockregistry && go build -o ../$(MOCKREG) . + +run: $(HARNESS) $(MOCK) $(MOCKREG) mkdir -p $(COVDIR) ifeq ($(UNAME_S),Darwin) DYLD_LIBRARY_PATH=.. GOCOVERDIR=$(COVDIR) ./$(HARNESS) @@ -60,4 +69,4 @@ cover: run clean: rm -rf $(COVDIR) $(HARNESS) coverage.out - rm -f ../libpilot.$(EXT) ../libpilot.h $(MOCK) + rm -f ../libpilot.$(EXT) ../libpilot.h $(MOCK) $(MOCKREG) diff --git a/cintegration/harness.c b/cintegration/harness.c index 1972963..5c6fd94 100644 --- a/cintegration/harness.c +++ b/cintegration/harness.c @@ -63,6 +63,11 @@ static int has_error(const char *json) { return strstr(json, "\"error\"") != NULL; } +// Forward declaration so embedded-daemon tests defined above the mock- +// daemon helpers can call the predicate. Definition is co-located with +// the mock-daemon helpers further down. +static int has_no_error(const char *json); + // --------------------------------------------------------------------------- // Handle-table edge cases // --------------------------------------------------------------------------- @@ -116,6 +121,92 @@ static void test_conn_close_bad_handle(void) { free_c_string(err); } +// Bad-handle smoke tests for the //export functions whose only +// uncovered branch in the baseline was the `driverFromHandle(h)` +// failure path. Each one wraps one statement-of-coverage worth of +// uncovered code per binding. Keeps total coverage just over the +// 80% threshold without spinning up the mock daemon for every one. +static void test_disconnect_bad_handle(void) { + char *err = PilotDisconnect(0, 1); + if (err == NULL || !has_error(err)) { + FAIL("disconnect_bad_handle", "expected error"); + if (err) free_c_string(err); + return; + } + PASS("disconnect_bad_handle"); + free_c_string(err); +} + +static void test_dial_timeout_bad_handle(void) { + char addr[] = "1:0001.0001.0001:80"; + struct PilotDialTimeout_return r = PilotDialTimeout(0, addr, 100); + if (r.r1 == NULL || !has_error(r.r1)) { + FAIL("dial_timeout_bad_handle", "expected error"); + if (r.r1) free_c_string(r.r1); + return; + } + PASS("dial_timeout_bad_handle"); + free_c_string(r.r1); +} + +// Stub — actual coverage for the ParseSocketAddr error branch in +// PilotDialTimeout requires a valid handle, so the real test lives +// alongside the mock-daemon helpers further down +// (test_mock_dial_timeout_malformed_addr). +static void test_dial_timeout_malformed_addr(void) { + // Bad handle path — same code line as test_dial_timeout_bad_handle. + // Kept as a sentinel so future refactors don't drop the test slot. + char addr[] = "this-is-not-a-pilot-addr"; + struct PilotDialTimeout_return r = PilotDialTimeout(0, addr, 100); + if (r.r1 == NULL || !has_error(r.r1)) { + FAIL("dial_timeout_malformed_addr", "expected error"); + if (r.r1) free_c_string(r.r1); + return; + } + PASS("dial_timeout_malformed_addr"); + free_c_string(r.r1); +} + +static void test_member_tags_set_bad_handle(void) { + char tags[] = "[\"a\"]"; + char *err = PilotMemberTagsSet(0, 1, 1, tags); + if (err == NULL || !has_error(err)) { + FAIL("member_tags_set_bad_handle", "expected error"); + if (err) free_c_string(err); + return; + } + PASS("member_tags_set_bad_handle"); + free_c_string(err); +} + +static void test_member_tags_set_invalid_json(void) { + // Bad handle short-circuits before the json.Unmarshal call, so use a + // direct mock-daemon-backed test for the invalid-JSON branch (added + // separately below). This one just exercises the bad-handle path + // with a known-good JSON body, doubling as a regression for handle + // validation ordering. + char tags[] = "[\"x\"]"; + char *err = PilotMemberTagsSet(0xFEEDC0DEFACE, 1, 1, tags); + if (err == NULL || !has_error(err)) { + FAIL("member_tags_set_invalid_json", "expected error"); + if (err) free_c_string(err); + return; + } + PASS("member_tags_set_invalid_json"); + free_c_string(err); +} + +static void test_conn_set_read_deadline_bad_handle(void) { + char *err = PilotConnSetReadDeadline(0, 0); + if (err == NULL || !has_error(err)) { + FAIL("conn_set_read_deadline_bad_handle", "expected error"); + if (err) free_c_string(err); + return; + } + PASS("conn_set_read_deadline_bad_handle"); + free_c_string(err); +} + // --------------------------------------------------------------------------- // Info / Health / TrustedPeers / Pending on invalid handle — every // handle-checking function should return an error envelope without @@ -432,6 +523,285 @@ static void test_embedded_stop_when_not_running(void) { PASS("embedded_stop_when_not_running"); } +// --------------------------------------------------------------------------- +// Embedded daemon with fake registry — full PilotEmbeddedStart success +// path. The fake registry (./mockregistry-bin) is a TCP binary that +// speaks length-prefixed JSON to satisfy the pkg/daemon Start() flow: +// it answers `register` with a canned node_id + address, and `lookup` +// with an empty networks list. STUN against -beacon-addr fails (no UDP +// listener) but pkg/daemon's discoverWithTempSocket logs that as a +// warning and falls back to the local listen address, so Start +// succeeds end-to-end. +// +// This is the only path that exercises PilotEmbeddedStart past its +// JSON-parse guard. Without a real registry the //export function +// short-circuits at the daemon.Start() error return — visible in the +// 13.6% baseline coverage of that function. +// --------------------------------------------------------------------------- + +static pid_t mockreg_pid = 0; +static char mockreg_addr[64] = {0}; +static char mockreg_addr_file[256] = {0}; + +// wait_for_file polls up to ~3 seconds for `path` to exist and be +// non-empty. Used to learn the OS-assigned port from the mock registry +// without parsing its stdout (the child renames .tmp → path so the read +// is never torn). +static int wait_for_file(const char *path, char *out, size_t outsz) { + for (int i = 0; i < 300; i++) { + FILE *f = fopen(path, "r"); + if (f != NULL) { + size_t n = fread(out, 1, outsz - 1, f); + fclose(f); + if (n > 0) { + out[n] = '\0'; + // Strip trailing newline if any. + while (n > 0 && (out[n - 1] == '\n' || out[n - 1] == '\r')) { + out[--n] = '\0'; + } + if (n > 0) return 1; + } + } + struct timespec ts = {0, 10 * 1000 * 1000}; // 10 ms + nanosleep(&ts, NULL); + } + return 0; +} + +static int start_mock_registry(void) { + snprintf(mockreg_addr_file, sizeof(mockreg_addr_file), + "/tmp/libpilot-mockreg-%d.addr", (int)getpid()); + unlink(mockreg_addr_file); + + pid_t pid = fork(); + if (pid < 0) { + fprintf(stderr, "fork mockreg: %s\n", strerror(errno)); + return 0; + } + if (pid == 0) { + execl("./mockregistry-bin", "mockregistry-bin", + "-addr-file", mockreg_addr_file, (char *)NULL); + fprintf(stderr, "execl mockregistry-bin: %s\n", strerror(errno)); + _exit(127); + } + mockreg_pid = pid; + + if (!wait_for_file(mockreg_addr_file, mockreg_addr, + sizeof(mockreg_addr))) { + fprintf(stderr, + "mockregistry-bin did not write addr file %s within 3s\n", + mockreg_addr_file); + kill(pid, SIGTERM); + waitpid(pid, NULL, 0); + mockreg_pid = 0; + return 0; + } + return 1; +} + +static void stop_mock_registry(void) { + if (mockreg_pid > 0) { + kill(mockreg_pid, SIGTERM); + waitpid(mockreg_pid, NULL, 0); + mockreg_pid = 0; + } + if (mockreg_addr_file[0] != '\0') { + unlink(mockreg_addr_file); + } +} + +// Drives the full PilotEmbeddedStart success path against a fake +// registry. Builds a per-test data dir + IPC socket, spins up the +// in-process daemon, probes it via PilotConnect+PilotInfo, then tears +// it down with PilotEmbeddedStop. +// +// The test is conservative — any path that returns a {"error":...} +// envelope (registry dial, daemon Start, IPC socket creation) is +// treated as FAIL with the error text logged, so a regression in any +// of the upstream stages surfaces clearly instead of silently passing. +static void test_embedded_start_with_mock_registry(void) { + if (!start_mock_registry()) { + fail_count++; + printf(" FAIL embedded_start_with_mock_registry: " + "could not spawn mockregistry-bin\n"); + return; + } + printf("\n[mock registry] pid=%d addr=%s\n", + (int)mockreg_pid, mockreg_addr); + + // Per-test data dir + IPC socket. /tmp avoids the macOS sun_path + // 104-byte ceiling that TMPDIR (/var/folders/...) can blow past. + char data_dir[256]; + char socket_path[256]; + snprintf(data_dir, sizeof(data_dir), + "/tmp/libpilot-emb-%d", (int)getpid()); + snprintf(socket_path, sizeof(socket_path), + "/tmp/libpilot-emb-%d.sock", (int)getpid()); + + // Best-effort clean of any stale artefacts from a prior run. + unlink(socket_path); + mkdir(data_dir, 0700); + + // Build the config JSON. registry_addr points at the fake registry + // we just spawned. beacon_addr is a closed TCP port — STUN will fail + // but daemon.Start() treats that as a warning and falls back to the + // local listen address. keepalive_sec is large so the trustRepublish + // loop never fires during the test window. trust_auto_approve avoids + // the trustedagents plugin's curated list (not registered in + // embedded mode anyway). + char config_json[1024]; + snprintf(config_json, sizeof(config_json), + "{" + "\"data_dir\":\"%s\"," + "\"socket_path\":\"%s\"," + "\"registry_addr\":\"%s\"," + "\"beacon_addr\":\"127.0.0.1:1\"," + "\"trust_auto_approve\":true," + "\"keepalive_sec\":3600," + "\"version\":\"mock-embed-1\"" + "}", + data_dir, socket_path, mockreg_addr); + + char *start_res = PilotEmbeddedStart(config_json); + if (start_res == NULL) { + FAIL("embedded_start_with_mock_registry", + "PilotEmbeddedStart returned NULL"); + stop_mock_registry(); + return; + } + if (has_error(start_res)) { + FAIL("embedded_start_with_mock_registry", start_res); + free_c_string(start_res); + // Best-effort stop in case the start half-succeeded. + char *stop_err = PilotEmbeddedStop(); + if (stop_err) free_c_string(stop_err); + stop_mock_registry(); + return; + } + PASS("embedded_start_with_mock_registry"); + free_c_string(start_res); + + // Calling PilotEmbeddedStart a second time while the singleton is + // live must error — covers the `embedded.node != nil` early-return + // branch at the top of PilotEmbeddedStart. + char *dup_res = PilotEmbeddedStart(config_json); + if (dup_res == NULL || !has_error(dup_res)) { + FAIL("embedded_start_double_call", "expected error JSON"); + } else if (strstr(dup_res, "already started") == NULL) { + FAIL("embedded_start_double_call", + "expected error to mention already started"); + } else { + PASS("embedded_start_double_call"); + } + if (dup_res) free_c_string(dup_res); + + // Connect a driver handle to the freshly-booted embedded daemon and + // probe both Info and Health. This exercises the same IPC socket + // pkg/daemon.IPC creates inside Start(), end-to-end, without going + // through any mock-side IPC. Covers the "embedded boots a real + // socket the driver can dial" guarantee. + struct PilotConnect_return conn = PilotConnect(socket_path); + if (conn.r0 == 0) { + FAIL("embedded_info_via_driver", + conn.r1 ? conn.r1 : "PilotConnect failed"); + if (conn.r1) free_c_string(conn.r1); + } else { + if (conn.r1) free_c_string(conn.r1); + char *info = PilotInfo(conn.r0); + if (!has_no_error(info)) { + FAIL("embedded_info_via_driver", info ? info : "null"); + } else { + PASS("embedded_info_via_driver"); + } + if (info) free_c_string(info); + + char *health = PilotHealth(conn.r0); + if (!has_no_error(health)) { + FAIL("embedded_health_via_driver", health ? health : "null"); + } else { + PASS("embedded_health_via_driver"); + } + if (health) free_c_string(health); + + char *closed = PilotClose(conn.r0); + if (closed) free_c_string(closed); + } + + // Tear down the embedded daemon. PilotEmbeddedStop returns either + // {"status":"stopped"} or {"status":"stopped","warning":"..."} — both + // count as success (plugin teardown is best-effort). + char *stop_res = PilotEmbeddedStop(); + if (stop_res == NULL || has_error(stop_res)) { + FAIL("embedded_stop_after_start", + stop_res ? stop_res : "null"); + } else { + PASS("embedded_stop_after_start"); + } + if (stop_res) free_c_string(stop_res); + + // Clean filesystem artefacts. The data dir gets rm-rf'd on the + // process exit anyway, but tidy up so back-to-back runs don't pile + // up identity.json files. + unlink(socket_path); + + stop_mock_registry(); +} + +// Exercises the "data_dir required" guard — first call after a clean +// state, with a config that parses but omits data_dir. PilotEmbeddedStart +// must reject this without booting the daemon. +static void test_embedded_start_missing_data_dir(void) { + const char *cfg = "{\"socket_path\":\"/tmp/x.sock\"}"; + char *res = PilotEmbeddedStart((char *)cfg); + if (res == NULL || !has_error(res)) { + FAIL("embedded_start_missing_data_dir", "expected error JSON"); + if (res) free_c_string(res); + return; + } + if (strstr(res, "data_dir") == NULL) { + FAIL("embedded_start_missing_data_dir", + "expected error to mention data_dir"); + free_c_string(res); + return; + } + PASS("embedded_start_missing_data_dir"); + free_c_string(res); +} + +// Exercises the "socket_path required" guard with the data_dir branch +// already satisfied. Also confirms the embeddedConfig.defaults() runs: +// the call should fail on missing socket, not on missing registry. +static void test_embedded_start_missing_socket_path(void) { + const char *cfg = "{\"data_dir\":\"/tmp\"}"; + char *res = PilotEmbeddedStart((char *)cfg); + if (res == NULL || !has_error(res)) { + FAIL("embedded_start_missing_socket_path", "expected error JSON"); + if (res) free_c_string(res); + return; + } + if (strstr(res, "socket_path") == NULL) { + FAIL("embedded_start_missing_socket_path", + "expected error to mention socket_path"); + free_c_string(res); + return; + } + PASS("embedded_start_missing_socket_path"); + free_c_string(res); +} + +// Calls PilotEmbeddedStart with a NULL configJSON pointer. The +// unmarshalCString helper has an explicit nil branch this exercises. +static void test_embedded_start_null_config(void) { + char *res = PilotEmbeddedStart(NULL); + if (res == NULL || !has_error(res)) { + FAIL("embedded_start_null_config", "expected error JSON"); + if (res) free_c_string(res); + return; + } + PASS("embedded_start_null_config"); + free_c_string(res); +} + // --------------------------------------------------------------------------- // Free path — passing NULL must not crash. // --------------------------------------------------------------------------- @@ -1187,6 +1557,73 @@ static void test_mock_listener_accept(void) { mock_close(b); } +// PilotDialTimeout against the mock daemon — exercises the +// ParseSocketAddr success path AND the d.DialAddrTimeout path that the +// bad-handle test could never reach. Uses a non-zero timeout so the +// timeoutMs cast doesn't trip the overflow check. +static void test_mock_dial_timeout(void) { + uint64_t h = mock_connect_or_fail("mock_dial_timeout"); + if (!h) return; + char addr[] = "1:0001.0002.0003:80"; + struct PilotDialTimeout_return r = PilotDialTimeout(h, addr, 5000); + if (r.r0 == 0 || (r.r1 && has_error(r.r1))) { + FAIL("mock_dial_timeout", r.r1 ? r.r1 : "no conn handle"); + if (r.r1) free_c_string(r.r1); + mock_close(h); + return; + } + PASS("mock_dial_timeout"); + if (r.r1) free_c_string(r.r1); + char *cc = PilotConnClose(r.r0); + if (cc) free_c_string(cc); + mock_close(h); +} + +// Drives the ParseSocketAddr error branch in PilotDialTimeout. Requires +// a valid handle so the driverFromHandle path passes, then a malformed +// addr to fail ParseSocketAddr. +static void test_mock_dial_timeout_bad_addr(void) { + uint64_t h = mock_connect_or_fail("mock_dial_timeout_bad_addr"); + if (!h) return; + char addr[] = "not-a-valid-socket-addr"; + struct PilotDialTimeout_return r = PilotDialTimeout(h, addr, 100); + if (r.r0 != 0 || r.r1 == NULL || !has_error(r.r1)) { + FAIL("mock_dial_timeout_bad_addr", "expected parse error"); + if (r.r1) free_c_string(r.r1); + mock_close(h); + return; + } + PASS("mock_dial_timeout_bad_addr"); + free_c_string(r.r1); + mock_close(h); +} + +// Drives the json.Unmarshal error branch in PilotMemberTagsSet. The +// handle-validation path is already covered; this one carries a malformed +// JSON body so the second early-return executes. +static void test_mock_member_tags_set_bad_json(void) { + uint64_t h = mock_connect_or_fail("mock_member_tags_set_bad_json"); + if (!h) return; + char bad[] = "{not a list}"; + char *err = PilotMemberTagsSet(h, 1, 1, bad); + if (err == NULL || !has_error(err)) { + FAIL("mock_member_tags_set_bad_json", "expected json error"); + if (err) free_c_string(err); + mock_close(h); + return; + } + if (strstr(err, "invalid tags JSON") == NULL) { + FAIL("mock_member_tags_set_bad_json", + "expected error to mention invalid tags JSON"); + free_c_string(err); + mock_close(h); + return; + } + PASS("mock_member_tags_set_bad_json"); + free_c_string(err); + mock_close(h); +} + // --------------------------------------------------------------------------- // Run all // --------------------------------------------------------------------------- @@ -1200,6 +1637,12 @@ int main(void) { test_close_unknown_handle(); test_listener_close_bad_handle(); test_conn_close_bad_handle(); + test_disconnect_bad_handle(); + test_dial_timeout_bad_handle(); + test_dial_timeout_malformed_addr(); + test_member_tags_set_bad_handle(); + test_member_tags_set_invalid_json(); + test_conn_set_read_deadline_bad_handle(); // Info / health / queries test_info_bad_handle(); @@ -1234,6 +1677,13 @@ int main(void) { // Embedded daemon test_embedded_start_invalid_json(); test_embedded_stop_when_not_running(); + test_embedded_start_null_config(); + test_embedded_start_missing_data_dir(); + test_embedded_start_missing_socket_path(); + // Full-boot test uses ./mockregistry-bin — spawned, queried, killed + // inside the test. Runs last in this group so a hard fail (mock + // binary missing, daemon Start panics) doesn't poison earlier checks. + test_embedded_start_with_mock_registry(); // Free test_free_null(); @@ -1271,6 +1721,9 @@ int main(void) { test_mock_member_tags(); test_mock_recv_from(); test_mock_listener_accept(); + test_mock_dial_timeout(); + test_mock_dial_timeout_bad_addr(); + test_mock_member_tags_set_bad_json(); stop_mock_daemon(); } else { diff --git a/cintegration/mockregistry/go.mod b/cintegration/mockregistry/go.mod new file mode 100644 index 0000000..e11601b --- /dev/null +++ b/cintegration/mockregistry/go.mod @@ -0,0 +1,3 @@ +module github.com/pilot-protocol/libpilot/cintegration/mockregistry + +go 1.25.10 diff --git a/cintegration/mockregistry/main.go b/cintegration/mockregistry/main.go new file mode 100644 index 0000000..bd55890 --- /dev/null +++ b/cintegration/mockregistry/main.go @@ -0,0 +1,275 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// Mock Pilot registry for libpilot C-integration tests. +// +// PilotEmbeddedStart boots a real in-process pkg/daemon. During Start() +// the daemon dials the registry (TCP, length-prefixed JSON) and runs a +// register round-trip plus a follow-up self-lookup. If either fails the +// whole Start() errors out and the //export entry point returns {"error": +// "..."} — which is exactly the path the existing harness's +// test_embedded_start_invalid_json already covers. +// +// To exercise the SUCCESS branch (and let PilotInfo/PilotHealth run +// against the embedded daemon afterwards) we need a registry that +// returns plausible canned responses. Spinning up the real +// rendezvous binary brings in disk persistence + WAL + Ed25519 admin +// auth — overkill for a coverage harness. Instead this binary speaks +// JUST the message types the embedded daemon hits during Start() and +// the first ~second of life, and returns canned data that satisfies +// the daemon's response shape checks. +// +// Wire format (matches pkg/registry/wire.WriteMessage/ReadMessage): +// [4B big-endian length][JSON body] +// +// Dispatch is by the "type" string in the request JSON. Unknown types +// return {"error": "unknown type"} so the daemon's reconnect path +// isn't taken silently. +// +// On startup the binary listens on 127.0.0.1:. If -port is 0 +// (default) the OS picks one and we print "MOCK_REGISTRY_ADDR=host:port" +// on stdout so the harness can read it. + +package main + +import ( + "bufio" + "encoding/binary" + "encoding/json" + "flag" + "fmt" + "io" + "log" + "net" + "os" + "os/signal" + "sync/atomic" + "syscall" +) + +// MaxMessageSize matches pkg/registry/wire.MaxMessageSize (64 MiB). +const maxMessageSize = 64 * 1024 * 1024 + +// Auto-incrementing fake node IDs handed out to register requests. +// 1 is reserved so the address looks like "1:0001.0000.0001" — a valid +// non-zero Pilot address. nodeIDCounter starts at 100 so the values +// don't collide with anything the harness might hard-code. +var nodeIDCounter atomic.Uint32 + +func init() { + nodeIDCounter.Store(100) +} + +func readMessage(r io.Reader) (map[string]interface{}, error) { + var lenBuf [4]byte + if _, err := io.ReadFull(r, lenBuf[:]); err != nil { + return nil, err + } + length := binary.BigEndian.Uint32(lenBuf[:]) + if length == 0 { + return nil, fmt.Errorf("zero-length frame") + } + if length > maxMessageSize { + return nil, fmt.Errorf("frame too large: %d", length) + } + body := make([]byte, length) + if _, err := io.ReadFull(r, body); err != nil { + return nil, err + } + var msg map[string]interface{} + if err := json.Unmarshal(body, &msg); err != nil { + return nil, fmt.Errorf("decode: %w", err) + } + return msg, nil +} + +func writeMessage(w io.Writer, msg map[string]interface{}) error { + body, err := json.Marshal(msg) + if err != nil { + return err + } + var lenBuf [4]byte + binary.BigEndian.PutUint32(lenBuf[:], uint32(len(body))) + if _, err := w.Write(lenBuf[:]); err != nil { + return err + } + _, err = w.Write(body) + return err +} + +// addressFor returns a daemon-parseable Pilot address string. +// Format: "N:NNNN.HHHH.LLLL" — network=1, node_id split into two +// uint16s big-endian. +func addressFor(nodeID uint32) string { + high := uint16(nodeID >> 16) + low := uint16(nodeID & 0xFFFF) + return fmt.Sprintf("1:0001.%04X.%04X", high, low) +} + +// dispatch handles a single decoded request and returns the response +// map. Reply shape is the union of every field the daemon's response +// readers parse; sending extras is harmless because json.Unmarshal into +// map[string]interface{} ignores them. +func dispatch(req map[string]interface{}) map[string]interface{} { + msgType, _ := req["type"].(string) + log.Printf("mock-registry: req type=%s", msgType) + + switch msgType { + case "register": + nodeID := nodeIDCounter.Add(1) + return map[string]interface{}{ + "status": "ok", + "node_id": nodeID, + "address": addressFor(nodeID), + "observed_addr": "", + // list-of-beacons hint the daemon's first beaconRefreshTick + // may read; safe to return empty. + "beacons": []interface{}{}, + // no networks at registration time + "networks": []interface{}{}, + } + + case "heartbeat": + // trustRepublishLoop runs the first heartbeat 0-5s after start. + // In a tight harness lifecycle the test will Stop() the daemon + // before this fires — but answer ok defensively. + return map[string]interface{}{ + "status": "ok", + } + + case "lookup": + // Daemon calls Lookup(self) from nodeNetworksFresh during + // loadNetworkPolicies. It only reads "networks". An empty list + // is the right answer for a freshly registered node. + nodeID, _ := req["node_id"].(float64) + return map[string]interface{}{ + "status": "ok", + "node_id": uint32(nodeID), + "address": addressFor(uint32(nodeID)), + "networks": []interface{}{}, + "tags": []interface{}{}, + } + + case "resolve": + // Same shape as lookup with a real_addr field — the daemon + // won't dial it during a bare Start, but other handles might. + nodeID, _ := req["node_id"].(float64) + return map[string]interface{}{ + "status": "ok", + "node_id": uint32(nodeID), + "address": addressFor(uint32(nodeID)), + "real_addr": "127.0.0.1:0", + "networks": []interface{}{}, + } + + case "set_visibility", "set_hostname", "deregister": + return map[string]interface{}{"status": "ok"} + + case "list_beacons": + return map[string]interface{}{ + "status": "ok", + "beacons": []interface{}{}, + } + + case "get_network_policy": + // loadNetworkPolicies skips on err — return an empty policy. + return map[string]interface{}{ + "status": "ok", + "allowed_ports": []interface{}{}, + } + + case "list_nodes": + return map[string]interface{}{ + "status": "ok", + "nodes": []interface{}{}, + } + + default: + // Unknown types: return an error envelope so the daemon's call + // site sees a non-nil err and the loop continues. The registry + // JSON convention is {"error": ""} (no "status"). + return map[string]interface{}{ + "error": fmt.Sprintf("mock-registry: unknown type %q", msgType), + } + } +} + +func handleConn(c net.Conn) { + defer c.Close() + log.Printf("mock-registry: client connected from %s", c.RemoteAddr()) + defer log.Printf("mock-registry: client %s disconnected", c.RemoteAddr()) + + // bufio.Reader smooths over small kernel buffer boundaries on the + // 4-byte length prefix. + br := bufio.NewReader(c) + for { + req, err := readMessage(br) + if err != nil { + if err != io.EOF { + log.Printf("mock-registry: read: %v", err) + } + return + } + resp := dispatch(req) + if err := writeMessage(c, resp); err != nil { + log.Printf("mock-registry: write: %v", err) + return + } + } +} + +func main() { + port := flag.Int("port", 0, "TCP port to listen on (0 = OS picks)") + addrFile := flag.String("addr-file", "", + "path to write the bound host:port after listen (default: stdout only)") + flag.Parse() + + log.SetOutput(os.Stderr) + log.SetPrefix("[mockregistry] ") + + addr := fmt.Sprintf("127.0.0.1:%d", *port) + ln, err := net.Listen("tcp", addr) + if err != nil { + log.Fatalf("listen %s: %v", addr, err) + } + defer ln.Close() + + // Tell the harness exactly which port we bound so it can build the + // embedded config. Print on stdout (separate stream from log + // output) and flush immediately. With -port=0 stdout is the only + // way the parent learns the port — but stdout is buffered when the + // parent dup2's it onto a pipe, and the harness wants a simple + // poll-this-file pattern. -addr-file writes the address + // atomically (write to .tmp, then rename) so the parent can + // stat-then-read without a torn-write race. + boundAddr := ln.Addr().String() + fmt.Printf("MOCK_REGISTRY_ADDR=%s\n", boundAddr) + os.Stdout.Sync() + if *addrFile != "" { + tmp := *addrFile + ".tmp" + if err := os.WriteFile(tmp, []byte(boundAddr), 0o644); err != nil { + log.Fatalf("write addr-file %s: %v", tmp, err) + } + if err := os.Rename(tmp, *addrFile); err != nil { + log.Fatalf("rename addr-file %s: %v", *addrFile, err) + } + } + log.Printf("listening on %s (pid=%d)", boundAddr, os.Getpid()) + + sigCh := make(chan os.Signal, 1) + signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) + go func() { + <-sigCh + log.Println("shutting down") + _ = ln.Close() + os.Exit(0) + }() + + for { + c, err := ln.Accept() + if err != nil { + log.Printf("accept: %v", err) + return + } + go handleConn(c) + } +}