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, DefaultLiveSurfaceFactory, LiveSessionTable,
37    LiveSurfaceFactory, decode_intent_body, encode_patches, encode_scene, 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    serve_with_surface_factory(cx, config, Box::new(DefaultLiveSurfaceFactory))
89}
90
91/// Bind and serve the shell with a caller-provided browser surface factory.
92///
93/// Domain products use this composition point to supply their own
94/// `SurfaceCodec`, transport, resource, and diminished authority while retaining
95/// the shell's HTTP lifecycle, opaque browser-session table, and one generic
96/// Scene interpreter.
97pub fn serve_with_surface_factory(
98    cx: &mut Cx,
99    config: &ServeConfig,
100    surface_factory: Box<dyn LiveSurfaceFactory + Send + Sync>,
101) -> std::io::Result<()> {
102    if config.dry_run {
103        println!("sim-web-shell: dry-run OK");
104        return Ok(());
105    }
106
107    let listener = bind(&config.addr)?;
108    let local = listener.local_addr()?;
109    let mut state = ShellState::with_surface_factory(config, cx, surface_factory)?;
110    println!("sim-web-shell: serving shell on http://{local}");
111    for stream in listener.incoming() {
112        match stream {
113            Ok(stream) => {
114                if let Err(err) = handle(stream, &mut state) {
115                    eprintln!("sim-web-shell: connection error: {err}");
116                }
117            }
118            Err(err) => eprintln!("sim-web-shell: accept error: {err}"),
119        }
120    }
121    Ok(())
122}
123
124fn bind(addr: &str) -> std::io::Result<TcpListener> {
125    let resolved = addr.to_socket_addrs()?.next().ok_or_else(|| {
126        std::io::Error::new(std::io::ErrorKind::InvalidInput, "no socket address")
127    })?;
128    TcpListener::bind(resolved)
129}
130
131struct ShellState<'a> {
132    atelier: AtelierWebState,
133    cookbook: Arc<CookbookWebState>,
134    cookbook_cx: &'a mut Cx,
135    live: LiveSessionTable,
136}
137
138impl<'a> ShellState<'a> {
139    #[cfg(test)]
140    fn new(config: &ServeConfig, cx: &'a mut Cx) -> std::io::Result<Self> {
141        Self::with_surface_factory(config, cx, Box::new(DefaultLiveSurfaceFactory))
142    }
143
144    fn with_surface_factory(
145        config: &ServeConfig,
146        cx: &'a mut Cx,
147        surface_factory: Box<dyn LiveSurfaceFactory + Send + Sync>,
148    ) -> std::io::Result<Self> {
149        // The cookbook eval sandbox is the bootloader-provided `cx`, which already
150        // carries the standard distribution the recipes require and read-eval,
151        // granted by the bootloader at the web-serve composition point. run_recipe
152        // gates each run on read-eval, so a session that never runs a recipe never
153        // uses it.
154        Ok(Self {
155            atelier: AtelierWebState::load(config.atelier_root.clone()),
156            cookbook: match &config.cookbook {
157                Some(cookbook) => Arc::clone(cookbook),
158                None => Arc::new(CookbookWebState::seeded().map_err(io_error)?),
159            },
160            cookbook_cx: cx,
161            live: LiveSessionTable::new(surface_factory),
162        })
163    }
164}
165
166#[cfg(test)]
167pub(crate) fn cookbook_index_for_test(
168    cx: &mut Cx,
169    config: &ServeConfig,
170) -> std::io::Result<CookbookWebResponse> {
171    let state = ShellState::new(config, cx)?;
172    Ok(state
173        .cookbook
174        .handle_request("GET", "/api/cookbook", Some(&mut *state.cookbook_cx)))
175}
176
177fn io_error(err: impl std::fmt::Display) -> std::io::Error {
178    std::io::Error::other(err.to_string())
179}
180
181fn handle(mut stream: TcpStream, state: &mut ShellState<'_>) -> std::io::Result<()> {
182    // Bound how long a single read may block; a slow-loris peer cannot pin the
183    // server. A failure to set the timeout is non-fatal (e.g. exotic streams).
184    let _ = stream.set_read_timeout(Some(READ_TIMEOUT));
185    let request = match read_request(&mut stream)? {
186        ReadOutcome::Request(request) => request,
187        ReadOutcome::TooLarge => {
188            write_response(
189                &mut stream,
190                413,
191                "Payload Too Large",
192                "text/plain; charset=utf-8",
193                b"payload too large",
194            )?;
195            return Ok(());
196        }
197        ReadOutcome::Invalid => {
198            write_response(
199                &mut stream,
200                400,
201                "Bad Request",
202                "text/plain; charset=utf-8",
203                b"bad request",
204            )?;
205            return Ok(());
206        }
207    };
208    if path_of(&request.target) == "/api/session/intent" {
209        return write_session_intent(&mut stream, &request, &mut state.live);
210    }
211    if path_of(&request.target) == "/api/session/open" {
212        return write_session_open(&mut stream, &request, &mut state.live);
213    }
214    if path_of(&request.target) == "/api/session/close" {
215        return write_session_close(&mut stream, &request, &mut state.live);
216    }
217    if request.target.starts_with("/api/cookbook") {
218        // read-eval was granted to cookbook_cx by the bootloader (see cli.rs);
219        // run_recipe gates each run on it.
220        let response = state.cookbook.handle_request(
221            &request.method,
222            &request.target,
223            Some(&mut *state.cookbook_cx),
224        );
225        return write_cookbook_response(&mut stream, &response);
226    }
227    if let Some(response) = state.atelier.response(&request.method, &request.target) {
228        return write_response(
229            &mut stream,
230            response.status,
231            status_text(response.status),
232            response.content_type,
233            response.body.as_bytes(),
234        );
235    }
236    if request.method != "GET" {
237        write_response(
238            &mut stream,
239            405,
240            "Method Not Allowed",
241            "text/plain; charset=utf-8",
242            b"method not allowed",
243        )?;
244        return Ok(());
245    }
246    match asset_for(&request.target) {
247        Some(asset) => write_response(&mut stream, 200, "OK", asset.content_type, asset.body),
248        None => write_response(
249            &mut stream,
250            404,
251            "Not Found",
252            "text/plain; charset=utf-8",
253            b"not found",
254        ),
255    }
256}
257
258#[derive(Debug)]
259struct RequestLine {
260    method: String,
261    target: String,
262    body: String,
263}
264
265/// The outcome of reading one request: a parsed request, an oversized body
266/// (answer 413), or an otherwise-unparseable request (answer 400).
267#[derive(Debug)]
268enum ReadOutcome {
269    Request(RequestLine),
270    TooLarge,
271    Invalid,
272}
273
274/// Read the request line, scan headers for `Content-Length`, and read the body.
275fn read_request(stream: &mut TcpStream) -> std::io::Result<ReadOutcome> {
276    let mut reader = BufReader::new(stream);
277    read_request_from(&mut reader)
278}
279
280/// Parse a request from any buffered reader, bounding the body at
281/// [`MAX_BODY_BYTES`]. A declared `Content-Length` over the cap returns
282/// [`ReadOutcome::TooLarge`] before any allocation, and the body read is capped
283/// at the same limit so a lying header cannot over-read.
284fn read_request_from(reader: &mut impl BufRead) -> std::io::Result<ReadOutcome> {
285    let mut request_line = String::new();
286    match read_capped_line(reader, &mut request_line, MAX_HEAD_LINE_BYTES)? {
287        // An oversized request line is refused with 413 before it can grow memory.
288        CapOutcome::TooLarge => return Ok(ReadOutcome::TooLarge),
289        CapOutcome::Eof => return Ok(ReadOutcome::Invalid),
290        CapOutcome::Line => {}
291    }
292    // Drain the rest of the header block, capturing the body length, so the peer
293    // is not left mid-write. Cap each header line and the header count so a
294    // hostile peer cannot grow memory unbounded with one huge header or an
295    // endless stream of tiny ones.
296    let mut content_length = 0usize;
297    let mut header = String::new();
298    let mut header_count = 0usize;
299    loop {
300        header_count += 1;
301        if header_count > MAX_HEADER_COUNT {
302            return Ok(ReadOutcome::TooLarge);
303        }
304        match read_capped_line(reader, &mut header, MAX_HEAD_LINE_BYTES)? {
305            CapOutcome::TooLarge => return Ok(ReadOutcome::TooLarge),
306            CapOutcome::Eof => break,
307            CapOutcome::Line => {}
308        }
309        if header == "\r\n" || header == "\n" {
310            break;
311        }
312        if let Some((name, value)) = header.split_once(':')
313            && name.trim().eq_ignore_ascii_case("content-length")
314        {
315            content_length = value.trim().parse().unwrap_or(0);
316        }
317    }
318    // Reject an oversized declared body before allocating anything for it.
319    if content_length > MAX_BODY_BYTES {
320        return Ok(ReadOutcome::TooLarge);
321    }
322    let mut body = vec![0u8; content_length];
323    if content_length > 0 {
324        // Read at most the cap even if the header under-declared (defence in
325        // depth): `body` is already capped, so `read_exact` cannot grow it.
326        reader.read_exact(&mut body)?;
327    }
328    let body = String::from_utf8_lossy(&body).into_owned();
329    let mut parts = request_line.split_whitespace();
330    let method = parts.next();
331    let target = parts.next();
332    match (method, target) {
333        (Some(method @ ("GET" | "POST")), Some(target)) => Ok(ReadOutcome::Request(RequestLine {
334            method: method.to_owned(),
335            target: target.to_owned(),
336            body,
337        })),
338        _ => Ok(ReadOutcome::Invalid),
339    }
340}
341
342/// Handle `POST /api/session/intent`: decode the Intent from the request body,
343/// submit it to the live session, and respond with the resulting Scene patches.
344/// Decode and validation failures respond with a structured error, never a
345/// panic.
346fn write_session_intent(
347    stream: &mut (impl Write + ?Sized),
348    request: &RequestLine,
349    live: &mut LiveSessionTable,
350) -> std::io::Result<()> {
351    if request.method != "POST" {
352        return write_json(stream, 405, &error_json("intent route requires POST"));
353    }
354    let session_id = match query_value(&request.target, "session") {
355        Ok(Some(value)) => value,
356        Ok(None) => return write_json(stream, 400, &error_json("missing session id")),
357        Err(err) => return write_json(stream, 400, &error_json(&err.to_string())),
358    };
359    let pane = match query_value(&request.target, "pane") {
360        Ok(Some(value)) => value,
361        Ok(None) => DEFAULT_PANE.to_owned(),
362        Err(err) => return write_json(stream, 400, &error_json(&err.to_string())),
363    };
364    let intent = match decode_intent_body(&request.body) {
365        Ok(intent) => intent,
366        Err(err) => return write_json(stream, 400, &error_json(&err)),
367    };
368    match live.submit(&session_id, &pane, &intent) {
369        Ok(updates) => write_json(stream, 200, &encode_patches(&updates)),
370        Err(err) => write_json(stream, 400, &error_json(&err.to_string())),
371    }
372}
373
374/// Handle `GET /api/session/open?resource=...&pane=...`: open the resource into
375/// the pane and respond with its initial Scene.
376fn write_session_open(
377    stream: &mut (impl Write + ?Sized),
378    request: &RequestLine,
379    live: &mut LiveSessionTable,
380) -> std::io::Result<()> {
381    if request.method != "GET" {
382        return write_json(stream, 405, &error_json("open route requires GET"));
383    }
384    let session_id = match query_value(&request.target, "session") {
385        Ok(value) => value,
386        Err(err) => return write_json(stream, 400, &error_json(&err.to_string())),
387    };
388    let resource = match query_value(&request.target, "resource") {
389        Ok(Some(value)) => value,
390        Ok(None) => DEFAULT_RESOURCE.to_owned(),
391        Err(err) => return write_json(stream, 400, &error_json(&err.to_string())),
392    };
393    let pane = match query_value(&request.target, "pane") {
394        Ok(Some(value)) => value,
395        Ok(None) => DEFAULT_PANE.to_owned(),
396        Err(err) => return write_json(stream, 400, &error_json(&err.to_string())),
397    };
398    match live.open(session_id.as_deref(), &resource, &pane) {
399        Ok((session_id, scene)) => {
400            write_json(stream, 200, &encode_session_open(&session_id, &scene))
401        }
402        Err(err) => write_json(stream, 400, &error_json(&err.to_string())),
403    }
404}
405
406/// Handle `POST /api/session/close?session=...`: cancel and remove a browser
407/// session so its authority and connection state cannot be reused.
408fn write_session_close(
409    stream: &mut (impl Write + ?Sized),
410    request: &RequestLine,
411    live: &mut LiveSessionTable,
412) -> std::io::Result<()> {
413    if request.method != "POST" {
414        return write_json(stream, 405, &error_json("close route requires POST"));
415    }
416    let session_id = match query_value(&request.target, "session") {
417        Ok(Some(value)) => value,
418        Ok(None) => return write_json(stream, 400, &error_json("missing session id")),
419        Err(err) => return write_json(stream, 400, &error_json(&err.to_string())),
420    };
421    match live.close(&session_id) {
422        Ok(()) => write_json(stream, 200, r#"{"ok":true}"#),
423        Err(err) => write_json(stream, 400, &error_json(&err)),
424    }
425}
426
427fn encode_session_open(session_id: &str, scene: &sim_kernel::Expr) -> String {
428    let mut value: serde_json::Value =
429        serde_json::from_str(&encode_scene(scene)).expect("encode_scene emits JSON object");
430    if let Some(object) = value.as_object_mut() {
431        object.insert(
432            "session".to_owned(),
433            serde_json::Value::String(session_id.to_owned()),
434        );
435    }
436    value.to_string()
437}
438
439/// The path portion of a request target, with any query or fragment stripped.
440fn path_of(target: &str) -> &str {
441    target.split(['?', '#']).next().unwrap_or(target)
442}
443
444#[derive(Debug, Clone, PartialEq, Eq)]
445struct QueryError {
446    key: String,
447    reason: String,
448}
449
450impl QueryError {
451    fn new(key: &str, reason: impl Into<String>) -> Self {
452        Self {
453            key: key.to_owned(),
454            reason: reason.into(),
455        }
456    }
457}
458
459impl fmt::Display for QueryError {
460    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
461        write!(
462            f,
463            "malformed query value for '{}': {}",
464            self.key, self.reason
465        )
466    }
467}
468
469/// Whether a request targets the cookbook RUN route
470/// (`POST /api/cookbook/recipe/<id>/run`). This is the only cookbook route that
471/// evaluates a recipe, so it is the only one the shell grants read-eval for;
472/// list/search/show routes stay ungated. Mirrors the run-route match in
473/// `sim-lib-server`'s `CookbookWebState::handle_request`.
474/// The first value of a query-string key in a request target, if present. Only a
475/// value for the matching key is percent-decoded; malformed escapes are errors
476/// so the session routes can reject them with a structured 400 response.
477fn query_value(target: &str, key: &str) -> Result<Option<String>, QueryError> {
478    let Some((_, query_and_fragment)) = target.split_once('?') else {
479        return Ok(None);
480    };
481    let query = query_and_fragment
482        .split('#')
483        .next()
484        .unwrap_or(query_and_fragment);
485    for pair in query.split('&') {
486        let (name, value) = pair.split_once('=').unwrap_or((pair, ""));
487        if name == key {
488            return percent_decode_query(value)
489                .map(Some)
490                .map_err(|reason| QueryError::new(key, reason));
491        }
492    }
493    Ok(None)
494}
495
496fn percent_decode_query(value: &str) -> Result<String, String> {
497    let bytes = value.as_bytes();
498    let mut decoded = Vec::with_capacity(bytes.len());
499    let mut index = 0usize;
500    while index < bytes.len() {
501        match bytes[index] {
502            b'%' => {
503                if index + 2 >= bytes.len() {
504                    return Err("incomplete percent escape".to_owned());
505                }
506                let high = hex_digit(bytes[index + 1])
507                    .ok_or_else(|| "invalid percent escape".to_owned())?;
508                let low = hex_digit(bytes[index + 2])
509                    .ok_or_else(|| "invalid percent escape".to_owned())?;
510                decoded.push((high << 4) | low);
511                index += 3;
512            }
513            b'+' => {
514                decoded.push(b' ');
515                index += 1;
516            }
517            byte => {
518                decoded.push(byte);
519                index += 1;
520            }
521        }
522    }
523    String::from_utf8(decoded).map_err(|_| "decoded value is not UTF-8".to_owned())
524}
525
526fn hex_digit(byte: u8) -> Option<u8> {
527    match byte {
528        b'0'..=b'9' => Some(byte - b'0'),
529        b'a'..=b'f' => Some(byte - b'a' + 10),
530        b'A'..=b'F' => Some(byte - b'A' + 10),
531        _ => None,
532    }
533}
534
535/// Write a JSON body with the given status.
536fn write_json(stream: &mut (impl Write + ?Sized), status: u16, body: &str) -> std::io::Result<()> {
537    write_response(
538        stream,
539        status,
540        status_text(status),
541        "application/json; charset=utf-8",
542        body.as_bytes(),
543    )
544}
545
546fn write_cookbook_response(
547    stream: &mut (impl Write + ?Sized),
548    response: &CookbookWebResponse,
549) -> std::io::Result<()> {
550    write_response(
551        stream,
552        response.status,
553        status_text(response.status),
554        response.content_type,
555        response.body.as_bytes(),
556    )
557}
558
559fn write_response(
560    stream: &mut (impl Write + ?Sized),
561    status: u16,
562    reason: &str,
563    content_type: &str,
564    body: &[u8],
565) -> std::io::Result<()> {
566    let header = format!(
567        "HTTP/1.1 {status} {reason}\r\nContent-Type: {content_type}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
568        body.len()
569    );
570    stream.write_all(header.as_bytes())?;
571    stream.write_all(body)?;
572    stream.flush()
573}
574
575fn status_text(status: u16) -> &'static str {
576    match status {
577        200 => "OK",
578        201 => "Created",
579        204 => "No Content",
580        301 => "Moved Permanently",
581        302 => "Found",
582        304 => "Not Modified",
583        400 => "Bad Request",
584        401 => "Unauthorized",
585        403 => "Forbidden",
586        404 => "Not Found",
587        405 => "Method Not Allowed",
588        409 => "Conflict",
589        413 => "Payload Too Large",
590        422 => "Unprocessable Entity",
591        429 => "Too Many Requests",
592        500 => "Internal Server Error",
593        501 => "Not Implemented",
594        503 => "Service Unavailable",
595        // Fall back to the reason phrase for the status class rather than
596        // mislabeling every unlisted code as "OK".
597        other => match other / 100 {
598            1 => "Informational",
599            2 => "OK",
600            3 => "Redirection",
601            4 => "Client Error",
602            _ => "Internal Server Error",
603        },
604    }
605}
606
607#[cfg(test)]
608#[path = "serve_tests.rs"]
609mod tests;