diff --git a/crates/gatewayd/src/approvals/mod.rs b/crates/gatewayd/src/approvals/mod.rs new file mode 100644 index 0000000..6a8cd58 --- /dev/null +++ b/crates/gatewayd/src/approvals/mod.rs @@ -0,0 +1,4 @@ +//! Approvals in Mattermost (M4b spec, sections 6 and 7): the text of the posts, and the +//! approvals this `gatewayd` has posted and the commands that answer them. + +pub mod text; diff --git a/crates/gatewayd/src/approvals/text.rs b/crates/gatewayd/src/approvals/text.rs new file mode 100644 index 0000000..fb2bb07 --- /dev/null +++ b/crates/gatewayd/src/approvals/text.rs @@ -0,0 +1,175 @@ +//! The text of an approval's posts, and of the line added when it ends (M4b spec, section 6). +//! Nothing here is shortened: arguments too long to show whole are not shown at all. + +use proto::escape::escape_json_text; +use proto::{DenyReason, PendingApproval, Timestamp, sha256}; + +/// Arguments are split into parts of at most this many characters. +pub const PART_CHARS: usize = 15_000; +/// At most this many parts; more are answerable only in `bxctl`. +pub const MAX_PARTS: usize = 4; +/// The longest a part's post may be, leaving room for the line added when it ends. +pub const MAX_PART_POST: usize = 15_500; + +/// An approval's posts, in order. Only an answer after the last counts; when `answerable` is +/// false, no command answers it. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Shown { + pub posts: Vec, + pub answerable: bool, +} + +/// How an approval ended, for the line added to its last post. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Ended { + Runs { by: String }, + Denied { by: String, reason: DenyReason }, + Refused { by: String, reason: Option }, + NoLonger, + Restarted, +} + +/// `text` as inline code, with a backtick run longer than any inside it. +pub fn code(text: &str) -> String { + let ticks = "`".repeat(longest_run(text) + 1); + let pad = if text.starts_with('`') || text.ends_with('`') { + " " + } else { + "" + }; + format!("{ticks}{pad}{text}{pad}{ticks}") +} + +/// `text` as a fenced block, with a fence longer than any backtick run inside it (at least 3). +pub fn fence(text: &str) -> String { + let ticks = "`".repeat((longest_run(text) + 1).max(3)); + format!("{ticks}\n{text}\n{ticks}") +} + +fn longest_run(text: &str) -> usize { + let mut longest = 0; + let mut current = 0; + for c in text.chars() { + current = if c == '`' { current + 1 } else { 0 }; + longest = longest.max(current); + } + longest +} + +/// `2026-09-24 14:47 UTC`. +fn minute(at: Timestamp) -> String { + let rfc = at.to_rfc3339(); + format!( + "{} {} UTC", + rfc.get(..10).unwrap_or_default(), + rfc.get(11..16).unwrap_or_default() + ) +} + +/// A serde name (`private`, `no_grant`), or the debug form if there is none. +fn name(value: &T) -> String { + match serde_json::to_value(value) { + Ok(serde_json::Value::String(s)) => s, + _ => format!("{value:?}"), + } +} + +fn reply_line(id: u64) -> String { + format!("Reply in this thread with `!approve {id}` or `!deny {id} `.") +} + +/// The posts that show `info`. +pub fn render(info: &PendingApproval) -> Shown { + let id = info.approval; + let args = escape_json_text(&info.arguments); + let header = format!( + "**Approval {id}** · tool {} · grant {} · taint {} · expires {}", + code(&escape_json_text(&info.tool)), + code(&escape_json_text(&info.grant)), + name(&info.taint), + minute(info.expires), + ); + let chars: Vec = args.chars().collect(); + let parts: Vec = if chars.is_empty() { + vec![String::new()] + } else { + chars + .chunks(PART_CHARS) + .map(|c| c.iter().collect()) + .collect() + }; + let count = parts.len(); + let posts: Vec = if count == 1 { + vec![format!("{header}\n{}\n{}", fence(&args), reply_line(id))] + } else { + let hash = sha256(args.as_bytes()) + .map(|h| h.to_hex()) + .unwrap_or_default(); + parts + .iter() + .enumerate() + .map(|(i, part)| { + let n = i + 1; + let top = if n == 1 { + format!("{header}\nPart 1 of {count} of the arguments:") + } else { + format!("**Approval {id}**, part {n} of {count} of the arguments:") + }; + let mut post = format!("{top}\n{}", fence(part)); + if n == count { + post.push_str(&format!( + "\nApproval {id}: {count} parts, {} characters, sha256 {hash} (of the arguments as shown).\n{}", + chars.len(), + reply_line(id) + )); + } + post + }) + .collect() + }; + if count > MAX_PARTS || posts.iter().any(|p| p.chars().count() > MAX_PART_POST) { + let hash = sha256(args.as_bytes()) + .map(|h| h.to_hex()) + .unwrap_or_default(); + let post = format!( + "{header}\nThe arguments are {} characters (sha256 {hash}), too long to show here; answer it with `bxctl approvals`.", + chars.len() + ); + return Shown { + posts: vec![post], + answerable: false, + }; + } + Shown { + posts, + answerable: true, + } +} + +/// The line added to the last post when the approval ends. `by` is `@username`, or the id. +pub fn ended_line(ended: &Ended) -> String { + match ended { + Ended::Runs { by } => format!("✅ Approved by {by}: the call runs."), + Ended::Denied { by, reason } => { + format!("Approved by {by}, but denied on the re-check: {}.", name(reason)) + } + Ended::Refused { by, reason: Some(reason) } => { + format!("❌ Refused by {by}: {}.", code(&escape_json_text(reason))) + } + Ended::Refused { by, reason: None } => { + format!("❌ Refused by {by}: no reason given.") + } + Ended::NoLonger => { + "No longer pending (answered elsewhere, expired, or its turn ended).".to_string() + } + Ended::Restarted => { + "No longer answerable here: gatewayd restarted. If it is still pending it is posted again." + .to_string() + } + } +} + +/// The last post's text with the ending line added. +pub fn with_ending(last: &str, ended: &Ended) -> String { + format!("{last}\n\n{}", ended_line(ended)) +} diff --git a/crates/gatewayd/src/lib.rs b/crates/gatewayd/src/lib.rs index a23379c..fb63fda 100644 --- a/crates/gatewayd/src/lib.rs +++ b/crates/gatewayd/src/lib.rs @@ -1,6 +1,7 @@ //! `gatewayd`: the Mattermost channel. It carries the owner's messages to `loopd` as turns and posts //! the answers back, over outbound connections only. M4a spec: `docs/specs/2026-09-23-m4a-gateway.md`. +pub mod approvals; pub mod config; pub mod deliver; pub mod http; diff --git a/crates/gatewayd/tests/approvals_text.rs b/crates/gatewayd/tests/approvals_text.rs new file mode 100644 index 0000000..fd6922b --- /dev/null +++ b/crates/gatewayd/tests/approvals_text.rs @@ -0,0 +1,197 @@ +//! The text of an approval's posts and of its ending line (M4b spec, section 6). Do not edit. + +use gatewayd::approvals::text::{ + Ended, MAX_PART_POST, MAX_PARTS, PART_CHARS, code, ended_line, fence, render, with_ending, +}; +use proto::{CallId, DataClass, DenyReason, PendingApproval, SessionId, Timestamp, sha256}; + +/// The escape for one code point, built here so this file holds no such character itself. +fn esc(code: u32) -> String { + format!("{}u{code:04x}", char::from(92)) +} + +fn info(arguments: &str) -> PendingApproval { + PendingApproval { + approval: 42, + session: SessionId::new("mm-r0000000000000000000000001").unwrap(), + call: CallId(3), + tool: "shell".to_string(), + arguments: arguments.to_string(), + grant: "ops-shell".to_string(), + taint: DataClass::Private, + created: Timestamp::parse("2026-09-24T14:32:10.000Z").unwrap(), + expires: Timestamp::parse("2026-09-24T14:47:10.000Z").unwrap(), + } +} + +#[test] +fn one_part_exactly() { + let shown = render(&info( + r#"{"command":"ls -la /srv/backups","timeout_ms":10000}"#, + )); + assert!(shown.answerable); + assert_eq!( + shown.posts, + [ + "**Approval 42** · tool `shell` · grant `ops-shell` · taint private · expires 2026-09-24 14:47 UTC\n```\n{\"command\":\"ls -la /srv/backups\",\"timeout_ms\":10000}\n```\nReply in this thread with `!approve 42` or `!deny 42 `." + ] + ); +} + +#[test] +fn fences_and_code_outrun_backticks() { + assert_eq!(fence("a"), "```\na\n```"); + assert_eq!(fence("a ``` b ```` c"), "`````\na ``` b ```` c\n`````"); + assert_eq!(code("ops"), "`ops`"); + assert_eq!(code("a`b"), "``a`b``"); + assert_eq!(code("`x"), "`` `x ``"); + let shown = render(&info(r#"{"command":"echo ```` done"}"#)); + assert!( + shown.posts[0].contains("\n`````\n{\"command\":\"echo ```` done\"}\n`````\n"), + "{}", + shown.posts[0] + ); +} + +#[test] +fn what_must_not_reach_the_reader_raw_is_escaped() { + let raw = "a\u{7}b\u{202e}c\u{200b}d\u{2028}e\u{feff}f\nnewline"; + let shown = render(&info(&format!("{{\"command\":\"{raw}\"}}"))); + let post = &shown.posts[0]; + for bad in ['\u{7}', '\u{202e}', '\u{200b}', '\u{2028}', '\u{feff}'] { + assert!(!post.contains(bad), "{bad:?} reached the post"); + } + let want = format!( + "a{}b{}c{}d{}e{}f{}newline", + esc(0x7), + esc(0x202e), + esc(0x200b), + esc(0x2028), + esc(0xfeff), + esc(0xa) + ); + assert!(post.contains(&want), "{post}"); + let mut odd = info("{}"); + odd.grant = "g`rant\u{202e}".to_string(); + assert!( + render(&odd).posts[0].contains(&format!("grant ``g`rant{}``", esc(0x202e))), + "{}", + render(&odd).posts[0] + ); +} + +#[test] +fn long_arguments_come_in_parts_with_a_hash() { + let args = format!("{{\"content\":\"{}\"}}", "é".repeat(PART_CHARS * 2)); + let shown = render(&info(&args)); + assert!(shown.answerable); + assert_eq!(shown.posts.len(), 3); + let total = args.chars().count(); + assert!(shown.posts[0].starts_with("**Approval 42** · tool `shell` · grant `ops-shell` · taint private · expires 2026-09-24 14:47 UTC\nPart 1 of 3 of the arguments:\n```\n{\"content\":\"")); + assert!(shown.posts[1].starts_with("**Approval 42**, part 2 of 3 of the arguments:\n```\n")); + let hash = sha256(args.as_bytes()).unwrap().to_hex(); + let last = &shown.posts[2]; + assert!(last.ends_with(&format!("\nApproval 42: 3 parts, {total} characters, sha256 {hash} (of the arguments as shown).\nReply in this thread with `!approve 42` or `!deny 42 `.")), "{last}"); + assert!( + !shown.posts[0].contains("Reply in this thread") + && !shown.posts[1].contains("Reply in this thread") + ); + let shown_again: String = shown + .posts + .iter() + .map(|p| { + let body = p.split_once("\n```\n").unwrap().1; + body.split_once("\n```").unwrap().0.to_string() + }) + .collect(); + assert_eq!( + shown_again, args, + "the parts, joined, are the arguments: nothing lost" + ); + assert!( + shown + .posts + .iter() + .all(|p| p.chars().count() <= MAX_PART_POST) + ); +} + +#[test] +fn too_long_to_show_is_answerable_only_in_bxctl() { + let args = "x".repeat(PART_CHARS * MAX_PARTS + 1); + let shown = render(&info(&args)); + assert!(!shown.answerable); + let hash = sha256(args.as_bytes()).unwrap().to_hex(); + assert_eq!( + shown.posts, + [format!( + "**Approval 42** · tool `shell` · grant `ops-shell` · taint private · expires 2026-09-24 14:47 UTC\nThe arguments are {} characters (sha256 {hash}), too long to show here; answer it with `bxctl approvals`.", + args.len() + )] + ); + let exactly = "x".repeat(PART_CHARS * MAX_PARTS); + assert!( + render(&info(&exactly)).answerable, + "four full parts still show" + ); + let ticks = format!("{}{}", "`".repeat(PART_CHARS - 10), "x".repeat(20)); + assert!( + !render(&info(&ticks)).answerable, + "a fence that would overflow the post is not shown" + ); +} + +#[test] +fn ending_lines() { + let kyle = "@kyle".to_string(); + assert_eq!( + ended_line(&Ended::Runs { by: kyle.clone() }), + "✅ Approved by @kyle: the call runs." + ); + assert_eq!( + ended_line(&Ended::Denied { + by: kyle.clone(), + reason: DenyReason::GrantExpired + }), + "Approved by @kyle, but denied on the re-check: grant_expired." + ); + assert_eq!( + ended_line(&Ended::Refused { + by: kyle.clone(), + reason: None + }), + "❌ Refused by @kyle: no reason given." + ); + assert_eq!( + ended_line(&Ended::Refused { + by: kyle.clone(), + reason: Some("not @channel `now`".to_string()) + }), + "❌ Refused by @kyle: `` not @channel `now` ``." + ); + assert_eq!( + ended_line(&Ended::NoLonger), + "No longer pending (answered elsewhere, expired, or its turn ended)." + ); + assert_eq!( + ended_line(&Ended::Restarted), + "No longer answerable here: gatewayd restarted. If it is still pending it is posted again." + ); + assert_eq!( + with_ending("post", &Ended::NoLonger), + "post\n\nNo longer pending (answered elsewhere, expired, or its turn ended)." + ); + let longest = render(&info(&"y".repeat(PART_CHARS))).posts.remove(0); + let ended = with_ending( + &longest, + &Ended::Refused { + by: format!("@{}", "u".repeat(64)), + reason: Some("r".repeat(500)), + }, + ); + assert!( + ended.chars().count() <= 16_383, + "an ended post still fits: {}", + ended.chars().count() + ); +} diff --git a/docs/implementer-log.md b/docs/implementer-log.md index 80e8a5c..1bc67cb 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/10-gatewayd-approval-text | 2026-09-24 | done | 1 | pass | none | Copied `docs/plans/M4b/files/crates/gatewayd/src/approvals/mod.rs-task10` to `crates/gatewayd/src/approvals/mod.rs` (the module comment and `pub mod text;`), the `text.rs` skeleton, and `docs/plans/M4b/files/crates/gatewayd/tests/approvals_text.rs` to `crates/gatewayd/tests/`, then added `pub mod approvals;` to `lib.rs` first, before `pub mod config;`. Filled the eight functions the skeleton left as `todo!()`: `code` (a backtick run one longer than `longest_run`, with a padding space on both sides when the text starts or ends with a backtick so CommonMark keeps it literal), `fence` (same, at least three backticks, wrapped `fence\n`), `longest_run` (a char scan keeping the longest run of consecutive backticks), `minute` (the rfc3339 date and `HH:MM` plus ` UTC`), `name` (the serde string form when there is one, else `{value:?}`), `reply_line` (the `!approve`/`!deny` line), `ended_line` (the six-arm match on `Ended`, the `Denied` reason and any `Refused` reason run through `name`/`code`), and `with_ending` (the last post, a blank line, then `ended_line`). `render` was already written in the skeleton. All six `approvals_text` tests pass; `make gate` prints `gate: ok` on the first run. | ? | | M4b/09-gatewayd-mm-edit | 2026-09-24 | done | 1 | pass | none | Copied `docs/plans/M4b/files/crates/gatewayd/tests/mm_edit.rs` into `crates/gatewayd/tests/`. In `rest.rs`: added the `User { username: String }` struct and `valid_username` (1..=64 bytes of a-z, 0-9, `.`, `-` and `_`) after `Channel`, then `patch_post` (a `post` that is not `valid_id` is `Json` and nothing is sent; otherwise PUT `/api/v4/posts/{post}/patch` with `{"message": message}` through `call`, the answer a `Post` `check`ed and dropped) and `username` (a `user` that is not `valid_id` is `Json`; GET `/api/v4/users/{user}`, a username that is not `valid_username` is `Json`, else the name) at the end of `impl Client`. `cargo fmt --all` then `cargo test -p gatewayd --test mm_edit --test mm_rest` green (4 and 10 passed); `make gate` prints `gate: ok` on the first run. | ? | | M4b/08-gatewayd-mention-commands | 2026-09-24 | done | 1 | pass | none | Copied `sessions_mention.rs` into `crates/gatewayd/tests/`, the new `sessions.rs` (commands now route to `Approval`, the mention stripped) and `serve.rs` (from `serve.rs-task08`, the turn's text is `start`). In `sessions.rs`: added `Route::Approval { thread, command, user, post, at }` after `Queue(Message)`; added the given `ApprovalCommand` enum, `MAX_REASON = proto::MAX_REASON`, and `approval_command` (returns `None` for text that is not a `!` command, else `Approve`/`Deny`/`Malformed`); wrote `strip_mention` (the five steps: strip a leading `@`, take the longest name run, trim a trailing dot, match `me` case-insensitively, return the message when the remainder is empty); in `route` bound `message` from `strip_mention(&post.message, &self.me_name)`, replaced the `!` command block with `approval_command` → Approval then any other `!` → Reply UNKNOWN_COMMAND, and changed the final Queue's `text: message.clone()` to `message.to_string()` since `message` is now `&str`; updated the step comment. In `handle.rs` `handle_post` added the `Route::Approval` arm posting M4B_COMMAND (approvals module wired in task 15) and `M4B_COMMAND` to the `use`. All three suites pass (serve 6, sessions 8, sessions_mention 4); `make gate` prints `gate: ok` on the first run. | ? | | M4b/07-gatewayd-config-approvals | 2026-09-24 | done | 1 | pass | none | Copied `docs/plans/M4b/files/crates/gatewayd/tests/config_approvals.rs` into `crates/gatewayd/tests/`. In `config.rs`: added `#[serde(default)] pub approvals: ApprovalsConfig,` after `pub limits: Limits,` in `Config`, the `ApprovalsConfig { users: Vec, socket: PathBuf }` struct (`#[serde(deny_unknown_fields, default)]`, with the doc comments) after `struct Config`, the `[approvals] users` check in `problem` right before the `[limits] queue` check (each id must be in `allow.users`), and the three methods before `state_path`: `approvers` (`&self.approvals.users`, or `&self.allow.users` when empty), `first_approver` (`.first().map(String::as_str)`), and `gateway_socket` (`self.approvals.socket`, or `/run/gateway/gateway.sock` when `as_os_str().is_empty()`). The `ApprovalsConfig` derives `Default` so the empty `socket` deserialises from an absent field; `PathBuf`'s default is empty, matching the runbook's "empty" rule. `cargo fmt --all` then `cargo test -p gatewayd --test config_approvals --test config` green (4 and 7 passed); `make gate` prints `gate: ok` on the first run. | ? |