Add SSH Console Ownership Boundary (Phase 8D.4)

Implement transport-qualified session identity and immutable owner
adapters
for SSH console lifecycle and output-drain operations. Add focused host
tests
covering admission, stale identities, deferred actions, completion
races,
prompts, backpressure, and slot reuse. Update Phase 8D documentation and
current-state tracking.
This commit is contained in:
2026-09-06 09:07:11 +02:00
parent f9ee6eec9c
commit 117c694cd4
13 changed files with 488 additions and 60 deletions
+132
View File
@@ -0,0 +1,132 @@
static admin_ssh_console_token_t a = { .slot_index=0, .session_id=7, .slot_generation=1 };
static admin_ssh_console_token_t b = { .slot_index=1, .session_id=7, .slot_generation=1, .transport=1 };
static user_principal_t admin = { .role=USER_ROLE_ADMIN };
static bool drained(const admin_ssh_console_token_t *t)
{ assert(!lock_depth); assert(t->session_id==7); return owner_drained; }
static esp_err_t perform(const admin_ssh_console_token_t *t,
admin_ssh_deferred_action_type_t action, uint32_t arg)
{ (void)t; (void)arg; assert(!lock_depth); assert(action==ADMIN_CONSOLE_DEFER_SELF_CLOSE); ++actions; return ESP_OK; }
static const admin_console_owner_t owner = {
.supported_actions=1U << ADMIN_CONSOLE_DEFER_SELF_CLOSE, .drained=drained, .perform=perform,
};
static void pump(void (*task)(void *)) { if (!setjmp(loop_done)) task(NULL); }
static void feed(const admin_ssh_console_token_t *t, const char *s)
{ size_t n=0; assert(admin_ssh_console_feed_input(t,(const uint8_t *)s,strlen(s),&n)); assert(n==strlen(s)); }
static void clear_output(const admin_ssh_console_token_t *t)
{ uint8_t out[4096]; size_t n; assert(admin_ssh_console_read_output(t,out,sizeof(out),&n)==ESP_OK); }
static void competing_completion(void)
{
size_t n=99;
assert(!admin_ssh_console_feed_input(&b,(const uint8_t *)"\t",1,&n));
assert(n==0 && s_completion_busy);
}
static void reopen_during_completion(void)
{
assert(!lock_depth && s_completion_busy);
admin_ssh_console_close(&a);
++a.slot_generation;
assert(admin_ssh_console_open_owned(&a,&admin,&owner)==ESP_OK);
clear_output(&a);
}
static void hidden_reply(void) { feed(&a,"secret\r"); }
static void cancel_reply(void) { feed(&a,"secret\x03"); }
static void close_prompt(void) { admin_ssh_console_close(&a); }
static void close_during_delay(void) { if (ticks>=200) admin_ssh_console_close(&a); }
static void close_during_command(void)
{
assert(s_sessions[0].executing);
admin_ssh_console_close(&a);
assert(admin_ssh_console_open_owned(&a,&admin,&owner)==ESP_ERR_INVALID_STATE);
}
static void setup_dispatch(void)
{ s_dispatch_remote=true; s_dispatch_token=a; s_sessions[0].executing=true; s_sessions[0].command_pending=true; }
int main(void)
{
assert(admin_ssh_console_init()==ESP_OK);
assert(admin_ssh_console_open_owned(&a,&admin,&owner)==ESP_ERR_INVALID_STATE);
assert(admin_ssh_console_start_uart_frontend()==ESP_OK);
principal_current=false;
assert(admin_ssh_console_open_owned(&a,&admin,&owner)==ESP_ERR_INVALID_STATE);
principal_current=true;
assert(admin_ssh_console_open_owned(&a,&admin,&owner)==ESP_OK);
assert(admin_ssh_console_open_owned(&b,&admin,&owner)==ESP_OK);
admin_ssh_console_token_t other=a; other.transport=1;
assert(admin_ssh_console_open_owned(&other,&admin,&owner)==ESP_ERR_INVALID_STATE);
admin_ssh_console_close(&other);
assert(!admin_ssh_console_accepts_input(&other));
other=a; ++other.slot_generation; admin_ssh_console_close(&other);
assert(admin_ssh_console_accepts_input(&a));
clear_output(&a); clear_output(&b);
completion_hook=competing_completion; feed(&a,"\t"); completion_hook=NULL;
assert(!s_completion_busy && s_sessions[0].output_length && !s_sessions[1].output_length);
/* Keep the in-flight token unchanged; only the reopened session advances. */
admin_ssh_console_token_t completing=a;
completion_hook=reopen_during_completion; feed(&completing,"\t"); completion_hook=NULL;
assert(a.slot_generation==completing.slot_generation+1);
assert(!s_completion_busy && !s_sessions[0].output_length && !s_sessions[0].input_length);
assert(!admin_ssh_console_accepts_input(&completing));
feed(&a,"\t"); assert(s_sessions[0].output_length); clear_output(&a);
feed(&a,"help\r"); assert(runs==0); pump(worker_task); assert(runs==1);
feed(&a,"\x1b[A"); assert(!strcmp((char *)s_sessions[0].input,"help"));
feed(&a,"\x03");
feed(&a,"stale\r"); admin_ssh_console_close(&a);
++a.slot_generation; assert(admin_ssh_console_open_owned(&a,&admin,&owner)==ESP_OK);
pump(worker_task); assert(runs==1);
feed(&a,"revoked\r"); principal_current=false; pump(worker_task); assert(runs==1); principal_current=true;
admin_request_t uart={ .origin=ADMIN_REQUEST_UART0 };
assert(xQueueSend(s_request_queue,&uart,0)); pump(worker_task); assert(runs==2);
feed(&a,"close\r"); command_hook=close_during_command; pump(worker_task); command_hook=NULL;
admin_session_t empty={0}; assert(!memcmp(&empty,&s_sessions[0],sizeof(empty)));
++a.slot_generation; assert(admin_ssh_console_open_owned(&a,&admin,&owner)==ESP_OK);
setup_dispatch(); clear_output(&a); prompt_hook=hidden_reply;
uint8_t secret[32]; size_t n;
assert(admin_ssh_console_dispatch_read_input("Password: ",secret,sizeof(secret),true,&n)==ESP_OK);
assert(n==6 && !memcmp(secret,"secret",6));
assert(s_sessions[0].output_length==strlen("Password: \r\n"));
clear_output(&a);
assert(admin_ssh_console_dispatch_read_input("Visible: ",secret,sizeof(secret),false,&n)==ESP_OK);
assert(s_sessions[0].output_length==strlen("Visible: secret\r\n"));
prompt_hook=cancel_reply;
assert(admin_ssh_console_dispatch_read_input("Password: ",secret,sizeof(secret),true,&n)==ESP_ERR_INVALID_STATE);
assert(n==0 && secret[0]==0 && s_sessions[0].prompt_input[0]==0);
prompt_hook=close_prompt;
assert(admin_ssh_console_dispatch_read_input("Password: ",secret,sizeof(secret),true,&n)==ESP_ERR_NOT_FOUND);
assert(admin_ssh_console_open_owned(&a,&admin,&owner)==ESP_ERR_INVALID_STATE);
secure_wipe(&s_sessions[0],sizeof(s_sessions[0])); prompt_hook=NULL;
assert(admin_ssh_console_open_owned(&a,&admin,&owner)==ESP_OK);
setup_dispatch(); clear_output(&a);
assert(admin_ssh_console_dispatch_defer(ADMIN_SSH_DEFER_STOP,0)==ESP_ERR_NOT_SUPPORTED);
assert(!s_sessions[0].deferred_action_pending);
queue_full=true;
assert(admin_ssh_console_dispatch_defer(ADMIN_CONSOLE_DEFER_SELF_CLOSE,0)==ESP_ERR_TIMEOUT);
assert(!s_sessions[0].deferred_action_pending); queue_full=false;
assert(admin_ssh_console_dispatch_defer(ADMIN_CONSOLE_DEFER_SELF_CLOSE,0)==ESP_OK);
assert(!admin_ssh_console_feed_input(&a,(const uint8_t *)"x",1,&n) && n==0);
s_sessions[0].command_pending=false; owner_drained=false; ticks=0;
pump(control_task); assert(ticks==10000 && actions==0);
clear_output(&a); owner_drained=true;
/* The fake esp_console_run does not invoke registered command callbacks. */
assert(command_exit(1,NULL)==0);
assert(s_control_queue->count==1);
admin_control_request_t exit_request;
memcpy(&exit_request,s_control_queue->bytes,sizeof(exit_request));
assert(exit_request.action==ADMIN_CONSOLE_DEFER_SELF_CLOSE);
assert(token_matches(&s_sessions[0],&exit_request.token));
assert(exit_request.owner==&owner && exit_request.argument==a.session_id);
ticks=0; pump(control_task); assert(ticks==200 && actions==1);
clear_output(&a);
assert(admin_ssh_console_dispatch_defer(ADMIN_CONSOLE_DEFER_SELF_CLOSE,0)==ESP_OK);
ticks=0; delay_hook=close_during_delay; pump(control_task); delay_hook=NULL; assert(actions==1);
secure_wipe(&s_sessions[0],sizeof(s_sessions[0]));
assert(admin_ssh_console_open_owned(&a,&admin,&owner)==ESP_OK);
s_sessions[0].output_length=4096; ticks=0;
assert(ssh_output_write(&a,"x",1)==-1 && errno==EAGAIN && ticks==5000);
clear_output(&a); s_dispatch_output_previous_cr=false;
assert(ssh_output_write(&a,"a\nb\r\n",6)==6);
assert(s_sessions[0].output_length==7);
admin_ssh_console_close(&a);
assert(ssh_output_write(&a,"x",1)==-1 && errno==EPIPE);
assert(!lock_depth);
puts("PASS: admission/identity, two owners, completion contention/reopen, history, queued stale/revoked work, UART dispatch, hidden/disconnected prompts, exit-to-SELF_CLOSE, deferred rejection/drain/close, 5s output backpressure");
}