Skip to main content

majordomus_cli/cockpit/
assets.rs

1//! The Cockpit's static files, served from the distribution's `share/cockpit/`.
2//!
3//! Read once and kept: an asset is loaded from disk the first time it is asked for and
4//! answered from memory afterwards, with the digest of its bytes as its cache key. The
5//! digest is in the URL the pages emit, so a changed file is a changed URL and the
6//! browser's cache never has to be told anything.
7//!
8//! Only the URL a page emits is immutable: a request whose `v` is anything but the file's
9//! current version is answered `no-cache`, and every answer carries the digest as its
10//! `ETag`, so a revalidation that still holds is a `304` with no body. A script module's
11//! relative imports are rewritten, when it is read, to the digested URLs of the modules
12//! they name: a module's digest then covers what it imports, so a change to `cockpit.js`
13//! is a new URL for every module that imports it, and every importer names the one URL.
14//!
15//! Nothing is compiled into the executable. `share/` is where this tool keeps its data —
16//! the kinds, the schemas, the allow-lists — and the Cockpit's stylesheet and scripts are
17//! more of the same, locatable per invocation and replaceable without a rebuild.
18
19use std::collections::BTreeMap;
20use std::path::{Path, PathBuf};
21use std::sync::{Arc, Mutex, OnceLock};
22
23use crate::http::router::{Request, Response};
24
25/// The directory inside the share directory that holds the Cockpit's files.
26pub const DIR: &str = "cockpit";
27
28/// The URL prefix every asset is served under.
29pub const PREFIX: &str = "/cockpit/assets/";
30
31/// Media types by file extension. An extension not named here is not served: the Cockpit
32/// ships text, and a directory that grows a binary by accident should not become a file
33/// server.
34const 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
44/// How long a browser may keep an asset whose URL carries the right digest. A year: the
45/// URL changes when the bytes change, so the entry can never be wrong.
46pub const IMMUTABLE: &str = "public, max-age=31536000, immutable";
47
48/// How many hex digits of the digest a URL carries as its `v`.
49const VERSION_LEN: usize = 16;
50
51/// One file, read. Kept behind an `Arc`, so that naming its URL or answering its
52/// revalidation copies nothing but a pointer.
53#[derive(Debug)]
54struct Loaded {
55    body: String,
56    media_type: &'static str,
57    digest: String,
58}
59
60impl Loaded {
61    /// The `v` a URL carries: the first [`VERSION_LEN`] hex digits of the digest.
62    fn version(&self) -> &str {
63        &self.digest[..VERSION_LEN.min(self.digest.len())]
64    }
65
66    /// The validator every answer carries.
67    fn etag(&self) -> String {
68        format!("\"{}\"", self.digest)
69    }
70}
71
72/// A surface's asset directory, with what has been read from it: the Cockpit's
73/// `share/cockpit/` under `/cockpit/assets/`, or another surface's beside it (ADR 0135).
74#[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    /// The assets under `share_dir/cockpit`, when that directory exists. A distribution
84    /// without one serves no assets and every page still renders: the stylesheet link and
85    /// the module scripts simply resolve to 404, and the Cockpit degrades to unstyled
86    /// server-rendered HTML rather than failing.
87    pub fn new(share_dir: &Path) -> Self {
88        Assets::under(share_dir, DIR, PREFIX)
89    }
90
91    /// The assets under `share_dir/<dir>`, served under `prefix`: one surface's files, read
92    /// and digested exactly as the Cockpit's are. A surface does not get a second file
93    /// server; it gets this one pointed at its own directory.
94    ///
95    /// ```
96    /// use majordomus_cli::cockpit::assets::Assets;
97    /// let dir = tempfile::tempdir().unwrap();
98    /// std::fs::create_dir_all(dir.path().join("ui")).unwrap();
99    /// std::fs::write(dir.path().join("ui/ui.css"), "body{}").unwrap();
100    /// let ui = Assets::under(dir.path(), "ui", "/ui/assets/");
101    /// assert!(ui.url("ui.css").starts_with("/ui/assets/ui.css?v="));
102    /// assert!(!Assets::new(dir.path()).present(), "the Cockpit's directory is another one");
103    /// ```
104    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    /// No asset directory at all: for a router built without a distribution.
115    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    /// No asset directory, for a surface whose files would live under `dir` and `prefix`:
125    /// nothing is servable, and every URL keeps its plain path under that surface's prefix,
126    /// so a missing file is a 404 that names where it was looked for.
127    ///
128    /// ```
129    /// use majordomus_cli::cockpit::assets::Assets;
130    /// let ui = Assets::none_under("ui", "/ui/assets/");
131    /// assert!(!ui.present());
132    /// assert!(!ui.has("ui.css"));
133    /// assert_eq!(ui.url("ui.css"), "/ui/assets/ui.css");
134    /// ```
135    pub fn none_under(dir: &'static str, prefix: &'static str) -> Self {
136        Assets {
137            dir,
138            prefix,
139            ..Assets::none()
140        }
141    }
142
143    /// Is there an asset directory?
144    pub fn present(&self) -> bool {
145        self.root.is_some()
146    }
147
148    /// Is this file there and servable?
149    pub fn has(&self, name: &str) -> bool {
150        self.load(name).is_some()
151    }
152
153    /// The URL a page emits for an asset: the path with the digest of its bytes, so the
154    /// URL changes exactly when the file does. An asset that is not there keeps its plain
155    /// path, so the 404 names the file a reader should look for.
156    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    /// Answer a request for `prefix + name`. Immutable only when the request's `v` is
164    /// exactly the version the URL a page emits carries; anything else (no `v`, an empty
165    /// one, a prefix, a stale one) revalidates. An `If-None-Match` naming the current
166    /// `ETag` is answered `304 Not Modified` with no body: the 200's validator, caching
167    /// and length, and nothing else crosses the wire.
168    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            // the length a 304 announces is the 200's (RFC 9110 section 8.6), never the
195            // empty body's; the server writes no body for this status
196            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    /// Load `name`, with `visiting` the modules whose reading is under way above it: a
211    /// module that imports one of them is a cycle, and that one import keeps its plain
212    /// URL rather than recursing forever.
213    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        // the name is already known to hold no `..` and no absolute segment; canonicalising
232        // both ends closes the door a symlink inside the directory would otherwise open
233        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
262/// Does an `If-None-Match` value name this `ETag`? The weak comparison the header is
263/// defined with: `*`, or any member of the list once a `W/` is set aside.
264fn 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
270/// Is this file a script module, whose relative imports are rewritten to digested URLs?
271fn is_script(name: &str) -> bool {
272    name.ends_with(".js") || name.ends_with(".mjs")
273}
274
275/// Rewrite every relative module specifier a script names, in `import ... from`,
276/// `export ... from`, a bare `import '...'` and an `import('...')` with a literal, to the
277/// same specifier with `?v=` and the version `version_of` gives it. A specifier
278/// `version_of` has no version for (a file that is not there, a cycle) is left as it is,
279/// which still works and only revalidates.
280///
281/// Only a quoted string that starts with `./` or `../`, directly after one of those
282/// keywords, is a candidate, so a string elsewhere in a minified library is not touched.
283fn 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        // a quote is one byte, so the text on either side of it is whole characters
291        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
319/// Does the text before a quote end in `from`, `import`, or `import(` — as whole words, and
320/// not as a property (`x.from(`)?
321fn 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
337/// A specifier this rewrite may version: a path of plain characters to a script, with no
338/// query or fragment of its own.
339fn 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
346/// The asset a relative specifier names from the module `from`, or `None` when it climbs
347/// out of the asset directory.
348fn 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
363/// The hex SHA-256 of some bytes: an asset's identity in its URL and in its `ETag`.
364fn 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
371/// The digest cache shared by every router of the process, so two routers over one
372/// distribution read each file once between them.
373pub 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
378/// A relative path safe to join onto the asset root: no absolute segment, no `..`, no
379/// empty segment, and nothing outside a conservative character set.
380///
381/// ```
382/// use majordomus_cli::cockpit::assets::safe_name;
383/// assert_eq!(safe_name("js/graph.js").as_deref(), Some("js/graph.js"));
384/// assert_eq!(safe_name("../../etc/passwd"), None);
385/// assert_eq!(safe_name("/etc/passwd"), None);
386/// assert_eq!(safe_name("js//graph.js"), None);
387/// assert_eq!(safe_name("js/../../x"), None);
388/// assert_eq!(safe_name("a b.css"), None);
389/// ```
390pub 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
410/// The media type of a file name, when it is one the Cockpit serves.
411///
412/// ```
413/// use majordomus_cli::cockpit::assets::media_type;
414/// assert_eq!(media_type("cockpit.css"), Some("text/css; charset=utf-8"));
415/// assert_eq!(media_type("x.wasm"), None);
416/// assert_eq!(media_type("noextension"), None);
417/// ```
418pub 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    /// A GET for `target`, with further request headers.
431    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    /// A share directory whose `cockpit/` holds `files`.
449    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        // the file is gone and the answer is the same: it was read once
508        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        // a module's version covers what it imports: its digest is of the rewritten text
663        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        // a cycle ends, with the import that closes it left plain
687        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}