Extend browser admin lifecycle actions

Support browser reboot and HTTPS stop through deferred control, plus
exact
`web certificate rotate --force` handoff to the dispatcher. Add typed
request
validation and focused boundary and lifecycle coverage.
This commit is contained in:
2026-09-07 09:36:38 +02:00
parent 17520b15b7
commit 326119812f
23 changed files with 699 additions and 47 deletions
+138
View File
@@ -0,0 +1,138 @@
/* Typed deferred work exercises the production dispatcher/control state machine. */
static admin_ssh_console_token_t token={.session_id=7, .slot_generation=1,
.transport=ADMIN_CONSOLE_TRANSPORT_WEB};
static user_principal_t principal={.role=USER_ROLE_ADMIN, .auth_generation=1};
static bool live=true, close_in_action;
static unsigned validations, invalidate_at;
static esp_err_t action_result;
static bool current(const admin_ssh_console_token_t *t, const user_principal_t *p) {
assert(!lock_depth && t->session_id==7 && p->auth_generation==1);
if (++validations==invalidate_at) live=false;
return live;
}
static bool drained(const admin_ssh_console_token_t *t) {
assert(!lock_depth && current_task==s_control_task && t->session_id==7);
return owner_drained;
}
static esp_err_t perform(const admin_ssh_console_token_t *t,
admin_ssh_deferred_action_type_t action, uint32_t arg);
static const admin_console_owner_t owner={
.supported_actions=1U << ADMIN_CONSOLE_DEFER_WEB_CERTIFICATE_ROTATE,
.dispatcher_actions=1U << ADMIN_CONSOLE_DEFER_WEB_CERTIFICATE_ROTATE,
.is_current=current, .drained=drained, .perform=perform,
};
static esp_err_t perform(const admin_ssh_console_token_t *t,
admin_ssh_deferred_action_type_t action, uint32_t arg) {
assert(!lock_depth && current_task==s_task && current_task!=s_control_task);
assert(action==ADMIN_CONSOLE_DEFER_WEB_CERTIFICATE_ROTATE && arg==0);
assert(s_sessions[0].executing && s_sessions[0].deferred_action_pending);
assert(!admin_ssh_console_accepts_input(t));
size_t consumed=99;
assert(!admin_ssh_console_feed_input(t,(const uint8_t *)"ignored",7,&consumed) && !consumed);
++actions;
if (close_in_action) {
admin_ssh_console_close(t);
admin_ssh_console_token_t replacement=*t; ++replacement.slot_generation;
assert(admin_ssh_console_open_owned(&replacement,&principal,&owner)==ESP_ERR_INVALID_STATE);
}
return action_result;
}
static void pump(void (*task)(void *)) {
current_task=task==control_task ? s_control_task : s_task;
if (!setjmp(loop_done)) task(NULL);
}
static void clear_output(void) {
uint8_t data[4096]; size_t n;
assert(admin_ssh_console_read_output(&token,data,sizeof(data),&n)==ESP_OK);
}
static void reopen_certificate_session(void) {
admin_ssh_console_close(&token); ++token.slot_generation;
live=principal_current=owner_drained=true; validations=invalidate_at=0;
close_in_action=false; action_result=ESP_OK; ticks=0;
assert(admin_ssh_console_open_owned(&token,&principal,&owner)==ESP_OK);
clear_output();
}
static esp_err_t schedule(void) {
current_task=s_task; s_dispatch_remote=true; s_dispatch_token=token;
s_dispatch_principal=principal;
s_sessions[0].executing=s_sessions[0].command_pending=true;
esp_err_t result=admin_ssh_console_dispatch_defer(ADMIN_CONSOLE_DEFER_WEB_CERTIFICATE_ROTATE,0);
s_sessions[0].executing=s_sessions[0].command_pending=false;
s_dispatch_remote=false;
return result;
}
static void revoke_delay(void) { if (ticks>=200) live=false; }
static void reuse_delay(void) {
if (ticks>=200) { delay_hook=NULL; reopen_certificate_session(); }
}
static void assert_pending(void) {
admin_ssh_console_session_snapshot_t snapshot;
assert(admin_ssh_console_get_session_snapshot(&token,&snapshot)==ESP_OK);
assert(snapshot.deferred_action_pending && !admin_ssh_console_accepts_input(&token));
}
static void uart_observes_pending(void) {
assert(current_task==s_task && actions==0); assert_pending();
}
int main(void) {
assert(admin_ssh_console_init()==ESP_OK);
assert(admin_ssh_console_start_uart_frontend()==ESP_OK);
s_task=(void *)1; s_control_task=(void *)2;
/* Union overlay preserves the old queue item allocation on this ABI. */
struct old_request { admin_request_origin_t origin; admin_ssh_console_token_t token;
user_principal_t principal; TaskHandle_t completion_task;
uint8_t line[ADMIN_SSH_CONSOLE_COMMAND_LINE_CAPACITY+1U]; };
assert(sizeof(admin_request_t)==sizeof(struct old_request));
assert(s_request_queue->capacity==4 && s_control_queue->capacity==2);
reopen_certificate_session(); queue_full=true;
assert(schedule()==ESP_ERR_TIMEOUT && !s_sessions[0].deferred_action_pending && !actions);
queue_full=false;
assert(schedule()==ESP_OK); assert_pending();
/* Drain waits for acknowledgement, then times out without enqueue/mutation. */
s_sessions[0].output_length=1; pump(control_task);
assert(ticks==10000 && !actions && !s_request_queue->count && !s_sessions[0].deferred_action_pending);
clear_output(); ticks=0;
assert(schedule()==ESP_OK);
admin_request_t uart={.origin=ADMIN_REQUEST_UART0, .line="memory"};
for (unsigned i=0;i<4;++i) assert(xQueueSend(s_request_queue,&uart,0));
pump(control_task);
assert(!actions && !s_sessions[0].deferred_action_pending && s_request_queue->count==4);
assert(s_sessions[0].output_length); pump(worker_task); clear_output();
puts("PASS: unchanged queue item/depths, admission and handoff queue failure before mutation, ack drain cancellation");
assert(schedule()==ESP_OK);
assert(xQueueSend(s_request_queue,&uart,0));
pump(control_task); assert_pending(); assert(!actions && s_request_queue->count==2);
command_hook=uart_observes_pending; pump(worker_task); command_hook=NULL;
assert(actions==1 && !s_sessions[0].deferred_action_pending && !s_sessions[0].executing);
assert(runs==5); /* Typed work never calls esp_console_run. */
puts("PASS: control only hands off, queued UART first, crypto callback exclusively serialized on dispatcher, input gated through callback");
for (unsigned cancellation=0;cancellation<7;++cancellation) {
reopen_certificate_session(); assert(schedule()==ESP_OK);
if (cancellation==0) delay_hook=revoke_delay;
if (cancellation==1) delay_hook=reuse_delay;
pump(control_task); delay_hook=NULL;
if (cancellation==2) live=false;
if (cancellation==3) principal_current=false;
if (cancellation==4) reopen_certificate_session();
if (cancellation==5) admin_ssh_console_close(&token);
if (cancellation==6) invalidate_at=2; /* Last check after executing reservation. */
pump(worker_task);
assert(actions==1 && !s_sessions[0].executing);
if (cancellation==1 || cancellation==4) {
assert(s_sessions[0].active && !s_sessions[0].deferred_action_pending && !s_sessions[0].output_length);
}
}
puts("PASS: delay/queued revoke, account revoke, close/reuse, final execution check; no output into replacements");
reopen_certificate_session(); action_result=ESP_ERR_NO_MEM; assert(schedule()==ESP_OK);
pump(control_task); pump(worker_task);
assert(actions==2 && !s_sessions[0].deferred_action_pending && admin_ssh_console_accepts_input(&token));
uint8_t out[512]={0}; size_t n;
assert(admin_ssh_console_read_output(&token,out,sizeof(out)-1,&n)==ESP_OK);
assert(strstr((char *)out,"Deferred remote action failed: fake"));
reopen_certificate_session(); close_in_action=true; assert(schedule()==ESP_OK);
pump(control_task); pump(worker_task);
admin_session_t empty={0}; assert(!memcmp(&empty,&s_sessions[0],sizeof(empty)) && actions==3);
puts("PASS: action error reaches deferred result, input resumes on failure, self-detach reserves slot until return and wipes state");
}
+6 -4
View File
@@ -20,7 +20,7 @@ typedef struct {
typedef unsigned TickType_t;
typedef void *TaskHandle_t;
typedef int portMUX_TYPE;
typedef struct { size_t size; unsigned count; unsigned char bytes[2048]; } StaticQueue_t;
typedef struct { size_t size; unsigned count, capacity; unsigned char bytes[2048]; } StaticQueue_t;
typedef StaticQueue_t *QueueHandle_t;
typedef int StaticSemaphore_t;
typedef int *SemaphoreHandle_t;
@@ -56,11 +56,13 @@ static void vTaskDelete(TaskHandle_t t) { (void)t; }
static void xTaskNotifyGive(TaskHandle_t t) { (void)t; }
static unsigned ulTaskNotifyTake(int b, unsigned t) { (void)b; (void)t; return 1; }
static QueueHandle_t xQueueCreateStatic(unsigned n, size_t s, uint8_t *b, StaticQueue_t *q)
{ (void)n; (void)b; q->size = s; return q; }
{ (void)b; q->size = s; q->capacity = n; assert(n*s <= sizeof(q->bytes)); return q; }
static int xQueueSend(QueueHandle_t q, const void *p, unsigned t)
{ (void)t; if (queue_full) return 0; assert(!q->count); memcpy(q->bytes,p,q->size); q->count=1; return 1; }
{ (void)t; if (queue_full || q->count==q->capacity) return 0;
memcpy(q->bytes+q->count*q->size,p,q->size); ++q->count; return 1; }
static int xQueueReceive(QueueHandle_t q, void *p, unsigned t)
{ (void)t; if (!q->count) longjmp(loop_done,1); memcpy(p,q->bytes,q->size); q->count=0; return 1; }
{ (void)t; if (!q->count) longjmp(loop_done,1); memcpy(p,q->bytes,q->size);
--q->count; memmove(q->bytes,q->bytes+q->size,q->count*q->size); return 1; }
static SemaphoreHandle_t xSemaphoreCreateBinaryStatic(StaticSemaphore_t *s) { return s; }
static int xSemaphoreTake(SemaphoreHandle_t s, unsigned t)
{ assert(!lock_depth); if (t && !*s) { ticks+=t; if (prompt_hook) prompt_hook(); }
+102
View File
@@ -0,0 +1,102 @@
#!/usr/bin/env python3
"""Actual canonical stop/reboot handlers with deterministic side-effect doubles."""
from pathlib import Path
import subprocess
import tempfile
ROOT = Path(__file__).resolve().parents[2]
def function(path, name):
source = path.read_text()
start = source.index('static int ' + name + '(')
return source[start:source.index('\n}', start) + 2]
header = '\n'.join(line for line in (ROOT / 'src/admin_ssh_console.h').read_text().splitlines()
if not line.startswith(('#include', '#pragma once')))
prelude = r'''
#include <assert.h>
#include <stdbool.h>
#include <stddef.h>
#include <stdint.h>
#include <stdio.h>
#include <string.h>
typedef int esp_err_t;
enum { ESP_OK=0, ESP_FAIL=-1, ESP_ERR_TIMEOUT=7 };
typedef struct { int unused; } user_principal_t;
'''
fakes = r'''
static bool remote, web;
static unsigned stops, reboots, scheduled, waits, rotations;
static esp_err_t schedule_result, stop_result;
static admin_ssh_deferred_action_type_t last_action;
bool admin_ssh_console_dispatch_is_remote(void) { return remote; }
bool admin_ssh_console_dispatch_is_web(void) { return remote && web; }
esp_err_t admin_ssh_console_dispatch_defer(admin_ssh_deferred_action_type_t action, uint32_t argument) {
assert(remote && !argument); ++scheduled; last_action=action; return schedule_result;
}
static const char *esp_err_to_name(esp_err_t error) { (void)error; return "fake"; }
static esp_err_t web_server_stop(void) { ++stops; return stop_result; }
static esp_err_t web_server_start(void) { assert(false); return ESP_FAIL; }
static esp_err_t web_server_clear_counters(void) { assert(false); return ESP_FAIL; }
static esp_err_t web_serial_transport_clear_counters(void) { assert(false); return ESP_FAIL; }
static void esp_restart(void) { ++reboots; }
static void vTaskDelay(unsigned delay) { assert(delay==100); ++waits; }
#define pdMS_TO_TICKS(ms) (ms)
static void print_usage(void) { assert(false); }
static int show_status(void) { assert(false); return 1; }
static int show_counters(void) { assert(false); return 1; }
static int show_credentials(void) { assert(false); return 1; }
static int show_certificate(void) { assert(false); return 1; }
static int rotate_credentials(void) { assert(false); return 1; }
static int rotate_certificate(void) { ++rotations; return 0; }
static int reset_material(void) { assert(false); return 1; }
static bool force_is_present(int argc, char **argv, int expected) {
return argc == expected && !strcmp(argv[expected - 1], "--force");
}
'''
tests = r'''
int main(void) {
char *stop[]={"web", "stop"};
remote=web=true;
assert(command_web(2,stop)==0 && scheduled==1 && !stops && last_action==ADMIN_CONSOLE_DEFER_WEB_STOP);
schedule_result=ESP_ERR_TIMEOUT;
assert(command_web(2,stop)==1 && scheduled==2 && !stops);
schedule_result=ESP_OK;
web=false; /* SSH preserves its synchronous HTTPS path. */
assert(command_web(2,stop)==0 && stops==1 && scheduled==2);
remote=false;
assert(command_web(2,stop)==0 && stops==2 && scheduled==2);
stop_result=ESP_FAIL;
assert(command_web(2,stop)==1 && stops==3);
remote=true;
assert(command_reboot(1,NULL)==0 && scheduled==3 && !reboots && last_action==ADMIN_SSH_DEFER_REBOOT);
web=true;
assert(command_reboot(1,NULL)==0 && scheduled==4 && !reboots && last_action==ADMIN_SSH_DEFER_REBOOT);
schedule_result=ESP_FAIL;
assert(command_reboot(1,NULL)==1 && scheduled==5 && !reboots);
assert(command_reboot(2,NULL)==1 && scheduled==5 && !reboots);
remote=false;
assert(command_reboot(1,NULL)==0 && reboots==1 && waits==1 && scheduled==5);
char *rotate[]={"web", "certificate", "rotate", "--force", "extra"};
remote=web=true; schedule_result=ESP_OK;
assert(command_web(4,rotate)==0 && scheduled==6 && !rotations &&
last_action==ADMIN_CONSOLE_DEFER_WEB_CERTIFICATE_ROTATE);
schedule_result=ESP_ERR_TIMEOUT;
assert(command_web(4,rotate)==1 && scheduled==7 && !rotations);
assert(command_web(3,rotate)==1 && scheduled==7 && !rotations);
assert(command_web(5,rotate)==1 && scheduled==7 && !rotations);
web=false;
assert(command_web(4,rotate)==0 && rotations==1 && scheduled==7);
remote=false;
assert(command_web(4,rotate)==0 && rotations==2 && scheduled==7);
puts("PASS: canonical WEB stop/certificate deferred, exact force required, SSH/UART unchanged, reboot and queue failure isolation");
}
'''
with tempfile.TemporaryDirectory(prefix='console-lifecycle-') as directory:
tmp = Path(directory)
(tmp / 'test.c').write_text(prelude + header + fakes +
function(ROOT / 'src/web_console.c', 'command_web') +
function(ROOT / 'src/system_console.c', 'command_reboot') + tests)
subprocess.run(['cc', '-std=c11', '-Wall', '-Wextra', '-Werror',
str(tmp / 'test.c'), '-o', str(tmp / 'test')], check=True, timeout=30)
subprocess.run([str(tmp / 'test')], check=True, timeout=10)
+8
View File
@@ -27,6 +27,14 @@ with tempfile.TemporaryDirectory(prefix="admin-console-boundary-") as directory:
"-g", str(path / "test.c"), parser,
"-o", str(path / "test")], check=True, timeout=30)
subprocess.run([str(path / "test")], check=True, timeout=10)
unit = ((ROOT / "tests/admin_console_boundary/fakes.h").read_text()
+ strip_includes(header) + "\n" + strip_includes(source)
+ (ROOT / "tests/admin_console_boundary/certificate.c").read_text())
(path / "certificate.c").write_text(unit)
subprocess.run(["cc", "-std=c11", "-Wall", "-Wextra", "-Werror",
"-g", str(path / "certificate.c"), parser,
"-o", str(path / "certificate")], check=True, timeout=30)
subprocess.run([str(path / "certificate")], check=True, timeout=10)
ssh = (ROOT / "src/ssh_transport.c").read_text()
adapter = ssh[ssh.index("static admin_ssh_console_token_t admin_console_token("):
ssh.index("static void *ssh_malloc(")]
+10 -1
View File
@@ -149,7 +149,7 @@ static void test_shared_admission(void)
assert(s_sessions[0].owner==&owner && s_sessions[1].owner==&second_owner);
unsigned before=runs;
clear_output(&web);
feed(&web,"\"web\" \"stop\"\r"); pump(worker_task);
feed(&web,"\"web\" \"reset\" --force\r"); pump(worker_task);
assert(runs==before && !s_control_queue->count);
uint8_t diagnostic[512]={0}; size_t received=0;
assert(admin_ssh_console_read_output(&web,diagnostic,sizeof(diagnostic)-1,&received)==ESP_OK);
@@ -235,12 +235,21 @@ int main(void)
secure_wipe(&s_sessions[0],sizeof(s_sessions[0])); prompt_hook=NULL;
assert(admin_ssh_console_open_owned(&a,&admin,&owner)==ESP_OK);
setup_dispatch(); clear_output(&a);
assert(!admin_ssh_console_dispatch_is_web());
s_dispatch_token.transport = ADMIN_CONSOLE_TRANSPORT_WEB;
assert(admin_ssh_console_dispatch_is_web());
s_dispatch_remote = false; assert(!admin_ssh_console_dispatch_is_web());
s_dispatch_remote = true; s_dispatch_token = a;
assert(admin_ssh_console_dispatch_defer(ADMIN_CONSOLE_DEFER_WEB_STOP,0)==ESP_ERR_NOT_SUPPORTED);
assert(admin_ssh_console_dispatch_defer((admin_ssh_deferred_action_type_t)32,0)==ESP_ERR_NOT_SUPPORTED);
assert(admin_ssh_console_dispatch_defer(ADMIN_SSH_DEFER_STOP,0)==ESP_ERR_NOT_SUPPORTED);
assert(!s_sessions[0].deferred_action_pending);
queue_full=true;
assert(admin_ssh_console_dispatch_defer(ADMIN_CONSOLE_DEFER_SELF_CLOSE,0)==ESP_ERR_TIMEOUT);
assert(!s_sessions[0].deferred_action_pending); queue_full=false;
assert(admin_ssh_console_dispatch_defer(ADMIN_CONSOLE_DEFER_SELF_CLOSE,0)==ESP_OK);
admin_ssh_console_session_snapshot_t pending;
assert(admin_ssh_console_get_session_snapshot(&a, &pending)==ESP_OK && pending.deferred_action_pending);
assert(!admin_ssh_console_feed_input(&a,(const uint8_t *)"x",1,&n) && n==0);
s_sessions[0].command_pending=false; owner_drained=false; ticks=0;
pump(control_task); assert(ticks==10000 && actions==0);
+12 -5
View File
@@ -55,17 +55,24 @@ int main(void) {
const char *web_allowed[] = {
"", " ", "help", "memory", "exit", "user", "user status", "user list",
"user show admin", "\"user\" \"show\" \"bootstrap\"",
"web status", "wifi status", "mdns status", "\"web\" \"status\"",
"web status", "web stop", "reboot", "\"reboot\"", "\"web\" \"stop\"",
"wifi status", "mdns status", "\"web\" \"status\"",
"web certificate rotate --force",
" \"web\" \"certificate\" \"rotate\" \"--force\" ",
"ssh status", "ssh sessions", "ssh counters", "ssh host-key info", "ssh start",
};
const char *web_denied[] = {
"web", "web help", "web start", "web stop", "web counters", "web clear-counters",
"web", "web help", "web start", "web stop extra", "web counters", "web clear-counters",
"web credentials show", "web credentials rotate --force", "web certificate info",
"web certificate rotate --force", "web reset --force", "web status extra",
"web certificate rotate", "web certificate rotate --force extra",
"web certificate rotate --force --force", "web certificate rotate --Force",
"web certificate rotate --forcex", "web certificate --force rotate",
"\"web\" \"certificate\" \"rotate\" \"--force extra\"",
"web reset --force", "web status extra",
"wifi", "wifi profiles", "wifi scan", "wifi start", "wifi stop", "wifi save",
"wifi load", "wifi defaults", "wifi reset", "wifi ping example.org",
"mdns", "mdns suffix test", "mdns save", "mdns load", "mdns defaults", "mdns reset",
"reboot", "reboot --force", "user bootstrap", "user recover --force",
"reboot --force", "user bootstrap", "user recover --force",
"user add other admin --generate", "user delete other --force",
"user role other user --force", "user password admin --generate",
"user password other", "user key add admin", "user key clear admin --force",
@@ -73,7 +80,7 @@ int main(void) {
"ssh stop", "ssh disconnect 7", "ssh host-key rotate --force", "ssh reset --force",
" \"user\" \"password\" \"admin\" \"--generate\"",
"\"web\" \"credentials\" \"show\"", "\"wifi\" \"stop\"",
"\"mdns\" \"reset\"", "\"reboot\"", "\"ssh\" \"stop\"",
"\"mdns\" \"reset\"", "\"reboot\" extra", "\"ssh\" \"stop\"",
"\"ssh\" \"host-key\" \"rotate\" --force", "\"user\" \"recover\" --force",
};
for (size_t i=0; i<sizeof(web_allowed)/sizeof(web_allowed[0]); ++i) {
+11 -2
View File
@@ -17,7 +17,15 @@ transport functions are not copied or reimplemented. Temporary output is removed
production entry points and inspects private state for lifecycle/wipe assertions.
No firmware build, network access or device operation is performed.
## Results recorded 2026-09-06
## Latest reported results — 8D.7 second certificate slice
Implementer reports `run.py --tickets` PASS **25 transport / 12 ticket groups**, including certificate owner routing, currentness rejection and commit → stop → start short-circuit/error behavior. The owner's `dispatcher_actions` mask selects the existing 12 KiB dispatcher, not the 4 KiB control task. `tests/admin_console_boundary/run.py` (including `certificate.c`) separately covers typed deferred handoff/pending gate/executing reservation; `lifecycle.py` covers canonical handlers and unchanged SSH/UART0 behavior. Policy, server lifecycle **11**, cookie `--admin` and store `--serial` also pass as reported. Independent review has no actionable findings; sanitizer validation is unavailable due to missing libasan/libubsan. No hardware validation is claimed or performed by this documentation update.
Current WEB policy allows exact parsed `web status`, `web stop`, `web certificate rotate --force`, and `reboot`/self-close; other web forms, account mutations, network mutations and restricted SSH lifecycle/key mutations remain blocked. Certificate drain/acknowledgement bounds do not bound queued execution or prove browser receipt. See `docs/phase8d7_implementation.md` for final parent build/resources, trust/relogin/failure checklist and authorized next bounded slice; M2 acceptance remains pending.
## Earlier results recorded 2026-09-06
8D.7 first slice: `run.py --tickets` passes **23 transport / 12 ticket groups**. Adds WEB stop/reboot owner routing, stale/revoked action rejection and stop-error propagation, pending-input discard before poll and cancellation-during-receive with/without an occupied RX buffer. `python3 tests/admin_console_boundary/lifecycle.py` separately checks the production canonical handlers and unchanged SSH/UART0 behavior. Dependencies remain doubled; no target stop/reboot is executed. See `docs/phase8d7_implementation.md` for scope and pending validation.
Final continuation: `run.py --tickets` passes **19 transport / 12 ticket groups**,
including the HTTPD-owned shutdown retry/reuse regression. `server_lifecycle.py`
@@ -59,7 +67,8 @@ After the production empty-frame, input-deadline and timer-generation fixes:
- At most one outstanding transport poll; byte-preserving input, partial input
consumption/retry, consumed-input wiping, output delivery and TX wiping.
- Nonfinal/text/oversized frames and another frame while RX is occupied fail
closed; stalled input closes after the five-second deadline. Pending bytes are
closed outside deferral; during observed deferral bounded input is discarded
without cancelling the scheduled action. Stalled input closes after the five-second deadline. Pending bytes are
not fed at or after the deadline even if the console can now consume them.
- Session/account notification isolation, idle currentness failure, invalidation
during currentness checking and between output consumption and send. Notifier
+18 -2
View File
@@ -4,6 +4,19 @@ static int server_storage;
#define SERVER ((void *)&server_storage)
static bool httpd_owner, alloc_fail, timer_fail, auth_allowed, session_current;
static bool console_live, console_full, queue_fail, send_fail, upgrade_fail;
static bool deferred_pending, cancel_on_receive;
static unsigned reboots, web_stops;
static esp_err_t web_stop_result, rotate_result, web_start_result;
static unsigned rotations, web_starts;
static esp_err_t web_security_rotate_certificate(void) {
OUTSIDE(); assert(!httpd_owner && !web_stops && !web_starts); ++rotations; return rotate_result;
}
static esp_err_t web_server_start(void) {
OUTSIDE(); assert(!httpd_owner && rotations && web_stops == 1 && web_stop_result == ESP_OK);
++web_starts; return web_start_result;
}
static void esp_restart(void) { OUTSIDE(); assert(!httpd_owner); ++reboots; }
esp_err_t web_server_stop(void) { OUTSIDE(); assert(!httpd_owner); ++web_stops; return web_stop_result; }
static bool ticket_live, upgrade_requested, revoke_on_open, revoke_on_send;
static unsigned upgrades, closes, sends, queues, wipes, checks, receive_headers;
static size_t feed_limit, fed_length, output_length;
@@ -93,7 +106,8 @@ esp_err_t admin_ssh_console_read_output(const admin_ssh_console_token_t *t, uint
}
esp_err_t admin_ssh_console_get_session_snapshot(const admin_ssh_console_token_t *t,
admin_ssh_console_session_snapshot_t *s) {
OUTSIDE(); assert(t); *s = (admin_ssh_console_session_snapshot_t){.active = console_live}; return ESP_OK;
OUTSIDE(); assert(t); *s = (admin_ssh_console_session_snapshot_t){.active = console_live,
.deferred_action_pending = deferred_pending}; return ESP_OK;
}
static esp_err_t httpd_queue_work(httpd_handle_t h, void (*fn)(void *), void *arg) {
OUTSIDE(); assert(h == SERVER); ++queues;
@@ -122,7 +136,9 @@ static esp_err_t httpd_ws_recv_frame(httpd_req_t *r, httpd_ws_frame_t *f, size_t
if (f->len == 0) { ++receive_headers; f->len = incoming.len; f->final = incoming.final; f->type = incoming.type; }
if (!capacity || !f->len) return ESP_OK;
if (f->len > capacity) return ESP_ERR_INVALID_ARG;
memcpy(f->payload, incoming.payload, f->len); return ESP_OK;
memcpy(f->payload, incoming.payload, f->len);
if (cancel_on_receive) deferred_pending = false;
return ESP_OK;
}
static esp_err_t httpd_resp_set_status(httpd_req_t *r, const char *s) { io(); (void)r; snprintf(status, sizeof(status), "%s", s); return ESP_OK; }
static esp_err_t httpd_resp_set_type(httpd_req_t *r, const char *s) { io(); (void)r; assert(!strcmp(s, "application/json")); return ESP_OK; }
+84 -2
View File
@@ -14,7 +14,9 @@ static void reset(void) {
httpd_owner = true; alloc_fail = timer_fail = false;
auth_allowed = session_current = upgrade_requested = true;
console_live = console_full = queue_fail = send_fail = upgrade_fail = shutdown_fail = false;
ticket_live = revoke_on_open = revoke_on_send = false;
ticket_live = revoke_on_open = revoke_on_send = deferred_pending = false;
reboots = web_stops = rotations = web_starts = 0;
web_stop_result = rotate_result = web_start_result = ESP_OK; cancel_on_receive = false;
upgrades = closes = sends = queues = wipes = checks = receive_headers = 0;
feed_limit = SIZE_MAX; fed_length = output_length = sent_length = 0;
memset(fed, 0, sizeof(fed)); memset(output, 0, sizeof(output)); memset(sent, 0, sizeof(sent));
@@ -213,7 +215,7 @@ int main(void) {
ok("submission fence timeout/retry and stopped retirement of unexecuted callback");
reset(); start(); admit();
assert(owner_perform(&s_slot.token, ADMIN_SSH_DEFER_REBOOT, 0) == ESP_ERR_NOT_SUPPORTED);
assert(owner_perform(&s_slot.token, ADMIN_SSH_DEFER_STOP, 0) == ESP_ERR_NOT_SUPPORTED);
assert(console_live && !s_slot.close_requested);
s_slot.sending = true; assert(!owner_drained(&s_slot.token)); s_slot.sending = false;
assert(owner_drained(&s_slot.token)); httpd_owner = false;
@@ -221,6 +223,86 @@ int main(void) {
assert(!console_live && !closes); httpd_owner = true;
ok("unsupported deferred action has no side effects; self-close notifier and drain guard");
reset(); start(); admit(); httpd_owner = false;
assert(s_owner.supported_actions & (1U << ADMIN_SSH_DEFER_REBOOT));
assert(s_owner.supported_actions & (1U << ADMIN_CONSOLE_DEFER_WEB_STOP));
admin_ssh_console_token_t stale = s_slot.token; ++stale.slot_generation;
assert(owner_perform(&stale, ADMIN_SSH_DEFER_REBOOT, 0) == ESP_ERR_NOT_FOUND && !reboots);
session_current = false;
assert(owner_perform(&s_slot.token, ADMIN_CONSOLE_DEFER_WEB_STOP, 0) == ESP_ERR_NOT_FOUND && !web_stops);
session_current = true; check_hook = revoke_check;
assert(owner_perform(&s_slot.token, ADMIN_SSH_DEFER_REBOOT, 0) == ESP_ERR_NOT_FOUND && !reboots);
httpd_owner = true;
ok("deferred lifecycle rechecks current session and generation after drain/delay; revoked work cannot act");
reset(); start(); admit(); httpd_owner = false;
web_stop_result = ESP_ERR_TIMEOUT;
assert(owner_perform(&s_slot.token, ADMIN_CONSOLE_DEFER_WEB_STOP, 0) == ESP_ERR_TIMEOUT);
assert(web_stops == 1 && !reboots && !closes);
web_stop_result = ESP_OK;
assert(owner_perform(&s_slot.token, ADMIN_CONSOLE_DEFER_WEB_STOP, 0) == ESP_OK && web_stops == 2);
assert(owner_perform(&s_slot.token, ADMIN_SSH_DEFER_REBOOT, 0) == ESP_OK && reboots == 1);
httpd_owner = true;
ok("HTTPS stop/reboot marshal to lifecycle APIs outside HTTPD/locks; stop failure propagates");
for (unsigned failure=0; failure<4; ++failure) {
reset(); start(); admit(); httpd_owner=false;
assert(s_owner.dispatcher_actions == (1U << ADMIN_CONSOLE_DEFER_WEB_CERTIFICATE_ROTATE));
assert(s_owner.supported_actions & s_owner.dispatcher_actions);
if (failure==1) rotate_result=ESP_FAIL;
if (failure==2) web_stop_result=ESP_ERR_TIMEOUT;
if (failure==3) web_start_result=ESP_ERR_NO_MEM;
esp_err_t expected[]={ESP_OK, ESP_FAIL, ESP_ERR_TIMEOUT, ESP_ERR_NO_MEM};
assert(owner_perform(&s_slot.token, ADMIN_CONSOLE_DEFER_WEB_CERTIFICATE_ROTATE, 0)==expected[failure]);
assert(rotations==1 && web_stops==(failure!=1) && web_starts==(failure==0 || failure==3));
assert(!closes && !sends);
}
ok("certificate dispatcher mask, transactional API then stop/start, first-error propagation and no IO");
for (unsigned invalid=0; invalid<5; ++invalid) {
reset(); start(); admit(); httpd_owner=false;
admin_ssh_console_token_t token=s_slot.token;
if (invalid==0) ++token.slot_generation;
if (invalid==1) session_current=false;
if (invalid==2) ++auth_view.principal.auth_generation;
if (invalid==3) check_hook=revoke_check;
if (invalid==4) ++s_slot.session;
assert(owner_perform(&token, ADMIN_CONSOLE_DEFER_WEB_CERTIFICATE_ROTATE, 0)==ESP_ERR_NOT_FOUND);
assert(!rotations && !web_stops && !web_starts && !closes);
}
ok("certificate rejects stale token, revoked cookie/principal, revocation during check and replaced session");
reset(); start(); admit(); incoming.payload = bytes; incoming.len = sizeof(bytes);
feed_limit = 0;
assert(frame_handler(&request) == ESP_OK && s_payload->rx_length);
deferred_pending = true;
/* A second frame can beat the next poll after the command becomes deferred. */
assert(frame_handler(&request) == ESP_OK && console_live && !s_slot.close_requested);
tick(); work();
assert(!fed_length && !s_payload->rx_length && zeroed(s_payload->rx, sizeof(s_payload->rx)));
assert(console_live && !s_slot.close_requested);
assert(frame_handler(&request) == ESP_OK && !s_payload->rx_length && !fed_length);
deferred_pending = false;
assert(frame_handler(&request) == ESP_OK && s_payload->rx_length); /* Held input. */
deferred_pending = true; tick(); work(); /* Poll also discards independently. */
assert(!s_payload->rx_length && zeroed(s_payload->rx, sizeof(s_payload->rx)));
deferred_pending = false; feed_limit = SIZE_MAX; tick(); work();
assert(!fed_length); /* Failed/cancelled deferred work must not replay held input. */
assert(frame_handler(&request) == ESP_OK && fed_length == sizeof(bytes));
ok("pending actions discard buffered/new input, preserve drain, and never replay it on cancellation");
for (unsigned buffered = 0; buffered < 2; ++buffered) {
reset(); start(); admit(); incoming.payload = bytes; incoming.len = sizeof(bytes);
if (buffered) {
feed_limit = 0; assert(frame_handler(&request) == ESP_OK && s_payload->rx_length);
}
feed_limit = SIZE_MAX; deferred_pending = cancel_on_receive = true;
assert(frame_handler(&request) == ESP_OK && !deferred_pending && !fed_length);
assert(!s_payload->rx_length && zeroed(s_payload->rx, sizeof(s_payload->rx)));
assert(console_live && !s_slot.close_requested);
}
ok("deferral observed before payload read stays discarded when cancellation races receive, with/without buffered tail");
reset(); start(); admit(); incoming.len = 0;
assert(frame_handler(&request) == ESP_OK);
assert(receive_headers == 1 && !fed_length && console_live && !s_slot.close_requested);
+5
View File
@@ -14,6 +14,11 @@ static void (*timer_poll)(void *), (*pending_poll)(void *);
static void *pending_argument;
static httpd_req_t connected;
static unsigned admin_closes;
/* These endpoint tests never dispatch lifecycle commands. */
void esp_restart(void) { assert(false); }
esp_err_t web_server_stop(void) { assert(false); return ESP_FAIL; }
esp_err_t web_server_start(void) { assert(false); return ESP_FAIL; }
esp_err_t web_security_rotate_certificate(void) { assert(false); return ESP_FAIL; }
void *heap_caps_calloc(size_t n, size_t size, unsigned caps) {
assert(caps == (MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT)); return calloc(n, size);
}
+1
View File
@@ -43,6 +43,7 @@ esp_err_t httpd_ws_respond_server_handshake(httpd_req_t *, const char *);
admin = "--admin" in sys.argv
if admin:
HEADERS["esp_system.h"] = "#pragma once\nvoid esp_restart(void);\n"
HEADERS["esp_heap_caps.h"] = """#pragma once
#include <stddef.h>
#define MALLOC_CAP_SPIRAM 1