Skip to main content

majordomus_cli/web/
serve.rs

1//! Serving the static surfaces of a resolved topology.
2//!
3//! One arm in the router asks this: does a static surface own the path? Every discovered
4//! `StaticDirectory` is answered here, and a surface that did not exist when this file was
5//! written is served because it was discovered, not because an arm was added for it. The
6//! routes the executable answers itself — the JSON index, the OpenAPI document, the Swagger
7//! shell, MCP, the Cockpit — are not dispatched here: they differ in behaviour rather than
8//! in data, and a variant per existing arm would be the match again with more ceremony
9//! (ADR 0013).
10//!
11//! The boundary this module defends: only a resolved surface's own directory is reachable,
12//! a request may not walk out of it, and only a closed set of media types is answered.
13
14use std::collections::BTreeMap;
15use std::path::{Path, PathBuf};
16use std::sync::Mutex;
17
18use super::model::{SurfaceKind, Topology};
19use crate::http::router::Response;
20
21/// The media types a generated report may be served as. A file whose extension is not here
22/// is not answered: a static surface is a directory of documents, never a file server.
23const MEDIA_TYPES: &[(&str, &str)] = &[
24    ("html", "text/html; charset=utf-8"),
25    ("css", "text/css; charset=utf-8"),
26    ("js", "text/javascript; charset=utf-8"),
27    ("mjs", "text/javascript; charset=utf-8"),
28    ("json", "application/json"),
29    ("map", "application/json"),
30    ("svg", "image/svg+xml"),
31    ("txt", "text/plain; charset=utf-8"),
32    ("xml", "application/xml"),
33];
34
35/// The static surfaces of a topology, ready to answer.
36///
37/// Built once at startup from the resolved topology: the router holds it and asks it before
38/// its own routes, so a mount that a surface owns is never shadowed by a later arm.
39#[derive(Debug)]
40pub struct StaticSurfaces {
41    mounts: Vec<Mounted>,
42    /// The mounts the executable answers itself. The application is mounted at `/` and
43    /// therefore owns every path nothing else claims — including the ones the router
44    /// answers — so precedence is resolved from the topology here rather than left to the
45    /// order the router happens to consult its arms in.
46    native: Vec<String>,
47    cache: Mutex<BTreeMap<PathBuf, Option<(&'static str, String)>>>,
48}
49
50#[derive(Debug)]
51struct Mounted {
52    id: String,
53    prefix: String,
54    root: PathBuf,
55    index: String,
56}
57
58impl StaticSurfaces {
59    /// Take every static surface of `topology` whose directory exists, resolved against
60    /// `root`. A surface whose producer has not run is not mounted: the validator is where
61    /// that becomes a finding, and a router that answered 500 for it would be worse.
62    pub fn new(topology: &Topology, root: &Path) -> Self {
63        let mut mounts = Vec::new();
64        let mut native = Vec::new();
65        for surface in &topology.surfaces {
66            if surface.kind != SurfaceKind::StaticDirectory {
67                native.push(surface.mount.as_str().to_string());
68                continue;
69            }
70            let Some(artifact) = surface.artifact.as_ref() else {
71                native.push(surface.mount.as_str().to_string());
72                continue;
73            };
74            let dir = root.join(artifact);
75            let Ok(canonical) = dir.canonicalize() else {
76                // Declared but not built. Its mount stays reserved rather than falling to
77                // whoever holds the prefix above it: the application mounted at the root
78                // would otherwise answer /docs with a page of its own the moment the
79                // documentation had not been generated, which is the one thing the
80                // reserved namespaces exist to prevent. The router answers the unbuilt
81                // mount itself, naming the command that builds it.
82                native.push(surface.mount.as_str().to_string());
83                continue;
84            };
85            mounts.push(Mounted {
86                id: surface.id.clone(),
87                prefix: surface.mount.prefix(),
88                root: canonical,
89                index: surface.index.clone().unwrap_or_else(|| "index.html".into()),
90            });
91        }
92        // most specific first, so a surface inside another's prefix is consulted first; the
93        // validator refuses that arrangement, and the order does not depend on it
94        mounts.sort_by(|a, b| b.prefix.len().cmp(&a.prefix.len()).then(a.id.cmp(&b.id)));
95        native.sort();
96        StaticSurfaces {
97            mounts,
98            native,
99            cache: Mutex::new(BTreeMap::new()),
100        }
101    }
102
103    /// The ids mounted, in the order they are consulted.
104    pub fn ids(&self) -> Vec<&str> {
105        self.mounts.iter().map(|m| m.id.as_str()).collect()
106    }
107
108    /// Is anything mounted?
109    pub fn is_empty(&self) -> bool {
110        self.mounts.is_empty()
111    }
112
113    /// Does a static surface own this path — and is it not one the executable answers?
114    pub fn owns(&self, path: &str) -> bool {
115        !self.reserved(path) && self.mounts.iter().any(|m| owns(m, path))
116    }
117
118    /// Is this path one a native route of the topology owns? Those are answered by the
119    /// router itself, and the application's catch-all mount may not take them.
120    fn reserved(&self, path: &str) -> bool {
121        self.native.iter().any(|mount| {
122            let bare = mount.trim_end_matches('/');
123            !bare.is_empty() && (path == bare || path.starts_with(&format!("{bare}/")))
124        })
125    }
126
127    /// Answer a request a static surface owns, or `None` when none does.
128    ///
129    /// A directory answers with its index; a path that walks out of the surface's root, or
130    /// names a media type the set does not hold, is a 404 rather than a hint about what is
131    /// on the disk.
132    pub fn handle(&self, method: &str, path: &str) -> Option<Response> {
133        if self.reserved(path) {
134            return None; // the executable answers this one itself
135        }
136        let mounted = self.mounts.iter().find(|m| owns(m, path))?;
137        if method != "GET" && method != "HEAD" {
138            return Some(Response::new(
139                405,
140                "application/json",
141                r#"{"error":"method_not_allowed","message":"a static surface answers GET"}"#,
142            ));
143        }
144        // exactly one separator is removed: `//` is a different URL, and collapsing it here
145        // would hide an empty segment that safe_relative exists to refuse
146        let after = path
147            .strip_prefix(mounted.prefix.trim_end_matches('/'))
148            .unwrap_or("");
149        let relative = after.strip_prefix('/').unwrap_or(after);
150        let relative = if relative.is_empty() || relative.ends_with('/') {
151            format!("{relative}{}", mounted.index)
152        } else {
153            relative.to_string()
154        };
155        let Some(safe) = safe_relative(&relative) else {
156            return Some(not_found(&mounted.id));
157        };
158        let file = mounted.root.join(&safe);
159        match self.read(&file, &mounted.root) {
160            Some((media_type, body)) => Some(Response::new(200, media_type, body)),
161            None => Some(not_found(&mounted.id)),
162        }
163    }
164
165    /// Read a file once and keep it: a generated report is rebuilt by its producer, not by
166    /// a request, so the bytes cannot change under a running server without one.
167    fn read(&self, file: &Path, root: &Path) -> Option<(&'static str, String)> {
168        if let Ok(cache) = self.cache.lock() {
169            if let Some(hit) = cache.get(file) {
170                return hit.clone();
171            }
172        }
173        let loaded = load(file, root);
174        if let Ok(mut cache) = self.cache.lock() {
175            cache.insert(file.to_path_buf(), loaded.clone());
176        }
177        loaded
178    }
179}
180
181fn owns(mounted: &Mounted, path: &str) -> bool {
182    let bare = mounted.prefix.trim_end_matches('/');
183    if bare.is_empty() {
184        return path.starts_with('/');
185    }
186    path == bare || path.starts_with(&mounted.prefix)
187}
188
189fn load(file: &Path, root: &Path) -> Option<(&'static str, String)> {
190    let media_type = media_type(file)?;
191    // the relative path is already known to hold no `..`; canonicalising both ends closes
192    // the door a symbolic link inside the directory would otherwise open
193    let resolved = file.canonicalize().ok()?;
194    if !resolved.starts_with(root) {
195        tracing::warn!(path = %file.display(), "a static surface's file resolves outside its root; refused");
196        return None;
197    }
198    let body = std::fs::read_to_string(&resolved).ok()?;
199    Some((media_type, body))
200}
201
202/// The media type of a file, or `None` when the set does not hold it.
203///
204/// ```
205/// use majordomus_cli::web::serve::media_type;
206/// use std::path::Path;
207/// assert_eq!(media_type(Path::new("a/index.html")), Some("text/html; charset=utf-8"));
208/// assert_eq!(media_type(Path::new("a/results.json")), Some("application/json"));
209/// assert_eq!(media_type(Path::new("a/binary.wasm")), None);
210/// assert_eq!(media_type(Path::new("noextension")), None);
211/// ```
212pub fn media_type(file: &Path) -> Option<&'static str> {
213    let extension = file.extension()?.to_str()?.to_ascii_lowercase();
214    MEDIA_TYPES
215        .iter()
216        .find(|(e, _)| *e == extension)
217        .map(|(_, t)| *t)
218}
219
220/// A request path's relative part, refused before the filesystem is touched when it holds
221/// anything but the characters a generated report uses, an empty segment, `.` or `..`.
222///
223/// ```
224/// use majordomus_cli::web::serve::safe_relative;
225/// assert_eq!(safe_relative("index.html").as_deref(), Some("index.html"));
226/// assert_eq!(safe_relative("coverage/index.html").as_deref(), Some("coverage/index.html"));
227/// assert_eq!(safe_relative("../../etc/passwd"), None);
228/// assert_eq!(safe_relative("a//b"), None);
229/// assert_eq!(safe_relative("a/./b"), None);
230/// ```
231pub fn safe_relative(relative: &str) -> Option<String> {
232    if relative.is_empty() || relative.len() > 300 {
233        return None;
234    }
235    if !relative
236        .bytes()
237        .all(|b| b.is_ascii_alphanumeric() || b"._-/".contains(&b))
238    {
239        return None;
240    }
241    let segments: Vec<&str> = relative.split('/').collect();
242    if segments
243        .iter()
244        .any(|s| s.is_empty() || *s == "." || *s == "..")
245    {
246        return None;
247    }
248    Some(segments.join("/"))
249}
250
251fn not_found(surface: &str) -> Response {
252    Response::new(
253        404,
254        "application/json",
255        format!(
256            r#"{{"error":"not_found","message":"the surface '{surface}' does not hold that path"}}"#
257        ),
258    )
259}
260
261#[cfg(test)]
262mod tests {
263    use super::*;
264    use crate::web::model::{Availability, Category, Mount, Surface, Visibility};
265    use std::collections::BTreeMap;
266
267    fn fixture() -> (tempfile::TempDir, StaticSurfaces) {
268        let tmp = tempfile::tempdir().unwrap();
269        let dir = tmp.path().join("target/web/tests");
270        std::fs::create_dir_all(dir.join("coverage")).unwrap();
271        std::fs::write(dir.join("index.html"), "<h1>tests</h1>").unwrap();
272        std::fs::write(dir.join("results.json"), "{}").unwrap();
273        std::fs::write(dir.join("coverage/index.html"), "<h1>coverage</h1>").unwrap();
274        std::fs::write(dir.join("secret.pem"), "no").unwrap();
275        let topology = Topology::new(vec![Surface {
276            id: "tests".into(),
277            title: "Tests".into(),
278            category: Category::Report,
279            visibility: Visibility::Public,
280            kind: SurfaceKind::StaticDirectory,
281            mount: Mount::parse("/tests").unwrap(),
282            producer: "test".into(),
283            feature: None,
284            artifact: Some("target/web/tests".into()),
285            index: Some("index.html".into()),
286            availability: Availability::Both,
287            built_from: None,
288            provenance: BTreeMap::new(),
289        }]);
290        let surfaces = StaticSurfaces::new(&topology, tmp.path());
291        (tmp, surfaces)
292    }
293
294    #[test]
295    fn the_mount_answers_with_its_index_with_or_without_a_trailing_slash() {
296        let (_tmp, s) = fixture();
297        for path in ["/tests", "/tests/"] {
298            let response = s.handle("GET", path).expect("the surface owns it");
299            assert_eq!(response.status, 200, "{path}");
300            assert!(response.body.text().contains("<h1>tests</h1>"), "{path}");
301        }
302    }
303
304    #[test]
305    fn a_file_and_a_nested_index_are_answered_with_their_media_types() {
306        let (_tmp, s) = fixture();
307        let json = s.handle("GET", "/tests/results.json").unwrap();
308        assert_eq!(json.content_type, "application/json");
309        let nested = s.handle("GET", "/tests/coverage/").unwrap();
310        assert!(nested.body.text().contains("coverage"));
311    }
312
313    #[test]
314    fn nothing_outside_the_surface_is_reachable() {
315        let (_tmp, s) = fixture();
316        for path in [
317            "/tests/../../../etc/passwd",
318            "/tests/./results.json",
319            "/tests//results.json",
320            "/tests/secret.pem",
321        ] {
322            let response = s.handle("GET", path).expect("the surface owns the prefix");
323            assert_eq!(response.status, 404, "{path} was answered");
324        }
325    }
326
327    #[test]
328    fn a_path_no_surface_owns_is_left_to_the_rest_of_the_router() {
329        let (_tmp, s) = fixture();
330        assert!(s.handle("GET", "/api/v1/objects").is_none());
331        assert!(s.handle("GET", "/testsomething").is_none());
332        assert!(!s.owns("/testsomething"));
333    }
334
335    #[test]
336    fn the_application_may_not_take_a_path_the_executable_answers() {
337        let tmp = tempfile::tempdir().unwrap();
338        let public = tmp.path().join("site/public");
339        std::fs::create_dir_all(&public).unwrap();
340        std::fs::write(public.join("index.html"), "<h1>app</h1>").unwrap();
341        let mut surfaces = crate::web::discover::native(crate::web::discover::Runtime::full());
342        surfaces.push(Surface {
343            id: "app".into(),
344            title: "App".into(),
345            category: Category::Interface,
346            visibility: Visibility::Public,
347            kind: SurfaceKind::StaticDirectory,
348            mount: Mount::root(),
349            producer: "site".into(),
350            feature: None,
351            artifact: Some("site/public".into()),
352            index: Some("index.html".into()),
353            availability: Availability::Both,
354            built_from: None,
355            provenance: BTreeMap::new(),
356        });
357        let s = StaticSurfaces::new(&Topology::new(surfaces), tmp.path());
358        // the application answers what nothing else claims
359        assert_eq!(s.handle("GET", "/").unwrap().status, 200);
360        // and never what the executable answers itself
361        for path in [
362            "/openapi.json",
363            "/swagger",
364            "/mcp",
365            "/cockpit",
366            "/api/v1/capabilities",
367        ] {
368            assert!(
369                s.handle("GET", path).is_none(),
370                "{path} was taken by the application"
371            );
372            assert!(!s.owns(path), "{path}");
373        }
374    }
375
376    #[test]
377    fn a_write_to_a_static_surface_is_refused_rather_than_ignored() {
378        let (_tmp, s) = fixture();
379        assert_eq!(s.handle("POST", "/tests/").unwrap().status, 405);
380    }
381
382    /// The documentation is a static surface like the application, and until its producer
383    /// has run it is a mount with nothing behind it. The application holds `/`, so the one
384    /// thing that must not happen is the application answering `/docs` with a page of its
385    /// own: a declared mount stays its owner's whether or not it has been built.
386    #[test]
387    fn a_declared_surface_that_was_never_built_still_holds_its_mount() {
388        let tmp = tempfile::tempdir().unwrap();
389        let public = tmp.path().join(crate::web::discover::SITE_PUBLIC);
390        std::fs::create_dir_all(public.join("docs")).unwrap();
391        std::fs::write(public.join("index.html"), "<h1>app</h1>").unwrap();
392        std::fs::write(
393            public.join("docs/index.html"),
394            "<h1>the app's own docs</h1>",
395        )
396        .unwrap();
397        let config = tmp.path().join(crate::web::discover::SITE_CONFIG);
398        std::fs::create_dir_all(config.parent().unwrap()).unwrap();
399        std::fs::write(&config, "base_url = \"https://example.invalid\"\n").unwrap();
400
401        // the documentation's own artifact is never written here
402        let surfaces = crate::web::discover::application(tmp.path());
403        let s = StaticSurfaces::new(&Topology::new(surfaces), tmp.path());
404
405        assert_eq!(
406            s.ids(),
407            vec![crate::web::discover::APPLICATION],
408            "only the built surface is mounted"
409        );
410        assert_eq!(s.handle("GET", "/").unwrap().status, 200);
411        for path in ["/docs", "/docs/", "/docs/index.html"] {
412            assert!(
413                s.handle("GET", path).is_none(),
414                "{path} was answered by the application"
415            );
416            assert!(!s.owns(path), "{path}");
417        }
418    }
419
420    #[test]
421    fn a_surface_whose_producer_has_not_run_is_not_mounted() {
422        let tmp = tempfile::tempdir().unwrap();
423        let topology = Topology::new(vec![Surface {
424            id: "benchmarks".into(),
425            title: "Benchmarks".into(),
426            category: Category::Report,
427            visibility: Visibility::Public,
428            kind: SurfaceKind::StaticDirectory,
429            mount: Mount::parse("/benchmarks").unwrap(),
430            producer: "test".into(),
431            feature: None,
432            artifact: Some("target/web/benchmarks".into()),
433            index: Some("index.html".into()),
434            availability: Availability::Both,
435            built_from: None,
436            provenance: BTreeMap::new(),
437        }]);
438        let surfaces = StaticSurfaces::new(&topology, tmp.path());
439        assert!(surfaces.is_empty());
440        assert!(surfaces.handle("GET", "/benchmarks/").is_none());
441    }
442}