test: add firmware host tests for nvs/csi/serial_prov + wire gcc harness into CI
Adds the three firmware host-test modules required by the Testing Strategy as a
plain gcc harness under firmware/test/ — NOT idf.py --target linux. That path was
rejected (docs/notes/firmware-host-test-approach.md, bf-21t): firmware/main
cannot host-link because csi.c pulls in esp_wifi.h and provision.c pulls in
driver/uart.h, and the single `main` component REQUIRES esp_wifi/bt/driver,
which have no linux build — so even nvs_migration.c (hostable in isolation) is
unhostable as part of the component. The harness therefore tests dependency-free
logic extractions and binary-format/wire contracts instead of linking the
firmware source.
- test_nvs_migration.c: fresh-install init to v1, no-downgrade guard, forward
migration loop dispatch (v→v+1 at index v−1), and the concrete v1→v2 step
(rename ms_ip→mothership_ip, default ntp_server), driven against an in-memory
NVS store. Mirrors nvs_migration.c decision-for-decision.
- test_csi_frame.c: 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). Mirrors the
websocket.c encoder contract (offset/byte for offset/byte).
- test_serial_prov.c: provisioning JSON parser + NVS-mapping mirror of
provision.c (all four protocol branches + every field mapping), shipping a
bounded recursive-descent JSON decoder as the fuzz target. The fuzz pass
(4000 random byte streams, a tricky-input corpus, 500 deep-nesting cases)
proves the parser never crashes and the protocol always answers a single
well-formed {"ok":...} line on any UART input.
- Makefile: gcc build/run recipe that globs every test_*.c + test_runner.c.
CI wiring: the Dockerfile firmware-builder stage now runs `make -C test test`
before the expensive ESP-IDF build, so a logic/format-contract regression fails
the image build fast. .gitignore + .dockerignore exclude the regenerable
host_tests binary.
docs/plan/plan.md Testing Strategy updated from the idf.py description to the
gcc harness (matching the decision record).
28 tests, all passing. go test ./... and go vet ./... unchanged (firmware-only).
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
4b0eaba9a7
commit
5e588592f4
5 changed files with 1458 additions and 6 deletions
10
Dockerfile
10
Dockerfile
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 v−1), 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)
|
||||
|
|
|
|||
330
firmware/test/test_csi_frame.c
Normal file
330
firmware/test/test_csi_frame.c
Normal 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, ×tamp_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, ×tamp_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);
|
||||
}
|
||||
373
firmware/test/test_nvs_migration.c
Normal file
373
firmware/test/test_nvs_migration.c
Normal file
|
|
@ -0,0 +1,373 @@
|
|||
/*
|
||||
* ============================================================================
|
||||
* Host test: NVS schema migration
|
||||
* ============================================================================
|
||||
*
|
||||
* Covers the plan's Testing-Strategy requirement:
|
||||
* `nvs` — NVS schema migration: simulate schema_ver=0→1 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 key→string 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 v2→v3 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 v2→v3 step (index 1), NOT v1→v2. 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);
|
||||
}
|
||||
700
firmware/test/test_serial_prov.c
Normal file
700
firmware/test/test_serial_prov.c
Normal 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));
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue