1use std::collections::BTreeMap;
20use std::path::{Path, PathBuf};
21use std::sync::{Arc, Mutex, OnceLock};
22
23use crate::http::router::{Request, Response};
24
25pub const DIR: &str = "cockpit";
27
28pub const PREFIX: &str = "/cockpit/assets/";
30
31const MEDIA_TYPES: &[(&str, &str)] = &[
35 ("css", "text/css; charset=utf-8"),
36 ("js", "text/javascript; charset=utf-8"),
37 ("mjs", "text/javascript; charset=utf-8"),
38 ("json", "application/json"),
39 ("map", "application/json"),
40 ("svg", "image/svg+xml"),
41 ("txt", "text/plain; charset=utf-8"),
42];
43
44pub const IMMUTABLE: &str = "public, max-age=31536000, immutable";
47
48const VERSION_LEN: usize = 16;
50
51#[derive(Debug)]
54struct Loaded {
55 body: String,
56 media_type: &'static str,
57 digest: String,
58}
59
60impl Loaded {
61 fn version(&self) -> &str {
63 &self.digest[..VERSION_LEN.min(self.digest.len())]
64 }
65
66 fn etag(&self) -> String {
68 format!("\"{}\"", self.digest)
69 }
70}
71
72#[derive(Debug)]
75pub struct Assets {
76 root: Option<PathBuf>,
77 dir: &'static str,
78 prefix: &'static str,
79 loaded: Mutex<BTreeMap<String, Option<Arc<Loaded>>>>,
80}
81
82impl Assets {
83 pub fn new(share_dir: &Path) -> Self {
88 Assets::under(share_dir, DIR, PREFIX)
89 }
90
91 pub fn under(share_dir: &Path, dir: &'static str, prefix: &'static str) -> Self {
105 let root = share_dir.join(dir);
106 Assets {
107 root: root.is_dir().then_some(root),
108 dir,
109 prefix,
110 loaded: Mutex::new(BTreeMap::new()),
111 }
112 }
113
114 pub fn none() -> Self {
116 Assets {
117 root: None,
118 dir: DIR,
119 prefix: PREFIX,
120 loaded: Mutex::new(BTreeMap::new()),
121 }
122 }
123
124 pub fn none_under(dir: &'static str, prefix: &'static str) -> Self {
136 Assets {
137 dir,
138 prefix,
139 ..Assets::none()
140 }
141 }
142
143 pub fn present(&self) -> bool {
145 self.root.is_some()
146 }
147
148 pub fn has(&self, name: &str) -> bool {
150 self.load(name).is_some()
151 }
152
153 pub fn url(&self, name: &str) -> String {
157 match self.load(name) {
158 Some(a) => format!("{}{name}?v={}", self.prefix, a.version()),
159 None => format!("{}{name}", self.prefix),
160 }
161 }
162
163 pub fn respond(&self, name: &str, req: &Request) -> Response {
169 let Some(asset) = self.load(name) else {
170 return Response::error(
171 404,
172 "not_found",
173 &format!(
174 "no asset '{name}'; the distribution's share/{}/ holds the ones there are",
175 self.dir
176 ),
177 );
178 };
179 let version = req
180 .query
181 .iter()
182 .find(|(k, _)| k == "v")
183 .map(|(_, v)| v.as_str());
184 let cache = if version == Some(asset.version()) {
185 IMMUTABLE
186 } else {
187 "no-cache"
188 };
189 let etag = asset.etag();
190 let response = if req
191 .header("If-None-Match")
192 .is_some_and(|candidates| none_match_holds(candidates, &etag))
193 {
194 Response::new(304, asset.media_type, String::new())
197 .with_header("Content-Length", asset.body.len().to_string())
198 } else {
199 Response::new(200, asset.media_type, asset.body.clone())
200 };
201 response
202 .with_header("Cache-Control", cache)
203 .with_header("ETag", etag)
204 }
205
206 fn load(&self, name: &str) -> Option<Arc<Loaded>> {
207 self.load_from(name, &mut Vec::new())
208 }
209
210 fn load_from(&self, name: &str, visiting: &mut Vec<String>) -> Option<Arc<Loaded>> {
214 if let Some(cached) = self.loaded.lock().ok()?.get(name) {
215 return cached.clone();
216 }
217 visiting.push(name.to_string());
218 let loaded = self.read(name, visiting).map(Arc::new);
219 visiting.pop();
220 if let Ok(mut map) = self.loaded.lock() {
221 map.insert(name.to_string(), loaded.clone());
222 }
223 loaded
224 }
225
226 fn read(&self, name: &str, visiting: &mut Vec<String>) -> Option<Loaded> {
227 let root = self.root.as_ref()?;
228 let relative = safe_name(name)?;
229 let media_type = media_type(&relative)?;
230 let path = root.join(&relative);
231 let resolved = path.canonicalize().ok()?;
234 let root = root.canonicalize().ok()?;
235 if !resolved.starts_with(&root) {
236 tracing::warn!(
237 asset = name,
238 dir = self.dir,
239 "an asset resolves outside its share directory; refused"
240 );
241 return None;
242 }
243 let mut body = std::fs::read_to_string(&resolved).ok()?;
244 if is_script(&relative) {
245 body = rewrite_imports(&body, |specifier| {
246 let target = resolve_relative(&relative, specifier)?;
247 if visiting.contains(&target) {
248 return None;
249 }
250 let loaded = self.load_from(&target, visiting)?;
251 Some(loaded.version().to_string())
252 });
253 }
254 Some(Loaded {
255 digest: digest(body.as_bytes()),
256 body,
257 media_type,
258 })
259 }
260}
261
262fn none_match_holds(candidates: &str, etag: &str) -> bool {
265 candidates.split(',').map(str::trim).any(|candidate| {
266 candidate == "*" || candidate.strip_prefix("W/").unwrap_or(candidate) == etag
267 })
268}
269
270fn is_script(name: &str) -> bool {
272 name.ends_with(".js") || name.ends_with(".mjs")
273}
274
275fn rewrite_imports(source: &str, mut version_of: impl FnMut(&str) -> Option<String>) -> String {
284 let bytes = source.as_bytes();
285 let mut out = String::with_capacity(source.len() + 64);
286 let mut copied = 0;
287 let mut i = 0;
288 while i < bytes.len() {
289 let quote = bytes[i];
290 let quoted = quote == b'\'' || quote == b'"';
292 let rest = if quoted { &source[i + 1..] } else { "" };
293 if quoted
294 && (rest.starts_with("./") || rest.starts_with("../"))
295 && follows_an_import(&source[..i])
296 {
297 if let Some(length) = rest.find(quote as char) {
298 let specifier = &rest[..length];
299 let end = i + 1 + length;
300 if let Some(version) = plain_module_specifier(specifier)
301 .then(|| version_of(specifier))
302 .flatten()
303 {
304 out.push_str(&source[copied..end]);
305 out.push_str("?v=");
306 out.push_str(&version);
307 copied = end;
308 }
309 i = end + 1;
310 continue;
311 }
312 }
313 i += 1;
314 }
315 out.push_str(&source[copied..]);
316 out
317}
318
319fn follows_an_import(before: &str) -> bool {
322 let before = before.trim_end();
323 let (before, keywords): (&str, &[&str]) = match before.strip_suffix('(') {
324 Some(call) => (call.trim_end(), &["import"]),
325 None => (before, &["from", "import"]),
326 };
327 keywords.iter().any(|keyword| {
328 before.strip_suffix(keyword).is_some_and(|head| {
329 !head
330 .chars()
331 .next_back()
332 .is_some_and(|c| c.is_ascii_alphanumeric() || "_$.".contains(c))
333 })
334 })
335}
336
337fn plain_module_specifier(specifier: &str) -> bool {
340 is_script(specifier)
341 && specifier
342 .bytes()
343 .all(|b| b.is_ascii_alphanumeric() || b"._-/".contains(&b))
344}
345
346fn resolve_relative(from: &str, specifier: &str) -> Option<String> {
349 let mut segments: Vec<&str> = from.split('/').collect();
350 segments.pop();
351 for segment in specifier.split('/') {
352 match segment {
353 "." | "" => {}
354 ".." => {
355 segments.pop()?;
356 }
357 other => segments.push(other),
358 }
359 }
360 safe_name(&segments.join("/"))
361}
362
363fn digest(bytes: &[u8]) -> String {
365 use sha2::{Digest, Sha256};
366 let mut h = Sha256::new();
367 h.update(bytes);
368 format!("{:x}", h.finalize())
369}
370
371pub fn shared(share_dir: &Path) -> &'static Assets {
374 static SHARED: OnceLock<Assets> = OnceLock::new();
375 SHARED.get_or_init(|| Assets::new(share_dir))
376}
377
378pub fn safe_name(name: &str) -> Option<String> {
391 if name.is_empty() || name.len() > 200 {
392 return None;
393 }
394 if !name
395 .bytes()
396 .all(|b| b.is_ascii_alphanumeric() || b"._-/".contains(&b))
397 {
398 return None;
399 }
400 let segments: Vec<&str> = name.split('/').collect();
401 if segments
402 .iter()
403 .any(|s| s.is_empty() || *s == "." || *s == "..")
404 {
405 return None;
406 }
407 Some(segments.join("/"))
408}
409
410pub fn media_type(name: &str) -> Option<&'static str> {
419 let extension = name.rsplit_once('.')?.1;
420 MEDIA_TYPES
421 .iter()
422 .find(|(e, _)| *e == extension)
423 .map(|(_, t)| *t)
424}
425
426#[cfg(test)]
427mod tests {
428 use super::*;
429
430 fn get(target: &str, headers: &[(&str, &str)]) -> Request {
432 Request::parse_target("GET", target, vec![]).with_headers(
433 headers
434 .iter()
435 .map(|(k, v)| ((*k).to_string(), (*v).to_string()))
436 .collect(),
437 )
438 }
439
440 fn header<'a>(response: &'a Response, name: &str) -> Option<&'a str> {
441 response
442 .headers
443 .iter()
444 .find(|(k, _)| k.eq_ignore_ascii_case(name))
445 .map(|(_, v)| v.as_str())
446 }
447
448 fn share(files: &[(&str, &str)]) -> tempfile::TempDir {
450 let dir = tempfile::tempdir().expect("tempdir");
451 for (name, body) in files {
452 let path = dir.path().join(DIR).join(name);
453 std::fs::create_dir_all(path.parent().expect("a parent")).expect("mkdir");
454 std::fs::write(path, body).expect("write");
455 }
456 dir
457 }
458
459 #[test]
460 fn an_absent_directory_serves_nothing_and_does_not_panic() {
461 let assets = Assets::new(Path::new("/nonexistent-share-directory"));
462 assert!(!assets.present());
463 assert_eq!(assets.respond("cockpit.css", &get("/", &[])).status, 404);
464 assert_eq!(assets.url("cockpit.css"), "/cockpit/assets/cockpit.css");
465 }
466
467 #[test]
468 fn a_traversal_is_refused_before_the_filesystem_is_touched() {
469 for hostile in [
470 "../Cargo.toml",
471 "../../etc/passwd",
472 "/etc/passwd",
473 "a/../../b",
474 "",
475 ] {
476 assert_eq!(safe_name(hostile), None, "{hostile}");
477 }
478 }
479
480 #[test]
481 fn a_file_is_read_once_and_answered_with_its_digest() {
482 let dir = tempfile::tempdir().expect("tempdir");
483 let cockpit = dir.path().join(DIR);
484 std::fs::create_dir_all(&cockpit).expect("mkdir");
485 std::fs::write(cockpit.join("cockpit.css"), "body{color:red}").expect("write");
486 let assets = Assets::new(dir.path());
487 assert!(assets.present());
488
489 let url = assets.url("cockpit.css");
490 assert!(url.starts_with("/cockpit/assets/cockpit.css?v="), "{url}");
491 let version = url.split_once("?v=").expect("a version").1.to_string();
492
493 let hit = assets.respond("cockpit.css", &get(&url, &[]));
494 assert_eq!(hit.status, 200);
495 assert_eq!(hit.body, "body{color:red}");
496 assert!(hit
497 .headers
498 .iter()
499 .any(|(k, v)| k == "Cache-Control" && v == IMMUTABLE));
500
501 let unversioned = assets.respond("cockpit.css", &get("/", &[]));
502 assert!(unversioned
503 .headers
504 .iter()
505 .any(|(k, v)| k == "Cache-Control" && v == "no-cache"));
506
507 std::fs::remove_file(cockpit.join("cockpit.css")).expect("remove");
509 assert_eq!(assets.respond("cockpit.css", &get("/", &[])).status, 200);
510 assert_eq!(version.len(), VERSION_LEN);
511 }
512
513 #[test]
514 fn an_extension_the_cockpit_does_not_serve_is_not_served() {
515 let dir = tempfile::tempdir().expect("tempdir");
516 let cockpit = dir.path().join(DIR);
517 std::fs::create_dir_all(&cockpit).expect("mkdir");
518 std::fs::write(cockpit.join("secrets.env"), "TOKEN=1").expect("write");
519 let assets = Assets::new(dir.path());
520 assert_eq!(assets.respond("secrets.env", &get("/", &[])).status, 404);
521 }
522
523 #[test]
524 fn only_the_exact_current_version_is_immutable() {
525 let dir = share(&[("cockpit.css", "body{color:red}")]);
526 let assets = Assets::new(dir.path());
527 let url = assets.url("cockpit.css");
528 let version = url.split_once("?v=").expect("a version").1.to_string();
529 let full = digest(b"body{color:red}");
530 for target in [
531 "/cockpit/assets/cockpit.css?v=".to_string(),
532 format!("/cockpit/assets/cockpit.css?v={}", &version[..4]),
533 format!("/cockpit/assets/cockpit.css?v={full}"),
534 "/cockpit/assets/cockpit.css?v=0000000000000000".to_string(),
535 "/cockpit/assets/cockpit.css".to_string(),
536 ] {
537 let answer = assets.respond("cockpit.css", &get(&target, &[]));
538 assert_eq!(answer.status, 200, "{target}");
539 assert_eq!(
540 header(&answer, "Cache-Control"),
541 Some("no-cache"),
542 "{target}"
543 );
544 assert_eq!(
545 header(&answer, "ETag").map(str::to_string),
546 Some(format!("\"{full}\"")),
547 "{target}"
548 );
549 }
550 let exact = assets.respond("cockpit.css", &get(&url, &[]));
551 assert_eq!(header(&exact, "Cache-Control"), Some(IMMUTABLE));
552 }
553
554 #[test]
555 fn a_revalidation_that_still_holds_is_a_304_with_no_body() {
556 let dir = share(&[("cockpit.js", "export const a = 1;")]);
557 let assets = Assets::new(dir.path());
558 let url = assets.url("cockpit.js");
559 let fresh = assets.respond("cockpit.js", &get(&url, &[]));
560 let etag = header(&fresh, "ETag").expect("an ETag").to_string();
561
562 for (target, candidates, cache) in [
563 (url.as_str(), etag.clone(), IMMUTABLE),
564 ("/cockpit/assets/cockpit.js", etag.clone(), "no-cache"),
565 (
566 "/cockpit/assets/cockpit.js",
567 format!("\"other\", W/{etag}"),
568 "no-cache",
569 ),
570 ("/cockpit/assets/cockpit.js", "*".to_string(), "no-cache"),
571 ] {
572 let answer = assets.respond(
573 "cockpit.js",
574 &get(target, &[("if-none-match", &candidates)]),
575 );
576 assert_eq!(answer.status, 304, "{target} {candidates}");
577 assert!(answer.body.is_empty(), "a 304 carries no body");
578 assert_eq!(header(&answer, "ETag"), Some(etag.as_str()));
579 assert_eq!(header(&answer, "Cache-Control"), Some(cache));
580 assert_eq!(
581 header(&answer, "Content-Length"),
582 Some(fresh.body.len().to_string().as_str()),
583 "the 200's length, not the empty body's"
584 );
585 }
586 for stale in ["\"0000\"", ""] {
587 let answer = assets.respond(
588 "cockpit.js",
589 &get("/cockpit/assets/cockpit.js", &[("If-None-Match", stale)]),
590 );
591 assert_eq!(answer.status, 200, "{stale:?}");
592 assert_eq!(answer.body, "export const a = 1;");
593 }
594 }
595
596 #[test]
597 fn naming_an_asset_shares_its_bytes_rather_than_copying_them() {
598 let dir = share(&[("cockpit.css", "body{}")]);
599 let assets = Assets::new(dir.path());
600 let first = assets.load("cockpit.css").expect("loaded");
601 let _ = assets.url("cockpit.css");
602 let second = assets.load("cockpit.css").expect("loaded");
603 assert!(Arc::ptr_eq(&first, &second), "one body, shared");
604 }
605
606 #[test]
607 fn a_module_names_its_imports_by_their_digested_urls() {
608 let dir = share(&[
609 ("cockpit.js", "// the shell — \"its\" helpers\nexport const api = 1;"),
610 ("socket.js", "import { api } from './cockpit.js';\nexport const s = api;"),
611 (
612 "executions.js",
613 "import { api } from './cockpit.js';\nimport { s } from \"./socket.js\";\nimport('./missing.js');",
614 ),
615 ("vendor/three.core.min.js", "export const V=1;"),
616 (
617 "vendor/three.module.min.js",
618 "import{V as a}from\"./three.core.min.js\";export{a}from\"./three.core.min.js\";",
619 ),
620 ("cycle-a.js", "import './cycle-b.js';"),
621 ("cycle-b.js", "import './cycle-a.js';"),
622 ]);
623 let assets = Assets::new(dir.path());
624 let version = |name: &str| {
625 assets
626 .url(name)
627 .split_once("?v=")
628 .expect("a version")
629 .1
630 .to_string()
631 };
632
633 let core = version("cockpit.js");
634 let socket = assets.respond("socket.js", &get("/", &[]));
635 assert!(
636 socket
637 .body
638 .text()
639 .contains(&format!("from './cockpit.js?v={core}'")),
640 "{}",
641 socket.body
642 );
643
644 let executions = assets
645 .respond("executions.js", &get("/", &[]))
646 .body
647 .text()
648 .into_owned();
649 assert!(
650 executions.contains(&format!("'./cockpit.js?v={core}'")),
651 "{executions}"
652 );
653 assert!(
654 executions.contains(&format!("\"./socket.js?v={}\"", version("socket.js"))),
655 "{executions}"
656 );
657 assert!(
658 executions.contains("import('./missing.js')"),
659 "an absent file keeps its plain URL"
660 );
661
662 assert_eq!(
664 assets.load("socket.js").expect("loaded").digest,
665 digest(socket.body.as_bytes())
666 );
667 assert_ne!(
668 assets.load("socket.js").expect("loaded").digest,
669 digest(b"import { api } from './cockpit.js';\nexport const s = api;")
670 );
671
672 let three = assets
673 .respond("vendor/three.module.min.js", &get("/", &[]))
674 .body
675 .text()
676 .into_owned();
677 let core3 = version("vendor/three.core.min.js");
678 assert_eq!(
679 three
680 .matches(&format!("\"./three.core.min.js?v={core3}\""))
681 .count(),
682 2,
683 "{three}"
684 );
685
686 assert!(assets.has("cycle-a.js"));
688 assert!(assets.has("cycle-b.js"));
689 }
690
691 #[test]
692 fn only_an_import_specifier_is_rewritten() {
693 let v = |s: &str| (s == "./a.js").then(|| "1234".to_string());
694 assert_eq!(
695 rewrite_imports("import { x } from './a.js';", v),
696 "import { x } from './a.js?v=1234';"
697 );
698 assert_eq!(
699 rewrite_imports("export * from \"./a.js\"", v),
700 "export * from \"./a.js?v=1234\""
701 );
702 assert_eq!(
703 rewrite_imports("import './a.js'", v),
704 "import './a.js?v=1234'"
705 );
706 assert_eq!(
707 rewrite_imports("await import ( './a.js' )", v),
708 "await import ( './a.js?v=1234' )"
709 );
710 for untouched in [
711 "const s = './a.js';",
712 "Array.from('./a.js')",
713 "x.import('./a.js')",
714 "reimport './a.js'",
715 "import './b.js';",
716 "import './a.js?v=1';",
717 "import './a.css';",
718 "import 'https://x/a.js';",
719 "import './a.js",
720 "// a dash — then 'quoted' — text",
721 ] {
722 assert_eq!(rewrite_imports(untouched, v), untouched);
723 }
724 }
725
726 #[test]
727 fn a_relative_specifier_resolves_inside_the_directory_or_not_at_all() {
728 assert_eq!(
729 resolve_relative("palette.js", "./cockpit.js").as_deref(),
730 Some("cockpit.js")
731 );
732 assert_eq!(
733 resolve_relative("vendor/three.module.min.js", "./three.core.min.js").as_deref(),
734 Some("vendor/three.core.min.js")
735 );
736 assert_eq!(
737 resolve_relative("vendor/x.js", "../cockpit.js").as_deref(),
738 Some("cockpit.js")
739 );
740 assert_eq!(resolve_relative("cockpit.js", "../outside.js"), None);
741 }
742
743 #[test]
744 fn if_none_match_is_compared_weakly_over_its_list() {
745 assert!(none_match_holds("\"ab\"", "\"ab\""));
746 assert!(none_match_holds(" \"x\" , W/\"ab\"", "\"ab\""));
747 assert!(none_match_holds("*", "\"ab\""));
748 assert!(!none_match_holds("\"a\"", "\"ab\""));
749 assert!(!none_match_holds("", "\"ab\""));
750 }
751}