1use 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
14const MAX_BODY_BYTES: usize = 1 << 20; const MAX_HEAD_LINE_BYTES: usize = 64 * 1024;
24
25const MAX_HEADER_COUNT: usize = 256;
28
29const 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
43pub struct ServeConfig {
45 pub addr: String,
47 pub atelier_root: PathBuf,
49 pub dry_run: bool,
52 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
79pub 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 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 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 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#[derive(Debug)]
242enum ReadOutcome {
243 Request(RequestLine),
244 TooLarge,
245 Invalid,
246}
247
248fn read_request(stream: &mut TcpStream) -> std::io::Result<ReadOutcome> {
250 let mut reader = BufReader::new(stream);
251 read_request_from(&mut reader)
252}
253
254fn 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 CapOutcome::TooLarge => return Ok(ReadOutcome::TooLarge),
263 CapOutcome::Eof => return Ok(ReadOutcome::Invalid),
264 CapOutcome::Line => {}
265 }
266 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 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 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
316fn 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
339fn 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
358fn path_of(target: &str) -> &str {
360 target.split(['?', '#']).next().unwrap_or(target)
361}
362
363fn 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
379fn 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 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 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 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 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}