proto: escape, moved from bxctl so gatewayd shows the same thing
Implemented-By: OpenCode session (model recorded in docs/implementer-log.md)
This commit is contained in:
@@ -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};
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<Zeroizing<Vec<u8>>, 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<Vec<u8>>` 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<StateError> 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 <url> as <username>", 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. | ? |
|
||||
|
||||
Reference in New Issue
Block a user