diff --git a/README.md b/README.md index 18eaa4e..ce8b558 100644 --- a/README.md +++ b/README.md @@ -60,6 +60,7 @@ Use `bootintel term` when you want a clean terminal and `bootintel analyze` when | Inspect a newly connected adapter | `bootintel ports` | Lists candidate ports with USB VID/PID and product metadata when available. | | Capture and analyze a boot | `bootintel analyze /dev/ttyUSB0 -b 115200 --log-file boot.log` | Preserves raw bytes and prints local findings as the device boots. | | Analyze a log without hardware | `bootintel scan boot.log --format text` | Runs the local detector set without an account or network connection. | +| Take the prompt on a board you have | `bootintel analyze /dev/ttyUSB0 --interrupt-autoboot` | Hammers the interrupt key, lands at `=>`, pulls the environment, prints the verdict. Power-cycle the board when it says to. | | Assess what the boot chain permits | `bootintel verdict session.log` | Reads a `printenv` dump taken at the U-Boot prompt and says what it permits. Entirely offline. | | Compare firmware boots | `bootintel diff before.log after.log` | Shows meaningful boot-log changes between two captures. | | Gate a build artifact | `bootintel scan boot.log --format sarif --gate-critical` | Emits CI-friendly output and exits non-zero for critical findings. | @@ -160,6 +161,7 @@ cargo build --release | --- | --- | | `bootintel scan ` | Analyze a saved boot log. Supports `--format json\|text\|sarif\|junit` and `--gate-critical` for CI gating on autoboot / telnet exposure. `-` reads from stdin. `--api` POSTs to bootintel.com for full CVE + exploit paths (needs `BOOTINTEL_API_KEY`); `--api --preview` uses the anonymous free quota (3/day per IP, no key). `--api-base` overrides the endpoint. | | `bootintel scan --applicability` | Ask which advisories **apply**, sending only the component inventory (names + versions), never the log. Usable on a client device under an NDA where `--api` is not. `--dry-run` prints the exact payload first. Needs `bootintel login`. | +| `bootintel analyze --interrupt-autoboot` | Interrupt autoboot on connect and pull the environment, then print the verdict and hand the terminal back. Hammers the key from the moment the port opens instead of waiting to see a countdown, because with `bootdelay=0` U-Boot checks for a keypress exactly once and a key sent in response to the banner arrives after that check; the byte has to already be in the UART. **Power-cycle the board after the tool says it is hammering.** Runs the read-only set `printenv`, `bdinfo`, `mtdparts`; `--at-prompt` replaces it entirely. `--interrupt-key` sends something other than a space (`esc`, `ctrl-c`, a literal string for `CONFIG_AUTOBOOT_KEYED` builds, or hex); CR and LF are refused, because the hammered bytes accumulate in U-Boot's line buffer and a newline would execute whatever they spell. `--reset-line dtr\|rts` pulses a modem line so the reset instant is the tool's rather than a human's, where the adapter is wired for it. Reports the window missed rather than exiting quietly. | | `bootintel verdict ` | Assess a U-Boot session, not a boot log. Reads a `printenv` dump taken at the prompt and reports what the boot chain permits: whether autoboot is interruptible, whether images are verified, whether a netboot path is pre-configured, whether `bootargs` can be rewritten, and whether `saveenv` makes any of it stick. Every entry names the variable it was read from. `--json` mirrors the server's `uboot_shell` / `uboot_env` / `boot_chain_verdict` keys; `--gate-exposed` exits 1 on any exposed verdict. Runs entirely offline: a U-Boot environment holds a client's internal addressing, so nothing is uploaded. Exits 3 when the capture contains no session, because "could not assess" must not look like "nothing wrong". | | `bootintel share ` | Print a bootintel.com share URL with the log embedded via lz-string compression. Nothing is uploaded — the log lives in the URL itself. | | `bootintel ports` | List serial ports on this machine with USB VID/PID + product info when known. | diff --git a/crates/cli/src/cmd/analyze.rs b/crates/cli/src/cmd/analyze.rs index cfd0533..0d35c33 100644 --- a/crates/cli/src/cmd/analyze.rs +++ b/crates/cli/src/cmd/analyze.rs @@ -10,8 +10,10 @@ use anyhow::{bail, Result}; use clap::Args as ClapArgs; use serialport::{DataBits, FlowControl, Parity, StopBits}; use std::path::PathBuf; +use std::time::Duration; use crate::analyze::state::AnalyzeState; +use crate::term::autoboot; use crate::term::hotkey::EscapePrefix; use crate::term::logfile::LogFileMode; use crate::term::run::{run_session, validate_port_hint, ApiConfig, TermOptions}; @@ -105,6 +107,53 @@ pub struct Args { #[arg(long)] no_live_display: bool, + /// Interrupt autoboot on connect, take the U-Boot prompt, pull the + /// environment, and print what the boot chain permits. + /// + /// Hammers the interrupt key from the moment the port opens rather than + /// waiting to see a countdown: with `bootdelay=0` U-Boot checks for a + /// keypress exactly once, so the key has to be in the UART before it + /// looks. POWER-CYCLE THE BOARD after this starts. Runs the read-only + /// commands (`printenv`, `bdinfo`, `mtdparts`), prints the verdict, then + /// hands the terminal back to you. Entirely offline. + #[arg(long)] + interrupt_autoboot: bool, + + /// Key hammered during the window: `space` (default), `esc`, `ctrl-c`, + /// `tab`, a literal string for builds with CONFIG_AUTOBOOT_KEYED (e.g. + /// `--interrupt-key stop`), or hex (`0x1b`). CR and LF are refused: the + /// bytes accumulate in U-Boot's line buffer and a newline would execute + /// whatever they spell. + #[arg(long, value_name = "SPEC")] + interrupt_key: Option, + + /// Milliseconds between hammer writes. Default 5. The aim is a byte + /// waiting in the receiver, not saturating the line. + #[arg(long, value_name = "MS")] + interrupt_interval: Option, + + /// Seconds to keep trying before reporting the window missed. Default 45, + /// which spans a hand power-cycle and several boot-loop cycles. + #[arg(long, value_name = "SECS")] + interrupt_timeout: Option, + + /// Command to run once the prompt is held. Repeatable; replaces the + /// read-only default set (`printenv`, `bdinfo`, `mtdparts`) entirely, so + /// you decide exactly what is typed at a client's board. + #[arg(long = "at-prompt", value_name = "CMD", action = clap::ArgAction::Append)] + at_prompt: Vec, + + /// Pulse a modem control line to reset the board, so the reset instant is + /// this tool's rather than a human's. Only works where the adapter's DTR + /// or RTS is actually wired to the board's reset, which many are not; on + /// the rest it does nothing and you should power-cycle by hand. + #[arg(long, value_name = "LINE", value_parser = ["none", "dtr", "rts"])] + reset_line: Option, + + /// Milliseconds to hold the reset line low. Default 250. + #[arg(long, value_name = "MS")] + reset_hold_ms: Option, + /// Arm Ctrl-A f for full server-side analysis (CVE matching + /// exploit paths + optional AI summary). Requires BOOTINTEL_API_KEY /// unless combined with --preview. @@ -177,6 +226,7 @@ pub fn run(args: Args) -> Result<()> { analyzer.live_display = false; } + let interrupt = build_interrupt_config(&args)?; let api = build_api_config(&args)?; let escape_prefix = EscapePrefix::parse(&args.escape).map_err(|e| anyhow::anyhow!("--escape: {e}"))?; @@ -215,6 +265,7 @@ pub fn run(args: Args) -> Result<()> { macros: super::term::build_macros(&args.macros_file, &args.macro_)?, analyzer: Some(analyzer), api, + interrupt, escape_prefix, }; @@ -253,6 +304,84 @@ pub fn run(args: Args) -> Result<()> { result } +/// Build the autoboot interrupter config, or None when the feature was not +/// asked for. +/// +/// Tuning flags are refused without `--interrupt-autoboot` rather than +/// silently ignored: someone who typed `--interrupt-key stop` and got a plain +/// terminal would reasonably conclude the tool had tried and failed. +fn build_interrupt_config(args: &Args) -> Result> { + let tuning_used = args.interrupt_key.is_some() + || args.interrupt_interval.is_some() + || args.interrupt_timeout.is_some() + || !args.at_prompt.is_empty() + || args.reset_line.is_some() + || args.reset_hold_ms.is_some(); + if !args.interrupt_autoboot { + if tuning_used { + bail!( + "the --interrupt-* / --at-prompt / --reset-* flags only apply with --interrupt-autoboot.\n example: bootintel analyze /dev/ttyUSB0 --interrupt-autoboot --interrupt-key esc" + ); + } + return Ok(None); + } + if args.tui { + bail!( + "--interrupt-autoboot is not wired into the --tui dashboard yet, and silently \n ignoring it would look like a board that refused to stop. Drop --tui for now." + ); + } + let defaults = autoboot::Config::default(); + let key = match &args.interrupt_key { + None => defaults.key.clone(), + Some(spec) => { + autoboot::parse_key(spec).map_err(|e| anyhow::anyhow!("--interrupt-key: {e}"))? + } + }; + let interval = match args.interrupt_interval { + None => defaults.interval, + // A zero interval is a busy loop on a shared serial port, which starves + // the reader thread and can only make the window harder to catch. + Some(0) => bail!("--interrupt-interval must be at least 1ms"), + Some(ms) => Duration::from_millis(ms), + }; + let timeout = match args.interrupt_timeout { + None => defaults.timeout, + Some(0) => bail!("--interrupt-timeout must be at least 1s"), + Some(secs) => Duration::from_secs(secs), + }; + let commands = if args.at_prompt.is_empty() { + defaults.commands.clone() + } else { + for cmd in &args.at_prompt { + if cmd.contains('\r') || cmd.contains('\n') { + bail!("--at-prompt {cmd:?} contains a newline; pass one command per flag"); + } + if cmd.trim().is_empty() { + bail!("--at-prompt cannot be empty"); + } + } + args.at_prompt.clone() + }; + let reset_line = match args.reset_line.as_deref() { + None | Some("none") => autoboot::ResetLine::None, + Some("dtr") => autoboot::ResetLine::Dtr, + Some("rts") => autoboot::ResetLine::Rts, + Some(other) => bail!("--reset-line {other:?}: expected none, dtr, or rts"), + }; + Ok(Some(autoboot::Config { + key, + interval, + timeout, + commands, + reset_line, + reset_hold: args + .reset_hold_ms + .map(Duration::from_millis) + .unwrap_or(defaults.reset_hold), + ..defaults + })) +} + /// Build the ApiConfig from CLI flags + env. None when --api wasn't /// requested. Errors early on illegal combos so the user finds out /// before starting a terminal session (rather than mid-session on diff --git a/crates/cli/src/cmd/term.rs b/crates/cli/src/cmd/term.rs index d15c2d3..c5b3774 100644 --- a/crates/cli/src/cmd/term.rs +++ b/crates/cli/src/cmd/term.rs @@ -174,6 +174,10 @@ pub fn run(args: Args) -> Result<()> { // Term mode never runs the analyzer. Users who want live // analysis should use `bootintel analyze` instead. analyzer: None, + // `term` is the plain terminal. The interrupter reports a verdict from + // the captured session, which needs the analyzer, so it lives on + // `analyze` alone rather than half-working here. + interrupt: None, // Nor does it wire the api. Ctrl-A f prints a hint pointing // at `bootintel analyze --api` if the user tries anyway. api: None, diff --git a/crates/cli/src/cmd/verdict.rs b/crates/cli/src/cmd/verdict.rs index 5a73cd2..5d527ce 100644 --- a/crates/cli/src/cmd/verdict.rs +++ b/crates/cli/src/cmd/verdict.rs @@ -183,7 +183,7 @@ fn state_code(state: &str) -> &'static str { } } -fn write_text( +pub(crate) fn write_text( out: &mut W, source: &str, session: &UbootSession, diff --git a/crates/cli/src/output.rs b/crates/cli/src/output.rs index 23d6902..3b5929f 100644 --- a/crates/cli/src/output.rs +++ b/crates/cli/src/output.rs @@ -851,3 +851,93 @@ mod tests { assert!(s.contains("**1 finding total**"), "singular: {s}"); } } + +/// Wraps a writer so bare LF becomes CRLF. +/// +/// Exists so one renderer can serve both a normal stdout and a terminal in raw +/// mode. Raw mode turns off ONLCR, so a `\n` moves down without returning the +/// carriage and every line starts further right than the last. Rather than keep +/// a second copy of each renderer with `\r\n` baked in (two copies of the same +/// output drift, and the boot-chain verdict is the last place that should +/// happen), the renderer keeps writing `\n` and this fixes it up in transit. +pub(crate) struct CrlfWriter { + inner: W, + /// So an LF that already follows a CR is left alone rather than becoming + /// CRCRLF. + last_was_cr: bool, +} + +impl CrlfWriter { + pub(crate) fn new(inner: W) -> Self { + Self { + inner, + last_was_cr: false, + } + } +} + +impl std::io::Write for CrlfWriter { + fn write(&mut self, buf: &[u8]) -> std::io::Result { + let mut out = Vec::with_capacity(buf.len() + 8); + for b in buf { + if *b == b'\n' && !self.last_was_cr { + out.push(b'\r'); + } + self.last_was_cr = *b == b'\r'; + out.push(*b); + } + self.inner.write_all(&out)?; + // Report the caller's byte count, not ours: a short write here would + // make the caller re-send bytes we already expanded and wrote. + Ok(buf.len()) + } + + fn flush(&mut self) -> std::io::Result<()> { + self.inner.flush() + } +} + +#[cfg(test)] +mod crlf_tests { + use super::CrlfWriter; + use std::io::Write; + + fn through(input: &str) -> String { + let mut sink = Vec::new(); + { + let mut w = CrlfWriter::new(&mut sink); + w.write_all(input.as_bytes()).unwrap(); + } + String::from_utf8(sink).unwrap() + } + + #[test] + fn bare_lf_becomes_crlf() { + assert_eq!(through("a\nb\n"), "a\r\nb\r\n"); + } + + #[test] + fn an_existing_crlf_is_left_alone() { + assert_eq!(through("a\r\nb"), "a\r\nb"); + } + + #[test] + fn the_split_across_writes_is_handled() { + // The CR and the LF can arrive in separate write calls, which is + // exactly what a formatter doing many small writes produces. + let mut sink = Vec::new(); + { + let mut w = CrlfWriter::new(&mut sink); + w.write_all(b"a\r").unwrap(); + w.write_all(b"\nb").unwrap(); + } + assert_eq!(String::from_utf8(sink).unwrap(), "a\r\nb"); + } + + #[test] + fn the_caller_sees_its_own_byte_count() { + let mut sink = Vec::new(); + let mut w = CrlfWriter::new(&mut sink); + assert_eq!(w.write(b"x\ny").unwrap(), 3); + } +} diff --git a/crates/cli/src/term/autoboot.rs b/crates/cli/src/term/autoboot.rs new file mode 100644 index 0000000..11ecb46 --- /dev/null +++ b/crates/cli/src/term/autoboot.rs @@ -0,0 +1,1050 @@ +//! Interrupting autoboot, and pulling the environment once the prompt lands. +//! +//! # Why this is not "watch for the countdown, then send a key" +//! +//! That is the obvious design and it does not work. U-Boot's autoboot delay is +//! a loop around `tstc()`, and the shortest useful configuration is +//! `bootdelay=0`, where the check happens **once**. Boards in the field are +//! routinely built that way, and plenty of others leave a window of a few +//! milliseconds. By the time "Hit any key to stop autoboot" has crossed the +//! wire, been read by this process, and been recognised, the window is gone: +//! at 115200 baud a single character is already ~87us of wire time, and the +//! read is scheduled by the OS, not by us. +//! +//! What works is that the byte is already in the UART's receive register when +//! the board looks. A serial port buffers, so a character sent before the check +//! is still waiting at the check. So this hammers from the instant the port +//! opens, continuously, and asks the operator to power-cycle AFTER that starts. +//! There is nothing to detect and nothing to react to, which is the point: +//! reaction is exactly the thing that is too slow. +//! +//! `--reset-line` closes the loop entirely by pulsing DTR or RTS, so the reset +//! instant is ours rather than a human's. That needs an adapter wired to the +//! board's reset, which many are not, so it is opt-in. +//! +//! # The split +//! +//! Two things with completely different requirements: +//! +//! * The hammer must be FAST, so it is a thread that writes one byte string +//! on a timer and holds no logic at all (`run.rs`). +//! * Deciding when to stop hammering, and what to type, must be CORRECT, and +//! happens at human timescales once a prompt exists. That is this module: +//! a state machine over the received bytes with no clock of its own and no +//! I/O, so the interesting cases are unit tests rather than a board on a +//! bench. +//! +//! # Safety on someone else's hardware +//! +//! A consultant runs this against a client's only sample of a device. +//! +//! * The hammer NEVER sends CR or LF. Its bytes accumulate in U-Boot's line +//! buffer, and a newline would execute whatever they spell. This is +//! enforced when the key is parsed, and asserted in the tests. +//! * The default key is a space, which is a no-op at a U-Boot prompt. +//! * The default commands are read-only: `printenv`, `bdinfo`, `mtdparts`. +//! * Every byte this module sends is announced, so a session transcript shows +//! what the tool typed as distinct from what the board said. + +use std::time::{Duration, Instant}; + +use regex::Regex; +use std::sync::LazyLock; + +/// A prompt, seen live. This is NOT the pattern the engine uses on a saved +/// capture, and the difference is deliberate. +/// +/// A saved capture is read line by line, and a prompt line there is terminated +/// because whatever was typed next ended it. Live, a prompt is the one thing +/// that arrives WITHOUT a newline: the board prints `=> ` and waits. So the +/// subject here is the unterminated tail of the stream, and the shapes worth +/// accepting are wider, because the vendor-rebranded `RTL8672 # ` that the +/// engine's stricter `=>` pattern skips is a prompt an operator can absolutely +/// type into. +static RE_PROMPT_TAIL: LazyLock = LazyLock::new(|| { + Regex::new(r"(?i)^\s*(?:=>|([\w][\w.@:/~()\-]{0,31}?)\s*(?:=>|[>#]))\s*$").unwrap() +}); + +static RE_ANSI: LazyLock = LazyLock::new(|| Regex::new(r"\x1b\[[0-?]*[ -/]*[@-~]").unwrap()); + +/// Markers that the bootloader has handed off. After one of these, a `#` +/// prompt is a Linux root shell, not U-Boot, and typing `printenv` into it +/// would produce a shell's environment and a confident, wrong verdict. +const HANDOFF: &[&str] = &[ + "Starting kernel", + "Booting Linux", + "Uncompressing Linux", + "Linux version", + "Booting kernel", + "starting pid ", + "init started", +]; + +/// Markers that a fresh bootloader cycle has begun, which clears the handoff +/// latch: the operator power-cycled and we are back before the handoff. +const BOOTLOADER_BANNER: &[&str] = &[ + "U-Boot 1", + "U-Boot 2", + "U-Boot SPL", + "Hit any key", + "autoboot", + "reset", + "CPU:", + "DRAM:", +]; + +/// What the caller should do next. Every side effect is one of these, so the +/// state machine itself touches nothing. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Action { + /// Begin (or resume) hammering the interrupt key. + StartHammer, + /// Stop hammering. Always emitted before anything is typed, so the tool's + /// commands cannot interleave with the hammer's bytes. + StopHammer, + /// Pulse the configured reset line. + PulseReset, + /// Write these bytes to the port verbatim. + Send(Vec), + /// Tell the operator something, on its own line. + Note(String), + /// Every command ran. The caller prints the verdict and hands the terminal + /// back to the user. + Done, + /// The window was not caught, or the prompt could not be held. Carries an + /// operator-facing explanation. Never silent, and never mistaken for + /// success. + GaveUp(String), +} + +/// How the interrupter behaves. Defaults are the ones that work on an unknown +/// board with no information about it. +#[derive(Debug, Clone)] +pub struct Config { + /// Bytes hammered during the window. Guaranteed CR/LF-free by + /// [`parse_key`]. + pub key: Vec, + /// Gap between hammer writes. The hammer's job is to have a byte waiting + /// in the receiver, not to flood, so this is a floor on wire use rather + /// than a race to be won. + pub interval: Duration, + /// Total time to keep trying before reporting the window missed. Spans a + /// power cycle the operator performs by hand, and several if the board is + /// in a boot loop. + pub timeout: Duration, + /// Commands to run once the prompt is held. Read-only by default. + pub commands: Vec, + /// How long the line must be quiet before typing. A board mid-transfer + /// prints a progress line that can look like a prompt; it will not stay + /// quiet, and a real prompt will. + pub settle: Duration, + /// How long to wait for the prompt to come back after typing. On expiry + /// the hammer is re-armed rather than the attempt abandoned, because the + /// usual cause is a false prompt or a board that reset under us. + pub prompt_wait: Duration, + /// Pulse a reset line at the start. + pub reset_line: ResetLine, + /// How long the reset line is held before releasing it. + pub reset_hold: Duration, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum ResetLine { + #[default] + None, + Dtr, + Rts, +} + +impl Default for Config { + fn default() -> Self { + Self { + key: vec![b' '], + interval: Duration::from_millis(5), + timeout: Duration::from_secs(45), + commands: vec![ + "printenv".to_string(), + "bdinfo".to_string(), + "mtdparts".to_string(), + ], + settle: Duration::from_millis(300), + prompt_wait: Duration::from_secs(5), + reset_line: ResetLine::None, + reset_hold: Duration::from_millis(250), + } + } +} + +/// Parse a `--interrupt-key` spec into bytes, refusing anything containing a +/// newline. +/// +/// The refusal is the point. A hammered CR executes whatever the other +/// hammered bytes spell, on a board that is not ours. +pub fn parse_key(spec: &str) -> Result, String> { + let bytes = match spec.to_ascii_lowercase().as_str() { + "space" | "" => vec![b' '], + "esc" | "escape" => vec![0x1b], + "ctrl-c" | "ctrl_c" | "^c" => vec![0x03], + "tab" => vec![b'\t'], + _ => { + if let Some(hex) = spec.strip_prefix("0x").or_else(|| spec.strip_prefix("0X")) { + if hex.is_empty() + || hex.len() % 2 != 0 + || !hex.chars().all(|c| c.is_ascii_hexdigit()) + { + return Err(format!( + "{spec:?} is not a byte string; use an even number of hex digits, e.g. 0x1b" + )); + } + (0..hex.len()) + .step_by(2) + .map(|i| u8::from_str_radix(&hex[i..i + 2], 16).expect("validated hex")) + .collect() + } else { + spec.as_bytes().to_vec() + } + } + }; + if bytes.iter().any(|b| *b == b'\r' || *b == b'\n') { + return Err( + "the interrupt key cannot contain CR or LF. It is sent repeatedly and its bytes \ + accumulate in U-Boot's line buffer, so a newline would execute whatever they \ + spell on the board. Send a bare key (space, esc) and let bootintel type the \ + commands." + .to_string(), + ); + } + if bytes.is_empty() { + return Err("the interrupt key cannot be empty".to_string()); + } + Ok(bytes) +} + +#[derive(Debug, Clone, PartialEq, Eq)] +enum Phase { + /// Hammering, waiting for a prompt to appear. + Hammering, + /// A prompt tail is present; waiting for the line to go quiet. + Settling { + last_byte: Instant, + }, + /// A bare CR was sent to flush the hammered bytes; waiting for the prompt + /// it should print in response. + Confirming { + sent: Instant, + }, + /// `commands[index]` was sent; waiting for the prompt that follows it. + Running { + index: usize, + sent: Instant, + }, + Finished, +} + +pub struct Interrupter { + cfg: Config, + phase: Phase, + started: Instant, + /// The unterminated tail of the current line, ANSI stripped. Reset on + /// every send so a prompt we have already acted on cannot match again. + tail: String, + /// True once the bootloader has handed off, cleared by a fresh banner. + /// While set, a `#` prompt is treated as a Linux shell and ignored. + past_bootloader: bool, + /// So the Linux-prompt explanation is given once rather than per chunk. + warned_past_bootloader: bool, + armed: bool, +} + +impl Interrupter { + pub fn new(cfg: Config, now: Instant) -> Self { + Self { + cfg, + phase: Phase::Hammering, + started: now, + tail: String::new(), + past_bootloader: false, + warned_past_bootloader: false, + armed: false, + } + } + + /// True once the sequence is over, either way. + pub fn is_finished(&self) -> bool { + self.phase == Phase::Finished + } + + /// The opening moves: start hammering before anything else happens, and + /// tell the operator what to do with the window that opens. + pub fn begin(&mut self) -> Vec { + self.armed = true; + let mut out = vec![Action::StartHammer]; + if self.cfg.reset_line != ResetLine::None { + // Say what is about to happen BEFORE it happens. Driving the line + // can fail (a USB adapter that does not expose it, a pty), and + // reading that failure before reading the intent is confusing. + out.push(Action::Note(format!( + "hammering {} and pulsing {} to reset the board", + describe(&self.cfg.key), + match self.cfg.reset_line { + ResetLine::Dtr => "DTR", + ResetLine::Rts => "RTS", + ResetLine::None => unreachable!(), + } + ))); + out.push(Action::PulseReset); + } else { + out.push(Action::Note(format!( + "hammering {} now: POWER-CYCLE THE BOARD. The key has to be waiting in the \ + UART before U-Boot looks, so this only works if the reset happens after \ + this line.", + describe(&self.cfg.key) + ))); + } + out + } + + /// Feed received bytes (empty on an idle tick) and get the next actions. + pub fn poll(&mut self, now: Instant, rx: &[u8]) -> Vec { + if self.phase == Phase::Finished { + return Vec::new(); + } + let mut out = Vec::new(); + if !rx.is_empty() { + self.absorb(rx); + } + + // The overall deadline applies in every phase but the last: a board + // that hands us a prompt and then reboots forever should still stop. + if now.duration_since(self.started) > self.cfg.timeout { + self.phase = Phase::Finished; + out.push(Action::StopHammer); + out.push(Action::GaveUp(self.missed_explanation())); + return out; + } + + loop { + let before = self.phase.clone(); + match self.phase.clone() { + Phase::Hammering => { + if self.prompt_visible() { + if self.past_bootloader { + if !self.warned_past_bootloader { + self.warned_past_bootloader = true; + out.push(Action::Note( + "a prompt appeared after the kernel handoff, so it is a \ + Linux shell, not U-Boot. Still hammering for the next \ + boot: power-cycle again." + .to_string(), + )); + } + } else { + out.push(Action::StopHammer); + out.push(Action::Note(format!( + "prompt reached: {:?}. Autoboot was interrupted.", + self.tail.trim() + ))); + self.phase = Phase::Settling { last_byte: now }; + } + } + } + Phase::Settling { last_byte } => { + if !rx.is_empty() { + self.phase = Phase::Settling { last_byte: now }; + } else if now.duration_since(last_byte) >= self.cfg.settle { + // A bare CR. It flushes the hammered bytes (spaces are + // a no-op line) and makes the board print a fresh + // prompt, which is the confirmation that we are really + // at one and not looking at a progress line that + // happened to end in `#`. + out.push(self.send(b"\r".to_vec())); + self.phase = Phase::Confirming { sent: now }; + } + } + Phase::Confirming { sent } => { + if self.prompt_visible() && !self.past_bootloader { + out.extend(self.start_command(0, now)); + } else if now.duration_since(sent) > self.cfg.prompt_wait { + // Not a prompt after all, or the board reset under us. + // Re-arm rather than abandon: a false positive should + // cost one round trip, not the session. + out.push(Action::Note( + "no prompt came back, so that was not one. Re-arming.".to_string(), + )); + out.push(Action::StartHammer); + self.phase = Phase::Hammering; + } + } + Phase::Running { index, sent } => { + if self.prompt_visible() && !self.past_bootloader { + let next = index + 1; + if next < self.cfg.commands.len() { + out.extend(self.start_command(next, now)); + } else { + self.phase = Phase::Finished; + out.push(Action::Note( + "environment captured. The prompt is yours; the verdict is \ + below." + .to_string(), + )); + out.push(Action::Done); + } + } else if now.duration_since(sent) > self.cfg.prompt_wait { + out.push(Action::Note(format!( + "no prompt after {:?}; the board may have reset. Re-arming.", + self.cfg.commands[index] + ))); + out.push(Action::StartHammer); + self.phase = Phase::Hammering; + } + } + Phase::Finished => {} + } + // Phases can advance more than once per poll (settle expiring and + // the prompt already being visible, say), so run until stable. + if self.phase == before { + break; + } + } + out + } + + fn start_command(&mut self, index: usize, now: Instant) -> Vec { + let cmd = self.cfg.commands[index].clone(); + let mut out = vec![Action::Note(format!("typing `{cmd}`"))]; + out.push(self.send(format!("{cmd}\r").into_bytes())); + self.phase = Phase::Running { index, sent: now }; + out + } + + /// Emit a send and clear the tail, so the prompt that prompted this send + /// cannot immediately satisfy the wait for the NEXT one. + fn send(&mut self, bytes: Vec) -> Action { + debug_assert!( + !bytes.is_empty(), + "an empty send would clear the tail for nothing" + ); + self.tail.clear(); + Action::Send(bytes) + } + + /// Track the unterminated tail and the two latches, without keeping the + /// whole session: the analyzer already has that. + fn absorb(&mut self, rx: &[u8]) { + let text = String::from_utf8_lossy(rx); + for chunk in text.split_inclusive(['\n', '\r']) { + if chunk.ends_with('\n') || chunk.ends_with('\r') { + let line = format!("{}{}", self.tail, chunk); + self.note_markers(&line); + self.tail.clear(); + } else { + self.tail.push_str(chunk); + // A prompt is short. Anything longer is output that has not + // ended yet, and letting it grow unbounded would turn a chatty + // board into a memory leak. + if self.tail.len() > 512 { + let keep = self.tail.len() - 256; + self.tail = self.tail.split_off(keep); + } + let tail = self.tail.clone(); + self.note_markers(&tail); + } + } + } + + fn note_markers(&mut self, line: &str) { + if HANDOFF.iter().any(|m| line.contains(m)) { + self.past_bootloader = true; + } else if BOOTLOADER_BANNER.iter().any(|m| line.contains(m)) { + // A new cycle: whatever we concluded about the last one no longer + // applies. + self.past_bootloader = false; + self.warned_past_bootloader = false; + } + } + + fn prompt_visible(&self) -> bool { + is_prompt_tail(&self.tail) + } + + fn missed_explanation(&self) -> String { + format!( + "autoboot was not interrupted within {}s, so nothing was assessed.\n \ + The usual causes, in order: the board was not reset while this was hammering \ + (the key must already be in the UART when U-Boot looks); the console is on a \ + different UART than {:?} suggests; the baud rate is wrong, so the board saw \ + noise rather than the key; or the build needs a specific key \ + (CONFIG_AUTOBOOT_KEYED), which --interrupt-key can send.", + self.cfg.timeout.as_secs(), + describe(&self.cfg.key), + ) + } +} + +/// Does this unterminated tail look like a prompt waiting for input? +pub fn is_prompt_tail(tail: &str) -> bool { + let clean = RE_ANSI.replace_all(tail, ""); + let clean = clean.trim_start_matches(['\r', '\n']); + if clean.len() > 64 { + return false; + } + match RE_PROMPT_TAIL.captures(clean) { + None => false, + Some(caps) => match caps.get(1) { + // The bare `=>` form: unambiguous. + None => true, + // A named prompt. `Loading: #` is a TFTP progress line, not a + // board called "Loading:", and a label ending in a colon is the + // cheap way to tell them apart. A real false positive here costs + // one round trip, since the prompt has to come back to be + // believed. + Some(name) => !name.as_str().ends_with(':'), + }, + } +} + +fn describe(key: &[u8]) -> String { + match key { + [b' '] => "space".to_string(), + [0x1b] => "ESC".to_string(), + [0x03] => "Ctrl-C".to_string(), + other => match std::str::from_utf8(other) { + Ok(s) if s.chars().all(|c| c.is_ascii_graphic()) => format!("{s:?}"), + _ => other + .iter() + .map(|b| format!("{b:02x}")) + .collect::>() + .join(" "), + }, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn cfg() -> Config { + Config { + settle: Duration::from_millis(100), + prompt_wait: Duration::from_millis(500), + timeout: Duration::from_secs(10), + commands: vec!["printenv".into(), "bdinfo".into()], + ..Config::default() + } + } + + fn sends(actions: &[Action]) -> Vec { + actions + .iter() + .filter_map(|a| match a { + Action::Send(b) => Some(String::from_utf8_lossy(b).into_owned()), + _ => None, + }) + .collect() + } + + #[test] + fn a_bare_uboot_prompt_is_recognised() { + for tail in [ + "=> ", + "=>", + " => ", + "U-Boot> ", + "uboot# ", + "RTL8672 # ", + "board=> ", + ] { + assert!(is_prompt_tail(tail), "{tail:?} should be a prompt"); + } + } + + /// The false positive that actually happens. U-Boot prints one `#` per + /// block during a TFTP or flash transfer, so mid-transfer the unterminated + /// tail is `Loading: #`. + #[test] + fn a_transfer_progress_line_is_not_a_prompt() { + for tail in [ + "Loading: #", + "Loading: ####", + "Loading: #################", + "## Booting kernel from Legacy Image at 82000000 ...", + "Uncompressing Linux... ", + "Hit any key to stop autoboot: 2", + "bootcmd=bootm 0x82000000", + ] { + assert!(!is_prompt_tail(tail), "{tail:?} should not be a prompt"); + } + } + + #[test] + fn escape_sequences_do_not_hide_a_prompt() { + assert!(is_prompt_tail("\x1b[0m=> ")); + assert!(is_prompt_tail("\x1b[1;32mRTL8672 # \x1b[0m")); + } + + #[test] + fn a_long_tail_is_output_not_a_prompt() { + let long = format!("{}# ", "x".repeat(80)); + assert!(!is_prompt_tail(&long)); + } + + /// The safety property. A hammered CR executes whatever the other hammered + /// bytes spell, on hardware that is not ours. + #[test] + fn a_key_containing_a_newline_is_refused() { + for spec in ["\r", "\n", "a\rb", "0x0d", "0x200a"] { + let err = parse_key(spec).expect_err("should refuse"); + assert!(err.contains("CR or LF"), "{spec:?}: {err}"); + } + } + + #[test] + fn key_specs_parse_to_the_documented_bytes() { + assert_eq!(parse_key("space").unwrap(), b" "); + assert_eq!(parse_key("esc").unwrap(), vec![0x1b]); + assert_eq!(parse_key("ctrl-c").unwrap(), vec![0x03]); + assert_eq!(parse_key("0x1b").unwrap(), vec![0x1b]); + assert_eq!(parse_key("stop").unwrap(), b"stop"); + assert!(parse_key("0xZZ").is_err()); + assert!(parse_key("0x1").is_err()); + } + + #[test] + fn hammering_starts_before_anything_is_observed() { + // The whole design rests on this: the key has to be on its way before + // the board is looked at, so StartHammer is the first action and it + // does not depend on having seen any bytes. + let t0 = Instant::now(); + let mut it = Interrupter::new(cfg(), t0); + let first = it.begin(); + assert_eq!(first[0], Action::StartHammer); + assert!(matches!(first[1], Action::Note(_))); + assert!(sends(&first).is_empty(), "nothing is typed before a prompt"); + } + + #[test] + fn the_happy_path_runs_every_command_in_order() { + let t0 = Instant::now(); + let mut it = Interrupter::new(cfg(), t0); + it.begin(); + + // Autoboot interrupted: a prompt with no newline after it. + let a = it.poll(t0, b"Hit any key to stop autoboot: 2\r\n=> "); + assert!(a.contains(&Action::StopHammer), "{a:?}"); + assert!( + sends(&a).is_empty(), + "nothing typed until the line is quiet" + ); + + // Quiet for the settle window: a bare CR to flush the hammered spaces + // and make the board prove it is at a prompt. + let a = it.poll(t0 + Duration::from_millis(150), b""); + assert_eq!(sends(&a), vec!["\r"]); + + // The board prints a fresh prompt, so the first command goes out. + let a = it.poll(t0 + Duration::from_millis(160), b"\r\n=> "); + assert_eq!(sends(&a), vec!["printenv\r"]); + + // printenv output, then the prompt again. + let a = it.poll( + t0 + Duration::from_millis(200), + b"bootdelay=2\r\nEnvironment size: 90/65532 bytes\r\n=> ", + ); + assert_eq!(sends(&a), vec!["bdinfo\r"]); + + let a = it.poll( + t0 + Duration::from_millis(300), + b"arch_number = 0x00000c\r\n=> ", + ); + assert!(a.contains(&Action::Done), "{a:?}"); + assert!(it.is_finished()); + assert!(it.poll(t0 + Duration::from_millis(400), b"=> ").is_empty()); + } + + /// Without clearing the tail on every send, one visible prompt would + /// satisfy the wait for all of them and the whole command list would go + /// out in a single burst, interleaved with the board's replies. + #[test] + fn a_stale_prompt_does_not_advance_the_next_command() { + let t0 = Instant::now(); + let mut it = Interrupter::new(cfg(), t0); + it.begin(); + it.poll(t0, b"=> "); + it.poll(t0 + Duration::from_millis(150), b""); + let a = it.poll(t0 + Duration::from_millis(160), b"\r\n=> "); + assert_eq!(sends(&a), vec!["printenv\r"]); + // No new bytes: the prompt we just used must not count again. + let a = it.poll(t0 + Duration::from_millis(170), b""); + assert!(sends(&a).is_empty(), "{a:?}"); + } + + /// A `#` prompt after the kernel has started is a root shell. Typing + /// `printenv` into it yields a shell environment and a confident, wrong + /// verdict about the boot chain. + #[test] + fn a_linux_prompt_is_not_mistaken_for_u_boot() { + let t0 = Instant::now(); + let mut it = Interrupter::new(cfg(), t0); + it.begin(); + let a = it.poll(t0, b"Starting kernel ...\r\n\r\nLinux version 5.10\r\n"); + assert!(sends(&a).is_empty()); + // A bare `# ` is not accepted as a prompt at all, which is the first + // line of defence. This is the shape that gets past that: a + // distro prompt with a hostname in it. + assert!( + !is_prompt_tail("# "), + "a bare hash is too ambiguous to act on" + ); + let a = it.poll( + t0 + Duration::from_millis(10), + b"BusyBox v1.35\r\nroot@openwrt:/# ", + ); + assert!(sends(&a).is_empty(), "typed into a Linux shell: {a:?}"); + assert!(!a.contains(&Action::StopHammer), "stopped hammering: {a:?}"); + assert!( + matches!(a.first(), Some(Action::Note(n)) if n.contains("Linux shell")), + "{a:?}" + ); + + // The operator power-cycles. A fresh bootloader banner clears the + // latch, and the next prompt is taken. + let a = it.poll( + t0 + Duration::from_millis(20), + b"\r\nU-Boot 2020.10\r\nDRAM: 128 MiB\r\n=> ", + ); + assert!(a.contains(&Action::StopHammer), "{a:?}"); + } + + /// A prompt that does not answer should cost one round trip, not the + /// attempt: the usual cause is a board that reset under us, or a watchdog, + /// and the window can still be caught on the next cycle. Giving up on the + /// first disappointment would make this useless on exactly the flaky + /// hardware it is for. + #[test] + fn a_prompt_that_does_not_answer_re_arms_rather_than_giving_up() { + let t0 = Instant::now(); + let mut it = Interrupter::new(cfg(), t0); + it.begin(); + it.poll(t0, b"=> "); + let a = it.poll(t0 + Duration::from_millis(150), b""); + assert_eq!(sends(&a), vec!["\r"]); + // Silence: the board went away between the prompt and the CR. + let a = it.poll(t0 + Duration::from_millis(800), b""); + assert!(a.contains(&Action::StartHammer), "{a:?}"); + assert!( + !a.iter().any(|x| matches!(x, Action::GaveUp(_))), + "gave up on one false positive: {a:?}" + ); + } + + #[test] + fn the_window_being_missed_is_reported_not_swallowed() { + let t0 = Instant::now(); + let mut it = Interrupter::new(cfg(), t0); + it.begin(); + let a = it.poll(t0 + Duration::from_secs(11), b""); + assert!(a.contains(&Action::StopHammer), "{a:?}"); + let reason = a + .iter() + .find_map(|x| match x { + Action::GaveUp(r) => Some(r.clone()), + _ => None, + }) + .expect("a reason"); + // The message has to be usable by someone standing at a bench with a + // board that did not stop. + for expected in [ + "not reset", + "baud", + "CONFIG_AUTOBOOT_KEYED", + "different UART", + ] { + assert!(reason.contains(expected), "missing {expected:?}: {reason}"); + } + assert!(it.is_finished()); + } + + #[test] + fn a_reset_line_is_pulsed_before_the_board_is_looked_at() { + let t0 = Instant::now(); + let mut it = Interrupter::new( + Config { + reset_line: ResetLine::Dtr, + ..cfg() + }, + t0, + ); + let a = it.begin(); + assert_eq!(a[0], Action::StartHammer, "hammer first, then reset: {a:?}"); + // The explanation comes before the action, so a failure to drive the + // line reads as a failure of something already announced. + let note = a + .iter() + .position(|x| matches!(x, Action::Note(_))) + .expect("a note"); + let pulse = a + .iter() + .position(|x| *x == Action::PulseReset) + .expect("the pulse"); + assert!(note < pulse, "{a:?}"); + } +} + +/// A simulated board, and the clock, so the timing claim in this module's +/// header is a test rather than an assertion. +/// +/// The thing being modelled is the case that makes the obvious design wrong: a +/// board whose autoboot check runs exactly ONCE, `check_at` after reset. Before +/// that instant the board is emitting boot output; at that instant it looks at +/// its receiver; after it, the board is either at a prompt or gone into the +/// kernel, with nothing an operator can do about it. +#[cfg(test)] +mod sim { + use super::*; + + pub struct OneShotBoard { + /// When the single `tstc()` happens, measured from reset. + pub check_at: Duration, + /// Bytes the tool has sent that the board has not consumed yet. This is + /// the UART receive buffer, and it is the whole reason a key sent + /// EARLIER than the check still counts AT the check. + pending: Vec, + checked: bool, + stopped: bool, + /// The line being typed at the prompt. + line: String, + pub commands_seen: Vec, + } + + impl OneShotBoard { + pub fn new(check_at: Duration) -> Self { + Self { + check_at, + pending: Vec::new(), + checked: false, + stopped: false, + line: String::new(), + commands_seen: Vec::new(), + } + } + + pub fn recv(&mut self, bytes: &[u8]) { + self.pending.extend_from_slice(bytes); + } + + /// Advance the board to `t` and return whatever it emitted. + pub fn tick(&mut self, t: Duration) -> Vec { + if !self.checked && t >= self.check_at { + self.checked = true; + // U-Boot prints the countdown and looks at the receiver in the + // same breath. A tool that waits to SEE this line and then + // sends a key is sending it after this moment has passed. + let mut out = b"Hit any key to stop autoboot: 0\r\n".to_vec(); + self.stopped = !self.pending.is_empty(); + self.pending.clear(); + if self.stopped { + out.extend_from_slice(b"=> "); + } else { + out.extend_from_slice(b"Starting kernel ...\r\n"); + } + return out; + } + if !self.stopped { + return Vec::new(); + } + // At the prompt: accumulate typed bytes, act on CR. + let mut out = Vec::new(); + let pending = std::mem::take(&mut self.pending); + for b in pending { + if b == b'\r' || b == b'\n' { + let cmd = self.line.trim().to_string(); + self.line.clear(); + out.extend_from_slice(b"\r\n"); + if !cmd.is_empty() { + self.commands_seen.push(cmd.clone()); + } + match cmd.as_str() { + "" => {} + "printenv" => out.extend_from_slice( + b"bootdelay=0\r\nbootcmd=bootm 0x82000000\r\n\ + Environment size: 48/65532 bytes\r\n", + ), + other => out + .extend_from_slice(format!("Unknown command '{other}'\r\n").as_bytes()), + } + out.extend_from_slice(b"=> "); + } else { + self.line.push(b as char); + } + } + out + } + } + + pub struct Outcome { + pub done: bool, + pub gave_up: Option, + pub commands: Vec, + /// When the hammer was first armed, or None if it never was. + pub armed_at: Option, + } + + /// Run the interrupter against a board for three simulated seconds, one + /// millisecond at a time. `arm_on_banner` models the WRONG design: instead + /// of hammering from the start, wait until the countdown has been seen. + pub fn run(board: &mut OneShotBoard, cfg: Config, arm_on_banner: bool) -> Outcome { + let base = Instant::now(); + let mut it = Interrupter::new(cfg.clone(), base); + let mut armed = false; + let mut armed_at = None; + let mut done = false; + let mut gave_up = None; + let mut hammer_due = Duration::ZERO; + + let apply = |actions: Vec, + board: &mut OneShotBoard, + armed: &mut bool, + armed_at: &mut Option, + done: &mut bool, + gave_up: &mut Option, + t: Duration| { + for a in actions { + match a { + Action::StartHammer => { + if !*armed { + *armed = true; + if armed_at.is_none() { + *armed_at = Some(t); + } + } + } + Action::StopHammer => *armed = false, + Action::Send(bytes) => board.recv(&bytes), + Action::Done => *done = true, + Action::GaveUp(r) => *gave_up = Some(r), + Action::Note(_) | Action::PulseReset => {} + } + } + }; + + let opening = it.begin(); + if arm_on_banner { + // The reactive design: discard the instruction to start hammering + // and wait for evidence instead. + let filtered: Vec = opening + .into_iter() + .filter(|a| *a != Action::StartHammer) + .collect(); + apply( + filtered, + board, + &mut armed, + &mut armed_at, + &mut done, + &mut gave_up, + Duration::ZERO, + ); + } else { + apply( + opening, + board, + &mut armed, + &mut armed_at, + &mut done, + &mut gave_up, + Duration::ZERO, + ); + } + + for ms in 0..3000u64 { + let t = Duration::from_millis(ms); + if armed && t >= hammer_due { + board.recv(&cfg.key); + hammer_due = t + cfg.interval; + } + let rx = board.tick(t); + if arm_on_banner && !armed && String::from_utf8_lossy(&rx).contains("Hit any key") { + // Seen it. Start hammering now, which is the point: "now" is + // already too late. + armed = true; + armed_at = Some(t); + } + let actions = it.poll(base + t, &rx); + apply( + actions, + board, + &mut armed, + &mut armed_at, + &mut done, + &mut gave_up, + t, + ); + if done || gave_up.is_some() { + break; + } + } + Outcome { + done, + gave_up, + commands: board.commands_seen.clone(), + armed_at, + } + } +} + +#[cfg(test)] +mod timing_tests { + use super::sim::{self, OneShotBoard}; + use super::*; + + fn cfg() -> Config { + Config { + settle: Duration::from_millis(20), + prompt_wait: Duration::from_millis(500), + timeout: Duration::from_secs(2), + commands: vec!["printenv".into()], + interval: Duration::from_millis(5), + ..Config::default() + } + } + + /// The claim this whole design rests on: hammering from the start catches a + /// window that is one check wide, wherever in the boot that check lands. + #[test] + fn hammering_from_the_start_catches_a_single_check() { + for ms in [1u64, 2, 7, 13, 60, 250, 900] { + let mut board = OneShotBoard::new(Duration::from_millis(ms)); + let out = sim::run(&mut board, cfg(), false); + assert!( + out.done, + "missed a check at {ms}ms: gave up with {:?}", + out.gave_up + ); + assert_eq!(out.commands, vec!["printenv"], "at {ms}ms"); + assert_eq!(out.armed_at, Some(Duration::ZERO), "at {ms}ms"); + } + } + + /// The contrast, and the reason the hammer does not wait for evidence. + /// This asserts the physics of the simulated board rather than this + /// module's logic: by the time the countdown has crossed the wire, the + /// board has already looked at its receiver and found it empty. + #[test] + fn waiting_to_see_the_countdown_misses_the_window() { + let mut board = OneShotBoard::new(Duration::from_millis(7)); + let out = sim::run(&mut board, cfg(), true); + assert!(!out.done, "a reactive key somehow caught a one-shot check"); + assert!(out.gave_up.is_some(), "it should report the miss, not hang"); + assert!(out.commands.is_empty(), "nothing should have been typed"); + } + + /// A board that boots straight past with no interruptible window at all + /// has to be reported, not quietly tolerated. + #[test] + fn a_board_that_never_stops_is_reported() { + let mut board = OneShotBoard::new(Duration::from_secs(30)); + let out = sim::run(&mut board, cfg(), false); + assert!(!out.done); + assert!(out.gave_up.expect("a reason").contains("not interrupted")); + } +} diff --git a/crates/cli/src/term/mod.rs b/crates/cli/src/term/mod.rs index dc6f7dd..02aa7c9 100644 --- a/crates/cli/src/term/mod.rs +++ b/crates/cli/src/term/mod.rs @@ -22,6 +22,7 @@ //! channel that the main thread drains. Fully synchronous — no //! async runtime. +pub mod autoboot; pub mod hotkey; pub mod logfile; pub mod macros; diff --git a/crates/cli/src/term/run.rs b/crates/cli/src/term/run.rs index b3c28e6..fe9e903 100644 --- a/crates/cli/src/term/run.rs +++ b/crates/cli/src/term/run.rs @@ -15,7 +15,7 @@ use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::mpsc; use std::sync::Arc; use std::thread; -use std::time::Duration; +use std::time::{Duration, Instant}; use super::hotkey::{Action, BackspaceMode, EscapePrefix, State as HotkeyState}; use super::logfile::{LogFile, LogFileMode}; @@ -84,6 +84,12 @@ pub struct TermOptions { /// When Some (analyze mode only), Ctrl-A f is armed for full /// server-side analysis. None disables the hotkey with a hint. pub api: Option, + /// When Some, interrupt autoboot on connect and pull the environment: + /// hammer the interrupt key from the moment the port opens, take the + /// prompt, run the read-only commands, then print the boot-chain verdict + /// and hand the terminal back. See `super::autoboot` for why this cannot + /// be done by watching for the countdown. + pub interrupt: Option, /// Escape prefix for the hotkey state machine. Defaults to Ctrl-A. /// Users nested inside a tmux/screen session that also binds /// Ctrl-A can pass --escape ctrl-t (or whatever) to avoid the @@ -186,6 +192,54 @@ pub fn run_session(mut opts: TermOptions) -> Result<()> { .context("cloning serial port for reader thread")?; let mut serial_thread = spawn_reader_thread(read_port, tx.clone(), reader_shutdown.clone()); + // Autoboot interrupter. Armed here, as early as the port allows, because + // the whole technique depends on the key being in the board's receiver + // before U-Boot looks at it -- see `super::autoboot`. The notes print + // before raw mode is entered, so "power-cycle the board" is on screen + // while the operator still has a normal terminal. + let hammer_on = Arc::new(AtomicBool::new(false)); + // Collected here and applied once the main loop's writers are in scope. + let mut pending_autoboot: Vec = Vec::new(); + let mut interrupter = match &opts.interrupt { + None => None, + Some(cfg) => { + let mut hammer_port = port + .try_clone() + .context("cloning serial port for the autoboot hammer")?; + let key = cfg.key.clone(); + let interval = cfg.interval; + let flag = hammer_on.clone(); + let hammer_shutdown = shutdown.clone(); + // Deliberately the dumbest thread in the program: no parsing, no + // decisions, nothing that can block. Every judgement lives in the + // state machine on the main thread, so the only thing that has to + // be fast is the only thing that is here. + thread::spawn(move || { + let mut was_armed = false; + while !hammer_shutdown.load(Ordering::Relaxed) { + let armed = flag.load(Ordering::Relaxed); + if armed { + // On the arming edge, a short burst. A board whose + // autoboot check runs once needs a byte already + // waiting, and the cost of eight spaces is nothing. + let reps = if was_armed { 1 } else { 8 }; + for _ in 0..reps { + if hammer_port.write_all(&key).is_err() { + return; + } + } + let _ = hammer_port.flush(); + } + was_armed = armed; + thread::sleep(interval); + } + }); + let mut it = super::autoboot::Interrupter::new(cfg.clone(), Instant::now()); + pending_autoboot = it.begin(); + Some(it) + } + }; + // Keyboard-read thread. crossterm::event::poll lets us check the // shutdown flag periodically without blocking forever on stdin. let kb_tx = tx.clone(); @@ -262,6 +316,28 @@ pub fn run_session(mut opts: TermOptions) -> Result<()> { let _ = out.flush(); } + // The opening moves (start hammering, maybe pulse reset, tell the operator + // to power-cycle) go out before the first read, for the reason in + // `super::autoboot`: reacting to the countdown is already too late. + if !pending_autoboot.is_empty() { + let actions = std::mem::take(&mut pending_autoboot); + let keep = apply_autoboot( + actions, + &mut port, + &mut out, + &hammer_on, + opts.interrupt.as_ref(), + opts.analyzer.as_ref(), + &opts.port_name, + use_color, + &mut dtr_state, + &mut rts_state, + ); + if !keep || interrupter.as_ref().is_some_and(|it| it.is_finished()) { + interrupter = None; + } + } + let exit_reason = loop { // A signal handler may have asked us to stop. Break out so the // teardown below runs: threads joined, raw mode restored, log @@ -293,6 +369,32 @@ pub fn run_session(mut opts: TermOptions) -> Result<()> { } } } + // A quiet line is what the interrupter is usually waiting for: the + // settle window before typing, and the deadline on a board that + // never stopped, both expire with no bytes arriving. + let actions = interrupter + .as_mut() + .map(|it| it.poll(Instant::now(), &[])) + .unwrap_or_default(); + { + if !actions.is_empty() { + let keep = apply_autoboot( + actions, + &mut port, + &mut out, + &hammer_on, + opts.interrupt.as_ref(), + opts.analyzer.as_ref(), + &opts.port_name, + use_color, + &mut dtr_state, + &mut rts_state, + ); + if !keep || interrupter.as_ref().is_some_and(|it| it.is_finished()) { + interrupter = None; + } + } + } continue; } let ev = ev_opt.unwrap(); @@ -333,6 +435,35 @@ pub fn run_session(mut opts: TermOptions) -> Result<()> { } } } + // Autoboot interrupter, fed the same bytes. After the analyzer + // so a finding about a line is on screen before a note about + // what we typed in response to it. + let actions = interrupter + .as_mut() + .map(|it| it.poll(Instant::now(), &bytes)) + .unwrap_or_default(); + { + if !actions.is_empty() { + let keep = apply_autoboot( + actions, + &mut port, + &mut out, + &hammer_on, + opts.interrupt.as_ref(), + opts.analyzer.as_ref(), + &opts.port_name, + use_color, + &mut dtr_state, + &mut rts_state, + ); + // The machine itself knows when it is done; the + // return value only reports an I/O failure the machine + // cannot see. + if !keep || interrupter.as_ref().is_some_and(|it| it.is_finished()) { + interrupter = None; + } + } + } } Ev::KeyPress(k) => { // Function-key macros take precedence over normal @@ -1323,3 +1454,140 @@ pub fn validate_port_hint(name: &str) -> Result<()> { } Ok(()) } + +/// Carry out what the interrupter decided. Returns false once the sequence is +/// over, so the caller stops polling it. +/// +/// Every side effect of the feature is here and nowhere else, which is what +/// lets the decisions be a pure state machine with unit tests instead of a +/// board on a bench. +#[allow(clippy::too_many_arguments)] +fn apply_autoboot( + actions: Vec, + port: &mut Box, + out: &mut W, + hammer_on: &AtomicBool, + cfg: Option<&super::autoboot::Config>, + analyzer: Option<&AnalyzeState>, + source: &str, + use_color: bool, + dtr_state: &mut bool, + rts_state: &mut bool, +) -> bool { + use super::autoboot::{Action, ResetLine}; + let mut keep = true; + for action in actions { + match action { + Action::StartHammer => hammer_on.store(true, Ordering::Relaxed), + Action::StopHammer => hammer_on.store(false, Ordering::Relaxed), + Action::PulseReset => { + let (line, hold) = match cfg { + Some(c) => (c.reset_line, c.reset_hold), + None => (ResetLine::None, Duration::from_millis(0)), + }; + // Blocking, on purpose: this happens once, at connect, before + // there is anything to interleave with, and a reset that + // overlaps the next action is not a reset. + let applied = match line { + ResetLine::Dtr => { + let r = port.write_data_terminal_ready(false); + thread::sleep(hold); + let _ = port.write_data_terminal_ready(true); + *dtr_state = true; + r + } + ResetLine::Rts => { + let r = port.write_request_to_send(false); + thread::sleep(hold); + let _ = port.write_request_to_send(true); + *rts_state = true; + r + } + ResetLine::None => Ok(()), + }; + if let Err(e) = applied { + let _ = autoboot_note( + out, + &format!( + "could not drive the reset line: {e}. Power-cycle the board by hand; \ + the hammer is already running." + ), + use_color, + ); + } + } + Action::Send(bytes) => { + // Written raw, bypassing the TX newline transform: these bytes + // are the tool's own, and the CR at the end of a command is + // meant literally rather than as something to rewrite. + if let Err(e) = port.write_all(&bytes).and_then(|()| port.flush()) { + let _ = autoboot_note( + out, + &format!("could not write to the port: {e}; giving up on the prompt"), + use_color, + ); + hammer_on.store(false, Ordering::Relaxed); + keep = false; + } + } + Action::Note(text) => { + let _ = autoboot_note(out, &text, use_color); + } + Action::GaveUp(reason) => { + let _ = autoboot_note(out, &reason, use_color); + keep = false; + } + Action::Done => { + keep = false; + // The verdict, from the session we just pulled. Rendered by the + // same function `bootintel verdict` uses, through a writer that + // turns its LFs into CRLFs, because raw mode has ONLCR off and + // a second copy of this renderer would be one more thing to + // drift. + let Some(analyzer) = analyzer else { + let _ = autoboot_note( + out, + "environment captured into the log; run `bootintel verdict` on it.", + use_color, + ); + continue; + }; + let (session, verdicts) = + bootintel_detectors::boot_chain::assess(analyzer.log_so_far()); + let _ = write!(out, "\r\n"); + let color = if use_color { + crate::output::ColorMode::On + } else { + crate::output::ColorMode::Off + }; + let mut crlf = crate::output::CrlfWriter::new(&mut *out); + let _ = + crate::cmd::verdict::write_text(&mut crlf, source, &session, &verdicts, color); + let _ = out.flush(); + } + } + } + keep +} + +/// One operator-facing line from the interrupter, distinguishable at a glance +/// from what the board said. A client-facing transcript has to show which +/// bytes were the tool's. +fn autoboot_note(out: &mut W, text: &str, use_color: bool) -> std::io::Result<()> { + let clean = crate::analyze::render::sanitize_for_term(text); + write!(out, "\r\n")?; + if use_color { + let _ = crossterm::queue!( + out, + crossterm::style::SetForegroundColor(crossterm::style::Color::Magenta), + crossterm::style::Print("[bootintel] ▸ "), + crossterm::style::ResetColor, + ); + } else { + write!(out, "[bootintel] \u{25b8} ")?; + } + // The note may be multi-line; raw mode needs every break to return the + // carriage. + write!(out, "{}\r\n", clean.replace('\n', "\r\n"))?; + out.flush() +} diff --git a/crates/cli/tests/autoboot_cli.rs b/crates/cli/tests/autoboot_cli.rs new file mode 100644 index 0000000..c78fb8b --- /dev/null +++ b/crates/cli/tests/autoboot_cli.rs @@ -0,0 +1,133 @@ +//! `analyze --interrupt-autoboot` argument handling, driven as a process. +//! +//! The state machine and the timing claim are unit-tested in +//! `src/term/autoboot.rs`, including against a simulated board whose autoboot +//! check runs exactly once. What only the binary can show is that the refusals +//! happen BEFORE a port is opened and a board is touched: a consultant finds +//! out that `--interrupt-key` needs `--interrupt-autoboot` while they are still +//! typing, not after the one window on a client's device has gone. + +use std::process::{Command, Output}; + +const BIN: &str = env!("CARGO_BIN_EXE_bootintel"); + +/// A port that does not exist. Every case here must fail on the arguments +/// before the port is ever opened, so no hardware is implied. +const PORT: &str = "/dev/ttyUSB-bootintel-does-not-exist"; + +fn run(args: &[&str]) -> Output { + Command::new(BIN) + .args(args) + .env("BOOTINTEL_NO_HISTORY", "1") + .env("NO_COLOR", "1") + .output() + .expect("running bootintel") +} + +fn stderr(out: &Output) -> String { + String::from_utf8_lossy(&out.stderr).into_owned() +} + +/// The safety rail. The key is sent repeatedly and its bytes accumulate in +/// U-Boot's line buffer, so a CR would execute whatever they spell on hardware +/// that is not ours. +#[test] +fn a_carriage_return_interrupt_key_is_refused_before_the_port_opens() { + for spec in ["0x0d", "0x0a"] { + let out = run(&[ + "analyze", + PORT, + "--interrupt-autoboot", + "--interrupt-key", + spec, + ]); + assert!(!out.status.success(), "{spec} was accepted"); + let err = stderr(&out); + assert!(err.contains("CR or LF"), "{spec}: {err}"); + // Proof it never got as far as the hardware. + assert!( + !err.contains("No such file") && !err.contains("no such"), + "{spec} reached the port before being refused: {err}" + ); + } +} + +/// Silently ignoring these would be worse than refusing them: someone who +/// passed `--interrupt-key esc` and got a plain terminal would reasonably +/// conclude the tool tried and the board refused. +#[test] +fn tuning_flags_without_the_feature_flag_are_refused() { + for args in [ + vec!["--interrupt-key", "esc"], + vec!["--interrupt-timeout", "10"], + vec!["--at-prompt", "printenv"], + vec!["--reset-line", "dtr"], + ] { + let mut full = vec!["analyze", PORT]; + full.extend(args.iter().copied()); + let out = run(&full); + assert!(!out.status.success(), "{args:?} was accepted"); + let err = stderr(&out); + assert!(err.contains("--interrupt-autoboot"), "{args:?}: {err}"); + } +} + +#[test] +fn nonsense_tuning_values_are_refused() { + let cases: &[(&[&str], &str)] = &[ + (&["--interrupt-interval", "0"], "at least 1ms"), + (&["--interrupt-timeout", "0"], "at least 1s"), + (&["--at-prompt", " "], "cannot be empty"), + (&["--interrupt-key", "0xZZ"], "hex"), + ]; + for (args, expected) in cases { + let mut full = vec!["analyze", PORT, "--interrupt-autoboot"]; + full.extend(args.iter().copied()); + let out = run(&full); + assert!(!out.status.success(), "{args:?} was accepted"); + assert!( + stderr(&out).contains(expected), + "{args:?}: {}", + stderr(&out) + ); + } +} + +/// The help text is where an operator learns the one thing they have to do +/// (power-cycle) and the one thing that can bite them (a key that ends a line). +#[test] +fn the_help_says_what_the_operator_has_to_do() { + let out = run(&["analyze", "--help"]); + let text = String::from_utf8_lossy(&out.stdout); + for expected in [ + "POWER-CYCLE", + "bootdelay=0", + "read-only", + "CR and LF are refused", + ] { + assert!(text.contains(expected), "missing {expected:?} from --help"); + } +} + +/// A valid invocation must get past argument handling and fail on the missing +/// port instead, which is what proves the refusals above are about the +/// arguments and not about the port. +#[test] +fn a_valid_invocation_reaches_the_port() { + let out = run(&[ + "analyze", + PORT, + "--interrupt-autoboot", + "--interrupt-key", + "esc", + "--at-prompt", + "printenv", + ]); + assert!(!out.status.success()); + let err = stderr(&out).to_lowercase(); + assert!( + err.contains("port") || err.contains("no such") || err.contains("not found"), + "expected a port error, got: {}", + stderr(&out) + ); +}