1use std::collections::BTreeMap;
15use std::path::{Path, PathBuf};
16use std::sync::Mutex;
17
18use crate::http::router::Response;
19
20use super::model::Surface;
21
22const MEDIA_TYPES: &[(&str, &str)] = &[
26 ("html", "text/html; charset=utf-8"),
27 ("css", "text/css; charset=utf-8"),
28 ("js", "text/javascript; charset=utf-8"),
29 ("mjs", "text/javascript; charset=utf-8"),
30 ("json", "application/json"),
31 ("map", "application/json"),
32 ("xml", "application/xml"),
33 ("txt", "text/plain; charset=utf-8"),
34 ("md", "text/markdown; charset=utf-8"),
35 ("svg", "image/svg+xml"),
36 ("png", "image/png"),
37 ("jpg", "image/jpeg"),
38 ("jpeg", "image/jpeg"),
39 ("gif", "image/gif"),
40 ("webp", "image/webp"),
41 ("ico", "image/x-icon"),
42 ("woff", "font/woff"),
43 ("woff2", "font/woff2"),
44 ("ttf", "font/ttf"),
45 ("pdf", "application/pdf"),
46];
47
48pub const DOCUMENT_CACHE: &str = "no-cache";
52
53pub const IMMUTABLE_CACHE: &str = "public, max-age=31536000, immutable";
56
57pub const CACHE_MAX_BYTES: usize = 512 * 1024;
60
61#[derive(Debug)]
66pub struct Files {
67 surface: Surface,
69 root: Option<PathBuf>,
71 artifact: String,
73 cached: Mutex<BTreeMap<String, Loaded>>,
75}
76
77#[derive(Debug, Clone)]
78struct Loaded {
79 body: Vec<u8>,
80 media_type: &'static str,
81}
82
83impl Files {
84 pub fn new(surface: &Surface, root: &Path) -> Self {
89 let artifact = surface
90 .artifact
91 .as_ref()
92 .map(|a| a.to_string_lossy().replace('\\', "/"))
93 .unwrap_or_default();
94 let dir = surface.artifact.as_ref().map(|a| root.join(a));
95 let canonical = dir.as_ref().and_then(|d| d.canonicalize().ok());
96 Files {
97 surface: surface.clone(),
98 root: canonical,
99 artifact,
100 cached: Mutex::new(BTreeMap::new()),
101 }
102 }
103
104 pub fn available(&self) -> bool {
106 self.root.is_some()
107 }
108
109 pub fn surface(&self) -> &Surface {
111 &self.surface
112 }
113
114 pub fn respond(&self, path: &str) -> Response {
116 let Some(root) = &self.root else {
117 return self.unavailable();
118 };
119 if path == self.surface.mount.as_str() && !self.surface.mount.is_root() {
122 return Response::new(
123 308,
124 "text/plain; charset=utf-8",
125 format!("{}\n", self.surface.mount.prefix()),
126 )
127 .with_header("Location", self.surface.mount.prefix());
128 }
129 let Some(relative) = self.relative(path).and_then(|r| safe_relative(&r)) else {
130 return Response::error(
131 400,
132 "invalid_input",
133 &format!("'{path}' is not a path under {}", self.surface.mount),
134 );
135 };
136 if let Ok(cache) = self.cached.lock() {
137 if let Some(hit) = cache.get(&relative) {
138 return document(hit.body.clone(), hit.media_type);
139 }
140 }
141 let Some(resolved) = resolve(root, &relative, self.surface.index.as_deref()) else {
142 return Response::error(
143 404,
144 "not_found",
145 &format!(
146 "no file for '{path}' in {} ({}); the surface is '{}', built by {}",
147 self.artifact, self.surface.title, self.surface.id, self.surface.producer
148 ),
149 );
150 };
151 let Some(media_type) = media_type(&resolved.name) else {
152 return Response::error(
153 404,
154 "not_found",
155 &format!(
156 "'{path}' names a file this server does not serve; {} answers the types it generates",
157 self.surface.id
158 ),
159 );
160 };
161 self.read(&resolved.path, &relative, media_type)
162 }
163
164 fn relative(&self, path: &str) -> Option<String> {
169 let mount = &self.surface.mount;
170 let rest = if mount.is_root() {
171 path.strip_prefix('/')?
172 } else if path == mount.as_str() {
173 ""
174 } else {
175 path.strip_prefix(&mount.prefix())?
176 };
177 Some(rest.to_string())
178 }
179
180 fn read(&self, path: &Path, name: &str, media_type: &'static str) -> Response {
181 let body = match std::fs::read(path) {
182 Ok(bytes) => bytes,
183 Err(e) => {
184 tracing::warn!(
185 surface = %self.surface.id,
186 file = name,
187 "a file the surface offers cannot be read: {e}"
188 );
189 return Response::error(
190 500,
191 "internal",
192 &format!(
193 "'{}/{name}' is listed by {} and cannot be read: {e}",
194 self.artifact, self.surface.id
195 ),
196 );
197 }
198 };
199 if body.len() <= CACHE_MAX_BYTES {
200 if let Ok(mut cache) = self.cached.lock() {
201 cache.insert(
202 name.to_string(),
203 Loaded {
204 body: body.clone(),
205 media_type,
206 },
207 );
208 }
209 }
210 document(body, media_type)
211 }
212
213 fn unavailable(&self) -> Response {
222 Response::error(
223 503,
224 "unavailable",
225 &format!(
226 "'{}' ({}) is not built: {} does not exist when this process started. Run: {} — then restart this server",
227 self.surface.id, self.surface.title, self.artifact, self.surface.producer
228 ),
229 )
230 }
231}
232
233fn document(body: Vec<u8>, media_type: &'static str) -> Response {
234 Response::new(200, media_type, body).with_header("Cache-Control", DOCUMENT_CACHE)
235}
236
237struct Resolved {
239 path: PathBuf,
241 name: String,
243}
244
245fn resolve(root: &Path, relative: &str, index: Option<&str>) -> Option<Resolved> {
251 let mut name = safe_relative(relative)?;
252 if name.is_empty() || name.ends_with('/') {
253 name.push_str(index?);
254 }
255 let mut candidate = root.join(&name);
256 if candidate.is_dir() {
257 name = format!("{}/{}", name.trim_end_matches('/'), index?);
258 candidate = root.join(&name);
259 }
260 let resolved = candidate.canonicalize().ok()?;
261 if !resolved.starts_with(root) {
262 tracing::warn!(
263 file = %relative,
264 "a request resolved outside the surface's directory and was refused"
265 );
266 return None;
267 }
268 if !resolved.is_file() {
269 return None;
270 }
271 Some(Resolved {
272 path: resolved,
273 name,
274 })
275}
276
277pub fn safe_relative(path: &str) -> Option<String> {
300 if path.len() > 1024 {
301 return None;
302 }
303 if path.is_empty() {
304 return Some(String::new());
305 }
306 if path.starts_with('/') {
307 return None;
308 }
309 let trailing = path.ends_with('/');
310 let body = path.trim_end_matches('/');
311 if body.is_empty() {
312 return Some(String::new());
313 }
314 for segment in body.split('/') {
315 if segment.is_empty() || segment == "." || segment == ".." {
316 return None;
317 }
318 if !segment
319 .bytes()
320 .all(|b| b.is_ascii_alphanumeric() || b"._-~@+()'!".contains(&b))
321 {
322 return None;
323 }
324 }
325 Some(if trailing {
326 format!("{body}/")
327 } else {
328 body.to_string()
329 })
330}
331
332pub fn media_type(name: &str) -> Option<&'static str> {
342 let extension = name.rsplit_once('.')?.1.to_ascii_lowercase();
343 MEDIA_TYPES
344 .iter()
345 .find(|(e, _)| *e == extension)
346 .map(|(_, t)| *t)
347}
348
349#[cfg(test)]
350mod tests {
351 use super::*;
352 use crate::web::model::{Availability, Category, Mount, SurfaceKind, Visibility};
353
354 fn surface(mount: &str, artifact: &str) -> Surface {
355 Surface {
356 id: "docs".into(),
357 title: "Documentation".into(),
358 category: Category::Documentation,
359 visibility: Visibility::Public,
360 kind: SurfaceKind::StaticDirectory,
361 mount: Mount::parse(mount).unwrap(),
362 producer: "scripts/site-build --serve".into(),
363 feature: None,
364 artifact: Some(artifact.into()),
365 index: Some("index.html".into()),
366 availability: Availability::ServedOnly,
367 built_from: None,
368 provenance: Default::default(),
369 }
370 }
371
372 fn tree() -> (tempfile::TempDir, Files) {
373 let tmp = tempfile::tempdir().unwrap();
374 let dir = tmp.path().join("target/web/docs");
375 std::fs::create_dir_all(dir.join("cli")).unwrap();
376 std::fs::write(dir.join("index.html"), "<h1>Documentation</h1>").unwrap();
377 std::fs::write(dir.join("cli/index.html"), "<h1>CLI</h1>").unwrap();
378 std::fs::write(dir.join("style.css"), "body{}").unwrap();
379 std::fs::write(dir.join("secrets.env"), "TOKEN=1").unwrap();
380 let files = Files::new(&surface("/docs", "target/web/docs"), tmp.path());
381 (tmp, files)
382 }
383
384 #[test]
385 fn the_mount_answers_with_the_index_and_its_bare_form_redirects() {
386 let (_tmp, files) = tree();
387 assert!(files.available());
388 let index = files.respond("/docs/");
389 assert_eq!(index.status, 200);
390 assert_eq!(index.content_type, "text/html; charset=utf-8");
391 assert!(index.body.text().contains("Documentation"));
392
393 let bare = files.respond("/docs");
394 assert_eq!(bare.status, 308);
395 assert!(bare
396 .headers
397 .iter()
398 .any(|(k, v)| k == "Location" && v == "/docs/"));
399 }
400
401 #[test]
402 fn a_nested_page_and_an_asset_are_served_with_their_types() {
403 let (_tmp, files) = tree();
404 assert!(files.respond("/docs/cli/").body.text().contains("CLI"));
405 assert!(files.respond("/docs/cli").body.text().contains("CLI"));
406 let css = files.respond("/docs/style.css");
407 assert_eq!(css.content_type, "text/css; charset=utf-8");
408 assert!(css
409 .headers
410 .iter()
411 .any(|(k, v)| k == "Cache-Control" && v == DOCUMENT_CACHE));
412 }
413
414 #[test]
415 fn nothing_escapes_the_surface() {
416 let (tmp, files) = tree();
417 std::fs::write(tmp.path().join("secret.html"), "<b>outside</b>").unwrap();
418 for hostile in [
419 "/docs/../secret.html",
420 "/docs/../../etc/passwd",
421 "/docs/cli/../../secret.html",
422 "/docs/%2e%2e/secret.html",
423 "/docs//etc/passwd",
424 ] {
425 let response = files.respond(hostile);
426 assert!(
427 response.status == 400 || response.status == 404,
428 "{hostile} answered {}",
429 response.status
430 );
431 assert!(!response.body.text().contains("outside"), "{hostile}");
432 }
433 }
434
435 #[test]
436 fn a_symlink_out_of_the_surface_is_refused() {
437 let (tmp, files) = tree();
438 std::fs::write(tmp.path().join("outside.html"), "<b>outside</b>").unwrap();
439 #[cfg(unix)]
440 std::os::unix::fs::symlink(
441 tmp.path().join("outside.html"),
442 tmp.path().join("target/web/docs/link.html"),
443 )
444 .unwrap();
445 let response = files.respond("/docs/link.html");
446 assert_eq!(response.status, 404, "{}", response.body);
447 }
448
449 #[test]
450 fn an_extension_the_surface_does_not_generate_is_not_served() {
451 let (_tmp, files) = tree();
452 assert_eq!(files.respond("/docs/secrets.env").status, 404);
453 }
454
455 #[test]
456 fn a_surface_whose_producer_has_not_run_says_what_to_run() {
457 let tmp = tempfile::tempdir().unwrap();
458 let files = Files::new(&surface("/docs", "target/web/docs"), tmp.path());
459 assert!(!files.available());
460 let response = files.respond("/docs/");
461 assert_eq!(response.status, 503);
462 assert!(response.body.text().contains("scripts/site-build --serve"));
463 assert!(response.body.text().contains("target/web/docs"));
464 }
465
466 #[test]
467 fn a_surface_serves_only_paths_it_owns() {
468 let (_tmp, files) = tree();
469 assert_eq!(files.respond("/docs/index.html").status, 200);
471 assert_eq!(files.respond("/elsewhere/index.html").status, 400);
472 assert_eq!(files.respond("/docsx").status, 400);
473 assert_eq!(files.surface().id, "docs");
474 }
475
476 #[test]
477 fn a_root_mount_answers_from_the_top_of_its_directory() {
478 let tmp = tempfile::tempdir().unwrap();
479 let dir = tmp.path().join("target/web/docs");
480 std::fs::create_dir_all(&dir).unwrap();
481 std::fs::write(dir.join("index.html"), "<h1>root</h1>").unwrap();
482 let files = Files::new(&surface("/", "target/web/docs"), tmp.path());
483 assert_eq!(files.respond("/").status, 200);
484 assert!(files.respond("/").body.text().contains("root"));
485 assert_ne!(files.respond("/").status, 308);
487 }
488
489 #[test]
490 fn a_surface_with_no_index_answers_nothing_for_its_mount() {
491 let (tmp, _files) = tree();
492 let mut without = surface("/docs", "target/web/docs");
493 without.index = None;
494 let files = Files::new(&without, tmp.path());
495 assert_eq!(files.respond("/docs/").status, 404);
496 assert_eq!(files.respond("/docs/style.css").status, 200);
498 }
499
500 #[test]
501 fn a_media_type_is_read_from_the_name_whatever_its_case() {
502 assert_eq!(media_type("LOGO.PNG"), Some("image/png"));
503 assert_eq!(media_type("a.b.json"), Some("application/json"));
504 assert_eq!(media_type("index.html"), media_type("INDEX.HTML"));
505 assert_eq!(media_type(".hidden"), None);
506 assert_eq!(safe_relative(&"a".repeat(2000)), None);
507 assert_eq!(
508 safe_relative("///"),
509 None,
510 "a relative path never starts at the root"
511 );
512 assert_eq!(safe_relative("a//"), Some("a/".to_string()));
513 }
514
515 fn rustdoc_tree() -> (tempfile::TempDir, Files) {
520 let tmp = tempfile::tempdir().unwrap();
521 let dir = tmp.path().join("target/web/rustdoc");
522 std::fs::create_dir_all(dir.join("static.files")).unwrap();
523 std::fs::create_dir_all(dir.join("majordomus_cli")).unwrap();
524 std::fs::write(dir.join("index.html"), "<h1>landing</h1>").unwrap();
525 std::fs::write(dir.join("static.files/a.css"), "body{}").unwrap();
526 std::fs::write(dir.join("static.files/b.js"), "var b;").unwrap();
527 std::fs::write(
529 dir.join("static.files/c.woff2"),
530 [0x77, 0x4f, 0x46, 0x32, 0xff],
531 )
532 .unwrap();
533 std::fs::write(dir.join("majordomus_cli/index.html"), "<h1>crate</h1>").unwrap();
534 std::fs::write(
535 dir.join("majordomus_cli/macro.m!.html"),
536 "<meta http-equiv=\"refresh\" content=\"0;URL=macro.m.html\">",
537 )
538 .unwrap();
539 let mut surface = surface("/rustdoc", "target/web/rustdoc");
540 surface.id = "rustdoc".into();
541 surface.availability = Availability::Both;
542 let files = Files::new(&surface, tmp.path());
543 (tmp, files)
544 }
545
546 #[test]
547 fn a_macros_redirect_stub_is_served_under_the_name_rustdoc_gives_it() {
548 let (_tmp, files) = rustdoc_tree();
549 let stub = files.respond("/rustdoc/majordomus_cli/macro.m!.html");
550 assert_eq!(stub.status, 200, "{}", stub.body);
551 assert_eq!(stub.content_type, "text/html; charset=utf-8");
552 assert!(stub.body.text().contains("URL=macro.m.html"));
553 assert_eq!(
555 files
556 .respond("/rustdoc/majordomus_cli/macro.x!.html")
557 .status,
558 404
559 );
560 }
561
562 #[test]
563 fn every_file_a_rustdoc_tree_needs_is_answered_with_its_type() {
564 let (_tmp, files) = rustdoc_tree();
565 for (path, media) in [
566 ("/rustdoc/", "text/html; charset=utf-8"),
567 ("/rustdoc/index.html", "text/html; charset=utf-8"),
568 ("/rustdoc/majordomus_cli/", "text/html; charset=utf-8"),
569 (
570 "/rustdoc/majordomus_cli/index.html",
571 "text/html; charset=utf-8",
572 ),
573 ("/rustdoc/static.files/a.css", "text/css; charset=utf-8"),
574 (
575 "/rustdoc/static.files/b.js",
576 "text/javascript; charset=utf-8",
577 ),
578 ("/rustdoc/static.files/c.woff2", "font/woff2"),
579 ] {
580 let response = files.respond(path);
581 assert_eq!(response.status, 200, "{path}: {}", response.body);
582 assert_eq!(response.content_type, media, "{path}");
583 }
584 let font = files.respond("/rustdoc/static.files/c.woff2");
585 assert_eq!(font.body.as_bytes(), [0x77, 0x4f, 0x46, 0x32, 0xff]);
586 let bare = files.respond("/rustdoc");
587 assert_eq!(bare.status, 308);
588 assert!(bare
589 .headers
590 .iter()
591 .any(|(k, v)| k == "Location" && v == "/rustdoc/"));
592 for (name, media) in [
596 ("a.html", "text/html; charset=utf-8"),
597 ("a.css", "text/css; charset=utf-8"),
598 ("a.js", "text/javascript; charset=utf-8"),
599 ("a.woff2", "font/woff2"),
600 ("a.svg", "image/svg+xml"),
601 ("a.png", "image/png"),
602 ("a.ico", "image/x-icon"),
603 ("a.json", "application/json"),
604 ("a.txt", "text/plain; charset=utf-8"),
605 ("a.md", "text/markdown; charset=utf-8"),
606 ] {
607 assert_eq!(media_type(name), Some(media), "{name}");
608 }
609 }
610
611 #[test]
612 fn allowing_a_bang_opens_no_way_out_of_the_surface() {
613 let (tmp, files) = rustdoc_tree();
614 std::fs::write(tmp.path().join("secret.html"), "<b>outside</b>").unwrap();
615 std::fs::write(tmp.path().join("target/web/secret!.html"), "<b>outside</b>").unwrap();
616 for hostile in [
617 "/rustdoc/../secret.html",
618 "/rustdoc/../secret!.html",
619 "/rustdoc/!/../../secret.html",
620 "/rustdoc/majordomus_cli/../../secret!.html",
621 "/rustdoc/%2e%2e/secret.html",
622 "/rustdoc/%2E%2E/secret!.html",
623 "/rustdoc/..%2fsecret.html",
624 "/rustdoc/%21/../../secret.html",
625 "/rustdoc//secret.html",
626 "/rustdoc/./index.html",
627 "/rustdoc/..!/secret.html",
628 ] {
629 let response = files.respond(hostile);
630 assert!(
631 response.status == 400 || response.status == 404,
632 "{hostile} answered {}",
633 response.status
634 );
635 assert!(!response.body.text().contains("outside"), "{hostile}");
636 }
637 #[cfg(unix)]
639 {
640 std::os::unix::fs::symlink(
641 tmp.path().join("secret.html"),
642 tmp.path().join("target/web/rustdoc/macro.leak!.html"),
643 )
644 .unwrap();
645 assert_eq!(files.respond("/rustdoc/macro.leak!.html").status, 404);
646 }
647 }
648
649 #[test]
650 fn a_file_is_read_once_and_answered_from_memory_afterwards() {
651 let (tmp, files) = tree();
652 assert_eq!(files.respond("/docs/").status, 200);
653 std::fs::remove_file(tmp.path().join("target/web/docs/index.html")).unwrap();
654 assert_eq!(files.respond("/docs/").status, 200);
655 }
656}