Add admin firmware upload support

Implement authenticated HTTPS OTA uploads with bounded streaming, image
validation, reboot coordination, and lifecycle exclusion. Add the admin
UI,
regression tests, and Phase 10 acceptance documentation.
This commit is contained in:
2026-09-18 22:22:11 +02:00
parent 4f628a4098
commit 31a22eba06
31 changed files with 1442 additions and 88 deletions
@@ -39,6 +39,8 @@ 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 bool reboot_busy;
static esp_err_t web_firmware_update_reserve_reboot(void) { return reboot_busy ? ESP_FAIL : ESP_OK; }
static void esp_restart(void) { ++reboots; }
static void vTaskDelay(unsigned delay) { assert(delay==100); ++waits; }
#define pdMS_TO_TICKS(ms) (ms)
@@ -95,6 +97,9 @@ int main(void) {
assert(command_reboot(1,NULL)==1 && scheduled==5 && !reboots);
assert(command_reboot(2,NULL)==1 && scheduled==5 && !reboots);
remote=false;
reboot_busy=true;
assert(command_reboot(1,NULL)==1 && !reboots && !waits && scheduled==5);
reboot_busy=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;
+5
View File
@@ -15,6 +15,11 @@ 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 bool reboot_busy;
static esp_err_t web_firmware_update_reserve_reboot(void) {
OUTSIDE(); assert(!httpd_owner);
return reboot_busy ? ESP_ERR_INVALID_STATE : ESP_OK;
}
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 esp_err_t web_server_replace_identity(uint32_t service, uint32_t identity, bool reset, bool *committed) {
@@ -180,20 +180,20 @@ static void pipeline_tests(void) {
reset(); lifecycle_fail_at = failure; start();
unsigned failed_route = (failure + 1) / 2;
assert(lifecycle_calls == failed_route && lifecycle_allocations == failure);
assert(registered_count == (failed_route == 1 ? 36 : 37));
assert(registered_count == (failed_route == 1 ? 37 : 38));
assert(!method_route("/api/settings/lifecycle-operation", HTTP_POST));
assert(!method_route("/api/settings/lifecycle-operation", HTTP_GET));
assert(!!method_route("/api/settings/lifecycle", HTTP_GET) == (failed_route != 1));
other_domains_complete(); network_complete(); display_complete(); broker_complete(); ssh_complete();
assert(web_server_stop() == ESP_OK);
lifecycle_fail_at = 0; fresh_registration(); start(); assert(registered_count == 39);
lifecycle_fail_at = 0; fresh_registration(); start(); assert(registered_count == 40);
assert(method_route("/api/settings/lifecycle-operation", HTTP_POST)->handler == web_lifecycle_operation_handler);
assert(web_server_stop() == ESP_OK);
}
puts("PASS all six lifecycle route allocation positions preserve other domains and restart recovers the complete optional API");
for (unsigned failure = 5; failure <= 6; ++failure) {
reset(); lifecycle_fail_at = failure; unregister_fail = true; start();
assert(registered_count == 38 && unregister_calls == 1);
assert(registered_count == 39 && unregister_calls == 1);
assert(method_route("/api/settings/lifecycle-operation", HTTP_GET));
assert(!method_route("/api/settings/lifecycle-operation", HTTP_POST));
other_domains_complete(); ssh_complete();
@@ -201,7 +201,7 @@ static void pipeline_tests(void) {
assert(web_server_start() == ESP_ERR_INVALID_STATE);
ssl_stop_error = ESP_OK; assert(web_server_stop() == ESP_OK);
unregister_fail = false; lifecycle_fail_at = 0; fresh_registration(); start();
assert(registered_count == 39 && web_server_stop() == ESP_OK);
assert(registered_count == 40 && web_server_stop() == ESP_OK);
}
puts("PASS lifecycle failed unregister leaves reads only; failed shutdown preserves ownership before successful restart");
}
+54 -49
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) != 36:
raise RuntimeError('Review URI extraction: expected 34 descriptors and two tables')
if len(uri_tables) != 37:
raise RuntimeError('Review URI extraction: expected 35 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()
@@ -46,6 +46,7 @@ header = '\n'.join(line for line in header.splitlines()
if not line.startswith(('#include', '#pragma once')))
constants = define(SOURCE, 'WEB_SERVER_PORT')
for filename, names in {
'web_firmware_update.h': ('WEB_FIRMWARE_UPDATE_URI',),
'web_admin_transport.h': ('WEB_ADMIN_TICKET_URI', 'WEB_ADMIN_WS_URI'),
'web_serial_transport.h': ('WEB_SERIAL_TRANSPORT_TICKET_URI', 'WEB_SERIAL_TRANSPORT_WS_URI'),
'web_security.h': ('WEB_SECURITY_CERTIFICATE_DER_CAPACITY', 'WEB_SECURITY_PRIVATE_KEY_DER_CAPACITY'),
@@ -104,7 +105,7 @@ static bool unregister_fail;
static bool settings_fail;
static unsigned settings_calls;
static unsigned operation_calls, operation_fail_at;
static const httpd_uri_t *registered[39];
static const httpd_uri_t *registered[40];
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; }
static SemaphoreHandle_t xSemaphoreCreateMutex(void) { assert(!locked); return mutex_fail ? NULL : &mutex_storage; }
@@ -134,6 +135,7 @@ static esp_err_t web_security_replace_reserved(uint32_t token) {
}
static void web_security_release_identity(uint32_t token) { assert(!locked); if (token == identity_token) identity_token = 0; }
#define HANDLER(name) static esp_err_t name(httpd_req_t *r) { (void)r; assert(!"HTTP handler must not run in lifecycle harness"); return ESP_FAIL; }
HANDLER(web_firmware_update_handler)
HANDLER(root_handler) HANDLER(status_handler) HANDLER(traced_ticket_handler)
HANDLER(traced_websocket_handler) HANDLER(asset_handler) HANDLER(web_cookie_auth_handler)
HANDLER(traced_admin_ticket_handler) HANDLER(traced_admin_upgrade_handler)
@@ -277,7 +279,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 == 39 && config->port_secure == 443);
assert(config->httpd.max_uri_handlers == 40 && 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->user_cb == tls_session_callback);
@@ -293,7 +295,7 @@ static esp_err_t register_one(httpd_handle_t server) {
}
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(s == SERVER && auth_live && ssl_live && registration_calls >= 18);
assert(uri->method == HTTP_GET && uri->handler == serial_settings_handler);
++settings_calls;
if (settings_fail) return ESP_ERR_NO_MEM;
@@ -301,11 +303,11 @@ static esp_err_t httpd_register_uri_handler(httpd_handle_t s, const httpd_uri_t
return ESP_OK;
}
if (!strcmp(uri->uri, "/api/admin/ws-ticket") || !strcmp(uri->uri, "/ws/admin")) {
assert(registration_calls >= 16);
assert(registration_calls >= 17);
assert(serial_init_error != ESP_OK || serial_live);
} else assert(registration_calls < 14);
} else assert(registration_calls < 15);
esp_err_t error = register_one(s);
if (error == ESP_OK) { assert(registered_count < 39); registered[registered_count++] = uri; }
if (error == ESP_OK) { assert(registered_count < 40); registered[registered_count++] = uri; }
return error;
}
static esp_err_t account_register(httpd_handle_t s, const httpd_uri_t *uri) {
@@ -355,7 +357,7 @@ static esp_err_t web_httpd_register_optional(httpd_handle_t s, const httpd_uri_t
}
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) ||
assert((registration_calls == 19 && !strcmp(uri, "/api/admin/ws-ticket") && method == HTTP_POST) ||
((!strcmp(uri, "/api/settings/serial-operation") || !strcmp(uri, "/api/settings/account-operation") || !strcmp(uri, "/api/settings/network-operation") || !strcmp(uri, "/api/settings/display-operation") || !strcmp(uri, "/api/settings/broker-operation") || !strcmp(uri, "/api/settings/ssh-operation") || !strcmp(uri, "/api/settings/lifecycle-operation")) && method == HTTP_GET));
++unregister_calls;
for (unsigned i = 0; i < registered_count; ++i) {
@@ -372,15 +374,15 @@ static esp_err_t httpd_unregister_uri_handler(httpd_handle_t s, const char *uri,
}
static esp_err_t httpd_register_err_handler(httpd_handle_t s, httpd_err_code_t code,
esp_err_t (*handler)(httpd_req_t *, httpd_err_code_t)) {
assert(registration_calls == 14 || registration_calls == 15);
assert(registration_calls == 15 || registration_calls == 16);
assert((code == 404 || code == 405) && handler == route_error_handler);
return register_one(s);
}
static esp_err_t web_serial_transport_attach_server(httpd_handle_t s) {
assert(!locked && s == SERVER && ssl_live && auth_live && registration_calls == 16);
assert(!locked && s == SERVER && ssl_live && auth_live && registration_calls == 17);
++serial_attaches; serial_live = true; return ESP_OK;
}
static esp_err_t web_admin_transport_init(void) { assert(!locked && auth_live && registration_calls == 18); ++admin_inits; return admin_init_error; }
static esp_err_t web_admin_transport_init(void) { assert(!locked && auth_live && registration_calls == 19); ++admin_inits; return admin_init_error; }
static esp_err_t web_admin_transport_attach(httpd_handle_t s) {
assert(!locked && s == SERVER && ssl_live && !admin_owned); ++admin_attaches;
admin_owned = admin_attach_error == ESP_OK; return admin_attach_error;
@@ -531,7 +533,8 @@ int main(void) {
}
puts("PASS optional admin init/attach failures do not disable M1 auth or serial attachment");
reset(); start(); assert(registered_count == 39 && registration_calls == 18 && settings_calls == 1 && operation_calls == 2);
reset(); start(); assert(registered_count == 40 && registration_calls == 19 && settings_calls == 1 && operation_calls == 2);
assert(route("/api/firmware")->method == HTTP_POST && route("/api/firmware")->handler == web_firmware_update_handler);
assert(generation_calls == 1 && route("/api/settings/accounts/generate-password")->handler == web_account_generate_password_handler);
assert(route("/api/settings/serial")->handler == serial_settings_handler);
assert(keys_calls == 1 && route("/api/settings/accounts/keys")->handler == web_account_keys_handler);
@@ -569,23 +572,23 @@ int main(void) {
assert(admin_detaches == 2 && serial_detaches == 1 && admin_stoppeds == 1 && !s_admin_transport_owned);
puts("PASS failed SSL stop retains admin ownership; stopped runs only after successful retry");
for (unsigned failure = 1; failure <= 16; ++failure) {
for (unsigned failure = 1; failure <= 17; ++failure) {
reset(); registration_fail_at = failure;
assert(web_server_start() == ESP_FAIL);
assert(registration_calls == failure && !admin_inits && !admin_attaches && !serial_attaches);
assert(!auth_live && !ssl_live && ssl_stops == 1 && !s_server && !s_admin_transport_owned);
assert(!admin_detaches && !admin_stoppeds && !s_transitioning && s_counters.start_failures == 1);
}
puts("PASS required registration positions 1..16 fail fatally before transport attachment");
puts("PASS required registration positions 1..17 fail fatally before transport attachment");
for (unsigned failure = 17; failure <= 18; ++failure) {
for (unsigned failure = 18; failure <= 19; ++failure) {
reset(); registration_fail_at = failure;
assert(web_server_start() == ESP_OK && registration_calls == failure);
assert(auth_live && ssl_live && serial_live && s_server == SERVER);
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 == 37 && unregister_calls == failure - 17);
assert(registered_count == 38 && unregister_calls == failure - 18);
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 == traced_websocket_handler);
@@ -594,13 +597,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 == 39 && admin_attaches == 1 && s_counters.starts == 2);
assert(registered_count == 40 && 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");
puts("PASS optional positions 18..19 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 == 38);
reset(); registration_fail_at = 19; unregister_fail = true;
assert(web_server_start() == ESP_OK && unregister_calls == 1 && registered_count == 39);
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");
@@ -612,7 +615,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 == 39 && admin_attaches == 1 && web_server_stop() == ESP_OK);
assert(registered_count == 40 && 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;
@@ -634,7 +637,7 @@ int main(void) {
assert(web_server_stop() == ESP_ERR_INVALID_STATE && !auth_stops);
puts("PASS auth/start failure gates and invalid/transitioning lifecycle rejection");
reset(); settings_fail = true; start();
assert(settings_calls == 1 && registered_count == 38);
assert(settings_calls == 1 && registered_count == 39);
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);
@@ -642,7 +645,7 @@ int main(void) {
puts("PASS optional Settings registration failure preserves auth and both transports; restart recovers");
for (unsigned failure = 1; failure <= 2; ++failure) {
reset(); operation_fail_at = failure; start();
assert(registered_count == 37 && operation_calls == failure && unregister_calls == failure - 1);
assert(registered_count == 38 && operation_calls == failure && unregister_calls == failure - 1);
assert(auth_live && serial_live && admin_owned);
for (unsigned i = 0; i < registered_count; ++i) assert(strcmp(registered[i]->uri, "/api/settings/serial-operation"));
assert(web_server_stop() == ESP_OK);
@@ -650,25 +653,27 @@ int main(void) {
puts("PASS optional Serial operation GET/POST failure never publishes a mutation-only route or disables transports");
for (unsigned failure = 1; failure <= 3; ++failure) {
reset(); account_calls = 0; account_fail_at = failure; start();
assert(account_calls == failure && registered_count == (failure == 1 ? 36 : 37));
assert(account_calls == failure && registered_count == (failure == 1 ? 37 : 38));
assert(keys_calls == 1 && route("/api/settings/accounts/keys")->handler == web_account_keys_handler);
assert(generation_calls == 1 && route("/api/settings/accounts/generate-password")->handler == web_account_generate_password_handler);
assert(route("/api/firmware")->method == HTTP_POST && route("/api/firmware")->handler == web_firmware_update_handler);
assert(generation_calls == 1 && route("/api/settings/accounts/generate-password")->handler == web_account_generate_password_handler);
assert(auth_live && serial_live && admin_owned);
for (unsigned i = 0; i < registered_count; ++i)
assert(strcmp(registered[i]->uri, "/api/settings/account-operation"));
assert(web_server_stop() == ESP_OK);
account_fail_at = 0; account_calls = 0; fresh_registration(); start();
assert(registered_count == 39 && account_calls == 3);
assert(registered_count == 40 && account_calls == 3);
assert(web_server_stop() == ESP_OK);
}
reset(); account_calls = 0; account_fail_at = 3; unregister_fail = true; start();
assert(registered_count == 38 && auth_live && serial_live && admin_owned);
assert(registered_count == 39 && auth_live && serial_live && admin_owned);
for (unsigned i = 0; i < registered_count; ++i)
assert(strcmp(registered[i]->uri, "/api/settings/account-operation") || registered[i]->method == HTTP_GET);
assert(web_server_stop() == ESP_OK); account_fail_at = 0;
puts("PASS optional Accounts list/result/mutation allocation failures preserve transports and never expose mutation without reads (including failed unregister)");
reset(); generation_fail = true; start();
assert(generation_calls == 1 && registered_count == 38 && account_calls == 3);
assert(route("/api/firmware")->method == HTTP_POST && route("/api/firmware")->handler == web_firmware_update_handler);
assert(generation_calls == 1 && registered_count == 39 && account_calls == 3);
assert(keys_calls == 1 && route("/api/settings/accounts/keys")->handler == web_account_keys_handler);
assert(!auth_stops && !ssl_stops && !unregister_calls && !s_counters.start_failures);
assert(route("/api/settings/accounts")->handler == web_account_settings_handler);
@@ -680,12 +685,12 @@ int main(void) {
}
assert(account_mutations == 1 && web_server_stop() == ESP_OK);
generation_fail = false; fresh_registration(); start();
assert(generation_calls == 2 && registered_count == 39);
assert(generation_calls == 2 && registered_count == 40);
assert(route("/api/settings/accounts/generate-password")->handler == web_account_generate_password_handler);
assert(web_server_stop() == ESP_OK);
puts("PASS optional password generation allocation failure preserves account routes/auth/transports; restart recovers");
reset(); keys_fail = true; start();
assert(keys_calls == 1 && registered_count == 38 && account_calls == 3 && generation_calls == 1);
assert(keys_calls == 1 && registered_count == 39 && account_calls == 3 && generation_calls == 1);
assert(!auth_stops && !ssl_stops && !unregister_calls && !s_counters.start_failures);
assert(route("/api/settings/accounts")->handler == web_account_settings_handler);
assert(route("/api/settings/accounts/generate-password")->handler == web_account_generate_password_handler);
@@ -700,7 +705,7 @@ int main(void) {
}
assert(account_mutations == 1 && web_server_stop() == ESP_OK);
keys_fail = false; fresh_registration(); start();
assert(keys_calls == 2 && registered_count == 39);
assert(keys_calls == 2 && registered_count == 40);
assert(route("/api/settings/accounts/keys")->handler == web_account_keys_handler);
assert(web_server_stop() == ESP_OK);
puts("PASS optional account keys allocation failure preserves account/generation/auth/transports; restart recovers");
@@ -725,7 +730,7 @@ int main(void) {
reset(); network_fail_at = failure; start();
unsigned failed_route = (failure + 1) / 2;
assert(network_calls == failed_route && network_allocations == failure);
assert(registered_count == (failed_route == 1 ? 36 : 37));
assert(registered_count == (failed_route == 1 ? 37 : 38));
assert(unregister_calls == (failed_route == 3 ? 1 : 0));
assert(!method_route("/api/settings/network-operation", HTTP_GET));
assert(!method_route("/api/settings/network-operation", HTTP_POST));
@@ -733,13 +738,13 @@ int main(void) {
other_domains_complete();
assert(web_server_stop() == ESP_OK);
network_fail_at = 0; fresh_registration(); start();
assert(registered_count == 39); network_complete();
assert(registered_count == 40); network_complete();
assert(web_server_stop() == ESP_OK);
}
puts("PASS all six Network descriptor/name allocation positions isolate failures and recover after restart");
for (unsigned failure = 5; failure <= 6; ++failure) {
reset(); network_fail_at = failure; unregister_fail = true; start();
assert(registered_count == 38 && unregister_calls == 1);
assert(registered_count == 39 && unregister_calls == 1);
assert(route("/api/settings/network")->handler == web_network_snapshot_handler);
assert(method_route("/api/settings/network-operation", HTTP_GET)->handler == web_network_operation_handler);
assert(!method_route("/api/settings/network-operation", HTTP_POST));
@@ -749,7 +754,7 @@ int main(void) {
assert(web_server_start() == ESP_ERR_INVALID_STATE && ssl_starts == 1);
ssl_stop_error = ESP_OK; assert(web_server_stop() == ESP_OK);
unregister_fail = false; network_fail_at = 0; fresh_registration(); start();
assert(registered_count == 39); network_complete();
assert(registered_count == 40); network_complete();
assert(web_server_stop() == ESP_OK);
}
puts("PASS failed Network result unregister leaves reads only and preserves stop-failure ownership/restart");
@@ -757,7 +762,7 @@ int main(void) {
reset(); display_fail_at = failure; start();
unsigned failed_route = (failure + 1) / 2;
assert(display_calls == failed_route && display_allocations == failure);
assert(registered_count == (failed_route == 1 ? 36 : 37));
assert(registered_count == (failed_route == 1 ? 37 : 38));
assert(unregister_calls == (failed_route == 3 ? 1 : 0));
assert(!method_route("/api/settings/display-operation", HTTP_GET));
assert(!method_route("/api/settings/display-operation", HTTP_POST));
@@ -765,13 +770,13 @@ int main(void) {
other_domains_complete(); network_complete();
assert(web_server_stop() == ESP_OK);
display_fail_at = 0; fresh_registration(); start();
assert(registered_count == 39); display_complete();
assert(registered_count == 40); display_complete();
assert(web_server_stop() == ESP_OK);
}
puts("PASS all six Display descriptor/name allocation positions isolate failures and recover after restart");
for (unsigned failure = 5; failure <= 6; ++failure) {
reset(); display_fail_at = failure; unregister_fail = true; start();
assert(registered_count == 38 && unregister_calls == 1);
assert(registered_count == 39 && unregister_calls == 1);
assert(route("/api/settings/display")->handler == web_display_settings_handler);
assert(method_route("/api/settings/display-operation", HTTP_GET)->handler == web_display_operation_handler);
assert(!method_route("/api/settings/display-operation", HTTP_POST));
@@ -781,7 +786,7 @@ int main(void) {
assert(web_server_start() == ESP_ERR_INVALID_STATE && ssl_starts == 1);
ssl_stop_error = ESP_OK; assert(web_server_stop() == ESP_OK);
unregister_fail = false; display_fail_at = 0; fresh_registration(); start();
assert(registered_count == 39); display_complete();
assert(registered_count == 40); display_complete();
assert(web_server_stop() == ESP_OK);
}
puts("PASS failed Display result unregister leaves reads only and preserves stop-failure ownership/restart");
@@ -789,7 +794,7 @@ int main(void) {
reset(); broker_fail_at = failure; start();
unsigned failed_route = (failure + 1) / 2;
assert(broker_calls == failed_route && broker_allocations == failure);
assert(registered_count == (failed_route == 1 ? 36 : 37));
assert(registered_count == (failed_route == 1 ? 37 : 38));
assert(unregister_calls == (failed_route == 3 ? 1 : 0));
assert(!method_route("/api/settings/broker-operation", HTTP_GET));
assert(!method_route("/api/settings/broker-operation", HTTP_POST));
@@ -797,13 +802,13 @@ int main(void) {
other_domains_complete(); network_complete(); display_complete();
assert(web_server_stop() == ESP_OK);
broker_fail_at = 0; fresh_registration(); start();
assert(registered_count == 39); broker_complete();
assert(registered_count == 40); broker_complete();
assert(web_server_stop() == ESP_OK);
}
puts("PASS all six Broker descriptor/name allocation positions isolate failures and recover after restart");
for (unsigned failure = 5; failure <= 6; ++failure) {
reset(); broker_fail_at = failure; unregister_fail = true; start();
assert(registered_count == 38 && unregister_calls == 1);
assert(registered_count == 39 && unregister_calls == 1);
assert(route("/api/settings/broker")->handler == web_broker_settings_handler);
assert(method_route("/api/settings/broker-operation", HTTP_GET)->handler == web_broker_operation_handler);
assert(!method_route("/api/settings/broker-operation", HTTP_POST));
@@ -813,7 +818,7 @@ int main(void) {
assert(web_server_start() == ESP_ERR_INVALID_STATE && ssl_starts == 1);
ssl_stop_error = ESP_OK; assert(web_server_stop() == ESP_OK);
unregister_fail = false; broker_fail_at = 0; fresh_registration(); start();
assert(registered_count == 39); broker_complete();
assert(registered_count == 40); broker_complete();
assert(web_server_stop() == ESP_OK);
}
puts("PASS failed Broker result unregister leaves reads only and preserves stop-failure ownership/restart");
@@ -821,7 +826,7 @@ int main(void) {
reset(); ssh_fail_at = failure; start();
unsigned failed_route = (failure + 1) / 2;
assert(ssh_calls == failed_route && ssh_allocations == failure);
assert(registered_count == (failed_route == 1 ? 36 : 37));
assert(registered_count == (failed_route == 1 ? 37 : 38));
assert(unregister_calls == (failed_route == 3 ? 1 : 0));
assert(!method_route("/api/settings/ssh-operation", HTTP_GET));
assert(!method_route("/api/settings/ssh-operation", HTTP_POST));
@@ -829,13 +834,13 @@ int main(void) {
other_domains_complete(); network_complete(); display_complete(); broker_complete();
assert(web_server_stop() == ESP_OK);
ssh_fail_at = 0; fresh_registration(); start();
assert(registered_count == 39); ssh_complete();
assert(registered_count == 40); ssh_complete();
assert(web_server_stop() == ESP_OK);
}
puts("PASS all six SSH descriptor/name allocation positions isolate failures and recover after restart");
for (unsigned failure = 5; failure <= 6; ++failure) {
reset(); ssh_fail_at = failure; unregister_fail = true; start();
assert(registered_count == 38 && unregister_calls == 1);
assert(registered_count == 39 && unregister_calls == 1);
assert(route("/api/settings/ssh")->handler == web_ssh_settings_handler);
assert(method_route("/api/settings/ssh-operation", HTTP_GET)->handler == web_ssh_operation_handler);
assert(!method_route("/api/settings/ssh-operation", HTTP_POST));
@@ -845,7 +850,7 @@ int main(void) {
assert(web_server_start() == ESP_ERR_INVALID_STATE && ssl_starts == 1);
ssl_stop_error = ESP_OK; assert(web_server_stop() == ESP_OK);
unregister_fail = false; ssh_fail_at = 0; fresh_registration(); start();
assert(registered_count == 39); ssh_complete();
assert(registered_count == 40); ssh_complete();
assert(web_server_stop() == ESP_OK);
}
puts("PASS failed SSH result unregister leaves reads only and preserves stop-failure ownership/restart");
@@ -927,7 +932,7 @@ static void management_tests(void) {
assert(reserved_gaps == 1 && !unlock_hook && !strcmp(events, "ADSHR"));
assert(s_generation == original + 2 && s_server == SERVER && !s_transitioning);
assert(auth_live && ssl_live && admin_owned && serial_live && idle_owned);
assert(ssl_starts == 2 && ssl_stops == 1 && serial_inits == 1 && registered_count == 39);
assert(ssl_starts == 2 && ssl_stops == 1 && serial_inits == 1 && registered_count == 40);
assert(web_server_stop_current(original) == ESP_ERR_INVALID_STATE);
assert(web_server_stop_current(s_generation) == ESP_OK);
puts("PASS conditional restart reserves stop-to-start gap against canonical and conditional callers");
+3
View File
@@ -241,6 +241,9 @@ int main(void) {
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);
reboot_busy = true;
assert(owner_perform(&s_slot.token, ADMIN_SSH_DEFER_REBOOT, 0) == ESP_ERR_INVALID_STATE && !reboots);
reboot_busy = false;
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");
+13 -1
View File
@@ -3,6 +3,7 @@
#include <sys/socket.h>
#include "admin_ssh_console.h"
#include "web_admin_tickets.h"
#include "web_firmware_update.h"
#include "esp_timer.h"
#include "esp_heap_caps.h"
#include "freertos/task.h"
@@ -14,7 +15,13 @@ 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. */
static bool reboot_reservation_expected;
esp_err_t web_firmware_update_reserve_reboot(void) {
assert(reboot_reservation_expected);
reboot_reservation_expected = false;
return ESP_ERR_INVALID_STATE;
}
/* A rejected reboot reservation must never reach reset or other lifecycle effects. */
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; }
@@ -131,6 +138,11 @@ static void admin_tests(void) {
assert(web_admin_transport_upgrade_handler(&req) == ESP_OK && upgrades == ++before);
connected = req; assert(console_active && admin_owner->is_current(&admin_token, &administrator));
if (mode == 0) {
reboot_reservation_expected = true;
assert(admin_owner->perform(&admin_token, ADMIN_SSH_DEFER_REBOOT, 0) == ESP_ERR_INVALID_STATE);
assert(!reboot_reservation_expected && console_active && !admin_closes);
assert(admin_owner->is_current(&admin_token, &administrator));
puts("PASS: live admin deferred reboot propagates firmware reservation refusal without reset or session closure");
admin_request(&admin, "/api/logout", true, true, true); expect("204 No Content");
assert(!console_active); present(&other);
} else if (mode == 1) stale_user = administrator.user_id;
+93
View File
@@ -0,0 +1,93 @@
# Firmware upload backend host tests
Run from the repository root:
```sh
python3 tests/web_firmware_update/run.py
python3 tests/web_cookie_auth/run.py
```
The first compiles the unchanged production upload source, production auth
admission helpers/parser, and production server upload reservation/typed reboot
functions with `-Wall -Wextra -Werror` and trapping undefined-behavior checks.
It uses installed ESP-IDF image-layout headers (`IDF_PATH`, otherwise the normal
PlatformIO framework directory). It performs no firmware build or device action.
HTTP IO, private header-adapter admission, database, OTA/flash and FreeRTOS
scheduling are doubles. The existing cookie-auth suite separately exercises
production private-adapter framing/duplicate-header handling and session storage.
The 61 cases cover:
- Cookie, Origin, CSRF, admin, query/method and readiness rejection before reads.
- Raw content type, nonempty body, actual partition capacity and inactive OTA app
selection; bytewise partial image prefix and bounded/exact streaming writes.
- Wrong magic/chip/segment/descriptor/hash-header rejection before OTA erase.
- Service/identity busy, allocation/task creation and OTA begin/write/end/metadata/
boot-selection failures; handle consumption versus abort cleanup.
- Parsed SDK image length mismatch, final session revocation/currentness failure.
- EOF before/after OTA begin, stalled receive and total slow-drip deadlines.
- Reservation rejection of competing upload/typed reboot, including during
receive and boot selection; response failure at every response stage.
- Successful commit followed by delayed reboot-owner action; failed response
retains selection but does not reboot and releases reservations/resources.
These are deterministic failure-injection tests, not real SDK image-integrity,
flash, concurrent scheduler, TLS, network, power-loss or hardware test evidence.
Stop/start/rotation guard use is source-checked, not dynamically executed here.
## Parent integration contract
`POST /api/firmware`, raw `application/octet-stream`, known `Content-Length`,
existing cookie + same-origin `Origin` + `X-CSRF-Token`, admin only. Use the
existing browser CORS-mode/same-origin-credentials request pattern. No multipart,
chunked transfer, `Expect`, query parameters, signature/version policy or filename
validation. Standard ESP32-S3 application image with appended SDK SHA-256 digest
is required. A filename alone is never trusted.
`200 {"ok":true,"rebooting":true}` means SDK validation and boot selection
succeeded. After synchronous send success, the preallocated owner task waits
500 ms and calls `esp_restart()` without stopping HTTPD or retaining a request
or socket. It keeps service/identity reservations until reset. Send success is
not peer receipt. Send failure after selection schedules **no reboot**, releases
reservations, and leaves the accepted image selected for a later reset. A lost
acknowledgement is therefore uncertain; never automatically retry.
All errors use `{"error":"code"}`:
| HTTP | Codes |
| --- | --- |
| 400 | `invalid_request`, `invalid_firmware`, `firmware_incomplete` |
| 401 | `authentication_required` |
| 403 | `origin`, `csrf`, `admin_required` |
| 408 | `firmware_timeout` |
| 413 | `firmware_too_large` |
| 415 | `firmware_content_type` |
| 500 | `firmware_write_failed`, `firmware_commit_failed` |
| 503 | `unavailable`, `busy`, `firmware_unavailable`, `firmware_resources` |
Rejections with unread bodies close rather than asking HTTPD to discard the
remaining upload. Responses are JSON, no-store, nosniff and no-referrer. Errors
contain no SDK diagnostic text or secret material. A boot-selection API failure
is not retried or followed by reboot; flash/power failures at metadata commit
cannot offer transactional certainty beyond SDK guarantees.
The synchronous handler may block other HTTPD work for the upload duration.
A 4 KiB heap buffer and a 2 KiB reboot-owner stack are prepared before flashing;
no whole-image allocation. Receive checks enforce 10 seconds without progress
and 120 seconds total, using the server's existing one-second receive timeout.
These checks do not preempt SDK flash/validation calls or scheduler delays.
Only the inactive app and SDK OTA metadata are written, not NVS/data partitions.
### Integration work outside backend ownership
- Server URI capacity is now **40**; `server_lifecycle.py` currently fails its
hardcoded 36-initializer extraction check (now 37). Its old 39-handler capacity,
required-registration positions 116 and optional positions 1718 also need
adjustment to 40, 117 and 1819, respectively, plus a firmware-handler fake.
That existing fixture is intentionally not edited here.
- HTTPS stop/start/rotation and typed reboot share the upload reservation; direct
identity mutation is separately reserved. **Direct `esp_restart()` callers in
`system_console.c`, `ssh_transport.c`, `web_admin_transport.c`, and
`local_status_ui.c` do not share this fence.** Those files were outside the
backend's allowed ownership. The parent must coordinate those reboot paths
for global software-reboot exclusion; do not claim that property yet.
+96
View File
@@ -0,0 +1,96 @@
/* SPDX-License-Identifier: GPL-3.0-only */
#pragma once
#include <assert.h>
#include <stdbool.h>
#include <stddef.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <strings.h>
typedef int esp_err_t;
#define ESP_OK 0
#define ESP_FAIL -1
#define ESP_ERR_NOT_FOUND 2
#define ESP_ERR_INVALID_STATE 3
#define ESP_ERR_TIMEOUT 4
#define ESP_ERR_INVALID_ARG 5
#define ESP_ERR_NO_MEM 6
#define ESP_ERR_INVALID_SIZE 7
#define ESP_ERR_HTTPD_INVALID_REQ 8
#define ESP_ERR_HTTPD_RESULT_TRUNC 9
#define HTTPD_SOCK_ERR_TIMEOUT -3
#define HTTP_POST 1
#define HTTP_GET 0
#define USER_ROLE_ADMIN 1
#define USER_ROLE_USER 0
#define pdPASS 1
#define pdTRUE 1
#define portMAX_DELAY UINT32_MAX
#define pdMS_TO_TICKS(n) (n)
#define taskENTER_CRITICAL(p) ((void)(p))
#define taskEXIT_CRITICAL(p) ((void)(p))
typedef void *httpd_handle_t;
typedef struct { const char *name, *value; } test_header_t;
struct httpd_req_aux { char *scratch; size_t scratch_cur_size; unsigned req_hdrs_count; };
typedef struct {
const char *uri;
int method;
size_t content_len, received;
httpd_handle_t handle;
struct httpd_req_aux *aux;
test_header_t headers[12];
size_t header_count;
bool headers_valid;
} httpd_req_t;
typedef struct { int role; uint32_t user_id; } user_principal_t;
typedef uint64_t web_session_id_t;
typedef struct { web_session_id_t id; user_principal_t principal; char csrf[65]; } web_session_view_t;
typedef struct {
uint32_t security_rejections, login_failures, throttled, capacity_rejections;
} web_cookie_auth_snapshot_t;
typedef void *TaskHandle_t;
typedef void *SemaphoreHandle_t;
#define eSetValueWithOverwrite 1
int xTaskCreate(void (*)(void *), const char *, unsigned, void *, unsigned, TaskHandle_t *);
int xTaskNotify(TaskHandle_t, uint32_t, int);
int xTaskNotifyWait(uint32_t, uint32_t, uint32_t *, uint32_t);
void vTaskDelay(unsigned);
void vTaskDelete(TaskHandle_t);
int xSemaphoreTake(SemaphoreHandle_t, unsigned);
void xSemaphoreGive(SemaphoreHandle_t);
void esp_restart(void);
int64_t esp_timer_get_time(void);
void secure_wipe(void *, size_t);
size_t httpd_req_get_hdr_value_len(httpd_req_t *, const char *);
esp_err_t httpd_req_get_hdr_value_str(httpd_req_t *, const char *, char *, size_t);
esp_err_t httpd_resp_set_status(httpd_req_t *, const char *);
esp_err_t httpd_resp_set_type(httpd_req_t *, const char *);
esp_err_t httpd_resp_set_hdr(httpd_req_t *, const char *, const char *);
esp_err_t httpd_resp_sendstr(httpd_req_t *, const char *);
int httpd_req_recv(httpd_req_t *, char *, size_t);
bool web_httpd_headers_valid(httpd_req_t *);
bool web_httpd_unread_body(httpd_req_t *);
void web_httpd_wipe_request(httpd_req_t *, bool);
esp_err_t web_session_store_lookup(const char *, size_t, const char *, size_t, web_session_view_t *);
esp_err_t web_session_store_check_principal(web_session_id_t, const user_principal_t *, bool *);
esp_err_t web_cookie_auth_require_body(httpd_req_t *, size_t, web_session_view_t *, bool *);
esp_err_t web_security_reserve_identity(uint32_t, bool, uint32_t *);
void web_security_release_identity(uint32_t);
typedef uint32_t esp_ota_handle_t;
typedef struct { uint32_t type, subtype, address, size, erase_size; } esp_partition_t;
#define ESP_PARTITION_TYPE_APP 0
#define ESP_PARTITION_SUBTYPE_APP_OTA_0 0x10
#define ESP_PARTITION_SUBTYPE_APP_OTA_15 0x1f
const esp_partition_t *esp_ota_get_running_partition(void);
const esp_partition_t *esp_ota_get_next_update_partition(const esp_partition_t *);
esp_err_t esp_ota_begin(const esp_partition_t *, size_t, esp_ota_handle_t *);
esp_err_t esp_ota_write(esp_ota_handle_t, const void *, size_t);
esp_err_t esp_ota_end(esp_ota_handle_t);
esp_err_t esp_ota_abort(esp_ota_handle_t);
esp_err_t esp_ota_set_boot_partition(const esp_partition_t *);
+110
View File
@@ -0,0 +1,110 @@
#!/usr/bin/env python3
"""Host failure tests: real upload/auth/header admission and server reservation.
The backend uses OTA/HTTP IO/task/database doubles; a separate contract test
executes pinned SDK begin/abort with injected flash/allocation failures. SDK
headers/getters come only from verified IDF 5.5.0. No build or device operations.
"""
import argparse
import os
from pathlib import Path
import re
import shutil
import subprocess
import tempfile
HERE = Path(__file__).resolve().parent
ROOT = HERE.parents[1]
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--idf-path", type=Path, help="Explicit ESP-IDF 5.5.0 source directory")
args = parser.parse_args()
if args.idf_path:
IDF = args.idf_path.expanduser().resolve()
else:
# Only this project's active build tree; never candidate builds or the
# unversioned PlatformIO package, which may now contain another SDK.
paths = set()
for cache in (ROOT / ".pio/build").glob("*/CMakeCache.txt"):
match = re.search(r"^esp-idf_SOURCE_DIR:[^=]+=(.+)$", cache.read_text(), re.M)
if match:
paths.add(Path(match.group(1)).resolve())
if len(paths) != 1:
parser.error("Cannot identify one active build SDK; pass --idf-path for ESP-IDF 5.5.0")
IDF = paths.pop()
try:
version_header = (IDF / "components/esp_common/include/esp_idf_version.h").read_text()
version = tuple(int(re.search(r"^#define ESP_IDF_VERSION_" + part + r"\s+(\d+)\s*$",
version_header, re.M).group(1))
for part in ("MAJOR", "MINOR", "PATCH"))
except (OSError, AttributeError) as error:
parser.error(f"Cannot verify SDK version at {IDF}: {error}")
if version != (5, 5, 0):
parser.error(f"ESP-IDF 5.5.0 required, found {'.'.join(map(str, version))} at {IDF}")
print(f"Using ESP-IDF 5.5.0: {IDF}", flush=True)
def function(source, name):
match = re.search(r"^(?:static )?(?:bool|void|size_t|esp_err_t|ota_ops_entry_t\s*\*)\s*" + name + r"\([^;{}]*\)\n\{.*?^\}", source, re.M | re.S)
if not match:
raise RuntimeError("Production function shape changed: " + name)
return match.group() + "\n"
with tempfile.TemporaryDirectory(prefix="web-firmware-update-") as directory:
tmp = Path(directory)
shutil.copy(HERE / "fakes.h", tmp / "fakes.h")
shutil.copy(HERE / "test.c", tmp / "test.c")
for name in ("esp_http_server.h", "esp_err.h", "esp_ota_ops.h", "esp_system.h",
"esp_timer.h", "secure_random.h", "web_cookie_auth.h", "web_httpd_adapter.h",
"web_security.h", "freertos/FreeRTOS.h", "freertos/task.h"):
path = tmp / name
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text('#pragma once\n#include "fakes.h"\n')
(tmp / "esp_assert.h").write_text('#define ESP_STATIC_ASSERT(c,m) _Static_assert(c,m)\n')
(tmp / "esp_attr.h").write_text('#define FORCE_INLINE_ATTR static inline\n')
(tmp / "esp_flash_partitions.h").write_text('#pragma once\n#include <stdint.h>\ntypedef struct { uint32_t offset, size; } esp_partition_pos_t;\n')
for component, name in (("bootloader_support", "esp_app_format.h"),
("bootloader_support", "esp_image_format.h"),
("esp_app_format", "esp_app_desc.h")):
shutil.copy(IDF / "components" / component / "include" / name, tmp / name)
for name in ("web_firmware_update.c", "web_firmware_update.h", "web_auth_parse.c", "web_auth_parse.h"):
shutil.copy(ROOT / "src" / name, tmp / name)
auth = (ROOT / "src/web_cookie_auth.c").read_text()
names = ("equal", "header", "origin", "cookie", "cookies_valid", "response", "failure",
"require", "web_cookie_auth_require_body")
constants = '\n'.join(line for line in auth.splitlines() if line.startswith(("#define SESSION_COOKIE", "#define PRELOGIN_COOKIE")))
(tmp / "auth_production.h").write_text(constants + "\n" + '\n'.join(function(auth, n) for n in names))
httpd = (IDF / "components/esp_http_server/src/httpd_parse.c").read_text()
adapter = (ROOT / "src/web_httpd_adapter.c").read_text()
(tmp / "httpd_production.h").write_text(
'\n'.join(function(httpd, n) for n in ("httpd_req_get_hdr_value_len", "httpd_req_get_hdr_value_str")) +
'\n#define web_httpd_headers_valid adapter_headers_valid\n' +
function(adapter, "web_httpd_headers_valid") + '\n#undef web_httpd_headers_valid\n')
sdk_ota = (IDF / "components/app_update/esp_ota_ops.c").read_text()
# Extract the actual SDK registry/type and begin/abort implementations, not
# a reimplementation of their ordering. Flash and allocation are injected.
registry = sdk_ota[sdk_ota.index('typedef struct ota_ops_entry_'):
sdk_ota.index('const static char *TAG')]
(tmp / "sdk_ota_production.h").write_text(registry + '\n' + '\n'.join(
function(sdk_ota, n) for n in ("is_ota_partition", "esp_ota_init_entry", "esp_ota_begin",
"get_ota_ops_entry", "esp_ota_abort")))
shutil.copy(HERE / "sdk_contract.c", tmp / "sdk_contract.c")
server = (ROOT / "src/web_server.c").read_text()
(tmp / "server_production.h").write_text('\n'.join(function(server, n) for n in
("web_firmware_update_reserve", "web_firmware_update_release", "web_server_reboot_current")))
# Review guards used by the same reservation. Full lifecycle harness is owned
# elsewhere; its hardcoded URI count must change from 39 to 40 upstream.
assert "s_transitioning != reserved" in function(server, "stop_server")
assert "s_transitioning != reserved" in function(server, "start_server")
assert "if (s_transitioning ||" in function(server, "web_server_replace_identity")
assert '.handler = web_firmware_update_handler' in server
assert '&s_firmware_uri,' in server
compiler = os.environ.get("CC", "cc")
command = [compiler, "-std=c11", "-Wall", "-Wextra", "-Werror", "-g",
"-fsanitize=undefined", "-fsanitize-undefined-trap-on-error", "-I", str(tmp),
str(tmp / "test.c"), str(tmp / "web_auth_parse.c"), "-o", str(tmp / "test")]
subprocess.run(command, check=True, timeout=30)
subprocess.run([str(tmp / "test")], check=True, timeout=30)
command = command[:command.index(str(tmp / "test.c"))] + [str(tmp / "sdk_contract.c"), "-o", str(tmp / "sdk_contract")]
subprocess.run(command, check=True, timeout=30)
subprocess.run([str(tmp / "sdk_contract")], check=True, timeout=30)
+69
View File
@@ -0,0 +1,69 @@
/* SPDX-License-Identifier: GPL-3.0-only */
/* Execute pinned SDK begin/abort and its real linked registry with injected
* allocation/erase failures. Not a flash or firmware-build test. */
#include "fakes.h"
#include <sys/queue.h>
#define WORD_ALIGNED_ATTR
#define ALIGN_UP(num, align) (((num) + ((align) - 1)) & ~((align) - 1))
#define ESP_PARTITION_SUBTYPE_APP_OTA_MAX 0x20
#define ESP_PARTITION_TYPE_BOOTLOADER 2
#define ESP_PARTITION_TYPE_PARTITION_TABLE 3
#define ESP_ERR_OTA_PARTITION_CONFLICT 10
#define OTA_WITH_SEQUENTIAL_WRITES 0xfffffffeU
#define OTA_SIZE_UNKNOWN 0xffffffffU
static const esp_partition_t running = {.type = ESP_PARTITION_TYPE_APP,
.subtype = ESP_PARTITION_SUBTYPE_APP_OTA_0, .erase_size = 4096, .size = 0x400000};
static const esp_partition_t target = {.type = ESP_PARTITION_TYPE_APP,
.subtype = ESP_PARTITION_SUBTYPE_APP_OTA_0 + 1, .erase_size = 4096, .size = 0x400000};
static bool fail_allocation, fail_erase;
static unsigned live_allocations, erase_calls;
static const esp_partition_t *esp_partition_verify(const esp_partition_t *p) { return p; }
const esp_partition_t *esp_ota_get_running_partition(void) { return &running; }
static void *sdk_calloc(size_t n, size_t size)
{
if (fail_allocation) return NULL;
void *p = calloc(n, size); assert(p); ++live_allocations; return p;
}
static void sdk_free(void *p) { assert(p && live_allocations); --live_allocations; free(p); }
static esp_err_t esp_partition_erase_range(const esp_partition_t *p, size_t offset, size_t size)
{
/* The actual registry allocation is still live when erase fails. */
assert(p == &target && !offset && size == 12288 && live_allocations == 1);
++erase_calls; return fail_erase ? ESP_FAIL : ESP_OK;
}
static void esp_image_bootloader_offset_set(uint32_t offset) { (void)offset; assert(0); }
static void *esp_flash_default_chip;
static void esp_flash_set_dangerous_write_protection(void *chip, bool enabled)
{
(void)chip; (void)enabled; assert(0);
}
#define calloc sdk_calloc
#define free sdk_free
#include "sdk_ota_production.h"
#undef calloc
#undef free
int main(void)
{
esp_ota_handle_t handle = 0;
assert(esp_ota_begin(NULL, 12000, &handle) == ESP_ERR_INVALID_ARG && !handle);
assert(esp_ota_begin(&running, 12000, &handle) == ESP_ERR_OTA_PARTITION_CONFLICT && !handle);
fail_allocation = true;
assert(esp_ota_begin(&target, 12000, &handle) == ESP_ERR_NO_MEM && !handle);
assert(!live_allocations && !erase_calls && LIST_EMPTY(&s_ota_ops_entries_head));
fail_allocation = false; fail_erase = true;
for (unsigned i = 0; i < 3; ++i) {
handle = 0;
assert(esp_ota_begin(&target, 12000, &handle) == ESP_FAIL && handle);
assert(get_ota_ops_entry(handle) && live_allocations == 1);
assert(esp_ota_abort(handle) == ESP_OK);
assert(!live_allocations && LIST_EMPTY(&s_ota_ops_entries_head));
assert(esp_ota_abort(handle) == ESP_ERR_NOT_FOUND);
}
fail_erase = false; handle = 0;
assert(esp_ota_begin(&target, 12000, &handle) == ESP_OK && handle && live_allocations == 1);
assert(esp_ota_abort(handle) == ESP_OK && !live_allocations);
puts("PASS actual ESP-IDF 5.5.0 begin/init/registry/abort: early failure has no handle; erase failure publishes live handle; abort frees it exactly once");
return 0;
}
+452
View File
@@ -0,0 +1,452 @@
/* SPDX-License-Identifier: GPL-3.0-only */
#include "fakes.h"
#include "web_auth_parse.h"
#include "esp_app_format.h"
#include "esp_app_desc.h"
#include "esp_image_format.h"
#include "web_firmware_update.h"
static bool httpd_valid_req(httpd_req_t *r) { return r && r->aux; }
static size_t strlcpy(char *out, const char *in, size_t size)
{
size_t length = strlen(in);
if (size) { size_t n = length < size - 1 ? length : size - 1; memcpy(out, in, n); out[n] = 0; }
return length;
}
/* IDF getters compare ptrdiff_t with size_t; retain their exact source. */
#pragma GCC diagnostic push
#pragma GCC diagnostic ignored "-Wsign-compare"
#include "httpd_production.h"
#pragma GCC diagnostic pop
static int s_lock;
static bool s_ready;
static web_cookie_auth_snapshot_t s_counts;
#include "auth_production.h"
static SemaphoreHandle_t s_server_mutex;
static httpd_handle_t s_server;
static bool s_transitioning;
static esp_err_t s_last_error;
static uint32_t s_generation;
#include "server_production.h"
static void *test_malloc(size_t);
static void test_free(void *);
#define malloc test_malloc
#define free test_free
#include "web_firmware_update.c"
#undef malloc
#undef free
static uint8_t image[12000], flash[12000];
static httpd_req_t request;
static esp_partition_t running, target;
static bool missing_target, missing_running;
static bool lookup_current, final_current, lookup_unavailable, final_unavailable;
static int role, stage_fail, response_fail, response_step;
static bool malloc_fail, task_fail, identity_busy, locked, mutex_busy;
static unsigned allocations, allocations_live, tasks_created, tasks_deleted;
static unsigned begins, writes, ends, aborts, metadata_reads, commits, receives, wipes;
static unsigned restarts, response_sends, releases, identity_releases;
static size_t flash_size, max_chunk, cutoff, stall_at, parsed_size;
static int64_t now_us, read_us;
static char response_status[64], response_body[160];
static uint32_t notification, identity_token;
static void (*task_entry)(void *);
static int task_storage, server_storage, mutex_storage;
static bool receive_race, commit_race, revoke_at_metadata;
static unsigned cases;
static bool ota_live;
static char raw_length[32], header_scratch[2048];
static struct httpd_req_aux request_aux;
static void *test_malloc(size_t size)
{
assert(size == BUFFER_SIZE); ++allocations;
if (malloc_fail) return NULL;
++allocations_live; return malloc(size);
}
static void test_free(void *p) { if (p) { assert(allocations_live == 1); --allocations_live; } free(p); }
void secure_wipe(void *p, size_t n) { memset(p, 0, n); }
int64_t esp_timer_get_time(void) { return now_us; }
int xSemaphoreTake(SemaphoreHandle_t m, unsigned ticks)
{
assert(m == &mutex_storage && !locked);
if (mutex_busy) { assert(ticks == 0); return 0; }
locked = true; return pdTRUE;
}
void xSemaphoreGive(SemaphoreHandle_t m) { assert(m == &mutex_storage && locked); locked = false; }
int xTaskCreate(void (*entry)(void *), const char *name, unsigned stack, void *arg,
unsigned priority, TaskHandle_t *out)
{
assert(!strcmp(name, "fw_reboot") && stack == 2048 && !arg && priority == 5);
assert(s_transitioning && identity_token && !commits && !task_entry);
if (task_fail) return 0;
task_entry = entry; *out = &task_storage; ++tasks_created; return pdPASS;
}
int xTaskNotify(TaskHandle_t t, uint32_t value, int action)
{
assert(t == &task_storage && task_entry && !notification && action == eSetValueWithOverwrite);
assert(value == 1 || value == 2);
if (value == 1) assert(commits == 1 && response_sends == 1 && !response_fail && !aborts);
notification = value; return pdPASS;
}
int xTaskNotifyWait(uint32_t clear_in, uint32_t clear_out, uint32_t *value, uint32_t wait)
{
assert(!clear_in && clear_out == UINT32_MAX && wait == portMAX_DELAY && notification);
*value = notification; return pdTRUE;
}
void vTaskDelay(unsigned ticks) { assert(ticks == 500 && notification == 1); }
void vTaskDelete(TaskHandle_t task) { assert(!task); ++tasks_deleted; }
void esp_restart(void) { assert(!locked); ++restarts; }
static void finish_owner(void)
{
if (task_entry) {
assert(notification); task_entry(NULL); task_entry = NULL;
assert(tasks_deleted == tasks_created);
}
}
static void set_header(const char *name, const char *value)
{
for (size_t i = 0; i < request.header_count; ++i) {
if (!strcmp(name, request.headers[i].name)) { request.headers[i].value = value; return; }
}
assert(request.header_count < 12);
request.headers[request.header_count++] = (test_header_t){name, value};
}
static void set_length(size_t length)
{
request.content_len = length;
snprintf(raw_length, sizeof(raw_length), "%zu", length);
set_header("Content-Length", raw_length);
}
bool web_httpd_headers_valid(httpd_req_t *r)
{
request_aux = (struct httpd_req_aux){.scratch = header_scratch};
for (size_t i = 0; i < r->header_count; ++i) {
if (!r->headers[i].value) continue;
size_t used = request_aux.scratch_cur_size;
int written = snprintf(header_scratch + used, sizeof(header_scratch) - used,
"%s:%s", r->headers[i].name, r->headers[i].value);
assert(written >= 0 && (size_t)written + 1 <= sizeof(header_scratch) - used);
request_aux.scratch_cur_size += (size_t)written + 1;
++request_aux.req_hdrs_count;
}
r->aux = &request_aux;
return r->headers_valid && adapter_headers_valid(r);
}
bool web_httpd_unread_body(httpd_req_t *r) { return r->received < r->content_len; }
void web_httpd_wipe_request(httpd_req_t *r, bool unread)
{
assert(unread == (r->received < r->content_len)); ++wipes;
}
static esp_err_t response_result(void) { return ++response_step == response_fail ? ESP_FAIL : ESP_OK; }
esp_err_t httpd_resp_set_status(httpd_req_t *r, const char *s)
{
assert(r == &request && strlen(s) < sizeof(response_status)); strcpy(response_status, s);
return response_result();
}
esp_err_t httpd_resp_set_type(httpd_req_t *r, const char *s)
{
assert(r == &request && !strcmp(s, "application/json; charset=utf-8")); return response_result();
}
esp_err_t httpd_resp_set_hdr(httpd_req_t *r, const char *name, const char *value)
{
assert(r == &request && name && value); return response_result();
}
esp_err_t httpd_resp_sendstr(httpd_req_t *r, const char *body)
{
assert(r == &request && strlen(body) < sizeof(response_body));
++response_sends; strcpy(response_body, body); return response_result();
}
static void competing_lifecycle(void)
{
assert(s_transitioning && !locked);
assert(web_firmware_update_reserve(request.handle) == ESP_ERR_INVALID_STATE);
assert(web_server_reboot_current(s_generation) == ESP_ERR_INVALID_STATE);
assert(!restarts);
uint32_t token = 0;
assert(web_security_reserve_identity(0, false, &token) == ESP_ERR_INVALID_STATE && !token);
}
int httpd_req_recv(httpd_req_t *r, char *out, size_t want)
{
assert(r == &request && want && want <= BUFFER_SIZE && s_transitioning && identity_token);
assert(web_firmware_update_reserve_reboot() == ESP_ERR_INVALID_STATE);
++receives; now_us += read_us;
if (receive_race) competing_lifecycle();
if (r->received >= stall_at) return HTTPD_SOCK_ERR_TIMEOUT;
if (r->received >= cutoff) return 0;
size_t count = want < max_chunk ? want : max_chunk;
if (count > cutoff - r->received) count = cutoff - r->received;
assert(r->received + count <= sizeof(image));
memcpy(out, image + r->received, count); r->received += count; return (int)count;
}
esp_err_t web_session_store_lookup(const char *token, size_t length, const char *origin,
size_t origin_length, web_session_view_t *view)
{
assert(length == 64 && token[0] == 'a' && origin_length == strlen(origin));
assert(!strcmp(origin, "https://device"));
if (lookup_unavailable) return ESP_FAIL;
if (!lookup_current) return ESP_ERR_NOT_FOUND;
view->id = 42; view->principal.role = role; view->principal.user_id = 9;
memset(view->csrf, 'b', 64); view->csrf[64] = 0; return ESP_OK;
}
esp_err_t web_session_store_check_principal(web_session_id_t id, const user_principal_t *principal, bool *current)
{
assert(id == 42 && principal->role == USER_ROLE_ADMIN && principal->user_id == 9);
assert(ends == 1 && metadata_reads == 1 && !commits);
*current = final_current; return final_unavailable ? ESP_FAIL : ESP_OK;
}
esp_err_t web_security_reserve_identity(uint32_t expected, bool reset, uint32_t *token)
{
assert(!expected && !reset && s_transitioning); *token = 0;
if (identity_busy || identity_token) return ESP_ERR_INVALID_STATE;
*token = identity_token = 7; return ESP_OK;
}
void web_security_release_identity(uint32_t token)
{
assert(token && token == identity_token && s_transitioning);
identity_token = 0; ++identity_releases;
}
const esp_partition_t *esp_ota_get_running_partition(void) { return missing_running ? NULL : &running; }
const esp_partition_t *esp_ota_get_next_update_partition(const esp_partition_t *p)
{
assert(!p); return missing_target ? NULL : &target;
}
esp_err_t esp_ota_begin(const esp_partition_t *p, size_t size, esp_ota_handle_t *handle)
{
assert(p == &target && p->address != running.address && size == request.content_len);
assert(size <= target.size && request.received == PREFIX_SIZE && s_transitioning && identity_token);
assert(tasks_created == 1 && !locked); ++begins;
assert(!ota_live && *handle == 0);
if (stage_fail == 1) return ESP_FAIL;
*handle = 123; ota_live = true;
/* IDF publishes the handle before erasing; this models erase failure. */
return stage_fail == 6 ? ESP_FAIL : ESP_OK;
}
esp_err_t esp_ota_write(esp_ota_handle_t handle, const void *data, size_t size)
{
assert(handle == 123 && s_transitioning && !locked && size <= BUFFER_SIZE);
assert(flash_size + size <= sizeof(flash)); ++writes;
if (stage_fail == 2) return ESP_FAIL;
memcpy(flash + flash_size, data, size); flash_size += size; return ESP_OK;
}
esp_err_t esp_ota_end(esp_ota_handle_t handle)
{
assert(handle == 123 && flash_size == request.content_len && !memcmp(flash, image, flash_size));
assert(!aborts && !locked && s_transitioning && tasks_created == 1 && ota_live); ++ends;
ota_live = false; /* End consumes on success AND validation failure. */
return stage_fail == 3 ? ESP_FAIL : ESP_OK;
}
esp_err_t esp_ota_abort(esp_ota_handle_t handle)
{
assert(handle == 123 && begins == 1 && !ends && !commits && ota_live);
ota_live = false; ++aborts; return ESP_OK;
}
esp_err_t esp_image_get_metadata(const esp_partition_pos_t *p, esp_image_metadata_t *metadata)
{
assert(p->offset == target.address && p->size == target.size && ends == 1);
++metadata_reads; metadata->image_len = (uint32_t)parsed_size;
if (revoke_at_metadata) final_current = false;
return stage_fail == 4 ? ESP_FAIL : ESP_OK;
}
esp_err_t esp_ota_set_boot_partition(const esp_partition_t *p)
{
assert(p == &target && ends == 1 && metadata_reads == 1 && final_current && !final_unavailable);
assert(tasks_created == 1 && !response_sends && s_transitioning && identity_token && !locked);
assert(web_firmware_update_reserve_reboot() == ESP_ERR_INVALID_STATE);
if (commit_race) competing_lifecycle();
if (stage_fail == 5) return ESP_FAIL;
++commits; return ESP_OK;
}
static void reset(void)
{
assert(!allocations_live && !task_entry && !locked && !ota_live);
atomic_store(&s_reboot_gate, false);
s_firmware_selected = false;
++cases;
memset(&request, 0, sizeof(request));
request.uri = WEB_FIRMWARE_UPDATE_URI; request.method = HTTP_POST;
request.content_len = sizeof(image); request.handle = &server_storage; request.headers_valid = true;
set_header("Host", "device"); set_header("Origin", "https://device");
set_header("Content-Type", "application/octet-stream");
set_length(sizeof(image));
set_header("Cookie", SESSION_COOKIE "=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa");
set_header("X-CSRF-Token", "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb");
memset(image, 0x55, sizeof(image)); memset(flash, 0, sizeof(flash));
esp_image_header_t header = {.magic = ESP_IMAGE_HEADER_MAGIC, .chip_id = ESP_CHIP_ID_ESP32S3,
.segment_count = 3, .hash_appended = 1};
esp_image_segment_header_t segment = {.data_len = sizeof(esp_app_desc_t)};
uint32_t magic = ESP_APP_DESC_MAGIC_WORD;
memcpy(image, &header, sizeof(header)); memcpy(image + sizeof(header), &segment, sizeof(segment));
memcpy(image + sizeof(header) + sizeof(segment), &magic, sizeof(magic));
running = (esp_partition_t){.type = 0, .subtype = 0x10, .address = 0x10000, .size = 0x400000};
target = (esp_partition_t){.type = 0, .subtype = 0x11, .address = 0x410000, .size = 0x400000};
missing_target = missing_running = false;
s_ready = lookup_current = final_current = true;
s_server_mutex = &mutex_storage; s_server = &server_storage;
s_transitioning = false; s_last_error = ESP_OK; s_generation = 11;
lookup_unavailable = final_unavailable = malloc_fail = task_fail = identity_busy = mutex_busy = false;
receive_race = commit_race = revoke_at_metadata = false;
role = USER_ROLE_ADMIN; stage_fail = response_fail = response_step = 0;
allocations = allocations_live = tasks_created = tasks_deleted = 0;
begins = writes = ends = aborts = metadata_reads = commits = receives = wipes = 0;
restarts = response_sends = releases = identity_releases = 0;
flash_size = 0; max_chunk = BUFFER_SIZE; cutoff = stall_at = SIZE_MAX; parsed_size = sizeof(image);
now_us = 0; read_us = 1000; notification = identity_token = 0;
response_status[0] = response_body[0] = 0;
}
static void rejected(const char *status, const char *code)
{
bool pre_reserved = s_transitioning;
bool gate_reserved = atomic_load(&s_reboot_gate);
esp_err_t result = web_firmware_update_handler(&request);
assert(!strcmp(response_status, status) && strstr(response_body, code));
assert(result == (request.received < request.content_len ? ESP_FAIL : ESP_OK));
assert(!commits && !allocations_live && !restarts && wipes == 1 && !ota_live);
assert(s_transitioning == pre_reserved && !identity_token);
assert(atomic_load(&s_reboot_gate) == gate_reserved);
if (task_entry) assert(notification == 2);
finish_owner(); assert(!restarts);
}
static void success(void)
{
assert(web_firmware_update_handler(&request) == ESP_OK);
assert(!strcmp(response_status, "200 OK") && !strcmp(response_body, "{\"ok\":true,\"rebooting\":true}"));
assert(commits == 1 && ends == 1 && !aborts && s_transitioning && identity_token);
assert(!allocations_live && notification == 1 && !restarts && wipes == 1);
assert(s_firmware_selected && web_firmware_update_reserve_reboot() == ESP_ERR_INVALID_STATE);
assert(request.received == request.content_len && !memcmp(image, flash, sizeof(image)));
finish_owner(); assert(restarts == 1);
}
int main(void)
{
reset();
assert(web_firmware_update_reserve_reboot() == ESP_OK);
rejected("503 Service Unavailable", "busy");
assert(!receives && !begins && !allocations);
reset(); s_server_mutex = NULL; s_server = NULL;
assert(web_firmware_update_reserve_reboot() == ESP_OK);
assert(web_firmware_update_reserve_reboot() == ESP_ERR_INVALID_STATE);
reset(); success();
reset(); max_chunk = 1; receive_race = commit_race = true; success();
assert(receives == sizeof(image));
reset(); max_chunk = 17; success();
puts("PASS bounded streaming, bytewise partial prefix, exact writes, delayed owner restart and reservation races");
reset(); lookup_current = false; rejected("401 Unauthorized", "authentication_required"); assert(!receives && !allocations);
reset(); lookup_unavailable = true; rejected("503 Service Unavailable", "unavailable");
reset(); role = USER_ROLE_USER; rejected("403 Forbidden", "admin_required"); assert(!receives && !allocations);
reset(); set_header("X-CSRF-Token", NULL); rejected("403 Forbidden", "csrf");
reset(); set_header("X-CSRF-Token", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"); rejected("403 Forbidden", "csrf");
reset(); set_header("Origin", NULL); rejected("403 Forbidden", "origin");
reset(); set_header("Origin", "null"); rejected("403 Forbidden", "origin");
reset(); set_header("Origin", "https://attacker"); rejected("403 Forbidden", "origin");
reset(); set_header("Sec-Fetch-Site", "cross-site"); rejected("403 Forbidden", "origin");
reset(); set_header("Cookie", NULL); rejected("401 Unauthorized", "authentication_required");
reset(); request.headers_valid = false; rejected("400 Bad Request", "invalid_request");
reset(); request.uri = "/api/firmware?x=1"; rejected("400 Bad Request", "invalid_request");
reset(); request.method = HTTP_GET; rejected("400 Bad Request", "invalid_request");
reset(); s_ready = false; rejected("503 Service Unavailable", "unavailable");
puts("PASS production cookie/Origin/CSRF/admin admission before receive or flash (SDK getters/adapter real, database doubled)");
const char *invalid_lengths[] = {NULL, "", " ", "+12000", "-12000", "12000x", "1.2e4",
"12 000", "12000,12000", "12000 ", "12000\t", "\t12000", "11999", "12001",
"000000000000000012000"};
for (size_t i = 0; i < sizeof(invalid_lengths) / sizeof(invalid_lengths[0]); ++i) {
reset(); set_header("Content-Length", invalid_lengths[i]);
rejected("400 Bad Request", "invalid_request");
assert(!receives && !begins && !allocations && !notification);
}
const char *oversized_lengths[] = {"4194305", "4296815136", "4294967296",
"18446744073709551615", "18446744073709551616", "99999999999999999999"};
for (size_t i = 0; i < sizeof(oversized_lengths) / sizeof(oversized_lengths[0]); ++i) {
reset(); set_header("Content-Length", oversized_lengths[i]);
/* Explicitly model HTTPD's 64-bit -> ESP32 size_t narrowing. */
request.content_len = i == 1 ? 1847840 : i == 2 ? 0 : sizeof(image);
rejected("413 Payload Too Large", "firmware_too_large");
assert(!receives && !begins && !allocations && !notification);
}
reset();
request.headers[request.header_count++] = (test_header_t){"content-length", "12000"};
rejected("400 Bad Request", "invalid_request"); assert(!receives && !begins && !allocations);
reset(); set_header("Content-Length", " 12000"); success();
reset(); set_header("Content-Length", "00000000000000012000"); success();
puts("PASS raw Content-Length missing/duplicate/malformed/oversized/64-bit wrap and overflow reject before receive/erase; IDF leading-space semantics");
reset(); set_header("Content-Type", "multipart/form-data"); rejected("415 Unsupported Media Type", "firmware_content_type");
reset(); set_header("Content-Type", NULL); rejected("415 Unsupported Media Type", "firmware_content_type");
reset(); set_length(0); rejected("400 Bad Request", "invalid_firmware");
reset(); set_length(PREFIX_SIZE - 1); rejected("400 Bad Request", "invalid_firmware");
reset(); set_length(target.size + 1); rejected("413 Payload Too Large", "firmware_too_large"); assert(!allocations);
reset(); target.size = sizeof(image); success();
reset(); missing_target = true; rejected("503 Service Unavailable", "firmware_unavailable");
reset(); missing_running = true; rejected("503 Service Unavailable", "firmware_unavailable");
reset(); target.address = running.address; rejected("503 Service Unavailable", "firmware_unavailable");
reset(); target.type = 1; rejected("503 Service Unavailable", "firmware_unavailable");
reset(); target.subtype = 0; rejected("503 Service Unavailable", "firmware_unavailable");
for (unsigned field = 0; field < 6; ++field) {
reset(); esp_image_header_t header; memcpy(&header, image, sizeof(header));
if (field == 0) header.magic = 0;
if (field == 1) header.chip_id = ESP_CHIP_ID_ESP32;
if (field == 2) header.segment_count = 0;
if (field == 3) header.hash_appended = 0;
memcpy(image, &header, sizeof(header));
if (field == 4) memset(image + sizeof(header) + sizeof(esp_image_segment_header_t), 0, 4);
if (field == 5) memset(image + sizeof(header), 0, sizeof(esp_image_segment_header_t));
rejected("400 Bad Request", "invalid_firmware"); assert(!begins && !aborts);
}
puts("PASS content type, length/capacity, inactive app selection, format/chip/descriptor/hash header rejection before erase");
reset(); s_transitioning = true; rejected("503 Service Unavailable", "busy");
reset(); identity_busy = true; rejected("503 Service Unavailable", "busy");
reset(); mutex_busy = true; rejected("503 Service Unavailable", "busy");
reset(); s_server = NULL; rejected("503 Service Unavailable", "busy");
reset(); malloc_fail = true; rejected("503 Service Unavailable", "firmware_resources"); assert(!tasks_created && !begins);
reset(); task_fail = true; rejected("503 Service Unavailable", "firmware_resources"); assert(!tasks_created && !begins);
for (int stage = 1; stage <= 5; ++stage) {
reset(); stage_fail = stage;
rejected(stage == 3 || stage == 4 ? "400 Bad Request" : "500 Internal Server Error",
stage == 3 || stage == 4 ? "invalid_firmware" : stage == 5 ? "firmware_commit_failed" : "firmware_write_failed");
assert(aborts == (unsigned)(stage == 2));
}
reset(); stage_fail = 6;
rejected("500 Internal Server Error", "firmware_write_failed");
assert(begins == 1 && aborts == 1 && !writes && !ends && !ota_live);
puts("PASS begin failure after handle publication aborts once; unpublished failure and consumed end never double-abort");
reset(); parsed_size--; rejected("400 Bad Request", "invalid_firmware");
reset(); parsed_size++; rejected("400 Bad Request", "invalid_firmware");
reset(); revoke_at_metadata = true; rejected("401 Unauthorized", "authentication_required");
reset(); final_unavailable = true; rejected("401 Unauthorized", "authentication_required");
puts("PASS busy/resources/OTA begin-write-end-metadata-commit failures, final auth revocation and exact SDK image length");
reset(); cutoff = 10; rejected("400 Bad Request", "firmware_incomplete"); assert(!begins);
reset(); cutoff = 300; rejected("400 Bad Request", "firmware_incomplete"); assert(aborts == 1);
reset(); stall_at = 0; read_us = 1000000; rejected("408 Request Timeout", "firmware_timeout"); assert(!begins && receives == 10);
reset(); stall_at = PREFIX_SIZE; read_us = 1000000; rejected("408 Request Timeout", "firmware_timeout"); assert(aborts == 1);
reset(); read_us = STALL_US; rejected("408 Request Timeout", "firmware_timeout"); assert(!begins);
reset(); max_chunk = 1; read_us = 40000; rejected("408 Request Timeout", "firmware_timeout"); assert(aborts == 1 && now_us == TOTAL_US);
puts("PASS incomplete body, stalled receive and total slow-drip deadline; no selection/reboot, handle abort when live");
for (int step = 1; step <= 6; ++step) {
reset(); response_fail = step;
assert(web_firmware_update_handler(&request) == ESP_FAIL);
assert(commits == 1 && !s_transitioning && !identity_token && !allocations_live);
assert(notification == 2 && !restarts && !aborts); finish_owner(); assert(!restarts);
assert(s_firmware_selected && !atomic_load(&s_reboot_gate));
request.received = 0; response_fail = 0;
assert(web_firmware_update_handler(&request) == ESP_FAIL);
assert(!strcmp(response_status, "409 Conflict") && strstr(response_body, "firmware_selected_reboot_required"));
assert(commits == 1 && begins == 1 && !atomic_load(&s_reboot_gate));
assert(web_firmware_update_reserve_reboot() == ESP_OK);
assert(web_firmware_update_reserve_reboot() == ESP_ERR_INVALID_STATE);
}
puts("PASS postcommit response failure: selected image retained, no automatic reboot, resources/reservations released");
printf("PASS %u firmware backend cases; SDK validation, flash and scheduling are mocked, not device evidence\n", cases);
return 0;
}
+12 -3
View File
@@ -14,7 +14,15 @@ const tick = async () => { for (let i = 0; i < 6; ++i) await new Promise(r => se
function browser({onlyLoader = false, withLoader = false, role = 'user', username = '<img>'} = {}) {
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': [], '/api/settings/serial': [], '/api/settings/serial-operation': [], '/api/settings/accounts': [], '/api/settings/account-operation': [], '/api/settings/accounts/generate-password': [], '/api/settings/accounts/keys': [], '/api/settings/network': [], '/api/settings/network-operation': [], '/api/settings/display': [], '/api/settings/display-operation': [], '/api/settings/broker': [], '/api/settings/broker-operation': [], '/api/settings/ssh': [], '/api/settings/ssh-operation': [], '/api/settings/lifecycle': [], '/api/settings/lifecycle-operation': []};
const fits = [];
const fits = [], uploads = [];
class Upload {
constructor() { this.upload = {}; this.headers = {}; uploads.push(this); }
open(method, url) { this.method = method; this.url = url; }
setRequestHeader(key, value) { this.headers[key] = value; }
send(file) { this.file = file; }
abort() { this.aborted = true; this.onabort?.(); }
reply(status, value) { this.status = status; this.responseText = typeof value === 'string' ? value : JSON.stringify(value); this.onload?.(); }
}
let serial = 0, now = Date.now();
class Clock extends Date { static now() { return now; } }
const on = (key, fn) => { if (!(events[key] ||= []).includes(fn)) events[key].push(fn); };
@@ -60,7 +68,7 @@ function browser({onlyLoader = false, withLoader = false, role = 'user', usernam
constructor() { this.measurements = []; this.calls = 0; fits.push(this); }
proposeDimensions() { ++this.calls; return this.measurements.length ? this.measurements.shift() : {cols: 80, rows: 24}; }
}},
TextEncoder, TextDecoder, Uint8Array, ArrayBuffer, AbortController, URL, Date: Clock, performance: {now: () => now}, WebSocket: Socket,
TextEncoder, TextDecoder, Uint8Array, ArrayBuffer, AbortController, URL, Date: Clock, performance: {now: () => now}, WebSocket: Socket, XMLHttpRequest: Upload,
fetch: async (url, options) => {
// Apply the Origin regression guard to every mutation, including logout.
assert.ok(Object.hasOwn(queues, url));
@@ -83,7 +91,7 @@ function browser({onlyLoader = false, withLoader = false, role = 'user', usernam
const match = [...timers].find(([, t]) => t.ms === ms); assert.ok(match, `missing timer ${ms}`);
const [id, t] = match; if (!t.interval) timers.delete(id); t.fn();
};
return {nodes, document: context.document, calls, redirects, timers, sockets, terminals, queues, fits, events, emit, start, fire,
return {nodes, document: context.document, calls, redirects, timers, sockets, terminals, queues, fits, events, emit, start, fire, uploads,
click: id => nodes[id].click(), elapse: ms => { now += ms; }, window};
}
async function connected() { const b = browser(); b.start(); await tick(); assert.equal(b.sockets.length, 1); return b; }
@@ -1399,5 +1407,6 @@ async function test(name, fn) { await fn(); ++passed; console.log('PASS JS:', na
await require('./broker.cjs')({test, browser, adminBrowser, tick, json, session, failure, deferred, token, html});
await require('./ssh.cjs')({test, browser, adminBrowser, tick, json, session, failure, deferred, token, html});
await require('./lifecycle.cjs')({test, browser, adminBrowser, tick, json, session, failure, deferred, token, html});
await require('./firmware.cjs')({test, browser, adminBrowser, tick, json, session, failure, deferred, token, html});
console.log(`PASS ${passed} browser behavior groups (production C-rendered JS)`);
})().catch(error => { console.error(error); process.exitCode = 1; });
+91
View File
@@ -0,0 +1,91 @@
'use strict';
const assert = require('node:assert/strict');
module.exports = async ({test, browser, adminBrowser, tick, json, session, failure, deferred, token, html}) => {
const snapshot = {generation:7,running:true,transitioning:false,controllable:true,identity_generation:11,fingerprint:'ab'.repeat(32),rotatable:true};
const file = (extra={}) => ({name:'firmware.bin',size:4194304,type:'',...extra});
const n = (b,id) => b.document.getElementById('firmware-'+id);
async function open() {
const b=await adminBrowser(); b.click('select-settings'); await tick();
b.queues['/api/settings/lifecycle'].push(json(snapshot)); b.click('settings-lifecycle'); await tick();
n(b,'file').files=[file()]; return b;
}
async function upload(b) { b.click('firmware-upload'); await tick(); return b.uploads.at(-1); }
await test('Firmware admin-only card validates filename hint/size, not unreliable browser MIME, and requires explicit confirmation', async()=>{
assert.match(html,/accept="\.bin"/); assert.match(html,/firmware\.bin built for this device/); assert.match(html,/Saved settings are kept/);
const u=browser(); u.start(); await tick(); n(u,'file').files=[file()]; await upload(u); assert.equal(u.uploads.length,0);
for(const f of [null,file({name:'flash.txt'}),file({size:0}),file({size:4194305}),file({size:NaN}),file({size:1.5})]) {
const b=await open(); n(b,'file').files=f?[f]:[]; await upload(b); assert.equal(b.uploads.length,0); assert.match(n(b,'detail').textContent,/nonempty .bin/);
}
const b=await open(); let warning=''; b.window.confirm=s=>{warning=s;return false;}; await upload(b); assert.equal(b.uploads.length,0);
assert.equal(warning,'Upload firmware and reboot? All connections will close. Saved settings are kept; unsaved changes will be lost.');
b.window.confirm=()=>true; n(b,'file').files=[file({name:'APP.BIN',type:'text/plain'})]; const x=await upload(b); assert.ok(x.file);
});
await test('Firmware sends original File raw with current CSRF and browser-managed headers; one flight and lifecycle controls gated',async()=>{
const b=await open(), f=n(b,'file').files[0], x=await upload(b);
assert.equal(x.method,'POST'); assert.equal(x.url,'/api/firmware'); assert.equal(x.file,f);
assert.deepEqual(x.headers,{'Content-Type':'application/octet-stream','X-CSRF-Token':token}); assert.equal(x.timeout,180000);
assert.ok(n(b,'file').disabled && n(b,'upload').disabled);
for(const action of ['stop','restart','rotate','reboot']) {assert.ok(b.nodes['lifecycle-'+action].disabled); b.click('lifecycle-'+action);}
await upload(b); assert.equal(b.uploads.length,1); assert.ok(!b.calls.some(c=>c.url.endsWith('lifecycle-operation')&&c.method==='POST'));
x.upload.onprogress({lengthComputable:true,loaded:25,total:100}); assert.equal(n(b,'progress').value,25);
x.upload.onprogress({lengthComputable:true,loaded:100,total:100}); assert.match(n(b,'detail').textContent,/Validating firmware/);
for(const e of [{lengthComputable:false},{lengthComputable:true,loaded:NaN,total:100},{lengthComputable:true,loaded:1,total:0}]) { x.upload.onprogress(e); assert.match(n(b,'detail').textContent,/unavailable/); }
x.reply(200,{ok:true,rebooting:true}); assert.match(n(b,'detail').textContent,/accepted; rebooting/); assert.match(n(b,'detail').textContent,/Reconnect and sign in/);
await upload(b); assert.equal(b.uploads.length,1); assert.ok(n(b,'upload').disabled);
x.onerror(); assert.match(n(b,'detail').textContent,/accepted; rebooting/);
});
await test('Firmware fences delayed session validation, changed identity/role/CSRF and logout before sending',async()=>{
for(const response of [failure(401),session({role:'user'}),session({role:'admin',username:'changed'}),session({role:'admin',csrf:'b'.repeat(64)})]) {
const b=await open(); b.queues['/api/session'].push(response); await upload(b); assert.equal(b.uploads.length,0); assert.ok(b.redirects.length);
}
const b=await open(), d=deferred(); b.queues['/api/session'].push(d.promise); await upload(b); await upload(b); assert.equal(b.uploads.length,0);
b.click('sign-out'); await tick(); d.resolve(session({role:'admin'})); await tick(); assert.equal(b.uploads.length,0);
});
await test('Firmware session loss/pagehide/logout abort once and fence all late progress/success/error/401 callbacks',async()=>{
for(const end of ['logout','pagehide','expiry','identity']) {
const b=await open(), x=await upload(b);
if(end==='logout') b.click('sign-out');
else if(end==='pagehide') b.emit('pagehide');
else if(end==='expiry') x.reply(401,{error:'authentication_required'});
else { b.queues['/api/session'].push(session({role:'admin',username:'changed'})); b.click('select-serial'); b.click('connection-toggle'); await tick(); b.click('connection-toggle'); }
await tick(); assert.ok(x.aborted,end); const detail=n(b,'detail').textContent, redirects=b.redirects.length;
assert.match(detail,/may already be installed/);
x.upload.onprogress({lengthComputable:true,loaded:50,total:100}); x.reply(200,{ok:true,rebooting:true}); x.reply(401,{}); x.onerror();
assert.equal(n(b,'detail').textContent,detail); assert.equal(b.redirects.length,redirects); assert.equal(b.uploads.length,1);
}
});
await test('Firmware navigation retains single upload and lifecycle gate without resending',async()=>{
const b=await open(), x=await upload(b); b.click('settings-serial'); await tick(); b.click('settings-lifecycle'); await tick();
assert.ok(!x.aborted); assert.ok(b.nodes['lifecycle-reboot'].disabled); await upload(b); assert.equal(b.uploads.length,1);
x.reply(200,{ok:true,rebooting:true}); assert.match(n(b,'detail').textContent,/rebooting/);
});
await test('Firmware maps every backend error safely, never displays arbitrary response text or retries',async()=>{
const groups={400:['invalid_request','invalid_firmware','firmware_incomplete'],403:['origin','csrf','admin_required'],408:['firmware_timeout'],413:['firmware_too_large'],415:['firmware_content_type'],500:['firmware_write_failed','firmware_commit_failed'],503:['unavailable','busy','firmware_unavailable','firmware_resources']};
for(const [status,codes] of Object.entries(groups)) for(const error of codes) {
const b=await open(), x=await upload(b); x.reply(Number(status),{error}); assert.doesNotMatch(n(b,'detail').textContent,/Update status unknown/); assert.ok(n(b,'detail').textContent.length > 0 && n(b,'detail').textContent.length < 100); assert.equal(b.uploads.length,1);
}
for(const response of ['SECRET ERROR BODY','x'.repeat(129),{ok:true},{ok:true,rebooting:true,secret:'bad'},{error:'<script>'},{error:'toString'}]) {
const b=await open(), x=await upload(b); x.reply(200,response); assert.match(n(b,'detail').textContent,/Update status unknown/); assert.doesNotMatch(n(b,'detail').textContent,/SECRET|script|bad/); assert.ok(n(b,'upload').disabled);
}
});
await test('Firmware refuses competing lifecycle work and stale prior-upload callbacks cannot affect an explicit retry',async()=>{
const b=await open(), d=deferred(); b.queues['/api/session'].push(d.promise); b.click('lifecycle-reboot'); await tick();
await upload(b); assert.equal(b.uploads.length,0); assert.ok(n(b,'upload').disabled);
b.queues['/api/settings/lifecycle-operation'].push(new Response(JSON.stringify({id:1,action:'reboot',state:'pending'}),{status:202}));
d.resolve(session({role:'admin'})); await tick(); await upload(b); assert.equal(b.uploads.length,0);
const retry=await open(), first=await upload(retry); first.reply(503,{error:'busy'});
assert.equal(n(retry,'upload').disabled,false); n(retry,'file').files=[file()]; const second=await upload(retry);
const message=n(retry,'detail').textContent; first.reply(401,{}); first.onerror(); first.upload.onprogress({lengthComputable:true,loaded:1,total:2});
assert.equal(retry.redirects.length,0); assert.equal(n(retry,'detail').textContent,message); assert.ok(!second.aborted);
second.reply(200,{ok:true,rebooting:true}); assert.match(n(retry,'detail').textContent,/accepted; rebooting/);
const preflight=await open(); preflight.queues['/api/session'].push(()=>{throw Error('offline');}); await upload(preflight);
assert.equal(preflight.uploads.length,0); assert.match(n(preflight,'detail').textContent,/no upload sent/); assert.equal(n(preflight,'upload').disabled,false);
});
await test('Firmware network/timeout/abort and lost acknowledgement are uncertain, locked, and never auto-replayed',async()=>{
for(const event of ['onerror','ontimeout','onabort']) {
const b=await open(), x=await upload(b); x[event](); assert.match(n(b,'detail').textContent,/Reconnect and check/); assert.ok(n(b,'upload').disabled);
await upload(b); assert.equal(b.uploads.length,1); x.reply(200,{ok:true,rebooting:true}); assert.match(n(b,'detail').textContent,/Update status unknown/);
}
const fresh=await open(); assert.equal(fresh.uploads.length,0); assert.equal(n(fresh,'upload').disabled,false);
});
};