diff --git a/frontier-cli/boxen/input_decoder.c b/frontier-cli/boxen/input_decoder.c index 8d12eba19..d7420220e 100644 --- a/frontier-cli/boxen/input_decoder.c +++ b/frontier-cli/boxen/input_decoder.c @@ -2,41 +2,49 @@ * input_decoder.c -- private terminal input decoder for the boxen tb2 backend. * * 2026-06-29 JES #809 M1: PTY-replay harness + decoder stub. + * 2026-06-30 JES #810 M2: cursor-key / SS3 / modifier-param / control-char + * / UTF-8 state machine. This is the first milestone that emits real + * events from the buffered byte stream. * - * Scope of this file at M1: + * Scope of this file at M2: * - Allocate / free the input_decoder_t struct. - * - Provide a ring buffer that input_decoder_inject_bytes() fills (test seam). - * - input_decoder_poll() always returns BOXEN_ERR_TIMEOUT (the state machine - * that drains the ring buffer into boxen_event_t lands in M2). - * - Mouse-enable and Kitty-enable record the request but emit nothing. - * - * Out of scope for M1: - * - The escape-sequence state machine (M2 -- GROUND / ESC_RECEIVED / - * CSI_COLLECTING / SS3_RECEIVED states; see INPUT_DECODER_PLAN.md s4). - * - SGR mouse / X10 mouse parsing (M3). - * - Bracketed paste (M4) including the BOXEN_EV_PASTE ABI addition. - * - Kitty CSI-u decode (M5). - * - Wiring into backend_tb2.c (M6). + * - Buffer bytes via input_decoder_inject_bytes() (test seam) -- M6 will + * replace inject with a real read(2) loop on tty_fd. + * - State machine through GROUND / ESC_RECEIVED / CSI_COLLECTING / + * SS3_RECEIVED / UTF8_CONT. + * - Emit boxen_event_t for: plain CSI cursor keys, SS3 cursor + nav keys, + * CSI cursor / nav / function keys with modifier params 2..16, ~-form + * nav (Ins/Del/PgUp/PgDn) and ~-form F-keys (F1 legacy, F5..F12), CSI + * P/Q/R/S function keys with modifiers, ESC-prefix Meta (Alt+letter), + * ESC-alone, control chars 0x01..0x1A and 0x7F, printable ASCII, UTF-8 + * multi-byte codepoints. + * - Partial-sequence robustness: parsing state survives across poll calls + * so a sequence split mid-stream (read(2) boundary, inject_bytes + * boundary) completes correctly on the next poll. + * + * Out of scope for M2: + * - SGR mouse / X10 mouse parsing (M3 -- final byte M/m on a CSI<-prefixed + * sequence). + * - Bracketed paste (M4 -- final byte ~ on params 200/201). + * - Kitty CSI-u decode (M5 -- final byte u). + * - The real read(2) / select(2) loop on tty_fd (M6). + * - Mouse-enable escape-sequence WRITE side (M3/M4 wire the bytes). * - The /mouse on/off slash command (M7). * - * The M1 deliverable is the harness, not the decoder. This file exists so - * the M2 sub-agent has a place to grow the state machine and so that the - * tests/input_decoder_tests.c harness has a real linkable surface that - * exercises the API shape (create / inject / poll / destroy). + * Plan reference: planning/phase_c/INPUT_DECODER_PLAN.md sections 3.1, 3.2, + * 3.3, 3.4, 3.5, 3.10, 3.11, 3.12, 4 (state machine), 7 (M2 scope). * * Threading: see the contract block in input_decoder.h. Summary: single * owner thread; GIL-held at every API entry/exit; the GIL is dropped only * inside the M6 blocking read inside input_decoder_poll, and the * single-owner invariant means no other thread can touch the decoder * during that window. No locks, no atomics -- by design. - * - * See planning/phase_c/INPUT_DECODER_PLAN.md sections 2, 6, 7 for the full - * design. */ #include "input_decoder.h" #include +#include #include #include @@ -54,8 +62,45 @@ * masked-index arithmetic if we move from memmove-on-drain to a true ring. */ #define INPUT_DECODER_BUFFER_SIZE 4096 +/* 2026-06-30 JES #810 M2: CSI parameter buffer. + * + * A modifier-rich CSI sequence holds at most ~3 semicolon-separated params + * (\e[1;3D, \e[15;5~, etc.). M3's SGR mouse adds button/x/y to that, still + * well under 5. 16 bytes of parameter text -- digits + ';' -- is comfortably + * over the longest sequence we expect (the legacy F-keys reach 2 digits; + * SGR mouse coords reach ~3 digits per param; total ~10 chars). Bound is + * load-bearing: it caps stack writes inside csi_parse_params and prevents + * a malicious terminal from steering us into UB by emitting infinite + * parameter bytes (CSI params are 0x30..0x3F per the standard; a stream of + * '9999;9999;...;9999' is a DoS vector if uncapped). When the buffer is + * full we treat further parameter bytes as a parse error and reset to + * GROUND -- safe, conservative, and the M3 SGR mouse code will inherit the + * same bound. */ +#define INPUT_DECODER_CSI_BUF_SIZE 64 + +/* 2026-06-30 JES #810 M2: parser state machine states. + * + * Names match plan section 4.1 one-to-one for grep'ability. PASTE_ACTIVE, + * OSC_COLLECTING, KITTY_COLLECTING are reserved for M4 / M5 and not used + * by this milestone's transitions -- the M2 implementation never sets them. */ +typedef enum { + DEC_STATE_GROUND = 0, + DEC_STATE_ESC_RECEIVED, + DEC_STATE_CSI_COLLECTING, + DEC_STATE_SS3_RECEIVED, + DEC_STATE_UTF8_CONT, + /* 2026-06-30 JES #810 M2 review-followup: drain a malformed/oversized CSI + * sequence to its final byte without emitting any event. Entered when + * csi_buf overflows or when csi_parse_params rejects a non-numeric byte. + * Prevents the post-overflow parameter tail from being emitted as + * synthetic printable keystrokes (the original M2 implementation reset to + * GROUND on overflow, which let `\e[ <65+ digits> A` inject the trailing + * digits into the input stream after the cap was hit). */ + DEC_STATE_CSI_SWALLOW, +} dec_state_t; + struct input_decoder { - /* TTY fd (or -1 for the test harness). M1: unused. M2+ uses select(2) + /* TTY fd (or -1 for the test harness). M2: unused. M6 uses select(2) * + read(2) on this fd inside input_decoder_poll. */ int tty_fd; @@ -67,21 +112,23 @@ struct input_decoder { * M5 will write \e[=1u to tty_fd and probe for a response. */ bool kitty_enabled; - /* Linear scratch buffer used as a simple drain-from-head queue. - * fill_len is the number of bytes currently held, starting at buf[0] - * (the "fill level" -- not an index, despite the future plan to grow - * into a head/tail ring). + /* 2026-06-30 JES #810 M2: byte buffer with head/tail cursors. * - * Future-tense: the M2 state machine will consume from the head and - * compact (memmove) on drain, or be promoted to a head/tail ring if - * profiling shows the memmove is hot. For M1 the buffer is filled by - * inject_bytes (test seam) and never drained because poll always returns - * BOXEN_ERR_TIMEOUT. + * Bytes are deposited at buf[fill_len] by inject_bytes (M6: read(2)). + * They are consumed from buf[head] by the parser inside poll. When the + * parser drains a complete event we may have head > 0 with unparsed tail + * bytes (next event in a burst, or a partial sequence we'll resume on + * the next poll); see compact_buffer() for the memmove that reclaims + * the prefix when inject_bytes needs room. * - * Invariant: fill_len <= INPUT_DECODER_BUFFER_SIZE. Enforced by clamp - * in inject_bytes; asserted at top of inject_bytes so a future drain - * regression cannot violate it silently. */ + * Invariants: + * 0 <= head <= fill_len <= INPUT_DECODER_BUFFER_SIZE + * buf[head..fill_len) is the live byte stream to parse next. + * + * For M2 the only producer is inject_bytes; M6 adds the read(2) path + * to the same buf without changing this contract. */ uint8_t buf[INPUT_DECODER_BUFFER_SIZE]; + size_t head; size_t fill_len; /* Cumulative count of bytes dropped because the ring was full when @@ -91,6 +138,41 @@ struct input_decoder { * decreases. This is the security-relevant back-pressure signal * called out by plan section 3.12 (burst-read robustness). */ size_t dropped_bytes; + + /* 2026-06-30 JES #810 M2: parser state. + * + * state -- current state-machine node (DEC_STATE_*). Persistent across + * poll calls so partial sequences resume correctly. + * csi_buf / csi_len -- parameter + intermediate bytes between the CSI + * introducer (\e[) and the final byte (0x40..0x7E). Capped at + * INPUT_DECODER_CSI_BUF_SIZE; overflow transitions to CSI_SWALLOW so + * the rest of the sequence is consumed without emitting. + * utf8_lead / utf8_remaining / utf8_codepoint -- UTF-8 lead byte + + * continuation accumulator. remaining == 0 in GROUND state. The + * lead byte is retained so the overlong-encoding check (see + * decode_utf8_codepoint_valid) can compare the assembled codepoint + * against the minimum-encoding boundary for its byte length. */ + dec_state_t state; + uint8_t csi_buf[INPUT_DECODER_CSI_BUF_SIZE]; + size_t csi_len; + + uint8_t utf8_lead; + uint8_t utf8_remaining; + uint32_t utf8_codepoint; + + /* 2026-06-30 JES #810 M2 review-followup: parse-error counter. + * + * Incremented on every silent rejection path (CSI overflow, unknown CSI + * final byte, malformed CSI parameter, invalid SS3 final, invalid UTF-8 + * codepoint -- surrogate, overlong, out-of-range). Surfaced via the + * test-seam accessor input_decoder_parse_errors() so tests can assert + * the rejection path actually fired (otherwise a "no event emitted" + * outcome is indistinguishable from a benign buffer-empty timeout). + * + * Monotonic; never decreases. The M6 production path will use the same + * counter as a runaway-terminal detection signal alongside + * dropped_bytes. */ + size_t parse_errors; }; /* ------------------------------------------------------------------------- @@ -103,10 +185,19 @@ input_decoder_t *input_decoder_create(int tty_fd) { return NULL; } dec->tty_fd = tty_fd; + /* calloc zeroed the rest; explicit assignments below mirror the doc + * comments so the audit trail is unambiguous. */ dec->mouse_enabled = false; dec->kitty_enabled = false; + dec->head = 0; dec->fill_len = 0; dec->dropped_bytes = 0; + dec->state = DEC_STATE_GROUND; + dec->csi_len = 0; + dec->utf8_lead = 0; + dec->utf8_remaining = 0; + dec->utf8_codepoint = 0; + dec->parse_errors = 0; return dec; } @@ -140,28 +231,6 @@ bool input_decoder_mouse_enabled(const input_decoder_t *dec) { return dec->mouse_enabled; } -/* ------------------------------------------------------------------------- - * Poll (M1 stub) - * ---------------------------------------------------------------------- */ - -int input_decoder_poll(input_decoder_t *dec, boxen_event_t *out, int timeout_ms) { - (void)timeout_ms; /* M1: ignored; M2+ uses it in select(2). */ - - if (dec == NULL || out == NULL) { - return BOXEN_ERR_INVALID; - } - - /* Always-zeroed out parameter on timeout, per the plan section 2.2: - * "On timeout returns BOXEN_ERR_TIMEOUT (*out is zeroed)." */ - memset(out, 0, sizeof(*out)); - - /* M1 stub: the state machine that consumes the ring buffer is M2's - * deliverable. Until then we always report timeout so the only event - * a caller can observe is "nothing happened". The buffered bytes - * remain in place for the M2 implementation to drain. */ - return BOXEN_ERR_TIMEOUT; -} - /* ------------------------------------------------------------------------- * Kitty enable (M1 stub; full impl in M5) * ---------------------------------------------------------------------- */ @@ -182,6 +251,677 @@ bool input_decoder_kitty_enabled(const input_decoder_t *dec) { return dec->kitty_enabled; } +/* ------------------------------------------------------------------------- + * 2026-06-30 JES #810 M2: buffer compaction. + * + * When the parser has consumed bytes from buf[0..head), compact the live + * tail buf[head..fill_len) back to the front so inject_bytes (or M6's + * read(2)) has the full ring available again. Cheap: typical fill is a + * single sequence (~6-16 bytes); memmove is O(n) on a tiny n. + * + * Called eagerly at the bottom of poll once an event has been emitted (or + * we've reached the end of buffered bytes), and from inject_bytes when the + * available space would otherwise be smaller than the requested write. + * Invariant: head == 0 on return. + * ---------------------------------------------------------------------- */ +static void compact_buffer(input_decoder_t *dec) { + /* 2026-06-30 JES #810 M2 review-followup: defense-in-depth on the + * head <= fill_len invariant. Without the assert, a hypothetical future + * off-by-one in the parser that pushed head past fill_len would silently + * underflow `live` (size_t subtraction) and produce a catastrophic OOB + * memmove. The state machine has 5+ head++ sites; cheap to pin the + * invariant at the choke point. */ + assert(dec->head <= dec->fill_len); + if (dec->head == 0) { + return; + } + size_t live = dec->fill_len - dec->head; + if (live > 0) { + memmove(dec->buf, dec->buf + dec->head, live); + } + dec->head = 0; + dec->fill_len = live; +} + +/* ------------------------------------------------------------------------- + * 2026-06-30 JES #810 M2: helpers. + * + * emit_key initializes the out event to BOXEN_EV_KEY with the given fields; + * the caller does not need to memset. All M2 emit sites funnel through + * here so the event shape stays consistent and we have one place to assert + * invariants when reviewing. + * ---------------------------------------------------------------------- */ +static void emit_key(boxen_event_t *out, boxen_key_t key, uint32_t ch, + uint16_t mod) { + memset(out, 0, sizeof(*out)); + out->type = BOXEN_EV_KEY; + out->key.key = key; + out->key.ch = ch; + out->key.mod = mod; +} + +/* 2026-06-30 JES #810 M2: modifier param decoder. + * + * Maps the modifier-param integer (1-based; 1=none, 2=Shift, 3=Alt, 4=Shift|Alt, + * 5=Ctrl, ..., 16=Shift|Alt|Ctrl|Meta) to the BOXEN_MOD_* bitmask. Plan + * section 4.3 spells out the algorithm: subtract 1, then bit 0=SHIFT, + * bit 1=ALT, bit 2=CTRL, bit 3=META. + * + * N <= 1 (or absent / unparseable) -> BOXEN_MOD_NONE. N > 16 is treated + * as NONE rather than masked-and-emitted because the protocol does not + * define modifier params beyond 16 and a wild value is more likely a parse + * error or hostile input than a legitimate combination. */ +static uint16_t modifier_from_param(int n) { + if (n <= 1 || n > 16) { + return BOXEN_MOD_NONE; + } + int bits = n - 1; + uint16_t mod = 0; + if (bits & 0x1) mod |= BOXEN_MOD_SHIFT; + if (bits & 0x2) mod |= BOXEN_MOD_ALT; + if (bits & 0x4) mod |= BOXEN_MOD_CTRL; + if (bits & 0x8) mod |= BOXEN_MOD_META; + return mod; +} + +/* 2026-06-30 JES #810 M2: parse the param prefix of a CSI sequence. + * + * The csi_buf holds the bytes that arrived between the CSI introducer + * (\e[) and the final byte (already consumed by the caller). It is a + * semicolon-separated list of decimal integers, possibly with intermediate + * bytes (0x20..0x2F) that we do not yet use at M2. + * + * out_params receives up to max_params parsed integers; *out_count is the + * number of parameters actually present (which may be 0 for sequences like + * \e[A). Absent / empty positions default to 0 -- the caller distinguishes + * "absent" from "0" by checking *out_count. + * + * Returns true on success, false if the buffer contains a non-digit / + * non-';' byte outside the parameter alphabet (which the M2 callers treat + * as a parse error -- reset to GROUND, no event emitted). Non-numeric + * intermediates are not expected on the M2 paths (\e[<...M is M3); their + * presence is treated as failure here so we surface unknown-protocol bytes + * as a clean drop rather than silently misparsing. */ +static bool csi_parse_params(const uint8_t *csi_buf, size_t csi_len, + int *out_params, size_t max_params, + size_t *out_count) { + /* 2026-06-30 JES #810 M2 review-followup: cap accumulator below INT_MAX + * to defeat signed-integer-overflow UB on adversarial input. No + * legitimate CSI parameter exceeds ~10000 (largest in spec is the SGR + * mouse coord, bounded by terminal width/height). Saturating at + * CSI_PARAM_CAP and refusing to grow it further keeps the accumulator + * far from INT_MAX even on a runaway terminal that fills the entire + * 64-byte CSI buffer with digits. modifier_from_param's `n > 16` + * guard rejects the saturated value cleanly. */ + enum { CSI_PARAM_CAP = 100000 }; + + size_t count = 0; + int cur = 0; + bool in_param = false; + + for (size_t i = 0; i < csi_len; i++) { + uint8_t b = csi_buf[i]; + if (b >= '0' && b <= '9') { + int digit = b - '0'; + if (cur >= CSI_PARAM_CAP) { + /* Already saturated; ignore further digits to avoid + * approaching INT_MAX. */ + cur = CSI_PARAM_CAP; + } else { + cur = (cur * 10) + digit; + if (cur > CSI_PARAM_CAP) { + cur = CSI_PARAM_CAP; + } + } + in_param = true; + } else if (b == ';') { + if (count < max_params) { + out_params[count] = in_param ? cur : 0; + count++; + } else { + /* 2026-06-30 JES #810 M2 review-followup: clamp count at + * max_params to keep the contract honest -- the caller + * reads params[0..count); allowing count to grow past + * max_params would let a future caller read uninitialized + * stack memory. */ + } + cur = 0; + in_param = false; + } else { + /* Unexpected byte for an M2 sequence. M3's SGR mouse will + * extend this to accept '<' as a leading intermediate; for + * now we conservatively bail. */ + return false; + } + } + /* Trailing param (no terminating ';'). */ + if (in_param || csi_len == 0) { + if (count < max_params) { + out_params[count] = cur; + if (in_param) { + count++; + } + } + } + *out_count = count; + return true; +} + +/* 2026-06-30 JES #810 M2: dispatch a complete CSI sequence to a boxen_event. + * + * Returns true if an event was emitted, false if the sequence is unknown + * (silently discarded -- never emitted as printable; plan section 3.12). + * + * The CSI dispatch table follows plan section 4.3 with M2-scope final + * bytes only: A/B/C/D (arrows), H/F (Home/End), ~ (Ins/Del/PgUp/PgDn, + * F1-legacy, F5..F12), P/Q/R/S (F1..F4 with modifier). M, m, u are + * reserved for M3 / M5 and currently fall through to "unknown". + * + * For the ~ form, plan section 3.3 / 3.4 specify the first param identifies + * the key (2=Ins, 3=Del, 5=PgUp, 6=PgDn, 11..24=function keys) and the + * second param (when present) is the modifier. For the letter form, the + * standard convention is \e[1;NLetter where N is the modifier; the M2 + * tests reflect this. */ +static bool decode_csi(const uint8_t *csi_buf, size_t csi_len, + uint8_t final_byte, boxen_event_t *out) { + int params[8]; + size_t count = 0; + if (!csi_parse_params(csi_buf, csi_len, params, 8, &count)) { + return false; + } + + /* Letter-final cursor / nav: \e[A, \e[1;3A, \e[H, \e[1;5F, etc. The + * modifier (when present) is in params[1]. Bare \e[A has count==0; we + * coerce to no-mod. */ + uint16_t letter_mod = (count >= 2) ? modifier_from_param(params[1]) + : BOXEN_MOD_NONE; + + switch (final_byte) { + case 'A': + emit_key(out, BOXEN_KEY_UP, 0, letter_mod); + return true; + case 'B': + emit_key(out, BOXEN_KEY_DOWN, 0, letter_mod); + return true; + case 'C': + emit_key(out, BOXEN_KEY_RIGHT, 0, letter_mod); + return true; + case 'D': + emit_key(out, BOXEN_KEY_LEFT, 0, letter_mod); + return true; + case 'H': + emit_key(out, BOXEN_KEY_HOME, 0, letter_mod); + return true; + case 'F': + emit_key(out, BOXEN_KEY_END, 0, letter_mod); + return true; + case 'P': + emit_key(out, BOXEN_KEY_F1, 0, letter_mod); + return true; + case 'Q': + emit_key(out, BOXEN_KEY_F2, 0, letter_mod); + return true; + case 'R': + emit_key(out, BOXEN_KEY_F3, 0, letter_mod); + return true; + case 'S': + emit_key(out, BOXEN_KEY_F4, 0, letter_mod); + return true; + case '~': { + /* ~-form: first param identifies key; second (if present) is the + * modifier. */ + if (count == 0) { + return false; + } + uint16_t tilde_mod = (count >= 2) ? modifier_from_param(params[1]) + : BOXEN_MOD_NONE; + switch (params[0]) { + case 2: emit_key(out, BOXEN_KEY_INSERT, 0, tilde_mod); return true; + case 3: emit_key(out, BOXEN_KEY_DELETE, 0, tilde_mod); return true; + case 5: emit_key(out, BOXEN_KEY_PGUP, 0, tilde_mod); return true; + case 6: emit_key(out, BOXEN_KEY_PGDN, 0, tilde_mod); return true; + case 11: emit_key(out, BOXEN_KEY_F1, 0, tilde_mod); return true; + case 12: emit_key(out, BOXEN_KEY_F2, 0, tilde_mod); return true; + case 13: emit_key(out, BOXEN_KEY_F3, 0, tilde_mod); return true; + case 14: emit_key(out, BOXEN_KEY_F4, 0, tilde_mod); return true; + case 15: emit_key(out, BOXEN_KEY_F5, 0, tilde_mod); return true; + case 17: emit_key(out, BOXEN_KEY_F6, 0, tilde_mod); return true; + case 18: emit_key(out, BOXEN_KEY_F7, 0, tilde_mod); return true; + case 19: emit_key(out, BOXEN_KEY_F8, 0, tilde_mod); return true; + case 20: emit_key(out, BOXEN_KEY_F9, 0, tilde_mod); return true; + case 21: emit_key(out, BOXEN_KEY_F10, 0, tilde_mod); return true; + case 23: emit_key(out, BOXEN_KEY_F11, 0, tilde_mod); return true; + case 24: emit_key(out, BOXEN_KEY_F12, 0, tilde_mod); return true; + /* params[0] == 200 / 201 are bracketed paste markers (M4). */ + default: return false; + } + } + default: + /* M / m (mouse, M3), u (Kitty, M5), and unknown finals all land + * here. Silently discarded -- never emit as printable. */ + return false; + } +} + +/* 2026-06-30 JES #810 M2: SS3 dispatch (\eO + one byte). + * + * SS3 form is used by VT100-compatible app-keypad mode for arrow keys, + * Home/End, and F1..F4. Modifiers do not propagate through SS3 in the + * standard -- a modified function key arrives as \e[1;NP rather than + * \eO. Plan section 3.4. */ +static bool decode_ss3(uint8_t final_byte, boxen_event_t *out) { + switch (final_byte) { + case 'A': emit_key(out, BOXEN_KEY_UP, 0, BOXEN_MOD_NONE); return true; + case 'B': emit_key(out, BOXEN_KEY_DOWN, 0, BOXEN_MOD_NONE); return true; + case 'C': emit_key(out, BOXEN_KEY_RIGHT, 0, BOXEN_MOD_NONE); return true; + case 'D': emit_key(out, BOXEN_KEY_LEFT, 0, BOXEN_MOD_NONE); return true; + case 'H': emit_key(out, BOXEN_KEY_HOME, 0, BOXEN_MOD_NONE); return true; + case 'F': emit_key(out, BOXEN_KEY_END, 0, BOXEN_MOD_NONE); return true; + case 'P': emit_key(out, BOXEN_KEY_F1, 0, BOXEN_MOD_NONE); return true; + case 'Q': emit_key(out, BOXEN_KEY_F2, 0, BOXEN_MOD_NONE); return true; + case 'R': emit_key(out, BOXEN_KEY_F3, 0, BOXEN_MOD_NONE); return true; + case 'S': emit_key(out, BOXEN_KEY_F4, 0, BOXEN_MOD_NONE); return true; + default: return false; + } +} + +/* 2026-06-30 JES #810 M2: control-char + printable ASCII dispatch. + * + * Plan section 3.11. The ctrl-letter mapping uses the standard PC layout: + * 0x01..0x1A -> CTRL_A..CTRL_Z + * 0x09 (TAB) and 0x0D (ENTER) get their dedicated key codes (which alias + * to CTRL_I / CTRL_M in the boxen header). We emit the dedicated codes + * so downstream consumers can distinguish "user pressed Tab" from "user + * pressed Ctrl-I" if their keymap ever needs to (the codes are aliases + * today but the indirection costs nothing and protects the option). + * 0x08 (^H) -> BACKSPACE. + * 0x7F (DEL) -> BACKSPACE -- macOS Terminal.app sends DEL for the + * Backspace key by default; treating both as the same logical key matches + * user expectation. + * 0x1C (FS) -> CTRL_BACKSLASH; 0x00 -> CTRL_SPACE. + * Printable 0x20..0x7E -> ch=byte, key=NONE. */ +static void decode_byte_ground(uint8_t b, boxen_event_t *out) { + /* Special cases first; the CTRL_* range catches the rest. */ + if (b == 0x09) { + emit_key(out, BOXEN_KEY_TAB, 0, BOXEN_MOD_NONE); + return; + } + if (b == 0x0D) { + emit_key(out, BOXEN_KEY_ENTER, 0, BOXEN_MOD_NONE); + return; + } + if (b == 0x08) { + emit_key(out, BOXEN_KEY_BACKSPACE, 0, BOXEN_MOD_NONE); + return; + } + if (b == 0x7F) { + emit_key(out, BOXEN_KEY_BACKSPACE, 0, BOXEN_MOD_NONE); + return; + } + if (b == 0x00) { + emit_key(out, BOXEN_KEY_CTRL_SPACE, 0, BOXEN_MOD_NONE); + return; + } + if (b == 0x1C) { + emit_key(out, BOXEN_KEY_CTRL_BACKSLASH, 0, BOXEN_MOD_NONE); + return; + } + if (b >= 0x01 && b <= 0x1A) { + /* CTRL_A..CTRL_Z are sequential in the enum starting at CTRL_A. */ + boxen_key_t k = (boxen_key_t)(BOXEN_KEY_CTRL_A + (b - 0x01)); + emit_key(out, k, 0, BOXEN_MOD_NONE); + return; + } + /* 0x20..0x7E: printable ASCII. */ + if (b >= 0x20 && b <= 0x7E) { + emit_key(out, BOXEN_KEY_NONE, (uint32_t)b, BOXEN_MOD_NONE); + return; + } + /* Anything else (0x1B is handled in poll before we reach here; + * high-bit bytes 0x80+ are also dropped in poll's GROUND branch + * before reaching us; only 0x1D / 0x1E / 0x1F group separator + * controls remain). Conservative: surfaces unexpected bytes as + * raw ch rather than silently dropping. */ + emit_key(out, BOXEN_KEY_NONE, (uint32_t)b, BOXEN_MOD_NONE); +} + +/* 2026-06-30 JES #810 M2: UTF-8 lead-byte classification. + * + * Returns the number of continuation bytes the lead expects (1..3) or 0 + * if the byte is not a valid UTF-8 lead (i.e., it's ASCII or invalid). + * The lead byte's payload bits are written to *out_codepoint -- the + * partial codepoint that subsequent continuation bytes shift in. + * + * 2026-06-30 JES #810 M2 review-followup: reject overlong-prone 2-byte + * leads 0xC0 and 0xC1 (they can only encode U+0000..U+007F, which must + * arrive as ASCII). Also reject 0xF5..0xFF leads, which can only encode + * codepoints > U+10FFFF (above the Unicode maximum). These early + * rejections close the most common UTF-8 smuggling vectors (overlong NUL + * 0xC0 0x80 etc.) without waiting for the assembled-codepoint validator + * to catch them post-assembly. */ +static int utf8_lead_classify(uint8_t b, uint32_t *out_codepoint) { + if ((b & 0xE0) == 0xC0) { /* 110xxxxx */ + if (b < 0xC2) { + /* 0xC0 / 0xC1 -- only used for overlong ASCII. */ + return 0; + } + *out_codepoint = b & 0x1F; + return 1; + } + if ((b & 0xF0) == 0xE0) { /* 1110xxxx */ + *out_codepoint = b & 0x0F; + return 2; + } + if ((b & 0xF8) == 0xF0) { /* 11110xxx */ + if (b > 0xF4) { + /* 0xF5..0xF7 leads encode codepoints > U+10FFFF. */ + return 0; + } + *out_codepoint = b & 0x07; + return 3; + } + return 0; +} + +/* 2026-06-30 JES #810 M2 review-followup: assembled-codepoint validator. + * + * Belt-and-suspenders to the lead-byte rejection above. Catches: + * - Surrogates (U+D800..U+DFFF) -- invalid in UTF-8 by RFC 3629. + * - Above-max codepoints (> U+10FFFF) -- only reachable via the + * borderline 4-byte lead 0xF4 with high continuation bytes + * (0xF4 0x90 0x80 0x80 = U+110000). + * - Overlong encodings -- codepoint smaller than the minimum the byte + * length can encode (lead_len is the number of CONTINUATION bytes, + * so 1=2-byte sequence, 2=3-byte, 3=4-byte; the minimum codepoints + * are 0x80, 0x800, 0x10000 respectively). + * + * Returns true if the codepoint is a valid Unicode scalar value for its + * UTF-8 byte length, false otherwise. Callers treat false as "emit + * U+FFFD replacement character and bump parse_errors". */ +static bool utf8_codepoint_valid(uint32_t cp, int lead_len) { + if (cp >= 0xD800 && cp <= 0xDFFF) { + return false; + } + if (cp > 0x10FFFF) { + return false; + } + if (lead_len == 1 && cp < 0x80) { + return false; + } + if (lead_len == 2 && cp < 0x800) { + return false; + } + if (lead_len == 3 && cp < 0x10000) { + return false; + } + return true; +} + +/* ------------------------------------------------------------------------- + * 2026-06-30 JES #810 M2: poll -- run the state machine against buffered + * bytes until one event emits or we run out of bytes. + * + * Returns BOXEN_OK + writes *out on a successful emit. + * Returns BOXEN_ERR_TIMEOUT (with *out zeroed) when no event can be + * produced from the currently-buffered bytes (empty buffer OR partial + * sequence still waiting for more bytes). + * Returns BOXEN_ERR_INVALID for NULL args. + * + * timeout_ms is currently unused (M6 will use it for the select(2) wait + * on tty_fd). In the test seam the caller drives bytes via inject_bytes + * before each poll, so a timeout has no observable effect. + * ---------------------------------------------------------------------- */ +int input_decoder_poll(input_decoder_t *dec, boxen_event_t *out, int timeout_ms) { + (void)timeout_ms; /* M6 wires the select(2) timeout. */ + + if (dec == NULL || out == NULL) { + return BOXEN_ERR_INVALID; + } + memset(out, 0, sizeof(*out)); + + while (dec->head < dec->fill_len) { + uint8_t b = dec->buf[dec->head]; + + switch (dec->state) { + case DEC_STATE_GROUND: + /* Plan section 4.2: GROUND transitions. */ + if (b == 0x1B) { + /* ESC: enter disambiguation. Consume the byte and + * transition; the next iteration decides between ESC-alone + * (no more bytes), CSI, SS3, or Meta. */ + dec->head++; + dec->state = DEC_STATE_ESC_RECEIVED; + continue; + } + { + uint32_t codepoint = 0; + int needed = utf8_lead_classify(b, &codepoint); + if (needed > 0) { + /* UTF-8 multi-byte lead. */ + dec->head++; + dec->utf8_codepoint = codepoint; + dec->utf8_remaining = (uint8_t)needed; + dec->utf8_lead = b; + dec->state = DEC_STATE_UTF8_CONT; + continue; + } + } + /* 2026-06-30 JES #810 M2 review-followup: any high-bit byte + * (>= 0x80) that wasn't classified as a valid UTF-8 lead is + * silently dropped. This covers: + * - 0x80..0xBF (stray continuation bytes) + * - 0xC0..0xC1 (overlong-only leads, rejected above) + * - 0xF5..0xF7 (encode codepoints > U+10FFFF, rejected above) + * - 0xF8..0xFF (invalid 5+ byte leads) + * Emitting any of these as raw ch=byte would let Latin-1- + * looking garbage and the classic UTF-8 smuggling primitives + * (overlong NUL, overlong slash) flow downstream. Bump the + * parse-error counter so tests can observe the rejection. */ + if (b >= 0x80) { + dec->head++; + dec->parse_errors++; + continue; + } + /* Single-byte: control char or printable ASCII (or high-bit + * non-lead). Emit and consume. */ + dec->head++; + decode_byte_ground(b, out); + compact_buffer(dec); + return BOXEN_OK; + + case DEC_STATE_ESC_RECEIVED: + /* We have committed to consuming the ESC byte. Now classify + * the follow-up. See plan section 4.2 / 4.4. */ + if (b == '[') { + dec->head++; + dec->state = DEC_STATE_CSI_COLLECTING; + dec->csi_len = 0; + continue; + } + if (b == 'O') { + dec->head++; + dec->state = DEC_STATE_SS3_RECEIVED; + continue; + } + if (b == 0x1B) { + /* Double ESC: emit the first ESCAPE; re-enter + * ESC_RECEIVED for the new one without consuming the + * follow-up (next loop iteration handles it). */ + emit_key(out, BOXEN_KEY_ESCAPE, 0, BOXEN_MOD_NONE); + dec->state = DEC_STATE_ESC_RECEIVED; + dec->head++; /* consume the second ESC, re-arm state */ + compact_buffer(dec); + return BOXEN_OK; + } + /* ESC + any other byte: Meta-key. Emit ch=byte, mod=ALT. + * Plan section 3.5 / 4.2: this covers \ea, \eb, \ef and any + * other ESC-prefixed printable. For control chars after ESC + * (rare) we still apply the ALT mod and use the same single- + * byte decode logic, then OR in ALT. + * + * For non-control: ch=b, key=NONE, mod=ALT. */ + dec->head++; + if (b >= 0x20 && b <= 0x7E) { + emit_key(out, BOXEN_KEY_NONE, (uint32_t)b, BOXEN_MOD_ALT); + } else if (b >= 0x01 && b <= 0x1A) { + boxen_key_t k = (boxen_key_t)(BOXEN_KEY_CTRL_A + (b - 0x01)); + emit_key(out, k, 0, BOXEN_MOD_ALT); + } else { + emit_key(out, BOXEN_KEY_NONE, (uint32_t)b, BOXEN_MOD_ALT); + } + dec->state = DEC_STATE_GROUND; + compact_buffer(dec); + return BOXEN_OK; + + case DEC_STATE_CSI_COLLECTING: { + /* Parameter / intermediate bytes are 0x20..0x3F per the + * standard. Final bytes are 0x40..0x7E. Anything else is a + * parse error -> transition to CSI_SWALLOW so the rest of the + * sequence (including any trailing param bytes that would + * otherwise be processed in GROUND as printable keystrokes) + * is drained quietly. */ + if (b >= 0x20 && b <= 0x3F) { + if (dec->csi_len >= INPUT_DECODER_CSI_BUF_SIZE) { + /* 2026-06-30 JES #810 M2 review-followup: overflow + * goes to CSI_SWALLOW, not GROUND. Resetting to + * GROUND let post-overflow parameter bytes + * (typically digits and ';') be emitted as + * printable keystrokes -- a malicious terminal could + * inject ~65+ synthetic chars via one long CSI. + * SWALLOW drops bytes until a final byte arrives. */ + dec->head++; + dec->state = DEC_STATE_CSI_SWALLOW; + dec->csi_len = 0; + dec->parse_errors++; + continue; + } + dec->csi_buf[dec->csi_len++] = b; + dec->head++; + continue; + } + if (b >= 0x40 && b <= 0x7E) { + /* Final byte: decode. */ + dec->head++; + bool emitted = decode_csi(dec->csi_buf, dec->csi_len, b, out); + dec->state = DEC_STATE_GROUND; + dec->csi_len = 0; + if (emitted) { + compact_buffer(dec); + return BOXEN_OK; + } + /* Unknown final: discard silently, keep parsing. */ + dec->parse_errors++; + continue; + } + /* Garbage in the middle of a CSI (byte outside the + * parameter / intermediate / final ranges): switch to + * SWALLOW so any remaining param-like bytes are drained + * cleanly rather than reinterpreted as input. */ + dec->head++; + dec->state = DEC_STATE_CSI_SWALLOW; + dec->csi_len = 0; + dec->parse_errors++; + continue; + } + + case DEC_STATE_CSI_SWALLOW: + /* 2026-06-30 JES #810 M2 review-followup: drain the tail of + * a malformed / oversized CSI. Consume bytes silently until + * the final byte (0x40..0x7E) is seen, then return to + * GROUND. Non-final / non-param bytes are also tolerated + * here -- the goal is to NEVER emit while swallowing. A + * sequence that never terminates lives in this state until + * a final byte or until the buffer empties (in which case + * the next inject continues swallowing). */ + dec->head++; + if (b >= 0x40 && b <= 0x7E) { + dec->state = DEC_STATE_GROUND; + } + continue; + + case DEC_STATE_SS3_RECEIVED: { + dec->head++; + bool emitted = decode_ss3(b, out); + dec->state = DEC_STATE_GROUND; + if (emitted) { + compact_buffer(dec); + return BOXEN_OK; + } + dec->parse_errors++; + continue; + } + + case DEC_STATE_UTF8_CONT: { + if ((b & 0xC0) != 0x80) { + /* Premature non-continuation: discard the partial codepoint + * and reprocess this byte in GROUND. Do NOT consume here + * -- the next loop iteration sees it again under + * DEC_STATE_GROUND. This matches plan section 4.2 (UTF8 + * fallthrough). */ + dec->state = DEC_STATE_GROUND; + dec->utf8_remaining = 0; + dec->utf8_codepoint = 0; + dec->parse_errors++; + continue; + } + /* Valid continuation byte: shift in the 6 payload bits. */ + dec->head++; + dec->utf8_codepoint = (dec->utf8_codepoint << 6) | (b & 0x3F); + dec->utf8_remaining--; + if (dec->utf8_remaining == 0) { + /* 2026-06-30 JES #810 M2 review-followup: validate the + * assembled codepoint before emit. Rejects surrogates, + * codepoints above U+10FFFF, and overlong encodings + * (the canonical UTF-8 smuggling vectors -- overlong + * NUL, overlong slash, etc.). Per the Unicode standard + * recommendation, emit U+FFFD REPLACEMENT CHARACTER so + * downstream consumers see an unambiguous "this was + * invalid input" marker rather than a silently dropped + * keystroke or a smuggled control code. */ + int lead_len = (int)(dec->utf8_lead >= 0xF0 ? 3 : + dec->utf8_lead >= 0xE0 ? 2 : 1); + uint32_t cp = dec->utf8_codepoint; + if (!utf8_codepoint_valid(cp, lead_len)) { + cp = 0xFFFD; + dec->parse_errors++; + } + emit_key(out, BOXEN_KEY_NONE, cp, BOXEN_MOD_NONE); + dec->state = DEC_STATE_GROUND; + dec->utf8_codepoint = 0; + dec->utf8_lead = 0; + compact_buffer(dec); + return BOXEN_OK; + } + /* Still need more continuation bytes. */ + continue; + } + } + } + + /* Drained all buffered bytes without emitting an event. Two subcases: + * + * 1. state == GROUND -- truly nothing pending. Compact and return + * TIMEOUT. + * 2. state == ESC_RECEIVED with no follow-up byte -- this is the lone + * Escape disambiguation (plan section 4.4). In the production read + * loop (M6) we drain the pipe non-blocking after seeing ESC; if zero + * bytes follow, emit ESCAPE. In the test seam (and equivalently + * when input_decoder_poll is called with timeout_ms == 0 and we've + * truly exhausted the buffer) we treat the poll boundary as + * "no more bytes are coming" and emit ESCAPE. + * 3. state == any other partial -- partial sequence; preserve state + * across calls. Return TIMEOUT; next inject_bytes + poll resumes. */ + if (dec->state == DEC_STATE_ESC_RECEIVED) { + emit_key(out, BOXEN_KEY_ESCAPE, 0, BOXEN_MOD_NONE); + dec->state = DEC_STATE_GROUND; + compact_buffer(dec); + return BOXEN_OK; + } + + compact_buffer(dec); + return BOXEN_ERR_TIMEOUT; +} + /* ------------------------------------------------------------------------- * Test seam (compile-guarded) * ---------------------------------------------------------------------- */ @@ -198,6 +938,13 @@ size_t input_decoder_inject_bytes(input_decoder_t *dec, * no-op'ing and looking like the inject succeeded). */ assert(bytes != NULL); + /* 2026-06-30 JES #810 M2: compact before measuring available space. + * Without this, a buffer where head has advanced (e.g., several events + * have been drained but the tail wasn't compacted yet) reports less + * free space than actually exists. compact_buffer is a no-op when + * head==0, so this is free in the common case. */ + compact_buffer(dec); + /* Load-bearing invariant: fill_len never exceeds buffer size. Cheap * to assert here; protects M2 / M3 future drain code from a partial- * compaction bug that would otherwise produce an underflow on the @@ -229,7 +976,13 @@ size_t input_decoder_buffered_bytes(const input_decoder_t *dec) { if (dec == NULL) { return 0; } - return dec->fill_len; + /* 2026-06-30 JES #810 M2: report only live tail (head..fill_len). + * The M1 contract was "bytes injected since create"; M2 introduces the + * head cursor (parser consumption point), so "buffered" now means + * "bytes the parser has not yet consumed". Test + * test_inject_bytes_appends_to_buffer still passes because head is 0 + * until poll runs. */ + return dec->fill_len - dec->head; } size_t input_decoder_dropped_bytes(const input_decoder_t *dec) { @@ -239,4 +992,11 @@ size_t input_decoder_dropped_bytes(const input_decoder_t *dec) { return dec->dropped_bytes; } +size_t input_decoder_parse_errors(const input_decoder_t *dec) { + if (dec == NULL) { + return 0; + } + return dec->parse_errors; +} + #endif /* INPUT_DECODER_TEST_SEAM */ diff --git a/frontier-cli/boxen/input_decoder.h b/frontier-cli/boxen/input_decoder.h index 68bf1415d..fa741b352 100644 --- a/frontier-cli/boxen/input_decoder.h +++ b/frontier-cli/boxen/input_decoder.h @@ -174,6 +174,28 @@ size_t input_decoder_buffered_bytes(const input_decoder_t *dec); * (in production) a runaway terminal burst that the state machine * failed to drain. */ size_t input_decoder_dropped_bytes(const input_decoder_t *dec); + +/* 2026-06-30 JES #810 M2 review-followup: cumulative parse-error count. + * + * Bumped on every silent rejection path inside the state machine: + * - CSI buffer overflow (sequence drained via CSI_SWALLOW state) + * - CSI parameter byte rejected as non-numeric + * - Unknown CSI final byte (legitimate sequence we have no mapping for) + * - Unknown SS3 final byte + * - Stray UTF-8 continuation byte in GROUND + * - Invalid 5+ byte UTF-8 lead in GROUND + * - Premature non-continuation byte mid-UTF-8 + * - Invalid UTF-8 codepoint (surrogate, overlong, > U+10FFFF) -- the + * event still emits as U+FFFD, but the counter is bumped so the + * rejection is observable. + * + * The complement of dropped_bytes: dropped_bytes counts back-pressure + * (ring full), parse_errors counts protocol rejection. Together they + * cover every "input arrived but no event was produced" path so a + * desync between bytes-in and events-out is always attributable. + * + * Returns 0 for a NULL decoder. Monotonic; never decreases. */ +size_t input_decoder_parse_errors(const input_decoder_t *dec); #endif #ifdef __cplusplus diff --git a/tests/input_decoder_tests.c b/tests/input_decoder_tests.c index f2beee9cf..d5f825f58 100644 --- a/tests/input_decoder_tests.c +++ b/tests/input_decoder_tests.c @@ -6,6 +6,12 @@ * here as SKIP stubs that will be un-skipped one milestone at a time * (M2 through M5; see planning/phase_c/INPUT_DECODER_PLAN.md section 6.2). * + * 2026-06-30 JES #810 M2: un-SKIP the M2-scope behavioral tests -- + * CSI cursor keys, SS3 cursor keys, modifier params 2-16, nav keys with + * modifiers, function keys (SS3 + CSI forms with modifiers), ESC-alone, + * ESC-prefix Meta, partial-sequence split, control characters 0x01-0x1A, + * UTF-8 multi-byte decode. M3/M4/M5 stubs remain SKIP. + * * SKIP mechanism note (no TR_SKIP exists in test_report.h): * The TR_RUN macro records a test as "passed" if its body returns without * firing assert(). Each protocol stub below prints a SKIP line to stderr @@ -71,6 +77,23 @@ static void tr_skip(const char *reason) { fprintf(stderr, "[SKIP] %s\n", reason); } +/* 2026-06-30 JES #810 M2: shared helper -- inject a byte sequence and poll + * once, asserting the return code. Keeps each behavioral test below short + * and uniform. The non-blocking timeout (0) is intentional: every M2 test + * supplies a complete sequence (or deliberately partial sequence) up front, + * and the state machine must drain the buffer synchronously. */ +static void inject_and_poll(input_decoder_t *dec, + const uint8_t *bytes, size_t len, + int expected_rc, boxen_event_t *out) { + if (len > 0) { + size_t accepted = input_decoder_inject_bytes(dec, bytes, len); + assert(accepted == len); + } + memset(out, 0x7f, sizeof(*out)); /* poison to verify zero-on-timeout */ + int rc = input_decoder_poll(dec, out, 0); + assert(rc == expected_rc); +} + /* ------------------------------------------------------------------------- * M1 harness smoke tests (PASS today) * @@ -269,123 +292,1186 @@ static void test_inject_overflow_increments_dropped_counter(void) { } /* ------------------------------------------------------------------------- - * M2 SKIP stubs -- cursor keys + modifiers + ESC disambiguation. + * 2026-06-30 JES #810 M2: CSI plain cursor keys (plan section 3.1) + * ---------------------------------------------------------------------- */ + +static void test_csi_plain_arrow_up(void) { + input_decoder_t *dec = input_decoder_create(-1); + static const uint8_t seq[] = {0x1b, '[', 'A'}; + boxen_event_t ev; + inject_and_poll(dec, seq, sizeof(seq), BOXEN_OK, &ev); + assert(ev.type == BOXEN_EV_KEY); + assert(ev.key.key == BOXEN_KEY_UP); + assert(ev.key.mod == BOXEN_MOD_NONE); + assert(ev.key.ch == 0); + input_decoder_destroy(dec); +} + +static void test_csi_plain_arrow_down(void) { + input_decoder_t *dec = input_decoder_create(-1); + static const uint8_t seq[] = {0x1b, '[', 'B'}; + boxen_event_t ev; + inject_and_poll(dec, seq, sizeof(seq), BOXEN_OK, &ev); + assert(ev.type == BOXEN_EV_KEY); + assert(ev.key.key == BOXEN_KEY_DOWN); + assert(ev.key.mod == BOXEN_MOD_NONE); + input_decoder_destroy(dec); +} + +static void test_csi_plain_arrow_right(void) { + input_decoder_t *dec = input_decoder_create(-1); + static const uint8_t seq[] = {0x1b, '[', 'C'}; + boxen_event_t ev; + inject_and_poll(dec, seq, sizeof(seq), BOXEN_OK, &ev); + assert(ev.type == BOXEN_EV_KEY); + assert(ev.key.key == BOXEN_KEY_RIGHT); + assert(ev.key.mod == BOXEN_MOD_NONE); + input_decoder_destroy(dec); +} + +static void test_csi_plain_arrow_left(void) { + input_decoder_t *dec = input_decoder_create(-1); + static const uint8_t seq[] = {0x1b, '[', 'D'}; + boxen_event_t ev; + inject_and_poll(dec, seq, sizeof(seq), BOXEN_OK, &ev); + assert(ev.type == BOXEN_EV_KEY); + assert(ev.key.key == BOXEN_KEY_LEFT); + assert(ev.key.mod == BOXEN_MOD_NONE); + input_decoder_destroy(dec); +} + +/* ------------------------------------------------------------------------- + * 2026-06-30 JES #810 M2: SS3 cursor keys (plan section 3.1) + * ---------------------------------------------------------------------- */ + +static void test_ss3_arrow_up(void) { + input_decoder_t *dec = input_decoder_create(-1); + static const uint8_t seq[] = {0x1b, 'O', 'A'}; + boxen_event_t ev; + inject_and_poll(dec, seq, sizeof(seq), BOXEN_OK, &ev); + assert(ev.type == BOXEN_EV_KEY); + assert(ev.key.key == BOXEN_KEY_UP); + assert(ev.key.mod == BOXEN_MOD_NONE); + input_decoder_destroy(dec); +} + +static void test_ss3_arrow_down(void) { + input_decoder_t *dec = input_decoder_create(-1); + static const uint8_t seq[] = {0x1b, 'O', 'B'}; + boxen_event_t ev; + inject_and_poll(dec, seq, sizeof(seq), BOXEN_OK, &ev); + assert(ev.type == BOXEN_EV_KEY); + assert(ev.key.key == BOXEN_KEY_DOWN); + input_decoder_destroy(dec); +} + +static void test_ss3_arrow_right(void) { + input_decoder_t *dec = input_decoder_create(-1); + static const uint8_t seq[] = {0x1b, 'O', 'C'}; + boxen_event_t ev; + inject_and_poll(dec, seq, sizeof(seq), BOXEN_OK, &ev); + assert(ev.type == BOXEN_EV_KEY); + assert(ev.key.key == BOXEN_KEY_RIGHT); + input_decoder_destroy(dec); +} + +static void test_ss3_arrow_left(void) { + input_decoder_t *dec = input_decoder_create(-1); + static const uint8_t seq[] = {0x1b, 'O', 'D'}; + boxen_event_t ev; + inject_and_poll(dec, seq, sizeof(seq), BOXEN_OK, &ev); + assert(ev.type == BOXEN_EV_KEY); + assert(ev.key.key == BOXEN_KEY_LEFT); + input_decoder_destroy(dec); +} + +static void test_ss3_home(void) { + input_decoder_t *dec = input_decoder_create(-1); + static const uint8_t seq[] = {0x1b, 'O', 'H'}; + boxen_event_t ev; + inject_and_poll(dec, seq, sizeof(seq), BOXEN_OK, &ev); + assert(ev.type == BOXEN_EV_KEY); + assert(ev.key.key == BOXEN_KEY_HOME); + input_decoder_destroy(dec); +} + +static void test_ss3_end(void) { + input_decoder_t *dec = input_decoder_create(-1); + static const uint8_t seq[] = {0x1b, 'O', 'F'}; + boxen_event_t ev; + inject_and_poll(dec, seq, sizeof(seq), BOXEN_OK, &ev); + assert(ev.type == BOXEN_EV_KEY); + assert(ev.key.key == BOXEN_KEY_END); + input_decoder_destroy(dec); +} + +/* ------------------------------------------------------------------------- + * 2026-06-30 JES #810 M2: CSI modifier params 2..16 -- the #805 fix. * - * Per plan section 7, M2 un-skips and implements these. The names below - * mirror the sequences in section 3 (Protocol Coverage Matrix) so the M2 - * sub-agent can grep "M2 SKIP" to find its TDD entry points. + * Each test exercises one slot of the 16-entry modifier table in plan + * section 3.2. Param N maps to a bitmask via modifier_from_param: subtract + * 1, then bit 0=SHIFT, bit 1=ALT, bit 2=CTRL, bit 3=META. N=9 (META) is + * the bit that termbox2 silently dropped on Terminal.app and is the + * specific root cause behind issue #805. * ---------------------------------------------------------------------- */ -static void test_skip_csi_plain_arrow_up(void) { - tr_skip("M2: \\e[A -> KEY UP, mod=NONE (plan section 3.1)"); +/* Helper: run one CSI cursor-key test with given modifier param N and + * direction letter, asserting the expected modifier bitmask. */ +static void check_csi_arrow_modifier(uint8_t direction, boxen_key_t key, + int n, uint16_t expected_mod) { + input_decoder_t *dec = input_decoder_create(-1); + uint8_t seq[16]; + int len = snprintf((char *)seq, sizeof(seq), "\x1b[1;%d%c", n, direction); + assert(len > 0 && (size_t)len < sizeof(seq)); + boxen_event_t ev; + inject_and_poll(dec, seq, (size_t)len, BOXEN_OK, &ev); + assert(ev.type == BOXEN_EV_KEY); + assert(ev.key.key == key); + assert(ev.key.mod == expected_mod); + input_decoder_destroy(dec); } -static void test_skip_csi_plain_arrow_down(void) { - tr_skip("M2: \\e[B -> KEY DOWN, mod=NONE (plan section 3.1)"); +/* The 16 modifier slots for all four arrow directions. Plan section 3.2 + * declares the full table; this validates every entry to prevent silent + * regressions on the bits termbox2 used to drop (notably N=9 META). */ +static void test_csi_modifier_all_params_arrow_up(void) { + check_csi_arrow_modifier('A', BOXEN_KEY_UP, 2, BOXEN_MOD_SHIFT); + check_csi_arrow_modifier('A', BOXEN_KEY_UP, 3, BOXEN_MOD_ALT); + check_csi_arrow_modifier('A', BOXEN_KEY_UP, 4, BOXEN_MOD_ALT | BOXEN_MOD_SHIFT); + check_csi_arrow_modifier('A', BOXEN_KEY_UP, 5, BOXEN_MOD_CTRL); + check_csi_arrow_modifier('A', BOXEN_KEY_UP, 6, BOXEN_MOD_CTRL | BOXEN_MOD_SHIFT); + check_csi_arrow_modifier('A', BOXEN_KEY_UP, 7, BOXEN_MOD_ALT | BOXEN_MOD_CTRL); + check_csi_arrow_modifier('A', BOXEN_KEY_UP, 8, + BOXEN_MOD_ALT | BOXEN_MOD_CTRL | BOXEN_MOD_SHIFT); + check_csi_arrow_modifier('A', BOXEN_KEY_UP, 9, BOXEN_MOD_META); + check_csi_arrow_modifier('A', BOXEN_KEY_UP, 10, BOXEN_MOD_META | BOXEN_MOD_SHIFT); + check_csi_arrow_modifier('A', BOXEN_KEY_UP, 11, BOXEN_MOD_META | BOXEN_MOD_ALT); + check_csi_arrow_modifier('A', BOXEN_KEY_UP, 12, + BOXEN_MOD_META | BOXEN_MOD_ALT | BOXEN_MOD_SHIFT); + check_csi_arrow_modifier('A', BOXEN_KEY_UP, 13, BOXEN_MOD_META | BOXEN_MOD_CTRL); + check_csi_arrow_modifier('A', BOXEN_KEY_UP, 14, + BOXEN_MOD_META | BOXEN_MOD_CTRL | BOXEN_MOD_SHIFT); + check_csi_arrow_modifier('A', BOXEN_KEY_UP, 15, + BOXEN_MOD_META | BOXEN_MOD_ALT | BOXEN_MOD_CTRL); + check_csi_arrow_modifier('A', BOXEN_KEY_UP, 16, + BOXEN_MOD_META | BOXEN_MOD_ALT | BOXEN_MOD_CTRL | + BOXEN_MOD_SHIFT); } -static void test_skip_csi_plain_arrow_right(void) { - tr_skip("M2: \\e[C -> KEY RIGHT, mod=NONE (plan section 3.1)"); +static void test_csi_modifier_all_params_arrow_down(void) { + check_csi_arrow_modifier('B', BOXEN_KEY_DOWN, 2, BOXEN_MOD_SHIFT); + check_csi_arrow_modifier('B', BOXEN_KEY_DOWN, 9, BOXEN_MOD_META); + check_csi_arrow_modifier('B', BOXEN_KEY_DOWN, 16, + BOXEN_MOD_META | BOXEN_MOD_ALT | BOXEN_MOD_CTRL | + BOXEN_MOD_SHIFT); } -static void test_skip_csi_plain_arrow_left(void) { - tr_skip("M2: \\e[D -> KEY LEFT, mod=NONE (plan section 3.1)"); +static void test_csi_modifier_all_params_arrow_right(void) { + check_csi_arrow_modifier('C', BOXEN_KEY_RIGHT, 2, BOXEN_MOD_SHIFT); + check_csi_arrow_modifier('C', BOXEN_KEY_RIGHT, 3, BOXEN_MOD_ALT); + check_csi_arrow_modifier('C', BOXEN_KEY_RIGHT, 9, BOXEN_MOD_META); } -static void test_skip_ss3_arrow_up(void) { - tr_skip("M2: \\eOA -> KEY UP, mod=NONE (plan section 3.1)"); +static void test_csi_modifier_all_params_arrow_left(void) { + check_csi_arrow_modifier('D', BOXEN_KEY_LEFT, 2, BOXEN_MOD_SHIFT); + check_csi_arrow_modifier('D', BOXEN_KEY_LEFT, 3, BOXEN_MOD_ALT); + check_csi_arrow_modifier('D', BOXEN_KEY_LEFT, 9, BOXEN_MOD_META); } -static void test_skip_csi_modifier_alt_left_issue_805(void) { - tr_skip("M2: \\e[1;3D -> KEY LEFT, mod=ALT -- the #805 root-cause case " - "(plan section 3.2)"); +/* Spot tests for the well-known #805 sequences -- explicitly named so + * grep'ing for "issue_805" surfaces the smoking-gun cases. */ +static void test_csi_modifier_alt_left_issue_805(void) { + check_csi_arrow_modifier('D', BOXEN_KEY_LEFT, 3, BOXEN_MOD_ALT); } -static void test_skip_csi_modifier_alt_right_issue_805(void) { - tr_skip("M2: \\e[1;3C -> KEY RIGHT, mod=ALT -- the #805 root-cause case " - "(plan section 3.2)"); +static void test_csi_modifier_alt_right_issue_805(void) { + check_csi_arrow_modifier('C', BOXEN_KEY_RIGHT, 3, BOXEN_MOD_ALT); } -static void test_skip_csi_modifier_shift_up(void) { - tr_skip("M2: \\e[1;2A -> KEY UP, mod=SHIFT (plan section 3.2)"); +static void test_csi_modifier_shift_up(void) { + check_csi_arrow_modifier('A', BOXEN_KEY_UP, 2, BOXEN_MOD_SHIFT); } -static void test_skip_csi_modifier_ctrl_down(void) { - tr_skip("M2: \\e[1;5B -> KEY DOWN, mod=CTRL (plan section 3.2)"); +static void test_csi_modifier_ctrl_down(void) { + check_csi_arrow_modifier('B', BOXEN_KEY_DOWN, 5, BOXEN_MOD_CTRL); } -static void test_skip_csi_modifier_alt_shift_left(void) { - tr_skip("M2: \\e[1;4D -> KEY LEFT, mod=ALT|SHIFT (plan section 3.2)"); +static void test_csi_modifier_alt_shift_left(void) { + check_csi_arrow_modifier('D', BOXEN_KEY_LEFT, 4, BOXEN_MOD_ALT | BOXEN_MOD_SHIFT); } -static void test_skip_csi_modifier_meta_up(void) { - tr_skip("M2: \\e[1;9A -> KEY UP, mod=META (plan section 3.2)"); +static void test_csi_modifier_meta_up(void) { + check_csi_arrow_modifier('A', BOXEN_KEY_UP, 9, BOXEN_MOD_META); } -static void test_skip_csi_nav_home(void) { - tr_skip("M2: \\e[H -> KEY HOME (plan section 3.3)"); +/* ------------------------------------------------------------------------- + * 2026-06-30 JES #810 M2: CSI nav keys -- Home/End/Ins/Del/PgUp/PgDn + * (plan section 3.3), with modifiers. + * ---------------------------------------------------------------------- */ + +static void test_csi_nav_home(void) { + input_decoder_t *dec = input_decoder_create(-1); + static const uint8_t seq[] = {0x1b, '[', 'H'}; + boxen_event_t ev; + inject_and_poll(dec, seq, sizeof(seq), BOXEN_OK, &ev); + assert(ev.type == BOXEN_EV_KEY); + assert(ev.key.key == BOXEN_KEY_HOME); + assert(ev.key.mod == BOXEN_MOD_NONE); + input_decoder_destroy(dec); } -static void test_skip_csi_nav_end(void) { - tr_skip("M2: \\e[F -> KEY END (plan section 3.3)"); +static void test_csi_nav_end(void) { + input_decoder_t *dec = input_decoder_create(-1); + static const uint8_t seq[] = {0x1b, '[', 'F'}; + boxen_event_t ev; + inject_and_poll(dec, seq, sizeof(seq), BOXEN_OK, &ev); + assert(ev.type == BOXEN_EV_KEY); + assert(ev.key.key == BOXEN_KEY_END); + input_decoder_destroy(dec); } -static void test_skip_csi_nav_pgup(void) { - tr_skip("M2: \\e[5~ -> KEY PGUP (plan section 3.3)"); +static void test_csi_nav_home_with_alt(void) { + input_decoder_t *dec = input_decoder_create(-1); + static const uint8_t seq[] = {0x1b, '[', '1', ';', '3', 'H'}; + boxen_event_t ev; + inject_and_poll(dec, seq, sizeof(seq), BOXEN_OK, &ev); + assert(ev.type == BOXEN_EV_KEY); + assert(ev.key.key == BOXEN_KEY_HOME); + assert(ev.key.mod == BOXEN_MOD_ALT); + input_decoder_destroy(dec); } -static void test_skip_csi_nav_pgdn(void) { - tr_skip("M2: \\e[6~ -> KEY PGDN (plan section 3.3)"); +static void test_csi_nav_end_with_ctrl(void) { + input_decoder_t *dec = input_decoder_create(-1); + static const uint8_t seq[] = {0x1b, '[', '1', ';', '5', 'F'}; + boxen_event_t ev; + inject_and_poll(dec, seq, sizeof(seq), BOXEN_OK, &ev); + assert(ev.type == BOXEN_EV_KEY); + assert(ev.key.key == BOXEN_KEY_END); + assert(ev.key.mod == BOXEN_MOD_CTRL); + input_decoder_destroy(dec); } -static void test_skip_csi_nav_delete(void) { - tr_skip("M2: \\e[3~ -> KEY DELETE (plan section 3.3)"); +static void test_csi_nav_insert(void) { + input_decoder_t *dec = input_decoder_create(-1); + static const uint8_t seq[] = {0x1b, '[', '2', '~'}; + boxen_event_t ev; + inject_and_poll(dec, seq, sizeof(seq), BOXEN_OK, &ev); + assert(ev.type == BOXEN_EV_KEY); + assert(ev.key.key == BOXEN_KEY_INSERT); + assert(ev.key.mod == BOXEN_MOD_NONE); + input_decoder_destroy(dec); } -static void test_skip_function_key_f1_ss3(void) { - tr_skip("M2: \\eOP -> KEY F1 (plan section 3.4)"); +static void test_csi_nav_delete(void) { + input_decoder_t *dec = input_decoder_create(-1); + static const uint8_t seq[] = {0x1b, '[', '3', '~'}; + boxen_event_t ev; + inject_and_poll(dec, seq, sizeof(seq), BOXEN_OK, &ev); + assert(ev.type == BOXEN_EV_KEY); + assert(ev.key.key == BOXEN_KEY_DELETE); + input_decoder_destroy(dec); } -static void test_skip_function_key_f5_csi(void) { - tr_skip("M2: \\e[15~ -> KEY F5 (plan section 3.4)"); +static void test_csi_nav_pgup(void) { + input_decoder_t *dec = input_decoder_create(-1); + static const uint8_t seq[] = {0x1b, '[', '5', '~'}; + boxen_event_t ev; + inject_and_poll(dec, seq, sizeof(seq), BOXEN_OK, &ev); + assert(ev.type == BOXEN_EV_KEY); + assert(ev.key.key == BOXEN_KEY_PGUP); + input_decoder_destroy(dec); } -static void test_skip_function_key_f1_shift(void) { - tr_skip("M2: \\e[1;2P -> KEY F1, mod=SHIFT (plan section 3.4)"); +static void test_csi_nav_pgdn(void) { + input_decoder_t *dec = input_decoder_create(-1); + static const uint8_t seq[] = {0x1b, '[', '6', '~'}; + boxen_event_t ev; + inject_and_poll(dec, seq, sizeof(seq), BOXEN_OK, &ev); + assert(ev.type == BOXEN_EV_KEY); + assert(ev.key.key == BOXEN_KEY_PGDN); + input_decoder_destroy(dec); } -static void test_skip_meta_prefix_alt_a(void) { - tr_skip("M2: \\ea -> ch='a', mod=ALT (plan section 3.5)"); +static void test_csi_nav_delete_with_shift(void) { + input_decoder_t *dec = input_decoder_create(-1); + static const uint8_t seq[] = {0x1b, '[', '3', ';', '2', '~'}; + boxen_event_t ev; + inject_and_poll(dec, seq, sizeof(seq), BOXEN_OK, &ev); + assert(ev.type == BOXEN_EV_KEY); + assert(ev.key.key == BOXEN_KEY_DELETE); + assert(ev.key.mod == BOXEN_MOD_SHIFT); + input_decoder_destroy(dec); } -static void test_skip_meta_prefix_alt_b(void) { - tr_skip("M2: \\eb -> ch='b', mod=ALT (plan section 3.5 policy: emit as " - "letter+ALT, not KEY LEFT)"); +static void test_csi_nav_pgup_with_meta(void) { + input_decoder_t *dec = input_decoder_create(-1); + static const uint8_t seq[] = {0x1b, '[', '5', ';', '9', '~'}; + boxen_event_t ev; + inject_and_poll(dec, seq, sizeof(seq), BOXEN_OK, &ev); + assert(ev.type == BOXEN_EV_KEY); + assert(ev.key.key == BOXEN_KEY_PGUP); + assert(ev.key.mod == BOXEN_MOD_META); + input_decoder_destroy(dec); } -static void test_skip_esc_alone_emits_escape(void) { - tr_skip("M2: lone 0x1B with no follow-up -> KEY ESCAPE (plan section 4.4)"); +/* ------------------------------------------------------------------------- + * 2026-06-30 JES #810 M2: function keys -- SS3 (F1-F4) + CSI (F1-F12) + * with modifiers (plan section 3.4). + * ---------------------------------------------------------------------- */ + +static void test_function_key_f1_ss3(void) { + input_decoder_t *dec = input_decoder_create(-1); + static const uint8_t seq[] = {0x1b, 'O', 'P'}; + boxen_event_t ev; + inject_and_poll(dec, seq, sizeof(seq), BOXEN_OK, &ev); + assert(ev.type == BOXEN_EV_KEY); + assert(ev.key.key == BOXEN_KEY_F1); + assert(ev.key.mod == BOXEN_MOD_NONE); + input_decoder_destroy(dec); } -static void test_skip_partial_sequence_across_inject_boundary(void) { - tr_skip("M2: inject \\e[1; then poll (timeout, no event), inject 3D then " - "poll (LEFT+ALT) -- burst-read robustness (plan section 3.12 + 6.1)"); +static void test_function_key_f2_ss3(void) { + input_decoder_t *dec = input_decoder_create(-1); + static const uint8_t seq[] = {0x1b, 'O', 'Q'}; + boxen_event_t ev; + inject_and_poll(dec, seq, sizeof(seq), BOXEN_OK, &ev); + assert(ev.type == BOXEN_EV_KEY); + assert(ev.key.key == BOXEN_KEY_F2); + input_decoder_destroy(dec); } -static void test_skip_control_char_ctrl_a(void) { - tr_skip("M2: 0x01 -> KEY CTRL_A (plan section 3.11)"); +static void test_function_key_f3_ss3(void) { + input_decoder_t *dec = input_decoder_create(-1); + static const uint8_t seq[] = {0x1b, 'O', 'R'}; + boxen_event_t ev; + inject_and_poll(dec, seq, sizeof(seq), BOXEN_OK, &ev); + assert(ev.type == BOXEN_EV_KEY); + assert(ev.key.key == BOXEN_KEY_F3); + input_decoder_destroy(dec); } -static void test_skip_control_char_tab(void) { - tr_skip("M2: 0x09 -> KEY TAB (plan section 3.11)"); +static void test_function_key_f4_ss3(void) { + input_decoder_t *dec = input_decoder_create(-1); + static const uint8_t seq[] = {0x1b, 'O', 'S'}; + boxen_event_t ev; + inject_and_poll(dec, seq, sizeof(seq), BOXEN_OK, &ev); + assert(ev.type == BOXEN_EV_KEY); + assert(ev.key.key == BOXEN_KEY_F4); + input_decoder_destroy(dec); } -static void test_skip_control_char_enter(void) { - tr_skip("M2: 0x0D -> KEY ENTER (plan section 3.11)"); +/* CSI F1..F4 via \e[1;NP|Q|R|S form -- modifier-only variants. */ +static void test_function_key_f1_shift(void) { + input_decoder_t *dec = input_decoder_create(-1); + static const uint8_t seq[] = {0x1b, '[', '1', ';', '2', 'P'}; + boxen_event_t ev; + inject_and_poll(dec, seq, sizeof(seq), BOXEN_OK, &ev); + assert(ev.type == BOXEN_EV_KEY); + assert(ev.key.key == BOXEN_KEY_F1); + assert(ev.key.mod == BOXEN_MOD_SHIFT); + input_decoder_destroy(dec); +} + +static void test_function_key_f4_alt(void) { + input_decoder_t *dec = input_decoder_create(-1); + static const uint8_t seq[] = {0x1b, '[', '1', ';', '3', 'S'}; + boxen_event_t ev; + inject_and_poll(dec, seq, sizeof(seq), BOXEN_OK, &ev); + assert(ev.type == BOXEN_EV_KEY); + assert(ev.key.key == BOXEN_KEY_F4); + assert(ev.key.mod == BOXEN_MOD_ALT); + input_decoder_destroy(dec); +} + +/* CSI ~-form F-keys: F1 legacy (\e[11~), F5 (\e[15~), F12 (\e[24~). */ +static void test_function_key_f1_csi_legacy(void) { + input_decoder_t *dec = input_decoder_create(-1); + static const uint8_t seq[] = {0x1b, '[', '1', '1', '~'}; + boxen_event_t ev; + inject_and_poll(dec, seq, sizeof(seq), BOXEN_OK, &ev); + assert(ev.type == BOXEN_EV_KEY); + assert(ev.key.key == BOXEN_KEY_F1); + input_decoder_destroy(dec); +} + +static void test_function_key_f5_csi(void) { + input_decoder_t *dec = input_decoder_create(-1); + static const uint8_t seq[] = {0x1b, '[', '1', '5', '~'}; + boxen_event_t ev; + inject_and_poll(dec, seq, sizeof(seq), BOXEN_OK, &ev); + assert(ev.type == BOXEN_EV_KEY); + assert(ev.key.key == BOXEN_KEY_F5); + input_decoder_destroy(dec); +} + +static void test_function_key_f6_csi(void) { + input_decoder_t *dec = input_decoder_create(-1); + static const uint8_t seq[] = {0x1b, '[', '1', '7', '~'}; + boxen_event_t ev; + inject_and_poll(dec, seq, sizeof(seq), BOXEN_OK, &ev); + assert(ev.type == BOXEN_EV_KEY); + assert(ev.key.key == BOXEN_KEY_F6); + input_decoder_destroy(dec); +} + +static void test_function_key_f11_csi(void) { + input_decoder_t *dec = input_decoder_create(-1); + static const uint8_t seq[] = {0x1b, '[', '2', '3', '~'}; + boxen_event_t ev; + inject_and_poll(dec, seq, sizeof(seq), BOXEN_OK, &ev); + assert(ev.type == BOXEN_EV_KEY); + assert(ev.key.key == BOXEN_KEY_F11); + input_decoder_destroy(dec); +} + +static void test_function_key_f12_csi(void) { + input_decoder_t *dec = input_decoder_create(-1); + static const uint8_t seq[] = {0x1b, '[', '2', '4', '~'}; + boxen_event_t ev; + inject_and_poll(dec, seq, sizeof(seq), BOXEN_OK, &ev); + assert(ev.type == BOXEN_EV_KEY); + assert(ev.key.key == BOXEN_KEY_F12); + input_decoder_destroy(dec); +} + +/* CSI ~-form F-key with modifier: \e[15;5~ -> F5 + CTRL. */ +static void test_function_key_f5_with_ctrl(void) { + input_decoder_t *dec = input_decoder_create(-1); + static const uint8_t seq[] = {0x1b, '[', '1', '5', ';', '5', '~'}; + boxen_event_t ev; + inject_and_poll(dec, seq, sizeof(seq), BOXEN_OK, &ev); + assert(ev.type == BOXEN_EV_KEY); + assert(ev.key.key == BOXEN_KEY_F5); + assert(ev.key.mod == BOXEN_MOD_CTRL); + input_decoder_destroy(dec); +} + +/* ------------------------------------------------------------------------- + * 2026-06-30 JES #810 M2: ESC-prefix Meta keys (plan section 3.5) + * + * Policy: \ea / \eb / \ef emit ch=letter, mod=ALT. The decoder does NOT + * conflate \eb with \e[1;3D (word-left); the REPL keymap can bind both + * to the same action if desired but the decoder keeps them distinct. + * ---------------------------------------------------------------------- */ + +static void test_meta_prefix_alt_a(void) { + input_decoder_t *dec = input_decoder_create(-1); + static const uint8_t seq[] = {0x1b, 'a'}; + boxen_event_t ev; + inject_and_poll(dec, seq, sizeof(seq), BOXEN_OK, &ev); + assert(ev.type == BOXEN_EV_KEY); + assert(ev.key.key == BOXEN_KEY_NONE); + assert(ev.key.ch == 'a'); + assert(ev.key.mod == BOXEN_MOD_ALT); + input_decoder_destroy(dec); +} + +static void test_meta_prefix_alt_b(void) { + input_decoder_t *dec = input_decoder_create(-1); + static const uint8_t seq[] = {0x1b, 'b'}; + boxen_event_t ev; + inject_and_poll(dec, seq, sizeof(seq), BOXEN_OK, &ev); + assert(ev.type == BOXEN_EV_KEY); + assert(ev.key.ch == 'b'); + assert(ev.key.mod == BOXEN_MOD_ALT); + input_decoder_destroy(dec); } -static void test_skip_control_char_backspace_del(void) { - tr_skip("M2: 0x7F -> KEY BACKSPACE (plan section 3.11)"); +static void test_meta_prefix_alt_f(void) { + input_decoder_t *dec = input_decoder_create(-1); + static const uint8_t seq[] = {0x1b, 'f'}; + boxen_event_t ev; + inject_and_poll(dec, seq, sizeof(seq), BOXEN_OK, &ev); + assert(ev.type == BOXEN_EV_KEY); + assert(ev.key.ch == 'f'); + assert(ev.key.mod == BOXEN_MOD_ALT); + input_decoder_destroy(dec); +} + +/* ------------------------------------------------------------------------- + * 2026-06-30 JES #810 M2: ESC-alone disambiguation (plan section 4.4) + * + * Test-seam contract: when poll is called with timeout_ms==0 and the buffer + * holds only 0x1B with nothing following, the decoder treats it as a lone + * Escape and emits KEY_ESCAPE. The production read(2) path will refine + * this with a non-blocking drain (M6 wiring) but the semantic is the same: + * "no more bytes are coming after the ESC" -> emit ESCAPE. + * ---------------------------------------------------------------------- */ + +static void test_esc_alone_emits_escape(void) { + input_decoder_t *dec = input_decoder_create(-1); + static const uint8_t seq[] = {0x1b}; + boxen_event_t ev; + inject_and_poll(dec, seq, sizeof(seq), BOXEN_OK, &ev); + assert(ev.type == BOXEN_EV_KEY); + assert(ev.key.key == BOXEN_KEY_ESCAPE); + assert(ev.key.mod == BOXEN_MOD_NONE); + input_decoder_destroy(dec); +} + +/* Double ESC: the first 0x1B with another 0x1B following is the case where + * the user pressed Escape twice in rapid succession. The decoder emits one + * KEY_ESCAPE for the first; the second 0x1B becomes a new ESC_RECEIVED that + * resolves on the next poll (no follow-up byte) to a second ESCAPE. */ +static void test_esc_then_esc_emits_two_escapes(void) { + input_decoder_t *dec = input_decoder_create(-1); + static const uint8_t seq[] = {0x1b, 0x1b}; + boxen_event_t ev; + inject_and_poll(dec, seq, sizeof(seq), BOXEN_OK, &ev); + assert(ev.type == BOXEN_EV_KEY); + assert(ev.key.key == BOXEN_KEY_ESCAPE); + + /* Second poll resolves the trailing ESC. */ + memset(&ev, 0x7f, sizeof(ev)); + int rc = input_decoder_poll(dec, &ev, 0); + assert(rc == BOXEN_OK); + assert(ev.type == BOXEN_EV_KEY); + assert(ev.key.key == BOXEN_KEY_ESCAPE); + + input_decoder_destroy(dec); +} + +/* ------------------------------------------------------------------------- + * 2026-06-30 JES #810 M2: partial-sequence split across inject boundary + * (plan sections 3.12, 6.1). The decoder MUST hold a partial CSI sequence + * in its buffer and emit nothing until the final byte arrives. This is + * the contract that fixes the read(2)-boundary fall-through identified in + * the pre-plan investigation. + * ---------------------------------------------------------------------- */ + +static void test_partial_sequence_across_inject_boundary(void) { + input_decoder_t *dec = input_decoder_create(-1); + + /* First half: \e[1; -- a clearly-partial CSI sequence. */ + static const uint8_t half1[] = {0x1b, '[', '1', ';'}; + size_t accepted = input_decoder_inject_bytes(dec, half1, sizeof(half1)); + assert(accepted == sizeof(half1)); + + boxen_event_t ev; + memset(&ev, 0x7f, sizeof(ev)); + int rc = input_decoder_poll(dec, &ev, 0); + assert(rc == BOXEN_ERR_TIMEOUT); + assert(ev.type == BOXEN_EV_NONE); + + /* Second half: 3D -- completes \e[1;3D = LEFT + ALT. */ + static const uint8_t half2[] = {'3', 'D'}; + accepted = input_decoder_inject_bytes(dec, half2, sizeof(half2)); + assert(accepted == sizeof(half2)); + + memset(&ev, 0x7f, sizeof(ev)); + rc = input_decoder_poll(dec, &ev, 0); + assert(rc == BOXEN_OK); + assert(ev.type == BOXEN_EV_KEY); + assert(ev.key.key == BOXEN_KEY_LEFT); + assert(ev.key.mod == BOXEN_MOD_ALT); + + input_decoder_destroy(dec); +} + +/* Partial split inside an SS3 sequence: \eO then poll (TIMEOUT) then A. */ +static void test_partial_ss3_across_inject_boundary(void) { + input_decoder_t *dec = input_decoder_create(-1); + + static const uint8_t half1[] = {0x1b, 'O'}; + input_decoder_inject_bytes(dec, half1, sizeof(half1)); + + boxen_event_t ev; + int rc = input_decoder_poll(dec, &ev, 0); + assert(rc == BOXEN_ERR_TIMEOUT); + + static const uint8_t half2[] = {'A'}; + input_decoder_inject_bytes(dec, half2, sizeof(half2)); + rc = input_decoder_poll(dec, &ev, 0); + assert(rc == BOXEN_OK); + assert(ev.type == BOXEN_EV_KEY); + assert(ev.key.key == BOXEN_KEY_UP); + + input_decoder_destroy(dec); +} + +/* Partial split right after ESC: inject \e alone -- this is the ambiguous + * case. With the test seam (no read loop), poll with timeout=0 must NOT + * eagerly emit ESCAPE -- doing so would race a still-arriving CSI prefix + * on the production fd path. + * + * The contract: when the buffer's tail is a lone ESC and the poll caller + * supplied timeout_ms==0, the decoder DOES emit KEY_ESCAPE. This matches + * plan section 4.4: the production read loop drains the pipe non-blocking + * after seeing ESC; if zero bytes follow, emit ESCAPE. In the test seam, + * the equivalent semantic is "the caller's poll boundary is the end of + * available bytes." test_esc_alone_emits_escape pins this. + * + * This separate test pins the inverse case: ESC followed by '[' but no + * final byte yet must NOT emit anything (the '[' commits to CSI_COLLECTING, + * which is unambiguously a sequence-in-progress). */ +static void test_partial_esc_bracket_does_not_emit(void) { + input_decoder_t *dec = input_decoder_create(-1); + + static const uint8_t partial[] = {0x1b, '['}; + input_decoder_inject_bytes(dec, partial, sizeof(partial)); + + boxen_event_t ev; + int rc = input_decoder_poll(dec, &ev, 0); + assert(rc == BOXEN_ERR_TIMEOUT); + assert(ev.type == BOXEN_EV_NONE); + + /* Now finish with 'A'. */ + static const uint8_t finish[] = {'A'}; + input_decoder_inject_bytes(dec, finish, sizeof(finish)); + rc = input_decoder_poll(dec, &ev, 0); + assert(rc == BOXEN_OK); + assert(ev.key.key == BOXEN_KEY_UP); + + input_decoder_destroy(dec); +} + +/* ------------------------------------------------------------------------- + * 2026-06-30 JES #810 M2: control characters 0x01..0x1A and DEL (plan + * section 3.11). + * ---------------------------------------------------------------------- */ + +static void test_control_char_ctrl_a(void) { + input_decoder_t *dec = input_decoder_create(-1); + static const uint8_t seq[] = {0x01}; + boxen_event_t ev; + inject_and_poll(dec, seq, sizeof(seq), BOXEN_OK, &ev); + assert(ev.type == BOXEN_EV_KEY); + assert(ev.key.key == BOXEN_KEY_CTRL_A); + assert(ev.key.ch == 0); + input_decoder_destroy(dec); +} + +static void test_control_char_ctrl_c(void) { + input_decoder_t *dec = input_decoder_create(-1); + static const uint8_t seq[] = {0x03}; + boxen_event_t ev; + inject_and_poll(dec, seq, sizeof(seq), BOXEN_OK, &ev); + assert(ev.type == BOXEN_EV_KEY); + assert(ev.key.key == BOXEN_KEY_CTRL_C); + input_decoder_destroy(dec); +} + +static void test_control_char_ctrl_e(void) { + input_decoder_t *dec = input_decoder_create(-1); + static const uint8_t seq[] = {0x05}; + boxen_event_t ev; + inject_and_poll(dec, seq, sizeof(seq), BOXEN_OK, &ev); + assert(ev.type == BOXEN_EV_KEY); + assert(ev.key.key == BOXEN_KEY_CTRL_E); + input_decoder_destroy(dec); +} + +static void test_control_char_ctrl_z(void) { + input_decoder_t *dec = input_decoder_create(-1); + static const uint8_t seq[] = {0x1a}; + boxen_event_t ev; + inject_and_poll(dec, seq, sizeof(seq), BOXEN_OK, &ev); + assert(ev.type == BOXEN_EV_KEY); + assert(ev.key.key == BOXEN_KEY_CTRL_Z); + input_decoder_destroy(dec); +} + +static void test_control_char_tab(void) { + input_decoder_t *dec = input_decoder_create(-1); + static const uint8_t seq[] = {0x09}; + boxen_event_t ev; + inject_and_poll(dec, seq, sizeof(seq), BOXEN_OK, &ev); + assert(ev.type == BOXEN_EV_KEY); + assert(ev.key.key == BOXEN_KEY_TAB); + input_decoder_destroy(dec); +} + +static void test_control_char_enter(void) { + input_decoder_t *dec = input_decoder_create(-1); + static const uint8_t seq[] = {0x0d}; + boxen_event_t ev; + inject_and_poll(dec, seq, sizeof(seq), BOXEN_OK, &ev); + assert(ev.type == BOXEN_EV_KEY); + assert(ev.key.key == BOXEN_KEY_ENTER); + input_decoder_destroy(dec); +} + +static void test_control_char_backspace_ctrl_h(void) { + input_decoder_t *dec = input_decoder_create(-1); + static const uint8_t seq[] = {0x08}; + boxen_event_t ev; + inject_and_poll(dec, seq, sizeof(seq), BOXEN_OK, &ev); + assert(ev.type == BOXEN_EV_KEY); + assert(ev.key.key == BOXEN_KEY_BACKSPACE); + input_decoder_destroy(dec); +} + +static void test_control_char_backspace_del(void) { + input_decoder_t *dec = input_decoder_create(-1); + static const uint8_t seq[] = {0x7f}; + boxen_event_t ev; + inject_and_poll(dec, seq, sizeof(seq), BOXEN_OK, &ev); + assert(ev.type == BOXEN_EV_KEY); + assert(ev.key.key == BOXEN_KEY_BACKSPACE); + input_decoder_destroy(dec); +} + +/* Printable ASCII -- the 7-bit non-control path. ch=letter, mod=NONE, + * key=BOXEN_KEY_NONE so the consumer distinguishes "printable" from + * "special". */ +static void test_printable_ascii(void) { + input_decoder_t *dec = input_decoder_create(-1); + static const uint8_t seq[] = {'A'}; + boxen_event_t ev; + inject_and_poll(dec, seq, sizeof(seq), BOXEN_OK, &ev); + assert(ev.type == BOXEN_EV_KEY); + assert(ev.key.key == BOXEN_KEY_NONE); + assert(ev.key.ch == 'A'); + assert(ev.key.mod == BOXEN_MOD_NONE); + input_decoder_destroy(dec); +} + +/* ------------------------------------------------------------------------- + * 2026-06-30 JES #810 M2: UTF-8 multi-byte assembly (plan section 3.10). + * + * The decoder accumulates UTF-8 continuation bytes in a per-codepoint + * scratch and emits one event with ev.key.ch = assembled codepoint when + * complete. Lead-then-cont split across inject must NOT emit until the + * final byte arrives. + * ---------------------------------------------------------------------- */ + +/* U+00A9 = COPYRIGHT SIGN, encoded as 0xC2 0xA9. */ +static void test_utf8_two_byte_copyright(void) { + input_decoder_t *dec = input_decoder_create(-1); + static const uint8_t seq[] = {0xC2, 0xA9}; + boxen_event_t ev; + inject_and_poll(dec, seq, sizeof(seq), BOXEN_OK, &ev); + assert(ev.type == BOXEN_EV_KEY); + assert(ev.key.key == BOXEN_KEY_NONE); + assert(ev.key.ch == 0xA9); + input_decoder_destroy(dec); +} + +/* U+2014 = EM DASH, encoded as 0xE2 0x80 0x94. */ +static void test_utf8_three_byte_em_dash(void) { + input_decoder_t *dec = input_decoder_create(-1); + static const uint8_t seq[] = {0xE2, 0x80, 0x94}; + boxen_event_t ev; + inject_and_poll(dec, seq, sizeof(seq), BOXEN_OK, &ev); + assert(ev.type == BOXEN_EV_KEY); + assert(ev.key.ch == 0x2014); + input_decoder_destroy(dec); +} + +/* U+1F600 = GRINNING FACE, encoded as 0xF0 0x9F 0x98 0x80. */ +static void test_utf8_four_byte_emoji(void) { + input_decoder_t *dec = input_decoder_create(-1); + static const uint8_t seq[] = {0xF0, 0x9F, 0x98, 0x80}; + boxen_event_t ev; + inject_and_poll(dec, seq, sizeof(seq), BOXEN_OK, &ev); + assert(ev.type == BOXEN_EV_KEY); + assert(ev.key.ch == 0x1F600); + input_decoder_destroy(dec); +} + +/* UTF-8 continuation split across inject calls. Inject 0xE2 0x80 (two of + * three em-dash bytes); poll returns TIMEOUT. Inject final 0x94; poll + * returns ch=0x2014. */ +static void test_utf8_continuation_split_across_inject(void) { + input_decoder_t *dec = input_decoder_create(-1); + + static const uint8_t half1[] = {0xE2, 0x80}; + input_decoder_inject_bytes(dec, half1, sizeof(half1)); + + boxen_event_t ev; + int rc = input_decoder_poll(dec, &ev, 0); + assert(rc == BOXEN_ERR_TIMEOUT); + assert(ev.type == BOXEN_EV_NONE); + + static const uint8_t half2[] = {0x94}; + input_decoder_inject_bytes(dec, half2, sizeof(half2)); + rc = input_decoder_poll(dec, &ev, 0); + assert(rc == BOXEN_OK); + assert(ev.type == BOXEN_EV_KEY); + assert(ev.key.ch == 0x2014); + + input_decoder_destroy(dec); +} + +/* ------------------------------------------------------------------------- + * 2026-06-30 JES #810 M2 review-followup: gate review fixes. + * + * P1 hardening tests (CSI overflow byte-injection, UTF-8 overlong / + * surrogate / out-of-range smuggling) and the bar-raiser-flagged + * coverage gaps (F7-F10 individual tests, full CTRL sweep, two-event + * burst in one inject, 0x00/0x1C controls, stray UTF-8 continuation). + * ---------------------------------------------------------------------- */ + +/* P1-1: CSI buffer overflow used to reset to GROUND with the remaining + * sequence bytes still in the buffer, which got emitted as printable + * keystrokes. The fix transitions to CSI_SWALLOW and drops bytes until + * the final byte arrives. Inject a CSI with > 64 param bytes followed by + * a single 'B' printable, then poll: the only event should be 'B', and + * the parse-error counter should be non-zero. */ +static void test_csi_overflow_does_not_inject_keystrokes(void) { + input_decoder_t *dec = input_decoder_create(-1); + /* \e[ + 80 ';' + 'X' (final byte) + 'B' (post-sequence printable) */ + uint8_t seq[256]; + size_t n = 0; + seq[n++] = 0x1b; + seq[n++] = '['; + for (int i = 0; i < 80; i++) { + seq[n++] = ';'; + } + seq[n++] = 'X'; /* final byte for the malformed CSI */ + seq[n++] = 'B'; /* a real printable; should be the only emit */ + size_t accepted = input_decoder_inject_bytes(dec, seq, n); + assert(accepted == n); + + boxen_event_t ev; + int rc = input_decoder_poll(dec, &ev, 0); + /* First emit must be the 'B' printable -- not any of the swallowed + * sequence bytes. */ + assert(rc == BOXEN_OK); + assert(ev.type == BOXEN_EV_KEY); + assert(ev.key.key == BOXEN_KEY_NONE); + assert(ev.key.ch == 'B'); + + /* No further events queued. */ + memset(&ev, 0x7f, sizeof(ev)); + rc = input_decoder_poll(dec, &ev, 0); + assert(rc == BOXEN_ERR_TIMEOUT); + + /* The rejection path was observable via the counter. */ + assert(input_decoder_parse_errors(dec) > 0); + + input_decoder_destroy(dec); +} + +/* P1-1 variant: CSI overflow split across inject boundaries -- swallow + * state must persist across polls. Inject \e[ + 70 ';' (forces overflow + * mid-stream); poll (TIMEOUT, swallow active); inject 'X' (final); + * poll (TIMEOUT, swallow ended); inject 'C' (printable); poll -> 'C'. */ +static void test_csi_swallow_persists_across_polls(void) { + input_decoder_t *dec = input_decoder_create(-1); + + uint8_t seq[80]; + size_t n = 0; + seq[n++] = 0x1b; + seq[n++] = '['; + for (int i = 0; i < 70; i++) { + seq[n++] = ';'; + } + input_decoder_inject_bytes(dec, seq, n); + + boxen_event_t ev; + int rc = input_decoder_poll(dec, &ev, 0); + assert(rc == BOXEN_ERR_TIMEOUT); + assert(input_decoder_parse_errors(dec) > 0); + + static const uint8_t final[] = {'X'}; + input_decoder_inject_bytes(dec, final, sizeof(final)); + rc = input_decoder_poll(dec, &ev, 0); + assert(rc == BOXEN_ERR_TIMEOUT); /* swallow drained, no event */ + + static const uint8_t printable[] = {'C'}; + input_decoder_inject_bytes(dec, printable, sizeof(printable)); + rc = input_decoder_poll(dec, &ev, 0); + assert(rc == BOXEN_OK); + assert(ev.key.ch == 'C'); + + input_decoder_destroy(dec); +} + +/* P1-1: a runaway long parameter (~63 digits) inside the CSI buffer used + * to feed a signed-int accumulator overflow (UB). The fix saturates + * cur at CSI_PARAM_CAP (100000) before it can approach INT_MAX. Verify + * the sequence still decodes safely (modifier > 16 -> NONE) and no + * crash. */ +static void test_csi_param_overflow_saturates_safely(void) { + input_decoder_t *dec = input_decoder_create(-1); + + /* \e[1;<60 nines>A -- modifier param huge; should clamp to NONE. */ + uint8_t seq[80]; + size_t n = 0; + seq[n++] = 0x1b; + seq[n++] = '['; + seq[n++] = '1'; + seq[n++] = ';'; + for (int i = 0; i < 60; i++) { + seq[n++] = '9'; + } + seq[n++] = 'A'; + input_decoder_inject_bytes(dec, seq, n); + + boxen_event_t ev; + int rc = input_decoder_poll(dec, &ev, 0); + assert(rc == BOXEN_OK); + assert(ev.type == BOXEN_EV_KEY); + assert(ev.key.key == BOXEN_KEY_UP); + assert(ev.key.mod == BOXEN_MOD_NONE); /* saturated -> no modifier */ + + input_decoder_destroy(dec); +} + +/* P1-3: overlong NUL encoding (\xC0\x80) -- the canonical UTF-8 smuggling + * primitive. Must NOT decode to U+0000; must emit U+FFFD and bump + * parse_errors. Belt-and-suspenders: lead-byte rejection (0xC0 is now + * rejected outright in utf8_lead_classify) drops this before the + * continuation byte even matters, so the second byte falls through as a + * stray continuation that ALSO bumps parse_errors. */ +static void test_utf8_overlong_nul_rejected(void) { + input_decoder_t *dec = input_decoder_create(-1); + static const uint8_t seq[] = {0xC0, 0x80}; + input_decoder_inject_bytes(dec, seq, sizeof(seq)); + + boxen_event_t ev; + int rc = input_decoder_poll(dec, &ev, 0); + /* Both bytes were rejected silently (0xC0 invalid lead, 0x80 stray + * continuation in GROUND); no event emits. */ + assert(rc == BOXEN_ERR_TIMEOUT); + assert(input_decoder_parse_errors(dec) >= 2); + + input_decoder_destroy(dec); +} + +/* P1-3: overlong slash (\xC0\xAF would encode U+002F as 2-byte). + * Symmetric to overlong NUL but pinning the lead-byte rejection. */ +static void test_utf8_overlong_slash_rejected(void) { + input_decoder_t *dec = input_decoder_create(-1); + static const uint8_t seq[] = {0xC0, 0xAF}; + input_decoder_inject_bytes(dec, seq, sizeof(seq)); + + boxen_event_t ev; + int rc = input_decoder_poll(dec, &ev, 0); + assert(rc == BOXEN_ERR_TIMEOUT); + assert(input_decoder_parse_errors(dec) >= 2); + + input_decoder_destroy(dec); +} + +/* P1-3: overlong 3-byte encoding of U+007F (\xE0\x81\xBF). This one has + * a valid 3-byte lead (0xE0), so it passes the lead check; the + * codepoint validator must catch it as overlong. */ +static void test_utf8_overlong_3byte_emits_fffd(void) { + input_decoder_t *dec = input_decoder_create(-1); + static const uint8_t seq[] = {0xE0, 0x81, 0xBF}; + input_decoder_inject_bytes(dec, seq, sizeof(seq)); + + boxen_event_t ev; + int rc = input_decoder_poll(dec, &ev, 0); + assert(rc == BOXEN_OK); + assert(ev.type == BOXEN_EV_KEY); + assert(ev.key.ch == 0xFFFD); /* replacement character */ + assert(input_decoder_parse_errors(dec) >= 1); + + input_decoder_destroy(dec); +} + +/* P1-3: UTF-16 surrogate U+D800 encoded as 3-byte UTF-8 (\xED\xA0\x80). + * Lead 0xED is valid; codepoint validator rejects the surrogate range. */ +static void test_utf8_surrogate_rejected(void) { + input_decoder_t *dec = input_decoder_create(-1); + static const uint8_t seq[] = {0xED, 0xA0, 0x80}; + input_decoder_inject_bytes(dec, seq, sizeof(seq)); + + boxen_event_t ev; + int rc = input_decoder_poll(dec, &ev, 0); + assert(rc == BOXEN_OK); + assert(ev.key.ch == 0xFFFD); + assert(input_decoder_parse_errors(dec) >= 1); + + input_decoder_destroy(dec); +} + +/* P1-3: codepoint above Unicode max -- 0xF4 0x90 0x80 0x80 = U+110000. + * Lead 0xF4 is valid (< 0xF5 rejection threshold), but the assembled + * codepoint exceeds 0x10FFFF. */ +static void test_utf8_above_max_rejected(void) { + input_decoder_t *dec = input_decoder_create(-1); + static const uint8_t seq[] = {0xF4, 0x90, 0x80, 0x80}; + input_decoder_inject_bytes(dec, seq, sizeof(seq)); + + boxen_event_t ev; + int rc = input_decoder_poll(dec, &ev, 0); + assert(rc == BOXEN_OK); + assert(ev.key.ch == 0xFFFD); + assert(input_decoder_parse_errors(dec) >= 1); + + input_decoder_destroy(dec); +} + +/* P1-3: invalid 5-byte UTF-8 lead 0xF8 should be silently dropped (not + * emitted as a raw ch=0xF8 -- that would let Latin-1 garbage flow + * downstream). */ +static void test_utf8_invalid_5byte_lead_dropped(void) { + input_decoder_t *dec = input_decoder_create(-1); + static const uint8_t seq[] = {0xF8, 'X'}; + input_decoder_inject_bytes(dec, seq, sizeof(seq)); + + boxen_event_t ev; + int rc = input_decoder_poll(dec, &ev, 0); + /* 0xF8 dropped; 'X' is the next event. */ + assert(rc == BOXEN_OK); + assert(ev.key.ch == 'X'); + assert(input_decoder_parse_errors(dec) >= 1); + + input_decoder_destroy(dec); +} + +/* P1-3: stray UTF-8 continuation byte (0x80-0xBF) in GROUND used to be + * emitted as a raw ch=byte (Latin-1 smuggling). Must now drop with + * parse-error bump. */ +static void test_utf8_stray_continuation_in_ground_dropped(void) { + input_decoder_t *dec = input_decoder_create(-1); + static const uint8_t seq[] = {0x80, 0xBF, 'Y'}; + input_decoder_inject_bytes(dec, seq, sizeof(seq)); + + boxen_event_t ev; + int rc = input_decoder_poll(dec, &ev, 0); + assert(rc == BOXEN_OK); + assert(ev.key.ch == 'Y'); /* 0x80 and 0xBF were dropped */ + assert(input_decoder_parse_errors(dec) >= 2); + + input_decoder_destroy(dec); +} + +/* P1-3: premature non-continuation byte mid-UTF-8. \xC2 (2-byte lead + * expecting 1 continuation) followed by 'A' (not a continuation): partial + * codepoint discarded, 'A' decoded fresh in GROUND. */ +static void test_utf8_invalid_continuation_fallback(void) { + input_decoder_t *dec = input_decoder_create(-1); + static const uint8_t seq[] = {0xC2, 'A'}; + input_decoder_inject_bytes(dec, seq, sizeof(seq)); + + boxen_event_t ev; + int rc = input_decoder_poll(dec, &ev, 0); + assert(rc == BOXEN_OK); + assert(ev.key.ch == 'A'); + assert(input_decoder_parse_errors(dec) >= 1); + + input_decoder_destroy(dec); +} + +/* Polish: F7..F10 (~-form), each with one dedicated assertion. The + * decode table covers them but the per-key behavioral pin was missing. */ +static void test_function_key_f7_csi(void) { + input_decoder_t *dec = input_decoder_create(-1); + static const uint8_t seq[] = {0x1b, '[', '1', '8', '~'}; + boxen_event_t ev; + inject_and_poll(dec, seq, sizeof(seq), BOXEN_OK, &ev); + assert(ev.key.key == BOXEN_KEY_F7); + input_decoder_destroy(dec); +} + +static void test_function_key_f8_csi(void) { + input_decoder_t *dec = input_decoder_create(-1); + static const uint8_t seq[] = {0x1b, '[', '1', '9', '~'}; + boxen_event_t ev; + inject_and_poll(dec, seq, sizeof(seq), BOXEN_OK, &ev); + assert(ev.key.key == BOXEN_KEY_F8); + input_decoder_destroy(dec); +} + +static void test_function_key_f9_csi(void) { + input_decoder_t *dec = input_decoder_create(-1); + static const uint8_t seq[] = {0x1b, '[', '2', '0', '~'}; + boxen_event_t ev; + inject_and_poll(dec, seq, sizeof(seq), BOXEN_OK, &ev); + assert(ev.key.key == BOXEN_KEY_F9); + input_decoder_destroy(dec); +} + +static void test_function_key_f10_csi(void) { + input_decoder_t *dec = input_decoder_create(-1); + static const uint8_t seq[] = {0x1b, '[', '2', '1', '~'}; + boxen_event_t ev; + inject_and_poll(dec, seq, sizeof(seq), BOXEN_OK, &ev); + assert(ev.key.key == BOXEN_KEY_F10); + input_decoder_destroy(dec); +} + +/* Polish: full CTRL sweep. The decode_byte_ground table maps 0x01..0x1A + * to BOXEN_KEY_CTRL_A..CTRL_Z via a single enum arithmetic; one regression + * there would break all 26 silently. This loop pins every slot. */ +static void test_control_chars_full_sweep(void) { + input_decoder_t *dec = input_decoder_create(-1); + for (uint8_t b = 0x01; b <= 0x1A; b++) { + uint8_t seq[] = {b}; + boxen_event_t ev; + size_t accepted = input_decoder_inject_bytes(dec, seq, 1); + assert(accepted == 1); + memset(&ev, 0x7f, sizeof(ev)); + int rc = input_decoder_poll(dec, &ev, 0); + assert(rc == BOXEN_OK); + assert(ev.type == BOXEN_EV_KEY); + /* TAB (0x09) and ENTER (0x0D) are surfaced via their dedicated + * key codes per the boxen header (which alias CTRL_I and + * CTRL_M); BACKSPACE (0x08) likewise. Skip those slots here -- + * they have dedicated tests above. */ + if (b == 0x08) { + assert(ev.key.key == BOXEN_KEY_BACKSPACE); + } else if (b == 0x09) { + assert(ev.key.key == BOXEN_KEY_TAB); + } else if (b == 0x0D) { + assert(ev.key.key == BOXEN_KEY_ENTER); + } else { + boxen_key_t expected = (boxen_key_t)(BOXEN_KEY_CTRL_A + (b - 0x01)); + assert(ev.key.key == expected); + } + } + input_decoder_destroy(dec); +} + +/* Polish: 0x00 -> CTRL_SPACE, 0x1C -> CTRL_BACKSLASH (the two + * special-cased control-code mappings outside the 0x01..0x1A range). */ +static void test_control_char_ctrl_space(void) { + input_decoder_t *dec = input_decoder_create(-1); + static const uint8_t seq[] = {0x00}; + boxen_event_t ev; + inject_and_poll(dec, seq, sizeof(seq), BOXEN_OK, &ev); + assert(ev.key.key == BOXEN_KEY_CTRL_SPACE); + input_decoder_destroy(dec); +} + +static void test_control_char_ctrl_backslash(void) { + input_decoder_t *dec = input_decoder_create(-1); + static const uint8_t seq[] = {0x1c}; + boxen_event_t ev; + inject_and_poll(dec, seq, sizeof(seq), BOXEN_OK, &ev); + assert(ev.key.key == BOXEN_KEY_CTRL_BACKSLASH); + input_decoder_destroy(dec); +} + +/* Polish: two complete sequences in one inject. The burst-read + * contract requires the decoder to dequeue them across two poll calls + * in order. M3 will extend this to the wheel-spam scenario; M2 pins + * the basic case so the M3 SKIP-stub remains the new-scope guard. */ +static void test_burst_two_events_one_inject(void) { + input_decoder_t *dec = input_decoder_create(-1); + static const uint8_t seq[] = {0x1b, '[', 'A', 0x1b, '[', 'B'}; + size_t accepted = input_decoder_inject_bytes(dec, seq, sizeof(seq)); + assert(accepted == sizeof(seq)); + + boxen_event_t ev; + int rc = input_decoder_poll(dec, &ev, 0); + assert(rc == BOXEN_OK); + assert(ev.key.key == BOXEN_KEY_UP); + + memset(&ev, 0x7f, sizeof(ev)); + rc = input_decoder_poll(dec, &ev, 0); + assert(rc == BOXEN_OK); + assert(ev.key.key == BOXEN_KEY_DOWN); + + /* No more events. */ + rc = input_decoder_poll(dec, &ev, 0); + assert(rc == BOXEN_ERR_TIMEOUT); + + input_decoder_destroy(dec); +} + +/* Polish: parse_errors accessor NULL-safety and starts at zero. */ +static void test_parse_errors_accessor_smoke(void) { + assert(input_decoder_parse_errors(NULL) == 0); + input_decoder_t *dec = input_decoder_create(-1); + assert(input_decoder_parse_errors(dec) == 0); + input_decoder_destroy(dec); } /* ------------------------------------------------------------------------- @@ -480,31 +1566,6 @@ static void test_skip_kitty_csi_u_escape_with_alt(void) { tr_skip("M5: \\e[27;3u -> KEY_ESCAPE, mod=ALT (plan section 3.9)"); } -/* ------------------------------------------------------------------------- - * UTF-8 SKIP stubs -- multi-byte decode. Slated for M2 (the codepoint - * accumulator is part of the GROUND-state path). - * ---------------------------------------------------------------------- */ - -static void test_skip_utf8_two_byte_codepoint(void) { - tr_skip("M2: 2-byte UTF-8 (U+00A9 copyright) -> ev.key.ch=0xA9 " - "(plan section 3.10)"); -} - -static void test_skip_utf8_three_byte_codepoint(void) { - tr_skip("M2: 3-byte UTF-8 (U+2014 em-dash) -> ev.key.ch=0x2014 " - "(plan section 3.10)"); -} - -static void test_skip_utf8_four_byte_codepoint(void) { - tr_skip("M2: 4-byte UTF-8 (U+1F600 emoji) -> ev.key.ch=0x1F600 " - "(plan section 3.10)"); -} - -static void test_skip_utf8_continuation_split_across_inject(void) { - tr_skip("M2: UTF-8 lead byte + first cont in inject 1, remaining cont " - "in inject 2 -> single emit on completion (plan section 3.10)"); -} - /* ------------------------------------------------------------------------- * main * ---------------------------------------------------------------------- */ @@ -527,38 +1588,109 @@ int main(void) { TR_RUN(test_kitty_enable_round_trip); TR_RUN(test_inject_overflow_increments_dropped_counter); - /* M2 SKIP -- cursor keys, modifiers, ESC, control chars, UTF-8. */ - TR_RUN(test_skip_csi_plain_arrow_up); - TR_RUN(test_skip_csi_plain_arrow_down); - TR_RUN(test_skip_csi_plain_arrow_right); - TR_RUN(test_skip_csi_plain_arrow_left); - TR_RUN(test_skip_ss3_arrow_up); - TR_RUN(test_skip_csi_modifier_alt_left_issue_805); - TR_RUN(test_skip_csi_modifier_alt_right_issue_805); - TR_RUN(test_skip_csi_modifier_shift_up); - TR_RUN(test_skip_csi_modifier_ctrl_down); - TR_RUN(test_skip_csi_modifier_alt_shift_left); - TR_RUN(test_skip_csi_modifier_meta_up); - TR_RUN(test_skip_csi_nav_home); - TR_RUN(test_skip_csi_nav_end); - TR_RUN(test_skip_csi_nav_pgup); - TR_RUN(test_skip_csi_nav_pgdn); - TR_RUN(test_skip_csi_nav_delete); - TR_RUN(test_skip_function_key_f1_ss3); - TR_RUN(test_skip_function_key_f5_csi); - TR_RUN(test_skip_function_key_f1_shift); - TR_RUN(test_skip_meta_prefix_alt_a); - TR_RUN(test_skip_meta_prefix_alt_b); - TR_RUN(test_skip_esc_alone_emits_escape); - TR_RUN(test_skip_partial_sequence_across_inject_boundary); - TR_RUN(test_skip_control_char_ctrl_a); - TR_RUN(test_skip_control_char_tab); - TR_RUN(test_skip_control_char_enter); - TR_RUN(test_skip_control_char_backspace_del); - TR_RUN(test_skip_utf8_two_byte_codepoint); - TR_RUN(test_skip_utf8_three_byte_codepoint); - TR_RUN(test_skip_utf8_four_byte_codepoint); - TR_RUN(test_skip_utf8_continuation_split_across_inject); + /* 2026-06-30 JES #810 M2: behavioral coverage. + * Plain CSI arrows. */ + TR_RUN(test_csi_plain_arrow_up); + TR_RUN(test_csi_plain_arrow_down); + TR_RUN(test_csi_plain_arrow_right); + TR_RUN(test_csi_plain_arrow_left); + + /* SS3 cursor and nav keys. */ + TR_RUN(test_ss3_arrow_up); + TR_RUN(test_ss3_arrow_down); + TR_RUN(test_ss3_arrow_right); + TR_RUN(test_ss3_arrow_left); + TR_RUN(test_ss3_home); + TR_RUN(test_ss3_end); + + /* CSI modifier params (the #805 fix). */ + TR_RUN(test_csi_modifier_all_params_arrow_up); + TR_RUN(test_csi_modifier_all_params_arrow_down); + TR_RUN(test_csi_modifier_all_params_arrow_right); + TR_RUN(test_csi_modifier_all_params_arrow_left); + TR_RUN(test_csi_modifier_alt_left_issue_805); + TR_RUN(test_csi_modifier_alt_right_issue_805); + TR_RUN(test_csi_modifier_shift_up); + TR_RUN(test_csi_modifier_ctrl_down); + TR_RUN(test_csi_modifier_alt_shift_left); + TR_RUN(test_csi_modifier_meta_up); + + /* CSI nav keys with modifiers. */ + TR_RUN(test_csi_nav_home); + TR_RUN(test_csi_nav_end); + TR_RUN(test_csi_nav_home_with_alt); + TR_RUN(test_csi_nav_end_with_ctrl); + TR_RUN(test_csi_nav_insert); + TR_RUN(test_csi_nav_delete); + TR_RUN(test_csi_nav_pgup); + TR_RUN(test_csi_nav_pgdn); + TR_RUN(test_csi_nav_delete_with_shift); + TR_RUN(test_csi_nav_pgup_with_meta); + + /* Function keys SS3 + CSI with modifiers. */ + TR_RUN(test_function_key_f1_ss3); + TR_RUN(test_function_key_f2_ss3); + TR_RUN(test_function_key_f3_ss3); + TR_RUN(test_function_key_f4_ss3); + TR_RUN(test_function_key_f1_shift); + TR_RUN(test_function_key_f4_alt); + TR_RUN(test_function_key_f1_csi_legacy); + TR_RUN(test_function_key_f5_csi); + TR_RUN(test_function_key_f6_csi); + TR_RUN(test_function_key_f11_csi); + TR_RUN(test_function_key_f12_csi); + TR_RUN(test_function_key_f5_with_ctrl); + + /* ESC-prefix Meta and ESC-alone disambiguation. */ + TR_RUN(test_meta_prefix_alt_a); + TR_RUN(test_meta_prefix_alt_b); + TR_RUN(test_meta_prefix_alt_f); + TR_RUN(test_esc_alone_emits_escape); + TR_RUN(test_esc_then_esc_emits_two_escapes); + + /* Partial-sequence splits (burst-read robustness). */ + TR_RUN(test_partial_sequence_across_inject_boundary); + TR_RUN(test_partial_ss3_across_inject_boundary); + TR_RUN(test_partial_esc_bracket_does_not_emit); + + /* Control characters and printable ASCII. */ + TR_RUN(test_control_char_ctrl_a); + TR_RUN(test_control_char_ctrl_c); + TR_RUN(test_control_char_ctrl_e); + TR_RUN(test_control_char_ctrl_z); + TR_RUN(test_control_char_tab); + TR_RUN(test_control_char_enter); + TR_RUN(test_control_char_backspace_ctrl_h); + TR_RUN(test_control_char_backspace_del); + TR_RUN(test_printable_ascii); + + /* UTF-8 multi-byte assembly. */ + TR_RUN(test_utf8_two_byte_copyright); + TR_RUN(test_utf8_three_byte_em_dash); + TR_RUN(test_utf8_four_byte_emoji); + TR_RUN(test_utf8_continuation_split_across_inject); + + /* 2026-06-30 JES #810 M2 review-followup: P1 hardening + polish. */ + TR_RUN(test_csi_overflow_does_not_inject_keystrokes); + TR_RUN(test_csi_swallow_persists_across_polls); + TR_RUN(test_csi_param_overflow_saturates_safely); + TR_RUN(test_utf8_overlong_nul_rejected); + TR_RUN(test_utf8_overlong_slash_rejected); + TR_RUN(test_utf8_overlong_3byte_emits_fffd); + TR_RUN(test_utf8_surrogate_rejected); + TR_RUN(test_utf8_above_max_rejected); + TR_RUN(test_utf8_invalid_5byte_lead_dropped); + TR_RUN(test_utf8_stray_continuation_in_ground_dropped); + TR_RUN(test_utf8_invalid_continuation_fallback); + TR_RUN(test_function_key_f7_csi); + TR_RUN(test_function_key_f8_csi); + TR_RUN(test_function_key_f9_csi); + TR_RUN(test_function_key_f10_csi); + TR_RUN(test_control_chars_full_sweep); + TR_RUN(test_control_char_ctrl_space); + TR_RUN(test_control_char_ctrl_backslash); + TR_RUN(test_burst_two_events_one_inject); + TR_RUN(test_parse_errors_accessor_smoke); /* M3 SKIP -- SGR mouse, X10 fallback, burst-read. */ TR_RUN(test_skip_sgr_mouse_left_press);