Merge origin/main into local test-runner work
Some checks are pending
CI Benchmark - Fusion Loop Timing / Fusion Loop Timing Benchmark (push) Waiting to run

Reconciles divergence: local 8fbc2e8 (volatile doc) and origin's
4b0eaba (same) plus 5e58859 (nvs/csi/serial_prov host tests + CI
wiring) and fbb86fb (gitignore). Clean merge: both branches agree on
test_runner.c content; origin additionally adds host tests and CI.
This commit is contained in:
jedarden 2026-07-03 13:37:38 -04:00
commit d9d1048320
6 changed files with 1463 additions and 6 deletions

5
.gitignore vendored
View file

@ -18,6 +18,11 @@ firmware/managed_components/
firmware/.cache/
firmware/sdkconfig
firmware/sdkconfig.old
# Abandoned ESP-IDF `idf.py --target linux` test-app experiments. That host-test
# path was rejected (see docs/notes/firmware-host-test-approach.md); the firmware
# host tests live in firmware/test/ as a gcc harness. Ignore any stray residue so
# the rejected approach's build output can't be committed by accident.
firmware/test_apps/
# Test and coverage
*.test

View file

@ -37,6 +37,16 @@ COPY firmware/ ./
# sdkconfig.defaults (which specifies CONFIG_ESPTOOLPY_FLASHSIZE_4MB=y).
RUN rm -f sdkconfig sdkconfig.old
# Firmware host-test gate: run the gcc host unit tests (nvs schema migration,
# CSI binary-frame serialization, serial_prov JSON parser fuzz) BEFORE the
# expensive ESP-IDF build so a logic/format-contract regression fails the image
# build fast. Pure gcc — no IDF toolchain needed; gcc + GNU make ship in this
# espressif/idf image. `make` propagates the suite's non-zero exit code on any
# assertion failure, failing the build. This is the gcc harness, NOT idf.py
# --target linux — see firmware/test/Makefile and the decision record
# docs/notes/firmware-host-test-approach.md (firmware/main cannot be host-linked).
RUN make -C test test
# Source export.sh to activate IDF toolchain (entrypoint is not called in build stages).
# set-target must be run explicitly before build even when CONFIG_IDF_TARGET is in sdkconfig.defaults.
# idf.py build produces build/spaxel-firmware.bin

View file

