From 4b0eaba9a7e1f5ebea50fb3ea597c92c1591deba Mon Sep 17 00:00:00 2001 From: jedarden Date: Fri, 3 Jul 2026 12:42:09 -0400 Subject: [PATCH 1/3] test: document setjmp volatile-analysis in firmware host harness loop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a comment next to the per-test setjmp(g_test_jmp) guard in main()'s loop recording the C11 7.13.2.1 volatile analysis: the loop index i is read in the post-longjmp path (g_tests[i].name) but is not modified between the setjmp() call and a possible longjmp() — the body only reads i, and the only write is the for-loop increment, which runs after control returns. So no volatile qualifier is needed. (bf-2ftl) Co-Authored-By: Claude --- firmware/test/test_runner.c | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/firmware/test/test_runner.c b/firmware/test/test_runner.c index e5cd7f9..7fad69c 100644 --- a/firmware/test/test_runner.c +++ b/firmware/test/test_runner.c @@ -234,6 +234,25 @@ int main(void) int failed = 0; for (int i = 0; i < g_test_count; i++) { + /* + * Per-test recovery guard. setjmp() establishes the longjmp() target + * that test_record_failure() jumps into on a failed assertion. On the + * direct call setjmp() returns 0 and the body runs as before; on a + * longjmp() return it yields non-zero and we take the else branch — + * the body is NOT re-invoked, the loop just falls through to the next + * i. That is the whole point: a failure in test N never blocks N+1. + * + * volatile analysis (C11 7.13.2.1): an automatic variable modified + * between setjmp() and longjmp() AND read after the longjmp returns + * has indeterminate value unless it is volatile-qualified. The loop + * index i IS read in the post-longjmp path (g_tests[i].name in the + * else branch), so the question is whether i is modified between the + * setjmp() call and a possible longjmp(). It is not: between them the + * body only READS i (g_tests[i].fn()), and the only write to i is the + * for-loop increment, which runs AFTER control returns here (whether + * via a normal body return or the longjmp). So no volatile is needed + * on i — confirmed safe. + */ if (setjmp(g_test_jmp) == 0) { g_tests[i].fn(); printf("PASS: %s\n", g_tests[i].name); From 5e588592f485fa9e9d419d0fbf7ae5adf43dc7b6 Mon Sep 17 00:00:00 2001 From: jedarden Date: Fri, 3 Jul 2026 13:06:43 -0400 Subject: [PATCH 2/3] test: add firmware host tests for nvs/csi/serial_prov + wire gcc harness into CI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- Dockerfile | 10 + docs/plan/plan.md | 51 ++- firmware/test/test_csi_frame.c | 330 ++++++++++++++ firmware/test/test_nvs_migration.c | 373 +++++++++++++++ firmware/test/test_serial_prov.c | 700 +++++++++++++++++++++++++++++ 5 files changed, 1458 insertions(+), 6 deletions(-) create mode 100644 firmware/test/test_csi_frame.c create mode 100644 firmware/test/test_nvs_migration.c create mode 100644 firmware/test/test_serial_prov.c diff --git a/Dockerfile b/Dockerfile index 78abc53..e94ca91 100644 --- a/Dockerfile +++ b/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 diff --git a/docs/plan/plan.md b/docs/plan/plan.md index f8ea7ce..31a1fc1 100644 --- a/docs/plan/plan.md +++ b/docs/plan/plan.md @@ -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) diff --git a/firmware/test/test_csi_frame.c b/firmware/test/test_csi_frame.c new file mode 100644 index 0000000..d0311b7 --- /dev/null +++ b/firmware/test/test_csi_frame.c @@ -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 +#include + +/* ---- 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); +} diff --git a/firmware/test/test_nvs_migration.c b/firmware/test/test_nvs_migration.c new file mode 100644 index 0000000..332607f --- /dev/null +++ b/firmware/test/test_nvs_migration.c @@ -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 +#include +#include +#include + +/* ---- 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); +} diff --git a/firmware/test/test_serial_prov.c b/firmware/test/test_serial_prov.c new file mode 100644 index 0000000..f8ed299 --- /dev/null +++ b/firmware/test/test_serial_prov.c @@ -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":""} + * + * 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 +#include +#include +#include +#include + +/* ---- 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)); + } +} From fbb86fb687bdbf62de405ba9dd898278abf48ec9 Mon Sep 17 00:00:00 2001 From: jedarden Date: Fri, 3 Jul 2026 13:28:21 -0400 Subject: [PATCH 3/3] chore(firmware): gitignore abandoned idf.py --target linux test_apps residue MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ESP-IDF host-test path (idf.py test --target linux) was explicitly rejected (decision record: docs/notes/firmware-host-test-approach.md) because firmware/main builds as one idf_component_register whose REQUIRES names esp_wifi/bt/driver — components with no host build, so the whole component is unhostable. Firmware host tests instead run as a plain gcc harness under firmware/test/ (wired into CI via the Dockerfile's 'RUN make -C test test'). firmware/test_apps/host_tests/ was leftover CMake/ninja build output from an abandoned idf.py linux experiment (no source files). Gitignore firmware/test_apps/ so the rejected approach's artifacts can't be committed by accident. Refs bead bf-31bp (host-test intent satisfied by the committed gcc harness). Co-Authored-By: Claude --- .gitignore | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.gitignore b/.gitignore index 03f8108..3f10018 100644 --- a/.gitignore +++ b/.gitignore @@ -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