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