Lab 6: The Parser, Version 1 (Milestone 4)
Background
You will build terminal-protocol: a VT state machine that turns a byte stream into typed actions
and knows nothing about screens. It is the most well-specified component in the whole curriculum —
which means it is the one where "works" and "correct" can actually be distinguished, by tests.
Why This Lab Matters
- Every screen bug you chase for the rest of the curriculum will start with "is the parser producing the right actions?" If the answer is reliably yes, you halve your debugging surface.
- The split-input property is not optional, and only a state machine has it.
- This crate is the one that compiles to WebAssembly, gets fuzzed, and gets reused. It earns those properties by knowing nothing.
Prerequisites
- The Parser State Machine and UTF-8 and Graphemes read.
- A recording corpus to test against.
Predict First
- Feed
\x1b[31mone byte at a time. How manycsi_dispatchcalls should result? - Feed
\x1b[38;5(truncated) then\x1b[0m. How many dispatches? - Feed a 4-byte emoji split across two
advance()calls. How manyprintcalls? - Feed 100 KB of
/dev/urandom. What is the worst thing that could happen?
Step 1: The Crate and the Public API
cargo new --lib crates/terminal-protocol --name terminal-protocol
[package]
name = "terminal-protocol"
version = "0.1.0"
edition = "2021"
[dependencies]
# NOTHING. This crate has no dependencies, and that is the point:
# it compiles for wasm32, for no_std targets with alloc, and everywhere else.
#![allow(unused)] fn main() { // crates/terminal-protocol/src/lib.rs #![deny(missing_docs)] //! A VT-style escape-sequence parser. Bytes in, structured actions out. //! //! This crate knows nothing about screens, cursors, colors, terminals, or I/O. //! It reports that a CSI sequence with parameters `[2]` and final byte `J` //! occurred; what that *means* is the consumer's business. pub struct Parser { /* ... */ } /// The consumer of parsed actions. pub trait Perform { /// A printable character. Already UTF-8 decoded. fn print(&mut self, c: char); /// A C0 control byte to execute (LF, CR, BS, HT, BEL, ...). fn execute(&mut self, byte: u8); /// `CSI <params> <intermediates> <final>`. fn csi_dispatch(&mut self, params: &Params, intermediates: &[u8], private: Option<u8>, action: char); /// `ESC <intermediates> <byte>`. fn esc_dispatch(&mut self, intermediates: &[u8], byte: u8); /// `OSC <params separated by ';'> ST`. Params are raw bytes: OSC payloads /// are not guaranteed to be valid UTF-8 (base64, URIs, binary). fn osc_dispatch(&mut self, params: &[&[u8]], bell_terminated: bool); /// DCS start. Payload arrives via `put`, then `unhook`. fn hook(&mut self, params: &Params, intermediates: &[u8], private: Option<u8>, action: char); /// One byte of a DCS payload. fn put(&mut self, byte: u8); /// End of a DCS payload. fn unhook(&mut self); } impl Parser { pub fn new() -> Self { /* ... */ } /// Feed bytes. May be called with any chunking, including one byte at a time. pub fn advance(&mut self, bytes: &[u8], perform: &mut impl Perform) { for &b in bytes { self.advance_byte(b, perform); } } } }
Why Perform is a trait rather than an enum-returning iterator: a trait lets the consumer own its
state and mutate it directly, with no intermediate allocation. It is also what makes the tracing
wrapper (Step 5) a five-line type. vte made the same choice, for the same reasons.
Step 2: States and Bounds
#![allow(unused)] fn main() { #[derive(Copy, Clone, PartialEq, Eq, Debug, Default)] enum State { #[default] Ground, Escape, EscapeIntermediate, CsiEntry, CsiParam, CsiIntermediate, CsiIgnore, DcsEntry, DcsParam, DcsIntermediate, DcsPassthrough, DcsIgnore, OscString, /// Saw ESC inside a string state: if the next byte is '\', this is ST. StringEscape, SosPmApcString, } impl State { /// String states swallow C0 controls rather than executing them. fn is_string(self) -> bool { matches!(self, State::OscString | State::DcsPassthrough | State::DcsIgnore | State::SosPmApcString) } } // Every bound here exists because a terminal parses UNTRUSTED input. const MAX_PARAMS: usize = 16; const MAX_SUBPARAMS: usize = 6; const MAX_INTERMEDIATES: usize = 2; const MAX_OSC_LEN: usize = 4096; const MAX_OSC_PARAMS: usize = 16; }
Step 3: The Transition Function
#![allow(unused)] fn main() { fn advance_byte(&mut self, byte: u8, perform: &mut impl Perform) { // ── Global rules. These apply from ANY state and give the parser its // self-healing property on corrupt input. ───────────────────────── match byte { // ESC inside a string state might begin ST; that is handled by the // StringEscape state, so check it before the global rule. 0x1b if self.state == State::StringEscape => { // ESC ESC: stay waiting for the terminator's second byte. return; } 0x1b if self.state.is_string() => { self.state = State::StringEscape; return; } 0x1b => { self.state = State::Escape; self.clear(); return; } 0x18 | 0x1a => { // CAN, SUB: abort to Ground self.state = State::Ground; self.clear(); perform.execute(byte); return; } // C0 executes immediately in non-string states without changing state. 0x00..=0x17 | 0x19 | 0x1c..=0x1f if !self.state.is_string() => { perform.execute(byte); return; } _ => {} } match self.state { State::Ground => self.ground(byte, perform), State::Escape => self.escape(byte, perform), State::EscapeIntermediate => self.escape_intermediate(byte, perform), State::CsiEntry => self.csi_entry(byte, perform), State::CsiParam => self.csi_param(byte, perform), State::CsiIntermediate => self.csi_intermediate(byte, perform), State::CsiIgnore => { // Swallow until a final byte, then return to Ground with no dispatch. if (0x40..=0x7e).contains(&byte) { self.state = State::Ground; } } State::OscString => self.osc_string(byte), State::StringEscape => { if byte == b'\\' { self.finish_string(perform); // ESC \ = ST } else { // Not ST: the global ESC rule already fired conceptually. // Restart parsing from Escape with this byte. self.state = State::Escape; self.clear(); self.advance_byte(byte, perform); } } State::DcsEntry | State::DcsParam | State::DcsIntermediate => self.dcs_prefix(byte, perform), State::DcsPassthrough => perform.put(byte), State::DcsIgnore | State::SosPmApcString => { /* swallow */ } } } fn ground(&mut self, byte: u8, perform: &mut impl Perform) { // UTF-8 decoding lives HERE, inside Ground, and nowhere else. Putting it in // front of the state machine corrupts escape sequences and binary payloads. match self.utf8.feed(byte) { DecodeResult::Char(c) => perform.print(c), DecodeResult::Incomplete => {} // wait for more bytes DecodeResult::Invalid { resume_with } => { perform.print('\u{FFFD}'); // The Unicode "maximal subpart" rule: the offending byte may start a // new valid sequence and must be reprocessed, not swallowed. if let Some(b) = resume_with { if let DecodeResult::Char(c) = self.utf8.feed(b) { perform.print(c); } } } } } fn csi_entry(&mut self, byte: u8, perform: &mut impl Perform) { match byte { // Private markers are ONLY valid as the first byte after CSI. 0x3c..=0x3f => { self.private = Some(byte); self.state = State::CsiParam; } 0x30..=0x39 | 0x3a | 0x3b => { self.param_byte(byte); self.state = State::CsiParam; } 0x20..=0x2f => { self.push_intermediate(byte); self.state = State::CsiIntermediate; } 0x40..=0x7e => { self.csi_dispatch(byte, perform); self.state = State::Ground; } 0x7f => {} // DEL: ignored _ => self.state = State::CsiIgnore, } } fn csi_param(&mut self, byte: u8, perform: &mut impl Perform) { match byte { 0x30..=0x39 | 0x3a | 0x3b => self.param_byte(byte), 0x20..=0x2f => { self.push_intermediate(byte); self.state = State::CsiIntermediate; } 0x40..=0x7e => { self.csi_dispatch(byte, perform); self.state = State::Ground; } // A private marker AFTER a parameter is malformed: swallow the sequence. 0x3c..=0x3f => self.state = State::CsiIgnore, 0x7f => {} _ => self.state = State::CsiIgnore, } } }
Two implementation notes worth reading twice:
param_bytemust saturate.self.value = self.value.saturating_mul(10).saturating_add(d). Au16multiply on99999999999999overflows and panics in debug, wraps in release. Both are wrong.- Exceeding
MAX_PARAMSsends you toCsiIgnore, not "silently drop the extras and dispatch." A sequence with 100 parameters is malformed; dispatching a truncated version of it is worse than ignoring it.
Step 4: Parameters with Sub-parameters
#![allow(unused)] fn main() { /// Bounded parameter storage supporting the colon sub-parameter form. /// `38:5:196` is ONE parameter with three sub-parameters; `38;5;196` is three /// parameters. Flattening the colon form breaks SGR 4:3 (curly underline). #[derive(Default, Clone, PartialEq, Eq, Debug)] pub struct Params { values: [u16; MAX_PARAMS * MAX_SUBPARAMS], /// Sub-parameter count for each parameter (>= 1 for a present parameter). counts: [u8; MAX_PARAMS], /// Whether each parameter slot was written at all (vs. empty → default). present: [bool; MAX_PARAMS], len: usize, } impl Params { pub fn len(&self) -> usize { self.len } pub fn is_empty(&self) -> bool { self.len == 0 } /// Parameter `i`, or `default` when absent or written empty. /// The "missing means DEFAULT, not 0" rule is enforced HERE so no caller /// can forget it. Never index the storage directly. pub fn get_or(&self, i: usize, default: u16) -> u16 { if i >= self.len || !self.present[i] { return default; } self.values[i * MAX_SUBPARAMS] } pub fn subparams(&self, i: usize) -> &[u16] { if i >= self.len { return &[]; } let start = i * MAX_SUBPARAMS; &self.values[start..start + self.counts[i] as usize] } } }
Step 5: The Tracing Wrapper (Instrumentation)
#![allow(unused)] fn main() { /// Wraps any Perform and logs every call before forwarding it. /// This is `--debug-actions`, and it is how you will debug every screen bug for /// the rest of the curriculum. pub struct TracingPerform<'a, P: Perform, W: std::io::Write> { inner: &'a mut P, out: W, } impl<P: Perform, W: std::io::Write> Perform for TracingPerform<'_, P, W> { fn print(&mut self, c: char) { let _ = writeln!(self.out, "PRINT {:?} U+{:04X}", c, c as u32); self.inner.print(c); } fn execute(&mut self, byte: u8) { let _ = writeln!(self.out, "EXECUTE {:#04x} {}", byte, c0_name(byte)); self.inner.execute(byte); } fn csi_dispatch(&mut self, params: &Params, intermediates: &[u8], private: Option<u8>, action: char) { let _ = writeln!(self.out, "CSI private={:?} params={:?} inter={:?} final={:?}", private.map(|b| b as char), params, intermediates, action); self.inner.csi_dispatch(params, intermediates, private, action); } // ... the rest, identically. } }
Tip: Write this in the same session as the parser, not later. Debugging a state machine without a trace of its transitions is a waste of an afternoon, every single time.
Step 6: Run It
# Trace a real program's output:
pty-runner --record ls.cast -- ls --color=always
terminal-debugger actions ls.cast | head -40
Expected Output
$ terminal-debugger actions ls.cast | head -20
CSI private=None params=[0] inter=[] final='m'
PRINT 'C' U+0043
PRINT 'a' U+0061
PRINT 'r' U+0072
PRINT 'g' U+0067
PRINT 'o' U+006F
PRINT '.' U+002E
PRINT 't' U+0074
...
EXECUTE 0x0d CR
EXECUTE 0x0a LF
CSI private=None params=[1, 34] inter=[] final='m'
PRINT 's' U+0073
PRINT 'r' U+0073
PRINT 'c' U+0063
CSI private=None params=[0] inter=[] final='m'
EXECUTE 0x0d CR
EXECUTE 0x0a LF
Debugging Steps
The split-input test fails
You are accumulating state somewhere that resets on the advance() boundary rather than persisting
across calls. Usual suspects: the UTF-8 partial buffer, or the parameter accumulator being cleared at
the start of advance rather than on ESC.
An emoji shows as two replacement characters
The UTF-8 decoder is emitting U+FFFD for an incomplete sequence at the end of a chunk instead of buffering it. See UTF-8 and Graphemes.
\x1b[38:5:196m gives three parameters
You are treating : like ;. Colons build sub-parameters within the current parameter.
A recording hangs the parser
An unterminated OSC or DCS with no length cap. Add the bound.
Fuzzing finds a panic
Almost always one of: parameter overflow (saturating_*), a slice index out of bounds in
subparams, or a char::from_u32().unwrap() in the UTF-8 path.
Experiment
CLAIM. A starts_with-based parser passes all naïve tests and fails on real input, because real
input splits.
METHOD. Write a deliberately naïve parser (30 lines, matching \x1b[ prefixes on a buffer), run
your golden corpus through it in one chunk — it will pass — then run the same corpus in
replay_output_bytewise mode.
PREDICTION. Before running: what fraction of the corpus produces different output in bytewise mode? Which sequence type fails first?
RESULT. Record it. Then delete the naïve parser; you have made the point.
Test
#![allow(unused)] fn main() { #[test] fn split_input_equivalence_over_the_whole_corpus() { // THE test. Every recording, both chunkings, identical action streams. for case in golden_corpus() { let whole = collect_actions_chunked(&case.bytes, usize::MAX); let single = collect_actions_chunked(&case.bytes, 1); assert_eq!(whole, single, "split mismatch in {}", case.name); // Also test a few awkward chunk sizes — 3 and 7 catch different bugs // than 1 does, because they land mid-parameter rather than mid-sequence. for n in [2usize, 3, 7, 13] { assert_eq!(whole, collect_actions_chunked(&case.bytes, n), "chunk size {n} mismatch in {}", case.name); } } } #[test] fn ground_state_utf8_does_not_consume_escape_bytes() { // If the decoder sat in front of the parser, 0x1b would be swallowed as // part of a decode attempt after an invalid lead byte. let actions = collect_actions(&[&[0xC3, 0x1b, b'[', b'3', b'1', b'm']]); assert!(matches!(actions[0], Action::Print('\u{FFFD}'))); assert!(matches!(actions[1], Action::Csi { .. }), "the escape sequence must survive an invalid UTF-8 lead byte"); } #[test] fn csi_ignore_swallows_the_whole_malformed_sequence() { let actions = collect_actions(&[b"\x1b[1\x3fHX"]); assert_eq!(actions, vec![Action::Print('X')]); } #[test] fn parser_state_is_bounded_regardless_of_input() { // Memory must not grow with input size. Feed 10 MB of adversarial input and // check the parser's own footprint is unchanged. let before = std::mem::size_of::<Parser>(); let mut p = Parser::new(); let mut sink = NullPerform; for _ in 0..100_000 { p.advance(b"\x1b[1;2;3;4;5;6;7;8;9;10;11;12;13;14;15;16;17;18;19;20", &mut sink); p.advance(b"\x1b]0;", &mut sink); p.advance(&[b'A'; 100], &mut sink); } assert_eq!(std::mem::size_of::<Parser>(), before); } }
cargo test -p terminal-protocol
Fuzzing (Required, Not Optional)
cargo install cargo-fuzz
cargo fuzz init -p terminal-protocol
#![allow(unused)] fn main() { // fuzz/fuzz_targets/parse.rs #![no_main] use libfuzzer_sys::fuzz_target; fuzz_target!(|data: &[u8]| { let mut parser = terminal_protocol::Parser::new(); let mut sink = NullPerform; // Feed in adversarial chunkings, not just one slice — chunk boundaries are // where state-machine bugs hide. for chunk in data.chunks(1) { parser.advance(chunk, &mut sink); } let mut parser2 = terminal_protocol::Parser::new(); parser2.advance(data, &mut sink); }); }
cargo fuzz run parse -- -max_total_time=600
The fuzz target asserts only three things: no panic, no hang, no unbounded memory. It is not checking correctness — it is checking that a terminal fed hostile bytes does not become a liability. Commit the corpus.
Challenge Extensions
- Add the split-input assertion to the fuzz target itself: collect actions both ways and assert equality. Now the fuzzer hunts for chunking bugs directly.
- Port to a transition table and benchmark on 10 MB. Report the speedup and whether the readability cost was worth it.
- Differential-test against
vte. Same corpus, compare action streams. Document every difference as either your bug or a deliberate divergence. no_std+alloc. Make the crate build withoutstd, proving it has no hidden OS dependencies.- Compile to
wasm32-unknown-unknownand run the test suite underwasm-pack test. This is the Milestone 13 criterion, arriving early.
Deliverables
-
terminal-protocolwith zero dependencies, implementing the full state machine. - The split-input test passing over the whole golden corpus at chunk sizes 1, 2, 3, 7, 13, and ∞.
- All bounds enforced (params, sub-params, intermediates, OSC length, OSC params).
-
TracingPerformimplemented and wired toterminal-debugger actions. - A fuzz target that has run ≥10 minutes clean, with the corpus committed.
- Every sequence in the CSI catalog produces the correct dispatch, with a test each.
-
cargo build --target wasm32-unknown-unknown -p terminal-protocolsucceeds.
Validation / Self-check
- Name every state and the byte that enters it.
- Why does
Performtake acharinprintand&[u8]inosc_dispatch? - Where does UTF-8 decoding happen, and what breaks if it happens earlier?
- What is the split-input property, and what chunk sizes should you test?
- Why does exceeding
MAX_PARAMSgo toCsiIgnorerather than dispatching a truncated sequence? - Why must parameter accumulation saturate?
- Why is
get_orthe only way to read a parameter? - What three things does the fuzz target assert, and what does it deliberately not check?
\x1b[1?Harrives. Trace the state transitions.- Why does this crate have no dependencies, and what capability does that buy?
Next: Lab 7 — The Screen Grid.