|
|
|
@@ -0,0 +1,264 @@
|
|
|
|
|
/* Real HTTP policy/store/parser/operation owner; deterministic serial/NVS/queue. */
|
|
|
|
|
#include "serial_service.h"
|
|
|
|
|
#include <errno.h>
|
|
|
|
|
#include <stdlib.h>
|
|
|
|
|
#define ESP_ERR_TIMEOUT 0x107
|
|
|
|
|
#include "serial_config_production.h"
|
|
|
|
|
#include "../../src/web_serial_settings.c"
|
|
|
|
|
|
|
|
|
|
static serial_config_t working, persisted;
|
|
|
|
|
static bool on_dispatcher, have_stored = true, queue_fail;
|
|
|
|
|
static unsigned api_calls, apply_calls, save_calls, load_calls, start_calls, stop_calls;
|
|
|
|
|
static unsigned apply_fail_at;
|
|
|
|
|
static esp_err_t get_error, save_error, load_error, start_error, stop_error;
|
|
|
|
|
static uint32_t queued_id;
|
|
|
|
|
static void (*apply_hook)(void);
|
|
|
|
|
esp_err_t admin_ssh_console_submit_serial_settings(uint32_t id) {
|
|
|
|
|
assert(!host_lock_depth && !on_dispatcher && id);
|
|
|
|
|
if (queue_fail) return ESP_ERR_TIMEOUT;
|
|
|
|
|
queued_id = id; return ESP_OK;
|
|
|
|
|
}
|
|
|
|
|
static void serial_api(void) { assert(on_dispatcher && !host_lock_depth); ++api_calls; }
|
|
|
|
|
esp_err_t serial_service_apply_config(const serial_config_t *config) {
|
|
|
|
|
serial_api(); ++apply_calls;
|
|
|
|
|
assert(serial_config_validate(config) == ESP_OK);
|
|
|
|
|
if (apply_hook) { void (*hook)(void) = apply_hook; apply_hook = NULL; hook(); }
|
|
|
|
|
if (apply_fail_at == apply_calls) return ESP_FAIL;
|
|
|
|
|
working = *config; return ESP_OK;
|
|
|
|
|
}
|
|
|
|
|
esp_err_t serial_service_get_config(serial_config_t *config) {
|
|
|
|
|
serial_api(); *config = working; return get_error;
|
|
|
|
|
}
|
|
|
|
|
esp_err_t serial_service_start(void) { serial_api(); ++start_calls; return start_error; }
|
|
|
|
|
esp_err_t serial_service_stop(void) { serial_api(); ++stop_calls; return stop_error; }
|
|
|
|
|
esp_err_t serial_config_save(const serial_config_t *config) {
|
|
|
|
|
serial_api(); ++save_calls;
|
|
|
|
|
if (save_error == ESP_OK) { persisted = *config; have_stored = true; }
|
|
|
|
|
return save_error;
|
|
|
|
|
}
|
|
|
|
|
esp_err_t serial_config_load(serial_config_t *config, bool *stored) {
|
|
|
|
|
serial_api(); ++load_calls; *stored = have_stored;
|
|
|
|
|
if (have_stored) *config = persisted; else serial_config_defaults(config);
|
|
|
|
|
return load_error;
|
|
|
|
|
}
|
|
|
|
|
esp_err_t serial_config_reset_storage(void) {
|
|
|
|
|
serial_config_t defaults; serial_config_defaults(&defaults); return serial_config_save(&defaults);
|
|
|
|
|
}
|
|
|
|
|
static void operation_begin(const issued_t *identity, const char *body) {
|
|
|
|
|
begin("/api/settings/serial-operation", body ? HTTP_POST : HTTP_GET, body);
|
|
|
|
|
same_origin();
|
|
|
|
|
if (body) add("Content-Type", "application/json");
|
|
|
|
|
if (identity) {
|
|
|
|
|
char cookies[100]; snprintf(cookies, sizeof(cookies), "__Host-sak-session=%s", identity->token);
|
|
|
|
|
add("Cookie", cookies);
|
|
|
|
|
if (body) add("X-CSRF-Token", identity->view.csrf);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
static void operation_expect(const char *status) {
|
|
|
|
|
unsigned before = api_calls;
|
|
|
|
|
esp_err_t error = web_serial_settings_handler(&req);
|
|
|
|
|
assert(error == (send_fail || aux.remaining_len ? ESP_FAIL : ESP_OK));
|
|
|
|
|
if (strcmp(response_status, status)) fprintf(stderr, "Expected %s, got %s\n", status, response_status);
|
|
|
|
|
assert(!strcmp(response_status, status) && api_calls == before);
|
|
|
|
|
assert(strlen(output) < 96); zero(scratch, sizeof(scratch));
|
|
|
|
|
}
|
|
|
|
|
static void execute(void) {
|
|
|
|
|
assert(queued_id); on_dispatcher = true;
|
|
|
|
|
web_serial_settings_execute(queued_id); on_dispatcher = false;
|
|
|
|
|
}
|
|
|
|
|
static void submit(const issued_t *identity, const char *body) {
|
|
|
|
|
operation_begin(identity, body); operation_expect("202 Accepted");
|
|
|
|
|
assert(s_operation.state == PENDING && queued_id == s_operation.id);
|
|
|
|
|
}
|
|
|
|
|
static void action(const issued_t *identity, const char *name) {
|
|
|
|
|
char body[40]; snprintf(body, sizeof(body), "{\"action\":\"%s\"}", name);
|
|
|
|
|
submit(identity, body); execute();
|
|
|
|
|
}
|
|
|
|
|
static const char apply_body[] = "{\"action\":\"apply\",\"baud\":230400,\"data_bits\":\"7\",\"parity\":\"even\",\"stop_bits\":\"2\",\"flow\":\"rts-cts\",\"dtr\":\"on-connect\",\"rts_threshold\":96}";
|
|
|
|
|
static void revoke_during_apply(void) { web_session_store_invalidate(s_operation.session); }
|
|
|
|
|
static issued_t executing_identity;
|
|
|
|
|
static void request_during_apply(void) {
|
|
|
|
|
serial_operation_t executing = s_operation;
|
|
|
|
|
operation_begin(&executing_identity, "{\"action\":\"reset\"}");
|
|
|
|
|
operation_expect("503 Service Unavailable");
|
|
|
|
|
assert(!memcmp(&executing, &s_operation, sizeof(executing)));
|
|
|
|
|
operation_begin(&executing_identity, NULL); operation_expect("200 OK");
|
|
|
|
|
assert(strstr(output, "\"state\":\"pending\""));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
static int show_status(void) { return 0; }
|
|
|
|
|
static int show_counters(void) { return 0; }
|
|
|
|
|
static void print_config(const serial_config_t *config) { (void)config; }
|
|
|
|
|
static void print_usage(void) {}
|
|
|
|
|
bool serial_service_is_running(void) { return false; }
|
|
|
|
|
void serial_service_clear_counters(void) {}
|
|
|
|
|
const char *esp_err_to_name(esp_err_t error) { (void)error; return "fake error"; }
|
|
|
|
|
#include "serial_console_production.h"
|
|
|
|
|
|
|
|
|
|
static void serial_settings_tests(void) {
|
|
|
|
|
auth_reset(); issued_t admin = mint(&alice), user = mint(&bob), other = mint(&alice);
|
|
|
|
|
receive_fragment = 64;
|
|
|
|
|
serial_config_defaults(&working); persisted = working;
|
|
|
|
|
operation_begin(NULL, "{\"action\":\"save\"}"); operation_expect("401 Unauthorized");
|
|
|
|
|
operation_begin(&user, "{\"action\":\"save\"}"); operation_expect("403 Forbidden");
|
|
|
|
|
operation_begin(&user, NULL); operation_expect("403 Forbidden");
|
|
|
|
|
for (unsigned mode = 0; mode < 9; ++mode) {
|
|
|
|
|
operation_begin(&admin, "{\"action\":\"save\"}");
|
|
|
|
|
if (mode == 0) req.content_len = aux.remaining_len = 257;
|
|
|
|
|
if (mode == 1) req.uri = "/api/settings/serial-operation?command=save";
|
|
|
|
|
if (mode == 2) req.method = HTTP_GET;
|
|
|
|
|
if (mode == 3) add("X-CSRF-Token", "duplicate");
|
|
|
|
|
if (mode == 4) add("Origin", "https://evil.example");
|
|
|
|
|
if (mode == 5) add("Transfer-Encoding", "chunked");
|
|
|
|
|
if (mode == 6) add("Content-Type", "text/plain");
|
|
|
|
|
if (mode == 7) { begin("/api/settings/serial-operation", HTTP_POST, "{\"action\":\"save\"}"); same_origin(); }
|
|
|
|
|
if (mode == 8) add("Sec-Fetch-Site", "cross-site");
|
|
|
|
|
unsigned before = s_next_id;
|
|
|
|
|
(void)web_serial_settings_handler(&req);
|
|
|
|
|
assert(strncmp(response_status, "4", 1) == 0 && s_next_id == before && !api_calls);
|
|
|
|
|
}
|
|
|
|
|
puts("PASS Serial mutation security: cookie/admin/current policy, duplicate headers, CSRF/Origin, body/query/method/framing bounds before admission");
|
|
|
|
|
|
|
|
|
|
const char *invalid[] = {"{}", "[]", "{\"action\":\"serial save\"}", "{\"action\":\"save\",\"action\":\"stop\"}",
|
|
|
|
|
"{\"action\":\"apply\"}", "{\"action\":\"save\",\"baud\":110}", "{\"action\":\"save\",}",
|
|
|
|
|
"{\"action\":\"save\"}x", "{\"action\":true}", "{\"action\":\"sa\\u0076e\"}", "{\"command\":\"save\"}",
|
|
|
|
|
"{\"baud\":1e3,\"action\":\"apply\"}", "{\"baud\":-1,\"action\":\"apply\"}", "{\"baud\":0110,\"action\":\"apply\"}",
|
|
|
|
|
"{\"baud\":42949672960,\"action\":\"apply\"}", "{\"baud\":1.1,\"action\":\"apply\"}"};
|
|
|
|
|
for (unsigned i = 0; i < sizeof(invalid) / sizeof(*invalid); ++i) {
|
|
|
|
|
operation_begin(&admin, invalid[i]); operation_expect("400 Bad Request");
|
|
|
|
|
}
|
|
|
|
|
serial_operation_t parsed;
|
|
|
|
|
assert(parse(apply_body, strlen(apply_body), &parsed));
|
|
|
|
|
char bad[256];
|
|
|
|
|
const char *fields[] = {"230400", "\"7\"", "\"even\"", "\"2\"", "\"rts-cts\"", "\"on-connect\"", "96"};
|
|
|
|
|
const char *replacements[] = {"109", "\"9\"", "\"mark\"", "\"1.5\"", "\"xon-xoff\"", "\"bad\"", "128"};
|
|
|
|
|
for (unsigned i = 0; i < 7; ++i) {
|
|
|
|
|
const char *at = strstr(apply_body, fields[i]); assert(at);
|
|
|
|
|
snprintf(bad, sizeof(bad), "%.*s%s%s", (int)(at - apply_body), apply_body, replacements[i], at + strlen(fields[i]));
|
|
|
|
|
operation_begin(&admin, bad); operation_expect("400 Bad Request");
|
|
|
|
|
}
|
|
|
|
|
for (size_t size = 0; size < strlen(apply_body); ++size) assert(!parse(apply_body, size, &parsed));
|
|
|
|
|
for (unsigned bits = 7; bits <= 8; ++bits)
|
|
|
|
|
for (unsigned parity = 0; parity < 3; ++parity)
|
|
|
|
|
for (unsigned stops = 1; stops <= 2; ++stops)
|
|
|
|
|
for (unsigned flow = 0; flow < 2; ++flow)
|
|
|
|
|
for (unsigned dtr = 0; dtr < 3; ++dtr)
|
|
|
|
|
for (unsigned baud = 0; baud < 2; ++baud)
|
|
|
|
|
for (unsigned threshold = 0; threshold < 2; ++threshold) {
|
|
|
|
|
const char *parities[] = {"none", "even", "odd"};
|
|
|
|
|
const char *flows[] = {"none", "rts-cts"};
|
|
|
|
|
const char *dtrs[] = {"inactive", "active", "on-connect"};
|
|
|
|
|
int size = snprintf(bad, sizeof(bad),
|
|
|
|
|
"{\"rts_threshold\":%u,\"dtr\":\"%s\",\"flow\":\"%s\",\"stop_bits\":\"%u\","
|
|
|
|
|
"\"parity\":\"%s\",\"data_bits\":\"%u\",\"baud\":%u,\"action\":\"apply\"}",
|
|
|
|
|
threshold ? 127U : 1U, dtrs[dtr], flows[flow], stops,
|
|
|
|
|
parities[parity], bits, baud ? 1000000U : 110U);
|
|
|
|
|
assert(size > 0 && (size_t)size < sizeof(bad) && parse(bad, (size_t)size, &parsed));
|
|
|
|
|
assert(parsed.config.baud_rate == (baud ? 1000000U : 110U));
|
|
|
|
|
assert(parsed.config.rts_threshold == (threshold ? 127U : 1U));
|
|
|
|
|
}
|
|
|
|
|
memcpy(bad, apply_body, sizeof(apply_body));
|
|
|
|
|
assert(!parse(bad, sizeof(apply_body), &parsed)); /* NUL is not JSON whitespace. */
|
|
|
|
|
char *control = strstr(bad, "on-connect"); assert(control); *control = '\1';
|
|
|
|
|
assert(!parse(bad, strlen(apply_body), &parsed));
|
|
|
|
|
receive_fragment = 1; operation_begin(&admin, apply_body); operation_expect("400 Bad Request"); assert(body_offset == 4);
|
|
|
|
|
receive_fragment = 64; recv_fail = true; operation_begin(&admin, apply_body); operation_expect("400 Bad Request"); recv_fail = false;
|
|
|
|
|
assert(!api_calls);
|
|
|
|
|
puts("PASS Serial JSON: exact seven-field Apply/action-only schema, enum/range rejection, truncation, unknown/duplicate/escaped/oversized numbers, four-read bound");
|
|
|
|
|
|
|
|
|
|
char full_body[257];
|
|
|
|
|
memset(full_body, ' ', sizeof(full_body) - 1);
|
|
|
|
|
memcpy(full_body, apply_body, strlen(apply_body)); full_body[256] = 0;
|
|
|
|
|
queue_fail = true; operation_begin(&admin, full_body); operation_expect("503 Service Unavailable"); queue_fail = false;
|
|
|
|
|
assert(body_offset == 256);
|
|
|
|
|
puts("PASS Serial boundaries: 288 valid framing/range combinations, action-last order, binary/control rejection, exact 256-byte/four-read body, rejected unread bodies close");
|
|
|
|
|
assert(s_operation.state == IDLE);
|
|
|
|
|
submit(&admin, apply_body); uint32_t first = queued_id;
|
|
|
|
|
operation_begin(&other, "{\"action\":\"stop\"}"); operation_expect("503 Service Unavailable"); assert(queued_id == first);
|
|
|
|
|
operation_begin(&other, NULL); operation_expect("200 OK"); assert(strstr(output, "\"state\":\"idle\""));
|
|
|
|
|
operation_begin(&admin, NULL); operation_expect("200 OK"); assert(strstr(output, "pending"));
|
|
|
|
|
execute(); assert(s_operation.state == OK && working.baud_rate == 230400 && persisted.baud_rate == 115200);
|
|
|
|
|
zero(&s_operation.principal, sizeof(s_operation.principal)); zero(&s_operation.config, sizeof(s_operation.config));
|
|
|
|
|
unsigned before = api_calls; execute(); assert(api_calls == before);
|
|
|
|
|
action(&admin, "save"); assert(persisted.baud_rate == 230400 && s_operation.state == OK);
|
|
|
|
|
action(&admin, "defaults"); assert(working.baud_rate == 115200 && persisted.baud_rate == 230400);
|
|
|
|
|
action(&admin, "load"); assert(working.baud_rate == 230400);
|
|
|
|
|
have_stored = false; action(&admin, "load"); assert(s_operation.state == LOADED_DEFAULTS && working.baud_rate == 115200);
|
|
|
|
|
action(&admin, "start"); action(&admin, "stop"); assert(start_calls == 1 && stop_calls == 1);
|
|
|
|
|
action(&admin, "reset"); assert(s_operation.state == OK && persisted.baud_rate == 115200);
|
|
|
|
|
puts("PASS Serial ownership: no serial/NVS on HTTPD, single pending slot, isolated results, replay fence, explicit apply/save/load/default/reset/start/stop semantics");
|
|
|
|
|
|
|
|
|
|
working.baud_rate = 460800; persisted.baud_rate = 230400; save_error = ESP_FAIL;
|
|
|
|
|
action(&admin, "reset"); assert(s_operation.state == FAILED && working.baud_rate == 460800 && persisted.baud_rate == 230400);
|
|
|
|
|
apply_fail_at = apply_calls + 2; action(&admin, "reset"); assert(s_operation.state == ROLLBACK_FAILED);
|
|
|
|
|
save_error = ESP_OK; apply_fail_at = apply_calls + 1; before = save_calls;
|
|
|
|
|
action(&admin, "reset"); assert(s_operation.state == FAILED && save_calls == before);
|
|
|
|
|
apply_fail_at = 0; get_error = ESP_FAIL; action(&admin, "save"); assert(s_operation.state == FAILED && save_calls == before); get_error = ESP_OK;
|
|
|
|
|
load_error = ESP_FAIL; before = apply_calls; action(&admin, "load"); assert(s_operation.state == FAILED && apply_calls == before); load_error = ESP_OK;
|
|
|
|
|
start_error = stop_error = ESP_FAIL; action(&admin, "start"); assert(s_operation.state == FAILED); action(&admin, "stop"); assert(s_operation.state == FAILED);
|
|
|
|
|
start_error = stop_error = ESP_OK;
|
|
|
|
|
puts("PASS Serial failure ordering: apply/get/load/save/lifecycle failures, reset commit failure rollback and explicit failed rollback");
|
|
|
|
|
|
|
|
|
|
for (unsigned i = 0; i < ACTION_COUNT; ++i) {
|
|
|
|
|
working.baud_rate = 460800; persisted.baud_rate = 230400; have_stored = true;
|
|
|
|
|
if (i == APPLY) { submit(&admin, apply_body); execute(); }
|
|
|
|
|
else action(&admin, s_actions[i]);
|
|
|
|
|
serial_config_t typed_working = working, typed_persisted = persisted;
|
|
|
|
|
working.baud_rate = 460800; persisted.baud_rate = 230400;
|
|
|
|
|
on_dispatcher = true;
|
|
|
|
|
if (i == APPLY) {
|
|
|
|
|
const char *names[] = {"baud", "data-bits", "parity", "stop-bits", "flow", "dtr", "rts-threshold"};
|
|
|
|
|
const char *values[] = {"230400", "7", "even", "2", "rts-cts", "on-connect", "96"};
|
|
|
|
|
for (unsigned j = 0; j < 7; ++j) assert(set_parameter(names[j], values[j]) == 0);
|
|
|
|
|
} else {
|
|
|
|
|
char *args[] = {"serial", (char *)s_actions[i]}; assert(command_serial(2, args) == 0);
|
|
|
|
|
}
|
|
|
|
|
on_dispatcher = false;
|
|
|
|
|
assert(!memcmp(&working, &typed_working, sizeof(working)) && !memcmp(&persisted, &typed_persisted, sizeof(persisted)));
|
|
|
|
|
}
|
|
|
|
|
puts("PASS Serial CLI comparison: exact canonical command/set handlers and typed actions produce matching working/persisted config (UART/NVS doubled)");
|
|
|
|
|
|
|
|
|
|
submit(&admin, apply_body);
|
|
|
|
|
serial_operation_t pending = s_operation;
|
|
|
|
|
before = api_calls;
|
|
|
|
|
on_dispatcher = true;
|
|
|
|
|
web_serial_settings_execute(0); web_serial_settings_execute(first);
|
|
|
|
|
on_dispatcher = false;
|
|
|
|
|
assert(api_calls == before && !memcmp(&pending, &s_operation, sizeof(pending)));
|
|
|
|
|
executing_identity = admin; apply_hook = request_during_apply; execute();
|
|
|
|
|
assert(s_operation.state == OK && api_calls == before + 1);
|
|
|
|
|
operation_begin(&other, NULL); operation_expect("200 OK"); assert(strstr(output, "\"state\":\"idle\""));
|
|
|
|
|
operation_begin(&admin, NULL); operation_expect("200 OK"); assert(strstr(output, "\"state\":\"ok\""));
|
|
|
|
|
puts("PASS Serial interleaving: obsolete/zero IDs cannot execute replacement, executing slot rejects reset and remains pollable, completed result isolated by session");
|
|
|
|
|
|
|
|
|
|
submit(&admin, apply_body); before = api_calls; now += 30000000LL; execute(); assert(s_operation.state == CANCELLED && api_calls == before);
|
|
|
|
|
submit(&admin, apply_body); web_session_store_invalidate(admin.view.id); execute(); assert(s_operation.state == CANCELLED && api_calls == before);
|
|
|
|
|
admin = mint(&alice); submit(&admin, apply_body); db_fail = true; execute(); db_fail = false; assert(s_operation.state == CANCELLED && api_calls == before);
|
|
|
|
|
submit(&other, apply_body); apply_hook = revoke_during_apply; execute(); assert(s_operation.state == OK); /* admitted work can finish */
|
|
|
|
|
operation_begin(&other, NULL); operation_expect("401 Unauthorized");
|
|
|
|
|
web_cookie_auth_stop(); assert(web_cookie_auth_start() == ESP_OK); admin = mint(&alice);
|
|
|
|
|
operation_begin(&admin, NULL); operation_expect("200 OK"); assert(strstr(output, "idle"));
|
|
|
|
|
submit(&admin, apply_body); web_cookie_auth_stop(); assert(web_cookie_auth_start() == ESP_OK); before = api_calls; execute(); assert(s_operation.state == CANCELLED && api_calls == before);
|
|
|
|
|
puts("PASS Serial currentness: queue deadline, session revocation, database failure, stop/restart IDs and admitted-work completion after revocation");
|
|
|
|
|
|
|
|
|
|
admin = mint(&alice); submit(&admin, apply_body); before = api_calls;
|
|
|
|
|
stale_user = alice.user_id; execute(); stale_user = 0;
|
|
|
|
|
assert(s_operation.state == CANCELLED && api_calls == before);
|
|
|
|
|
zero(&s_operation.principal, sizeof(s_operation.principal)); zero(&s_operation.config, sizeof(s_operation.config));
|
|
|
|
|
admin = mint(&alice); now = admin.view.expires_at_us - 1;
|
|
|
|
|
submit(&admin, apply_body);
|
|
|
|
|
now = admin.view.expires_at_us;
|
|
|
|
|
assert(now < s_operation.deadline); execute();
|
|
|
|
|
assert(s_operation.state == CANCELLED && api_calls == before);
|
|
|
|
|
puts("PASS Serial queued authorization: missed account revocation and absolute cookie expiry cancel without serial/NVS calls and wipe retained request data");
|
|
|
|
|
|
|
|
|
|
admin = mint(&alice); send_fail = true; submit(&admin, apply_body); send_fail = false; execute(); assert(s_operation.state == OK);
|
|
|
|
|
operation_begin(&admin, NULL); operation_expect("200 OK"); assert(strstr(output, "\"state\":\"ok\""));
|
|
|
|
|
for (unsigned failure = 1; failure <= 3; ++failure) {
|
|
|
|
|
operation_begin(&admin, NULL); server.config.max_resp_headers = failure - 1;
|
|
|
|
|
before = sends; assert(web_serial_settings_handler(&req) != ESP_OK && sends == before);
|
|
|
|
|
}
|
|
|
|
|
server.config.max_resp_headers = 8;
|
|
|
|
|
s_next_id = UINT32_MAX; operation_begin(&admin, apply_body); operation_expect("503 Service Unavailable");
|
|
|
|
|
puts("PASS Serial result transport: lost acknowledgement does not cancel/replay, bounded headers/response, no ID wrap");
|
|
|
|
|
}
|