@ -4139,13 +4139,52 @@ Located in `test/acceptance/` (the cross-cutting acceptance Go module), `mothers
| OTA rollback | Push invalid firmware | node reconnects with original version |
| Auth rejection | Connect without token | connection closed with HTTP 401 |
### Firmware Tests (host-based unit tests)
### Firmware Tests (host-based, gcc harness)
ESP-IDF supports host-based testing via `idf.py test --target linux`. The following firmware modules have host tests:
The three firmware modules below are tested **on the host with no hardware**, via a plain
**gcc harness under `firmware/test/`** — *not* ESP-IDF's `idf.py test --target linux`.
- `nvs` — NVS schema migration: simulate schema_ver=0→1 upgrade
- `csi` — Binary frame serialization: verify frame header fields and little-endian encoding
- `serial_prov` — Provisioning JSON parser: verify valid JSON parsed correctly; invalid JSON returns `{"ok":false}`
The `idf.py --target linux` / Unity-host path was evaluated and **rejected** (decision
record: `docs/notes/firmware-host-test-approach.md`, bead bf-21t): `firmware/main` builds
as a single `idf_component_register(...)` whose `REQUIRES` names `esp_wifi`, `bt`, and
`driver` — three components with no host build — so the whole component (and thus every
translation unit in it) is unhostable. `csi.c` pulls in `esp_wifi.h` and `provision.c`
pulls in `driver/uart.h`; even `nvs_migration.c`, whose own includes would be hostable in
isolation, is blocked because the `main` component can't be configured for the host target.
The harness therefore does **not** link `firmware/main/*.c`. It tests dependency-free
extractions of the logic and the binary-format/wire contracts in self-contained units that
compile with nothing more than a C compiler. The real `esp_wifi`/`uart`/`nvs` call sites
remain validated on-target and via the Go-side `spaxel-sim` acceptance suite; the host
tests are a logic-and-format safety net.
**Run (single command, no ESP-IDF toolchain):**
```
make -C firmware/test test
```
The Makefile globs every `test_*.c`, compiles them with `test_runner.c` under plain gcc
(`-std=c11 -Wall -Wextra`), and runs the suite; `make` propagates a non-zero exit on any
assertion failure. The same recipe runs as a CI gate inside the Docker `firmware-builder`
stage (`RUN make -C test test`, before the expensive ESP-IDF build — see Dockerfile).
**Modules covered:**
- `nvs` — NVS schema migration: fresh-install init to v1, no-downgrade guard, forward
migration loop dispatch (v→v+1 at index v1), and the concrete v1→v2 step
(rename `ms_ip``mothership_ip`, default `ntp_server`). Driven against a simulated
in-memory NVS store.
- `csi` — Binary frame serialization: 24-byte header field round-trip, explicit
little-endian timestamp byte order, signed-RSSI `(uint8_t)` reinterpretation, I/Q
payload copy, n_sub=0 header-only probe, and the ingestion-side validation rules
(too-short / payload-mismatch / n_sub>128 / bad channel) tied to the firmware encoder
contract.
- `serial_prov` — Provisioning JSON parser, including a **fuzz pass**: the parser is a
bounded recursive-descent JSON decoder (fixed node pool, fixed string arena, depth cap)
that is the fuzz target, and the protocol must always answer with a single well-formed
`{"ok":...}` line on any input — verified across random byte streams, a tricky-input
corpus, and deep-nesting stress.
### Property-Based / Fuzz Tests
@ -4168,7 +4207,7 @@ Fuzz targets are in `*_fuzz_test.go` files and must be run with `go test -fuzz`
1. `go test ./...` — all unit tests pass
2. `go vet ./...` — no vet warnings
3. `golangci-lint run` — no lint errors (at least: `errcheck`, `staticcheck`, `gosimple`)
4. `docker buildx build --platform linux/amd64 .` — single-arch (amd64) build succeeds. **amd64 only** is a deliberate decision: the ESP-IDF firmware build stage is x86_64-only and the deployment target is amd64 k8s. arm64 is tracked as future work (see Deployment > Dockerfile); it is not built in CI today.
4. `docker buildx build --platform linux/amd64 .` — single-arch (amd64) build succeeds. **amd64 only** is a deliberate decision: the ESP-IDF firmware build stage is x86_64-only and the deployment target is amd64 k8s. arm64 is tracked as future work (see Deployment > Dockerfile); it is not built in CI today. This gate also runs the firmware **host-test** suite (`make -C test test`, the gcc harness — see Firmware Tests above) inside the `firmware-builder` stage before the ESP-IDF build, so a logic/format-contract regression fails the image build.
5. Integration test suite: `spaxel-sim --nodes 4 --walkers 1 --duration 30s` with blob count >0
6. Integration test: OTA rollback test (invalid firmware → node reverts)
7. Integration test: auth rejection test (node without token → HTTP 401)

View file

@ -0,0 +1,330 @@
/*
* ============================================================================
* Host test: CSI binary frame serialization roundtrip
* ============================================================================
*
* Covers the plan's Testing-Strategy requirement:
* `csi` Binary frame serialization: verify frame header fields and
* little-endian encoding.
*
* This is a gcc host test (see test_runner.h's header comment + the decision
* record docs/notes/firmware-host-test-approach.md, bead bf-21t, for why this
* is plain gcc and NOT ESP-IDF --target linux: firmware/main cannot be
* host-linked because csi.c esp_wifi.h and provision.c driver/uart.h, and
* the single `main` component REQUIRES esp_wifi/bt/driver which have no linux
* build). The harness therefore pins the wire-format CONTRACT rather than
* linking the firmware source.
*
* The reference encoder below mirrors byte for byte, offset for offset the
* production serializer in firmware/main/websocket.c `websocket_send_csi()`
* (websocket.c lines 236-252):
*
* frame_len = 24 + n_sub*2
* memcpy(frame+0, node_mac, 6)
* memcpy(frame+6, peer_mac, 6)
* memcpy(frame+12, &timestamp_us, 8) // little-endian on Xtensa
* frame[20] = (uint8_t)rssi // int8 reinterpreted
* frame[21] = (uint8_t)noise_floor
* frame[22] = channel
* frame[23] = n_sub
* memcpy(frame+24, iq_data, n_sub*2) // int8 I,Q pairs
*
* The reference decoder mirrors the byte layout the Go ingestion server parses
* (plan §Ingestion "Binary CSI frame validation"). Round-tripping the two
* against each other is the cross-system contract guard: if the firmware ever
* changes the layout, the offset table here documents what every consumer must
* match, and the real end-to-end check lives in the Go spaxel-sim acceptance
* suite.
*
* The ingestion-side validator (csi_validate) reproduces the plan's ordered
* validation rules so we can assert malformed frames are flagged at the right
* stage connecting the firmware encoder contract to the mothership decoder
* contract, which is the real cross-system value of this test.
* ============================================================================
*/
#include "test_runner.h"
#include <stdint.h>
#include <string.h>
/* ---- Wire-format constants (mirror firmware/main/spaxel.h) ---------------- */
#define CSI_HEADER_SIZE 24u /* SPAXEL_FRAME_HEADER_SIZE */
#define CSI_MAX_SUB 128u /* ingestion safety margin (ESP32-S3 ships 64) */
#define CSI_MIN_FRAME_LEN CSI_HEADER_SIZE
/* Offsets within the 24-byte header. */
#define OFF_NODE_MAC 0
#define OFF_PEER_MAC 6
#define OFF_TIMESTAMP 12
#define OFF_RSSI 20
#define OFF_NOISE 21
#define OFF_CHANNEL 22
#define OFF_N_SUB 23
/* Decoded view of a frame — what the mothership reads back. */
typedef struct {
uint8_t node_mac[6];
uint8_t peer_mac[6];
uint64_t timestamp_us;
int8_t rssi;
int8_t noise_floor;
uint8_t channel;
uint8_t n_sub;
const int8_t *iq; /* points into the frame buffer; valid only while it lives */
} csi_frame_view_t;
/* Ingestion-side validation result (plan §"Binary CSI frame validation"). */
typedef enum {
CSI_OK,
CSI_TOO_SHORT, /* len < 24 (rule 1) */
CSI_PAYLOAD_MISMATCH,/* 24 + n_sub*2 != len (rule 3) */
CSI_N_SUB_TOO_BIG, /* n_sub > 128 (rule 4) */
CSI_BAD_CHANNEL, /* channel == 0 or > 14 (rules 6,7) */
} csi_valid_t;
/*
* Reference encoder same layout/offsets as websocket.c:websocket_send_csi().
* `out` must point to at least CSI_HEADER_SIZE + n_sub*2 bytes. Returns the
* number of bytes written. n_sub==0 produces a header-only probe (24 bytes),
* which the plan explicitly allows.
*/
static size_t csi_encode(const uint8_t node_mac[6], const uint8_t peer_mac[6],
uint64_t timestamp_us, int8_t rssi, int8_t noise_floor,
uint8_t channel, uint8_t n_sub, const int8_t *iq,
uint8_t *out)
{
size_t frame_len = CSI_HEADER_SIZE + (size_t)n_sub * 2u;
memcpy(out + OFF_NODE_MAC, node_mac, 6);
memcpy(out + OFF_PEER_MAC, peer_mac, 6);
memcpy(out + OFF_TIMESTAMP, &timestamp_us, 8); /* LE on ESP32 + x86-64 gcc */
out[OFF_RSSI] = (uint8_t)rssi;
out[OFF_NOISE] = (uint8_t)noise_floor;
out[OFF_CHANNEL] = channel;
out[OFF_N_SUB] = n_sub;
if (n_sub > 0 && iq != NULL) {
memcpy(out + CSI_HEADER_SIZE, iq, (size_t)n_sub * 2u);
}
return frame_len;
}
/* Reference decoder — reads back the byte layout the Go ingestion server sees. */
static void csi_decode(const uint8_t *frame, size_t len, csi_frame_view_t *v)
{
/* Caller is expected to have validated len >= CSI_HEADER_SIZE first. */
memcpy(v->node_mac, frame + OFF_NODE_MAC, 6);
memcpy(v->peer_mac, frame + OFF_PEER_MAC, 6);
memcpy(&v->timestamp_us, frame + OFF_TIMESTAMP, 8);
v->rssi = (int8_t)frame[OFF_RSSI];
v->noise_floor = (int8_t)frame[OFF_NOISE];
v->channel = frame[OFF_CHANNEL];
v->n_sub = frame[OFF_N_SUB];
v->iq = (len > CSI_HEADER_SIZE)
? (const int8_t *)(frame + CSI_HEADER_SIZE) : NULL;
}
/*
* Ingestion-side validation, mirroring the plan's ordered rules exactly.
* Order matters: a frame is dropped at the FIRST rule it violates.
*/
static csi_valid_t csi_validate(const uint8_t *frame, size_t len)
{
if (len < CSI_MIN_FRAME_LEN) { /* rule 1 */
return CSI_TOO_SHORT;
}
uint8_t n_sub = frame[OFF_N_SUB]; /* rule 2 */
if (CSI_HEADER_SIZE + (size_t)n_sub * 2u != len) { /* rule 3 */
return CSI_PAYLOAD_MISMATCH;
}
if (n_sub > CSI_MAX_SUB) { /* rule 4 */
return CSI_N_SUB_TOO_BIG;
}
/* rule 5: rssi == 0 is allowed (invalid-RSSI flag), not a drop. */
uint8_t channel = frame[OFF_CHANNEL];
if (channel == 0 || channel > 14) { /* rules 6, 7 */
return CSI_BAD_CHANNEL;
}
return CSI_OK;
}
/* ---- Tests ---------------------------------------------------------------- */
/* A 64-subcarrier frame is 24 + 64*2 = 152 bytes; n_sub==0 is 24 (probe). */
TEST(csi_frame_header_size)
{
uint8_t buf[CSI_HEADER_SIZE + CSI_MAX_SUB * 2u];
uint8_t mac[6] = {0};
uint8_t peer[6] = {0};
ASSERT_EQ(csi_encode(mac, peer, 0, 0, 0, 6, 0, NULL, buf), 24);
ASSERT_EQ(csi_encode(mac, peer, 0, 0, 0, 6, 64, NULL, buf), 152);
}
/* n_sub==0 is a valid header-only probe (plan: "n_sub=0 is valid"). */
TEST(csi_frame_header_only_probe)
{
uint8_t buf[CSI_HEADER_SIZE];
uint8_t mac[6] = {0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF};
uint8_t peer[6] = {0x11, 0x22, 0x33, 0x44, 0x55, 0x66};
size_t len = csi_encode(mac, peer, 42, -52, -95, 6, 0, NULL, buf);
ASSERT_EQ(len, 24);
ASSERT_EQ(csi_validate(buf, len), CSI_OK);
csi_frame_view_t v;
csi_decode(buf, len, &v);
ASSERT_EQ(v.n_sub, 0);
ASSERT_TRUE(v.iq == NULL);
}
/* Round-trip every header field through encode → decode. */
TEST(csi_frame_roundtrip_fields)
{
uint8_t buf[CSI_HEADER_SIZE + 64 * 2u];
uint8_t node[6] = {0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF};
uint8_t peer[6] = {0x11, 0x22, 0x33, 0x44, 0x55, 0x66};
int8_t iq[64 * 2];
for (int i = 0; i < 64 * 2; i++) {
iq[i] = (int8_t)((i % 51) - 25); /* some negatives */
}
size_t len = csi_encode(node, peer, 0x1122334455667788ULL, -52, -95, 6, 64,
iq, buf);
ASSERT_EQ(len, 152);
csi_frame_view_t v;
csi_decode(buf, len, &v);
ASSERT_EQ(memcmp(v.node_mac, node, 6), 0);
ASSERT_EQ(memcmp(v.peer_mac, peer, 6), 0);
ASSERT_EQ(v.timestamp_us, 0x1122334455667788ULL);
ASSERT_EQ(v.rssi, -52);
ASSERT_EQ(v.noise_floor, -95);
ASSERT_EQ(v.channel, 6);
ASSERT_EQ(v.n_sub, 64);
ASSERT_EQ(memcmp(v.iq, iq, 64 * 2), 0);
}
/*
* Explicitly pin LITTLE-ENDIAN byte order of the 8-byte timestamp, independent
* of host endianness. ts = 0x0102030405060708 must land as
* bytes {08,07,06,05,04,03,02,01} at offset 12. This is the plan's "verify
* little-endian encoding" requirement, made into a concrete byte assertion.
*/
TEST(csi_frame_timestamp_is_little_endian)
{
uint8_t buf[CSI_HEADER_SIZE];
uint8_t mac[6] = {0};
uint8_t peer[6] = {0};
csi_encode(mac, peer, 0x0102030405060708ULL, 0, 0, 6, 0, NULL, buf);
ASSERT_EQ(buf[OFF_TIMESTAMP + 0], 0x08);
ASSERT_EQ(buf[OFF_TIMESTAMP + 1], 0x07);
ASSERT_EQ(buf[OFF_TIMESTAMP + 2], 0x06);
ASSERT_EQ(buf[OFF_TIMESTAMP + 3], 0x05);
ASSERT_EQ(buf[OFF_TIMESTAMP + 4], 0x04);
ASSERT_EQ(buf[OFF_TIMESTAMP + 5], 0x03);
ASSERT_EQ(buf[OFF_TIMESTAMP + 6], 0x02);
ASSERT_EQ(buf[OFF_TIMESTAMP + 7], 0x01);
/* And decoding reconstructs the original 64-bit value. */
csi_frame_view_t v;
csi_decode(buf, sizeof(buf), &v);
ASSERT_EQ(v.timestamp_us, 0x0102030405060708ULL);
}
/*
* RSSI / noise_floor are signed dBm carried as raw bytes. A negative value
* (e.g. -52 dBm) must survive the (uint8_t) cast on encode and reinterpret as
* int8 on decode. Validates the firmware's `frame[20] = (uint8_t)rssi` trick.
*/
TEST(csi_frame_signed_rssi_roundtrip)
{
uint8_t buf[CSI_HEADER_SIZE];
uint8_t mac[6] = {0};
uint8_t peer[6] = {0};
csi_encode(mac, peer, 0, -1, -128, 11, 0, NULL, buf);
csi_frame_view_t v;
csi_decode(buf, sizeof(buf), &v);
ASSERT_EQ(v.rssi, -1);
ASSERT_EQ(v.noise_floor, -128);
csi_encode(mac, peer, 0, -52, -95, 1, 0, NULL, buf);
csi_decode(buf, sizeof(buf), &v);
ASSERT_EQ(v.rssi, -52);
ASSERT_EQ(v.noise_floor, -95);
}
/* I/Q payload bytes are copied verbatim — verify a small known payload. */
TEST(csi_frame_iq_payload)
{
uint8_t buf[CSI_HEADER_SIZE + 4 * 2u];
uint8_t mac[6] = {0};
uint8_t peer[6] = {0};
int8_t iq[8] = {10, -10, 20, -20, 30, -30, 40, -40};
size_t len = csi_encode(mac, peer, 0, -40, -90, 6, 4, iq, buf);
ASSERT_EQ(len, 32);
csi_frame_view_t v;
csi_decode(buf, len, &v);
ASSERT_EQ(v.n_sub, 4);
ASSERT_EQ(v.iq[0], 10);
ASSERT_EQ(v.iq[1], -10);
ASSERT_EQ(v.iq[2], 20);
ASSERT_EQ(v.iq[3], -20);
ASSERT_EQ(v.iq[4], 30);
ASSERT_EQ(v.iq[5], -30);
ASSERT_EQ(v.iq[6], 40);
ASSERT_EQ(v.iq[7], -40);
}
/*
* Ingestion-side validation: malformed frames are dropped at the right rule,
* matching the plan's ordered checks. This ties the firmware encoder contract to
* the mothership decoder contract.
*/
TEST(csi_frame_ingestion_validation)
{
/* Zeroed up front: the first sub-test passes len=23, and although csi_validate
* returns CSI_TOO_SHORT before reading any byte, zeroing removes any
* -Wmaybe-uninitialized ambiguity under differing opt levels. */
uint8_t buf[CSI_HEADER_SIZE + CSI_MAX_SUB * 2u];
memset(buf, 0, sizeof(buf));
uint8_t mac[6] = {0};
uint8_t peer[6] = {0};
/* Rule 1: too short to contain a header. */
ASSERT_EQ(csi_validate(buf, 23), CSI_TOO_SHORT);
/* Rule 3: payload length mismatch — 24-byte frame claims n_sub=5 (→34 B). */
memset(buf, 0, sizeof(buf));
csi_encode(mac, peer, 0, -50, -95, 6, 5, NULL, buf); /* claims 34 B */
ASSERT_EQ(csi_validate(buf, 24), CSI_PAYLOAD_MISMATCH);
/* Rule 4: n_sub > 128 with a length that otherwise matches. n_sub=130 → 284 B. */
memset(buf, 0, sizeof(buf));
buf[OFF_N_SUB] = 130;
buf[OFF_CHANNEL] = 6;
ASSERT_EQ(csi_validate(buf, 24 + 130u * 2u), CSI_N_SUB_TOO_BIG);
/* Rule 6: channel == 0 is invalid. */
memset(buf, 0, sizeof(buf)); /* n_sub=0, channel=0 */
ASSERT_EQ(csi_validate(buf, 24), CSI_BAD_CHANNEL);
/* Rule 7: channel > 14 is invalid. */
memset(buf, 0, sizeof(buf));
buf[OFF_CHANNEL] = 15;
ASSERT_EQ(csi_validate(buf, 24), CSI_BAD_CHANNEL);
/* Valid: channel 1..14, n_sub=0. rssi==0 is allowed (rule 5, not a drop). */
memset(buf, 0, sizeof(buf));
buf[OFF_CHANNEL] = 6;
ASSERT_EQ(csi_validate(buf, 24), CSI_OK);
/* Valid 64-subcarrier frame. */
size_t len = csi_encode(mac, peer, 0, -52, -95, 11, 64, NULL, buf);
ASSERT_EQ(csi_validate(buf, len), CSI_OK);
}

