1use std::collections::BTreeMap;
15use std::path::{Path, PathBuf};
16use std::sync::Mutex;
17
18use super::model::{SurfaceKind, Topology};
19use crate::http::router::Response;
20
21const 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#[derive(Debug)]
40pub struct StaticSurfaces {
41 mounts: Vec<Mounted>,
42 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 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 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 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 pub fn ids(&self) -> Vec<&str> {
105 self.mounts.iter().map(|m| m.id.as_str()).collect()
106 }
107
108 pub fn is_empty(&self) -> bool {
110 self.mounts.is_empty()
111 }
112
113 pub fn owns(&self, path: &str) -> bool {
115 !self.reserved(path) && self.mounts.iter().any(|m| owns(m, path))
116 }
117
118 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 pub fn handle(&self, method: &str, path: &str) -> Option<Response> {
133 if self.reserved(path) {
134 return None; }
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 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 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 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
202pub 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
220pub 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 assert_eq!(s.handle("GET", "/").unwrap().status, 200);
360 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 #[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 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}