Add admin serial settings view

This commit is contained in:
2026-09-07 20:12:33 +02:00
parent c73674cda2
commit 5a2aa0d4d8
20 changed files with 612 additions and 25 deletions
+33 -10
View File
@@ -37,8 +37,8 @@ def define(path, name):
uri_tables = re.findall(r'^static const httpd_uri_t(?: \*const)? \w+\[?\]? = \{.*?^\};',
source, re.M | re.S)
# Non-array declarations have no brackets; explicit shape avoids silent omission.
if len(uri_tables) != 13:
raise RuntimeError('Review URI extraction: expected 11 descriptors and two tables')
if len(uri_tables) != 14:
raise RuntimeError('Review URI extraction: expected 12 descriptors and two tables')
state = source[source.index('static SemaphoreHandle_t s_server_mutex;'):
source.index('static esp_err_t ensure_mutex(void)')]
header = (ROOT / 'src/web_server.h').read_text()
@@ -93,6 +93,8 @@ static unsigned ssl_starts, ssl_stops, serial_attaches, serial_detaches;
static unsigned admin_attaches, admin_detaches, admin_stoppeds;
static unsigned registration_calls, registration_fail_at, registered_count, unregister_calls;
static bool unregister_fail;
static bool settings_fail;
static unsigned settings_calls;
static const httpd_uri_t *registered[32];
static char events[128]; static size_t event_length;
static void event(char value) { assert(!locked && event_length + 1 < sizeof(events)); events[event_length++] = value; events[event_length] = 0; }
@@ -104,6 +106,7 @@ static void secure_wipe(void *p, size_t n) { assert(!locked); memset(p, 0, n); }
HANDLER(root_handler) HANDLER(status_handler) HANDLER(ticket_handler)
HANDLER(websocket_handler) HANDLER(asset_handler) HANDLER(web_cookie_auth_handler)
HANDLER(web_admin_transport_ticket_handler) HANDLER(web_admin_transport_upgrade_handler)
HANDLER(serial_settings_handler)
static esp_err_t route_error_handler(httpd_req_t *r, httpd_err_code_t c) { (void)r; (void)c; assert(0); return ESP_FAIL; }
static esp_err_t web_serial_transport_init(void) { assert(!locked); ++serial_inits; return serial_init_error; }
static esp_err_t web_cookie_auth_start(void) { assert(!locked); ++auth_starts; auth_live = auth_error == ESP_OK; return auth_error; }
@@ -115,7 +118,7 @@ static esp_err_t web_security_copy_tls_material(uint8_t *cert, size_t nc, size_t
static esp_err_t httpd_ssl_start(httpd_handle_t *server, const httpd_ssl_config_t *config) {
assert(!locked && auth_live && !ssl_live); ++ssl_starts;
assert(config->httpd.max_open_sockets == 6 && !config->httpd.lru_purge_enable);
assert(config->httpd.max_uri_handlers == 16 && config->port_secure == 443);
assert(config->httpd.max_uri_handlers == 17 && config->port_secure == 443);
assert(config->httpd.recv_wait_timeout == 1 && config->httpd.send_wait_timeout == 1);
assert(config->tls_handshake_timeout_ms == 5000);
assert(config->servercert_len == 1 && config->servercert[0] == 1);
@@ -128,6 +131,14 @@ static esp_err_t register_one(httpd_handle_t server) {
return registration_calls == registration_fail_at ? ESP_FAIL : ESP_OK;
}
static esp_err_t httpd_register_uri_handler(httpd_handle_t s, const httpd_uri_t *uri) {
if (!strcmp(uri->uri, "/api/settings/serial")) {
assert(s == SERVER && auth_live && ssl_live && registration_calls >= 17);
assert(uri->method == HTTP_GET && uri->handler == serial_settings_handler);
++settings_calls;
if (settings_fail) return ESP_ERR_NO_MEM;
registered[registered_count++] = uri;
return ESP_OK;
}
if (!strcmp(uri->uri, "/api/admin/ws-ticket") || !strcmp(uri->uri, "/ws/admin")) {
assert(registration_calls >= 16);
assert(serial_init_error != ESP_OK || serial_live);
@@ -136,6 +147,10 @@ static esp_err_t httpd_register_uri_handler(httpd_handle_t s, const httpd_uri_t
if (error == ESP_OK) { assert(registered_count < 32); registered[registered_count++] = uri; }
return error;
}
static esp_err_t web_httpd_register_optional_get(httpd_handle_t s, const httpd_uri_t *uri) {
assert(!strcmp(uri->uri, "/api/settings/serial"));
return httpd_register_uri_handler(s, uri);
}
static esp_err_t httpd_unregister_uri_handler(httpd_handle_t s, const char *uri, int method) {
assert(!locked && s == SERVER && ssl_live && auth_live && serial_live);
assert(registration_calls == 18 && !strcmp(uri, "/api/admin/ws-ticket") && method == HTTP_POST);
@@ -201,7 +216,7 @@ static void reset(void) {
serial_inits = admin_inits = auth_starts = auth_stops = ssl_starts = ssl_stops = 0;
serial_attaches = serial_detaches = admin_attaches = admin_detaches = admin_stoppeds = 0;
registration_calls = registration_fail_at = registered_count = unregister_calls = 0;
unregister_fail = false; clear_events();
unregister_fail = settings_fail = false; settings_calls = 0; clear_events();
}
static void fresh_registration(void) { registration_calls = registered_count = 0; }
static void start(void) {
@@ -235,7 +250,8 @@ int main(void) {
}
puts("PASS optional admin init/attach failures do not disable M1 auth or serial attachment");
reset(); start(); assert(registered_count == 16 && registration_calls == 18);
reset(); start(); assert(registered_count == 17 && registration_calls == 18 && settings_calls == 1);
assert(route("/api/settings/serial")->handler == serial_settings_handler);
const httpd_uri_t *ticket = route("/api/admin/ws-ticket"), *ws = route("/ws/admin");
assert(ticket->method == HTTP_POST && ticket->handler == web_admin_transport_ticket_handler && !ticket->is_websocket);
assert(ws->method == HTTP_GET && ws->handler == web_admin_transport_upgrade_handler && !ws->is_websocket);
@@ -286,7 +302,7 @@ int main(void) {
assert(s_serial_transport_attached && !s_admin_transport_owned && !admin_owned);
assert(!admin_inits && !admin_attaches && !auth_stops && !ssl_stops);
assert(!s_transitioning && s_last_error == ESP_OK && s_counters.starts == 1 && !s_counters.start_failures);
assert(registered_count == 14 && unregister_calls == failure - 17);
assert(registered_count == 15 && unregister_calls == failure - 17);
for (unsigned i = 0; i < registered_count; ++i)
assert(strcmp(registered[i]->uri, "/api/admin/ws-ticket") && strcmp(registered[i]->uri, "/ws/admin"));
assert(route("/ws/serial")->handler == websocket_handler);
@@ -295,13 +311,13 @@ int main(void) {
clear_events(); assert(web_server_stop() == ESP_OK && !strcmp(events, "ASH"));
assert(!admin_detaches && !admin_stoppeds);
registration_fail_at = 0; fresh_registration(); start();
assert(registered_count == 16 && admin_attaches == 1 && s_counters.starts == 2);
assert(registered_count == 17 && admin_attaches == 1 && s_counters.starts == 2);
assert(web_server_stop() == ESP_OK && admin_stoppeds == 1);
}
puts("PASS optional positions 17..18 preserve M1, roll back ticket when needed and recover after stop/restart");
reset(); registration_fail_at = 18; unregister_fail = true;
assert(web_server_start() == ESP_OK && unregister_calls == 1 && registered_count == 15);
assert(web_server_start() == ESP_OK && unregister_calls == 1 && registered_count == 16);
assert(auth_live && ssl_live && serial_live && s_serial_transport_attached);
assert(!admin_inits && !admin_attaches && !admin_owned && !s_admin_transport_owned);
ticket = route("/api/admin/ws-ticket");
@@ -313,7 +329,7 @@ int main(void) {
clear_events(); assert(web_server_stop() == ESP_OK && !strcmp(events, "ASH"));
assert(!admin_detaches && !admin_stoppeds);
unregister_fail = false; registration_fail_at = 0; fresh_registration(); start();
assert(registered_count == 16 && admin_attaches == 1 && web_server_stop() == ESP_OK);
assert(registered_count == 17 && admin_attaches == 1 && web_server_stop() == ESP_OK);
puts("PASS failed unregister retains only original ticket handler, no admin attachment, and permits restart");
reset(); registration_fail_at = 6; ssl_stop_error = ESP_FAIL;
@@ -334,7 +350,14 @@ int main(void) {
assert(web_server_start() == ESP_ERR_INVALID_STATE && !auth_starts);
assert(web_server_stop() == ESP_ERR_INVALID_STATE && !auth_stops);
puts("PASS auth/start failure gates and invalid/transitioning lifecycle rejection");
puts("11 lifecycle groups passed (16 required fatal positions, 2 optional positions, plus failed unregister)");
reset(); settings_fail = true; start();
assert(settings_calls == 1 && registered_count == 16);
assert(auth_live && serial_live && admin_owned && web_server_stop() == ESP_OK);
settings_fail = false; fresh_registration(); start();
assert(route("/api/settings/serial")->handler == serial_settings_handler);
assert(web_server_stop() == ESP_OK);
puts("PASS optional Settings registration failure preserves auth and both transports; restart recovers");
puts("12 lifecycle groups passed (16 required fatal positions, 3 optional routes, plus failed unregister)");
return 0;
}
'''
+21
View File
@@ -15,3 +15,24 @@ Coverage includes challenge reuse/consumption/expiry, capacities without evictio
This is **not** the full IDF parser/dispatcher, real handshake/TLS/socket, browser, multicore task or hardware test. The private struct doubles do not prove binary layout; firmware compilation uses the actual pinned headers, and the version guard requires a new audit on SDK changes. Handshake sending and transport revocation are doubled. Actual on-wire pre-101 rejection, frame routing, pipelining/early bytes, cookie/CSP/browser recovery and loaded expiry latency remain M1 target gates. No sanitizer or runtime memory-reserve result is implied.
See `docs/phase8d3_implementation.md` for source verification, other suite commands, build accounting and the target checklist.
## Read-only Serial Settings
```sh
python3 tests/web_cookie_auth/run.py --settings
```
Runs the existing auth/store suite plus five 8D.8 groups. Compiles exact extracted
production server handler/helpers, serial snapshot getter and enum formatters,
with the real cookie/store/parser/private adapter. Serial locking/state and HTTP
IO are doubled; authorization is not. Exercises normal-role/stale/expired/revoked
denial, DB failure, body/query/method/header/framing/Origin rejection before any
serial read, working values, zero-wait busy/uninitialized failure, no-store and
header/send errors. Adapter-only allocator substitution injects both staged
registration failures; the installed IDF unregister function frees successful
registration. No SDK files are modified. Lifecycle registration/optional failure
orchestration is separately tested by `tests/web_admin_transport/server_lifecycle.py`.
This does not run the full serial task/UART driver, TLS/network dispatcher or a real
browser. Target comparison with UART0 and runtime memory/stack validation remain
pending in `docs/phase8d8_implementation.md`; prior M2 signoff remains accepted.
+28 -1
View File
@@ -37,11 +37,13 @@ struct sock_db { bool ws_handshake_done; esp_err_t (*ws_handler)(httpd_req_t *);
struct httpd_req_aux { struct sock_db *sd; char *scratch; size_t scratch_cur_size, remaining_len;
unsigned req_hdrs_count, resp_hdrs_count; bool ws_handshake_detect;
struct resp_hdr { const char *field, *value; } *resp_hdrs; };
struct httpd_data { struct { unsigned max_resp_headers; } config; };
struct httpd_data { struct { unsigned max_resp_headers, max_uri_handlers; void *uri_match_fn; } config;
httpd_uri_t **hd_calls; };
esp_err_t httpd_ws_respond_server_handshake(httpd_req_t *, const char *);
"""
admin = "--admin" in sys.argv
settings = "--settings" in sys.argv
if admin:
HEADERS["esp_system.h"] = "#pragma once\nvoid esp_restart(void);\n"
HEADERS["esp_heap_caps.h"] = """#pragma once
@@ -73,8 +75,10 @@ extracted = """
#pragma GCC diagnostic ignored "-Wsign-compare"
#include <string.h>
#include <strings.h>
#include <stdlib.h>
#include "esp_httpd_priv.h"
#define ESP_LOGD(...) ((void)0)
#define ESP_LOGW(...) ((void)0)
#define MIN(a,b) ((a) < (b) ? (a) : (b))
static bool httpd_valid_req(httpd_req_t *r) { return r && r->aux; }
static size_t strlcpy(char *d, const char *s, size_t n) {
@@ -88,6 +92,9 @@ for name in ["httpd_req_get_hdr_value_len", "httpd_req_get_hdr_value_str"]:
extracted += function(txrx[txrx.index("esp_err_t httpd_resp_set_hdr"):], "httpd_resp_set_hdr")
extracted += "\n" + function(txrx[txrx.index("static size_t httpd_recv_pending"):], "httpd_recv_pending")
extracted += "\nsize_t host_read_pending(httpd_req_t *r, char *out, size_t n) { return httpd_recv_pending(r, out, n); }\n"
if settings:
uri_source = (idf / 'components/esp_http_server/src/httpd_uri.c').read_text()
extracted += '\n' + function(uri_source[uri_source.index('esp_err_t httpd_unregister_uri_handler'):], 'httpd_unregister_uri_handler')
with tempfile.TemporaryDirectory(prefix="web-cookie-auth-") as directory:
tmp = pathlib.Path(directory)
@@ -96,12 +103,32 @@ with tempfile.TemporaryDirectory(prefix="web-cookie-auth-") as directory:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(text)
(tmp / "installed_httpd.c").write_text(extracted)
if settings:
# Compile exact production handler/helpers and nonblocking snapshot body.
server_source = (ROOT / 'src/web_server.c').read_text()
service_source = (ROOT / 'src/serial_service.c').read_text()
config_source = (ROOT / 'src/serial_config.c').read_text()
settings_source = function(service_source, 'serial_service_get_snapshot') + '\n'
for name in ('data_bits', 'parity', 'stop_bits', 'flow_control', 'dtr_behavior'):
settings_source += function(config_source[config_source.index('const char *serial_config_' + name + '_to_string'):], 'serial_config_' + name + '_to_string') + '\n'
for name in ('set_common_headers', 'send_plain_error', 'authorize_or_respond', 'safe_string', 'serial_settings_handler'):
settings_source += function(server_source, name) + '\n'
(tmp / 'settings_production.h').write_text(settings_source)
sources = [HERE / "test.c", tmp / "installed_httpd.c"]
sources += [ROOT / "src" / name for name in ["web_session_store.c", "web_auth_parse.c", "web_cookie_auth.c", "web_httpd_adapter.c"]]
if admin:
sources += [ROOT / "src" / name for name in ["web_admin_tickets.c", "web_admin_transport.c"]]
if settings:
adapter = ROOT / 'src/web_httpd_adapter.c'
sources.remove(adapter)
subprocess.run(['cc', '-std=c11', '-D_GNU_SOURCE', '-Wall', '-Wextra', '-Werror',
'-Dmalloc=settings_malloc', '-Dfree=settings_free',
'-I' + str(tmp), '-I' + str(ROOT / 'src'), '-c', str(adapter),
'-o', str(tmp / 'adapter.o')], check=True, timeout=30)
sources.append(tmp / 'adapter.o')
subprocess.run(["cc", "-std=c11", "-Wall", "-Wextra", "-Werror", "-g", "-DHOST_OPENSSL",
*(["-DHOST_ADMIN"] if admin else []),
*(["-DHOST_SETTINGS"] if settings else []),
"-I" + str(tmp), "-I" + str(ROOT / "src"), *map(str, sources), "-lcrypto",
"-o", str(tmp / "test")], check=True, timeout=30)
subprocess.run([str(tmp / "test")], check=True, timeout=20)
+154
View File
@@ -0,0 +1,154 @@
/* Exact production snapshot/HTTP bodies, real auth/store/adapter, fake UART mutex. */
#include <inttypes.h>
#include <stdatomic.h>
#include <sys/types.h>
#include <stdlib.h>
#include "serial_service.h"
#include "web_server.h"
#define pdTRUE 1
#define ESP_ERR_TIMEOUT 0x107
static bool s_initialized, serial_busy, serial_locked;
static atomic_bool s_running;
static serial_config_t s_config;
static int s_state_mutex;
static unsigned serial_takes;
static web_server_counters_t s_counters;
static unsigned allocations, allocation_fail_at, frees;
static httpd_uri_t *routes[2];
void *settings_malloc(size_t size) {
assert(routes[1] == NULL); /* Neither allocation may publish partial state. */
if (++allocations == allocation_fail_at) return NULL;
void *value = malloc(size); assert(value); memset(value, 0xa5, size); return value;
}
void settings_free(void *value) { ++frees; free(value); }
esp_err_t httpd_unregister_uri_handler(httpd_handle_t, const char *, httpd_method_t);
static int xSemaphoreTake(int mutex, unsigned wait) {
(void)mutex; assert(wait == 0 && !serial_locked && !host_lock_depth);
++serial_takes;
if (serial_busy) return 0;
serial_locked = true; return pdTRUE;
}
static void xSemaphoreGive(int mutex) { (void)mutex; assert(serial_locked); serial_locked = false; }
static void increment_counter(uint64_t *counter) { ++*counter; }
esp_err_t httpd_resp_send(httpd_req_t *r, const char *body, int length) {
assert(!serial_locked && length >= 0 && length < 256 && (size_t)length == strlen(body));
return httpd_resp_sendstr(r, body);
}
#include "settings_production.h"
static void settings_begin(const issued_t *identity) {
begin("/api/settings/serial", HTTP_GET, NULL);
add("Host", "device.example"); /* GET may omit Origin, but Host is validated. */
if (identity) {
char cookies[100];
snprintf(cookies, sizeof(cookies), "__Host-sak-session=%s", identity->token);
add("Cookie", cookies);
}
}
static void settings_expect(const char *status) {
unsigned before = serial_takes;
(void)serial_settings_handler(&req);
if (strcmp(response_status, status) || serial_takes != before)
fprintf(stderr, "Settings expected %s, got %s; serial reads %u\n", status, response_status, serial_takes - before);
assert(!strcmp(response_status, status) && serial_takes == before);
assert(strlen(output) < 128 && !body_offset);
zero(scratch, sizeof(scratch));
}
static void settings_tests(void) {
httpd_uri_t existing = {.uri = "/", .method = HTTP_GET, .handler = serial_settings_handler};
httpd_uri_t route = {.uri = "/api/settings/serial", .method = HTTP_GET, .handler = serial_settings_handler};
routes[0] = &existing; server.hd_calls = routes; server.config.max_uri_handlers = 2;
for (unsigned failure = 1; failure <= 2; ++failure) {
allocations = frees = 0; allocation_fail_at = failure;
assert(web_httpd_register_optional_get(&server, &route) == ESP_ERR_NO_MEM);
assert(allocations == failure && frees == failure - 1 && routes[0] == &existing && !routes[1]);
}
allocation_fail_at = 0; allocations = 0;
assert(web_httpd_register_optional_get(&server, &route) == ESP_OK && allocations == 2);
assert(routes[1] && routes[1] != &route && routes[1]->uri != route.uri);
assert(!strcmp(routes[1]->uri, route.uri) && routes[1]->handler == route.handler);
assert(web_httpd_register_optional_get(&server, &route) == ESP_ERR_INVALID_STATE);
httpd_uri_t other = route; other.uri = "/other";
assert(web_httpd_register_optional_get(&server, &other) == ESP_ERR_NO_MEM);
assert(httpd_unregister_uri_handler(&server, route.uri, HTTP_GET) == ESP_OK && !routes[1]);
assert(routes[0] == &existing); /* Actual installed IDF frees staged allocations. */
for (unsigned mode = 0; mode < 5; ++mode) {
other = route; char oversized[129]; memset(oversized, 'x', 128); oversized[128] = 0;
if (mode == 0) other.method = HTTP_POST;
if (mode == 1) other.is_websocket = true;
if (mode == 2) other.supported_subprotocol = "test";
if (mode == 3) other.uri = oversized;
if (mode == 4) server.config.uri_match_fn = &existing;
assert(web_httpd_register_optional_get(&server, &other) == ESP_ERR_INVALID_ARG && !routes[1]);
server.config.uri_match_fn = NULL;
}
assert(web_httpd_register_optional_get(NULL, &route) == ESP_ERR_INVALID_ARG);
assert(web_httpd_register_optional_get(&server, NULL) == ESP_ERR_INVALID_ARG);
puts("PASS Settings registration: both allocation failures leave table intact, duplicate/full/shape bounds, installed IDF unregister frees successful ownership");
auth_reset(); issued_t admin = mint(&alice), user = mint(&bob);
s_initialized = true; s_running = true;
s_config = (serial_config_t){.version = SERIAL_CONFIG_VERSION, .baud_rate = 230400,
.data_bits = SERIAL_CONFIG_DATA_BITS_8, .parity = SERIAL_CONFIG_PARITY_NONE,
.stop_bits = SERIAL_CONFIG_STOP_BITS_1, .flow_control = SERIAL_CONFIG_FLOW_CONTROL_RTS_CTS,
.dtr_behavior = SERIAL_CONFIG_DTR_ON_CONNECT, .rts_threshold = 96};
serial_service_snapshot_t value;
assert(serial_service_get_snapshot(NULL) == ESP_ERR_INVALID_ARG);
s_initialized = false; memset(&value, 0xa5, sizeof(value));
assert(serial_service_get_snapshot(&value) == ESP_ERR_INVALID_STATE); zero(&value, sizeof(value));
s_initialized = true; serial_busy = true;
assert(serial_service_get_snapshot(&value) == ESP_ERR_TIMEOUT); zero(&value, sizeof(value));
serial_busy = false;
assert(serial_service_get_snapshot(&value) == ESP_OK && value.running);
assert(!memcmp(&value.config, &s_config, sizeof(s_config)) && !serial_locked);
puts("PASS Settings snapshot: exact nonblocking production body, failure clearing and lock-consistent copy");
settings_begin(NULL); settings_expect("401 Unauthorized");
settings_begin(&user); settings_expect("403 Forbidden");
for (unsigned mode = 0; mode < 7; ++mode) {
settings_begin(&admin);
if (mode == 0) req.uri = "/api/settings/serial?unknown=1";
if (mode == 1) req.content_len = aux.remaining_len = 1000000;
if (mode == 2) req.method = HTTP_POST;
if (mode == 3) add("Host", "device.example");
if (mode == 4) add("Transfer-Encoding", "chunked");
if (mode == 5) add("Origin", "https://elsewhere.example");
if (mode == 6) add("Origin", "null");
settings_expect(mode < 5 ? "400 Bad Request" : "403 Forbidden");
}
stale_user = alice.user_id; settings_begin(&admin); settings_expect("401 Unauthorized"); stale_user = 0;
admin = mint(&alice);
db_fail = true; settings_begin(&admin); settings_expect("503 Service Unavailable"); db_fail = false;
admin = mint(&alice);
puts("PASS Settings HTTP: unauthenticated/user/stale/DB failure and query/body/method/duplicate/framing/Origin denied before serial read");
settings_begin(&admin); assert(serial_settings_handler(&req) == ESP_OK);
assert(!strcmp(output, "{\"running\":true,\"baud\":230400,\"data_bits\":\"8\",\"parity\":\"none\",\"stop_bits\":\"1\",\"flow\":\"rts-cts\",\"dtr\":\"on-connect\",\"rts_threshold\":96}"));
assert(aux.resp_hdrs_count == 3 && !strcmp(response_headers[0].value, "no-store"));
zero(scratch, sizeof(scratch));
s_running = false; s_config.baud_rate = 1000000; s_config.data_bits = SERIAL_CONFIG_DATA_BITS_7;
s_config.parity = SERIAL_CONFIG_PARITY_ODD; s_config.stop_bits = SERIAL_CONFIG_STOP_BITS_2;
s_config.flow_control = SERIAL_CONFIG_FLOW_CONTROL_NONE;
s_config.dtr_behavior = SERIAL_CONFIG_DTR_ACTIVE; s_config.rts_threshold = 127;
settings_begin(&admin); assert(serial_settings_handler(&req) == ESP_OK);
assert(!strcmp(output, "{\"running\":false,\"baud\":1000000,\"data_bits\":\"7\",\"parity\":\"odd\",\"stop_bits\":\"2\",\"flow\":\"none\",\"dtr\":\"active\",\"rts_threshold\":127}"));
for (unsigned mode = 0; mode < 2; ++mode) {
settings_begin(&admin); serial_busy = mode == 0; s_initialized = mode == 0;
assert(serial_settings_handler(&req) == ESP_OK && !strcmp(response_status, "503 Service Unavailable"));
assert(!strcmp(response_headers[0].field, "Retry-After") && !strcmp(response_headers[0].value, "1"));
}
serial_busy = false; s_initialized = true;
settings_begin(&admin); send_fail = true; unsigned before = sends;
assert(serial_settings_handler(&req) == ESP_FAIL && sends == before + 1); send_fail = false;
for (unsigned limit = 0; limit < 3; ++limit) {
settings_begin(&admin); server.config.max_resp_headers = limit; before = sends;
assert(serial_settings_handler(&req) != ESP_OK && sends == before);
}
server.config.max_resp_headers = 8;
puts("PASS Settings HTTP: exact typed working values, no-store, bounded busy/unavailable, header/send failure without second response");
web_session_store_invalidate(admin.view.id);
settings_begin(&admin); settings_expect("401 Unauthorized");
settings_begin(&user); settings_expect("403 Forbidden");
admin = mint(&alice); now += WEB_SESSION_STORE_LIFETIME_US;
settings_begin(&admin); settings_expect("401 Unauthorized");
puts("PASS Settings HTTP: originating-session invalidation, unrelated user isolation and absolute expiry");
}
+6
View File
@@ -119,6 +119,9 @@ static void auth_reset(void) {
#ifdef HOST_ADMIN
#include "admin_test.c"
#endif
#ifdef HOST_SETTINGS
#include "settings_test.c"
#endif
int main(void) {
assert(store_tests() == 0); auth_reset();
@@ -266,6 +269,9 @@ int main(void) {
puts("PASS: exact six-header successful login budget; all smaller header capacities invalidate unpublished login");
#ifdef HOST_ADMIN
admin_tests();
#endif
#ifdef HOST_SETTINGS
settings_tests();
#endif
return 0;
}
@@ -23,8 +23,12 @@ TaskHandle_t xTaskCreateStatic(void (*)(void *), const char *, uint32_t, void *,
#include <stddef.h>
#include "esp_err.h"
typedef void *httpd_handle_t;
typedef int httpd_method_t;
typedef struct { httpd_handle_t handle; void *sess_ctx; void (*free_ctx)(void *);
int method; size_t content_len; const char *uri; void *aux; } httpd_req_t;
typedef struct { const char *uri; int method; esp_err_t (*handler)(httpd_req_t *);
void *user_ctx; bool is_websocket, handle_ws_control_frames;
const char *supported_subprotocol; } httpd_uri_t;
typedef enum { HTTPD_WS_TYPE_CONTINUE, HTTPD_WS_TYPE_TEXT, HTTPD_WS_TYPE_BINARY } httpd_ws_type_t;
typedef enum { HTTPD_WS_CLIENT_HTTP, HTTPD_WS_CLIENT_WEBSOCKET } httpd_ws_client_info_t;
typedef struct { bool final, fragmented; httpd_ws_type_t type; unsigned char *payload;
+8 -4
View File
@@ -44,11 +44,15 @@ Coverage:
logout; same-session restore retains both scrollbacks behind validation gating.
- Undefined initial dimensions recover at unchanged bounds; failed fits never
populate the cache, readiness retries stop at three, and teardown fences stale
callbacks even after restore. Sixteen Node groups total.
callbacks even after restore.
- Read-only admin Settings/Serial: 10 selection cycles preserve both sockets/IDs,
drain hidden output and block terminal input; exact eight-field/256-byte schema,
explicit refresh, error/timeout containment, late cancellation/restore/identity,
and concurrent serial reconnect without superseding admission. **21 Node groups total.**
## Integration and known gaps
This covers the Phase 8D.3 browser session behavior and 8D.6 selector. The renderer
This covers 8D.3 session behavior, the 8D.6 selector and 8D.8 Settings. The renderer
still relies on its caller to authenticate resources; protected asset failures
must be 401, never a redirect to HTML served as JavaScript. No Basic fallback is
implemented here. Existing 8D.5 server authorization/protocols are unchanged.
@@ -57,7 +61,7 @@ These tests model DOM, timers, fetch cancellation and WebSocket events. They do
not prove real-browser CSP enforcement, script-loading errors, TLS/HTTPD behavior,
actual bfcache policy, cookie expiry, server revocation, or hardware serial byte
integrity, actual xterm escape parsing, hidden prompts, or desktop/mobile layout.
The 8D.6 firmware build and pending target checklist are recorded separately in
`docs/phase8d6_implementation.md`. No target resource reserve is claimed. Browser secret
Prior 8D.6 signoff stands; the new Settings build and pending target checklist are in
`docs/phase8d8_implementation.md`. No target resource reserve is claimed. Browser secret
references are dropped and never persisted/logged, but JavaScript cannot securely
wipe engine-managed strings.
+93 -1
View File
@@ -6,12 +6,14 @@ const token = 'a'.repeat(64);
const json = value => new Response(JSON.stringify(value));
const session = (extra = {}) => json({username: '<img>', role: 'user', csrf: token, expires_in: 3600, ...extra});
const ticket = () => json({ticket: 't'.repeat(32)});
const serialSettings = (extra = {}) => ({running: true, baud: 230400, data_bits: '8', parity: 'none',
stop_bits: '1', flow: 'rts-cts', dtr: 'on-connect', rts_threshold: 96, ...extra});
const failure = status => new Response('SECRET ERROR BODY', {status, headers: {'Retry-After': '7'}});
const deferred = () => { let resolve; const promise = new Promise(r => { resolve = r; }); return {promise, resolve}; };
const tick = async () => { for (let i = 0; i < 6; ++i) await new Promise(r => setImmediate(r)); };
function browser({onlyLoader = false, withLoader = false, role = 'user'} = {}) {
const nodes = {}, events = {}, calls = [], redirects = [], timers = new Map(), sockets = [], terminals = [];
const queues = {'/api/session': [], '/api/status': [], '/api/ws-ticket': [], '/api/admin/ws-ticket': [], '/api/logout': []};
const queues = {'/api/session': [], '/api/status': [], '/api/ws-ticket': [], '/api/admin/ws-ticket': [], '/api/logout': [], '/api/settings/serial': []};
const fits = [];
let serial = 0;
const on = (key, fn) => { if (!(events[key] ||= []).includes(fn)) events[key].push(fn); };
@@ -55,6 +57,7 @@ function browser({onlyLoader = false, withLoader = false, role = 'user'} = {}) {
if (next !== undefined) return typeof next === 'function' ? next(options) : next;
if (url === '/api/session') return session({role});
if (url === '/api/status') return json({});
if (url === '/api/settings/serial') return json(serialSettings());
if (url === '/api/ws-ticket') return ticket();
if (url === '/api/admin/ws-ticket') return json({ticket: '0123456789abcdef'.repeat(4), expires_in: 30});
throw new Error('network unavailable');
@@ -366,5 +369,94 @@ async function test(name, fn) { await fn(); ++passed; console.log('PASS JS:', na
}
}
});
await test('Settings is admin-only, read-only, bounded and preserves both sockets, lease and hidden output', async () => {
const u = await connected(); u.click('select-settings'); u.click('refresh-settings'); await tick();
assert.ok(!u.calls.some(c => c.url === '/api/settings/serial'));
const b = await adminBrowser(), [serial, admin] = b.sockets;
assert.ok(!b.calls.some(c => c.url === '/api/settings/serial'));
for (let i = 0; i < 10; ++i) {
b.click('select-settings'); await tick();
assert.equal(b.nodes['serial-settings'].hidden, false);
assert.equal(b.nodes.terminal.hidden, true); assert.equal(b.nodes['admin-terminal'].hidden, true);
assert.equal(b.nodes['settings-values'].hidden, false);
assert.equal(b.nodes['setting-baud'].textContent, '230400');
assert.equal(b.nodes['setting-dtr'].textContent, 'on-connect');
assert.equal(b.nodes['setting-running'].textContent, 'Running');
b.terminals.forEach(t => { assert.equal(t.options.disableStdin, true); t.input('WRONG'); });
serial.emit('message', {data: Uint8Array.of(65).buffer}); admin.emit('message', {data: Uint8Array.of(66).buffer});
b.click('select-serial'); b.click('select-admin');
}
assert.equal(b.sockets.length, 2); assert.ok(!serial.closed && !admin.closed);
assert.equal(serial.sent.length, 0); assert.equal(admin.sent.length, 0);
assert.equal(b.nodes['client-id'].textContent, '8'); assert.equal(b.nodes['writer-id'].textContent, '8');
assert.equal(b.nodes['release-control'].disabled, false);
assert.deepEqual(b.terminals.map(t => t.writes.length), [10, 10]);
const reads = b.calls.filter(c => c.url === '/api/settings/serial');
assert.equal(reads.length, 10); assert.ok(reads.every(c => c.method === 'GET' && c.body === undefined));
b.queues['/api/settings/serial'].push(json(serialSettings({running: false, baud: 110, data_bits: '7', parity: 'odd', stop_bits: '2', flow: 'none', dtr: 'inactive', rts_threshold: 1})));
b.click('select-settings'); await tick(); assert.equal(b.nodes['setting-running'].textContent, 'Stopped');
b.click('refresh-settings'); await tick(); assert.equal(b.nodes['setting-running'].textContent, 'Running');
});
await test('Settings rejects malformed/oversized schemas, contains errors, and retries only explicitly', async () => {
for (const response of [json(null), json(serialSettings({secret: 'bad'})), json(serialSettings({baud: 1000001})),
json(serialSettings({running: 1})), json(serialSettings({parity: '<img>'})), json(serialSettings({rts_threshold: 0})),
new Response(' '.repeat(257)), new Response(Uint8Array.of(255)), failure(400), failure(403), failure(404), failure(429), failure(503)]) {
const b = await adminBrowser(); b.queues['/api/settings/serial'].push(response);
b.click('select-settings'); await tick();
assert.equal(b.nodes['settings-values'].hidden, true); assert.equal(b.nodes['setting-baud'].textContent, '');
assert.match(b.nodes['settings-detail'].textContent, /Refresh to retry/);
assert.ok(!b.nodes['settings-detail'].textContent.includes('SECRET'));
assert.equal(b.nodes['refresh-settings'].disabled, false); assert.ok(b.sockets.every(s => !s.closed));
assert.equal(b.calls.filter(c => c.url === '/api/settings/serial').length, 1);
b.click('refresh-settings'); await tick(); assert.equal(b.nodes['settings-values'].hidden, false);
}
const b = await adminBrowser();
b.queues['/api/settings/serial'].push(o => new Promise((_, reject) => o.signal.addEventListener('abort', () => reject(new Error('SECRET timeout')))));
b.click('select-settings'); await tick(); b.click('refresh-settings');
assert.equal(b.calls.filter(c => c.url === '/api/settings/serial').length, 1);
b.fire(15000); await tick(); assert.match(b.nodes['settings-detail'].textContent, /Refresh to retry/);
assert.ok(b.sockets.every(s => !s.closed));
});
await test('Settings cancellation fences late replies; session change, 401, logout and restore clear the view', async () => {
for (const action of ['switch', 'pagehide', 'expiry', 'logout']) {
const b = await adminBrowser(), d = deferred(); b.queues['/api/settings/serial'].push(d.promise);
b.click('select-settings'); await tick(); const call = b.calls.find(c => c.url === '/api/settings/serial');
if (action === 'switch') b.click('select-serial');
if (action === 'pagehide') b.emit('pagehide');
if (action === 'expiry') b.window.sakSessionExpired();
if (action === 'logout') { b.queues['/api/logout'].push(new Response(null, {status: 204})); await b.click('sign-out'); }
assert.ok(call.signal.aborted); d.resolve(failure(401)); await tick();
assert.equal(b.nodes['serial-settings'].hidden, true); assert.equal(b.nodes['setting-baud'].textContent, '');
if (action === 'switch') { assert.deepEqual(b.redirects, []); assert.ok(b.sockets.every(s => !s.closed)); }
if (action === 'pagehide') {
b.emit('pageshow', {persisted: true}); await tick();
assert.deepEqual(b.redirects, []); assert.equal(b.nodes['settings-values'].hidden, true);
b.click('refresh-settings'); await tick(); assert.equal(b.nodes['settings-values'].hidden, false);
}
}
for (const change of ['401', 'identity']) {
const b = await adminBrowser();
if (change === '401') b.queues['/api/settings/serial'].push(failure(401));
else b.queues['/api/session'].push(session({role: 'admin', csrf: 'b'.repeat(64)}));
b.click('select-settings'); await tick();
assert.deepEqual(b.redirects, [change === '401' ? '/login' : '/']);
assert.ok(b.sockets.every(s => s.closed)); assert.equal(b.nodes['serial-settings'].hidden, true);
assert.equal(b.nodes['setting-baud'].textContent, '');
}
});
await test('Settings session validation never strands concurrent serial reconnect; newer admission fences old settings checks', async () => {
const b = await adminBrowser(), d = deferred();
b.queues['/api/session'].push(d.promise); b.sockets[0].emit('close'); b.fire(1000); await tick();
b.click('select-settings'); await tick(); assert.equal(b.nodes['settings-values'].hidden, false);
d.resolve(session({role: 'admin'})); await tick();
assert.equal(b.sockets.length, 3); assert.ok(!b.sockets[1].closed);
const c = await adminBrowser(), old = deferred(); c.queues['/api/session'].push(old.promise);
c.click('select-settings'); await tick(); c.sockets[0].emit('close'); c.fire(1000); await tick();
old.resolve(session({role: 'admin'})); await tick();
assert.equal(c.sockets.length, 3); assert.ok(!c.sockets[1].closed);
assert.equal(c.nodes['refresh-settings'].disabled, false);
assert.match(c.nodes['settings-detail'].textContent, /Refresh to retry/);
c.click('refresh-settings'); await tick(); assert.equal(c.nodes['settings-values'].hidden, false);
});
console.log(`PASS ${passed} browser behavior groups (production C-rendered JS)`);
})().catch(error => { console.error(error); process.exitCode = 1; });