diff --git a/crates/bxctl/src/escape.rs b/crates/bxctl/src/escape.rs index 95522d1..a0d4072 100644 --- a/crates/bxctl/src/escape.rs +++ b/crates/bxctl/src/escape.rs @@ -1,43 +1,3 @@ -//! The two text-escape helpers the owner's client needs. +//! The text-escape helpers, from `proto` (moved there in M4b so `gatewayd` shows the same thing). -// The control bytes, the zero-width and Unicode line/paragraph separators, and the BOM. A code -// point in any of these ranges must never reach the terminal raw. -const ESCAPE_RANGES: &[(u32, u32)] = &[ - (0x0000, 0x001f), - (0x007f, 0x009f), - (0x200b, 0x200f), - (0x2028, 0x202e), - (0x2060, 0x2069), - (0xfeff, 0xfeff), -]; - -pub fn escape_json_text(s: &str) -> String { - escape(s, false) -} - -pub fn escape_model_text(s: &str) -> String { - escape(s, true) -} - -fn escape(s: &str, model_text: bool) -> String { - let mut out = String::with_capacity(s.len()); - for c in s.chars() { - let cp = u32::from(c); - if should_escape(c, model_text) { - out.push_str(&format!("\\u{:04x}", cp)); - } else { - out.push(c); - } - } - out -} - -// A control point is escaped, except that model text keeps \n and \t. -fn should_escape(c: char, model_text: bool) -> bool { - let cp = u32::from(c); - if ESCAPE_RANGES.iter().any(|(lo, hi)| cp >= *lo && cp <= *hi) { - !(model_text && (cp == 0x09 || cp == 0x0a)) - } else { - false - } -} +pub use proto::escape::{escape_json_text, escape_model_text}; diff --git a/crates/proto/src/escape.rs b/crates/proto/src/escape.rs new file mode 100644 index 0000000..6ce3987 --- /dev/null +++ b/crates/proto/src/escape.rs @@ -0,0 +1,45 @@ +//! Text from outside (model output, tool arguments, server errors) made safe to show: control +//! characters, zero-width and bidirectional marks, line separators and the BOM become `\uXXXX`. +//! Used by `bxctl` for the terminal and by `gatewayd` for Mattermost, so both show the same. + +// The control bytes, the zero-width and Unicode line/paragraph separators, and the BOM. A code +// point in any of these ranges must never reach the terminal raw. +const ESCAPE_RANGES: &[(u32, u32)] = &[ + (0x0000, 0x001f), + (0x007f, 0x009f), + (0x200b, 0x200f), + (0x2028, 0x202e), + (0x2060, 0x2069), + (0xfeff, 0xfeff), +]; + +pub fn escape_json_text(s: &str) -> String { + escape(s, false) +} + +pub fn escape_model_text(s: &str) -> String { + escape(s, true) +} + +fn escape(s: &str, model_text: bool) -> String { + let mut out = String::with_capacity(s.len()); + for c in s.chars() { + let cp = u32::from(c); + if should_escape(c, model_text) { + out.push_str(&format!("\\u{:04x}", cp)); + } else { + out.push(c); + } + } + out +} + +// A control point is escaped, except that model text keeps \n and \t. +fn should_escape(c: char, model_text: bool) -> bool { + let cp = u32::from(c); + if ESCAPE_RANGES.iter().any(|(lo, hi)| cp >= *lo && cp <= *hi) { + !(model_text && (cp == 0x09 || cp == 0x0a)) + } else { + false + } +} diff --git a/crates/proto/src/lib.rs b/crates/proto/src/lib.rs index d705918..283b61f 100644 --- a/crates/proto/src/lib.rs +++ b/crates/proto/src/lib.rs @@ -3,6 +3,7 @@ pub mod audit; pub mod chain; pub mod class; +pub mod escape; pub mod frame; pub mod grant; pub mod hash; diff --git a/crates/proto/tests/escape.rs b/crates/proto/tests/escape.rs new file mode 100644 index 0000000..74bbb30 --- /dev/null +++ b/crates/proto/tests/escape.rs @@ -0,0 +1,150 @@ +//! Tests for `proto::escape`: text the model wrote is printed as data. Do not edit. +//! +//! The expected escapes are built by `esc`, never spelled out, so that nothing that handles this +//! file can turn one into the character it stands for. + +use proto::escape::{escape_json_text, escape_model_text}; + +const BACKSLASH: char = '\\'; + +/// The escape for one code point: a backslash, `u`, and four lowercase hex digits. +fn esc(code: u32) -> String { + format!("{BACKSLASH}u{code:04x}") +} + +fn ch(code: u32) -> char { + char::from_u32(code).unwrap() +} + +/// Every code point that must be escaped, as inclusive ranges. +const HIDDEN: [(u32, u32); 6] = [ + (0x0000, 0x001f), + (0x007f, 0x009f), + (0x200b, 0x200f), + (0x2028, 0x202e), + (0x2060, 0x2069), + (0xfeff, 0xfeff), +]; + +fn hidden(code: u32) -> bool { + HIDDEN.iter().any(|(lo, hi)| (*lo..=*hi).contains(&code)) +} + +/// Walks every code point below U+11000, not a sample: each is either escaped exactly or left +/// exactly as it is. +#[test] +fn every_listed_code_point_is_escaped_and_no_other() { + let mut escaped = 0; + for code in 0..0x11000u32 { + let Some(c) = char::from_u32(code) else { + continue; // the surrogates are not characters + }; + let text = format!("a{c}b"); + let got = escape_json_text(&text); + if hidden(code) { + escaped += 1; + assert_eq!(got, format!("a{}b", esc(code)), "U+{code:04X}"); + } else { + assert_eq!(got, text, "U+{code:04X} must pass through"); + } + } + assert_eq!(escaped, 32 + 33 + 5 + 7 + 10 + 1, "the six ranges, in full"); +} + +#[test] +fn the_edges_of_each_range() { + for (lo, hi) in HIDDEN { + assert_eq!(escape_json_text(&ch(lo).to_string()), esc(lo)); + assert_eq!(escape_json_text(&ch(hi).to_string()), esc(hi)); + if lo > 0 { + let before = ch(lo - 1).to_string(); + assert_eq!(escape_json_text(&before), before, "U+{:04X}", lo - 1); + } + let after = ch(hi + 1).to_string(); + assert_eq!(escape_json_text(&after), after, "U+{:04X}", hi + 1); + } +} + +#[test] +fn hex_digits_are_lowercase_and_there_are_always_four() { + assert_eq!(escape_json_text("\x1b"), esc(0x1b)); + assert!(escape_json_text("\x1b").ends_with("001b")); + assert!(escape_json_text("\0").ends_with("0000")); + assert!(escape_json_text(&ch(0xfeff).to_string()).ends_with("feff")); + assert!(escape_json_text(&ch(0x202e).to_string()).ends_with("202e")); +} + +#[test] +fn an_escape_sequence_cannot_reach_the_terminal() { + let text = "before\x1b[8mhidden\x1b[0m\x07after"; + let got = escape_json_text(text); + assert!(!got.contains('\x1b') && !got.contains('\x07'), "{got:?}"); + assert_eq!( + got, + format!( + "before{}[8mhidden{}[0m{}after", + esc(0x1b), + esc(0x1b), + esc(0x07) + ) + ); +} + +#[test] +fn a_path_cannot_be_shown_backwards() { + // U+202E makes a terminal draw what follows from right to left. + let text = format!("/home/kyle/notes/{}dm.terces", ch(0x202e)); + assert_eq!( + escape_json_text(&text), + format!("/home/kyle/notes/{}dm.terces", esc(0x202e)) + ); + let text = format!("a{}b{}c", ch(0x200b), ch(0x2066)); + assert_eq!( + escape_json_text(&text), + format!("a{}b{}c", esc(0x200b), esc(0x2066)) + ); +} + +#[test] +fn ordinary_text_is_unchanged() { + for text in [ + "", + "plain", + r#"{"command":"ls -l","cwd":"/home/kyle"}"#, + "naïve café 日本語 🙂", + "a backslash \\ and a quote \" stay as they are", + ] { + assert_eq!(escape_json_text(text), text); + assert_eq!(escape_model_text(text), text); + } +} + +#[test] +fn json_text_escapes_newline_and_tab_but_model_text_keeps_them() { + let text = "one\n\ttwo\r\n"; + assert_eq!( + escape_json_text(text), + format!("one{}{}two{}{}", esc(0x0a), esc(0x09), esc(0x0d), esc(0x0a)) + ); + assert_eq!( + escape_model_text(text), + format!("one\n\ttwo{}\n", esc(0x0d)), + "only newline and tab pass; a carriage return could overwrite the line" + ); +} + +#[test] +fn model_text_escapes_everything_else_the_same_way() { + for (lo, hi) in HIDDEN { + for code in lo..=hi { + if code == 0x0a || code == 0x09 { + continue; + } + assert_eq!( + escape_model_text(&ch(code).to_string()), + esc(code), + "U+{code:04X}" + ); + } + } +} diff --git a/docs/implementer-log.md b/docs/implementer-log.md index 56d9c7e..1cbe0b4 100644 --- a/docs/implementer-log.md +++ b/docs/implementer-log.md @@ -6,6 +6,7 @@ reviewer adds findings under "Reviews" once per milestone. | Task | Date | Status | Gate runs | First gate | Deviations | Notes | Model | |---|---|---|---|---|---|---|---| +| M4b/01-proto-escape | 2026-09-24 | done | 1 | pass | none | Copied `docs/plans/M4b/files/crates/proto/src/escape.rs`, `bxctl/src/escape.rs` and `proto/tests/escape.rs` into place, and added `pub mod escape;` to `proto/src/lib.rs` after `pub mod class;`. The bxctl file is now the one-line re-export `pub use proto::escape::{escape_json_text, escape_model_text};`, so no bxctl source or test changed and behaviour is identical. `cargo test -p proto --test escape` reports 8 passed; every bxctl suite passes unchanged (the moved escape test now runs against `proto::escape`); `cargo fmt --all` then `make gate` prints `gate: ok` on the first run. | ? | | M4a/16-gatewayd-secret-file-race | 2026-09-24 | done | 2 | pass | Reworded the template comment in `read_checked` to drop the literal `check_file` token, so step 5's grep prints only the credential branch's `std::fs::read(&path)` | Replaced `check_file` with `pub fn read_checked(path, between) -> Result>, String>`: check the path (absolute; `symlink_metadata` as `named`; not a symlink; a regular file), call `between()`, open the path, take `opened` metadata, refuse if `(opened.dev(), opened.ino()) != (named.dev(), named.ino())`, then the owner-uid and mode checks on `opened` (not `named`), then read the whole file into a `Zeroizing>` from that same handle. Added `use std::io::Read;`. In `load`, the File branch now calls `read_checked(path, &|| {})` and `value(std::mem::take(&mut *bytes))` so nothing unwiped remains. The three suites pass (5, 8, 5); the only remaining `std::fs::read(` is the credential branch and `check_file` is gone. `make gate` prints `gate: ok`. | ? | | M4a/15-gatewayd-main | 2026-09-24 | done | 1 | pass | none | Replaced the placeholder `src/main.rs` with the skeleton and copied `tests/main.rs`. `main` parses `args_os` like `loopd`'s; `serve` runs the five start checks in order — `Config::load` (`gatewayd: {e}\n{START_FAILED}`), `token_source` (`gatewayd: {}: {why}\n{START_FAILED}` with the path), `secrets::load` (`gatewayd: {e}`, its own `#secret-unavailable` pointer, then the file warning if any), then the state dir via `DirBuilder` recursive + `mode(0o700)` (`gatewayd: cannot prepare {}: {e}\n{START_FAILED}`), each returning exit 1 before anything is written. Then `run` with a `Log` that echoes to stderr and the returned `Stop` printed, exit 1. The todo comment matches the function and every test; the token never reaches output (it is only ever `expose`d inside `serve`, never here). `cargo test -p gatewayd` every suite passes; `make gate` prints `gate: ok` first run. | ? | | M4a/14-gatewayd-serve | 2026-09-24 | done | 1 | pass | none | Copied `tests/serve.rs`, `tests/serve_restart.rs`, `tests/support/fake_mm.rs`, `tests/support/gateway.rs` and the `src/serve/mod.rs`/`handle.rs` skeletons, added `pub mod serve;` to `lib.rs` (before `secrets`, alphabetical). Filled `mod.rs`: `From for Stop` → `Stop::State(e)`; `backoff` (the last backoff repeats past its end); `sleep_unless` (interruptible 20 ms steps until the deadline); `Gateway::connected` (log "connected to as ", route as this user from `users/me`, and on the first connection only, answer each in-flight turn with INTERRUPTED so a later reconnect does not). Filled `handle.rs`: `now_ms` (millis with an i64 clamp); `post` (a failing post logs, never throws); `tracked` ("D" or an allowed channel); `handle_post` (skip untracked/seen, record `handled` before routing so a crash never answers twice, then route with the state answering `knows_thread` — an `NotAllowed` stranger is logged by post id and user id only, never the text, a reply posts, a queued message joins the thread then pushes with Start/Waiting/Full); `start` (record the in-flight turn, spawn `deliver` on its own thread, and on a spawn error post LOOP_DOWN and un-busy the session); `finished` (drain done, end each turn, start the next batch); `typing` (a `user_typing` per running thread, seq incremented); `catch_up_channel` (no mark → mark "now" and stop, history not answered; else `posts_since`, log when `full`, replay each post). `SessionId` has no `Display`, so the start-error line formats `session.as_str()`. `serve` 6 and `serve_restart` 7 pass five runs in ~1.3 s; `make gate` prints `gate: ok` first run. | ? |