Compare commits
3 Commits
f373892870
...
6c284a94db
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6c284a94db | ||
|
|
8edd3badfc | ||
|
|
2cd3bba98d |
@@ -4,6 +4,14 @@
|
||||
> point-in-time review of the `unsafe`/FFI surface; line numbers and the
|
||||
> soundness arguments below are only valid against that tree. Re-run it after
|
||||
> any change to `usb_kbd.rs`, the SD/git FFI, or an `esp-idf-sys` bump.
|
||||
>
|
||||
> **Remediation (2026-07-10).** Findings #1 and #3 are addressed in follow-up
|
||||
> commits, and the decode logic is now the host-tested `keymap` crate (14 tests,
|
||||
> including an ASCII-invariant sweep over all 256 usage IDs and a never-panics
|
||||
> fuzz). The `usb_kbd.rs` line numbers below predate that refactor. The fixes are
|
||||
> verified by inspection plus the `keymap` tests, but still need an on-device
|
||||
> hot-plug run to confirm — #1/#3 live in FFI that can't run on the host.
|
||||
> Per-finding status is inline.
|
||||
|
||||
## Scope & method
|
||||
|
||||
@@ -39,6 +47,10 @@ operation.
|
||||
- Closing #1 so the in-flight invariant is explicit → 9. A 10 on FFI this heavy
|
||||
needs the structural guards in the "Regression testing" section.
|
||||
|
||||
**Update (2026-07-10):** #1 and #3 are now addressed in code and the decode path
|
||||
is host-tested (see per-finding status). Once the hot-plug bench run confirms #1
|
||||
on hardware this becomes a 9; #2 and #4 are the remaining gap to 10.
|
||||
|
||||
This is a *memory-safety* score. Robustness (leaks on hot-plug) and correctness
|
||||
would score separately and slightly lower.
|
||||
|
||||
@@ -84,6 +96,13 @@ before freeing — or track an in-flight flag set on submit and cleared in
|
||||
minimum, check the return value of `usb_host_transfer_free` and don't null the
|
||||
pointer / proceed to `device_close` while it reports the transfer busy.
|
||||
|
||||
**Status: fixed in code.** A `REPORT_INFLIGHT` flag is set on submit and cleared
|
||||
first thing in every `report_cb`. The teardown moved into a `close_device`
|
||||
helper that pumps client events (bounded) until the transfer quiesces before
|
||||
freeing it — and *leaks it rather than frees* if it never does, since a leak is
|
||||
recoverable and a UAF is not. `usb_host_transfer_free`'s return value is now
|
||||
checked. Needs the hot-plug bench run below to confirm on hardware.
|
||||
|
||||
### 2. `mem::zeroed()` / `MaybeUninit::zeroed().assume_init()` on bindgen structs is a latent footgun — `usb_kbd.rs:110,143`, `sd_fat.rs:138,173,192`
|
||||
|
||||
**Sound today**: every field of the zeroed descriptors is valid at all-zero (C
|
||||
@@ -101,6 +120,12 @@ Prefer keeping them as `MaybeUninit` and writing fields via `addr_of_mut!`, or
|
||||
at least add a static/compile-time assertion (or a test) that pins the
|
||||
zero-is-valid assumption so a dependency bump fails loudly.
|
||||
|
||||
**Status: left as-is deliberately** (sound today, low severity). There is no
|
||||
clean *stable* compile-time "all-zero is a valid bit pattern" assertion, and a
|
||||
runtime canary can't fire before the UB it would guard (`assume_init` is the UB
|
||||
site). The `addr_of_mut!` rewrite is the real fix but churns five call sites for
|
||||
a latent-only risk; deferred until an `esp-idf-sys` bump makes it worth it.
|
||||
|
||||
### 3. Resource leaks on re-attach and on submit error — not UB — `usb_kbd.rs:163-168, 417/436, 449`
|
||||
|
||||
- A second keyboard attaching while one is open makes `setup_keyboard` overwrite
|
||||
@@ -116,6 +141,10 @@ which matters for an always-powered appliance. Guard the re-attach case
|
||||
(`if !open_dev.is_null()` → tear down first) and free-on-error in
|
||||
`control_request`.
|
||||
|
||||
**Status: fixed in code.** A new attach while a device is still open now runs
|
||||
`close_device` on the old one first. `control_request` and `start_report_polling`
|
||||
free the transfer they allocated on a submit error before returning.
|
||||
|
||||
### 4. USB thread stack sizing is unverified — `usb_kbd.rs:121,132`
|
||||
|
||||
Daemon thread = 4096 B, client thread = 8192 B. The client thread runs
|
||||
@@ -175,13 +204,18 @@ split by what's reachable where, ranked by leverage.
|
||||
`handle_report`'s decode, the editor text ops, `changed_rows` /
|
||||
`only_adds_ink` in `main.rs`, the `epd` row math. Pull them into a
|
||||
no-esp-deps module/crate (workspace member or `#[cfg]`-gated) so `cargo test`
|
||||
runs on host. Then:
|
||||
- **Fuzz `handle_report` on host under Miri or ASAN** — the single most
|
||||
valuable test. It's the exact path where a broken/malicious keyboard's
|
||||
bytes meet `from_raw_parts` + slicing; feed arbitrary `&[u8]` and Miri
|
||||
catches any OOB the clamp fails to prevent. Guards finding #5.
|
||||
- Unit-test that `translate` never emits a non-ASCII `char`, pinning the
|
||||
invariant `editor.rs` byte-indexing depends on.
|
||||
runs on host.
|
||||
- **Done for the keyboard decode.** `translate` + the report edge-detection
|
||||
are now the `../keymap` crate (`#![no_std]`, `#![forbid(unsafe_code)]`, zero
|
||||
deps), with 14 host tests including a `translate` **ASCII-invariant sweep**
|
||||
over all 256 usage IDs × modifiers and a **never-panics fuzz** feeding
|
||||
arbitrary-length/content byte streams. The slice itself is bounds-clamped in
|
||||
the FFI layer (finding #5), and once it's a safe `&[u8]` the decode is
|
||||
`forbid(unsafe_code)` — so Miri adds nothing over the panic-freedom the fuzz
|
||||
test already proves; the plain `cargo test` run is the guard.
|
||||
- **Still TODO:** the editor text ops and `changed_rows`/`only_adds_ink` are
|
||||
also FFI-free and worth the same treatment, but stay coupled to
|
||||
`embedded-graphics` / `epd` constants for now.
|
||||
2. **Compile-time guards for the `zeroed()` assumption (#2).** Static assertions
|
||||
(or a test constructing the struct and checking a sentinel field is
|
||||
`None`/`0`) so an `esp-idf-sys` bump fails loudly instead of going silently
|
||||
@@ -189,7 +223,9 @@ split by what's reachable where, ranked by leverage.
|
||||
3. **`clippy` as a ratchet.** `#![warn(clippy::undocumented_unsafe_blocks)]` +
|
||||
`clippy::multiple_unsafe_ops_per_block`, deny-warnings in CI. Forces every
|
||||
new `unsafe` to carry a SAFETY comment — keeps the existing discipline from
|
||||
eroding.
|
||||
eroding. **Deferred:** turning it on crate-wide today floods warnings on the
|
||||
many existing bare-FFI `unsafe` one-liners, which trains people to ignore it.
|
||||
Worth doing *after* a pass that adds `// SAFETY:` to the existing blocks.
|
||||
4. **On-device tests for what only exists on device (#1, #3, #4).**
|
||||
- **Hot-plug stress loop**: attach/detach ~100× on a bench script, log
|
||||
`esp_get_free_heap_size` each cycle. A downward trend proves the leaks
|
||||
|
||||
@@ -144,8 +144,9 @@ package.json pnpm + oxfmt — formatting toolchain for docs/JSON
|
||||
≤32 GB card ([postmortem](docs/postmortems/2026-07-05-spike3-sd-cmd59.md)).
|
||||
- [ ] Heap fragmentation over a long writing session with the PSRAM allocator.
|
||||
- [ ] Real-world e-ink ghosting with the current partial-refresh cadence.
|
||||
- [ ] Possible use-after-free freeing the in-flight USB transfer on keyboard
|
||||
unplug ([`MEMORY_AUDIT.md`](MEMORY_AUDIT.md) finding #1).
|
||||
- [~] Use-after-free freeing the in-flight USB transfer on keyboard unplug —
|
||||
fixed in code, pending an on-device hot-plug run to confirm
|
||||
([`MEMORY_AUDIT.md`](MEMORY_AUDIT.md) finding #1).
|
||||
|
||||
Retired risks ([gix push](docs/postmortems/2026-07-05-spike7-gix-https-push.md),
|
||||
TinyUSB HID stability, TLS heap, libgit2-on-xtensa) and how they died:
|
||||
|
||||
@@ -78,6 +78,9 @@ git = ["dep:git2"]
|
||||
[dependencies]
|
||||
anyhow = "1"
|
||||
log = "0.4"
|
||||
# Pure HID decode (Key type + edge-detecting Decoder), split out so it is
|
||||
# host-testable off the xtensa target. See ../keymap and MEMORY_AUDIT.md.
|
||||
keymap = { path = "../keymap" }
|
||||
git2 = { version = "0.20", default-features = false, optional = true }
|
||||
esp-idf-svc = { version = "0.52.1", features = ["critical-section", "embassy-time-driver", "embassy-sync"] }
|
||||
# Remove `generic-queue-8` if you plan to use `embassy-time` WITH `embassy-executor`
|
||||
|
||||
@@ -38,22 +38,11 @@ use esp_idf_svc::sys::{
|
||||
usb_transfer_t, EspError, ESP_INTR_FLAG_LEVEL1,
|
||||
};
|
||||
|
||||
/// A decoded key-down event. Beyond plain characters, the decoder recognises a
|
||||
/// few editing combos (resolved here so the main loop only sees intents) and a
|
||||
/// dual-role Caps Lock: held it acts as Ctrl, tapped it emits `Escape`.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub enum Key {
|
||||
Char(char),
|
||||
Enter,
|
||||
Backspace,
|
||||
/// Ctrl+Backspace or Ctrl+W — delete the word before the caret.
|
||||
DeleteWord,
|
||||
/// Cmd/GUI+Backspace — delete back to the start of the current line.
|
||||
DeleteLine,
|
||||
/// Caps Lock tapped on its own. A no-op for now; groundwork for a future
|
||||
/// vim-style normal mode.
|
||||
Escape,
|
||||
}
|
||||
/// A decoded key-down event, re-exported from the `keymap` crate. The decode
|
||||
/// logic (edge detection + US-QWERTY translation) lives there — pure, with no
|
||||
/// esp/std deps — so it is host-testable and fuzzable off the xtensa target.
|
||||
/// This module only bridges it to the USB transport. See MEMORY_AUDIT.md.
|
||||
pub use keymap::Key;
|
||||
|
||||
/// Boot-keyboard parameters, confirmed by Spike 4's enumeration.
|
||||
const KBD_INTERFACE: u8 = 0;
|
||||
@@ -83,13 +72,18 @@ static CTRL_STATUS: AtomicU32 = AtomicU32::new(0);
|
||||
/// mutex-guarded queue rather than a channel because `mpsc::Sender` is not
|
||||
/// `Sync` and so can't live in a `static`.
|
||||
static KEY_QUEUE: OnceLock<Mutex<VecDeque<Key>>> = OnceLock::new();
|
||||
/// Keycodes held in the previous report, for key-down edge detection. Only
|
||||
/// ever touched from the single client thread's `report_cb`.
|
||||
static PREV_KEYS: Mutex<[u8; 6]> = Mutex::new([0; 6]);
|
||||
/// Caps Lock dual-role tracking: set while Caps is held once any other key is
|
||||
/// pressed, so releasing Caps only emits `Escape` on a clean tap. Only touched
|
||||
/// from the client thread's `report_cb`.
|
||||
static CAPS_USED: AtomicBool = AtomicBool::new(false);
|
||||
/// Edge-detecting decode state (previous report + Caps dual-role), owned here
|
||||
/// as one `keymap::Decoder` rather than the pair of loose statics it used to
|
||||
/// be. Only ever touched from the single client thread's `report_cb`; the mutex
|
||||
/// is for the `static`, not contention. The decode logic itself is the
|
||||
/// host-tested `keymap` crate.
|
||||
static DECODER: Mutex<keymap::Decoder> = Mutex::new(keymap::Decoder::new());
|
||||
/// Whether the interrupt-IN report transfer is currently in-flight (submitted
|
||||
/// and awaiting completion). Set on submit, cleared the moment `report_cb`
|
||||
/// fires. Read on unplug to quiesce the transfer before freeing it — freeing an
|
||||
/// in-flight transfer races the library's pending completion into a
|
||||
/// use-after-free (MEMORY_AUDIT.md finding #1).
|
||||
static REPORT_INFLIGHT: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
/// Pop the next decoded key-down event, if any.
|
||||
pub fn next_key() -> Option<Key> {
|
||||
@@ -160,6 +154,13 @@ fn client_loop() {
|
||||
|
||||
let addr = NEW_DEV_ADDR.swap(0, Ordering::SeqCst);
|
||||
if addr != 0 {
|
||||
// A new attach while a device is still open means we missed the
|
||||
// detach event; tear the old one down first so its transfer and
|
||||
// handle aren't leaked and overwritten (MEMORY_AUDIT.md finding #3).
|
||||
if !open_dev.is_null() {
|
||||
log::warn!("new device while one is still open; closing the previous keyboard");
|
||||
close_device(client, &mut open_dev, &mut report_xfer);
|
||||
}
|
||||
match setup_keyboard(client, addr) {
|
||||
Ok((dev, xfer)) => {
|
||||
open_dev = dev;
|
||||
@@ -171,21 +172,54 @@ fn client_loop() {
|
||||
}
|
||||
if DEV_GONE.swap(false, Ordering::SeqCst) && !open_dev.is_null() {
|
||||
log::info!("keyboard unplugged; releasing interface and closing");
|
||||
// Order per the USB Host Library: free transfers, release
|
||||
// interfaces, then close the device.
|
||||
if !report_xfer.is_null() {
|
||||
unsafe { usb_host_transfer_free(report_xfer) };
|
||||
report_xfer = ptr::null_mut();
|
||||
}
|
||||
unsafe { usb_host_interface_release(client, open_dev, KBD_INTERFACE) };
|
||||
unsafe { usb_host_device_close(client, open_dev) };
|
||||
open_dev = ptr::null_mut();
|
||||
*PREV_KEYS.lock().unwrap() = [0; 6];
|
||||
KBD_PRESENT.store(false, Ordering::SeqCst);
|
||||
close_device(client, &mut open_dev, &mut report_xfer);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Tear down the open keyboard: quiesce + free the report transfer, release the
|
||||
/// interface, close the device, then reset the decode + presence state. Order
|
||||
/// per the USB Host Library: free transfers, release interfaces, then close.
|
||||
///
|
||||
/// The report transfer is freed only once it is no longer in-flight. On unplug
|
||||
/// the library completes the pending interrupt transfer with a canceled status
|
||||
/// and fires `report_cb` (which clears `REPORT_INFLIGHT`); we pump client events
|
||||
/// until that happens so we never hand `usb_host_transfer_free` a transfer the
|
||||
/// lower layer still owns — doing so would race the pending completion into a
|
||||
/// use-after-free (MEMORY_AUDIT.md finding #1). Bounded so a wedged transfer
|
||||
/// can't spin the client loop forever; if it never quiesces we leak it rather
|
||||
/// than risk the free.
|
||||
fn close_device(
|
||||
client: usb_host_client_handle_t,
|
||||
open_dev: &mut usb_device_handle_t,
|
||||
report_xfer: &mut *mut usb_transfer_t,
|
||||
) {
|
||||
if !(*report_xfer).is_null() {
|
||||
let mut spins = 0;
|
||||
while REPORT_INFLIGHT.load(Ordering::SeqCst) && spins < 100 {
|
||||
unsafe { usb_host_client_handle_events(client, 10) };
|
||||
spins += 1;
|
||||
}
|
||||
if REPORT_INFLIGHT.load(Ordering::SeqCst) {
|
||||
log::error!(
|
||||
"report transfer still in-flight after drain; leaking it rather than \
|
||||
freeing (a free here would be a use-after-free)"
|
||||
);
|
||||
} else {
|
||||
let err = unsafe { usb_host_transfer_free(*report_xfer) };
|
||||
if err != 0 {
|
||||
log::warn!("usb_host_transfer_free(report) returned {err}");
|
||||
}
|
||||
}
|
||||
*report_xfer = ptr::null_mut();
|
||||
}
|
||||
unsafe { usb_host_interface_release(client, *open_dev, KBD_INTERFACE) };
|
||||
unsafe { usb_host_device_close(client, *open_dev) };
|
||||
*open_dev = ptr::null_mut();
|
||||
DECODER.lock().unwrap().reset();
|
||||
KBD_PRESENT.store(false, Ordering::SeqCst);
|
||||
}
|
||||
|
||||
/// Client event callback — runs inside `usb_host_client_handle_events`. Keep
|
||||
/// it minimal: stash what happened and let the client loop do the FFI work.
|
||||
unsafe extern "C" fn client_event_cb(msg: *const usb_host_client_event_msg_t, _arg: *mut c_void) {
|
||||
@@ -216,14 +250,24 @@ unsafe extern "C" fn ctrl_cb(transfer: *mut usb_transfer_t) {
|
||||
/// `usb_host_client_handle_events`. On any non-completed status (e.g. the
|
||||
/// device was unplugged and the transfer canceled) it stops resubmitting.
|
||||
unsafe extern "C" fn report_cb(transfer: *mut usb_transfer_t) {
|
||||
// A completion fired, so the transfer is no longer in-flight. Clear the flag
|
||||
// first — the non-completed (canceled-on-unplug) path below returns without
|
||||
// resubmitting, and leaving it false is what lets close_device free the
|
||||
// transfer safely (MEMORY_AUDIT.md finding #1).
|
||||
REPORT_INFLIGHT.store(false, Ordering::SeqCst);
|
||||
let t = unsafe { &mut *transfer };
|
||||
if t.status == usb_transfer_status_t_USB_TRANSFER_STATUS_COMPLETED {
|
||||
let n = (t.actual_num_bytes as usize).min(BOOT_REPORT_LEN);
|
||||
// SAFETY: data_buffer was allocated with BOOT_REPORT_LEN bytes and `n`
|
||||
// is clamped to that, so the slice stays within the allocation even if
|
||||
// the device reports a bogus actual_num_bytes.
|
||||
let report = unsafe { core::slice::from_raw_parts(t.data_buffer, n) };
|
||||
handle_report(report);
|
||||
DECODER.lock().unwrap().feed(report, enqueue);
|
||||
let err = unsafe { usb_host_transfer_submit(transfer) };
|
||||
if err != 0 {
|
||||
log::error!("interrupt resubmit failed: {err}");
|
||||
} else {
|
||||
REPORT_INFLIGHT.store(true, Ordering::SeqCst);
|
||||
}
|
||||
} else {
|
||||
log::info!("interrupt transfer stopped, status {}", t.status as u32);
|
||||
@@ -238,116 +282,6 @@ fn enqueue(key: Key) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Caps Lock usage ID — repurposed as a dual-role Ctrl/Escape key.
|
||||
const CAPS: u8 = 0x39;
|
||||
|
||||
/// Edge-detect key-downs in an 8-byte boot report and enqueue translated keys.
|
||||
/// Layout: [modifiers, reserved, key1..key6]; 0 means "no key".
|
||||
fn handle_report(report: &[u8]) {
|
||||
if report.len() < 3 {
|
||||
return;
|
||||
}
|
||||
let mods = report[0];
|
||||
let shift = mods & 0x22 != 0; // LShift 0x02 | RShift 0x20
|
||||
let cmd = mods & 0x88 != 0; // LGUI 0x08 | RGUI 0x80
|
||||
let current = &report[2..];
|
||||
|
||||
let mut prev = PREV_KEYS.lock().unwrap();
|
||||
|
||||
// Caps Lock is a normal key in the boot report (not a modifier bit), so we
|
||||
// track its down/up edges here. Held, it acts as Ctrl; tapped alone, it
|
||||
// emits Escape.
|
||||
let caps_now = current.contains(&CAPS);
|
||||
let caps_before = prev.contains(&CAPS);
|
||||
let ctrl = mods & 0x11 != 0 || caps_now; // LCtrl 0x01 | RCtrl 0x10, or Caps
|
||||
// Any other key down while Caps is held means it was used as Ctrl — so its
|
||||
// release must not fire Escape.
|
||||
if caps_now && current.iter().any(|&k| k != 0 && k != CAPS) {
|
||||
CAPS_USED.store(true, Ordering::SeqCst);
|
||||
}
|
||||
|
||||
for &k in current {
|
||||
if k == 0 || k == CAPS || prev.contains(&k) {
|
||||
continue; // empty slot, the Caps key itself, or already held
|
||||
}
|
||||
if let Some(key) = translate(k, shift, ctrl, cmd) {
|
||||
enqueue(key);
|
||||
}
|
||||
}
|
||||
|
||||
// Caps released as a clean tap (nothing else pressed while it was down) →
|
||||
// Escape. Reset the used-flag on both the press and release edges.
|
||||
if caps_before && !caps_now {
|
||||
if !CAPS_USED.swap(false, Ordering::SeqCst) {
|
||||
enqueue(Key::Escape);
|
||||
}
|
||||
} else if caps_now && !caps_before {
|
||||
CAPS_USED.store(false, Ordering::SeqCst);
|
||||
}
|
||||
|
||||
let mut next = [0u8; 6];
|
||||
for (slot, &k) in next.iter_mut().zip(current.iter()) {
|
||||
*slot = k;
|
||||
}
|
||||
*prev = next;
|
||||
}
|
||||
|
||||
/// Translate a HID keyboard usage ID to a key event using a US QWERTY layout.
|
||||
/// Editing combos (Ctrl/Cmd chords) resolve to intents here and take priority
|
||||
/// over character insertion; other keys with Ctrl or Cmd held are swallowed.
|
||||
fn translate(usage: u8, shift: bool, ctrl: bool, cmd: bool) -> Option<Key> {
|
||||
match usage {
|
||||
0x2a => {
|
||||
// Backspace: Cmd = delete line, Ctrl = delete word, else one char.
|
||||
return Some(if cmd {
|
||||
Key::DeleteLine
|
||||
} else if ctrl {
|
||||
Key::DeleteWord
|
||||
} else {
|
||||
Key::Backspace
|
||||
});
|
||||
}
|
||||
0x1a if ctrl => return Some(Key::DeleteWord), // Ctrl+W, readline-style
|
||||
_ => {}
|
||||
}
|
||||
|
||||
// With Ctrl or Cmd held and no combo matched above, insert nothing — so
|
||||
// Caps+J or Cmd+S don't type a stray character.
|
||||
if ctrl || cmd {
|
||||
return None;
|
||||
}
|
||||
|
||||
let key = match usage {
|
||||
0x04..=0x1d => {
|
||||
let base = b'a' + (usage - 0x04);
|
||||
Key::Char(if shift { base.to_ascii_uppercase() } else { base } as char)
|
||||
}
|
||||
0x1e..=0x27 => {
|
||||
const UNSHIFTED: [char; 10] = ['1', '2', '3', '4', '5', '6', '7', '8', '9', '0'];
|
||||
const SHIFTED: [char; 10] = ['!', '@', '#', '$', '%', '^', '&', '*', '(', ')'];
|
||||
let i = (usage - 0x1e) as usize;
|
||||
Key::Char(if shift { SHIFTED[i] } else { UNSHIFTED[i] })
|
||||
}
|
||||
0x28 => Key::Enter,
|
||||
0x2a => Key::Backspace,
|
||||
0x2b => Key::Char('\t'),
|
||||
0x2c => Key::Char(' '),
|
||||
0x2d => Key::Char(if shift { '_' } else { '-' }),
|
||||
0x2e => Key::Char(if shift { '+' } else { '=' }),
|
||||
0x2f => Key::Char(if shift { '{' } else { '[' }),
|
||||
0x30 => Key::Char(if shift { '}' } else { ']' }),
|
||||
0x31 => Key::Char(if shift { '|' } else { '\\' }),
|
||||
0x33 => Key::Char(if shift { ':' } else { ';' }),
|
||||
0x34 => Key::Char(if shift { '"' } else { '\'' }),
|
||||
0x35 => Key::Char(if shift { '~' } else { '`' }),
|
||||
0x36 => Key::Char(if shift { '<' } else { ',' }),
|
||||
0x37 => Key::Char(if shift { '>' } else { '.' }),
|
||||
0x38 => Key::Char(if shift { '?' } else { '/' }),
|
||||
_ => return None,
|
||||
};
|
||||
Some(key)
|
||||
}
|
||||
|
||||
/// Open a newly-attached device, dump its descriptors, claim the keyboard
|
||||
/// interface, put it in boot protocol, and start polling for reports.
|
||||
fn setup_keyboard(
|
||||
@@ -427,7 +361,12 @@ fn control_request(
|
||||
}
|
||||
|
||||
CTRL_DONE.store(false, Ordering::SeqCst);
|
||||
esp!(unsafe { usb_host_transfer_submit_control(client, xfer) })?;
|
||||
if let Err(e) = esp!(unsafe { usb_host_transfer_submit_control(client, xfer) }) {
|
||||
// Free the transfer we allocated before bailing, or it leaks
|
||||
// (MEMORY_AUDIT.md finding #3).
|
||||
unsafe { usb_host_transfer_free(xfer) };
|
||||
return Err(e);
|
||||
}
|
||||
while !CTRL_DONE.load(Ordering::SeqCst) {
|
||||
unsafe { usb_host_client_handle_events(client, u32::MAX) };
|
||||
}
|
||||
@@ -455,6 +394,12 @@ fn start_report_polling(dev: usb_device_handle_t) -> Result<*mut usb_transfer_t,
|
||||
t.callback = Some(report_cb);
|
||||
t.context = ptr::null_mut();
|
||||
}
|
||||
esp!(unsafe { usb_host_transfer_submit(xfer) })?;
|
||||
if let Err(e) = esp!(unsafe { usb_host_transfer_submit(xfer) }) {
|
||||
// Free the transfer we allocated before bailing, or it leaks
|
||||
// (MEMORY_AUDIT.md finding #3).
|
||||
unsafe { usb_host_transfer_free(xfer) };
|
||||
return Err(e);
|
||||
}
|
||||
REPORT_INFLIGHT.store(true, Ordering::SeqCst);
|
||||
Ok(xfer)
|
||||
}
|
||||
|
||||
2
keymap/.gitignore
vendored
Normal file
2
keymap/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
/target
|
||||
/Cargo.lock
|
||||
10
keymap/Cargo.toml
Normal file
10
keymap/Cargo.toml
Normal file
@@ -0,0 +1,10 @@
|
||||
[package]
|
||||
name = "keymap"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
description = "Pure HID boot-keyboard decode: the Key event type, a US-QWERTY translate, and an edge-detecting Decoder. No esp/std deps, so it is host-testable and fuzzable off-device (see MEMORY_AUDIT.md)."
|
||||
|
||||
# Deliberately dependency-free and #![no_std] (except under test) so the decode
|
||||
# path — the one place untrusted device bytes are parsed — can be exercised on
|
||||
# the host without the ESP toolchain. The firmware crate depends on this by path.
|
||||
[dependencies]
|
||||
355
keymap/src/lib.rs
Normal file
355
keymap/src/lib.rs
Normal file
@@ -0,0 +1,355 @@
|
||||
//! Pure HID boot-keyboard decode — the logic half of `firmware/src/usb_kbd.rs`,
|
||||
//! extracted so it can be built and tested on the host (the firmware crate is
|
||||
//! pinned to the xtensa target and can't run `cargo test`).
|
||||
//!
|
||||
//! It owns nothing hardware-shaped: no USB transfers, no logging, no globals.
|
||||
//! You feed it raw 8-byte boot reports and it emits decoded [`Key`] events via
|
||||
//! a callback. `firmware` wires the USB interrupt endpoint to [`Decoder::feed`];
|
||||
//! tests here drive it directly.
|
||||
//!
|
||||
//! Why this is the module worth testing: [`Decoder::feed`] is the one place
|
||||
//! device-controlled bytes are parsed, and [`translate`] is the sole source of
|
||||
//! `Key::Char`, whose ASCII-only guarantee the editor's byte==char indexing
|
||||
//! relies on. Both invariants are pinned by the tests below. See MEMORY_AUDIT.md.
|
||||
|
||||
#![cfg_attr(not(test), no_std)]
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
/// A decoded key-down event. Beyond plain characters, the decoder recognises a
|
||||
/// few editing combos (resolved here so the main loop only sees intents) and a
|
||||
/// dual-role Caps Lock: held it acts as Ctrl, tapped it emits `Escape`.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Key {
|
||||
Char(char),
|
||||
Enter,
|
||||
Backspace,
|
||||
/// Ctrl+Backspace or Ctrl+W — delete the word before the caret.
|
||||
DeleteWord,
|
||||
/// Cmd/GUI+Backspace — delete back to the start of the current line.
|
||||
DeleteLine,
|
||||
/// Caps Lock tapped on its own. A no-op for now; groundwork for a future
|
||||
/// vim-style normal mode.
|
||||
Escape,
|
||||
}
|
||||
|
||||
/// Caps Lock usage ID — repurposed as a dual-role Ctrl/Escape key.
|
||||
const CAPS: u8 = 0x39;
|
||||
|
||||
/// Edge-detecting boot-report decoder. Holds the previous report's key slots
|
||||
/// (for key-down edge detection) and the Caps dual-role state. Construct once
|
||||
/// per attached keyboard; call [`reset`](Decoder::reset) on detach.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Decoder {
|
||||
/// Keycodes held in the previous report.
|
||||
prev: [u8; 6],
|
||||
/// Set while Caps is held once any other key is pressed, so releasing Caps
|
||||
/// only emits `Escape` on a clean tap.
|
||||
caps_used: bool,
|
||||
}
|
||||
|
||||
impl Default for Decoder {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl Decoder {
|
||||
pub const fn new() -> Self {
|
||||
Self { prev: [0; 6], caps_used: false }
|
||||
}
|
||||
|
||||
/// Clear all state (call when the keyboard is unplugged so a stale "held"
|
||||
/// slot from the old device can't suppress the first key of the next one).
|
||||
pub fn reset(&mut self) {
|
||||
*self = Self::new();
|
||||
}
|
||||
|
||||
/// Edge-detect key-downs in an 8-byte boot report and emit translated keys.
|
||||
/// Layout: `[modifiers, reserved, key1..key6]`; `0` means "no key". Robust
|
||||
/// to any slice length — a short report (< 3 bytes) is ignored, and extra
|
||||
/// bytes past the six key slots are simply processed too, never indexed
|
||||
/// out of range.
|
||||
pub fn feed(&mut self, report: &[u8], mut emit: impl FnMut(Key)) {
|
||||
if report.len() < 3 {
|
||||
return;
|
||||
}
|
||||
let mods = report[0];
|
||||
let shift = mods & 0x22 != 0; // LShift 0x02 | RShift 0x20
|
||||
let cmd = mods & 0x88 != 0; // LGUI 0x08 | RGUI 0x80
|
||||
let current = &report[2..];
|
||||
|
||||
// Caps Lock is a normal key in the boot report (not a modifier bit), so
|
||||
// we track its down/up edges here. Held, it acts as Ctrl; tapped alone,
|
||||
// it emits Escape.
|
||||
let caps_now = current.contains(&CAPS);
|
||||
let caps_before = self.prev.contains(&CAPS);
|
||||
let ctrl = mods & 0x11 != 0 || caps_now; // LCtrl 0x01 | RCtrl 0x10, or Caps
|
||||
// Any other key down while Caps is held means it was used as Ctrl — so
|
||||
// its release must not fire Escape.
|
||||
if caps_now && current.iter().any(|&k| k != 0 && k != CAPS) {
|
||||
self.caps_used = true;
|
||||
}
|
||||
|
||||
for &k in current {
|
||||
if k == 0 || k == CAPS || self.prev.contains(&k) {
|
||||
continue; // empty slot, the Caps key itself, or already held
|
||||
}
|
||||
if let Some(key) = translate(k, shift, ctrl, cmd) {
|
||||
emit(key);
|
||||
}
|
||||
}
|
||||
|
||||
// Caps released as a clean tap (nothing else pressed while it was down)
|
||||
// → Escape. Reset the used-flag on both the press and release edges.
|
||||
if caps_before && !caps_now {
|
||||
if !core::mem::replace(&mut self.caps_used, false) {
|
||||
emit(Key::Escape);
|
||||
}
|
||||
} else if caps_now && !caps_before {
|
||||
self.caps_used = false;
|
||||
}
|
||||
|
||||
let mut next = [0u8; 6];
|
||||
for (slot, &k) in next.iter_mut().zip(current.iter()) {
|
||||
*slot = k;
|
||||
}
|
||||
self.prev = next;
|
||||
}
|
||||
}
|
||||
|
||||
/// Translate a HID keyboard usage ID to a key event using a US QWERTY layout.
|
||||
/// Editing combos (Ctrl/Cmd chords) resolve to intents here and take priority
|
||||
/// over character insertion; other keys with Ctrl or Cmd held are swallowed.
|
||||
///
|
||||
/// Every `Key::Char` this returns is ASCII — the editor depends on it (a byte
|
||||
/// offset into its buffer is also a char index). The `translate_only_emits_ascii`
|
||||
/// test pins this for all 256 usage IDs × modifier combinations.
|
||||
fn translate(usage: u8, shift: bool, ctrl: bool, cmd: bool) -> Option<Key> {
|
||||
match usage {
|
||||
0x2a => {
|
||||
// Backspace: Cmd = delete line, Ctrl = delete word, else one char.
|
||||
return Some(if cmd {
|
||||
Key::DeleteLine
|
||||
} else if ctrl {
|
||||
Key::DeleteWord
|
||||
} else {
|
||||
Key::Backspace
|
||||
});
|
||||
}
|
||||
0x1a if ctrl => return Some(Key::DeleteWord), // Ctrl+W, readline-style
|
||||
_ => {}
|
||||
}
|
||||
|
||||
// With Ctrl or Cmd held and no combo matched above, insert nothing — so
|
||||
// Caps+J or Cmd+S don't type a stray character.
|
||||
if ctrl || cmd {
|
||||
return None;
|
||||
}
|
||||
|
||||
let key = match usage {
|
||||
0x04..=0x1d => {
|
||||
let base = b'a' + (usage - 0x04);
|
||||
Key::Char(if shift { base.to_ascii_uppercase() } else { base } as char)
|
||||
}
|
||||
0x1e..=0x27 => {
|
||||
const UNSHIFTED: [char; 10] = ['1', '2', '3', '4', '5', '6', '7', '8', '9', '0'];
|
||||
const SHIFTED: [char; 10] = ['!', '@', '#', '$', '%', '^', '&', '*', '(', ')'];
|
||||
let i = (usage - 0x1e) as usize;
|
||||
Key::Char(if shift { SHIFTED[i] } else { UNSHIFTED[i] })
|
||||
}
|
||||
0x28 => Key::Enter,
|
||||
0x2a => Key::Backspace,
|
||||
0x2b => Key::Char('\t'),
|
||||
0x2c => Key::Char(' '),
|
||||
0x2d => Key::Char(if shift { '_' } else { '-' }),
|
||||
0x2e => Key::Char(if shift { '+' } else { '=' }),
|
||||
0x2f => Key::Char(if shift { '{' } else { '[' }),
|
||||
0x30 => Key::Char(if shift { '}' } else { ']' }),
|
||||
0x31 => Key::Char(if shift { '|' } else { '\\' }),
|
||||
0x33 => Key::Char(if shift { ':' } else { ';' }),
|
||||
0x34 => Key::Char(if shift { '"' } else { '\'' }),
|
||||
0x35 => Key::Char(if shift { '~' } else { '`' }),
|
||||
0x36 => Key::Char(if shift { '<' } else { ',' }),
|
||||
0x37 => Key::Char(if shift { '>' } else { '.' }),
|
||||
0x38 => Key::Char(if shift { '?' } else { '/' }),
|
||||
_ => return None,
|
||||
};
|
||||
Some(key)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Build an 8-byte boot report: modifier byte, reserved 0, then up to six
|
||||
/// key slots (zero-padded).
|
||||
fn report(mods: u8, keys: &[u8]) -> Vec<u8> {
|
||||
let mut r = vec![mods, 0];
|
||||
r.extend_from_slice(keys);
|
||||
r.resize(8, 0);
|
||||
r
|
||||
}
|
||||
|
||||
fn feed(dec: &mut Decoder, report: &[u8]) -> Vec<Key> {
|
||||
let mut out = Vec::new();
|
||||
dec.feed(report, |k| out.push(k));
|
||||
out
|
||||
}
|
||||
|
||||
// ---- translate: the ASCII invariant the editor relies on ----
|
||||
|
||||
#[test]
|
||||
fn translate_only_emits_ascii() {
|
||||
for usage in 0u8..=255 {
|
||||
for &shift in &[false, true] {
|
||||
for &ctrl in &[false, true] {
|
||||
for &cmd in &[false, true] {
|
||||
if let Some(Key::Char(c)) = translate(usage, shift, ctrl, cmd) {
|
||||
assert!(
|
||||
c.is_ascii(),
|
||||
"usage {usage:#04x} (shift={shift} ctrl={ctrl} cmd={cmd}) \
|
||||
produced non-ASCII {c:?} — breaks editor byte==char indexing"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn translate_letters_and_shift() {
|
||||
assert_eq!(translate(0x04, false, false, false), Some(Key::Char('a')));
|
||||
assert_eq!(translate(0x04, true, false, false), Some(Key::Char('A')));
|
||||
assert_eq!(translate(0x1d, false, false, false), Some(Key::Char('z')));
|
||||
assert_eq!(translate(0x1d, true, false, false), Some(Key::Char('Z')));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn translate_digits_and_symbols() {
|
||||
assert_eq!(translate(0x1e, false, false, false), Some(Key::Char('1')));
|
||||
assert_eq!(translate(0x1e, true, false, false), Some(Key::Char('!')));
|
||||
assert_eq!(translate(0x27, false, false, false), Some(Key::Char('0')));
|
||||
assert_eq!(translate(0x27, true, false, false), Some(Key::Char(')')));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn translate_backspace_variants() {
|
||||
assert_eq!(translate(0x2a, false, false, false), Some(Key::Backspace));
|
||||
assert_eq!(translate(0x2a, false, true, false), Some(Key::DeleteWord)); // Ctrl
|
||||
assert_eq!(translate(0x2a, false, false, true), Some(Key::DeleteLine)); // Cmd
|
||||
assert_eq!(translate(0x1a, false, true, false), Some(Key::DeleteWord)); // Ctrl+W
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn translate_ctrl_or_cmd_swallows_plain_chars() {
|
||||
assert_eq!(translate(0x04, false, true, false), None); // Ctrl+a
|
||||
assert_eq!(translate(0x04, false, false, true), None); // Cmd+a
|
||||
}
|
||||
|
||||
// ---- Decoder: edge detection ----
|
||||
|
||||
#[test]
|
||||
fn key_down_emits_once_then_hold_is_silent() {
|
||||
let mut d = Decoder::new();
|
||||
assert_eq!(feed(&mut d, &report(0, &[0x04])), vec![Key::Char('a')]);
|
||||
// Same key still held → no repeat.
|
||||
assert_eq!(feed(&mut d, &report(0, &[0x04])), vec![]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn release_then_press_again_re_emits() {
|
||||
let mut d = Decoder::new();
|
||||
feed(&mut d, &report(0, &[0x04]));
|
||||
assert_eq!(feed(&mut d, &report(0, &[])), vec![]); // release
|
||||
assert_eq!(feed(&mut d, &report(0, &[0x04])), vec![Key::Char('a')]); // re-press
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multiple_new_keys_in_one_report() {
|
||||
let mut d = Decoder::new();
|
||||
// 'a' (0x04) and 'b' (0x05) newly down in the same report.
|
||||
assert_eq!(
|
||||
feed(&mut d, &report(0, &[0x04, 0x05])),
|
||||
vec![Key::Char('a'), Key::Char('b')]
|
||||
);
|
||||
}
|
||||
|
||||
// ---- Decoder: Caps Lock dual role ----
|
||||
|
||||
#[test]
|
||||
fn caps_tap_emits_escape() {
|
||||
let mut d = Decoder::new();
|
||||
assert_eq!(feed(&mut d, &report(0, &[CAPS])), vec![]); // Caps down, nothing
|
||||
assert_eq!(feed(&mut d, &report(0, &[])), vec![Key::Escape]); // clean release
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn caps_held_as_ctrl_suppresses_escape() {
|
||||
let mut d = Decoder::new();
|
||||
feed(&mut d, &report(0, &[CAPS])); // Caps down
|
||||
// Caps + Backspace → Ctrl+Backspace = DeleteWord.
|
||||
assert_eq!(feed(&mut d, &report(0, &[CAPS, 0x2a])), vec![Key::DeleteWord]);
|
||||
// Releasing Caps must NOT emit Escape (it was used as Ctrl).
|
||||
assert_eq!(feed(&mut d, &report(0, &[])), vec![]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn modifier_ctrl_and_cmd_backspace() {
|
||||
let mut d = Decoder::new();
|
||||
assert_eq!(feed(&mut d, &report(0x01, &[0x2a])), vec![Key::DeleteWord]); // LCtrl
|
||||
feed(&mut d, &report(0, &[])); // release
|
||||
assert_eq!(feed(&mut d, &report(0x08, &[0x2a])), vec![Key::DeleteLine]); // LGUI
|
||||
}
|
||||
|
||||
// ---- Decoder: robustness on malformed / untrusted input ----
|
||||
|
||||
#[test]
|
||||
fn short_report_is_ignored() {
|
||||
let mut d = Decoder::new();
|
||||
assert_eq!(feed(&mut d, &[]), vec![]);
|
||||
assert_eq!(feed(&mut d, &[0x00]), vec![]);
|
||||
assert_eq!(feed(&mut d, &[0x00, 0x00]), vec![]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn never_panics_on_arbitrary_input() {
|
||||
// The FFI layer clamps reports to 8 bytes, but the decoder must not
|
||||
// panic on anything — feed it every length 0..=16, every fill byte, a
|
||||
// full sweep of single-key usages, and a deterministic pseudo-random
|
||||
// stream. A panic here fails the test.
|
||||
let mut d = Decoder::new();
|
||||
|
||||
for len in 0..=16usize {
|
||||
for fill in 0u8..=255 {
|
||||
let buf = vec![fill; len];
|
||||
d.feed(&buf, |_| {});
|
||||
}
|
||||
}
|
||||
|
||||
// Every usage ID as the sole key in a well-formed report.
|
||||
for usage in 0u8..=255 {
|
||||
d.feed(&report(0xff, &[usage]), |_| {});
|
||||
}
|
||||
|
||||
// Deterministic LCG so the stream is reproducible without a rand dep.
|
||||
let mut state = 0x1234_5678u32;
|
||||
for _ in 0..10_000 {
|
||||
state = state.wrapping_mul(1_664_525).wrapping_add(1_013_904_223);
|
||||
let len = (state >> 28) as usize; // 0..=15
|
||||
let buf: Vec<u8> = (0..len)
|
||||
.map(|i| (state.rotate_left(i as u32 * 3) & 0xff) as u8)
|
||||
.collect();
|
||||
d.feed(&buf, |_| {});
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_held_state() {
|
||||
let mut d = Decoder::new();
|
||||
feed(&mut d, &report(0, &[0x04])); // 'a' held
|
||||
d.reset();
|
||||
// After reset the same key reads as a fresh down, not a held slot.
|
||||
assert_eq!(feed(&mut d, &report(0, &[0x04])), vec![Key::Char('a')]);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user