gatewayd: gateway, the gateway.sock listener and one thread per connection
Implemented-By: OpenCode session (model recorded in docs/implementer-log.md)
This commit is contained in:
@@ -0,0 +1,298 @@
|
|||||||
|
//! `gateway.sock`: the listener, and one thread per `brokerd` connection (M4b spec, sections 4
|
||||||
|
//! and 6). A connection thread speaks the protocol only; everything that touches Mattermost or the
|
||||||
|
//! state file is asked of the serve loop, which alone owns them.
|
||||||
|
|
||||||
|
use std::os::unix::fs::{DirBuilderExt, FileTypeExt, PermissionsExt};
|
||||||
|
use std::os::unix::net::{UnixListener, UnixStream};
|
||||||
|
use std::path::Path;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::sync::mpsc::{self, Receiver, RecvTimeoutError, Sender, TryRecvError};
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use proto::{
|
||||||
|
ApprovalPosted, ApprovalReply, Envelope, ErrorCode, FrameError, GatewayOutcome, Message,
|
||||||
|
PROTOCOL_VERSION, PendingApproval, WireError, read_frame, write_frame,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// A log line printer, shared by the threads.
|
||||||
|
pub type Log = Arc<dyn Fn(&str) + Send + Sync>;
|
||||||
|
|
||||||
|
/// What a connection thread asks of the serve loop.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub enum ToLoop {
|
||||||
|
/// Post this approval; an accepted answer comes back on `answer`, and the post ids (or why
|
||||||
|
/// it could not be posted) on `shown`.
|
||||||
|
Show {
|
||||||
|
info: PendingApproval,
|
||||||
|
answer: Sender<ApprovalReply>,
|
||||||
|
shown: Sender<Result<Vec<String>, String>>,
|
||||||
|
},
|
||||||
|
/// It ended: edit its last post, and forget it.
|
||||||
|
Ended { approval: u64, ending: Ending },
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How an approval ended, as far as this `gatewayd` knows.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub enum Ending {
|
||||||
|
/// Answered here, by `by`, and `brokerd` said what came of it.
|
||||||
|
Answered {
|
||||||
|
by: String,
|
||||||
|
reason: Option<String>,
|
||||||
|
outcome: GatewayOutcome,
|
||||||
|
},
|
||||||
|
/// `brokerd` closed first, or said nothing after the answer.
|
||||||
|
NoLonger,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How long to wait: for the first frame, for the loop to post, for `brokerd`'s outcome; and how
|
||||||
|
/// often to look for an answer meanwhile.
|
||||||
|
#[derive(Debug, Clone, Copy)]
|
||||||
|
pub struct Timing {
|
||||||
|
pub first_frame: Duration,
|
||||||
|
pub posting: Duration,
|
||||||
|
pub outcome: Duration,
|
||||||
|
pub tick: Duration,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for Timing {
|
||||||
|
fn default() -> Self {
|
||||||
|
Timing {
|
||||||
|
first_frame: Duration::from_secs(10),
|
||||||
|
posting: Duration::from_secs(120),
|
||||||
|
outcome: Duration::from_secs(30),
|
||||||
|
tick: Duration::from_millis(100),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Listen on `socket`: its directory made 0700, a stale socket removed, the socket made 0600.
|
||||||
|
pub fn listen(socket: &Path) -> Result<UnixListener, String> {
|
||||||
|
// 1. The directory: `socket.parent()`, which must not be empty, else "cannot prepare <socket>:
|
||||||
|
// a socket needs a directory".
|
||||||
|
// 2. `std::fs::DirBuilder::new().recursive(true).mode(0o700).create(dir)`, then
|
||||||
|
// `set_permissions(dir, 0o700)`; an error is "cannot prepare <dir>: <e>".
|
||||||
|
// 3. `std::fs::symlink_metadata(socket)`: a socket (`file_type().is_socket()`) is removed
|
||||||
|
// (stale); anything else there is "cannot listen on <socket>: something else is there";
|
||||||
|
// nothing there is fine.
|
||||||
|
// 4. `UnixListener::bind(socket)`, then `set_permissions(socket, 0o600)`; an error is "cannot
|
||||||
|
// listen on <socket>: <e>".
|
||||||
|
// 5. Ok(listener). Paths are shown with `.display()`.
|
||||||
|
let dir = match socket.parent() {
|
||||||
|
Some(dir) => dir,
|
||||||
|
None => {
|
||||||
|
return Err(format!(
|
||||||
|
"cannot prepare {}: a socket needs a directory",
|
||||||
|
socket.display()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if let Err(e) = std::fs::DirBuilder::new()
|
||||||
|
.recursive(true)
|
||||||
|
.mode(0o700)
|
||||||
|
.create(dir)
|
||||||
|
{
|
||||||
|
return Err(format!("cannot prepare {}: {e}", dir.display()));
|
||||||
|
}
|
||||||
|
if let Err(e) = std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700)) {
|
||||||
|
return Err(format!("cannot prepare {}: {e}", dir.display()));
|
||||||
|
}
|
||||||
|
match std::fs::symlink_metadata(socket) {
|
||||||
|
Ok(meta) if meta.file_type().is_socket() => {
|
||||||
|
if let Err(e) = std::fs::remove_file(socket) {
|
||||||
|
return Err(format!("cannot listen on {}: {e}", socket.display()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(_) => {
|
||||||
|
return Err(format!(
|
||||||
|
"cannot listen on {}: something else is there",
|
||||||
|
socket.display()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Err(_) => {}
|
||||||
|
}
|
||||||
|
let listener = match UnixListener::bind(socket) {
|
||||||
|
Ok(listener) => listener,
|
||||||
|
Err(e) => return Err(format!("cannot listen on {}: {e}", socket.display())),
|
||||||
|
};
|
||||||
|
if let Err(e) = std::fs::set_permissions(socket, std::fs::Permissions::from_mode(0o600)) {
|
||||||
|
return Err(format!("cannot listen on {}: {e}", socket.display()));
|
||||||
|
}
|
||||||
|
Ok(listener)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Accept connections on a thread of their own, each served on its own thread.
|
||||||
|
pub fn serve(
|
||||||
|
listener: UnixListener,
|
||||||
|
to_loop: Sender<ToLoop>,
|
||||||
|
timing: Timing,
|
||||||
|
log: Log,
|
||||||
|
) -> std::io::Result<()> {
|
||||||
|
// Spawn (with `std::thread::Builder`, `?`) a thread that loops over `listener.incoming()`,
|
||||||
|
// skipping errors. For each stream: clone `to_loop` and `log`, and spawn a thread running
|
||||||
|
// `connection(stream, &to_loop, timing, &log)`; if that spawn fails, log "gatewayd: cannot
|
||||||
|
// start a thread for an approval: <e>". Then Ok(()).
|
||||||
|
std::thread::Builder::new()
|
||||||
|
.name("gateway-accept".to_string())
|
||||||
|
.spawn(move || {
|
||||||
|
for stream in listener.incoming() {
|
||||||
|
match stream {
|
||||||
|
Ok(stream) => {
|
||||||
|
let to_loop = to_loop.clone();
|
||||||
|
let thread_log = log.clone();
|
||||||
|
if let Err(e) = std::thread::Builder::new()
|
||||||
|
.name("gateway-connection".to_string())
|
||||||
|
.spawn(move || connection(stream, &to_loop, timing, &thread_log))
|
||||||
|
{
|
||||||
|
log(&format!(
|
||||||
|
"gatewayd: cannot start a thread for an approval: {e}"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(_) => continue,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn frame(id: u64, last: bool, msg: Message) -> Envelope {
|
||||||
|
Envelope {
|
||||||
|
v: PROTOCOL_VERSION,
|
||||||
|
id,
|
||||||
|
r#final: last,
|
||||||
|
msg,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every frame `brokerd` sends, read whole on a thread of its own; shutting the stream down ends it.
|
||||||
|
fn frames(stream: &UnixStream) -> std::io::Result<Receiver<Result<Envelope, FrameError>>> {
|
||||||
|
// As in `brokerd`'s courier: `let mut reader = stream.try_clone()?;`, a channel, and a thread
|
||||||
|
// (`std::thread::Builder`, `?`) looping `let frame = read_frame(&mut reader); let last =
|
||||||
|
// frame.is_err(); if tx.send(frame).is_err() || last { return; }`. Then `Ok(rx)`.
|
||||||
|
let mut reader = stream.try_clone()?;
|
||||||
|
let (tx, rx) = mpsc::channel();
|
||||||
|
std::thread::Builder::new()
|
||||||
|
.name("gateway-frame".to_string())
|
||||||
|
.spawn(move || {
|
||||||
|
loop {
|
||||||
|
let frame = read_frame(&mut reader);
|
||||||
|
let last = frame.is_err();
|
||||||
|
if tx.send(frame).is_err() || last {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})?;
|
||||||
|
Ok(rx)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One `brokerd` connection, frames 1 to 4 of the spec's section 4.
|
||||||
|
pub fn connection(mut stream: UnixStream, to_loop: &Sender<ToLoop>, timing: Timing, log: &Log) {
|
||||||
|
// 1. The request, within `first_frame`.
|
||||||
|
let _ = stream.set_read_timeout(Some(timing.first_frame));
|
||||||
|
let info = match read_frame(&mut stream) {
|
||||||
|
Ok(Envelope {
|
||||||
|
id: 1,
|
||||||
|
r#final: true,
|
||||||
|
msg: Message::ApprovalRequest(req),
|
||||||
|
..
|
||||||
|
}) => req.approval,
|
||||||
|
_ => return,
|
||||||
|
};
|
||||||
|
let _ = stream.set_read_timeout(None);
|
||||||
|
let id = info.approval;
|
||||||
|
// 2. The loop posts it.
|
||||||
|
let (answer_tx, answer_rx) = mpsc::channel();
|
||||||
|
let (shown_tx, shown_rx) = mpsc::channel();
|
||||||
|
if to_loop
|
||||||
|
.send(ToLoop::Show {
|
||||||
|
info,
|
||||||
|
answer: answer_tx,
|
||||||
|
shown: shown_tx,
|
||||||
|
})
|
||||||
|
.is_err()
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let posts = match shown_rx.recv_timeout(timing.posting) {
|
||||||
|
Ok(Ok(posts)) => posts,
|
||||||
|
Ok(Err(why)) => {
|
||||||
|
let e = WireError {
|
||||||
|
code: ErrorCode::Internal,
|
||||||
|
detail: why,
|
||||||
|
};
|
||||||
|
let _ = write_frame(&mut stream, &frame(1, true, Message::Error(e)));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Err(_) => {
|
||||||
|
let e = WireError {
|
||||||
|
code: ErrorCode::Internal,
|
||||||
|
detail: "gatewayd did not post it in time".to_string(),
|
||||||
|
};
|
||||||
|
let _ = write_frame(&mut stream, &frame(1, true, Message::Error(e)));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let rx = match frames(&stream) {
|
||||||
|
Ok(rx) => rx,
|
||||||
|
Err(_) => return end(to_loop, id, Ending::NoLonger),
|
||||||
|
};
|
||||||
|
if write_frame(
|
||||||
|
&mut stream,
|
||||||
|
&frame(1, false, Message::ApprovalPosted(ApprovalPosted { posts })),
|
||||||
|
)
|
||||||
|
.is_err()
|
||||||
|
{
|
||||||
|
return end(to_loop, id, Ending::NoLonger);
|
||||||
|
}
|
||||||
|
// 3. An answer from an approver, or brokerd closing first.
|
||||||
|
let mut answers = Some(answer_rx);
|
||||||
|
let reply = loop {
|
||||||
|
if let Some(answers_rx) = &answers {
|
||||||
|
match answers_rx.recv_timeout(timing.tick) {
|
||||||
|
Ok(reply) => break reply,
|
||||||
|
Err(RecvTimeoutError::Timeout) => {}
|
||||||
|
Err(RecvTimeoutError::Disconnected) => answers = None,
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
std::thread::sleep(timing.tick);
|
||||||
|
}
|
||||||
|
match rx.try_recv() {
|
||||||
|
Err(TryRecvError::Empty) => {}
|
||||||
|
// brokerd closed (the approval ended elsewhere), or sent what it may not.
|
||||||
|
_ => {
|
||||||
|
let _ = stream.shutdown(std::net::Shutdown::Both);
|
||||||
|
return end(to_loop, id, Ending::NoLonger);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let (by, reason) = (reply.by.clone(), reply.reason.clone());
|
||||||
|
if write_frame(&mut stream, &frame(1, true, Message::ApprovalReply(reply))).is_err() {
|
||||||
|
return end(to_loop, id, Ending::NoLonger);
|
||||||
|
}
|
||||||
|
// 4. What came of it.
|
||||||
|
let ending = match rx.recv_timeout(timing.outcome) {
|
||||||
|
Ok(Ok(Envelope {
|
||||||
|
id: 2,
|
||||||
|
r#final: true,
|
||||||
|
msg: Message::ApprovalOutcome(o),
|
||||||
|
..
|
||||||
|
})) => Ending::Answered {
|
||||||
|
by,
|
||||||
|
reason,
|
||||||
|
outcome: o.outcome,
|
||||||
|
},
|
||||||
|
_ => {
|
||||||
|
(log)(&format!(
|
||||||
|
"gatewayd: approval {id}: brokerd said nothing after the answer"
|
||||||
|
));
|
||||||
|
Ending::NoLonger
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let _ = stream.shutdown(std::net::Shutdown::Both);
|
||||||
|
end(to_loop, id, ending);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn end(to_loop: &Sender<ToLoop>, approval: u64, ending: Ending) {
|
||||||
|
// `let _ = to_loop.send(ToLoop::Ended { approval, ending });`
|
||||||
|
let _ = to_loop.send(ToLoop::Ended { approval, ending });
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@
|
|||||||
pub mod approvals;
|
pub mod approvals;
|
||||||
pub mod config;
|
pub mod config;
|
||||||
pub mod deliver;
|
pub mod deliver;
|
||||||
|
pub mod gateway;
|
||||||
pub mod http;
|
pub mod http;
|
||||||
pub mod mm;
|
pub mod mm;
|
||||||
pub mod net;
|
pub mod net;
|
||||||
|
|||||||
@@ -0,0 +1,288 @@
|
|||||||
|
//! `gateway.sock` from `gatewayd`'s side: the socket's permissions, and one connection's frames,
|
||||||
|
//! with the test playing both the serve loop and `brokerd` (M4b spec, sections 4 and 6). Do not
|
||||||
|
//! edit.
|
||||||
|
|
||||||
|
#[path = "support/tmp.rs"]
|
||||||
|
mod tmp;
|
||||||
|
|
||||||
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
use std::os::unix::net::UnixStream;
|
||||||
|
use std::path::Path;
|
||||||
|
use std::sync::mpsc::{self, Receiver};
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use gatewayd::gateway::{Ending, Timing, ToLoop, listen, serve};
|
||||||
|
use proto::{
|
||||||
|
ApprovalOutcome, ApprovalReply, ApprovalRequest, CallId, DataClass, Envelope, ErrorCode,
|
||||||
|
GatewayOutcome, Message, PROTOCOL_VERSION, PendingApproval, SessionId, Timestamp, read_frame,
|
||||||
|
write_frame,
|
||||||
|
};
|
||||||
|
use tmp::TempDir;
|
||||||
|
|
||||||
|
const KYLE: &str = "k0000000000000000000000000";
|
||||||
|
const POST: &str = "p0000000000000000000000001";
|
||||||
|
const CMD: &str = "c0000000000000000000000001";
|
||||||
|
const WAIT: Duration = Duration::from_secs(5);
|
||||||
|
|
||||||
|
fn info(approval: u64) -> PendingApproval {
|
||||||
|
PendingApproval {
|
||||||
|
approval,
|
||||||
|
session: SessionId::new("mm-r0000000000000000000000001").unwrap(),
|
||||||
|
call: CallId(3),
|
||||||
|
tool: "shell".to_string(),
|
||||||
|
arguments: "{}".to_string(),
|
||||||
|
grant: "g".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 frame(id: u64, last: bool, msg: Message) -> Envelope {
|
||||||
|
Envelope {
|
||||||
|
v: PROTOCOL_VERSION,
|
||||||
|
id,
|
||||||
|
r#final: last,
|
||||||
|
msg,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn timing() -> Timing {
|
||||||
|
Timing {
|
||||||
|
first_frame: Duration::from_millis(500),
|
||||||
|
posting: WAIT,
|
||||||
|
outcome: Duration::from_millis(500),
|
||||||
|
tick: Duration::from_millis(20),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A listening gatewayd side: the socket and what it asks of the loop.
|
||||||
|
fn start(
|
||||||
|
dir: &TempDir,
|
||||||
|
) -> (
|
||||||
|
std::path::PathBuf,
|
||||||
|
Receiver<ToLoop>,
|
||||||
|
Arc<Mutex<Vec<String>>>,
|
||||||
|
) {
|
||||||
|
let socket = dir.path().join("run/gateway/gateway.sock");
|
||||||
|
let listener = listen(&socket).unwrap();
|
||||||
|
let (tx, rx) = mpsc::channel();
|
||||||
|
let lines = Arc::new(Mutex::new(Vec::new()));
|
||||||
|
let sink = Arc::clone(&lines);
|
||||||
|
serve(
|
||||||
|
listener,
|
||||||
|
tx,
|
||||||
|
timing(),
|
||||||
|
Arc::new(move |l: &str| sink.lock().unwrap().push(l.to_string())),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
(socket, rx, lines)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// brokerd: connect and send the request.
|
||||||
|
fn request(socket: &Path, approval: u64) -> UnixStream {
|
||||||
|
let mut s = UnixStream::connect(socket).unwrap();
|
||||||
|
s.set_read_timeout(Some(WAIT)).unwrap();
|
||||||
|
write_frame(
|
||||||
|
&mut s,
|
||||||
|
&frame(
|
||||||
|
1,
|
||||||
|
true,
|
||||||
|
Message::ApprovalRequest(ApprovalRequest {
|
||||||
|
approval: info(approval),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
s
|
||||||
|
}
|
||||||
|
|
||||||
|
fn reply(approval: u64) -> ApprovalReply {
|
||||||
|
ApprovalReply {
|
||||||
|
approval,
|
||||||
|
approved: true,
|
||||||
|
by: KYLE.to_string(),
|
||||||
|
post: CMD.to_string(),
|
||||||
|
reason: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_socket_is_private_and_a_stale_one_is_replaced() {
|
||||||
|
let dir = TempDir::new("gw-perms");
|
||||||
|
let socket = dir.path().join("run/gateway/gateway.sock");
|
||||||
|
drop(listen(&socket).unwrap());
|
||||||
|
let listener = listen(&socket).expect("a stale socket is replaced");
|
||||||
|
let mode = |p: &Path| std::fs::metadata(p).unwrap().permissions().mode() & 0o777;
|
||||||
|
assert_eq!(
|
||||||
|
(mode(&socket), mode(socket.parent().unwrap())),
|
||||||
|
(0o600, 0o700)
|
||||||
|
);
|
||||||
|
drop(listener);
|
||||||
|
let file = dir.path().join("run/gateway/not-a-socket");
|
||||||
|
std::fs::write(&file, "x").unwrap();
|
||||||
|
let why = listen(&file).unwrap_err();
|
||||||
|
assert!(
|
||||||
|
why.starts_with(&format!("cannot listen on {}", file.display())),
|
||||||
|
"{why}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
dir.path().join("run/gateway/not-a-socket").exists(),
|
||||||
|
"a file that is not a socket is left alone"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn posted_answered_and_ended() {
|
||||||
|
let dir = TempDir::new("gw-answer");
|
||||||
|
let (socket, to_loop, _) = start(&dir);
|
||||||
|
let mut brokerd = request(&socket, 42);
|
||||||
|
let (answer, shown) = match to_loop.recv_timeout(WAIT).unwrap() {
|
||||||
|
ToLoop::Show {
|
||||||
|
info: got,
|
||||||
|
answer,
|
||||||
|
shown,
|
||||||
|
} => {
|
||||||
|
assert_eq!(got, info(42));
|
||||||
|
(answer, shown)
|
||||||
|
}
|
||||||
|
other => panic!("{other:?}"),
|
||||||
|
};
|
||||||
|
shown.send(Ok(vec![POST.to_string()])).unwrap();
|
||||||
|
let posted = read_frame(&mut brokerd).unwrap();
|
||||||
|
assert_eq!((posted.id, posted.r#final), (1, false));
|
||||||
|
assert!(
|
||||||
|
matches!(posted.msg, Message::ApprovalPosted(ref p) if p.posts == [POST]),
|
||||||
|
"{posted:?}"
|
||||||
|
);
|
||||||
|
answer.send(reply(42)).unwrap();
|
||||||
|
let answered = read_frame(&mut brokerd).unwrap();
|
||||||
|
assert_eq!(answered, frame(1, true, Message::ApprovalReply(reply(42))));
|
||||||
|
write_frame(
|
||||||
|
&mut brokerd,
|
||||||
|
&frame(
|
||||||
|
2,
|
||||||
|
true,
|
||||||
|
Message::ApprovalOutcome(ApprovalOutcome {
|
||||||
|
outcome: GatewayOutcome::Runs {},
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
match to_loop.recv_timeout(WAIT).unwrap() {
|
||||||
|
ToLoop::Ended { approval, ending } => {
|
||||||
|
assert_eq!(approval, 42);
|
||||||
|
assert_eq!(
|
||||||
|
ending,
|
||||||
|
Ending::Answered {
|
||||||
|
by: KYLE.to_string(),
|
||||||
|
reason: None,
|
||||||
|
outcome: GatewayOutcome::Runs {}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
other => panic!("{other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn brokerd_closing_first_ends_it_as_no_longer_pending() {
|
||||||
|
let dir = TempDir::new("gw-closed");
|
||||||
|
let (socket, to_loop, _) = start(&dir);
|
||||||
|
let mut brokerd = request(&socket, 7);
|
||||||
|
let ToLoop::Show { shown, answer, .. } = to_loop.recv_timeout(WAIT).unwrap() else {
|
||||||
|
panic!()
|
||||||
|
};
|
||||||
|
shown.send(Ok(vec![POST.to_string()])).unwrap();
|
||||||
|
read_frame(&mut brokerd).unwrap();
|
||||||
|
drop(brokerd);
|
||||||
|
match to_loop.recv_timeout(WAIT).unwrap() {
|
||||||
|
ToLoop::Ended { approval, ending } => assert_eq!((approval, ending), (7, Ending::NoLonger)),
|
||||||
|
other => panic!("{other:?}"),
|
||||||
|
}
|
||||||
|
assert!(
|
||||||
|
answer.send(reply(7)).is_err(),
|
||||||
|
"nobody listens for an answer any more"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_approval_that_cannot_be_posted_is_an_error_to_brokerd() {
|
||||||
|
let dir = TempDir::new("gw-cannot");
|
||||||
|
let (socket, to_loop, _) = start(&dir);
|
||||||
|
let mut brokerd = request(&socket, 8);
|
||||||
|
let ToLoop::Show { shown, .. } = to_loop.recv_timeout(WAIT).unwrap() else {
|
||||||
|
panic!()
|
||||||
|
};
|
||||||
|
shown.send(Err("Mattermost is down".to_string())).unwrap();
|
||||||
|
let got = read_frame(&mut brokerd).unwrap();
|
||||||
|
assert_eq!((got.id, got.r#final), (1, true));
|
||||||
|
match got.msg {
|
||||||
|
Message::Error(e) => assert_eq!(
|
||||||
|
(e.code, e.detail.as_str()),
|
||||||
|
(ErrorCode::Internal, "Mattermost is down")
|
||||||
|
),
|
||||||
|
other => panic!("{other:?}"),
|
||||||
|
}
|
||||||
|
assert!(
|
||||||
|
to_loop.recv_timeout(Duration::from_millis(300)).is_err(),
|
||||||
|
"nothing more for the loop"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn no_outcome_after_the_answer_is_no_longer_pending_and_logged() {
|
||||||
|
let dir = TempDir::new("gw-silent");
|
||||||
|
let (socket, to_loop, lines) = start(&dir);
|
||||||
|
let mut brokerd = request(&socket, 9);
|
||||||
|
let ToLoop::Show { shown, answer, .. } = to_loop.recv_timeout(WAIT).unwrap() else {
|
||||||
|
panic!()
|
||||||
|
};
|
||||||
|
shown.send(Ok(vec![POST.to_string()])).unwrap();
|
||||||
|
read_frame(&mut brokerd).unwrap();
|
||||||
|
answer.send(reply(9)).unwrap();
|
||||||
|
read_frame(&mut brokerd).unwrap();
|
||||||
|
match to_loop.recv_timeout(WAIT).unwrap() {
|
||||||
|
ToLoop::Ended { approval, ending } => assert_eq!((approval, ending), (9, Ending::NoLonger)),
|
||||||
|
other => panic!("{other:?}"),
|
||||||
|
}
|
||||||
|
assert_eq!(
|
||||||
|
*lines.lock().unwrap(),
|
||||||
|
["gatewayd: approval 9: brokerd said nothing after the answer"]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn anything_but_a_request_first_is_dropped() {
|
||||||
|
let dir = TempDir::new("gw-bad");
|
||||||
|
let (socket, to_loop, _) = start(&dir);
|
||||||
|
for first in [
|
||||||
|
frame(1, true, Message::ApprovalReply(reply(1))),
|
||||||
|
frame(
|
||||||
|
2,
|
||||||
|
true,
|
||||||
|
Message::ApprovalRequest(ApprovalRequest { approval: info(1) }),
|
||||||
|
),
|
||||||
|
frame(
|
||||||
|
1,
|
||||||
|
false,
|
||||||
|
Message::ApprovalRequest(ApprovalRequest { approval: info(1) }),
|
||||||
|
),
|
||||||
|
] {
|
||||||
|
let mut s = UnixStream::connect(&socket).unwrap();
|
||||||
|
s.set_read_timeout(Some(WAIT)).unwrap();
|
||||||
|
write_frame(&mut s, &first).unwrap();
|
||||||
|
assert!(
|
||||||
|
read_frame(&mut s).is_err(),
|
||||||
|
"closed without an answer: {first:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let silent = UnixStream::connect(&socket).unwrap();
|
||||||
|
std::thread::sleep(Duration::from_millis(700));
|
||||||
|
drop(silent);
|
||||||
|
assert!(
|
||||||
|
to_loop.recv_timeout(Duration::from_millis(300)).is_err(),
|
||||||
|
"nothing reached the loop"
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ reviewer adds findings under "Reviews" once per milestone.
|
|||||||
|
|
||||||
| Task | Date | Status | Gate runs | First gate | Deviations | Notes | Model |
|
| Task | Date | Status | Gate runs | First gate | Deviations | Notes | Model |
|
||||||
|---|---|---|---|---|---|---|---|
|
|---|---|---|---|---|---|---|---|
|
||||||
|
| M4b/13-gatewayd-gateway-sock | 2026-09-24 | done | 1 | pass | none | Copied `docs/plans/M4b/files/crates/gatewayd/src/gateway.rs` to `crates/gatewayd/src/` and `docs/plans/M4b/files/crates/gatewayd/tests/gateway_sock.rs` to `crates/gatewayd/tests/`, then added `pub mod gateway;` to `lib.rs` after `pub mod deliver;`. Filled the four `todo!()`s the skeleton left in `gateway.rs`: `end` (send `ToLoop::Ended { approval, ending }` to the loop, the only thing that touches the loop); `frames` (`try_clone` the stream, spawn a reader thread that hands whole frames over an mpsc channel so the connection waits with a timeout without losing half a frame, `Ok(rx)` — the same as brokerd's courier); `listen` (the parent dir made 0700 via a recursive `DirBuilder` then `set_permissions`, a stale socket removed, anything else left in place as "cannot listen on <socket>: something else is there", bind, then the socket made 0600, with the spec's error strings and paths via `.display()`); and `serve` (spawn an accept loop that skips `incoming()` errors and starts a `connection` thread per stream, logging a spawn failure as "gatewayd: cannot start a thread for an approval: <e>", keeping a separate `log` clone for that message). The skeleton's `ToLoop` was already complete with `Ended`, so it needed no change. All six `gateway_sock` tests pass three times running in ~1 s; `make gate` prints `gate: ok` on the first run. | ? |
|
||||||
| M4b/12-gatewayd-state-approvals | 2026-09-24 | done | 1 | pass | none | Copied `docs/plans/M4b/files/crates/gatewayd/tests/state_approvals.rs` into `crates/gatewayd/tests/`. In `state.rs`: added the `#[serde(default)] approvals: Vec<ApprovalPosts>` field after `in_flight` (optional so an M4a state file loads unchanged) and the `ApprovalPosts { approval, channel, posts }` struct (`#[serde(deny_unknown_fields)`) after `struct StateFile`; in `problem` the `in_flight` loop now has a loop over `approvals` requiring a `valid_id` channel and at least one `valid_id` post, else "approval {}'s posts are not valid"; added `turn_of` (find the `in_flight` entry of a session, cloned), `add_approval` (replace any entry of the same approval, push, save), `remove_approval` (retain by approval, save only when one was removed) and `take_approvals` (`std::mem::take`, save only when there were some, return them) at the end of `impl State`. `cargo fmt --all` then `cargo test -p gatewayd --test state_approvals --test state --test serve_restart` green (4, 4 and 7 passed); `make gate` prints `gate: ok` on the first run. | ? |
|
| M4b/12-gatewayd-state-approvals | 2026-09-24 | done | 1 | pass | none | Copied `docs/plans/M4b/files/crates/gatewayd/tests/state_approvals.rs` into `crates/gatewayd/tests/`. In `state.rs`: added the `#[serde(default)] approvals: Vec<ApprovalPosts>` field after `in_flight` (optional so an M4a state file loads unchanged) and the `ApprovalPosts { approval, channel, posts }` struct (`#[serde(deny_unknown_fields)`) after `struct StateFile`; in `problem` the `in_flight` loop now has a loop over `approvals` requiring a `valid_id` channel and at least one `valid_id` post, else "approval {}'s posts are not valid"; added `turn_of` (find the `in_flight` entry of a session, cloned), `add_approval` (replace any entry of the same approval, push, save), `remove_approval` (retain by approval, save only when one was removed) and `take_approvals` (`std::mem::take`, save only when there were some, return them) at the end of `impl State`. `cargo fmt --all` then `cargo test -p gatewayd --test state_approvals --test state --test serve_restart` green (4, 4 and 7 passed); `make gate` prints `gate: ok` on the first run. | ? |
|
||||||
| M4b/11-gatewayd-approvals-registry | 2026-09-24 | done | 1 | pass | none | Copied `docs/plans/M4b/files/crates/gatewayd/src/approvals/mod.rs` (the structs `Posted`/`Judged`/`Registry`, the `pub mod text;` still there, and the five `todo!()`s) to `crates/gatewayd/src/approvals/`, and `docs/plans/M4b/files/crates/gatewayd/tests/approvals_judge.rs` to `crates/gatewayd/tests/`. Filled `add` (insert by `approval`), `remove` (remove by id), `holds` (contains_key), `waiting_sessions` (the `session` of every entry), and `judge` (the six spec-section-7 checks in order: `user` in `approvers` else `NOT_APPROVER`, the command to `(id, approved, reason)` — `Approve` true/None, `Deny` false/`reason.clone()`, `Malformed` `USAGE`, then `get(&id)` present else "not pending here", `answerable` else "bxctl approvals", `root` matches else "its own thread", `at <= shown_at` else "answered before shown in full", then `remove` and `send` the `ApprovalReply`, a gone connection → "not pending here"). Brought `Approve`/`Deny`/`Malformed` into scope with the `ApprovalCommand` type, and cloned the `answer` `Sender` out of the entry before the early-return refusals so the held lock's `remove` does not conflict. All six `approvals_judge` and six `approvals_text` tests pass; `make gate` prints `gate: ok` on the first run. | ? |
|
| M4b/11-gatewayd-approvals-registry | 2026-09-24 | done | 1 | pass | none | Copied `docs/plans/M4b/files/crates/gatewayd/src/approvals/mod.rs` (the structs `Posted`/`Judged`/`Registry`, the `pub mod text;` still there, and the five `todo!()`s) to `crates/gatewayd/src/approvals/`, and `docs/plans/M4b/files/crates/gatewayd/tests/approvals_judge.rs` to `crates/gatewayd/tests/`. Filled `add` (insert by `approval`), `remove` (remove by id), `holds` (contains_key), `waiting_sessions` (the `session` of every entry), and `judge` (the six spec-section-7 checks in order: `user` in `approvers` else `NOT_APPROVER`, the command to `(id, approved, reason)` — `Approve` true/None, `Deny` false/`reason.clone()`, `Malformed` `USAGE`, then `get(&id)` present else "not pending here", `answerable` else "bxctl approvals", `root` matches else "its own thread", `at <= shown_at` else "answered before shown in full", then `remove` and `send` the `ApprovalReply`, a gone connection → "not pending here"). Brought `Approve`/`Deny`/`Malformed` into scope with the `ApprovalCommand` type, and cloned the `answer` `Sender` out of the entry before the early-return refusals so the held lock's `remove` does not conflict. All six `approvals_judge` and six `approvals_text` tests pass; `make gate` prints `gate: ok` on the first run. | ? |
|
||||||
| 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/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. | ? |
|
||||||
|
|||||||
Reference in New Issue
Block a user