From b3d5885c2672abe2deadfc91f4d8fdeb8cca07cd Mon Sep 17 00:00:00 2001 From: ruv Date: Wed, 21 Oct 2026 14:51:34 -0400 Subject: [PATCH] fix(firmware): harden config rollback and prepare 0.8.15 release --- CHANGELOG.md | 2 + docs/releases/v0.8.15-esp32.md | 31 ++++ firmware/esp32-csi-node/README.md | 16 +- firmware/esp32-csi-node/main/config_api.c | 161 ++++++++++-------- firmware/esp32-csi-node/test/Makefile | 20 ++- .../test/config_stubs/config_test_sdk.h | 58 +++++++ .../test/config_stubs/esp_err.h | 2 + .../test/config_stubs/esp_http_server.h | 2 + .../test/config_stubs/esp_log.h | 2 + .../test/config_stubs/esp_system.h | 2 + .../test/config_stubs/esp_timer.h | 2 + .../test/config_stubs/esp_wifi.h | 2 + .../esp32-csi-node/test/config_stubs/nvs.h | 2 + .../test/config_stubs/nvs_flash.h | 2 + .../test/test_config_recovery.c | 140 +++++++++++++++ firmware/esp32-csi-node/version.txt | 2 +- 16 files changed, 371 insertions(+), 75 deletions(-) create mode 100644 docs/releases/v0.8.15-esp32.md create mode 100644 firmware/esp32-csi-node/test/config_stubs/config_test_sdk.h create mode 100644 firmware/esp32-csi-node/test/config_stubs/esp_err.h create mode 100644 firmware/esp32-csi-node/test/config_stubs/esp_http_server.h create mode 100644 firmware/esp32-csi-node/test/config_stubs/esp_log.h create mode 100644 firmware/esp32-csi-node/test/config_stubs/esp_system.h create mode 100644 firmware/esp32-csi-node/test/config_stubs/esp_timer.h create mode 100644 firmware/esp32-csi-node/test/config_stubs/esp_wifi.h create mode 100644 firmware/esp32-csi-node/test/config_stubs/nvs.h create mode 100644 firmware/esp32-csi-node/test/config_stubs/nvs_flash.h create mode 100644 firmware/esp32-csi-node/test/test_config_recovery.c diff --git a/CHANGELOG.md b/CHANGELOG.md index 3448c3b82..b556f46ca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.8.15-esp32] - 2026-10-21 + ### ESP32 CSI input and onboarding corrections - Process the bounded prefix of S3 STBC CSI frames instead of discarding the diff --git a/docs/releases/v0.8.15-esp32.md b/docs/releases/v0.8.15-esp32.md new file mode 100644 index 000000000..55346dcff --- /dev/null +++ b/docs/releases/v0.8.15-esp32.md @@ -0,0 +1,31 @@ +# RuView ESP32 firmware 0.8.15 prerelease + +Firmware 0.8.15 improves CSI processing and remote configuration recovery for +ESP32-S3 and ESP32-C6 nodes. + +## Changes + +- S3 STBC frames now contribute their bounded CSI prefix to edge processing + instead of being discarded, preserving input for vitals and feature output. +- Gateway self-ping waits are capped to one probe interval. AP-specific ICMP + airtime remains a deployment factor; `scripts/csi-udp-poke.py` provides a + bounded private-LAN traffic source when CSI traffic is otherwise sparse. +- USB serial/JTAG onboarding prepares or reuses the IDF 6 driver and reports + setup failures. +- The config API validates complete requests before writing and retains a + durable rollback bank across failed writes and recovery attempts. +- Rust dependencies were updated for current Wasmtime and WebPKI advisories; + the audit policy retains narrowly documented ignores for ZIP extraction + advisories whose vulnerable extraction APIs are not used by the workspace. + +## Validation + +- Firmware host suite passed, including production-code STBC, self-ping, + configuration rules, and write-through NVS rollback fault injection. +- ESP32-C6 firmware built with ESP-IDF 6.0.2 and fits the 4 MB partition map. +- The attached ESP32-C6 was observed on the LAN running firmware 0.8.14. + Thirty-second paired captures recorded about 29 CSI packets/s in baseline + and with bounded host traffic. The board was not flashed with 0.8.15 because + a complete flash backup could not be obtained. +- This prerelease has no new physical-board firmware qualification or sensing + accuracy claim. See the [candidate validation record](../validation/2026-10-20-esp32-csi-input-release-candidate.md). diff --git a/firmware/esp32-csi-node/README.md b/firmware/esp32-csi-node/README.md index 943c69bda..e91f1140e 100644 --- a/firmware/esp32-csi-node/README.md +++ b/firmware/esp32-csi-node/README.md @@ -19,7 +19,21 @@ This firmware captures WiFi Channel State Information (CSI) from an ESP32-S3 (pr > | **Fall detection** | Phase acceleration threshold | Configurable sensitivity | > | **Programmable sensing** | WASM modules loaded over HTTP | Hot-swap, no reflash | -## Firmware 0.8.14 prerelease: device identity and OTA provisioning +## Firmware 0.8.15 prerelease: CSI reliability and recoverable configuration + +Version 0.8.15 preserves bounded CSI input for ESP32-S3 STBC frames, caps +gateway ping waits to one probe interval, and prepares IDF 6 USB serial/JTAG +before onboarding. The authenticated remote configuration endpoint validates +whole requests before writing and retains a durable rollback bank if a write +or recovery step fails. + +The attached ESP32-C6 supplied live CSI transport evidence while running +0.8.14. It was not flashed with 0.8.15 because a complete flash backup could +not be obtained. CI builds and host fault-injection tests verify software +behavior; they do not establish 0.8.15 physical-board qualification or sensing +accuracy. + +### Firmware 0.8.14 prerelease: device identity and OTA provisioning Version 0.8.14 fixes the six-byte base-MAC input used for USB onboarding on ESP32-C6, where the previous API wrote an eight-byte EUI-64 into that buffer. diff --git a/firmware/esp32-csi-node/main/config_api.c b/firmware/esp32-csi-node/main/config_api.c index 53cab0620..9644b0c4e 100644 --- a/firmware/esp32-csi-node/main/config_api.c +++ b/firmware/esp32-csi-node/main/config_api.c @@ -70,12 +70,16 @@ static volatile bool s_pending_reboot = false; * literal string "(set)" and lock the node off the network permanently -- the * exact failure the trial exists to prevent. */ -static cJSON *read_key(nvs_handle_t h, const cfg_key_t *k, bool redact) +static cJSON *read_key(nvs_handle_t h, const cfg_key_t *k, bool redact, + esp_err_t *read_status) { + if (read_status) *read_status = ESP_OK; if (k->type == T_STR) { char buf[96]; size_t len = sizeof(buf); - if (nvs_get_str(h, k->nvs, buf, &len) != ESP_OK) return cJSON_CreateNull(); + esp_err_t e = nvs_get_str(h, k->nvs, buf, &len); + if (read_status) *read_status = e; + if (e != ESP_OK) return cJSON_CreateNull(); /* Never hand back key material, even to an authenticated caller: it * would end up in logs and terminal scrollback. Presence only. */ if (redact && strcmp(k->json, "wifi_password") == 0) { @@ -86,12 +90,13 @@ static cJSON *read_key(nvs_handle_t h, const cfg_key_t *k, bool redact) uint32_t v = 0; esp_err_t e; if (k->type == T_U8) { - uint8_t x; e = nvs_get_u8(h, k->nvs, &x); v = x; + uint8_t x = 0; e = nvs_get_u8(h, k->nvs, &x); v = x; } else if (k->type == T_U16) { - uint16_t x; e = nvs_get_u16(h, k->nvs, &x); v = x; + uint16_t x = 0; e = nvs_get_u16(h, k->nvs, &x); v = x; } else { e = nvs_get_u32(h, k->nvs, &v); } + if (read_status) *read_status = e; if (e != ESP_OK) return cJSON_CreateNull(); if (k->scale > 0) return cJSON_CreateNumber((double)v / k->scale); return cJSON_CreateNumber((double)v); @@ -192,8 +197,8 @@ static uint32_t banked_number(const cfg_key_t *k, const cJSON *v) static bool restore_key(nvs_handle_t h, const cfg_key_t *k, const cJSON *v) { if (cJSON_IsNull(v)) { - nvs_erase_key(h, k->nvs); /* absence is a state worth restoring */ - return true; + esp_err_t e = nvs_erase_key(h, k->nvs); + return e == ESP_OK || e == ESP_ERR_NVS_NOT_FOUND; } char err[64]; return store_key(h, k, v, banked_number(k, v), err, sizeof(err)); @@ -212,63 +217,66 @@ static bool restore_bank(nvs_handle_t h, const cJSON *bank) cJSON *it = NULL; cJSON_ArrayForEach(it, bank) { const cfg_key_t *k = config_find_key(it->string); - if (k && !restore_key(h, k, it)) { - ESP_LOGE(TAG, "could not restore %s", k->json); + if (!k || !restore_key(h, k, it)) { + ESP_LOGE(TAG, "could not restore banked key"); ok = false; } } return ok; } +/* Recovery metadata is discarded only after the previous state is durable. */ +static bool clear_recovery_bank(void) +{ + nvs_handle_t h; + if (nvs_open(TRIAL_NS, NVS_READWRITE, &h) != ESP_OK) return false; + bool ok = nvs_erase_all(h) == ESP_OK && nvs_commit(h) == ESP_OK; + nvs_close(h); + return ok; +} + +static bool finish_recovery_bank(bool needs_trial) +{ + if (!needs_trial) return clear_recovery_bank(); + nvs_handle_t h; + if (nvs_open(TRIAL_NS, NVS_READWRITE, &h) != ESP_OK) return false; + bool ok = nvs_set_u8(h, "rollback", 0) == ESP_OK && nvs_commit(h) == ESP_OK; + nvs_close(h); + return ok; +} + /* ---------------------------------------------------------------- trial --- */ /** Apply the banked values and reboot. Runs from the esp_timer task. */ static void trial_revert(void *arg) { (void)arg; - ESP_LOGE(TAG, "config trial FAILED to associate -- reverting"); - - nvs_handle_t t, c; - if (nvs_open(TRIAL_NS, NVS_READWRITE, &t) != ESP_OK) { - /* Cannot read the bank, so cannot revert. Rebooting would spin - * forever; stay up so the node is at least diagnosable if it is - * somehow still reachable. */ - ESP_LOGE(TAG, "trial namespace unreadable; cannot revert"); + s_pending_reboot = true; + nvs_handle_t t; + if (nvs_open(TRIAL_NS, NVS_READONLY, &t) != ESP_OK) { + ESP_LOGE(TAG, "recovery bank unreadable; retaining write lock"); return; } - size_t len = 0; char *json = NULL; - if (nvs_get_str(t, "bank", NULL, &len) == ESP_OK && len > 1 && - (json = malloc(len)) != NULL && - nvs_get_str(t, "bank", json, &len) == ESP_OK && - nvs_open(CFG_NS, NVS_READWRITE, &c) == ESP_OK) { - - cJSON *root = cJSON_Parse(json); - if (root) { - cJSON *it = NULL; - cJSON_ArrayForEach(it, root) { - const cfg_key_t *k = config_find_key(it->string); - if (k) { - restore_key(c, k, it); - ESP_LOGW(TAG, "reverted %s", k->json); - } - } - cJSON_Delete(root); - } - nvs_commit(c); + bool loaded = nvs_get_str(t, "bank", NULL, &len) == ESP_OK && len > 1 && + len <= BODY_MAX * 2 && (json = malloc(len)) != NULL && + nvs_get_str(t, "bank", json, &len) == ESP_OK; + nvs_close(t); + cJSON *bank = loaded ? cJSON_Parse(json) : NULL; + free(json); + bool restored = false; + nvs_handle_t c; + if (cJSON_IsObject(bank) && nvs_open(CFG_NS, NVS_READWRITE, &c) == ESP_OK) { + restored = restore_bank(c, bank) && nvs_commit(c) == ESP_OK; nvs_close(c); } - free(json); - - /* Clear the trial BEFORE rebooting: if the restored config is itself - * broken we must not loop reverting forever. One attempt, then the node - * comes up on the old values and stays there. */ - nvs_erase_all(t); - nvs_commit(t); - nvs_close(t); - - ESP_LOGE(TAG, "rebooting onto the previous configuration"); + cJSON_Delete(bank); + if (!restored || !clear_recovery_bank()) { + ESP_LOGE(TAG, "recovery failed; keeping bank and write lock for retry"); + return; + } + ESP_LOGW(TAG, "previous configuration restored; rebooting"); esp_restart(); } @@ -278,10 +286,16 @@ void config_trial_boot_check(void) if (nvs_open(TRIAL_NS, NVS_READONLY, &t) != ESP_OK) return; uint8_t armed = 0; + uint8_t rollback = 0; uint32_t deadline = TRIAL_DEFAULT; nvs_get_u8(t, "armed", &armed); + nvs_get_u8(t, "rollback", &rollback); nvs_get_u32(t, "deadline", &deadline); nvs_close(t); + if (rollback) { + trial_revert(NULL); + return; + } if (!armed) return; s_trial_armed = true; @@ -302,7 +316,11 @@ void config_trial_boot_check(void) void config_trial_notify_connected(void) { - if (!s_trial_armed) return; + if (!s_trial_armed || s_pending_reboot) return; + if (!clear_recovery_bank()) { + ESP_LOGE(TAG, "could not commit trial; preserving recovery state"); + return; + } s_trial_armed = false; if (s_revert_timer) { @@ -311,12 +329,6 @@ void config_trial_notify_connected(void) s_revert_timer = NULL; } - nvs_handle_t t; - if (nvs_open(TRIAL_NS, NVS_READWRITE, &t) == ESP_OK) { - nvs_erase_all(t); - nvs_commit(t); - nvs_close(t); - } ESP_LOGI(TAG, "config trial COMMITTED -- association proved on new settings"); } @@ -351,7 +363,7 @@ static esp_err_t config_get_handler(httpd_req_t *req) cJSON *vals = cJSON_AddObjectToObject(root, "config"); if (nvs_open(CFG_NS, NVS_READONLY, &h) == ESP_OK) { for (size_t i = 0; i < CONFIG_KEY_COUNT; i++) { - cJSON_AddItemToObject(vals, CONFIG_KEYS[i].json, read_key(h, &CONFIG_KEYS[i], true)); + cJSON_AddItemToObject(vals, CONFIG_KEYS[i].json, read_key(h, &CONFIG_KEYS[i], true, NULL)); } nvs_close(h); } @@ -503,7 +515,18 @@ static esp_err_t config_post_handler(httpd_req_t *req) cJSON_ArrayForEach(it, root) { if (config_is_meta(it->string)) continue; const cfg_key_t *k = config_find_key(it->string); - cJSON_AddItemToObject(bank, k->json, read_key(ro, k, false)); + esp_err_t read_status; + cJSON *value = read_key(ro, k, false, &read_status); + if (!value || (read_status != ESP_OK && read_status != ESP_ERR_NVS_NOT_FOUND) || + !cJSON_AddItemToObject(bank, k->json, value)) { + cJSON_Delete(value); + nvs_close(ro); + cJSON_Delete(root); + cJSON_Delete(bank); + httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, + "Could not snapshot the complete current config"); + return ESP_FAIL; + } } nvs_close(ro); } @@ -514,14 +537,15 @@ static esp_err_t config_post_handler(httpd_req_t *req) * leave the node on unproven settings with nothing to fall back to. Once * the bank IS on flash the node can get back to the previous * configuration even if everything after this point fails. */ - if (needs_trial) { + { char *bj = cJSON_PrintUnformatted(bank); nvs_handle_t t; bool ok = bj && nvs_open(TRIAL_NS, NVS_READWRITE, &t) == ESP_OK; if (ok) { ok = nvs_set_str(t, "bank", bj) == ESP_OK && nvs_set_u32(t, "deadline", deadline) == ESP_OK && - nvs_set_u8(t, "armed", 1) == ESP_OK && + nvs_set_u8(t, "rollback", 1) == ESP_OK && + nvs_set_u8(t, "armed", needs_trial ? 1 : 0) == ESP_OK && nvs_commit(t) == ESP_OK; nvs_close(t); } @@ -545,7 +569,7 @@ static esp_err_t config_post_handler(httpd_req_t *req) * 2, and the recovery bank is already on flash. */ nvs_handle_t rw; if (nvs_open(CFG_NS, NVS_READWRITE, &rw) != ESP_OK) { - s_pending_reboot = false; + s_pending_reboot = !clear_recovery_bank(); cJSON_Delete(root); cJSON_Delete(bank); httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "NVS unavailable"); @@ -568,6 +592,7 @@ static esp_err_t config_post_handler(httpd_req_t *req) } esp_err_t ce = err[0] ? ESP_FAIL : nvs_commit(rw); nvs_close(rw); + if (ce == ESP_OK && !finish_recovery_bank(needs_trial)) ce = ESP_FAIL; if (err[0] || ce != ESP_OK) { /* A write failed part way through: the node is holding a mix of old @@ -577,17 +602,14 @@ static esp_err_t config_post_handler(httpd_req_t *req) nvs_handle_t r; bool restored = false; if (nvs_open(CFG_NS, NVS_READWRITE, &r) == ESP_OK) { - restored = restore_bank(r, bank); + restored = restore_bank(r, bank) && nvs_commit(r) == ESP_OK; nvs_close(r); } - if (needs_trial) { - nvs_handle_t t; - if (nvs_open(TRIAL_NS, NVS_READWRITE, &t) == ESP_OK) { - nvs_erase_all(t); - nvs_close(t); - } - } - s_pending_reboot = false; + if (restored) restored = clear_recovery_bank(); + /* A failed restore must retain the persisted bank and write lock. + * The rollback marker makes the next boot retry recovery BEFORE WiFi, + * instead of promoting a partially written config on association. */ + s_pending_reboot = !restored; cJSON_Delete(root); cJSON_Delete(bank); if (!restored) { @@ -655,12 +677,15 @@ static esp_err_t config_post_handler(httpd_req_t *req) const esp_timer_create_args_t a = { .callback = reboot_soon, .name = "cfg_boot" }; esp_timer_handle_t th; if (esp_timer_create(&a, &th) == ESP_OK) { - esp_timer_start_once(th, 3000000ULL); + if (esp_timer_start_once(th, 3000000ULL) != ESP_OK) { + esp_timer_delete(th); + ESP_LOGE(TAG, "reboot timer failed; retaining write lock until reset"); + } } else { /* No reboot means no pending window. Leaving the lock set would * refuse every later write for the life of the process. */ ESP_LOGE(TAG, "could not schedule the reboot; clearing the pending lock"); - s_pending_reboot = false; + /* Keep the bank protected from a second request until reset. */ } } else { /* Applied immediately, nothing in flight. */ diff --git a/firmware/esp32-csi-node/test/Makefile b/firmware/esp32-csi-node/test/Makefile index 5fde76d93..959c0faac 100644 --- a/firmware/esp32-csi-node/test/Makefile +++ b/firmware/esp32-csi-node/test/Makefile @@ -38,6 +38,7 @@ CFLAGS = -fsanitize=fuzzer,address,undefined -g -O1 \ STUBS_SRC = stubs/esp_stubs.c MAIN_DIR = ../main +CJSON_DIR ?= $(if $(wildcard $(IDF_PATH)/components/json/cJSON/cJSON.h),$(IDF_PATH)/components/json/cJSON,$(MAIN_DIR)/../managed_components/espressif__cjson/cJSON) # GNU ld and Apple's ld spell dead section elimination differently. # Keep the host sanitation test portable across the Linux CI runner and macOS. @@ -165,6 +166,17 @@ test_config_rules: test_config_rules.c $(MAIN_DIR)/config_rules.c $(MAIN_DIR)/co run_config_rules: test_config_rules ./test_config_rules +# --- Configuration API write-through rollback and boot recovery --- +# Compiles the production handler against deterministic, write-through NVS +# stubs and injects each failure between partial writes and restoration. +test_config_recovery: test_config_recovery.c $(MAIN_DIR)/config_api.c $(MAIN_DIR)/config_rules.c $(MAIN_DIR)/config_limits.h $(CJSON_DIR)/cJSON.h $(CJSON_DIR)/cJSON.c $(wildcard config_stubs/*) + cc -std=c99 -Wall -Wextra -Wno-unused-variable -Wno-unused-parameter -Werror \ + -Iconfig_stubs -Istubs -I$(MAIN_DIR) -I$(CJSON_DIR) \ + test_config_recovery.c $(MAIN_DIR)/config_rules.c $(CJSON_DIR)/cJSON.c -lm -o $@ + +run_config_recovery: test_config_recovery + ./test_config_recovery + run_delivery_contract: python3 test_delivery_contract.py @@ -183,8 +195,8 @@ run_self_ping: test_self_ping host_tests: run_self_ping run_edge_stbc run_adr110 run_vitals run_mmwave_detect run_thermal run_csi_sanitize \ run_c6_antenna run_serial_onboarding run_onboarding_identity run_onboarding_console run_delivery_contract run_ota_health \ - run_config_rules - @echo "Host tests passed (firmware contracts, DSP, configuration, onboarding and OTA health)" + run_config_rules run_config_recovery + @echo "Host tests passed (firmware contracts, DSP, configuration recovery, onboarding and OTA health)" # --- Serialize fuzzer --- # Tests csi_serialize_frame() with random wifi_csi_info_t inputs. @@ -222,7 +234,5 @@ run_all: run_serialize run_edge run_nvs clean: rm -f fuzz_serialize fuzz_edge fuzz_nvs test_adr110 test_vitals test_mmwave_detect \ test_thermal test_csi_sanitize test_c6_antenna test_serial_onboarding \ - test_ota_health test_onboarding_identity - rm -f test_edge_stbc test_self_ping - test_ota_health test_onboarding_identity test_config_rules + test_ota_health test_onboarding_identity test_config_rules test_edge_stbc test_self_ping test_config_recovery rm -rf corpus_serialize/ corpus_edge/ corpus_nvs/ diff --git a/firmware/esp32-csi-node/test/config_stubs/config_test_sdk.h b/firmware/esp32-csi-node/test/config_stubs/config_test_sdk.h new file mode 100644 index 000000000..175eadef1 --- /dev/null +++ b/firmware/esp32-csi-node/test/config_stubs/config_test_sdk.h @@ -0,0 +1,58 @@ +#pragma once +#include +#include +#include +#include +#include +#include +typedef int esp_err_t; +#define ESP_OK 0 +#define ESP_FAIL -1 +#define ESP_ERR_NO_MEM 0x101 +#define ESP_ERR_NVS_NOT_FOUND 0x1102 +#define ESP_LOGE(...) ((void)0) +#define ESP_LOGW(...) ((void)0) +#define ESP_LOGI(...) ((void)0) +typedef int nvs_handle_t; +#define NVS_READONLY 0 +#define NVS_READWRITE 1 +esp_err_t nvs_open(const char *, int, nvs_handle_t *); +void nvs_close(nvs_handle_t); +esp_err_t nvs_commit(nvs_handle_t); +esp_err_t nvs_get_str(nvs_handle_t, const char *, char *, size_t *); +esp_err_t nvs_get_u8(nvs_handle_t, const char *, uint8_t *); +esp_err_t nvs_get_u16(nvs_handle_t, const char *, uint16_t *); +esp_err_t nvs_get_u32(nvs_handle_t, const char *, uint32_t *); +esp_err_t nvs_set_str(nvs_handle_t, const char *, const char *); +esp_err_t nvs_set_u8(nvs_handle_t, const char *, uint8_t); +esp_err_t nvs_set_u16(nvs_handle_t, const char *, uint16_t); +esp_err_t nvs_set_u32(nvs_handle_t, const char *, uint32_t); +esp_err_t nvs_erase_key(nvs_handle_t, const char *); +esp_err_t nvs_erase_all(nvs_handle_t); +typedef struct { const char *body; int content_len, offset, status; } httpd_req_t; +typedef void *httpd_handle_t; +typedef struct { const char *uri; int method; esp_err_t (*handler)(httpd_req_t *); } httpd_uri_t; +#define HTTP_GET 0 +#define HTTP_POST 1 +#define HTTPD_400_BAD_REQUEST 400 +#define HTTPD_403_FORBIDDEN 403 +#define HTTPD_500_INTERNAL_SERVER_ERROR 500 +static inline int httpd_req_recv(httpd_req_t *r, char *b, size_t n) { + if (n > (size_t)(r->content_len - r->offset)) n = r->content_len - r->offset; + memcpy(b, r->body + r->offset, n); r->offset += n; return (int)n; +} +static inline esp_err_t httpd_resp_send_err(httpd_req_t *r, int s, const char *m) { (void)m; r->status = s; return ESP_OK; } +static inline esp_err_t httpd_resp_sendstr(httpd_req_t *r, const char *s) { (void)s; if (!r->status) r->status = 200; return ESP_OK; } +static inline esp_err_t httpd_resp_set_type(httpd_req_t *r, const char *s) { (void)r; (void)s; return ESP_OK; } +static inline esp_err_t httpd_resp_set_status(httpd_req_t *r, const char *s) { r->status = atoi(s); return ESP_OK; } +static inline esp_err_t httpd_register_uri_handler(httpd_handle_t h, const httpd_uri_t *u) { (void)h; (void)u; return ESP_OK; } +typedef void *esp_timer_handle_t; +typedef struct { void (*callback)(void *); const char *name; } esp_timer_create_args_t; +esp_err_t esp_timer_create(const esp_timer_create_args_t *, esp_timer_handle_t *); +esp_err_t esp_timer_start_once(esp_timer_handle_t, uint64_t); +static inline esp_err_t esp_timer_stop(esp_timer_handle_t h) { (void)h; return ESP_OK; } +static inline esp_err_t esp_timer_delete(esp_timer_handle_t h) { (void)h; return ESP_OK; } +static inline int64_t esp_timer_get_time(void) { return 100000000; } +void esp_restart(void); +static inline esp_err_t esp_wifi_disconnect(void) { return ESP_OK; } +static inline const char *esp_err_to_name(esp_err_t e) { (void)e; return "synthetic"; } diff --git a/firmware/esp32-csi-node/test/config_stubs/esp_err.h b/firmware/esp32-csi-node/test/config_stubs/esp_err.h new file mode 100644 index 000000000..ecfe56c87 --- /dev/null +++ b/firmware/esp32-csi-node/test/config_stubs/esp_err.h @@ -0,0 +1,2 @@ +#pragma once +#include "config_test_sdk.h" diff --git a/firmware/esp32-csi-node/test/config_stubs/esp_http_server.h b/firmware/esp32-csi-node/test/config_stubs/esp_http_server.h new file mode 100644 index 000000000..ecfe56c87 --- /dev/null +++ b/firmware/esp32-csi-node/test/config_stubs/esp_http_server.h @@ -0,0 +1,2 @@ +#pragma once +#include "config_test_sdk.h" diff --git a/firmware/esp32-csi-node/test/config_stubs/esp_log.h b/firmware/esp32-csi-node/test/config_stubs/esp_log.h new file mode 100644 index 000000000..ecfe56c87 --- /dev/null +++ b/firmware/esp32-csi-node/test/config_stubs/esp_log.h @@ -0,0 +1,2 @@ +#pragma once +#include "config_test_sdk.h" diff --git a/firmware/esp32-csi-node/test/config_stubs/esp_system.h b/firmware/esp32-csi-node/test/config_stubs/esp_system.h new file mode 100644 index 000000000..ecfe56c87 --- /dev/null +++ b/firmware/esp32-csi-node/test/config_stubs/esp_system.h @@ -0,0 +1,2 @@ +#pragma once +#include "config_test_sdk.h" diff --git a/firmware/esp32-csi-node/test/config_stubs/esp_timer.h b/firmware/esp32-csi-node/test/config_stubs/esp_timer.h new file mode 100644 index 000000000..ecfe56c87 --- /dev/null +++ b/firmware/esp32-csi-node/test/config_stubs/esp_timer.h @@ -0,0 +1,2 @@ +#pragma once +#include "config_test_sdk.h" diff --git a/firmware/esp32-csi-node/test/config_stubs/esp_wifi.h b/firmware/esp32-csi-node/test/config_stubs/esp_wifi.h new file mode 100644 index 000000000..ecfe56c87 --- /dev/null +++ b/firmware/esp32-csi-node/test/config_stubs/esp_wifi.h @@ -0,0 +1,2 @@ +#pragma once +#include "config_test_sdk.h" diff --git a/firmware/esp32-csi-node/test/config_stubs/nvs.h b/firmware/esp32-csi-node/test/config_stubs/nvs.h new file mode 100644 index 000000000..ecfe56c87 --- /dev/null +++ b/firmware/esp32-csi-node/test/config_stubs/nvs.h @@ -0,0 +1,2 @@ +#pragma once +#include "config_test_sdk.h" diff --git a/firmware/esp32-csi-node/test/config_stubs/nvs_flash.h b/firmware/esp32-csi-node/test/config_stubs/nvs_flash.h new file mode 100644 index 000000000..ecfe56c87 --- /dev/null +++ b/firmware/esp32-csi-node/test/config_stubs/nvs_flash.h @@ -0,0 +1,2 @@ +#pragma once +#include "config_test_sdk.h" diff --git a/firmware/esp32-csi-node/test/test_config_recovery.c b/firmware/esp32-csi-node/test/test_config_recovery.c new file mode 100644 index 000000000..0a4a72bb0 --- /dev/null +++ b/firmware/esp32-csi-node/test/test_config_recovery.c @@ -0,0 +1,140 @@ +/* SYNTHETIC: run the real HTTP handler and boot recovery against write-through + * NVS fault injection. No device, credentials or RF data are used. */ +#include "config_test_sdk.h" +#include "cJSON.h" +#include +#include "../main/config_api.c" + +nvs_config_t g_nvs_config; +static cJSON *db[3]; +static int live_sets, fail_set_at, restarts; +static bool fail_restore, fail_erase, fail_bank_clear, fail_read, fail_restore_open; +static bool fail_timer, fail_commit; + +static void put(nvs_handle_t h, const char *key, cJSON *value) +{ + cJSON_DeleteItemFromObjectCaseSensitive(db[h], key); + assert(cJSON_AddItemToObject(db[h], key, value)); +} +esp_err_t nvs_open(const char *name, int mode, nvs_handle_t *h) +{ + *h = strcmp(name, "csi_cfg") == 0 ? 1 : 2; + if (*h == 1 && mode == NVS_READWRITE && fail_restore_open && live_sets >= 2) return ESP_FAIL; + return ESP_OK; +} +void nvs_close(nvs_handle_t h) { (void)h; } +esp_err_t nvs_commit(nvs_handle_t h) { return h == 1 && fail_commit ? ESP_FAIL : ESP_OK; } +esp_err_t nvs_get_str(nvs_handle_t h, const char *key, char *value, size_t *size) +{ + if (h == 1 && fail_read) return ESP_FAIL; + cJSON *v = cJSON_GetObjectItemCaseSensitive(db[h], key); + if (!cJSON_IsString(v)) return ESP_ERR_NVS_NOT_FOUND; + size_t n = strlen(v->valuestring) + 1; + if (value) { if (*size < n) return ESP_FAIL; memcpy(value, v->valuestring, n); } + *size = n; return ESP_OK; +} +static esp_err_t get_num(nvs_handle_t h, const char *key, uint32_t *value) +{ + cJSON *v = cJSON_GetObjectItemCaseSensitive(db[h], key); + if (!cJSON_IsNumber(v)) return ESP_ERR_NVS_NOT_FOUND; + *value = (uint32_t)v->valuedouble; return ESP_OK; +} +esp_err_t nvs_get_u8(nvs_handle_t h, const char *k, uint8_t *v) { uint32_t n=0; int e=get_num(h,k,&n); *v=(uint8_t)n; return e; } +esp_err_t nvs_get_u16(nvs_handle_t h, const char *k, uint16_t *v) { uint32_t n=0; int e=get_num(h,k,&n); *v=(uint16_t)n; return e; } +esp_err_t nvs_get_u32(nvs_handle_t h, const char *k, uint32_t *v) { return get_num(h,k,v); } +static esp_err_t set_value(nvs_handle_t h, const char *k, cJSON *v) +{ + if (h == 1) { + live_sets++; + if ((fail_set_at && live_sets == fail_set_at) || + (fail_restore && live_sets > fail_set_at)) { cJSON_Delete(v); return ESP_FAIL; } + } + put(h,k,v); return ESP_OK; /* setters immediately change persistent state */ +} +esp_err_t nvs_set_str(nvs_handle_t h, const char *k, const char *v) { return set_value(h,k,cJSON_CreateString(v)); } +esp_err_t nvs_set_u8(nvs_handle_t h, const char *k, uint8_t v) { return set_value(h,k,cJSON_CreateNumber(v)); } +esp_err_t nvs_set_u16(nvs_handle_t h, const char *k, uint16_t v) { return set_value(h,k,cJSON_CreateNumber(v)); } +esp_err_t nvs_set_u32(nvs_handle_t h, const char *k, uint32_t v) { return set_value(h,k,cJSON_CreateNumber(v)); } +esp_err_t nvs_erase_key(nvs_handle_t h, const char *k) +{ + if (fail_erase) return ESP_FAIL; + if (!cJSON_GetObjectItemCaseSensitive(db[h],k)) return ESP_ERR_NVS_NOT_FOUND; + cJSON_DeleteItemFromObjectCaseSensitive(db[h],k); return ESP_OK; +} +esp_err_t nvs_erase_all(nvs_handle_t h) +{ + if (h == 2 && fail_bank_clear) return ESP_FAIL; + cJSON_Delete(db[h]); db[h]=cJSON_CreateObject(); return ESP_OK; +} +esp_err_t esp_timer_create(const esp_timer_create_args_t *a, esp_timer_handle_t *h) { (void)a; *h=(void *)1; return fail_timer ? ESP_FAIL : ESP_OK; } +esp_err_t esp_timer_start_once(esp_timer_handle_t h, uint64_t us) { (void)h; (void)us; return fail_timer ? ESP_FAIL : ESP_OK; } +void esp_restart(void) { restarts++; } +bool ota_auth_check(httpd_req_t *r) { (void)r; return true; } +void ota_update_log_httpd_stack(const char *s) { (void)s; } + +static void reset(void) +{ + for (int i=1;i<=2;i++) { cJSON_Delete(db[i]); db[i]=cJSON_CreateObject(); } + put(1,"ssid",cJSON_CreateString("old-network")); + put(1,"edge_tier",cJSON_CreateNumber(0)); + live_sets=fail_set_at=restarts=0; + fail_restore=fail_erase=fail_bank_clear=fail_read=fail_restore_open=fail_timer=fail_commit=false; + s_pending_reboot=s_trial_armed=false; s_revert_timer=NULL; +} +static int request(const char *body) +{ + httpd_req_t req={.body=body,.content_len=(int)strlen(body)}; + config_post_handler(&req); return req.status; +} +static bool bank_present(void) { return cJSON_IsString(cJSON_GetObjectItem(db[2],"bank")); } +static void assert_old(void) { assert(strcmp(cJSON_GetObjectItem(db[1],"ssid")->valuestring,"old-network")==0); } + +int main(void) +{ + const char *change="{\"wifi_ssid\":\"new-network\",\"edge_tier\":1}"; + reset(); + assert(request("{\"wifi_ssid\":\"new-network\",\"edge_tier\":\"invalid\"}")==400); + assert_old(); assert(live_sets==0 && !bank_present()); + reset(); fail_read=true; + assert(request(change)==500); assert(live_sets==0); assert_old(); + reset(); + assert(request("{\"led_mode\":1,\"reboot\":false}")==200); + assert(!s_pending_reboot && !bank_present() && g_nvs_config.led_mode==1); + reset(); fail_set_at=2; + assert(request(change)==500); assert_old(); + assert(!s_pending_reboot && !bank_present()); + for (int mode=0;mode<3;mode++) { + reset(); fail_set_at=2; + fail_restore=mode==0; fail_restore_open=mode==1; fail_bank_clear=mode==2; + assert(request(change)==500); + assert(bank_present() && s_pending_reboot); + int previous=live_sets; + assert(request("{\"led_mode\":1}")==409 && live_sets==previous); + config_trial_notify_connected(); assert(bank_present()); + /* A reset must restore before association can promote mixed values. */ + s_pending_reboot=s_trial_armed=false; + fail_restore=fail_restore_open=fail_bank_clear=false; fail_set_at=0; + config_trial_boot_check(); + assert_old(); assert(restarts==1 && !bank_present()); + } + reset(); fail_set_at=2; fail_restore=true; + assert(request(change)==500); + trial_revert(NULL); assert(bank_present() && restarts==0 && s_pending_reboot); + reset(); fail_erase=true; + cJSON *absent=cJSON_CreateNull(); + assert(!restore_key(1,config_find_key("led_mode"),absent)); cJSON_Delete(absent); + reset(); fail_timer=true; + assert(request(change)==200); assert(bank_present() && s_pending_reboot); + assert(request("{\"led_mode\":1}")==409); + reset(); + assert(request(change)==200); assert(bank_present() && s_pending_reboot); + config_trial_notify_connected(); assert(bank_present()); /* old connection */ + s_pending_reboot=s_trial_armed=false; + config_trial_boot_check(); assert(s_trial_armed && bank_present()); + config_trial_notify_connected(); assert(!s_trial_armed && !bank_present()); + reset(); fail_commit=true; + assert(request(change)==500); assert(bank_present() && s_pending_reboot); + for (int i=1;i<=2;i++) cJSON_Delete(db[i]); + puts("PASS: production HTTP/NVS validation, rollback faults, write lock and boot recovery"); + return 0; +} diff --git a/firmware/esp32-csi-node/version.txt b/firmware/esp32-csi-node/version.txt index 832bad274..7d87d9947 100644 --- a/firmware/esp32-csi-node/version.txt +++ b/firmware/esp32-csi-node/version.txt @@ -1 +1 @@ -0.8.14 +0.8.15