Skip to main content

sim_web_shell/
serve.rs

1//! Minimal blocking HTTP/1.1 server for the Web shell.
2//!
3//! The server serves embedded assets, the cookbook API adapter, and the Atelier
4//! shell cache API. Runtime transport remains the Intent/Scene bridge over
5//! `realize`/`EvalFabric`.
6
7use std::io::{BufRead, BufReader, Read, Write};
8use std::net::{TcpListener, TcpStream, ToSocketAddrs};
9use std::path::PathBuf;
10use std::time::Duration;
11
12/// Largest request body the shell will read. A larger declared `Content-Length`
13/// is rejected with 413 before any allocation, so a hostile header cannot force
14/// an unbounded `vec![0u8; n]`.
15const MAX_BODY_BYTES: usize = 1 << 20; // 1 MiB.
16
17/// Largest single request line or header line the shell will read. Matches the
18/// 64 KiB head cap the peer HTTP readers in sim-agent-net enforce, so a hostile
19/// multi-gigabyte request line or header cannot grow memory unbounded before it
20/// is rejected with 413.
21const MAX_HEAD_LINE_BYTES: usize = 64 * 1024;
22
23/// Largest number of header lines the shell will read before rejecting the
24/// request, so an endless stream of tiny headers cannot grow memory unbounded.
25const MAX_HEADER_COUNT: usize = 256;
26
27/// Per-read timeout on a connection, so a peer that declares a body but then
28/// dribbles (or stalls) cannot block the single-threaded server forever.
29const READ_TIMEOUT: Duration = Duration::from_secs(30);
30
31use crate::assets::asset_for;
32use crate::atelier::AtelierWebState;
33use crate::live::{
34    DEFAULT_PANE, DEFAULT_RESOURCE, LiveSession, decode_intent_body, encode_patches, encode_scene,
35    error_json,
36};
37use sim_codec_algol::AlgolCodecLib;
38use sim_codec_binary::BinaryCodecLib;
39use sim_codec_chat::ChatCodecLib;
40use sim_codec_json::JsonCodecLib;
41use sim_kernel::{Cx, Result as SimResult};
42use sim_lib_server::{CookbookWebResponse, CookbookWebState};
43use sim_lib_stream_core::install_stream_core_shapes_lib;
44
45/// Configuration for the shell server.
46#[derive(Debug)]
47pub struct ServeConfig {
48    /// The address to bind, e.g. `127.0.0.1:8787`.
49    pub addr: String,
50    /// Directory containing generated Atelier cache files.
51    pub atelier_root: PathBuf,
52    /// Install codecs and shapes, then return before binding the socket. Lets a
53    /// caller confirm the serve verb dispatches and boots without holding a port.
54    pub dry_run: bool,
55}
56
57impl Default for ServeConfig {
58    fn default() -> Self {
59        Self {
60            addr: "127.0.0.1:8787".to_owned(),
61            atelier_root: PathBuf::from(".sim/atelier"),
62            dry_run: false,
63        }
64    }
65}
66
67/// Bind and serve the shell until the process is terminated, using the
68/// bootloader-provided `cx` as the cookbook eval sandbox. The `sim-web-shell`
69/// binary boots through `sim_run_core::Bootloader` (see `cli.rs`), which loads the
70/// `codec/lisp` boot codec and dispatches the `serve` verb into this function with
71/// a ready `cx`. Read-eval is granted to that `cx` by the bootloader at the
72/// web-serve composition point (`configure_web_bootloader`, through the boot
73/// session's host GrantSeat), not self-granted here; `run_recipe` gates each run
74/// on it (REVIEW_12 F4/F23).
75pub fn serve_with_cx(cx: &mut Cx, config: &ServeConfig) -> std::io::Result<()> {
76    install_codecs(cx).map_err(io_error)?;
77    install_stream_core_shapes_lib(cx).map_err(io_error)?;
78
79    if config.dry_run {
80        println!("sim-web-shell: dry-run OK");
81        return Ok(());
82    }
83
84    let listener = bind(&config.addr)?;
85    let local = listener.local_addr()?;
86    let mut state = ShellState::new(config, cx)?;
87    println!("sim-web-shell: serving shell on http://{local}");
88    for stream in listener.incoming() {
89        match stream {
90            Ok(stream) => {
91                if let Err(err) = handle(stream, &mut state) {
92                    eprintln!("sim-web-shell: connection error: {err}");
93                }
94            }
95            Err(err) => eprintln!("sim-web-shell: accept error: {err}"),
96        }
97    }
98    Ok(())
99}
100
101fn bind(addr: &str) -> std::io::Result<TcpListener> {
102    let resolved = addr.to_socket_addrs()?.next().ok_or_else(|| {
103        std::io::Error::new(std::io::ErrorKind::InvalidInput, "no socket address")
104    })?;
105    TcpListener::bind(resolved)
106}
107
108struct ShellState<'a> {
109    atelier: AtelierWebState,
110    cookbook: CookbookWebState,
111    cookbook_cx: &'a mut Cx,
112    live: LiveSession,
113}
114
115impl<'a> ShellState<'a> {
116    fn new(config: &ServeConfig, cx: &'a mut Cx) -> std::io::Result<Self> {
117        // The cookbook eval sandbox is the bootloader-provided `cx`, which already
118        // carries the standard distribution the recipes require and read-eval,
119        // granted by the bootloader at the web-serve composition point. run_recipe
120        // gates each run on read-eval, so a session that never runs a recipe never
121        // uses it.
122        Ok(Self {
123            atelier: AtelierWebState::load(config.atelier_root.clone()),
124            cookbook: CookbookWebState::seeded().map_err(io_error)?,
125            cookbook_cx: cx,
126            live: LiveSession::new().map_err(io_error)?,
127        })
128    }
129}
130
131/// Installs the cookbook eval codecs. `codec/lisp` is the boot codec provided by the
132/// bootloader, so it is not reinstalled here (that would double-register the symbol).
133fn install_codecs(cx: &mut Cx) -> SimResult<()> {
134    let json = JsonCodecLib::new(cx.registry_mut().fresh_codec_id());
135    cx.load_lib(&json)?;
136    let binary = BinaryCodecLib::new(cx.registry_mut().fresh_codec_id());
137    cx.load_lib(&binary)?;
138    let chat = ChatCodecLib::new(cx.registry_mut().fresh_codec_id());
139    cx.load_lib(&chat)?;
140    let algol = AlgolCodecLib::new(cx.registry_mut().fresh_codec_id());
141    cx.load_lib(&algol)?;
142    Ok(())
143}
144
145fn io_error(err: impl std::fmt::Display) -> std::io::Error {
146    std::io::Error::other(err.to_string())
147}
148
149fn handle(mut stream: TcpStream, state: &mut ShellState<'_>) -> std::io::Result<()> {
150    // Bound how long a single read may block; a slow-loris peer cannot pin the
151    // server. A failure to set the timeout is non-fatal (e.g. exotic streams).
152    let _ = stream.set_read_timeout(Some(READ_TIMEOUT));
153    let request = match read_request(&mut stream)? {
154        ReadOutcome::Request(request) => request,
155        ReadOutcome::TooLarge => {
156            write_response(
157                &mut stream,
158                413,
159                "Payload Too Large",
160                "text/plain; charset=utf-8",
161                b"payload too large",
162            )?;
163            return Ok(());
164        }
165        ReadOutcome::Invalid => {
166            write_response(
167                &mut stream,
168                400,
169                "Bad Request",
170                "text/plain; charset=utf-8",
171                b"bad request",
172            )?;
173            return Ok(());
174        }
175    };
176    if path_of(&request.target) == "/api/session/intent" {
177        return write_session_intent(&mut stream, &request, &mut state.live);
178    }
179    if path_of(&request.target) == "/api/session/open" {
180        return write_session_open(&mut stream, &request, &mut state.live);
181    }
182    if request.target.starts_with("/api/cookbook") {
183        // read-eval was granted to cookbook_cx by the bootloader (see cli.rs);
184        // run_recipe gates each run on it (REVIEW_12 F4/F23).
185        let response = state.cookbook.handle_request(
186            &request.method,
187            &request.target,
188            Some(&mut *state.cookbook_cx),
189        );
190        return write_cookbook_response(&mut stream, &response);
191    }
192    if let Some(response) = state.atelier.response(&request.method, &request.target) {
193        return write_response(
194            &mut stream,
195            response.status,
196            status_text(response.status),
197            response.content_type,
198            response.body.as_bytes(),
199        );
200    }
201    if request.method != "GET" {
202        write_response(
203            &mut stream,
204            405,
205            "Method Not Allowed",
206            "text/plain; charset=utf-8",
207            b"method not allowed",
208        )?;
209        return Ok(());
210    }
211    match asset_for(&request.target) {
212        Some(asset) => write_response(&mut stream, 200, "OK", asset.content_type, asset.body),
213        None => write_response(
214            &mut stream,
215            404,
216            "Not Found",
217            "text/plain; charset=utf-8",
218            b"not found",
219        ),
220    }
221}
222
223#[derive(Debug)]
224struct RequestLine {
225    method: String,
226    target: String,
227    body: String,
228}
229
230/// The outcome of reading one request: a parsed request, an oversized body
231/// (answer 413), or an otherwise-unparseable request (answer 400).
232#[derive(Debug)]
233enum ReadOutcome {
234    Request(RequestLine),
235    TooLarge,
236    Invalid,
237}
238
239/// Read the request line, scan headers for `Content-Length`, and read the body.
240fn read_request(stream: &mut TcpStream) -> std::io::Result<ReadOutcome> {
241    let mut reader = BufReader::new(stream);
242    read_request_from(&mut reader)
243}
244
245/// Read one line into `buf` (clearing it first), reading at most `cap` bytes.
246/// Returns `Ok(None)` when the line would exceed the cap (the caller answers
247/// 413), `Ok(Some(0))` at end of input, and `Ok(Some(n))` for a line of `n`
248/// bytes otherwise. The `take(cap + 1)` ceiling means a hostile unterminated
249/// line is bounded before it can grow memory.
250fn read_capped_line(
251    reader: &mut impl BufRead,
252    buf: &mut String,
253    cap: usize,
254) -> std::io::Result<Option<usize>> {
255    buf.clear();
256    let read = Read::take(&mut *reader, cap as u64 + 1).read_line(buf)?;
257    if buf.len() > cap {
258        return Ok(None);
259    }
260    Ok(Some(read))
261}
262
263/// Parse a request from any buffered reader, bounding the body at
264/// [`MAX_BODY_BYTES`]. A declared `Content-Length` over the cap returns
265/// [`ReadOutcome::TooLarge`] before any allocation, and the body read is capped
266/// at the same limit so a lying header cannot over-read.
267fn read_request_from(reader: &mut impl BufRead) -> std::io::Result<ReadOutcome> {
268    let mut request_line = String::new();
269    match read_capped_line(reader, &mut request_line, MAX_HEAD_LINE_BYTES)? {
270        // An oversized request line is refused with 413 before it can grow memory.
271        None => return Ok(ReadOutcome::TooLarge),
272        Some(0) => return Ok(ReadOutcome::Invalid),
273        Some(_) => {}
274    }
275    // Drain the rest of the header block, capturing the body length, so the peer
276    // is not left mid-write. Cap each header line and the header count so a
277    // hostile peer cannot grow memory unbounded with one huge header or an
278    // endless stream of tiny ones.
279    let mut content_length = 0usize;
280    let mut header = String::new();
281    let mut header_count = 0usize;
282    loop {
283        header_count += 1;
284        if header_count > MAX_HEADER_COUNT {
285            return Ok(ReadOutcome::TooLarge);
286        }
287        let read = match read_capped_line(reader, &mut header, MAX_HEAD_LINE_BYTES)? {
288            None => return Ok(ReadOutcome::TooLarge),
289            Some(read) => read,
290        };
291        if read == 0 || header == "\r\n" || header == "\n" {
292            break;
293        }
294        if let Some((name, value)) = header.split_once(':')
295            && name.trim().eq_ignore_ascii_case("content-length")
296        {
297            content_length = value.trim().parse().unwrap_or(0);
298        }
299    }
300    // Reject an oversized declared body before allocating anything for it.
301    if content_length > MAX_BODY_BYTES {
302        return Ok(ReadOutcome::TooLarge);
303    }
304    let mut body = vec![0u8; content_length];
305    if content_length > 0 {
306        // Read at most the cap even if the header under-declared (defence in
307        // depth): `body` is already capped, so `read_exact` cannot grow it.
308        reader.read_exact(&mut body)?;
309    }
310    let body = String::from_utf8_lossy(&body).into_owned();
311    let mut parts = request_line.split_whitespace();
312    let method = parts.next();
313    let target = parts.next();
314    match (method, target) {
315        (Some(method @ ("GET" | "POST")), Some(target)) => Ok(ReadOutcome::Request(RequestLine {
316            method: method.to_owned(),
317            target: target.to_owned(),
318            body,
319        })),
320        _ => Ok(ReadOutcome::Invalid),
321    }
322}
323
324/// Handle `POST /api/session/intent`: decode the Intent from the request body,
325/// submit it to the live session, and respond with the resulting Scene patches.
326/// Decode and validation failures respond with a structured error, never a
327/// panic.
328fn write_session_intent(
329    stream: &mut (impl Write + ?Sized),
330    request: &RequestLine,
331    live: &mut LiveSession,
332) -> std::io::Result<()> {
333    if request.method != "POST" {
334        return write_json(stream, 405, &error_json("intent route requires POST"));
335    }
336    let pane = query_value(&request.target, "pane").unwrap_or_else(|| DEFAULT_PANE.to_owned());
337    let intent = match decode_intent_body(&request.body) {
338        Ok(intent) => intent,
339        Err(err) => return write_json(stream, 400, &error_json(&err)),
340    };
341    match live.submit(&pane, &intent) {
342        Ok(updates) => write_json(stream, 200, &encode_patches(&updates)),
343        Err(err) => write_json(stream, 400, &error_json(&err.to_string())),
344    }
345}
346
347/// Handle `GET /api/session/open?resource=...&pane=...`: open the resource into
348/// the pane and respond with its initial Scene.
349fn write_session_open(
350    stream: &mut (impl Write + ?Sized),
351    request: &RequestLine,
352    live: &mut LiveSession,
353) -> std::io::Result<()> {
354    if request.method != "GET" {
355        return write_json(stream, 405, &error_json("open route requires GET"));
356    }
357    let resource =
358        query_value(&request.target, "resource").unwrap_or_else(|| DEFAULT_RESOURCE.to_owned());
359    let pane = query_value(&request.target, "pane").unwrap_or_else(|| DEFAULT_PANE.to_owned());
360    match live.open(&resource, &pane) {
361        Ok(scene) => write_json(stream, 200, &encode_scene(&scene)),
362        Err(err) => write_json(stream, 400, &error_json(&err.to_string())),
363    }
364}
365
366/// The path portion of a request target, with any query or fragment stripped.
367fn path_of(target: &str) -> &str {
368    target.split(['?', '#']).next().unwrap_or(target)
369}
370
371/// Whether a request targets the cookbook RUN route
372/// (`POST /api/cookbook/recipe/<id>/run`). This is the only cookbook route that
373/// evaluates a recipe, so it is the only one the shell grants read-eval for;
374/// list/search/show routes stay ungated. Mirrors the run-route match in
375/// `sim-lib-server`'s `CookbookWebState::handle_request`.
376/// The first value of a query-string key in a request target, if present. Only a
377/// plain `key=value` split is performed; values are expected to be simple
378/// identifiers (pane and resource names).
379fn query_value(target: &str, key: &str) -> Option<String> {
380    let (_, query) = target.split_once('?')?;
381    query.split('&').find_map(|pair| {
382        let (name, value) = pair.split_once('=').unwrap_or((pair, ""));
383        (name == key).then(|| value.to_owned())
384    })
385}
386
387/// Write a JSON body with the given status.
388fn write_json(stream: &mut (impl Write + ?Sized), status: u16, body: &str) -> std::io::Result<()> {
389    write_response(
390        stream,
391        status,
392        status_text(status),
393        "application/json; charset=utf-8",
394        body.as_bytes(),
395    )
396}
397
398fn write_cookbook_response(
399    stream: &mut (impl Write + ?Sized),
400    response: &CookbookWebResponse,
401) -> std::io::Result<()> {
402    write_response(
403        stream,
404        response.status,
405        status_text(response.status),
406        response.content_type,
407        response.body.as_bytes(),
408    )
409}
410
411fn write_response(
412    stream: &mut (impl Write + ?Sized),
413    status: u16,
414    reason: &str,
415    content_type: &str,
416    body: &[u8],
417) -> std::io::Result<()> {
418    let header = format!(
419        "HTTP/1.1 {status} {reason}\r\nContent-Type: {content_type}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
420        body.len()
421    );
422    stream.write_all(header.as_bytes())?;
423    stream.write_all(body)?;
424    stream.flush()
425}
426
427fn status_text(status: u16) -> &'static str {
428    match status {
429        200 => "OK",
430        201 => "Created",
431        204 => "No Content",
432        301 => "Moved Permanently",
433        302 => "Found",
434        304 => "Not Modified",
435        400 => "Bad Request",
436        401 => "Unauthorized",
437        403 => "Forbidden",
438        404 => "Not Found",
439        405 => "Method Not Allowed",
440        409 => "Conflict",
441        413 => "Payload Too Large",
442        422 => "Unprocessable Entity",
443        429 => "Too Many Requests",
444        500 => "Internal Server Error",
445        501 => "Not Implemented",
446        503 => "Service Unavailable",
447        // Fall back to the reason phrase for the status class rather than
448        // mislabeling every unlisted code as "OK".
449        other => match other / 100 {
450            1 => "Informational",
451            2 => "OK",
452            3 => "Redirection",
453            4 => "Client Error",
454            _ => "Internal Server Error",
455        },
456    }
457}
458
459#[cfg(test)]
460mod tests {
461    use super::{
462        MAX_BODY_BYTES, MAX_HEAD_LINE_BYTES, MAX_HEADER_COUNT, ReadOutcome, read_request_from,
463    };
464    use std::io::{BufReader, Cursor};
465
466    fn parse(raw: &str) -> ReadOutcome {
467        let mut reader = BufReader::new(Cursor::new(raw.as_bytes().to_vec()));
468        read_request_from(&mut reader).expect("read")
469    }
470
471    #[test]
472    fn oversized_content_length_is_rejected_before_allocation() {
473        // A 4 GB declared body must be refused with 413, never allocated.
474        let raw = "POST /api/session/intent HTTP/1.1\r\nContent-Length: 4000000000\r\n\r\n";
475        assert!(
476            matches!(parse(raw), ReadOutcome::TooLarge),
477            "an oversized Content-Length must yield TooLarge (413)"
478        );
479    }
480
481    #[test]
482    fn content_length_at_the_cap_boundary_is_rejected_when_over() {
483        let over = MAX_BODY_BYTES + 1;
484        let raw = format!("POST /x HTTP/1.1\r\nContent-Length: {over}\r\n\r\n");
485        assert!(matches!(parse(&raw), ReadOutcome::TooLarge));
486    }
487
488    #[test]
489    fn an_oversized_request_line_is_rejected_before_growing_memory() {
490        // A request line past the head cap must be refused with 413, not read
491        // into an unbounded String.
492        let mut raw = String::from("GET /");
493        raw.push_str(&"a".repeat(MAX_HEAD_LINE_BYTES + 16));
494        raw.push_str(" HTTP/1.1\r\n\r\n");
495        assert!(
496            matches!(parse(&raw), ReadOutcome::TooLarge),
497            "an oversized request line must yield TooLarge (413)"
498        );
499    }
500
501    #[test]
502    fn an_oversized_header_line_is_rejected_before_growing_memory() {
503        let mut raw = String::from("GET /x HTTP/1.1\r\nX-Big: ");
504        raw.push_str(&"a".repeat(MAX_HEAD_LINE_BYTES + 16));
505        raw.push_str("\r\n\r\n");
506        assert!(
507            matches!(parse(&raw), ReadOutcome::TooLarge),
508            "an oversized header line must yield TooLarge (413)"
509        );
510    }
511
512    #[test]
513    fn too_many_header_lines_are_rejected() {
514        let mut raw = String::from("GET /x HTTP/1.1\r\n");
515        for _ in 0..(MAX_HEADER_COUNT + 8) {
516            raw.push_str("X-Pad: 1\r\n");
517        }
518        raw.push_str("\r\n");
519        assert!(
520            matches!(parse(&raw), ReadOutcome::TooLarge),
521            "an endless header block must yield TooLarge (413)"
522        );
523    }
524
525    #[test]
526    fn a_small_body_within_the_cap_reads() {
527        let raw = "POST /x HTTP/1.1\r\nContent-Length: 5\r\n\r\nhello";
528        match parse(raw) {
529            ReadOutcome::Request(line) => {
530                assert_eq!(line.method, "POST");
531                assert_eq!(line.body, "hello");
532            }
533            other => panic!("expected a parsed request, got {other:?}"),
534        }
535    }
536}