fix(io): cap line reads on the serve and MCP transports

Bug:
The --serve automation loop (stdin/socket) and the MCP stdio loop read
input with unbounded read_line/lines. A single line over 64 MiB - a
huge paste or a hostile client - grows the buffer until the allocator
aborts, the same class of failure as the HTTP path, on the non-HTTP
transports.

Reproduce:
1. Start --serve on a socket (or the MCP server on stdio).
2. Send one 65 MiB line with no terminating newline anywhere in it.
3. Before: RSS grows to the full line, then "memory allocation failed"
   (or a multi-second stall while the line is copied).
4. After: the read fails at the cap and the existing EOF arm closes the
   connection/stdio promptly.

Fix:
New shared src/io/line_read.rs module: lines_capped(reader, 64 MiB)
yields Err for an over-cap line, which the serve and MCP loops already
treat as EOF/close. rest.rs was refactored onto the same helpers so the
cap logic lives in one place. A regression test streams a 65 MiB line
at the serve socket and asserts the connection ends without a response.
This commit is contained in:
Karim Jerbi committed 2026-09-30 19:15:49 +01:00
1 parent 6c3c266475
commit be8a7b95c4
5 files changed
+197 -49

No files matched your search

+3 -41
View File
@@ -23,8 +23,9 @@
//! so both `--http` modes serve the identical surface.
use crate::app::OpenCADStudio;
use crate::io::line_read::{is_line_too_long, read_line_capped};
use serde_json::{json, Value};
use std::io::{BufRead, BufReader, Read, Write};
use std::io::{BufReader, Read, Write};
use std::net::{TcpListener, TcpStream};
use std::sync::atomic::{AtomicU16, AtomicU64, AtomicUsize, Ordering};
use std::sync::Arc;
@@ -206,46 +207,6 @@ fn serve_connection(mut stream: TcpStream, jobs: std::sync::mpsc::Sender<Job>) {
}
}
/// Marker for a line that exceeded its size cap. Kept as a distinct error
/// payload so the parse path can answer `431` for it while transport errors
/// (reset, timeout) and invalid UTF-8 keep their existing silent-close path.
#[derive(Debug)]
struct LineTooLong;
impl std::fmt::Display for LineTooLong {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("line exceeds the size limit")
}
}
impl std::error::Error for LineTooLong {}
fn is_line_too_long(error: &std::io::Error) -> bool {
error
.get_ref()
.map_or(false, |inner| inner.is::<LineTooLong>())
}
/// `BufRead::read_line` capped at `cap` bytes — the whole line including its
/// newline. The underlying read stops at `cap + 1` bytes, so memory stays
/// bounded no matter how much the peer streams; an over-cap line fails with
/// [`LineTooLong`] instead of growing `buf` forever.
fn read_line_capped(
reader: &mut impl BufRead,
buf: &mut String,
cap: usize,
) -> std::io::Result<usize> {
buf.clear();
let read = reader.by_ref().take(cap as u64 + 1).read_line(buf)?;
if read > cap {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidData,
LineTooLong,
));
}
Ok(read)
}
/// Answer a peer whose request line or header block blew a size cap — RFC
/// 9110's `431` — then hand the caller `Ok(None)` so it closes without
/// dispatching. Writing the refusal from here keeps the headless `--http`
@@ -764,6 +725,7 @@ pub(crate) fn map_status(response: Value, created: bool) -> u16 {
mod tests {
use super::*;
use crate::app::OpenCADStudio;
use std::io::BufRead;
fn request(method: &str, path: &str, body: &str) -> HttpRequest {
HttpRequest {