spaxel/firmware/test/test_serial_prov.c
jedarden 5e588592f4 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>
2026-07-03 13:21:26 -04:00

700 lines
27 KiB
C

/*
* ============================================================================
* 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));
}
}