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