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