Oregami
Repositories/oxedyne/fe2o3

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
18use crate::{
19 file::RequestPath,
20 http::status::HttpStatus,
21};
22
23use oxedyne_fe2o3_core::prelude::*;
24
25use 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.
46const 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.
53pub 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.
59struct 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.
68pub 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
73impl 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
227impl 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`)?
234fn header_block_ends(buf: &[u8]) -> bool {
235 buf.windows(4).any(|w| w == b"\r\n\r\n")
236}
237
238#[cfg(test)]
239mod 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}