The control channel read each request with no length cap, so a client sending gigabytes without a newline made the server thread allocate until the process was killed. Read lines against the 16 MiB cap the MCP server already uses, now one shared constant, answer a longer one with an error and close; close a connection after eight lines that are not JSON, serve at most sixteen connections at once, and run at most sixty-four queued requests per frame so a flood cannot starve drawing. Every file command checked the size with the metadata and then read to the end. A character device such as /dev/zero reports a length of 0, so the cap passed and the read was unbounded; a FIFO blocked the calling thread, which over the control channel is the thread drawing the window. vfs::read now takes the caller's cap, refuses anything that is not a regular file before opening it, re-checks on the handle to close the window between the two, and reads no more than the cap plus the byte that proves it was exceeded. Callers pass the limits they already had, and the recovery read and the CLI's own reads go through it too. `solvecraft-cli eval /dev/zero` and a FIFO now fail at once instead of never returning. Fixes #147 Co-Authored-By: Claude Opus 5 <[email protected]> (cherry picked from commit 4156e8acd11c90c435a54a3b0ccc59991db71b12)
171 lines
6.5 KiB
Rust
171 lines
6.5 KiB
Rust
//! Loopback JSON-lines control server: one request per line, one reply per line.
|
|
//! Agents and tests drive the app through it (adapted from CADCraft, MIT OR Apache-2.0).
|
|
|
|
use std::io::{BufRead, BufReader, Write};
|
|
use std::net::{TcpListener, TcpStream};
|
|
use std::sync::atomic::{AtomicUsize, Ordering};
|
|
use std::sync::mpsc::{Receiver, Sender, channel};
|
|
use std::time::Duration;
|
|
|
|
use serde_json::{Value, json};
|
|
use solvecraft_engine::MAX_LINE;
|
|
use solvecraft_ui_egui::ControlRequest;
|
|
|
|
/// Connections served at once. The channel has no authentication beyond being bound to
|
|
/// loopback, so a flood must not be able to spawn threads without end.
|
|
const MAX_CONNECTIONS: usize = 16;
|
|
/// Lines that are not JSON before the connection is closed, so garbage cannot loop forever.
|
|
const MAX_BAD_LINES: usize = 8;
|
|
|
|
static OPEN: AtomicUsize = AtomicUsize::new(0);
|
|
|
|
pub fn start(port: u16, ctx: egui::Context) -> Receiver<ControlRequest> {
|
|
let (tx, rx) = channel::<ControlRequest>();
|
|
let listener = match TcpListener::bind(("127.0.0.1", port)) {
|
|
Ok(l) => l,
|
|
Err(e) => {
|
|
eprintln!("solvecraft: control server failed to bind 127.0.0.1:{port}: {e}");
|
|
return rx;
|
|
}
|
|
};
|
|
eprintln!("solvecraft: control server listening on 127.0.0.1:{port}");
|
|
std::thread::spawn(move || {
|
|
for stream in listener.incoming().flatten() {
|
|
if OPEN.fetch_add(1, Ordering::Relaxed) >= MAX_CONNECTIONS {
|
|
OPEN.fetch_sub(1, Ordering::Relaxed);
|
|
let mut refused = stream;
|
|
let _ = writeln!(refused, "{}", json!({"ok": false, "error": "too many connections"}));
|
|
continue;
|
|
}
|
|
let tx = tx.clone();
|
|
let ctx = ctx.clone();
|
|
std::thread::spawn(move || {
|
|
serve(stream, tx, ctx);
|
|
OPEN.fetch_sub(1, Ordering::Relaxed);
|
|
});
|
|
}
|
|
});
|
|
rx
|
|
}
|
|
|
|
/// One line without its newline, or `None` at the end of the stream. A client that sends
|
|
/// gigabytes with no newline would otherwise make this thread allocate until the process is
|
|
/// killed, so the line is refused as soon as it passes `max`.
|
|
fn read_line(r: &mut impl BufRead, max: usize) -> std::io::Result<Option<Vec<u8>>> {
|
|
let mut line = Vec::new();
|
|
loop {
|
|
let (done, used) = {
|
|
let available = r.fill_buf()?;
|
|
if available.is_empty() {
|
|
return Ok((!line.is_empty()).then_some(line));
|
|
}
|
|
match available.iter().position(|b| *b == b'\n') {
|
|
Some(i) => {
|
|
line.extend_from_slice(available.get(..i).unwrap_or(&[]));
|
|
(true, i + 1)
|
|
}
|
|
None => {
|
|
line.extend_from_slice(available);
|
|
(false, available.len())
|
|
}
|
|
}
|
|
};
|
|
r.consume(used);
|
|
if done {
|
|
return Ok(Some(line));
|
|
}
|
|
if line.len() > max {
|
|
return Err(std::io::Error::new(std::io::ErrorKind::InvalidData, "request too large"));
|
|
}
|
|
}
|
|
}
|
|
|
|
fn serve(stream: TcpStream, tx: Sender<ControlRequest>, ctx: egui::Context) {
|
|
let Ok(read) = stream.try_clone() else { return };
|
|
let mut out = stream;
|
|
let mut reader = BufReader::new(read);
|
|
let mut bad = 0usize;
|
|
loop {
|
|
let line = match read_line(&mut reader, MAX_LINE) {
|
|
Ok(Some(l)) => String::from_utf8_lossy(&l).into_owned(),
|
|
Ok(None) => break,
|
|
Err(e) => {
|
|
let _ = writeln!(out, "{}", json!({"ok": false, "error": e.to_string()}));
|
|
break;
|
|
}
|
|
};
|
|
if line.trim().is_empty() {
|
|
continue;
|
|
}
|
|
let reply = match serde_json::from_str::<Value>(&line) {
|
|
Ok(msg) => {
|
|
let id = msg.get("id").cloned().unwrap_or(Value::Null);
|
|
let method = msg.get("method").and_then(Value::as_str).unwrap_or("").to_string();
|
|
let params = msg.get("params").cloned().unwrap_or(json!({}));
|
|
let (req, rrx) = ControlRequest::new(method, params);
|
|
if tx.send(req).is_err() {
|
|
break;
|
|
}
|
|
ctx.request_repaint();
|
|
let mut r = rrx.recv_timeout(Duration::from_secs(60)).unwrap_or_else(|_| json!({"ok": false, "error": "timeout"}));
|
|
if let Some(o) = r.as_object_mut() {
|
|
o.insert("id".into(), id);
|
|
}
|
|
r
|
|
}
|
|
Err(e) => {
|
|
bad += 1;
|
|
json!({"ok": false, "error": format!("bad JSON: {e}")})
|
|
}
|
|
};
|
|
if writeln!(out, "{reply}").is_err() {
|
|
break;
|
|
}
|
|
if bad >= MAX_BAD_LINES {
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
/// A request longer than the cap is refused instead of allocated.
|
|
#[test]
|
|
fn lines_past_the_cap_are_refused() {
|
|
let flood = vec![b'A'; MAX_LINE + 2];
|
|
let mut r = BufReader::new(flood.as_slice());
|
|
let e = read_line(&mut r, MAX_LINE).unwrap_err();
|
|
assert_eq!(e.kind(), std::io::ErrorKind::InvalidData);
|
|
assert!(e.to_string().contains("request too large"), "{e}");
|
|
}
|
|
|
|
/// Ordinary framing: lines split on newlines, blank lines kept, no trailing newline needed.
|
|
#[test]
|
|
fn lines_split_on_newlines() {
|
|
let text = b"{\"a\":1}\n\n{\"b\":2}\nlast".to_vec();
|
|
let mut r = BufReader::new(text.as_slice());
|
|
let mut got = Vec::new();
|
|
while let Some(line) = read_line(&mut r, MAX_LINE).unwrap() {
|
|
got.push(String::from_utf8(line).unwrap());
|
|
}
|
|
assert_eq!(got, vec!["{\"a\":1}", "", "{\"b\":2}", "last"]);
|
|
assert!(read_line(&mut r, MAX_LINE).unwrap().is_none());
|
|
}
|
|
|
|
/// A line that spans several reads of the underlying stream still arrives whole.
|
|
#[test]
|
|
fn lines_longer_than_one_buffer_arrive_whole() {
|
|
let long = "x".repeat(200_000);
|
|
let text = format!("{long}\n{long}\n").into_bytes();
|
|
let mut r = BufReader::with_capacity(64, text.as_slice());
|
|
assert_eq!(read_line(&mut r, MAX_LINE).unwrap().unwrap().len(), long.len());
|
|
assert_eq!(read_line(&mut r, MAX_LINE).unwrap().unwrap().len(), long.len());
|
|
assert!(read_line(&mut r, MAX_LINE).unwrap().is_none());
|
|
// The same line against a smaller cap is refused.
|
|
let mut r = BufReader::with_capacity(64, text.as_slice());
|
|
assert!(read_line(&mut r, 1000).is_err());
|
|
}
|
|
}
|