Skip to main content

majordomus_cli/web/
files.rs

1//! Serving a static surface: a generated directory answered under its mount.
2//!
3//! The directory is the producer's output and this module never writes to it. What it adds
4//! is the three things a directory of files is not: a request path resolved safely into it,
5//! a media type, and a validator for the bytes it hands back.
6//!
7//! Safety is by construction rather than by inspection. A request path is decomposed into
8//! segments, every segment is checked against a conservative character set, and any segment
9//! that is empty, `.` or `..` refuses the request before the filesystem is touched; the
10//! resolved path is then canonicalised and required to still be inside the canonical root,
11//! which closes the door a symlink inside the directory would otherwise open. There is no
12//! path concatenation of untrusted text anywhere in this file.
13
14use std::collections::BTreeMap;
15use std::path::{Path, PathBuf};
16use std::sync::Mutex;
17
18use crate::http::router::Response;
19
20use super::model::Surface;
21
22/// Media types by file extension. A file whose extension is not here is not served: a
23/// generated directory that grows something unexpected must not turn this into a general
24/// file server.
25const 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
48/// How long a browser may keep a document. Conservative: the pages are rebuilt from a
49/// working tree a person is editing, and a cached page that is one build behind is worse
50/// than a request.
51pub const DOCUMENT_CACHE: &str = "no-cache";
52
53/// How long a browser may keep an asset whose name carries a content hash. Zola writes
54/// such names for nothing today, so this is used only where the name proves it is safe.
55pub const IMMUTABLE_CACHE: &str = "public, max-age=31536000, immutable";
56
57/// The largest file answered from memory. Anything larger is read per request rather than
58/// held: a documentation tree is small, and a cache with no ceiling is a leak with a plan.
59pub const CACHE_MAX_BYTES: usize = 512 * 1024;
60
61/// A static surface, ready to answer.
62///
63/// Built once, when the router is: the root is resolved and the surface's own metadata is
64/// kept, so answering a request costs one path check and one read at most.
65#[derive(Debug)]
66pub struct Files {
67    /// The surface this serves, for diagnostics and for the page that says it is missing.
68    surface: Surface,
69    /// The canonical directory, when it exists. Absent means the producer has not run.
70    root: Option<PathBuf>,
71    /// The repository-relative directory, named in every diagnostic.
72    artifact: String,
73    /// What has been read, by relative path.
74    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    /// Prepare to serve `surface` out of `root`, the repository root.
85    ///
86    /// A surface whose directory is absent is not an error here: it is a surface whose
87    /// producer has not run, and every request under it answers with what to run.
88    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    /// Is the producer's output there?
105    pub fn available(&self) -> bool {
106        self.root.is_some()
107    }
108
109    /// The surface being served.
110    pub fn surface(&self) -> &Surface {
111        &self.surface
112    }
113
114    /// Answer a request whose path this surface owns.
115    pub fn respond(&self, path: &str) -> Response {
116        let Some(root) = &self.root else {
117            return self.unavailable();
118        };
119        // a mount reached without its trailing slash resolves relative links one level too
120        // high in every browser; send it to the canonical form rather than serving there
121        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    /// The path of a request relative to the mount, or none when it is not under it.
165    ///
166    /// The mount itself and the mount with a trailing slash both resolve to the empty
167    /// path, which is the surface's index.
168    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    /// What a request under a surface whose producer has not run answers with: the reason,
214    /// the directory that is missing, the command that writes it, and that this process
215    /// resolved its surfaces once at start.
216    ///
217    /// Restart-based rediscovery is this executable's contract everywhere — the index, the
218    /// registry and the topology are all read at start and immutable for the process — so
219    /// building the directory while the server runs does not make it appear, and saying so
220    /// is cheaper than a reader wondering why.
221    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
237/// A file inside a surface's directory, resolved.
238struct Resolved {
239    /// The path on disk.
240    path: PathBuf,
241    /// The name relative to the root, from which the media type is read.
242    name: String,
243}
244
245/// Resolve a relative request path inside `root`, applying the index rule.
246///
247/// The request path is decomposed and every segment checked before anything touches the
248/// filesystem; the result is canonicalised and required to still be inside the canonical
249/// root, so neither `..` nor a symlink can leave the surface.
250fn 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
277/// A request path made safe to join onto a surface's root, or none.
278///
279/// The character set is what the producers write and nothing wider. `!` is in it because
280/// rustdoc names the redirect stub of every macro `macro.<name>!.html`, and a reference
281/// that 404s on its own macros is a reference with holes: it is a legal path character
282/// that no browser escapes, it cannot spell `.`, `..` or a separator, and it therefore
283/// opens no route out of the directory that the segment checks do not already close.
284/// `%` stays out, so an escaped traversal is refused as text rather than decoded into one.
285///
286/// ```
287/// use majordomus_cli::web::files::safe_relative;
288/// assert_eq!(safe_relative("cli/index.html").as_deref(), Some("cli/index.html"));
289/// assert_eq!(safe_relative("").as_deref(), Some(""));
290/// assert_eq!(safe_relative("cli/").as_deref(), Some("cli/"));
291/// assert_eq!(safe_relative("m/macro.x!.html").as_deref(), Some("m/macro.x!.html"));
292/// assert_eq!(safe_relative("../Cargo.toml"), None);
293/// assert_eq!(safe_relative("a/../../b"), None);
294/// assert_eq!(safe_relative("/etc/passwd"), None);
295/// assert_eq!(safe_relative("a//b"), None);
296/// assert_eq!(safe_relative("a\0b"), None);
297/// assert_eq!(safe_relative("%2e%2e/b"), None);
298/// ```
299pub 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
332/// The media type of a file name, when it is one a static surface serves.
333///
334/// ```
335/// use majordomus_cli::web::files::media_type;
336/// assert_eq!(media_type("index.html"), Some("text/html; charset=utf-8"));
337/// assert_eq!(media_type("logo.png"), Some("image/png"));
338/// assert_eq!(media_type("secrets.env"), None);
339/// assert_eq!(media_type("Makefile"), None);
340/// ```
341pub 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        // the mount's own subtree, and nothing else
470        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        // the root mount has no bare form to redirect to
486        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        // a named file is still served: only the mount itself has nothing to answer with
497        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    /// A rustdoc tree as the producer leaves it, reduced to one file of every kind a
516    /// browser asks for when it opens the reference: the landing page, the crate's page,
517    /// the hashed stylesheet, script and font rustdoc writes under `static.files/`, and the
518    /// redirect stub rustdoc names after a macro with its `!`.
519    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        // not UTF-8, as a font is not: the bytes must come back untouched
528        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        // a `!` names a file; it does not find one that is not there
554        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        // and every extension a rustdoc build carries, whether or not this tree has one:
593        // measured over a real `cargo doc` output (css, html, js, md, png, svg, txt, woff2),
594        // with the favicon and search types a newer rustdoc may add
595        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        // a symbolic link named like a macro stub is still a link out, and still refused
638        #[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}