Skip to main content

scema_daemon/
http.rs

1//! A minimal HTTP/1.1 server, on `std` and nothing else.
2//!
3//! ## Why hand-rolled rather than axum
4//!
5//! Two reasons, and the second is the real one.
6//!
7//! The first is consistency: `scema-world` takes two dependencies because it is the wire
8//! format a reimplementer has to match, and `alchem-link` ships a whole terminal toolkit on
9//! the standard library for the same reason. A loopback JSON server for a known client is
10//! squarely in that class.
11//!
12//! The second is that pulling a full async HTTP stack into this workspace would pull
13//! `hyper` → `rustls`/`tokio`, and the moment omni carries a TLS stack somebody will try to
14//! path-depend it from the bot workspace and rediscover the `zeroize`/`curve25519-dalek`
15//! conflict the root `Cargo.toml` documents at length. A server that speaks `Content-Length`
16//! HTTP/1.1 to a client on the same machine does not need any of it.
17//!
18//! ## What this deliberately does not implement
19//!
20//! Chunked transfer encoding, keep-alive, pipelining, compression, TLS, HTTP/2. Every
21//! response closes the connection. Anything a browser or `curl` sends to a localhost JSON
22//! API works; a general-purpose server this is not, and it must never be exposed to a
23//! network — see [`crate::routes`] for the bind rule.
24//!
25//! ## Limits are enforced, not assumed
26//!
27//! [`MAX_HEADER_BYTES`] and [`MAX_BODY_BYTES`] are checked while reading, not after. An
28//! unbounded read from a socket is a memory exhaustion bug that looks like a hang, and the
29//! client here can be any local process.
30
31use std::collections::BTreeMap;
32use std::io::{BufRead, BufReader, Read, Write};
33use std::net::{IpAddr, Ipv4Addr, SocketAddr, TcpListener, TcpStream};
34use std::sync::atomic::{AtomicUsize, Ordering};
35use std::sync::Arc;
36use std::time::Duration;
37
38/// Request line plus headers may not exceed this.
39pub const MAX_HEADER_BYTES: usize = 16 * 1024;
40/// Body cap. A `WorldState` from a large page is the biggest thing this carries.
41pub const MAX_BODY_BYTES: usize = 8 * 1024 * 1024;
42/// Concurrent connections. Beyond this, new connections are accepted and immediately
43/// answered `503` rather than queued — an agent daemon that stops responding under load is
44/// worse than one that says it is busy.
45pub const MAX_CONNECTIONS: usize = 32;
46/// Per-connection read/write timeout. A client that opens a socket and says nothing must
47/// not hold a slot forever.
48pub const IO_TIMEOUT: Duration = Duration::from_secs(15);
49
50#[derive(Debug, Clone)]
51pub struct Request {
52    pub method: String,
53    /// Path only, query stripped and decoded into [`Request::query`].
54    pub path: String,
55    pub query: BTreeMap<String, String>,
56    /// Header names lowercased, so lookup does not depend on what the client capitalised.
57    pub headers: BTreeMap<String, String>,
58    pub body: Vec<u8>,
59}
60
61impl Request {
62    pub fn header(&self, name: &str) -> Option<&str> {
63        self.headers.get(&name.to_lowercase()).map(|s| s.as_str())
64    }
65
66    /// Path segments, empty ones dropped: `/decisions/abc/verify` → `["decisions", "abc", "verify"]`.
67    pub fn segments(&self) -> Vec<&str> {
68        self.path.split('/').filter(|s| !s.is_empty()).collect()
69    }
70}
71
72#[derive(Debug, Clone)]
73pub struct Response {
74    pub status: u16,
75    pub content_type: String,
76    pub body: Vec<u8>,
77    /// Extra headers, sent verbatim.
78    pub extra: Vec<(String, String)>,
79}
80
81impl Response {
82    pub fn json(status: u16, body: impl Into<Vec<u8>>) -> Self {
83        Response {
84            status,
85            content_type: "application/json; charset=utf-8".into(),
86            body: body.into(),
87            extra: vec![],
88        }
89    }
90
91    pub fn text(status: u16, body: impl Into<String>) -> Self {
92        Response {
93            status,
94            content_type: "text/plain; charset=utf-8".into(),
95            body: body.into().into_bytes(),
96            extra: vec![],
97        }
98    }
99
100    /// A JSON error with a stable shape, so a client never has to parse prose.
101    pub fn error(status: u16, code: &str, message: impl std::fmt::Display) -> Self {
102        let body = serde_json::json!({ "error": code, "message": message.to_string() });
103        Response::json(status, body.to_string())
104    }
105}
106
107fn reason(status: u16) -> &'static str {
108    match status {
109        200 => "OK",
110        400 => "Bad Request",
111        401 => "Unauthorized",
112        403 => "Forbidden",
113        404 => "Not Found",
114        405 => "Method Not Allowed",
115        413 => "Payload Too Large",
116        421 => "Misdirected Request",
117        500 => "Internal Server Error",
118        503 => "Service Unavailable",
119        _ => "Unknown",
120    }
121}
122
123fn percent_decode(s: &str) -> String {
124    let bytes = s.as_bytes();
125    let mut out = Vec::with_capacity(bytes.len());
126    let mut i = 0;
127    while i < bytes.len() {
128        match bytes[i] {
129            b'%' if i + 2 < bytes.len() => {
130                let hex = std::str::from_utf8(&bytes[i + 1..i + 3]).unwrap_or("");
131                match u8::from_str_radix(hex, 16) {
132                    Ok(b) => {
133                        out.push(b);
134                        i += 3;
135                    }
136                    // A malformed escape is kept literally rather than dropped. Dropping it
137                    // would silently change the path being requested.
138                    Err(_) => {
139                        out.push(bytes[i]);
140                        i += 1;
141                    }
142                }
143            }
144            b'+' => {
145                out.push(b' ');
146                i += 1;
147            }
148            b => {
149                out.push(b);
150                i += 1;
151            }
152        }
153    }
154    String::from_utf8_lossy(&out).into_owned()
155}
156
157fn parse_query(raw: &str) -> BTreeMap<String, String> {
158    raw.split('&')
159        .filter(|p| !p.is_empty())
160        .map(|p| match p.split_once('=') {
161            Some((k, v)) => (percent_decode(k), percent_decode(v)),
162            None => (percent_decode(p), String::new()),
163        })
164        .collect()
165}
166
167/// Read one request. `Ok(None)` means the peer closed without sending anything.
168fn read_request(stream: &mut BufReader<&TcpStream>) -> Result<Option<Request>, Response> {
169    let mut head = Vec::new();
170    let mut line = Vec::new();
171
172    loop {
173        line.clear();
174        let n = stream
175            .read_until(b'\n', &mut line)
176            .map_err(|e| Response::error(400, "read_failed", e))?;
177        if n == 0 {
178            if head.is_empty() {
179                return Ok(None);
180            }
181            return Err(Response::error(400, "truncated", "connection closed mid-headers"));
182        }
183        head.extend_from_slice(&line);
184        if head.len() > MAX_HEADER_BYTES {
185            return Err(Response::error(
186                413,
187                "headers_too_large",
188                format!("request head exceeds {MAX_HEADER_BYTES} bytes"),
189            ));
190        }
191        // Blank line terminates the head.
192        if line == b"\r\n" || line == b"\n" {
193            break;
194        }
195    }
196
197    let text = String::from_utf8_lossy(&head).into_owned();
198    let mut lines = text.lines();
199    let request_line = lines
200        .next()
201        .ok_or_else(|| Response::error(400, "empty_request", "no request line"))?;
202    let mut parts = request_line.split_whitespace();
203    let method = parts
204        .next()
205        .ok_or_else(|| Response::error(400, "bad_request_line", request_line))?
206        .to_string();
207    let target = parts
208        .next()
209        .ok_or_else(|| Response::error(400, "bad_request_line", request_line))?;
210
211    let (raw_path, raw_query) = match target.split_once('?') {
212        Some((p, q)) => (p, q),
213        None => (target, ""),
214    };
215
216    let mut headers = BTreeMap::new();
217    for l in lines {
218        if l.is_empty() {
219            continue;
220        }
221        if let Some((k, v)) = l.split_once(':') {
222            headers.insert(k.trim().to_lowercase(), v.trim().to_string());
223        }
224    }
225
226    let len: usize = headers
227        .get("content-length")
228        .and_then(|v| v.parse().ok())
229        .unwrap_or(0);
230    if len > MAX_BODY_BYTES {
231        return Err(Response::error(
232            413,
233            "body_too_large",
234            format!("{len} bytes exceeds the {MAX_BODY_BYTES} byte limit"),
235        ));
236    }
237    let mut body = vec![0u8; len];
238    if len > 0 {
239        stream
240            .read_exact(&mut body)
241            .map_err(|e| Response::error(400, "body_read_failed", e))?;
242    }
243
244    Ok(Some(Request {
245        method,
246        path: percent_decode(raw_path),
247        query: parse_query(raw_query),
248        headers,
249        body,
250    }))
251}
252
253fn write_response(stream: &mut TcpStream, r: &Response) -> std::io::Result<()> {
254    let mut head = format!(
255        "HTTP/1.1 {} {}\r\nContent-Type: {}\r\nContent-Length: {}\r\nConnection: close\r\n",
256        r.status,
257        reason(r.status),
258        r.content_type,
259        r.body.len()
260    );
261    // No `Access-Control-Allow-Origin`, ever, and no `OPTIONS` handler. See the note in
262    // `crate::routes`: the browser extension reaches this from its service worker with
263    // host permissions, which is not subject to CORS, so a web page gets no way to read a
264    // response even if it manages to send a request.
265    head.push_str("X-Content-Type-Options: nosniff\r\n");
266    for (k, v) in &r.extra {
267        head.push_str(&format!("{k}: {v}\r\n"));
268    }
269    head.push_str("\r\n");
270    stream.write_all(head.as_bytes())?;
271    stream.write_all(&r.body)?;
272    stream.flush()
273}
274
275/// The bind address. **Loopback only, and not configurable.**
276///
277/// This process reads the operator's filesystem and answers questions about it. Binding it
278/// to anything routable turns a local tool into an unauthenticated file-disclosure service
279/// on the network, and the one thing that reliably happens to a `--bind` flag is that
280/// somebody sets it to `0.0.0.0` to reach it from another machine. The port is
281/// configurable; the interface is not.
282pub fn loopback(port: u16) -> SocketAddr {
283    SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), port)
284}
285
286/// Serve until the process is killed.
287///
288/// `handler` runs on a worker thread per connection and must not panic; a panic is caught
289/// and answered `500` so one bad request cannot take the daemon down.
290pub fn serve<H>(listener: TcpListener, handler: H) -> std::io::Result<()>
291where
292    H: Fn(Request) -> Response + Send + Sync + 'static,
293{
294    let handler = Arc::new(handler);
295    let live = Arc::new(AtomicUsize::new(0));
296
297    for incoming in listener.incoming() {
298        let mut stream = match incoming {
299            Ok(s) => s,
300            Err(_) => continue,
301        };
302        let _ = stream.set_read_timeout(Some(IO_TIMEOUT));
303        let _ = stream.set_write_timeout(Some(IO_TIMEOUT));
304
305        if live.load(Ordering::SeqCst) >= MAX_CONNECTIONS {
306            let _ = write_response(
307                &mut stream,
308                &Response::error(503, "busy", "too many concurrent connections"),
309            );
310            continue;
311        }
312
313        let handler = Arc::clone(&handler);
314        let live = Arc::clone(&live);
315        live.fetch_add(1, Ordering::SeqCst);
316        std::thread::spawn(move || {
317            handle_connection(&mut stream, handler.as_ref());
318            live.fetch_sub(1, Ordering::SeqCst);
319        });
320    }
321    Ok(())
322}
323
324fn handle_connection<H>(stream: &mut TcpStream, handler: &H)
325where
326    H: Fn(Request) -> Response,
327{
328    let response = {
329        let mut reader = BufReader::new(&*stream);
330        match read_request(&mut reader) {
331            Ok(None) => return,
332            Ok(Some(req)) => std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| handler(req)))
333                .unwrap_or_else(|_| {
334                    Response::error(500, "handler_panicked", "the request handler panicked")
335                }),
336            Err(e) => e,
337        }
338    };
339    let _ = write_response(stream, &response);
340    let _ = stream.shutdown(std::net::Shutdown::Both);
341}
342
343#[cfg(test)]
344mod tests {
345    use super::*;
346
347    #[test]
348    fn a_query_string_is_decoded_not_passed_through() {
349        let q = parse_query("a=one%20two&b&c=%2Fpath");
350        assert_eq!(q.get("a").map(String::as_str), Some("one two"));
351        assert_eq!(q.get("b").map(String::as_str), Some(""));
352        assert_eq!(q.get("c").map(String::as_str), Some("/path"));
353    }
354
355    #[test]
356    fn a_malformed_escape_is_kept_literally_not_dropped() {
357        // Dropping it would silently change which path was requested, and a path is a
358        // security decision here.
359        assert_eq!(percent_decode("%zz"), "%zz");
360        assert_eq!(percent_decode("100%"), "100%");
361    }
362
363    #[test]
364    fn segments_ignore_empty_components() {
365        let r = Request {
366            method: "GET".into(),
367            path: "//decisions//abc/verify/".into(),
368            query: BTreeMap::new(),
369            headers: BTreeMap::new(),
370            body: vec![],
371        };
372        assert_eq!(r.segments(), vec!["decisions", "abc", "verify"]);
373    }
374
375    #[test]
376    fn header_lookup_is_case_insensitive() {
377        let mut headers = BTreeMap::new();
378        headers.insert("authorization".to_string(), "Bearer x".to_string());
379        let r = Request {
380            method: "GET".into(),
381            path: "/".into(),
382            query: BTreeMap::new(),
383            headers,
384            body: vec![],
385        };
386        assert_eq!(r.header("Authorization"), Some("Bearer x"));
387    }
388
389    #[test]
390    fn the_bind_address_is_always_loopback() {
391        // Asserted rather than trusted to review. This process answers questions about the
392        // operator's filesystem.
393        assert!(loopback(7842).ip().is_loopback());
394    }
395
396    #[test]
397    fn a_response_never_carries_a_cors_allow_header() {
398        // The rule that keeps a malicious page from reading an answer even if it manages to
399        // send a request. Checked on the serialised bytes, since that is what a browser sees.
400        let listener = TcpListener::bind(loopback(0)).unwrap();
401        let port = listener.local_addr().unwrap().port();
402        std::thread::spawn(move || {
403            serve(listener, |_req| Response::json(200, "{}")).unwrap();
404        });
405        let mut s = TcpStream::connect(loopback(port)).unwrap();
406        s.write_all(b"GET / HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n").unwrap();
407        let mut buf = String::new();
408        s.read_to_string(&mut buf).unwrap();
409        assert!(buf.starts_with("HTTP/1.1 200"), "{buf}");
410        assert!(
411            !buf.to_lowercase().contains("access-control-allow"),
412            "a CORS allow header would let any web page read this: {buf}"
413        );
414    }
415
416    #[test]
417    fn an_oversized_body_is_refused_by_the_declared_length() {
418        // Refused from Content-Length, before any of it is read into memory.
419        let listener = TcpListener::bind(loopback(0)).unwrap();
420        let port = listener.local_addr().unwrap().port();
421        std::thread::spawn(move || {
422            serve(listener, |_req| Response::json(200, "{}")).unwrap();
423        });
424        let mut s = TcpStream::connect(loopback(port)).unwrap();
425        let head = format!(
426            "POST /observe HTTP/1.1\r\nHost: 127.0.0.1\r\nContent-Length: {}\r\n\r\n",
427            MAX_BODY_BYTES + 1
428        );
429        s.write_all(head.as_bytes()).unwrap();
430        let mut buf = String::new();
431        s.read_to_string(&mut buf).unwrap();
432        assert!(buf.starts_with("HTTP/1.1 413"), "{buf}");
433    }
434
435    #[test]
436    fn a_panicking_handler_answers_500_rather_than_killing_the_daemon() {
437        let listener = TcpListener::bind(loopback(0)).unwrap();
438        let port = listener.local_addr().unwrap().port();
439        std::thread::spawn(move || {
440            serve(listener, |_req| panic!("boom")).unwrap();
441        });
442        // Silence the panic message; the behaviour under test is the response.
443        let prev = std::panic::take_hook();
444        std::panic::set_hook(Box::new(|_| {}));
445        let mut s = TcpStream::connect(loopback(port)).unwrap();
446        s.write_all(b"GET / HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n").unwrap();
447        let mut buf = String::new();
448        s.read_to_string(&mut buf).unwrap();
449        std::panic::set_hook(prev);
450        assert!(buf.starts_with("HTTP/1.1 500"), "{buf}");
451
452        // And the daemon is still serving.
453        let mut s2 = TcpStream::connect(loopback(port)).unwrap();
454        s2.write_all(b"GET / HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n").unwrap();
455        let mut buf2 = String::new();
456        s2.read_to_string(&mut buf2).unwrap();
457        assert!(buf2.starts_with("HTTP/1.1 500"), "{buf2}");
458    }
459
460    #[test]
461    fn a_body_is_read_in_full_before_the_handler_sees_it() {
462        let listener = TcpListener::bind(loopback(0)).unwrap();
463        let port = listener.local_addr().unwrap().port();
464        std::thread::spawn(move || {
465            serve(listener, |req| {
466                Response::text(200, String::from_utf8_lossy(&req.body).to_string())
467            })
468            .unwrap();
469        });
470        let mut s = TcpStream::connect(loopback(port)).unwrap();
471        let payload = r#"{"locator":"."}"#;
472        s.write_all(
473            format!(
474                "POST /observe HTTP/1.1\r\nHost: 127.0.0.1\r\nContent-Length: {}\r\n\r\n{}",
475                payload.len(),
476                payload
477            )
478            .as_bytes(),
479        )
480        .unwrap();
481        let mut buf = String::new();
482        s.read_to_string(&mut buf).unwrap();
483        assert!(buf.ends_with(payload), "{buf}");
484    }
485}