Plan M4b: approvals over Mattermost in 15 tasks
Skeletons for new files only; existing files change by exact edits, so no task overwrites an earlier one's work. Each task's tests were run against a reference built in order from master, with the gate at each step and mutations checked; each skeleton compiles and fails its tests, and every todo!() comment was read beside its signature. The reference is kept off this machine. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
@@ -0,0 +1 @@
|
||||
{"v":1,"id":2,"final":true,"msg":{"kind":"approval_outcome","body":{"outcome":{"outcome":"denied","reason":"no_grant"}}}}
|
||||
+1
@@ -0,0 +1 @@
|
||||
{"v":1,"id":2,"final":true,"msg":{"kind":"approval_outcome","body":{"outcome":{"outcome":"gone"}}}}
|
||||
+1
@@ -0,0 +1 @@
|
||||
{"v":1,"id":2,"final":true,"msg":{"kind":"approval_outcome","body":{"outcome":{"outcome":"refused"}}}}
|
||||
+1
@@ -0,0 +1 @@
|
||||
{"v":1,"id":2,"final":true,"msg":{"kind":"approval_outcome","body":{"outcome":{"outcome":"runs"}}}}
|
||||
@@ -0,0 +1 @@
|
||||
{"v":1,"id":1,"final":false,"msg":{"kind":"approval_posted","body":{"posts":["p0000000000000000000000001","p0000000000000000000000002"]}}}
|
||||
+1
@@ -0,0 +1 @@
|
||||
{"v":1,"id":1,"final":true,"msg":{"kind":"approval_reply","body":{"approval":41,"approved":true,"by":"k0000000000000000000000000","post":"c0000000000000000000000001","reason":null}}}
|
||||
+1
@@ -0,0 +1 @@
|
||||
{"v":1,"id":1,"final":true,"msg":{"kind":"approval_reply","body":{"approval":41,"approved":false,"by":"k0000000000000000000000000","post":"c0000000000000000000000001","reason":"not on a Friday"}}}
|
||||
@@ -0,0 +1 @@
|
||||
{"v":1,"id":1,"final":true,"msg":{"kind":"approval_request","body":{"approval":{"approval":41,"session":"mm-r0000000000000000000000001","call":3,"tool":"shell","arguments":"{\"command\":\"ls -la /srv\"}","grant":"shell-srv","taint":"private","created":"2026-09-24T08:05:00.000Z","expires":"2026-09-24T08:20:00.000Z"}}}}
|
||||
@@ -0,0 +1,187 @@
|
||||
//! The four messages of `gateway.sock` against byte-exact fixtures, every object in them refusing
|
||||
//! an unknown key, and the checks on an answer (M4b spec, section 4). Do not edit these or the
|
||||
//! fixtures.
|
||||
|
||||
use proto::{
|
||||
ApprovalOutcome, ApprovalPosted, ApprovalReply, ApprovalRequest, CallId, DataClass, DenyReason,
|
||||
Envelope, GatewayOutcome, MAX_REASON, Message, PendingApproval, SessionId, Timestamp,
|
||||
is_mattermost_id,
|
||||
};
|
||||
use serde_json::Value;
|
||||
|
||||
const BY: &str = "k0000000000000000000000000";
|
||||
const POST: &str = "c0000000000000000000000001";
|
||||
|
||||
fn fixture(name: &str) -> String {
|
||||
let path = format!("{}/tests/fixtures/wire/{name}", env!("CARGO_MANIFEST_DIR"));
|
||||
let text = std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("{path}: {e}"));
|
||||
text.trim_end_matches('\n').to_string()
|
||||
}
|
||||
|
||||
/// The fixture must decode to `want`, and `want` must encode to exactly the fixture's bytes.
|
||||
fn check(name: &str, id: u64, last: bool, msg: Message) {
|
||||
let want = Envelope {
|
||||
v: 1,
|
||||
id,
|
||||
r#final: last,
|
||||
msg,
|
||||
};
|
||||
let text = fixture(name);
|
||||
let got: Envelope = serde_json::from_str(&text).unwrap_or_else(|e| panic!("{name}: {e}"));
|
||||
assert_eq!(got, want, "{name}: decoded value");
|
||||
assert_eq!(
|
||||
serde_json::to_string(&want).unwrap(),
|
||||
text,
|
||||
"{name}: encoded bytes"
|
||||
);
|
||||
}
|
||||
|
||||
fn pending() -> PendingApproval {
|
||||
PendingApproval {
|
||||
approval: 41,
|
||||
session: SessionId::new("mm-r0000000000000000000000001").unwrap(),
|
||||
call: CallId(3),
|
||||
tool: "shell".to_string(),
|
||||
arguments: r#"{"command":"ls -la /srv"}"#.to_string(),
|
||||
grant: "shell-srv".to_string(),
|
||||
taint: DataClass::Private,
|
||||
created: Timestamp::parse("2026-09-24T08:05:00.000Z").unwrap(),
|
||||
expires: Timestamp::parse("2026-09-24T08:20:00.000Z").unwrap(),
|
||||
}
|
||||
}
|
||||
|
||||
fn reply(approved: bool, reason: Option<&str>) -> ApprovalReply {
|
||||
ApprovalReply {
|
||||
approval: 41,
|
||||
approved,
|
||||
by: BY.to_string(),
|
||||
post: POST.to_string(),
|
||||
reason: reason.map(str::to_string),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_four_messages() {
|
||||
check(
|
||||
"gateway_request.json",
|
||||
1,
|
||||
true,
|
||||
Message::ApprovalRequest(ApprovalRequest {
|
||||
approval: pending(),
|
||||
}),
|
||||
);
|
||||
let posts = vec![
|
||||
"p0000000000000000000000001".to_string(),
|
||||
"p0000000000000000000000002".to_string(),
|
||||
];
|
||||
check(
|
||||
"gateway_posted.json",
|
||||
1,
|
||||
false,
|
||||
Message::ApprovalPosted(ApprovalPosted { posts }),
|
||||
);
|
||||
check(
|
||||
"gateway_reply_approved.json",
|
||||
1,
|
||||
true,
|
||||
Message::ApprovalReply(reply(true, None)),
|
||||
);
|
||||
check(
|
||||
"gateway_reply_refused.json",
|
||||
1,
|
||||
true,
|
||||
Message::ApprovalReply(reply(false, Some("not on a Friday"))),
|
||||
);
|
||||
for (name, outcome) in [
|
||||
("gateway_outcome_runs.json", GatewayOutcome::Runs {}),
|
||||
(
|
||||
"gateway_outcome_denied.json",
|
||||
GatewayOutcome::Denied {
|
||||
reason: DenyReason::NoGrant,
|
||||
},
|
||||
),
|
||||
("gateway_outcome_refused.json", GatewayOutcome::Refused {}),
|
||||
("gateway_outcome_gone.json", GatewayOutcome::Gone {}),
|
||||
] {
|
||||
check(
|
||||
name,
|
||||
2,
|
||||
true,
|
||||
Message::ApprovalOutcome(ApprovalOutcome { outcome }),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Every copy of `value` with one extra key in one object.
|
||||
fn with_one_unknown_key(value: &Value) -> Vec<Value> {
|
||||
let mut out = Vec::new();
|
||||
if let Value::Object(map) = value {
|
||||
let mut extended = map.clone();
|
||||
extended.insert("zz_unknown".to_string(), Value::Bool(true));
|
||||
out.push(Value::Object(extended));
|
||||
for (key, child) in map {
|
||||
for changed in with_one_unknown_key(child) {
|
||||
let mut copy = map.clone();
|
||||
copy.insert(key.clone(), changed);
|
||||
out.push(Value::Object(copy));
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_object_refuses_an_unknown_key() {
|
||||
for (name, objects) in [
|
||||
("gateway_request.json", 4),
|
||||
("gateway_posted.json", 3),
|
||||
("gateway_reply_approved.json", 3),
|
||||
("gateway_reply_refused.json", 3),
|
||||
("gateway_outcome_runs.json", 4),
|
||||
("gateway_outcome_denied.json", 4),
|
||||
("gateway_outcome_refused.json", 4),
|
||||
("gateway_outcome_gone.json", 4),
|
||||
] {
|
||||
let value: Value = serde_json::from_str(&fixture(name)).unwrap();
|
||||
let variations = with_one_unknown_key(&value);
|
||||
assert_eq!(variations.len(), objects, "{name}: objects walked");
|
||||
for changed in variations {
|
||||
assert!(
|
||||
serde_json::from_value::<Envelope>(changed.clone()).is_err(),
|
||||
"{name}: accepted {changed}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_answer_is_checked() {
|
||||
assert_eq!(reply(true, None).check(), Ok(()));
|
||||
assert_eq!(reply(false, Some(&"x".repeat(MAX_REASON))).check(), Ok(()));
|
||||
assert_eq!(
|
||||
reply(false, Some(&"é".repeat(MAX_REASON))).check(),
|
||||
Ok(()),
|
||||
"characters, not bytes"
|
||||
);
|
||||
assert!(
|
||||
reply(false, Some(&"x".repeat(MAX_REASON + 1)))
|
||||
.check()
|
||||
.is_err()
|
||||
);
|
||||
for bad in [
|
||||
"",
|
||||
"K0000000000000000000000000",
|
||||
"k000000000000000000000000",
|
||||
"k00000000000000000000000000",
|
||||
"../../../../../../../../etc",
|
||||
] {
|
||||
let mut r = reply(true, None);
|
||||
r.by = bad.to_string();
|
||||
assert!(r.check().is_err(), "by {bad:?}");
|
||||
let mut r = reply(true, None);
|
||||
r.post = bad.to_string();
|
||||
assert!(r.check().is_err(), "post {bad:?}");
|
||||
assert!(!is_mattermost_id(bad), "{bad:?}");
|
||||
}
|
||||
assert!(is_mattermost_id(BY) && is_mattermost_id(POST));
|
||||
}
|
||||
Reference in New Issue
Block a user