Add Typed Admin Network Settings
This commit is contained in:
@@ -0,0 +1,236 @@
|
||||
# Network backend contract and host tests — 8D.12/8D.13
|
||||
|
||||
## Integration boundary
|
||||
|
||||
Compile `src/web_network_settings.c`. Register these three independently optional,
|
||||
exact method/path handlers using the existing settings registration pattern:
|
||||
|
||||
| Method | Path | Handler |
|
||||
|---|---|---|
|
||||
| GET | `/api/settings/network` | `web_network_snapshot_handler` |
|
||||
| GET | `/api/settings/network-operation` | `web_network_operation_handler` |
|
||||
| POST | `/api/settings/network-operation` | `web_network_operation_handler` |
|
||||
|
||||
This backend change does not edit `web_server`, `web_ui`, CMake, or `docs/`.
|
||||
The existing administration dispatcher now accepts a Network operation ID, not
|
||||
commands or credentials. Its item size, queue depth, task count and stack sizes
|
||||
are unchanged. Browser-shell Wi-Fi/mDNS restrictions are unchanged.
|
||||
|
||||
All three routes use existing cookie-auth policy: current admin, origin-bound
|
||||
session, duplicate/framing/Fetch-Metadata checks. POST requires matching Origin,
|
||||
CSRF and `application/json` or `application/json; charset=utf-8`. GET follows
|
||||
existing settings GET Origin policy (an absent Origin is permitted; a supplied
|
||||
mismatch is denied). GET is bodyless; query strings are rejected on every route.
|
||||
Responses use JSON, no-store, nosniff and no-referrer. No credential export route.
|
||||
|
||||
## Snapshot
|
||||
|
||||
A complete example (values are illustrative, never defaults to install):
|
||||
|
||||
```json
|
||||
{
|
||||
"wifi": {
|
||||
"generation": 7,
|
||||
"enabled_at_boot": true,
|
||||
"ap": {"policy": "fallback", "channel": 6, "ssid": "ESP32-SAK-example", "password_configured": true},
|
||||
"profiles": [
|
||||
{"index": 0, "enabled": true, "priority": 10, "security": "mixed", "ssid": "office", "password_configured": true},
|
||||
{"index": 1, "enabled": false, "priority": 20, "security": "wpa3", "ssid": "backup", "password_configured": false},
|
||||
{"index": 2, "enabled": false, "priority": 0, "security": "mixed", "ssid": "", "password_configured": false},
|
||||
{"index": 3, "enabled": false, "priority": 0, "security": "mixed", "ssid": "", "password_configured": false}
|
||||
]
|
||||
},
|
||||
"runtime": {"started": true, "state": "online", "active_profile": 0, "ip": "192.168.1.20", "ap_running": false, "ap_clients": 0, "last_error": 0},
|
||||
"mdns": {"generation": 3, "suffix": "example", "hostname": "sak-example", "announced": true, "last_error": 0}
|
||||
}
|
||||
```
|
||||
|
||||
Four stable indices 0..3 are always present. `active_profile:-1` means none.
|
||||
Runtime states are canonical `stopped`, `starting`, `connecting`, `waiting-ip`,
|
||||
`online`, `backoff`, `ap-only`, `error` (fallback `unknown`). `hostname` excludes
|
||||
`.local`. The existing responder is STA-only. `announced` is the service's
|
||||
expected-announcement status, not a client-observed DNS verification.
|
||||
|
||||
Wi-Fi working configuration and runtime are copied together under its mutex;
|
||||
mDNS is a separate consistent projection, not an atomic cross-domain snapshot.
|
||||
Both acquisitions use zero wait. Either unavailable/contended yields HTTP 503
|
||||
`{"error":"snapshot_unavailable"}`, not guessed partial values. No driver,
|
||||
NVS or secret-bearing config getter runs on HTTPD.
|
||||
|
||||
No PSKs or PSK lengths occur in projection structs/JSON. `password_configured`
|
||||
is only a boolean, justified by disabled-profile staging/enabling validation.
|
||||
|
||||
### SSID byte strings
|
||||
|
||||
SSID length is 0..32 decoded **bytes**, not UTF-8 characters or JSON bytes.
|
||||
A nonempty AP SSID and nonempty enabled-profile SSID are required. To clear a
|
||||
profile's SSID, its password must also be absent and the profile disabled.
|
||||
|
||||
The reversible wire codec accepts raw printable ASCII, JSON `\"`, `\\`, `\/`,
|
||||
`\b`, `\f`, `\n`, `\r`, `\t`, and `\u00HH` (hex case-insensitive). Every decoded
|
||||
codepoint maps to exactly one byte. It rejects raw non-ASCII, non-byte Unicode,
|
||||
surrogates and malformed escapes. Snapshot encoding emits other bytes, quote
|
||||
and backslash as `\u00hh`; embedded zero and arbitrary non-UTF-8 round-trip.
|
||||
For example `"A\u0000\u00ff"` means bytes `41 00 ff`.
|
||||
|
||||
UI must not pass ordinary JS UTF-16 strings straight through `JSON.stringify`
|
||||
for SSIDs. Encode user text as UTF-8 bytes first and encode each non-ASCII byte
|
||||
as `\u00HH`. Preserve an explicit reversible byte editing/display mode for
|
||||
existing arbitrary SSIDs; never silently replacement-decode and resubmit them.
|
||||
|
||||
## POST operations
|
||||
|
||||
A single flat JSON object, unknown/duplicate fields rejected. No nested config,
|
||||
arrays, nulls, signed/fractional/exponent integers or leading-zero numbers.
|
||||
Booleans are JSON `true`/`false`. Generation is a nonzero uint32 from the selected
|
||||
domain snapshot. All optional patch fields preserve current values when omitted;
|
||||
at least one patch field is required. Each POST changes only one domain/target.
|
||||
|
||||
| action | Required fields besides action | Optional fields |
|
||||
|---|---|---|
|
||||
| `wifi-patch` | `generation` (Wi-Fi) | `enabled_at_boot`, `ap_policy` (`off/fallback/always`), `channel` (1..11), `ssid`, `password`, `clear_password:true` |
|
||||
| `profile-patch` | `generation` (Wi-Fi), `profile` (0..3) | `enabled`, `priority` (0..255), `security` (`mixed/wpa3`), `ssid`, `password`, `clear_password:true` |
|
||||
| `wifi-save` | `generation` (Wi-Fi) | none |
|
||||
| `wifi-load` | `generation` (Wi-Fi) | none |
|
||||
| `start` | none | none |
|
||||
| `stop` | none | none |
|
||||
| `reconnect` | none | none |
|
||||
| `next-profile` | none | none |
|
||||
| `mdns-set` | `generation` (mDNS), `suffix` | none |
|
||||
| `mdns-save` | `generation` (mDNS) | none |
|
||||
| `mdns-load` | `generation` (mDNS) | none |
|
||||
| `mdns-defaults` | `generation` (mDNS) | none |
|
||||
|
||||
Examples:
|
||||
|
||||
```json
|
||||
{"action":"profile-patch","generation":7,"profile":0,"enabled":true,"priority":10,"security":"mixed","ssid":"office","password":"new-example-password"}
|
||||
{"action":"profile-patch","generation":8,"profile":0,"enabled":false,"clear_password":true}
|
||||
{"action":"wifi-patch","generation":9,"ap_policy":"always","channel":6}
|
||||
{"action":"wifi-save","generation":10}
|
||||
{"action":"wifi-load","generation":10}
|
||||
{"action":"next-profile"}
|
||||
{"action":"mdns-set","generation":3,"suffix":"lab-serial"}
|
||||
```
|
||||
|
||||
Password replacement is 8..63 printable ASCII bytes; `password:""` is rejected.
|
||||
Replacement and clear cannot coexist; `clear_password:false` is rejected.
|
||||
Clearing a disabled STA password is supported; a single patch can disable and
|
||||
clear. Enabled STA must retain a valid password. AP clear is rejected even with
|
||||
policy `off`: the canonical config never permits invalid/open AP credentials.
|
||||
`mixed` means WPA2-or-stronger, not an open network or WPA2-only guarantee.
|
||||
|
||||
Patching compares generation and merges into **current** secret bytes under the
|
||||
Wi-Fi mutex, validates the full canonical candidate, queues any required owner
|
||||
restart, then publishes. Queue failure leaves RAM unchanged. Stale browser edits
|
||||
cannot undo local start/stop or a newer CLI apply. Generations never wrap/reuse.
|
||||
|
||||
Edits are RAM-only. Disabled-profile-only edits do not restart the radio; enabling,
|
||||
disabling, enabled-profile changes and AP changes follow canonical asynchronous
|
||||
restart policy. `enabled_at_boot` alone is next-boot policy, not Start/Stop.
|
||||
Start/Stop intentionally also change RAM `enabled_at_boot`; explicit Save persists
|
||||
it. Reconnect and next-profile do nothing when the manager is stopped. Next means
|
||||
next enabled profile in canonical priority order, wrapping; no explicit-index
|
||||
connection-selection API was added.
|
||||
|
||||
Wi-Fi Save persists the selected generation under the config mutex. Wi-Fi Load
|
||||
reads only the existing canonical blob and conditionally installs it; missing,
|
||||
invalid/incompatible or failed storage never generates/installs a new AP secret
|
||||
or changes RAM. No Wi-Fi defaults/reset actions. mDNS suffix is 1..55 lowercase
|
||||
ASCII letters/digits/hyphens, no leading/trailing hyphen; hostname is `sak-` plus
|
||||
suffix. mDNS edits/default/load are RAM-only and queue owner reannouncement;
|
||||
Save persists. mDNS Load may select deterministic MAC defaults and reports that
|
||||
result. Offline suffix edits reach an already-initialized responder on the next
|
||||
STA IP. NVS remains unencrypted; clearing/replacing is not secure flash erasure.
|
||||
|
||||
## Admission/results, errors and uncertainty
|
||||
|
||||
POST admission: HTTP 202, e.g.
|
||||
|
||||
```json
|
||||
{"id":42,"action":"profile-patch","state":"pending","error":0}
|
||||
```
|
||||
|
||||
GET operation returns HTTP 200 with exactly the same four fields. Only the
|
||||
initiating login can retrieve its slot. A different admin/no retained result gets
|
||||
`{"id":0,"action":"none","state":"idle","error":0}`. No query ID: UI compares
|
||||
returned `id` to its acknowledged ID. A later admitted operation replaces the
|
||||
previous result. IDs never wrap; exhaustion denies admission until reboot.
|
||||
|
||||
| state | Meaning |
|
||||
|---|---|
|
||||
| `idle` | No retained result for this login |
|
||||
| `pending` | Waiting for dispatcher or executing |
|
||||
| `accepted` | RAM apply / owner queue request accepted; NOT association, DHCP, online, radio completion or verified DNS |
|
||||
| `ok` | Explicit Wi-Fi/mDNS save returned success |
|
||||
| `failed` | Canonical/owner/storage error; `error` is numeric `esp_err_t` |
|
||||
| `cancelled` | Queued expiry or session/currentness/dequeue deadline rejection; no canonical operation admitted |
|
||||
| `stale` | Selected config generation no longer matches |
|
||||
| `invalid` | Canonical config rejects the patch/load (e.g. enabled STA clear or AP clear) |
|
||||
| `loaded_defaults` | mDNS Load selected deterministic RAM defaults and reannouncement was queued |
|
||||
| `applied_not_queued` | mDNS RAM change succeeded but manager reannouncement queue failed; refresh, do not assume rollback |
|
||||
|
||||
`error` is diagnostic numeric status, not a state override: cancelled can have
|
||||
zero error (deadline/currentness false). No arbitrary error text or input echo.
|
||||
Known terminal results should trigger a fresh snapshot. Runtime failures after
|
||||
`accepted` appear in subsequent snapshots, not by rewriting the result.
|
||||
|
||||
HTTP errors: existing 400 invalid/framing/query/body/method, 401 authentication,
|
||||
403 Origin/CSRF/admin, 503 auth-unavailable; backend-specific 400
|
||||
`invalid_network_request`, 503 `timer_unavailable`, 503 `busy` (Retry-After: 1),
|
||||
503 `snapshot_unavailable`. Unread body/receive failures close rather than drain.
|
||||
Malformed input is never queued. Syntactically valid but canonically invalid
|
||||
patches may receive 202 and then terminal `invalid`.
|
||||
|
||||
One static session-bound pending/result slot, executing reservation under a short
|
||||
portMUX, no credentials in the dispatcher queue. One firmware-lifetime one-second
|
||||
ESP timer inspects the current ID/deadline and wipes/cancels non-executing input at
|
||||
30 seconds plus scheduler latency. Shared inputs wipe on dequeue before auth;
|
||||
dispatcher-local inputs wipe on every return. HTTP body/parser/operation inputs
|
||||
wipe, including rejection and before response IO. Already-admitted work can
|
||||
finish after logout/disconnect/deadline; this is not transactional session liveness
|
||||
or a hard wall-clock erasure guarantee. Expired IDs cannot execute replacements.
|
||||
|
||||
Network-changing controls need UI confirmation/recovery warnings. HTTPS/SSH and
|
||||
both browser WebSockets may disconnect before any ACK/result. A lost ACK, 401 or
|
||||
disconnect proves neither success nor cancellation. Never automatically replay.
|
||||
Reconnect to STA/AP and inspect configuration/runtime; UART0/native USB recovery
|
||||
remain independent. No terminal lease/transport changes are made by this module.
|
||||
|
||||
## Bounds and validation
|
||||
|
||||
- 768-byte POST, at most four receives, at most 13 distinct flat keys, 64-byte
|
||||
parser value scratch; enough for one fully escaped 32-byte SSID and 63-byte
|
||||
replacement plus the typed fields. No heap JSON tree/cJSON.
|
||||
- 2,048-byte snapshot buffer. Maximum escaped fixture: 1,877 payload bytes
|
||||
(five 32-byte SSIDs at six bytes/byte, four profiles, full-width numbers,
|
||||
55-byte mDNS suffix plus hostname, longest booleans/state/security/policy).
|
||||
- 128-byte operation response buffer; one static operation and one small timer.
|
||||
No new task, queue/depth/stack expansion or schema migration.
|
||||
- Target RAM/stack margins and hardware behavior are not measured by host tests.
|
||||
|
||||
Commands run successfully:
|
||||
|
||||
```sh
|
||||
python3 tests/web_network_settings/run.py
|
||||
python3 tests/web_cookie_auth/run.py --network
|
||||
python3 tests/web_cookie_auth/run.py --accounts
|
||||
python3 tests/web_cookie_auth/run.py --serial-settings
|
||||
python3 tests/web_cookie_auth/run.py --settings
|
||||
python3 tests/web_cookie_auth/run.py --admin
|
||||
python3 tests/admin_console_boundary/run.py
|
||||
python3 tests/admin_console_boundary/lifecycle.py
|
||||
python3 tests/admin_console_boundary/accounts.py
|
||||
```
|
||||
|
||||
The new manager harness compiles verbatim production mutation/queue functions
|
||||
with real `wifi_config.c`, `mdns_config.c`, `mdns_service.c`; deterministic RTOS,
|
||||
NVS, radio admission and mDNS component doubles. It does not simulate the whole
|
||||
Wi-Fi event loop, power loss or target scheduling. Cookie tests compile the real
|
||||
backend/auth/session/HTTPD adapter with owner doubles and actual installed IDF
|
||||
header getter/response-header functions.
|
||||
|
||||
Sanitizer attempt (`run.py --sanitize`) could not link: this host lacks
|
||||
`libasan.so.8.0.0` and `libubsan.so.1.0.0`. Normal suites reran successfully.
|
||||
No `pio run`, upload, erase, asset generation or commit. Integration and target
|
||||
validation remain with their owners.
|
||||
@@ -0,0 +1,101 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Real config modules/service and verbatim manager mutation paths, host RTOS/NVS.
|
||||
|
||||
The full manager driver/event loop is NOT simulated. Extracted functions include
|
||||
both canonical legacy apply/lifecycle admission and new mutex-local APIs, so the
|
||||
regressions exercise the actual shared transaction rather than a second model.
|
||||
"""
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import tempfile
|
||||
import sys
|
||||
os.environ['CCACHE_DISABLE'] = '1'
|
||||
sys.dont_write_bytecode = True
|
||||
HERE = Path(__file__).resolve().parent
|
||||
ROOT = HERE.parents[1]
|
||||
HEADERS = {
|
||||
'esp_err.h': '''#pragma once
|
||||
typedef int esp_err_t;
|
||||
#define ESP_OK 0
|
||||
#define ESP_FAIL -1
|
||||
#define ESP_ERR_NO_MEM 0x101
|
||||
#define ESP_ERR_INVALID_ARG 0x102
|
||||
#define ESP_ERR_INVALID_STATE 0x103
|
||||
#define ESP_ERR_INVALID_SIZE 0x104
|
||||
#define ESP_ERR_NOT_FOUND 0x105
|
||||
#define ESP_ERR_TIMEOUT 0x107
|
||||
#define ESP_ERR_NVS_NOT_FOUND 0x1102
|
||||
#define ESP_ERR_NVS_TYPE_MISMATCH 0x1103
|
||||
#define ESP_ERR_NVS_INVALID_LENGTH 0x110c
|
||||
''',
|
||||
'esp_wifi_types.h': '#pragma once\ntypedef int wifi_auth_mode_t;\n',
|
||||
'freertos/FreeRTOS.h': '''#pragma once
|
||||
#include <stdint.h>
|
||||
#define pdTRUE 1
|
||||
#define portMAX_DELAY UINT32_MAX
|
||||
''',
|
||||
'freertos/semphr.h': '''#pragma once
|
||||
#include <stdint.h>
|
||||
typedef int *SemaphoreHandle_t;
|
||||
SemaphoreHandle_t xSemaphoreCreateMutex(void);
|
||||
int xSemaphoreTake(SemaphoreHandle_t,uint32_t);
|
||||
int xSemaphoreGive(SemaphoreHandle_t);
|
||||
''',
|
||||
'nvs.h': '''#pragma once
|
||||
#include <stddef.h>
|
||||
#include "esp_err.h"
|
||||
typedef int nvs_handle_t;
|
||||
#define NVS_READONLY 0
|
||||
#define NVS_READWRITE 1
|
||||
esp_err_t nvs_open(const char *,int,nvs_handle_t *);
|
||||
esp_err_t nvs_get_blob(nvs_handle_t,const char *,void *,size_t *);
|
||||
esp_err_t nvs_set_blob(nvs_handle_t,const char *,const void *,size_t);
|
||||
esp_err_t nvs_commit(nvs_handle_t);
|
||||
void nvs_close(nvs_handle_t);
|
||||
''',
|
||||
'nvs_flash.h': '#pragma once\n#include "esp_err.h"\nesp_err_t nvs_flash_init(void);\n',
|
||||
'esp_mac.h': '''#pragma once
|
||||
#include <stdint.h>
|
||||
#include "esp_err.h"
|
||||
#define ESP_MAC_WIFI_STA 0
|
||||
#define ESP_MAC_WIFI_SOFTAP 1
|
||||
esp_err_t esp_read_mac(uint8_t *,int);
|
||||
''',
|
||||
'mdns.h': '''#pragma once
|
||||
#include "esp_err.h"
|
||||
esp_err_t mdns_init(void);
|
||||
esp_err_t mdns_hostname_set(const char *);
|
||||
esp_err_t mdns_instance_name_set(const char *);
|
||||
void mdns_free(void);
|
||||
''',
|
||||
}
|
||||
def function(source, name):
|
||||
start = source.index(name + '(')
|
||||
start = source.rfind('\n', 0, start) + 1
|
||||
return source[start:source.index('\n}', start) + 2]
|
||||
source = (ROOT / 'src/wifi_manager.c').read_text()
|
||||
names = ['count_queue_drop', 'enqueue_message', 'wifi_manager_get_snapshot',
|
||||
'profiles_equal', 'config_requires_radio_restart', 'apply_config_locked',
|
||||
'wifi_manager_apply_working_config', 'wifi_manager_get_settings',
|
||||
'generation_matches', 'wifi_manager_patch_current', 'wifi_manager_save_current',
|
||||
'wifi_manager_load_current', 'enqueue_lifecycle_command', 'wifi_manager_start',
|
||||
'wifi_manager_stop', 'wifi_manager_reconnect', 'wifi_manager_next_profile',
|
||||
'wifi_manager_mdns_reannounce']
|
||||
# Guard both ownership and no expansion of the real policy owner.
|
||||
assert '#define WIFI_MANAGER_QUEUE_LENGTH 16U' in source
|
||||
assert '#define WIFI_MANAGER_TASK_STACK_SIZE 6144U' in source
|
||||
assert 'wifi_config_load(' not in function(source, 'wifi_manager_load_current')
|
||||
with tempfile.TemporaryDirectory(prefix='web-network-settings-') as directory:
|
||||
tmp = Path(directory)
|
||||
for name, contents in HEADERS.items():
|
||||
path = tmp / name
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(contents)
|
||||
(tmp / 'manager_production.h').write_text('\n'.join(function(source, name) for name in names))
|
||||
sanitizer = ['-fsanitize=address,undefined', '-fno-omit-frame-pointer'] if '--sanitize' in sys.argv else []
|
||||
subprocess.run(['cc', '-std=c11', '-Wall', '-Wextra', '-Werror', '-g', *sanitizer,
|
||||
'-I' + str(tmp), '-I' + str(ROOT / 'src'), str(HERE / 'test.c'),
|
||||
*[str(ROOT / 'src' / name) for name in ('wifi_config.c', 'mdns_config.c', 'mdns_service.c')],
|
||||
'-o', str(tmp / 'test')], check=True, timeout=30)
|
||||
subprocess.run([str(tmp / 'test')], check=True, timeout=20)
|
||||
@@ -0,0 +1,214 @@
|
||||
#include <assert.h>
|
||||
#include <stdint.h>
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
#include "wifi_manager.h"
|
||||
#include "mdns_service.h"
|
||||
#include "secure_random.h"
|
||||
#include "freertos/FreeRTOS.h"
|
||||
#include "freertos/semphr.h"
|
||||
#include "nvs.h"
|
||||
|
||||
static int wifi_mutex, mdns_mutex;
|
||||
static SemaphoreHandle_t s_mutex=&wifi_mutex;
|
||||
static struct { wifi_app_config_t config; wifi_manager_snapshot_t snapshot; } s_shared;
|
||||
typedef enum { MESSAGE_COMMAND_START, MESSAGE_COMMAND_STOP, MESSAGE_COMMAND_APPLY,
|
||||
MESSAGE_COMMAND_RECONNECT, MESSAGE_COMMAND_NEXT_PROFILE, MESSAGE_COMMAND_MDNS_REANNOUNCE } manager_message_type_t;
|
||||
typedef struct { manager_message_type_t type; } manager_message_t;
|
||||
static unsigned queued, random_calls, wiped_candidates, commits, hostname_calls;
|
||||
static bool queue_fail, snapshot_contention;
|
||||
static esp_err_t nvs_error, commit_error, hostname_error;
|
||||
static manager_message_type_t last_message;
|
||||
static void lock_shared(void) { assert(!wifi_mutex); wifi_mutex=1; }
|
||||
static void unlock_shared(void) { assert(wifi_mutex); wifi_mutex=0; }
|
||||
static int s_drop_mux, s_queue;
|
||||
static uint64_t s_queue_drops;
|
||||
#define portENTER_CRITICAL(mux) do { assert((mux)==&s_drop_mux && !s_drop_mux); s_drop_mux=1; } while (0)
|
||||
#define portEXIT_CRITICAL(mux) do { assert((mux)==&s_drop_mux && s_drop_mux); s_drop_mux=0; } while (0)
|
||||
static int xQueueSend(int queue,const manager_message_t *message,uint32_t wait) {
|
||||
assert(queue==s_queue && !wait && wifi_mutex && !s_drop_mux);
|
||||
if(queue_fail) return 0;
|
||||
++queued; last_message=message->type; return pdTRUE;
|
||||
}
|
||||
SemaphoreHandle_t xSemaphoreCreateMutex(void) { return &mdns_mutex; }
|
||||
int xSemaphoreTake(SemaphoreHandle_t mutex,uint32_t wait) {
|
||||
if(wait==0 && (snapshot_contention || *mutex)) return 0;
|
||||
assert(!*mutex); *mutex=1; return pdTRUE;
|
||||
}
|
||||
int xSemaphoreGive(SemaphoreHandle_t mutex) { assert(*mutex); *mutex=0; return pdTRUE; }
|
||||
void secure_wipe(void *data,size_t size) {
|
||||
if(size==sizeof(wifi_app_config_t)) ++wiped_candidates;
|
||||
volatile uint8_t *p=data; while(size--) *p++=0;
|
||||
}
|
||||
esp_err_t secure_random_fill(void *data,size_t size) { ++random_calls; memset(data,17,size); return ESP_OK; }
|
||||
esp_err_t esp_read_mac(uint8_t *mac,int interface) { (void)interface; memset(mac,0x12,6); return ESP_OK; }
|
||||
static struct { uint8_t bytes[528]; size_t size; bool present; } blobs[2];
|
||||
esp_err_t nvs_flash_init(void) { return nvs_error; }
|
||||
esp_err_t nvs_open(const char *name,int mode,nvs_handle_t *handle) {
|
||||
*handle=!strcmp(name,MDNS_CONFIG_NVS_NAMESPACE);
|
||||
if(mode==NVS_READONLY && !blobs[*handle].present) return ESP_ERR_NVS_NOT_FOUND;
|
||||
return ESP_OK;
|
||||
}
|
||||
esp_err_t nvs_get_blob(nvs_handle_t handle,const char *key,void *out,size_t *size) {
|
||||
assert(!strcmp(key,"config"));
|
||||
if(!blobs[handle].present) return ESP_ERR_NVS_NOT_FOUND;
|
||||
if(!out) { *size=blobs[handle].size; return ESP_OK; }
|
||||
if(*size<blobs[handle].size) return ESP_ERR_NVS_INVALID_LENGTH;
|
||||
*size=blobs[handle].size; memcpy(out,blobs[handle].bytes,*size); return ESP_OK;
|
||||
}
|
||||
esp_err_t nvs_set_blob(nvs_handle_t handle,const char *key,const void *data,size_t size) {
|
||||
assert(!strcmp(key,"config") && size<=528 && (wifi_mutex || mdns_mutex));
|
||||
if(commit_error) return ESP_OK;
|
||||
memcpy(blobs[handle].bytes,data,size); blobs[handle].size=size; blobs[handle].present=true; return ESP_OK;
|
||||
}
|
||||
esp_err_t nvs_commit(nvs_handle_t handle) { (void)handle; ++commits; return commit_error; }
|
||||
void nvs_close(nvs_handle_t handle) { (void)handle; }
|
||||
static char announced_hostname[60];
|
||||
esp_err_t mdns_init(void) { return ESP_OK; }
|
||||
esp_err_t mdns_hostname_set(const char *hostname) {
|
||||
assert(!mdns_mutex); ++hostname_calls; strcpy(announced_hostname,hostname); return hostname_error;
|
||||
}
|
||||
esp_err_t mdns_instance_name_set(const char *name) { assert(name); return ESP_OK; }
|
||||
void mdns_free(void) {}
|
||||
#include "manager_production.h"
|
||||
|
||||
static uint32_t generation(void) { return s_shared.snapshot.config_generation; }
|
||||
static esp_err_t patch(wifi_manager_patch_t *p) { return wifi_manager_patch_current(generation(),p); }
|
||||
static void same_secret(const uint8_t *secret,const char *expected) { assert(!memcmp(secret,expected,strlen(expected))); }
|
||||
int main(void) {
|
||||
assert(wifi_config_defaults(&s_shared.config)==ESP_OK); s_shared.snapshot.config_generation=1;
|
||||
s_shared.snapshot.active_profile=-1; s_shared.snapshot.state=WIFI_MANAGER_STATE_STOPPED;
|
||||
wifi_manager_settings_t projection;
|
||||
snapshot_contention=true; memset(&projection,0xff,sizeof(projection));
|
||||
assert(wifi_manager_get_settings(&projection)==ESP_ERR_TIMEOUT);
|
||||
for(unsigned i=0;i<sizeof(projection);++i) assert(((uint8_t *)&projection)[i]==0);
|
||||
snapshot_contention=false;
|
||||
assert(wifi_manager_get_settings(&projection)==ESP_OK && projection.ap_password_configured);
|
||||
assert(!projection.profiles[0].password_configured && projection.runtime.active_profile==-1);
|
||||
wifi_manager_patch_t p={.profile=0,.fields=WIFI_PATCH_SSID,.ssid_len=32};
|
||||
for(unsigned i=0;i<32;++i) p.ssid[i]=(uint8_t)(i*8);
|
||||
assert(patch(&p)==ESP_OK && queued==0 && !s_shared.config.profiles[0].enabled);
|
||||
assert(wifi_manager_get_settings(&projection)==ESP_OK && projection.profiles[0].ssid_len==32);
|
||||
assert(!memcmp(projection.profiles[0].ssid,p.ssid,32));
|
||||
p.fields=WIFI_PATCH_PASSWORD; p.password_len=11; memcpy(p.password,"supersecret",11);
|
||||
assert(patch(&p)==ESP_OK && queued==0); same_secret(s_shared.config.profiles[0].psk,"supersecret");
|
||||
uint32_t stale=generation();
|
||||
p.password_len=12; memcpy(p.password,"replacement!",12); assert(patch(&p)==ESP_OK);
|
||||
wifi_app_config_t before=s_shared.config;
|
||||
p.fields=WIFI_PATCH_PRIORITY; p.priority=255;
|
||||
assert(wifi_manager_patch_current(stale,&p)==ESP_ERR_NOT_FOUND && !memcmp(&before,&s_shared.config,sizeof(before)));
|
||||
assert(patch(&p)==ESP_OK); same_secret(s_shared.config.profiles[0].psk,"replacement!");
|
||||
/* Omitted password in a fresh patch cannot restore the caller's old copy. */
|
||||
p.fields=WIFI_PATCH_ENABLED; p.enabled=1;
|
||||
queue_fail=true; before=s_shared.config; stale=generation();
|
||||
assert(patch(&p)==ESP_ERR_TIMEOUT && generation()==stale && !memcmp(&before,&s_shared.config,sizeof(before)));
|
||||
assert(s_queue_drops==1 && s_shared.snapshot.counters.queue_drops==0);
|
||||
wifi_manager_snapshot_t runtime;
|
||||
assert(wifi_manager_get_snapshot(&runtime)==ESP_OK);
|
||||
assert(wifi_manager_get_settings(&projection)==ESP_OK);
|
||||
assert(projection.runtime.counters.queue_drops==1 &&
|
||||
projection.runtime.counters.queue_drops==runtime.counters.queue_drops && !s_drop_mux);
|
||||
puts("PASS real manager: failed owner enqueue updates settings queue_drops consistently with runtime snapshot");
|
||||
queue_fail=false; assert(patch(&p)==ESP_OK && queued==1 && last_message==MESSAGE_COMMAND_APPLY);
|
||||
assert(s_shared.snapshot.state==WIFI_MANAGER_STATE_STOPPED && !s_shared.snapshot.started);
|
||||
p.fields=WIFI_PATCH_PASSWORD; p.password_len=0;
|
||||
before=s_shared.config; assert(patch(&p)==ESP_ERR_INVALID_ARG && !memcmp(&before,&s_shared.config,sizeof(before)));
|
||||
p.fields=WIFI_PATCH_PASSWORD|WIFI_PATCH_ENABLED; p.enabled=0;
|
||||
assert(patch(&p)==ESP_OK && s_shared.config.profiles[0].psk_len==0 && queued==2);
|
||||
for(unsigned i=0;i<63;++i) assert(s_shared.config.profiles[0].psk[i]==0);
|
||||
p.fields=WIFI_PATCH_ENABLED; p.enabled=1; assert(patch(&p)==ESP_ERR_INVALID_ARG);
|
||||
p.profile=-1; p.fields=WIFI_PATCH_PASSWORD; p.password_len=0;
|
||||
assert(patch(&p)==ESP_ERR_INVALID_ARG);
|
||||
p.fields=WIFI_PATCH_POLICY; p.ap_policy=WIFI_CONFIG_AP_POLICY_OFF; assert(patch(&p)==ESP_OK);
|
||||
p.fields=WIFI_PATCH_PASSWORD; assert(patch(&p)==ESP_ERR_INVALID_ARG);
|
||||
p.fields=WIFI_PATCH_CHANNEL; p.ap_channel=12; assert(patch(&p)==ESP_ERR_INVALID_ARG);
|
||||
p.profile=4; assert(patch(&p)==ESP_ERR_INVALID_ARG);
|
||||
p.profile=-1; p.fields=WIFI_PATCH_ENABLED; assert(patch(&p)==ESP_ERR_INVALID_ARG);
|
||||
p.fields=UINT32_MAX; assert(patch(&p)==ESP_ERR_INVALID_ARG);
|
||||
puts("PASS real manager: zero-wait secret-free projection, byte SSIDs, stale generation, omission preserves CURRENT PSK, disabled clear and canonical AP/enabled constraints");
|
||||
|
||||
unsigned staged_queue=queued;
|
||||
for(unsigned i=0;i<4;++i) {
|
||||
p=(wifi_manager_patch_t){.profile=(int8_t)i,.fields=WIFI_PATCH_SSID|WIFI_PATCH_PRIORITY|WIFI_PATCH_SECURITY,
|
||||
.ssid_len=1,.ssid={(uint8_t)('a'+i)},.priority=(uint8_t)(255-i),.security=WIFI_CONFIG_SECURITY_WPA3};
|
||||
assert(patch(&p)==ESP_OK && queued==staged_queue);
|
||||
}
|
||||
assert(wifi_manager_get_settings(&projection)==ESP_OK);
|
||||
for(unsigned i=0;i<4;++i) {
|
||||
assert(projection.profiles[i].priority==255-i && projection.profiles[i].security==WIFI_CONFIG_SECURITY_WPA3);
|
||||
assert(projection.profiles[i].ssid_len==1 && projection.profiles[i].ssid[0]=='a'+i);
|
||||
}
|
||||
p=(wifi_manager_patch_t){.profile=-1,.fields=WIFI_PATCH_PASSWORD,.password_len=12,.password="AP-replaced!"};
|
||||
assert(patch(&p)==ESP_OK); same_secret(s_shared.config.ap_psk,"AP-replaced!");
|
||||
stale=generation();
|
||||
p.fields=WIFI_PATCH_SSID; p.ssid_len=3; memcpy(p.ssid,"AP!",3); assert(patch(&p)==ESP_OK);
|
||||
same_secret(s_shared.config.ap_psk,"AP-replaced!");
|
||||
assert(wifi_manager_load_current(stale)==ESP_ERR_NOT_FOUND);
|
||||
puts("PASS real manager: four stable profiles, disabled security/priority staging, AP replacement and omission preserve");
|
||||
|
||||
/* Legacy/local apply and start/stop participate in the same generation. */
|
||||
before=s_shared.config; stale=generation();
|
||||
before.ap_channel=3; assert(wifi_manager_apply_working_config(&before)==ESP_OK && generation()!=stale);
|
||||
p.profile=-1; p.fields=WIFI_PATCH_BOOT; p.enabled_at_boot=0;
|
||||
assert(wifi_manager_patch_current(stale,&p)==ESP_ERR_NOT_FOUND);
|
||||
stale=generation(); assert(wifi_manager_stop()==ESP_OK && generation()!=stale && !s_shared.config.enabled_at_boot);
|
||||
assert(wifi_manager_save_current(stale)==ESP_ERR_NOT_FOUND);
|
||||
queue_fail=true; stale=generation(); assert(wifi_manager_start()==ESP_ERR_TIMEOUT && generation()==stale && !s_shared.config.enabled_at_boot); queue_fail=false;
|
||||
assert(wifi_manager_start()==ESP_OK && s_shared.config.enabled_at_boot);
|
||||
assert(wifi_manager_reconnect()==ESP_OK && last_message==MESSAGE_COMMAND_RECONNECT);
|
||||
assert(wifi_manager_next_profile()==ESP_OK && last_message==MESSAGE_COMMAND_NEXT_PROFILE);
|
||||
assert(wifi_manager_mdns_reannounce()==ESP_OK && last_message==MESSAGE_COMMAND_MDNS_REANNOUNCE);
|
||||
unsigned random_before=random_calls;
|
||||
before=s_shared.config; stale=generation();
|
||||
assert(wifi_manager_load_current(stale)==ESP_ERR_NVS_NOT_FOUND && generation()==stale && random_calls==random_before);
|
||||
assert(!memcmp(&before,&s_shared.config,sizeof(before)));
|
||||
assert(wifi_manager_save_current(generation())==ESP_OK && blobs[0].present);
|
||||
p.fields=WIFI_PATCH_CHANNEL; p.ap_channel=9; assert(patch(&p)==ESP_OK);
|
||||
stale=generation(); queue_fail=true;
|
||||
assert(wifi_manager_load_current(stale)==ESP_ERR_TIMEOUT && generation()==stale && s_shared.config.ap_channel==9); queue_fail=false;
|
||||
assert(wifi_manager_load_current(stale)==ESP_OK && s_shared.config.ap_channel==3 && random_calls==random_before);
|
||||
nvs_error=ESP_FAIL; assert(wifi_manager_save_current(generation())==ESP_FAIL);
|
||||
before=s_shared.config; assert(wifi_manager_load_current(generation())==ESP_FAIL && !memcmp(&before,&s_shared.config,sizeof(before))); nvs_error=ESP_OK;
|
||||
commit_error=ESP_FAIL; assert(wifi_manager_save_current(generation())==ESP_FAIL); commit_error=ESP_OK;
|
||||
blobs[0].bytes[0]=0; assert(wifi_manager_load_current(generation())==ESP_ERR_INVALID_ARG);
|
||||
blobs[0].size=527; assert(wifi_manager_load_current(generation())==ESP_ERR_INVALID_SIZE && random_calls==random_before);
|
||||
assert(wiped_candidates>10);
|
||||
s_shared.snapshot.config_generation=UINT32_MAX;
|
||||
assert(patch(&p)==ESP_ERR_INVALID_STATE);
|
||||
assert(wifi_manager_stop()==ESP_ERR_INVALID_STATE && s_shared.config.enabled_at_boot);
|
||||
assert(wifi_manager_apply_working_config(&before)==ESP_ERR_INVALID_STATE);
|
||||
puts("PASS real manager: legacy/local generations, atomic queue failures, asynchronous lifecycle, conditional NVS failures/load without RNG, candidate wipe and no generation wrap");
|
||||
|
||||
mdns_config_t config; mdns_config_defaults(&config); assert(mdns_service_init(&config)==ESP_OK);
|
||||
mdns_service_snapshot_t m;
|
||||
snapshot_contention=true; assert(mdns_service_get_settings(&m)==ESP_ERR_TIMEOUT); snapshot_contention=false;
|
||||
assert(mdns_service_get_settings(&m)==ESP_OK && m.config_generation==1);
|
||||
bool stored;
|
||||
memset(config.suffix,0,sizeof(config.suffix)); strcpy(config.suffix,"first"); config.suffix_len=5;
|
||||
assert(mdns_service_update_current(1,MDNS_SETTINGS_SET,&config,&stored)==ESP_OK);
|
||||
assert(mdns_service_update_current(1,MDNS_SETTINGS_SAVE,NULL,&stored)==ESP_ERR_NOT_FOUND);
|
||||
assert(mdns_service_start()==ESP_OK && !strcmp(announced_hostname,"sak-first"));
|
||||
mdns_service_stop();
|
||||
assert(mdns_service_get_settings(&m)==ESP_OK && !m.announced);
|
||||
memset(config.suffix,0,sizeof(config.suffix)); strcpy(config.suffix,"offline"); config.suffix_len=7;
|
||||
assert(mdns_service_update_current(m.config_generation,MDNS_SETTINGS_SET,&config,&stored)==ESP_OK);
|
||||
unsigned calls=hostname_calls;
|
||||
assert(mdns_service_start()==ESP_OK && hostname_calls==calls+1 && !strcmp(announced_hostname,"sak-offline"));
|
||||
assert(mdns_service_get_settings(&m)==ESP_OK);
|
||||
assert(mdns_service_update_current(m.config_generation,MDNS_SETTINGS_SAVE,NULL,&stored)==ESP_OK);
|
||||
strcpy(config.suffix,"another"); assert(mdns_service_set_config(&config)==ESP_OK);
|
||||
assert(mdns_service_update_current(m.config_generation,MDNS_SETTINGS_LOAD,NULL,&stored)==ESP_ERR_NOT_FOUND);
|
||||
assert(mdns_service_get_settings(&m)==ESP_OK);
|
||||
assert(mdns_service_update_current(m.config_generation,MDNS_SETTINGS_LOAD,NULL,&stored)==ESP_OK && stored);
|
||||
assert(mdns_service_get_settings(&m)==ESP_OK && !strcmp(m.suffix,"offline"));
|
||||
assert(mdns_service_reannounce()==ESP_OK && !strcmp(announced_hostname,"sak-offline"));
|
||||
blobs[1].present=false;
|
||||
assert(mdns_service_update_current(m.config_generation,MDNS_SETTINGS_LOAD,NULL,&stored)==ESP_OK && !stored);
|
||||
assert(mdns_service_get_settings(&m)==ESP_OK && !strcmp(m.suffix,"121212121212"));
|
||||
assert(mdns_service_update_current(m.config_generation,MDNS_SETTINGS_DEFAULTS,NULL,&stored)==ESP_OK);
|
||||
assert(mdns_service_get_settings(&m)==ESP_OK);
|
||||
commit_error=ESP_FAIL; assert(mdns_service_update_current(m.config_generation,MDNS_SETTINGS_SAVE,NULL,&stored)==ESP_FAIL); commit_error=ESP_OK;
|
||||
hostname_error=ESP_FAIL; assert(mdns_service_reannounce()==ESP_FAIL);
|
||||
assert(mdns_service_get_settings(&m)==ESP_OK && m.last_error==ESP_FAIL);
|
||||
puts("PASS real mDNS service: zero wait, conditional set/save/load/default, legacy races, NVS errors and offline suffix reannouncement regression");
|
||||
}
|
||||
Reference in New Issue
Block a user