View file

@ -0,0 +1,373 @@
/*
* ============================================================================
* Host test: NVS schema migration
* ============================================================================
*
* Covers the plan's Testing-Strategy requirement:
* `nvs` NVS schema migration: simulate schema_ver=01 upgrade.
*
* This is a gcc host test (see test_runner.h's header comment + the decision
* record docs/notes/firmware-host-test-approach.md, bead bf-21t, for why this
* is plain gcc and NOT ESP-IDF --target linux: firmware/main builds as one
* idf_component whose REQUIRES names esp_wifi/bt/driver, none of which have a
* linux build, so nvs_migration.c whose own includes WOULD be hostable in
* isolation is still unhostable as part of the component). The harness
* therefore mirrors the migration *logic* as a pure, dependency-free extraction
* against an in-memory key-value store rather than linking the firmware source.
*
* What is mirrored (byte-for-byte decision logic) from
* firmware/main/nvs_migration.c:
*
* - NVS schema version key is "schema_ver" (spaxel.h NVS_KEY_SCHEMA_VER).
* - Fresh install (no schema_ver) initializes it to 1, NOT 0 the "0→1"
* in the plan is loose wording for "first-run initialization".
* - The forward-migration loop runs migrations[v-1] for v in
* [found_ver, compiled_ver). i.e. migration v(v+1) lives at index (v-1).
* - found_ver > compiled_ver return OK WITHOUT downgrading (caution path).
* - found_ver == compiled_ver no-op, OK.
* - A requested migration index past the end of the migrations[] array
* ESP_ERR_NOT_FOUND (a defined-but-unimplemented version gap).
* - migrate_v1_to_v2 concrete effects: rename key "ms_ip" "mothership_ip"
* (only if present), and set "ntp_server"="pool.ntp.org" if absent.
*
* Subtlety pinned here: today COMPILED_NVS_VERSION == 1, so the loop body
* never executes in production (fresh init sets schema_ver straight to 1).
* The migrations[] array is defined ahead-of-time for FUTURE bumps. The
* machinery must still be correct so the day COMPILED goes to 2 the rename
* fires automatically so these tests drive it against a simulated higher
* compiled version to prove the dispatch + side effects work.
*
* The real esp_ NVS call sites remain validated on-target and via the Go
* spaxel-sim acceptance suite; this is the logic safety net.
* ============================================================================
*/
#include "test_runner.h"
#include <stdbool.h>
#include <stdint.h>
#include <stdio.h>
#include <string.h>
/* ---- Status codes (mirror the subset of esp_err_t the migration uses) ----- */
enum {
MIG_OK = 0, /* ESP_OK */
MIG_ERR_NOT_FOUND, /* ESP_ERR_NOT_FOUND — undefined migration index */
};
/* ---- In-memory NVS stand-in ---------------------------------------------- */
/*
* Tiny string keystring value store. The production migration only touches
* string keys (schema_ver is u8, carried here as its decimal string), so a
* string store models everything nvs_migration.c actually reads and writes.
*/
#define KV_MAX 32
#define KEY_LEN 16 /* ESP-IDF NVS key limit is 15 chars + NUL */
#define VAL_LEN 128
typedef struct {
char key[KEY_LEN];
char val[VAL_LEN];
} kv_t;
typedef struct {
kv_t rows[KV_MAX];
int count;
} nvs_store_t;
static void store_reset(nvs_store_t *s) { s->count = 0; }
static kv_t *store_find(nvs_store_t *s, const char *key) {
for (int i = 0; i < s->count; i++) {
if (strncmp(s->rows[i].key, key, KEY_LEN) == 0) {
return &s->rows[i];
}
}
return NULL;
}
/* Returns true if a key is present (mirrors nvs_str_exists). */
static bool store_exists(nvs_store_t *s, const char *key) {
return store_find(s, key) != NULL;
}
static void store_set(nvs_store_t *s, const char *key, const char *val) {
kv_t *row = store_find(s, key);
if (row == NULL) {
/* Capacity is generous for these tests; a full store is a test bug. */
if (s->count >= KV_MAX) {
return;
}
row = &s->rows[s->count++];
strncpy(row->key, key, KEY_LEN - 1);
row->key[KEY_LEN - 1] = '\0';
}
strncpy(row->val, val, VAL_LEN - 1);
row->val[VAL_LEN - 1] = '\0';
}
static const char *store_get(nvs_store_t *s, const char *key) {
kv_t *row = store_find(s, key);
return row ? row->val : NULL;
}
static void store_del(nvs_store_t *s, const char *key) {
for (int i = 0; i < s->count; i++) {
if (strncmp(s->rows[i].key, key, KEY_LEN) == 0) {
/* Compact by moving the last row into the freed slot. */
s->rows[i] = s->rows[s->count - 1];
s->count--;
return;
}
}
}
/* ---- Mirror of the migration steps (firmware/main/nvs_migration.c) -------- */
/*
* NVS schema version key + the rename target/default from the real migration.
*/
#define KEY_SCHEMA_VER "schema_ver"
#define KEY_MS_IP "ms_ip"
#define KEY_MS_IP_RENAMED "mothership_ip"
#define KEY_NTP_SERVER "ntp_server"
#define DEFAULT_NTP "pool.ntp.org"
/*
* Test-only dispatch probe: the index of the most recent migration the runner
* dispatched. Lets the index-arithmetic test assert WHICH step fired without
* depending on a migration's side effects (production v2v3 is a no-op).
*/
static int g_last_migration_idx = -1;
static int migrate_v1_to_v2(nvs_store_t *s) {
g_last_migration_idx = 0;
/* Rename ms_ip → mothership_ip (only if ms_ip is present). */
const char *ms_ip = store_get(s, KEY_MS_IP);
if (ms_ip) {
store_set(s, KEY_MS_IP_RENAMED, ms_ip);
store_del(s, KEY_MS_IP);
}
/* Add ntp_server default only if absent. */
if (!store_exists(s, KEY_NTP_SERVER)) {
store_set(s, KEY_NTP_SERVER, DEFAULT_NTP);
}
return MIG_OK;
}
static int migrate_v2_to_v3(nvs_store_t *s) {
g_last_migration_idx = 1;
(void)s; /* No changes yet — matches production placeholder. */
return MIG_OK;
}
typedef int (*migration_fn_t)(nvs_store_t *);
static const migration_fn_t g_migrations[] = {
migrate_v1_to_v2, /* index 0: v1 → v2 */
migrate_v2_to_v3, /* index 1: v2 → v3 */
};
#define MIGRATION_COUNT ((int)(sizeof(g_migrations) / sizeof(g_migrations[0])))
/*
* Mirror of nvs_migration_run(), but parameterized on the compiled target
* version so the machinery can be exercised against a future bump. Returns
* MIG_OK on success / no-op, MIG_ERR_NOT_FOUND if a needed migration index is
* beyond the defined array.
*
* found_ver is passed explicitly instead of read from the store so the fresh-
* install branch (missing schema_ver init to 1) can be tested distinctly;
* on a fresh install the caller passes found_ver=0 with *was_missing=true.
*/
static int migration_run(nvs_store_t *s, uint8_t found_ver, bool was_missing,
uint8_t compiled_ver)
{
/* Fresh install: missing schema_ver initializes to 1 (NOT 0). */
if (was_missing) {
found_ver = 1;
char buf[8];
snprintf(buf, sizeof(buf), "%u", (unsigned)found_ver);
store_set(s, KEY_SCHEMA_VER, buf);
}
/* Downgrade caution path: found newer than compiled → leave it, OK. */
if (found_ver > compiled_ver) {
return MIG_OK;
}
/* Already current → no-op. */
if (found_ver == compiled_ver) {
return MIG_OK;
}
/* Forward migration loop: for v in [found_ver, compiled_ver). */
for (uint8_t v = found_ver; v < compiled_ver; v++) {
size_t idx = (size_t)(v - 1); /* migration v→v+1 lives at index v-1 */
if (idx >= (size_t)MIGRATION_COUNT) {
return MIG_ERR_NOT_FOUND;
}
int rc = g_migrations[idx](s);
if (rc != MIG_OK) {
return rc;
}
char buf[8];
snprintf(buf, sizeof(buf), "%u", (unsigned)(v + 1));
store_set(s, KEY_SCHEMA_VER, buf);
}
return MIG_OK;
}
/* ---- Tests ---------------------------------------------------------------- */
/* Fresh install (no schema_ver) initializes to v1 and writes nothing else. */
TEST(nvs_migration_fresh_install_inits_to_v1)
{
nvs_store_t s; store_reset(&s);
g_last_migration_idx = -1;
int rc = migration_run(&s, 0, true /*was_missing*/, 1);
ASSERT_EQ(rc, MIG_OK);
ASSERT_TRUE(store_exists(&s, KEY_SCHEMA_VER));
ASSERT_EQ(strcmp(store_get(&s, KEY_SCHEMA_VER), "1"), 0);
ASSERT_EQ(g_last_migration_idx, -1); /* loop never ran (1 < 1 is false) */
ASSERT_EQ(s.count, 1); /* only schema_ver was written */
}
/* Already at the current version: no-op, no migrations dispatched. */
TEST(nvs_migration_already_current_is_noop)
{
nvs_store_t s; store_reset(&s);
store_set(&s, KEY_SCHEMA_VER, "1");
g_last_migration_idx = -1;
int rc = migration_run(&s, 1, false, 1);
ASSERT_EQ(rc, MIG_OK);
ASSERT_EQ(strcmp(store_get(&s, KEY_SCHEMA_VER), "1"), 0);
ASSERT_EQ(g_last_migration_idx, -1);
}
/*
* No-downgrade guard: a schema_ver NEWER than compiled is left untouched and
* the run still returns OK (production logs a warning but does not downgrade).
*/
TEST(nvs_migration_does_not_downgrade_newer_version)
{
nvs_store_t s; store_reset(&s);
store_set(&s, KEY_SCHEMA_VER, "3");
store_set(&s, KEY_MS_IP, "10.0.0.1"); /* must survive untouched */
g_last_migration_idx = -1;
int rc = migration_run(&s, 3, false, 1);
ASSERT_EQ(rc, MIG_OK);
ASSERT_EQ(strcmp(store_get(&s, KEY_SCHEMA_VER), "3"), 0);
ASSERT_TRUE(store_exists(&s, KEY_MS_IP));
ASSERT_FALSE(store_exists(&s, KEY_MS_IP_RENAMED));
ASSERT_EQ(g_last_migration_idx, -1);
}
/*
* The plan's headline case, driven against a simulated compiled=v2 so the
* forward machinery actually fires: v1 + ms_ip v2, ms_ip renamed to
* mothership_ip, ntp_server defaulted in.
*/
TEST(nvs_migration_v1_to_v2_renames_ms_ip_and_defaults_ntp)
{
nvs_store_t s; store_reset(&s);
store_set(&s, KEY_SCHEMA_VER, "1");
store_set(&s, KEY_MS_IP, "192.168.1.10");
g_last_migration_idx = -1;
int rc = migration_run(&s, 1, false, 2);
ASSERT_EQ(rc, MIG_OK);
ASSERT_EQ(g_last_migration_idx, 0); /* v1→v2 fired */
ASSERT_EQ(strcmp(store_get(&s, KEY_SCHEMA_VER), "2"), 0);
ASSERT_FALSE(store_exists(&s, KEY_MS_IP)); /* old key erased */
ASSERT_EQ(strcmp(store_get(&s, KEY_MS_IP_RENAMED), "192.168.1.10"), 0);
ASSERT_EQ(strcmp(store_get(&s, KEY_NTP_SERVER), DEFAULT_NTP), 0);
}
/* An existing ntp_server must NOT be overwritten by the default on migration. */
TEST(nvs_migration_v1_to_v2_preserves_existing_ntp)
{
nvs_store_t s; store_reset(&s);
store_set(&s, KEY_SCHEMA_VER, "1");
store_set(&s, KEY_NTP_SERVER, "time.google.com");
int rc = migration_run(&s, 1, false, 2);
ASSERT_EQ(rc, MIG_OK);
ASSERT_EQ(strcmp(store_get(&s, KEY_NTP_SERVER), "time.google.com"), 0);
}
/* ms_ip absent → the rename step is skipped cleanly, no key invented. */
TEST(nvs_migration_v1_to_v2_without_ms_ip_skips_rename)
{
nvs_store_t s; store_reset(&s);
store_set(&s, KEY_SCHEMA_VER, "1");
int rc = migration_run(&s, 1, false, 2);
ASSERT_EQ(rc, MIG_OK);
ASSERT_FALSE(store_exists(&s, KEY_MS_IP));
ASSERT_FALSE(store_exists(&s, KEY_MS_IP_RENAMED));
ASSERT_TRUE(store_exists(&s, KEY_NTP_SERVER)); /* default still applied */
}
/*
* Migration-index arithmetic: a store already at v2 advancing to v3 must
* dispatch the v2v3 step (index 1), NOT v1v2. Proves the loop selects
* migrations[v-1], not a fixed index, and would never re-run an old migration.
*/
TEST(nvs_migration_index_arithmetic_picks_correct_step)
{
nvs_store_t s; store_reset(&s);
store_set(&s, KEY_SCHEMA_VER, "2");
store_set(&s, KEY_MS_IP, "10.0.0.5"); /* would be renamed ONLY by v1→v2 */
g_last_migration_idx = -1;
int rc = migration_run(&s, 2, false, 3);
ASSERT_EQ(rc, MIG_OK);
ASSERT_EQ(g_last_migration_idx, 1); /* v2→v3 fired */
ASSERT_EQ(strcmp(store_get(&s, KEY_SCHEMA_VER), "3"), 0);
/* ms_ip untouched: proves v1→v2 (index 0) did NOT run. */
ASSERT_TRUE(store_exists(&s, KEY_MS_IP));
ASSERT_FALSE(store_exists(&s, KEY_MS_IP_RENAMED));
}
/*
* Undefined-version gap: asking for a compiled version whose migration index
* is beyond the defined array returns MIG_ERR_NOT_FOUND rather than reading
* garbage. Production returns ESP_ERR_NOT_FOUND and leaves NVS at the prior
* consistent version.
*/
TEST(nvs_migration_undefined_future_version_returns_not_found)
{
nvs_store_t s; store_reset(&s);
store_set(&s, KEY_SCHEMA_VER, "1");
/* Only v1→v2 and v2→v3 are defined; compiled=4 needs v3→v4 (index 2). */
int rc = migration_run(&s, 1, false, 4);
ASSERT_EQ(rc, MIG_ERR_NOT_FOUND);
/* schema_ver stays where the last SUCCESSFUL step left it (v3). */
ASSERT_EQ(strcmp(store_get(&s, KEY_SCHEMA_VER), "3"), 0);
}
/*
* Multi-step advance (v1 v3) runs both migrations in order and lands at v3.
* Guards against off-by-one in the loop bound (compiled_ver is exclusive).
*/
TEST(nvs_migration_multi_step_advance)
{
nvs_store_t s; store_reset(&s);
store_set(&s, KEY_SCHEMA_VER, "1");
store_set(&s, KEY_MS_IP, "172.16.0.2");
int rc = migration_run(&s, 1, false, 3);
ASSERT_EQ(rc, MIG_OK);
ASSERT_EQ(strcmp(store_get(&s, KEY_SCHEMA_VER), "3"), 0);
ASSERT_EQ(strcmp(store_get(&s, KEY_MS_IP_RENAMED), "172.16.0.2"), 0);
ASSERT_EQ(strcmp(store_get(&s, KEY_NTP_SERVER), DEFAULT_NTP), 0);
}

