1use std::path::{Path, PathBuf};
11
12use async_executor::Executor;
13use async_io::Async;
14use futures_lite::future;
15use http_body_util::Full;
16use hyper::body::{Bytes, Incoming};
17use hyper::server::conn::http1;
18use hyper::service::service_fn;
19use hyper::{Request, Response, StatusCode};
20use smol_hyper::rt::{FuturesIo, SmolTimer};
21
22pub fn serve(dir: &Path, preferred_port: u16) -> Result<(), String> {
27 serve_listener(bind(preferred_port)?, dir)
28}
29
30fn serve_listener(listener: Async<std::net::TcpListener>, dir: &Path) -> Result<(), String> {
36 let url = listener
37 .get_ref()
38 .local_addr()
39 .map_err(|error| format!("reading the listener's address: {error}"))?;
40 println!(
41 "serving {} on http://{url}/index.html — Ctrl-C stops the server",
42 dir.display()
43 );
44
45 let executor = Executor::new();
46 future::block_on(executor.run(async {
47 loop {
48 let (stream, peer) = match listener.accept().await {
49 Ok(accepted) => accepted,
50 Err(error) => {
51 eprintln!("accepting a connection: {error}");
52 continue;
53 }
54 };
55 let dir = dir.to_path_buf();
56 executor
57 .spawn(async move {
58 if let Err(error) = serve_connection(stream, dir).await {
59 eprintln!("{peer}: {error}");
60 }
61 })
62 .detach();
63 }
64 }))
65}
66
67fn bind(preferred_port: u16) -> Result<Async<std::net::TcpListener>, String> {
69 let loopback = std::net::Ipv4Addr::LOCALHOST;
70 let last = preferred_port.saturating_add(10);
71 (preferred_port..=last)
72 .find_map(|port| Async::<std::net::TcpListener>::bind((loopback, port)).ok())
73 .ok_or_else(|| format!("no free port in {preferred_port}..={last} on loopback"))
74}
75
76async fn serve_connection(stream: Async<std::net::TcpStream>, dir: PathBuf) -> Result<(), String> {
81 let service = service_fn(move |request: Request<Incoming>| {
84 let dir = dir.clone();
85 async move { Ok::<_, std::convert::Infallible>(answer(request, &dir)) }
86 });
87
88 http1::Builder::new()
89 .timer(SmolTimer::new())
90 .serve_connection(FuturesIo::new(stream), service)
91 .await
92 .map_err(|error| format!("serving the connection: {error}"))
93}
94
95fn answer(request: Request<Incoming>, dir: &Path) -> Response<Full<Bytes>> {
97 if request.method() != hyper::Method::GET && request.method() != hyper::Method::HEAD {
98 return plain(StatusCode::METHOD_NOT_ALLOWED, "method not allowed");
99 }
100
101 let Some(path) = resolve(request.uri().path(), dir) else {
102 return plain(StatusCode::NOT_FOUND, "not found");
103 };
104
105 match std::fs::read(&path) {
106 Ok(bytes) => Response::builder()
107 .status(StatusCode::OK)
108 .header(hyper::header::CONTENT_TYPE, mime(&path))
109 .header(hyper::header::CACHE_CONTROL, "no-store")
111 .body(Full::new(Bytes::from(bytes)))
112 .expect("the response is well formed"),
113 Err(error) => {
114 eprintln!("reading {}: {error}", path.display());
115 plain(StatusCode::NOT_FOUND, "not found")
116 }
117 }
118}
119
120fn plain(status: StatusCode, message: &'static str) -> Response<Full<Bytes>> {
122 Response::builder()
123 .status(status)
124 .header(hyper::header::CONTENT_TYPE, "text/plain; charset=utf-8")
125 .body(Full::new(Bytes::from_static(message.as_bytes())))
126 .expect("the response is well formed")
127}
128
129fn resolve(target: &str, dir: &Path) -> Option<PathBuf> {
134 if target.contains("..") {
135 return None;
136 }
137 let path = target.trim_start_matches('/');
138 let path = if path.is_empty() { "index.html" } else { path };
139 let path = dir.join(path);
140 let path = if path.is_dir() {
143 path.join("index.html")
144 } else {
145 path
146 };
147 path.is_file().then_some(path)
148}
149
150fn mime(path: &Path) -> &'static str {
152 match path.extension().and_then(|ext| ext.to_str()) {
153 Some("html") => "text/html; charset=utf-8",
154 Some("js") | Some("mjs") => "text/javascript; charset=utf-8",
157 Some("wasm") => "application/wasm",
159 Some("css") => "text/css; charset=utf-8",
160 Some("json") => "application/json",
161 Some("png") => "image/png",
162 Some("svg") => "image/svg+xml",
163 Some("ico") => "image/x-icon",
164 Some("txt") => "text/plain; charset=utf-8",
165 _ => "application/octet-stream",
166 }
167}
168
169#[cfg(test)]
170mod tests {
171 use std::io::{Read, Write};
172 use std::net::TcpStream;
173
174 use super::*;
175
176 fn fixture() -> PathBuf {
179 let dir = std::env::temp_dir().join(format!("unlit3d-http-{}", std::process::id()));
180 std::fs::create_dir_all(&dir).expect("the fixture directory is writable");
181 std::fs::write(dir.join("index.html"), "<html></html>").expect("index is writable");
182 std::fs::write(dir.join("app.js"), "console.log(1)").expect("script is writable");
183 std::fs::write(dir.join("mod.wasm"), b"\0asm").expect("wasm is writable");
184 dir
185 }
186
187 fn serve_fixture(dir: PathBuf) -> String {
194 let listener = bind(0).expect("a free port is available on loopback");
197 let addr = listener
198 .get_ref()
199 .local_addr()
200 .expect("the listener has an address");
201 std::thread::spawn(move || {
202 let _ = serve_listener(listener, &dir);
203 });
204 for _ in 0..100 {
207 if TcpStream::connect(addr).is_ok() {
208 break;
209 }
210 std::thread::sleep(std::time::Duration::from_millis(20));
211 }
212 format!("{addr}")
213 }
214
215 fn request(addr: &str, method: &str, target: &str) -> String {
217 let mut stream = TcpStream::connect(addr).expect("the test server is listening");
218 write!(
219 stream,
220 "{method} {target} HTTP/1.1\r\nHost: {addr}\r\nConnection: close\r\n\r\n"
221 )
222 .expect("the request is written");
223 let mut response = Vec::new();
224 stream
225 .read_to_end(&mut response)
226 .expect("the response is read");
227 String::from_utf8_lossy(&response).into_owned()
228 }
229
230 fn status(response: &str) -> &str {
232 response
233 .lines()
234 .next()
235 .expect("a response has a status line")
236 }
237
238 fn header<'a>(response: &'a str, name: &str) -> Option<&'a str> {
240 response
241 .lines()
242 .take_while(|line| !line.is_empty())
243 .find_map(|line| {
244 let (key, value) = line.split_once(':')?;
245 key.eq_ignore_ascii_case(name).then(|| value.trim())
246 })
247 }
248
249 #[test]
250 fn serves_files_with_the_mime_a_browser_needs() {
251 let addr = serve_fixture(fixture());
252
253 let index = request(&addr, "GET", "/");
254 assert_eq!(status(&index), "HTTP/1.1 200 OK");
255 assert_eq!(
256 header(&index, "content-type"),
257 Some("text/html; charset=utf-8")
258 );
259 assert!(index.ends_with("<html></html>"));
260
261 let script = request(&addr, "GET", "/app.js");
262 assert_eq!(
263 header(&script, "content-type"),
264 Some("text/javascript; charset=utf-8")
265 );
266
267 let wasm = request(&addr, "GET", "/mod.wasm");
270 assert_eq!(header(&wasm, "content-type"), Some("application/wasm"));
271 }
272
273 #[test]
274 fn refuses_what_is_not_there_and_what_escapes_the_directory() {
275 let addr = serve_fixture(fixture());
276
277 assert_eq!(
278 status(&request(&addr, "GET", "/missing.html")),
279 "HTTP/1.1 404 Not Found"
280 );
281 assert_eq!(
283 status(&request(&addr, "GET", "/../Cargo.toml")),
284 "HTTP/1.1 404 Not Found"
285 );
286 assert_eq!(
287 status(&request(&addr, "POST", "/")),
288 "HTTP/1.1 405 Method Not Allowed"
289 );
290 }
291
292 #[test]
293 fn head_answers_with_headers_only() {
294 let addr = serve_fixture(fixture());
295 let response = request(&addr, "HEAD", "/index.html");
296
297 assert_eq!(status(&response), "HTTP/1.1 200 OK");
298 assert_eq!(header(&response, "content-length"), Some("13"));
299 let body = response
301 .split_once("\r\n\r\n")
302 .expect("a response has a body separator")
303 .1;
304 assert!(body.is_empty(), "HEAD returned a body: {body:?}");
305 }
306}