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