View file

@ -0,0 +1,700 @@
/*
* ============================================================================
* Host test: serial provisioning JSON parser (with fuzz)
* ============================================================================
*
* Covers the plan's Testing-Strategy requirement:
* `serial_prov` Provisioning JSON parser: verify valid JSON parsed
* correctly; invalid JSON returns {"ok":false}. (bead bf-31bp adds the
* fuzz pass: the parser must never crash on arbitrary UART input.)
*
* gcc host test (see test_runner.h's header comment + decision record
* docs/notes/firmware-host-test-approach.md, bead bf-21t, for why this is
* plain gcc and NOT ESP-IDF --target linux: provision.c pulls in driver/uart.h
* and the `main` component REQUIRES esp_wifi/bt/driver, none of which have a
* linux build). The harness therefore mirrors the parser + protocol logic as
* a dependency-free extraction rather than linking the firmware source.
*
* What is mirrored (decision-for-decision) from firmware/main/provision.c:
*
* Protocol (provision_listen_window), per received line:
* cJSON_Parse(line) == NULL {"ok":false,"error":"invalid_json"}
* root has no "provision" member {"ok":false,"error":"missing_provision_key"}
* provision_write_nvs(prov) != ESP_OK {"ok":false,"error":"nvs_write_failed"}
* otherwise {"ok":true,"mac":"<MAC>"}
*
* Mapping (provision_write_nvs), JSON key NVS key / type:
* wifi_ssid string, NON-EMPTY, REQUIRED (else ESP_ERR_INVALID_ARG)
* wifi_pass string (optional)
* node_id string (optional)
* node_token string (optional)
* ms_mdns string (optional)
* ms_ip string non-empty writes BOTH ms_ip and ms_ip_prov
* ms_port number > 0 u16
* debug bool u8 (cJSON_IsTrue ? 1 : 0)
* ntp_server string (optional)
* then unconditionally sets provisioned=1, schema_ver=NVS_SCHEMA_VERSION(=1)
*
* cJSON is not vendored in the tree (it is the IDF `json` component), so this
* file ships a compact, BOUNDED JSON parser j_*() below sufficient for the
* provisioning object. It is the FUZZ TARGET: a UART line is untrusted,
* adversarial input, so the parser must be robust (no out-of-bounds, no
* unbounded recursion) and the protocol must always answer with a single,
* well-formed {"ok":...} line. The fuzz loop proves exactly that.
*
* The real esp_ UART/NVS call sites remain validated on-target and via the Go
* spaxel-sim acceptance suite; this is the logic-and-robustness safety net.
* ============================================================================
*/
#include "test_runner.h"
#include <stdbool.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
/* ---- Status / classification (mirror esp_err_t subset + protocol result) - */
enum {
PROV_OK = 0, /* ESP_OK */
PROV_ERR_INVALID_ARG, /* ESP_ERR_INVALID_ARG — missing ssid */
};
typedef enum {
CLASS_OK,
CLASS_INVALID_JSON,
CLASS_MISSING_KEY,
CLASS_WRITE_FAILED,
} prov_class_t;
/* ---- In-memory string KV (mirrors the string-valued NVS writes) ---------- */
#define PSTR_MAX 32
#define PKEY_LEN 16
#define PVAL_LEN 128
typedef struct {
char key[PKEY_LEN];
char val[PVAL_LEN];
} pstr_t;
typedef struct { pstr_t rows[PSTR_MAX]; int count; } pstr_store_t;
static pstr_t *pstr_find(pstr_store_t *s, const char *k) {
for (int i = 0; i < s->count; i++)
if (strncmp(s->rows[i].key, k, PKEY_LEN) == 0) return &s->rows[i];
return NULL;
}
static bool pstr_exists(pstr_store_t *s, const char *k) { return pstr_find(s, k) != NULL; }
static void pstr_set(pstr_store_t *s, const char *k, const char *v) {
pstr_t *r = pstr_find(s, k);
if (!r) {
if (s->count >= PSTR_MAX) return;
r = &s->rows[s->count++];
strncpy(r->key, k, PKEY_LEN - 1); r->key[PKEY_LEN - 1] = '\0';
}
strncpy(r->val, v, PVAL_LEN - 1); r->val[PVAL_LEN - 1] = '\0';
}
static const char *pstr_get(pstr_store_t *s, const char *k) {
pstr_t *r = pstr_find(s, k); return r ? r->val : NULL;
}
/* Provisioned device state: string keys + the typed u8/u16 slots provision.c writes. */
typedef struct {
pstr_store_t str;
uint8_t debug; bool debug_set;
uint16_t ms_port; bool ms_port_set;
uint8_t provisioned;
uint8_t schema_ver;
} prov_state_t;
static void prov_reset(prov_state_t *st) {
memset(st, 0, sizeof(*st));
}
/* NVS key names (mirror spaxel.h NVS_KEY_*). */
#define K_SSID "wifi_ssid"
#define K_PASS "wifi_pass"
#define K_NODE_ID "node_id"
#define K_TOKEN "node_token"
#define K_MDNS "ms_mdns"
#define K_MS_IP "ms_ip"
#define K_MS_IP_PROV "ms_ip_prov"
#define K_NTP "ntp_server"
/* ========================================================================== */
/* Bounded JSON parser (the fuzz target) */
/* ========================================================================== */
/*
* A small recursive-descent parser over the JSON grammar (object, array,
* string, number, true/false/null). Every allocation comes from fixed-size
* pools with hard caps, so NO input can cause unbounded memory use or
* recursion:
* - at most J_MAX_NODES nodes,
* - at most J_ARENA bytes of string data,
* - at most J_MAX_DEPTH nesting.
* Any violation (malformed token, overflow, excess depth) returns NULL up the
* stack. Returned node trees point into parser-owned pools and are valid only
* for the parser's lifetime.
*/
#define J_MAX_NODES 64
#define J_ARENA 4096
#define J_MAX_DEPTH 32
typedef enum { J_NULL, J_BOOL, J_NUM, J_STR, J_OBJ, J_ARR } jtype_t;
typedef struct jnode {
jtype_t type;
struct jnode *child; /* first member (obj) / element (arr) */
struct jnode *next; /* next sibling */
const char *name; /* member name (obj members only); NULL otherwise */
bool b;
double num;
const char *str; /* points into arena (J_STR) */
} jnode_t;
typedef struct {
const char *src;
size_t len, pos;
int depth;
jnode_t nodes[J_MAX_NODES];
int node_count;
char arena[J_ARENA];
size_t arena_used;
} jparser_t;
static jnode_t *j_alloc(jparser_t *p) {
if (p->node_count >= J_MAX_NODES) return NULL;
jnode_t *n = &p->nodes[p->node_count++];
memset(n, 0, sizeof(*n));
return n;
}
static void j_skip_ws(jparser_t *p) {
while (p->pos < p->len) {
char c = p->src[p->pos];
if (c == ' ' || c == '\t' || c == '\n' || c == '\r') p->pos++;
else break;
}
}
/* Copy a decoded string into the arena; returns arena pointer or NULL. */
static const char *j_parse_string(jparser_t *p) {
if (p->pos >= p->len || p->src[p->pos] != '"') return NULL;
p->pos++; /* opening quote */
size_t start = p->arena_used;
while (p->pos < p->len) {
char c = p->src[p->pos++];
if (c == '"') {
if (p->arena_used >= J_ARENA) return NULL;
p->arena[p->arena_used++] = '\0';
return &p->arena[start];
}
if (c == '\\') {
if (p->pos >= p->len) return NULL;
char esc = p->src[p->pos++];
char out = '\0';
switch (esc) {
case '"': case '\\': case '/': out = esc; break;
case 'b': out = '\b'; break;
case 'f': out = '\f'; break;
case 'n': out = '\n'; break;
case 'r': out = '\r'; break;
case 't': out = '\t'; break;
case 'u': {
/* \uXXXX — decode to one byte via the low byte; surrogate
* pairs are not valid for our ASCII NVS values, so a lone
* surrogate is accepted as its raw code unit low byte. The
* point is robustness, not canonical UTF-8. */
if (p->pos + 4 > p->len) return NULL;
unsigned int u = 0;
for (int i = 0; i < 4; i++) {
char h = p->src[p->pos + i];
u <<= 4;
if (h >= '0' && h <= '9') u |= (unsigned)(h - '0');
else if (h >= 'a' && h <= 'f') u |= (unsigned)(h - 'a' + 10);
else if (h >= 'A' && h <= 'F') u |= (unsigned)(h - 'A' + 10);
else return NULL;
}
p->pos += 4;
out = (char)(u & 0xFF);
break;
}
default: return NULL; /* invalid escape */
}
c = out;
} else if ((unsigned char)c < 0x20) {
return NULL; /* raw control char not allowed in JSON string */
}
if (p->arena_used >= J_ARENA) return NULL;
p->arena[p->arena_used++] = c;
}
return NULL; /* unterminated string */
}
static jnode_t *j_parse_value(jparser_t *p); /* fwd */
static jnode_t *j_parse_array(jparser_t *p) {
/* assumes p->src[p->pos] == '[' */
p->pos++;
jnode_t *arr = j_alloc(p);
if (!arr) return NULL;
arr->type = J_ARR;
jnode_t *tail = NULL;
j_skip_ws(p);
if (p->pos < p->len && p->src[p->pos] == ']') { p->pos++; return arr; }
for (;;) {
jnode_t *v = j_parse_value(p);
if (!v) return NULL;
if (!arr->child) arr->child = v; else tail->next = v;
tail = v;
j_skip_ws(p);
if (p->pos >= p->len) return NULL;
char c = p->src[p->pos++];
if (c == ']') return arr;
if (c != ',') return NULL;
j_skip_ws(p);
}
}
static jnode_t *j_parse_object(jparser_t *p) {
/* assumes p->src[p->pos] == '{' */
p->pos++;
jnode_t *obj = j_alloc(p);
if (!obj) return NULL;
obj->type = J_OBJ;
jnode_t *tail = NULL;
j_skip_ws(p);
if (p->pos < p->len && p->src[p->pos] == '}') { p->pos++; return obj; }
for (;;) {
j_skip_ws(p);
const char *name = j_parse_string(p);
if (!name) return NULL;
j_skip_ws(p);
if (p->pos >= p->len || p->src[p->pos] != ':') return NULL;
p->pos++;
jnode_t *v = j_parse_value(p);
if (!v) return NULL;
v->name = name;
if (!obj->child) obj->child = v; else tail->next = v;
tail = v;
j_skip_ws(p);
if (p->pos >= p->len) return NULL;
char c = p->src[p->pos++];
if (c == '}') return obj;
if (c != ',') return NULL;
}
}
static jnode_t *j_parse_number(jparser_t *p) {
size_t start = p->pos;
if (p->pos < p->len && (p->src[p->pos] == '-' || p->src[p->pos] == '+')) p->pos++;
bool any = false;
while (p->pos < p->len) {
char c = p->src[p->pos];
if ((c >= '0' && c <= '9') || c == '.' || c == 'e' || c == 'E' ||
c == '+' || c == '-') { p->pos++; any = true; }
else break;
}
if (!any) return NULL;
char buf[32];
size_t n = p->pos - start;
if (n >= sizeof(buf)) n = sizeof(buf) - 1; /* truncate huge numbers */
memcpy(buf, p->src + start, n);
buf[n] = '\0';
jnode_t *node = j_alloc(p);
if (!node) return NULL;
node->type = J_NUM;
node->num = strtod(buf, NULL);
return node;
}
static jnode_t *j_parse_value(jparser_t *p) {
j_skip_ws(p);
if (p->pos >= p->len) return NULL;
if (p->depth >= J_MAX_DEPTH) return NULL;
p->depth++;
jnode_t *out = NULL;
char c = p->src[p->pos];
if (c == '{') out = j_parse_object(p);
else if (c == '[') out = j_parse_array(p);
else if (c == '"') {
const char *s = j_parse_string(p);
if (s) { out = j_alloc(p); if (out) { out->type = J_STR; out->str = s; } }
} else if (c == '-' || c == '+' || (c >= '0' && c <= '9')) {
out = j_parse_number(p);
} else if (p->pos + 4 <= p->len && memcmp(p->src + p->pos, "true", 4) == 0) {
p->pos += 4; out = j_alloc(p); if (out) { out->type = J_BOOL; out->b = true; }
} else if (p->pos + 5 <= p->len && memcmp(p->src + p->pos, "false", 5) == 0) {
p->pos += 5; out = j_alloc(p); if (out) { out->type = J_BOOL; out->b = false; }
} else if (p->pos + 4 <= p->len && memcmp(p->src + p->pos, "null", 4) == 0) {
p->pos += 4; out = j_alloc(p); if (out) { out->type = J_NULL; }
}
p->depth--;
return out;
}
/*
* Parse a full document into the CALLER-owned parser state `*p`. After the top
* value, only trailing whitespace is allowed; anything else is malformed
* (returns NULL). NULL on any error.
*
* The parser owns the node pool and string arena inside `*p`; the returned node
* tree (and every node->str) points into it. The CALLER must keep `*p` alive for
* as long as it holds the returned tree hence `p` is passed in rather than
* being a local here: a local would die with this stack frame and leave every
* returned pointer dangling (use-after-return).
*/
static jnode_t *j_parse(jparser_t *p, const char *src, size_t len) {
memset(p, 0, sizeof(*p));
p->src = src; p->len = len;
jnode_t *root = j_parse_value(p);
if (!root) return NULL;
j_skip_ws(p);
if (p->pos != p->len) return NULL; /* trailing garbage */
return root;
}
/* Find a member by name in an object; NULL if not an object / not found. */
static jnode_t *j_get(jnode_t *obj, const char *key) {
if (!obj || obj->type != J_OBJ) return NULL;
for (jnode_t *c = obj->child; c; c = c->next)
if (c->name && strcmp(c->name, key) == 0) return c;
return NULL;
}
/* ========================================================================== */
/* Mirror of provision_write_nvs (firmware/main/provision.c) */
/* ========================================================================== */
static int provision_write_nvs(jnode_t *prov, prov_state_t *st) {
/* wifi_ssid is REQUIRED and must be a non-empty string. */
jnode_t *ssid = j_get(prov, "wifi_ssid");
if (!ssid || ssid->type != J_STR || ssid->str[0] == '\0') {
return PROV_ERR_INVALID_ARG;
}
pstr_set(&st->str, K_SSID, ssid->str);
jnode_t *pass = j_get(prov, "wifi_pass");
if (pass && pass->type == J_STR) pstr_set(&st->str, K_PASS, pass->str);
jnode_t *node_id = j_get(prov, "node_id");
if (node_id && node_id->type == J_STR) pstr_set(&st->str, K_NODE_ID, node_id->str);
jnode_t *token = j_get(prov, "node_token");
if (token && token->type == J_STR) pstr_set(&st->str, K_TOKEN, token->str);
jnode_t *mdns = j_get(prov, "ms_mdns");
if (mdns && mdns->type == J_STR) pstr_set(&st->str, K_MDNS, mdns->str);
jnode_t *ms_ip = j_get(prov, "ms_ip");
if (ms_ip && ms_ip->type == J_STR && ms_ip->str[0] != '\0') {
pstr_set(&st->str, K_MS_IP, ms_ip->str);
pstr_set(&st->str, K_MS_IP_PROV, ms_ip->str); /* mirrored to both keys */
}
jnode_t *port = j_get(prov, "ms_port");
if (port && port->type == J_NUM && port->num > 0) {
st->ms_port = (uint16_t)port->num;
st->ms_port_set = true;
}
jnode_t *dbg = j_get(prov, "debug");
if (dbg) {
st->debug = (dbg->type == J_BOOL && dbg->b) ? 1 : 0; /* cJSON_IsTrue */
st->debug_set = true;
}
jnode_t *ntp = j_get(prov, "ntp_server");
if (ntp && ntp->type == J_STR) pstr_set(&st->str, K_NTP, ntp->str);
st->provisioned = 1;
st->schema_ver = 1; /* NVS_SCHEMA_VERSION */
return PROV_OK;
}
/* ========================================================================== */
/* Mirror of provision_listen_window's per-line decision */
/* ========================================================================== */
/*
* Returns the protocol classification and writes the exact response line the
* firmware would emit on UART into resp (always a single well-formed JSON
* object terminated by '\n'). Mirrors provision.c's four branches.
*/
static prov_class_t provision_handle_line(const char *line, size_t len,
const char *mac,
prov_state_t *st,
char *resp, size_t resp_cap)
{
/*
* The parser state lives on THIS frame so the returned node tree (and every
* node->str into the arena) stays valid for the whole function every
* j_get / provision_write_nvs read below dereferences into `parser`.
*/
jparser_t parser;
jnode_t *root = j_parse(&parser, line, len);
if (!root) {
snprintf(resp, resp_cap, "{\"ok\":false,\"error\":\"invalid_json\"}\n");
return CLASS_INVALID_JSON;
}
jnode_t *prov = j_get(root, "provision");
if (!prov) {
snprintf(resp, resp_cap, "{\"ok\":false,\"error\":\"missing_provision_key\"}\n");
return CLASS_MISSING_KEY;
}
if (provision_write_nvs(prov, st) != PROV_OK) {
snprintf(resp, resp_cap, "{\"ok\":false,\"error\":\"nvs_write_failed\"}\n");
return CLASS_WRITE_FAILED;
}
snprintf(resp, resp_cap, "{\"ok\":true,\"mac\":\"%s\"}\n", mac);
return CLASS_OK;
}
/* ========================================================================== */
/* Tests */
/* ========================================================================== */
static const char *TEST_MAC = "AA:BB:CC:DD:EE:FF";
/* A complete valid provisioning payload maps every field into NVS correctly. */
TEST(serial_prov_valid_full_payload)
{
const char *line =
"{\"provision\":{\"wifi_ssid\":\"HomeNet\",\"wifi_pass\":\"secret\","
"\"node_id\":\"f47ac10b-58cf\",\"node_token\":\"a1b2c3d4\","
"\"ms_mdns\":\"spaxel\",\"ms_ip\":\"192.168.1.5\",\"ms_port\":8080,"
"\"debug\":true,\"ntp_server\":\"time.google.com\"}}";
prov_state_t st; prov_reset(&st);
char resp[128];
prov_class_t c = provision_handle_line(line, strlen(line), TEST_MAC, &st, resp, sizeof(resp));
ASSERT_EQ(c, CLASS_OK);
ASSERT_TRUE(strstr(resp, "\"ok\":true") != NULL);
ASSERT_TRUE(strstr(resp, TEST_MAC) != NULL);
ASSERT_EQ(strcmp(pstr_get(&st.str, K_SSID), "HomeNet"), 0);
ASSERT_EQ(strcmp(pstr_get(&st.str, K_PASS), "secret"), 0);
ASSERT_EQ(strcmp(pstr_get(&st.str, K_NODE_ID), "f47ac10b-58cf"), 0);
ASSERT_EQ(strcmp(pstr_get(&st.str, K_TOKEN), "a1b2c3d4"), 0);
ASSERT_EQ(strcmp(pstr_get(&st.str, K_MDNS), "spaxel"), 0);
ASSERT_EQ(strcmp(pstr_get(&st.str, K_MS_IP), "192.168.1.5"), 0);
ASSERT_EQ(strcmp(pstr_get(&st.str, K_MS_IP_PROV), "192.168.1.5"), 0);
ASSERT_EQ(strcmp(pstr_get(&st.str, K_NTP), "time.google.com"), 0);
ASSERT_EQ(st.ms_port, 8080);
ASSERT_TRUE(st.ms_port_set);
ASSERT_EQ(st.debug, 1);
ASSERT_TRUE(st.debug_set);
ASSERT_EQ(st.provisioned, 1);
ASSERT_EQ(st.schema_ver, 1);
}
/* Missing wifi_ssid → nvs_write_failed (provision_write_nvs rejects it). */
TEST(serial_prov_missing_ssid_rejected)
{
const char *line = "{\"provision\":{\"wifi_pass\":\"x\"}}";
prov_state_t st; prov_reset(&st);
char resp[128];
prov_class_t c = provision_handle_line(line, strlen(line), TEST_MAC, &st, resp, sizeof(resp));
ASSERT_EQ(c, CLASS_WRITE_FAILED);
ASSERT_TRUE(strstr(resp, "nvs_write_failed") != NULL);
ASSERT_FALSE(pstr_exists(&st.str, K_PASS)); /* nothing written */
ASSERT_EQ(st.provisioned, 0); /* not provisioned on failure */
}
/* Empty wifi_ssid is also rejected (must be non-empty). */
TEST(serial_prov_empty_ssid_rejected)
{
const char *line = "{\"provision\":{\"wifi_ssid\":\"\"}}";
prov_state_t st; prov_reset(&st);
char resp[128];
prov_class_t c = provision_handle_line(line, strlen(line), TEST_MAC, &st, resp, sizeof(resp));
ASSERT_EQ(c, CLASS_WRITE_FAILED);
ASSERT_FALSE(pstr_exists(&st.str, K_SSID));
}
/* Optional fields absent: provisioning still succeeds with just the SSID. */
TEST(serial_prov_minimal_payload_ok)
{
const char *line = "{\"provision\":{\"wifi_ssid\":\"Solo\"}}";
prov_state_t st; prov_reset(&st);
char resp[128];
prov_class_t c = provision_handle_line(line, strlen(line), TEST_MAC, &st, resp, sizeof(resp));
ASSERT_EQ(c, CLASS_OK);
ASSERT_EQ(strcmp(pstr_get(&st.str, K_SSID), "Solo"), 0);
ASSERT_FALSE(pstr_exists(&st.str, K_PASS));
ASSERT_FALSE(st.ms_port_set);
ASSERT_FALSE(st.debug_set);
ASSERT_EQ(st.provisioned, 1);
}
/* Valid JSON but no "provision" wrapper key → missing_provision_key. */
TEST(serial_prov_missing_provision_key)
{
const char *line = "{\"wifi_ssid\":\"HomeNet\"}";
prov_state_t st; prov_reset(&st);
char resp[128];
prov_class_t c = provision_handle_line(line, strlen(line), TEST_MAC, &st, resp, sizeof(resp));
ASSERT_EQ(c, CLASS_MISSING_KEY);
ASSERT_TRUE(strstr(resp, "missing_provision_key") != NULL);
}
/* Top-level non-object (array) has no members → missing_provision_key. */
TEST(serial_prov_top_level_array_is_missing_key)
{
const char *line = "[1,2,3]";
prov_state_t st; prov_reset(&st);
char resp[128];
prov_class_t c = provision_handle_line(line, strlen(line), TEST_MAC, &st, resp, sizeof(resp));
ASSERT_EQ(c, CLASS_MISSING_KEY);
}
/* Garbage input → invalid_json, never crashes. */
TEST(serial_prov_invalid_json)
{
const char *cases[] = {
"",
"not json",
"{",
"{unquoted}",
"{\"provision\":}",
"{\"a\":1,}", /* trailing comma */
"}{",
"\xff\xfe garbage",
};
for (size_t i = 0; i < sizeof(cases) / sizeof(cases[0]); i++) {
prov_state_t st; prov_reset(&st);
char resp[128];
prov_class_t c = provision_handle_line(cases[i], strlen(cases[i]),
TEST_MAC, &st, resp, sizeof(resp));
ASSERT_EQ(c, CLASS_INVALID_JSON);
ASSERT_TRUE(strstr(resp, "invalid_json") != NULL);
}
}
/* A debug value that is present but not a bool writes 0 (cJSON_IsTrue==false). */
TEST(serial_prov_debug_non_bool_writes_zero)
{
const char *line = "{\"provision\":{\"wifi_ssid\":\"H\",\"debug\":\"yes\"}}";
prov_state_t st; prov_reset(&st);
char resp[128];
prov_class_t c = provision_handle_line(line, strlen(line), TEST_MAC, &st, resp, sizeof(resp));
ASSERT_EQ(c, CLASS_OK);
ASSERT_TRUE(st.debug_set);
ASSERT_EQ(st.debug, 0);
}
/* debug:false explicitly writes 0; ms_port given as a string is ignored. */
TEST(serial_prov_port_wrong_type_ignored)
{
const char *line =
"{\"provision\":{\"wifi_ssid\":\"H\",\"ms_port\":\"8080\",\"debug\":false}}";
prov_state_t st; prov_reset(&st);
char resp[128];
provision_handle_line(line, strlen(line), TEST_MAC, &st, resp, sizeof(resp));
ASSERT_FALSE(st.ms_port_set); /* string, not number → ignored */
ASSERT_TRUE(st.debug_set);
ASSERT_EQ(st.debug, 0);
}
/*
* String escapes round-trip: a SSID with quotes/backslashes/control escapes
* is decoded into the stored value exactly as the firmware's cJSON would.
*/
TEST(serial_prov_string_escapes_decoded)
{
const char *line = "{\"provision\":{\"wifi_ssid\":\"a\\\"b\\\\c\\nd\"}}";
prov_state_t st; prov_reset(&st);
char resp[128];
prov_class_t c = provision_handle_line(line, strlen(line), TEST_MAC, &st, resp, sizeof(resp));
ASSERT_EQ(c, CLASS_OK);
ASSERT_EQ(strcmp(pstr_get(&st.str, K_SSID), "a\"b\\c\nd"), 0);
}
/* ========================================================================== */
/* Fuzz: the parser must never crash on arbitrary UART input, and the */
/* protocol must always answer with a single well-formed {"ok":...} line. */
/* ========================================================================== */
/* Deterministic LCG (no reliance on libc rand state / seed). */
static uint32_t fuzz_lcg(uint32_t *s) {
*s = (*s * 1103515245u + 12345u) & 0x7fffffffu;
return *s;
}
/*
* Validate that a response line is a single, complete JSON object: starts with
* '{"ok":', contains no embedded newline, and ends with "}\n". This is the
* robustness contract a malformed UART line must never yield a half-framed
* response that could desync the host's line reader.
*/
static bool resp_is_well_formed(const char *resp) {
if (resp[0] != '{') return false;
if (strstr(resp, "\"ok\":") == NULL) return false;
size_t n = strlen(resp);
if (n < 4) return false;
if (resp[n - 1] != '\n' || resp[n - 2] != '}') return false;
for (size_t i = 0; i + 1 < n; i++)
if (resp[i] == '\n') return false; /* no embedded newlines */
return true;
}
TEST(serial_prov_fuzz_random_bytes_never_crash)
{
static const char *corpus[] = {
"{", "}", "[]", "[[[[[[[[[[[", "{\"provision\":{\"wifi_ssid\":",
"{\"a\":" , "{\"a\":null}", "null", "true", "false", "1234567890",
"\"unterminated", "{\"provision\":{\"wifi_ssid\":\"\\u00",
"{\"provision\":{\"wifi_ssid\":\"x\",\"extra\":" ,
"\xef\xbb\xbf{\"provision\":{\"wifi_ssid\":\"bom\"}}", /* UTF-8 BOM */
"{\"provision\" : { \"wifi_ssid\" : \"ws\" } }", /* ws tolerance */
};
uint32_t s = 0xC0FFEEu; /* fixed seed → reproducible */
unsigned char buf[300];
/* Random byte streams of varied length. */
for (int iter = 0; iter < 4000; iter++) {
size_t len = fuzz_lcg(&s) % (sizeof(buf));
for (size_t i = 0; i < len; i++) buf[i] = (unsigned char)(fuzz_lcg(&s) & 0xFF);
prov_state_t st; prov_reset(&st);
char resp[160];
prov_class_t c = provision_handle_line((const char *)buf, len, TEST_MAC,
&st, resp, sizeof(resp));
(void)c; /* any class is fine — the contract is robustness */
ASSERT_TRUE(resp_is_well_formed(resp));
}
/* Fixed corpus of tricky / malformed inputs. */
for (size_t i = 0; i < sizeof(corpus) / sizeof(corpus[0]); i++) {
prov_state_t st; prov_reset(&st);
char resp[160];
provision_handle_line(corpus[i], strlen(corpus[i]), TEST_MAC,
&st, resp, sizeof(resp));
ASSERT_TRUE(resp_is_well_formed(resp));
}
}
/*
* Deep-nesting stress: the parser's depth cap must reject pathological input
* without unbounded recursion (which would overflow the stack). Each input is
* a wall of opening braces/brackets.
*/
TEST(serial_prov_fuzz_deep_nesting_capped)
{
char deep[2048];
memset(deep, '{', sizeof(deep) - 1);
deep[sizeof(deep) - 1] = '\0';
uint32_t s = 1;
for (int iter = 0; iter < 500; iter++) {
/* Mix of '{', '[', '"', and random bytes to stress the depth path. */
size_t len = 64 + (fuzz_lcg(&s) % (sizeof(deep) - 65));
for (size_t i = 0; i < len; i++) {
uint32_t r = fuzz_lcg(&s) & 3;
deep[i] = (r == 0) ? '{' : (r == 1) ? '[' : (r == 2) ? '"' : (char)(fuzz_lcg(&s) & 0x7F);
}
deep[len] = '\0';
prov_state_t st; prov_reset(&st);
char resp[160];
provision_handle_line(deep, len, TEST_MAC, &st, resp, sizeof(resp));
ASSERT_TRUE(resp_is_well_formed(resp));
}
}