oxedyne/fe2o3/fe2o3_net/src/http/local.rs
13.7 KiB, 1 run
created by r1870400018:58837, which is this file's identity for as long as the history lasts, whatever it is later renamed to
download · who wrote it · its history
| 1 | //! A std-only loopback static HTTP/1.1 server: no tokio, no TLS, no configuration file. |
| 2 | //! |
| 3 | //! `fe2o3_net`'s async HTTP layer (`HttpMessage`/`HttpMessageReader`) is the crate's real server, and |
| 4 | //! the right reach for anything a network peer touches -- but it rides on `tokio`'s full feature set and, |
| 5 | //! behind the `async` feature, `rustls`/`ring`/`rcgen`. A desktop reader shell that serves four files to |
| 6 | //! a browser tab on `127.0.0.1`, and a `typst watch`-style dev preview that serves a rasterised document |
| 7 | //! to a phone on the LAN, want none of that: they parse a bodiless GET, answer it, and close. Making a |
| 8 | //! caller link an async runtime and a TLS stack to do so was the whole reason a downstream app grew its |
| 9 | //! own std-only server (`fe2o3_pearlite::shell`) and a downstream script shelled out to python's |
| 10 | //! `http.server`. This is that server, lifted into fe2o3 and generalised, so neither has to. |
| 11 | //! |
| 12 | //! One thread per connection, GET only, `Connection: close`, `Cache-Control: no-store` (a dev preview |
| 13 | //! polls its own status files, and a stale answer there is a preview showing the wrong page). Two ways to |
| 14 | //! give it something to serve, which compose: fixed in-memory [`LocalServer::route`]s answered first, and |
| 15 | //! an optional [`LocalServer::dir`] root read off the disk for everything else, with the path-traversal |
| 16 | //! reject and content-type map the crate already carries ([`RequestPath`]). |
| 17 | |
| 18 | use crate::{ |
| 19 | file::RequestPath, |
| 20 | http::status::HttpStatus, |
| 21 | }; |
| 22 | |
| 23 | use oxedyne_fe2o3_core::prelude::*; |
| 24 | |
| 25 | use std::{ |
| 26 | borrow::Cow, |
| 27 | collections::BTreeMap, |
| 28 | io::{ |
| 29 | Read, |
| 30 | Write, |
| 31 | }, |
| 32 | net::{ |
| 33 | IpAddr, |
| 34 | Ipv4Addr, |
| 35 | SocketAddr, |
| 36 | TcpListener, |
| 37 | TcpStream, |
| 38 | }, |
| 39 | path::PathBuf, |
| 40 | sync::Arc, |
| 41 | thread, |
| 42 | }; |
| 43 | |
| 44 | // Far past any request head this server ever answers a GET to; a request that has not ended its headers |
| 45 | // by here is not one of the fixed routes, nor a file under the root, asking politely. |
| 46 | const MAX_REQUEST_HEAD: usize = 1 << 16; |
| 47 | |
| 48 | /// Which interface a [`LocalServer`] listens on. |
| 49 | /// |
| 50 | /// `Loopback` binds `127.0.0.1` alone -- there is no "expose to network" knob, which is the right stance |
| 51 | /// for a shell whose only client is a browser tab on the same machine. `Any` binds `0.0.0.0`, for the one |
| 52 | /// case that wants it: a dev preview reachable from a phone on the same network. |
| 53 | pub enum Listen { |
| 54 | Loopback(u16), |
| 55 | Any(u16), |
| 56 | } |
| 57 | |
| 58 | // One fixed in-memory route: the bytes and the exact `Content-Type` string to answer with. |
| 59 | struct Route { |
| 60 | body: Cow<'static, [u8]>, |
| 61 | content_type: String, |
| 62 | } |
| 63 | |
| 64 | /// A blocking static file server for loopback or LAN use. |
| 65 | /// |
| 66 | /// Fixed routes are matched first, exactly, by request path; anything not matched is looked up under the |
| 67 | /// directory root if one was set, and 404s otherwise. Both may be present at once. |
| 68 | pub struct LocalServer { |
| 69 | root: Option<PathBuf>, // files served off the disk, `None` for a routes-only server |
| 70 | routes: BTreeMap<String, Route>, // fixed in-memory answers, keyed by exact request path |
| 71 | } |
| 72 | |
| 73 | impl LocalServer { |
| 74 | |
| 75 | /// A server with no directory root: only the fixed routes added with [`LocalServer::route`] are |
| 76 | /// served. |
| 77 | pub fn new() -> Self { |
| 78 | Self { |
| 79 | root: None, |
| 80 | routes: BTreeMap::new(), |
| 81 | } |
| 82 | } |
| 83 | |
| 84 | /// A server that reads files under `root`. A request path is resolved beneath it (`/` maps to |
| 85 | /// `index.html`), rejecting any `.`/`..` component; fixed routes still take precedence. |
| 86 | pub fn dir(root: impl Into<PathBuf>) -> Self { |
| 87 | Self { |
| 88 | root: Some(root.into()), |
| 89 | routes: BTreeMap::new(), |
| 90 | } |
| 91 | } |
| 92 | |
| 93 | /// Add a fixed in-memory route answered at exactly `path` (e.g. `/`, `/pearl.js`). A borrowed body |
| 94 | /// costs nothing to hold; an owned one (a document read into memory) is moved in. |
| 95 | pub fn route(mut self, path: &str, body: Cow<'static, [u8]>, content_type: &str) -> Self { |
| 96 | self.routes.insert( |
| 97 | path.to_string(), |
| 98 | Route { |
| 99 | body, |
| 100 | content_type: content_type.to_string(), |
| 101 | }, |
| 102 | ); |
| 103 | self |
| 104 | } |
| 105 | |
| 106 | /// Bind a listener on the chosen interface. Port `0` asks the OS for an ephemeral one, which the |
| 107 | /// caller reads back with `listener.local_addr()`. |
| 108 | pub fn bind(listen: Listen) -> Outcome<TcpListener> { |
| 109 | let addr = match listen { |
| 110 | Listen::Loopback(port) => SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), port), |
| 111 | Listen::Any(port) => SocketAddr::new(IpAddr::V4(Ipv4Addr::UNSPECIFIED), port), |
| 112 | }; |
| 113 | Ok(res!(TcpListener::bind(addr), IO, Network, Init)) |
| 114 | } |
| 115 | |
| 116 | /// Serve forever on `listener`, one thread per connection. Returns only on a fatal accept error; a |
| 117 | /// per-connection failure is logged and the loop keeps running, since a broken connection is a client |
| 118 | /// that moved on, not a reason to stop the server. |
| 119 | pub fn serve(self, listener: TcpListener) -> Outcome<()> { |
| 120 | let shared = Arc::new(self); |
| 121 | loop { |
| 122 | let (stream, _peer) = res!(listener.accept(), IO, Network); |
| 123 | let server = shared.clone(); |
| 124 | let spawned = thread::Builder::new() |
| 125 | .name("local-http-conn".to_string()) |
| 126 | .spawn(move || { |
| 127 | if let Err(e) = server.handle(stream) { |
| 128 | warn!("Local HTTP connection: {}", e); |
| 129 | } |
| 130 | }); |
| 131 | if let Err(e) = spawned { |
| 132 | warn!("Local HTTP server: could not spawn a connection thread: {}", e); |
| 133 | } |
| 134 | } |
| 135 | } |
| 136 | |
| 137 | /// Reads one HTTP/1.1 request head from `stream` and answers it. Every route here is a bodiless GET, |
| 138 | /// so reading stops at the header block's terminating blank line -- there is never a body to read. |
| 139 | fn handle(&self, mut stream: TcpStream) -> Outcome<()> { |
| 140 | let mut req = Vec::new(); |
| 141 | let mut chunk = [0u8; 4096]; |
| 142 | loop { |
| 143 | let n = res!(stream.read(&mut chunk), IO, Network, Read); |
| 144 | if n == 0 { |
| 145 | break; // the peer closed before a full header block arrived |
| 146 | } |
| 147 | req.extend_from_slice(&chunk[..n]); |
| 148 | if header_block_ends(&req) { |
| 149 | break; |
| 150 | } |
| 151 | if req.len() > MAX_REQUEST_HEAD { |
| 152 | return self.respond(&mut stream, HttpStatus::BadRequest, "text/plain", |
| 153 | b"request head too large"); |
| 154 | } |
| 155 | } |
| 156 | if req.is_empty() { |
| 157 | return Ok(()); // nothing arrived; the peer closed an idle connection |
| 158 | } |
| 159 | |
| 160 | let head = String::from_utf8_lossy(&req); |
| 161 | let line = head.lines().next().unwrap_or(""); |
| 162 | let mut parts = line.split_whitespace(); |
| 163 | let method = parts.next().unwrap_or(""); |
| 164 | let target = parts.next().unwrap_or("/"); |
| 165 | let path = target.split('?').next().unwrap_or("/"); |
| 166 | |
| 167 | if method != "GET" { |
| 168 | return self.respond(&mut stream, HttpStatus::MethodNotAllowed, "text/plain", |
| 169 | b"only GET is served here"); |
| 170 | } |
| 171 | |
| 172 | // A fixed route wins over the directory root, so a caller can shadow or supply files the root does |
| 173 | // not hold. |
| 174 | if let Some(route) = self.routes.get(path) { |
| 175 | let content_type = route.content_type.clone(); |
| 176 | return self.respond(&mut stream, HttpStatus::OK, &content_type, &route.body); |
| 177 | } |
| 178 | |
| 179 | match &self.root { |
| 180 | Some(root) => { |
| 181 | let root_str = root.to_string_lossy().into_owned(); |
| 182 | let index = "index.html".to_string(); |
| 183 | // A `.`/`..` component, or a path that ends in `/`, is refused rather than served: the |
| 184 | // reject is the crate's own, shared with the async server. |
| 185 | let resolved = match RequestPath::new(path).validate(&root_str, &index) { |
| 186 | Ok(p) => p, |
| 187 | Err(_) => return self.respond(&mut stream, HttpStatus::Forbidden, "text/plain", |
| 188 | b"forbidden"), |
| 189 | }; |
| 190 | match std::fs::read(&resolved) { |
| 191 | Ok(body) => { |
| 192 | let content_type = fmt!("{}", RequestPath::content_type(&resolved)); |
| 193 | self.respond(&mut stream, HttpStatus::OK, &content_type, &body) |
| 194 | }, |
| 195 | // A missing file, or a path that named a directory, is simply not here. |
| 196 | Err(_) => self.respond(&mut stream, HttpStatus::NotFound, "text/plain", b"not found"), |
| 197 | } |
| 198 | }, |
| 199 | None => self.respond(&mut stream, HttpStatus::NotFound, "text/plain", b"not found"), |
| 200 | } |
| 201 | } |
| 202 | |
| 203 | fn respond( |
| 204 | &self, |
| 205 | stream: &mut TcpStream, |
| 206 | status: HttpStatus, |
| 207 | content_type: &str, |
| 208 | body: &[u8], |
| 209 | ) |
| 210 | -> Outcome<()> |
| 211 | { |
| 212 | // `Cache-Control: no-store` because the motivating callers poll: a dev preview re-fetches its own |
| 213 | // status files, and a stale answer there is a preview showing the wrong page. |
| 214 | let head = fmt!( |
| 215 | "HTTP/1.1 {} {}\r\n\ |
| 216 | Content-Type: {}\r\n\ |
| 217 | Content-Length: {}\r\n\ |
| 218 | Cache-Control: no-store\r\n\ |
| 219 | Connection: close\r\n\r\n", |
| 220 | status, status.desc(), content_type, body.len()); |
| 221 | res!(stream.write_all(head.as_bytes()), IO, Network, Write); |
| 222 | res!(stream.write_all(body), IO, Network, Write); |
| 223 | Ok(()) |
| 224 | } |
| 225 | } |
| 226 | |
| 227 | impl Default for LocalServer { |
| 228 | fn default() -> Self { |
| 229 | Self::new() |
| 230 | } |
| 231 | } |
| 232 | |
| 233 | /// Does `buf` carry a complete HTTP header block (ending `\r\n\r\n`)? |
| 234 | fn header_block_ends(buf: &[u8]) -> bool { |
| 235 | buf.windows(4).any(|w| w == b"\r\n\r\n") |
| 236 | } |
| 237 | |
| 238 | #[cfg(test)] |
| 239 | mod tests { |
| 240 | use super::*; |
| 241 | |
| 242 | use std::time::Duration; |
| 243 | |
| 244 | /// One raw GET over loopback, read to connection close (every response here sets `Connection: |
| 245 | /// close`), returned as the response text -- head and body together, since a small fixed asset needs |
| 246 | /// no separate parse to check. |
| 247 | fn get(port: u16, path: &str) -> String { |
| 248 | let mut stream = TcpStream::connect(("127.0.0.1", port)) |
| 249 | .expect("connecting to the test server"); |
| 250 | let req = fmt!("GET {} HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n", path); |
| 251 | stream.write_all(req.as_bytes()).expect("writing the test request"); |
| 252 | let mut buf = Vec::new(); |
| 253 | stream.read_to_end(&mut buf).expect("reading the test response"); |
| 254 | String::from_utf8_lossy(&buf).into_owned() |
| 255 | } |
| 256 | |
| 257 | /// A raw request with an arbitrary method, for the GET-only check. |
| 258 | fn request(port: u16, method: &str, path: &str) -> String { |
| 259 | let mut stream = TcpStream::connect(("127.0.0.1", port)) |
| 260 | .expect("connecting to the test server"); |
| 261 | let req = fmt!("{} {} HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n", method, path); |
| 262 | stream.write_all(req.as_bytes()).expect("writing the test request"); |
| 263 | let mut buf = Vec::new(); |
| 264 | stream.read_to_end(&mut buf).expect("reading the test response"); |
| 265 | String::from_utf8_lossy(&buf).into_owned() |
| 266 | } |
| 267 | |
| 268 | fn head_of(s: &str) -> &str { |
| 269 | s.lines().next().unwrap_or("") |
| 270 | } |
| 271 | |
| 272 | // The whole gate for the fixed-route mode (the shell case ported from `fe2o3_pearlite::shell`): the |
| 273 | // server starts on loopback, serves an index and a script byte for byte, serves a caller's document |
| 274 | // at its own path, 404s an unknown path, refuses a non-GET, and marks every answer `no-store`. |
| 275 | #[test] |
| 276 | fn test_fixed_routes_are_served_and_others_refused_00() -> Outcome<()> { |
| 277 | let index = "<!doctype html><title>Fixture</title><script src=pearl.js></script>"; |
| 278 | let script = "// pearl reader fixture\n"; |
| 279 | let doc = "pearl 1\nnot a real document, just fixture bytes\n"; |
| 280 | let server = LocalServer::new() |
| 281 | .route("/", Cow::Borrowed(index.as_bytes()), "text/html; charset=utf-8") |
| 282 | .route("/index.html", Cow::Borrowed(index.as_bytes()), "text/html; charset=utf-8") |
| 283 | .route("/pearl.js", Cow::Borrowed(script.as_bytes()), "text/javascript; charset=utf-8") |
| 284 | .route("/opened.prl", Cow::Owned(doc.as_bytes().to_vec()), "text/plain; charset=utf-8"); |
| 285 | let listener = res!(LocalServer::bind(Listen::Loopback(0))); |
| 286 | let port = res!(listener.local_addr(), IO, Network).port(); |
| 287 | thread::spawn(move || { |
| 288 | let _ = server.serve(listener); |
| 289 | }); |
| 290 | // The listener is already bound before the thread starts; the sleep is a generous margin for the |
| 291 | // accept loop to be running, not something the assertions depend on for correctness. |
| 292 | thread::sleep(Duration::from_millis(50)); |
| 293 | |
| 294 | let got_index = get(port, "/"); |
| 295 | assert!(got_index.starts_with("HTTP/1.1 200"), "index did not answer 200: {}", head_of(&got_index)); |
| 296 | assert!(got_index.contains("Fixture"), "index body was not the fixture page"); |
| 297 | assert!(got_index.to_lowercase().contains("cache-control: no-store"), |
| 298 | "index answer was not marked no-store: {}", got_index); |
| 299 | |
| 300 | let got_js = get(port, "/pearl.js"); |
| 301 | assert!(got_js.starts_with("HTTP/1.1 200"), "pearl.js did not answer 200: {}", head_of(&got_js)); |
| 302 | assert!(got_js.to_lowercase().contains("content-type: text/javascript"), |
| 303 | "pearl.js was not served as javascript: {}", got_js); |
| 304 | |
| 305 | let got_doc = get(port, "/opened.prl"); |
| 306 | assert!(got_doc.starts_with("HTTP/1.1 200"), "the document did not answer 200"); |
| 307 | assert!(got_doc.ends_with(doc), "the served document bytes did not match what was given"); |
| 308 | |
| 309 | let missing = get(port, "/nope"); |
| 310 | assert!(missing.starts_with("HTTP/1.1 404"), "an unknown path should 404: {}", head_of(&missing)); |
| 311 | |
| 312 | let posted = request(port, "POST", "/"); |
| 313 | assert!(posted.starts_with("HTTP/1.1 405"), "a non-GET should 405: {}", head_of(&posted)); |
| 314 | Ok(()) |
| 315 | } |
| 316 | |
| 317 | // The directory-root mode (the dev-preview case): files under the root are read off the disk with the |
| 318 | // right content type, `/` maps to `index.html`, and a `..` traversal is refused rather than served. |
| 319 | #[test] |
| 320 | fn test_a_directory_root_serves_files_and_refuses_traversal_01() -> Outcome<()> { |
| 321 | let dir = std::env::temp_dir() |
| 322 | .join(fmt!("fe2o3-local-{}-{}", std::process::id(), "dir")); |
| 323 | res!(std::fs::create_dir_all(&dir), IO, File); |
| 324 | res!(std::fs::write(dir.join("index.html"), b"<!doctype html><title>Root</title>"), IO, File); |
| 325 | res!(std::fs::write(dir.join("pages.json"), br#"{"stamp":"x","pages":2}"#), IO, File); |
| 326 | |
| 327 | let server = LocalServer::dir(dir.clone()); |
| 328 | let listener = res!(LocalServer::bind(Listen::Loopback(0))); |
| 329 | let port = res!(listener.local_addr(), IO, Network).port(); |
| 330 | thread::spawn(move || { |
| 331 | let _ = server.serve(listener); |
| 332 | }); |
| 333 | thread::sleep(Duration::from_millis(50)); |
| 334 | |
| 335 | let root = get(port, "/"); |
| 336 | assert!(root.starts_with("HTTP/1.1 200"), "`/` did not map to index.html: {}", head_of(&root)); |
| 337 | assert!(root.contains("Root"), "`/` did not serve index.html's bytes"); |
| 338 | |
| 339 | let json = get(port, "/pages.json"); |
| 340 | assert!(json.starts_with("HTTP/1.1 200"), "pages.json did not answer 200"); |
| 341 | assert!(json.to_lowercase().contains("content-type: application/json"), |
| 342 | "pages.json was not served as JSON: {}", json); |
| 343 | |
| 344 | let escape = get(port, "/../Cargo.toml"); |
| 345 | assert!(!escape.starts_with("HTTP/1.1 200"), |
| 346 | "a `..` traversal was served: {}", head_of(&escape)); |
| 347 | |
| 348 | let _ = std::fs::remove_dir_all(&dir); |
| 349 | Ok(()) |
| 350 | } |
| 351 | } |