Skip to main content

yah_mesofact_bundle/
assets.rs

1//! Per-file serving metadata for a served static tree (MFT-R825-F1): the
2//! **asset index**, the declared **immutable** globs it is built from, and the
3//! publish-time **precompression** that fills in its encodings.
4//!
5//! One rule, three readers. Before this module, "may this file be cached
6//! forever?" was answered three different ways — `mesofact serve` never said
7//! (no `Cache-Control`, no `ETag`, identity bytes only), yubaba's R2 publish
8//! sniffed hash-shaped filenames under `hydrate/`, and `mesofact-build` wrote
9//! `immutable: false` into every manifest `static_assets` entry. Now:
10//!
11//! - **Immutability is declared, never sniffed.** A component lists the output
12//!   files it knows to be content-addressed in its `mesofact.config.toml`
13//!   (`[build] immutable = ["app-*.js", "*_bg.wasm"]`, read by
14//!   [`declared_immutable`], matched by [`ImmutableGlobs`]). The only thing
15//!   still recognised by shape is
16//!   mesofact's own bundler output under `hydrate/`
17//!   ([`is_content_hashed_hydrate_output`]), because there mesofact is the
18//!   author of the naming and the declaration would be redundant.
19//! - **Encodings are produced at publish time, never per request.**
20//!   [`finalize_component_output`] writes `<file>.br` / `<file>.gz` siblings
21//!   for compressible files and records which ones exist. Compressing a 35 MB
22//!   wasm costs ~10 s of CPU at brotli 9 and ~80 s at brotli 11 (measured on
23//!   noisetable's `_bg.wasm`, 2026-10-09) — a cost a deploy can pay once and a
24//!   request path cannot pay at all.
25//! - **The index is the carrier.** Both facts land in
26//!   [`ASSET_INDEX_FILE`] at the root of the served tree, keyed by path
27//!   relative to that root, alongside each file's sha256 (its `ETag`). Because
28//!   it lives *inside* the tree, it travels with the files through every
29//!   deploy shape unchanged: a mounted component's index stages at
30//!   `app/dist/html/<mount>/` along with the component, so a server resolving
31//!   the nearest index above a file needs no knowledge of mounts at all.
32//!
33//! Adding these files to a bundle is not a bundle-contract change
34//! ([`crate::BUNDLE_CONTRACT_VERSION`]): a runtime that predates the index
35//! serves the same identity bytes it always did, and a runtime that reads it
36//! falls back to hashing on demand for a bundle that has none.
37
38use std::collections::{BTreeMap, BTreeSet};
39use std::fs;
40use std::io::Write;
41use std::path::Path;
42
43use serde::{Deserialize, Serialize};
44use sha2::{Digest, Sha256};
45
46use crate::BundleError;
47
48/// File name of the asset index at the root of a served tree.
49///
50/// Dot-prefixed so it sorts out of the way in a listing; it is not secret —
51/// every value in it is also sent as an `ETag` header.
52pub const ASSET_INDEX_FILE: &str = ".mesofact-assets.json";
53
54/// Current [`AssetIndex::version`]. A reader seeing a different value ignores
55/// the index (and serves without it) rather than guessing at its shape.
56pub const ASSET_INDEX_VERSION: u32 = 1;
57
58/// `Cache-Control` for a declared-immutable file. Byte-equal to
59/// `yah_object_store::CACHE_CONTROL_IMMUTABLE` and to mesofact serve's SSR
60/// instance-pointer value — one directive, everywhere it is emitted.
61pub const CACHE_CONTROL_IMMUTABLE: &str = "public, max-age=31536000, immutable";
62
63/// `Cache-Control` for everything not declared immutable: cache it, but
64/// revalidate before every use. With an `ETag` that revalidation is a 304 with
65/// no body, so "not immutable" no longer means "re-download everything".
66pub const CACHE_CONTROL_REVALIDATE: &str = "no-cache";
67
68/// Below this size a file is never precompressed: the header overhead and the
69/// extra round of negotiation are worth more than the bytes saved.
70const MIN_COMPRESS_BYTES: u64 = 1024;
71
72/// A variant is kept only when it is at most this fraction (in percent) of the
73/// identity size; anything closer is not worth a second representation.
74const MAX_KEPT_PERCENT: u64 = 95;
75
76/// Files at or above this size get brotli quality 9 instead of 11. Measured on
77/// noisetable's 35,306,127 B `_bg.wasm`: q11 → 7,845,662 B in 79 s CPU, q9 →
78/// 9,161,591 B in 12.5 s. Below the threshold (every JS/CSS/HTML file a site
79/// realistically ships) q11 costs well under a second and is what's used.
80const BROTLI_MAX_QUALITY_BELOW: u64 = 4 * 1024 * 1024;
81
82/// A content encoding a precompressed variant can carry.
83#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
84#[serde(rename_all = "lowercase")]
85pub enum Encoding {
86    /// brotli — `<file>.br`, `Content-Encoding: br`.
87    Br,
88    /// gzip — `<file>.gz`, `Content-Encoding: gzip`.
89    Gzip,
90}
91
92impl Encoding {
93    /// Every encoding, in server preference order (smallest output first).
94    pub const ALL: [Encoding; 2] = [Encoding::Br, Encoding::Gzip];
95
96    /// The `Content-Encoding` / `Accept-Encoding` token.
97    pub fn token(self) -> &'static str {
98        match self {
99            Encoding::Br => "br",
100            Encoding::Gzip => "gzip",
101        }
102    }
103
104    /// Suffix appended to the identity file's name for this variant.
105    pub fn suffix(self) -> &'static str {
106        match self {
107            Encoding::Br => ".br",
108            Encoding::Gzip => ".gz",
109        }
110    }
111}
112
113/// What the index records about one file.
114#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
115pub struct AssetEntry {
116    /// Lowercase hex sha256 of the identity bytes — the file's strong `ETag`,
117    /// and the same digest a mesofact manifest records as `content_hash`.
118    pub sha256: String,
119    /// Identity size in bytes. A reader compares it with the file on disk and
120    /// ignores an entry that no longer describes the file.
121    pub size: u64,
122    /// Declared content-addressed: safe to cache for a year without
123    /// revalidating.
124    #[serde(default)]
125    pub immutable: bool,
126    /// Precompressed variants present beside the file, in preference order.
127    #[serde(default, skip_serializing_if = "Vec::is_empty")]
128    pub encodings: Vec<Encoding>,
129}
130
131impl AssetEntry {
132    /// The strong `ETag` (quotes included) of the representation in
133    /// `encoding`. Each encoding is a distinct representation and must carry a
134    /// distinct tag, or a cache could answer a gzip-only client with brotli
135    /// bytes it validated against the identity tag.
136    pub fn etag(&self, encoding: Option<Encoding>) -> String {
137        etag_for(&self.sha256, encoding)
138    }
139
140    /// `Cache-Control` value for this file.
141    pub fn cache_control(&self) -> &'static str {
142        if self.immutable {
143            CACHE_CONTROL_IMMUTABLE
144        } else {
145            CACHE_CONTROL_REVALIDATE
146        }
147    }
148}
149
150/// Lowercase hex sha256 — the digest an [`AssetEntry`] records, exposed so a
151/// server hashing an unindexed file on demand produces the identical `ETag`.
152pub fn sha256_hex(bytes: &[u8]) -> String {
153    format!("{:x}", Sha256::digest(bytes))
154}
155
156/// Strong `ETag` for content `sha256` in `encoding`; see [`AssetEntry::etag`].
157pub fn etag_for(sha256: &str, encoding: Option<Encoding>) -> String {
158    match encoding {
159        None => format!("\"{sha256}\""),
160        Some(e) => format!("\"{sha256}.{}\"", e.token()),
161    }
162}
163
164/// The index written at the root of a served tree as [`ASSET_INDEX_FILE`].
165#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
166pub struct AssetIndex {
167    pub version: u32,
168    /// Keyed by forward-slashed path relative to the directory holding the
169    /// index.
170    pub assets: BTreeMap<String, AssetEntry>,
171}
172
173impl AssetIndex {
174    /// Parse an index, returning `None` for anything this build cannot trust —
175    /// malformed JSON or a version it does not speak. Callers treat `None`
176    /// exactly like a missing index.
177    pub fn parse(raw: &[u8]) -> Option<Self> {
178        let index: AssetIndex = serde_json::from_slice(raw).ok()?;
179        (index.version == ASSET_INDEX_VERSION).then_some(index)
180    }
181}
182
183/// Declared-immutable path patterns, matched against a file's path relative to
184/// the served root of the component that produced it.
185///
186/// Syntax, deliberately small: `/` separates segments; `*` matches any run of
187/// characters within one segment; `?` matches one character; a whole `**`
188/// segment matches zero or more segments. Matching is against the WHOLE path,
189/// so `*_bg.wasm` matches `app_bg.wasm` but not `snippets/x/app_bg.wasm` —
190/// write `**/*_bg.wasm` to reach into directories. That is on purpose: Trunk's
191/// `snippets/<crate>-<hash>/` directories look hashed but are keyed on the
192/// crate, not on the bytes, and a pattern that reached them by accident would
193/// pin stale JS in browsers for a year.
194#[derive(Debug, Clone, Default, PartialEq, Eq)]
195pub struct ImmutableGlobs {
196    patterns: Vec<Vec<String>>,
197}
198
199impl ImmutableGlobs {
200    /// Validate and compile `patterns`. Rejects what could only be a mistake:
201    /// an empty pattern, an absolute one, a `..` or empty segment, and `[` —
202    /// character classes are not supported and silently matching `[` literally
203    /// would turn a typo into a year-long cache entry.
204    pub fn new<S: AsRef<str>>(patterns: &[S]) -> Result<Self, BundleError> {
205        let mut compiled = Vec::with_capacity(patterns.len());
206        for p in patterns {
207            let p = p.as_ref();
208            let bad = |why: &str| {
209                BundleError::AssetPolicy(format!("immutable pattern {p:?}: {why}"))
210            };
211            if p.is_empty() {
212                return Err(bad("empty pattern"));
213            }
214            if p.starts_with('/') {
215                return Err(bad("must be relative to the build output, not absolute"));
216            }
217            if p.contains('[') || p.contains(']') {
218                return Err(bad("character classes are not supported"));
219            }
220            let segs: Vec<String> = p.split('/').map(str::to_string).collect();
221            if segs.iter().any(|s| s.is_empty() || s == "." || s == "..") {
222                return Err(bad("empty, `.` and `..` segments are not allowed"));
223            }
224            compiled.push(segs);
225        }
226        Ok(Self { patterns: compiled })
227    }
228
229    pub fn is_empty(&self) -> bool {
230        self.patterns.is_empty()
231    }
232
233    /// True when `rel` (forward-slashed, relative to the served root) matches
234    /// any declared pattern.
235    pub fn matches(&self, rel: &str) -> bool {
236        let path: Vec<&str> = rel.split('/').filter(|s| !s.is_empty()).collect();
237        self.patterns.iter().any(|p| match_segments(p, &path))
238    }
239}
240
241fn match_segments(pat: &[String], path: &[&str]) -> bool {
242    match pat.split_first() {
243        None => path.is_empty(),
244        Some((head, rest)) if head == "**" => {
245            (0..=path.len()).any(|skip| match_segments(rest, &path[skip..]))
246        }
247        Some((head, rest)) => match path.split_first() {
248            Some((seg, path_rest)) => match_segment(head, seg) && match_segments(rest, path_rest),
249            None => false,
250        },
251    }
252}
253
254/// Wildcard match of one segment (`*`, `?`), iterative with single-star
255/// backtracking — linear in practice, no recursion on adversarial input.
256fn match_segment(pat: &str, s: &str) -> bool {
257    let p: Vec<char> = pat.chars().collect();
258    let t: Vec<char> = s.chars().collect();
259    let (mut pi, mut ti) = (0, 0);
260    let mut star: Option<(usize, usize)> = None;
261    while ti < t.len() {
262        if pi < p.len() && (p[pi] == '?' || p[pi] == t[ti]) {
263            pi += 1;
264            ti += 1;
265        } else if pi < p.len() && p[pi] == '*' {
266            star = Some((pi, ti));
267            pi += 1;
268        } else if let Some((sp, st)) = star {
269            pi = sp + 1;
270            ti = st + 1;
271            star = Some((sp, st + 1));
272        } else {
273            return false;
274        }
275    }
276    while pi < p.len() && p[pi] == '*' {
277        pi += 1;
278    }
279    pi == p.len()
280}
281
282/// mesofact's own bundler names every hydrate output `<key>.<hash>.js` or
283/// `<key>.chunk-<hash>.js` (`mesofact-build` `bundle.rs`), so under `hydrate/`
284/// a hash-shaped name IS a declaration — mesofact wrote it. `rel` is relative
285/// to the hydrate directory.
286///
287/// The shape check is still strict, not "has two dots": a hand-dropped
288/// `app.min.js` or a plain `app.js` (someone bypassing the bundler) is not
289/// content-addressed, and a year-long immutable cache on it would be
290/// unrecoverable. Unknown ⇒ not immutable, always; guessing "cacheable" wrong
291/// costs far more than guessing "not". Moved here from yubaba's
292/// `r2_publish::cache_control_for` (R703-T10) so the R2 path and the bundle
293/// path apply one rule.
294pub fn is_content_hashed_hydrate_output(rel: &str) -> bool {
295    rel.rsplit_once('.')
296        // `<name>.<hash>.js` → after dropping `.js`, the trailing dot-segment
297        // is the hash. Bun/rolldown emit 8+ chars of base62/hex; require that.
298        .and_then(|(stem, ext)| (ext == "js").then_some(stem))
299        .and_then(|stem| stem.rsplit_once('.'))
300        .is_some_and(|(_, tok)| {
301            let tok = tok.strip_prefix("chunk-").unwrap_or(tok);
302            tok.len() >= 8 && tok.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
303        })
304}
305
306/// File, relative to a component's project root, that carries its declared
307/// immutable patterns as `[build] immutable = [...]`.
308pub const DECLARATION_FILE: &str = "mesofact.config.toml";
309
310/// Read a component's declared immutable patterns from
311/// `<project_root>/mesofact.config.toml`, `[build] immutable` — beside
312/// `[build] public_dir`, in mesofact's own project config.
313///
314/// **Why not `workload.toml`**, which is where R825 first suggested it:
315/// `workload_spec::BuildConfig` is `deny_unknown_fields` AND rides the kamaji
316/// postcard wire (positional, non-self-describing), so a new `[build]` key
317/// there is a fleet wire change touching yah, yubaba and kamaji to state a
318/// fact no node ever reads. This one is consumed only at build and assembly
319/// time, by mesofact-build and by this crate. A component that is not a
320/// mesofact project (a Trunk app) gets a two-line `mesofact.config.toml`
321/// carrying just this table.
322///
323/// The single reader of the key: mesofact-build calls it for the manifest's
324/// `static_assets[].immutable`, assembly calls it via
325/// [`finalize_component_output`]. No file ⇒ nothing declared. A malformed
326/// file, or a pattern [`ImmutableGlobs::new`] rejects, IS an error — a deploy
327/// that quietly drops a declaration is the failure this module exists to
328/// remove.
329pub fn declared_immutable(project_root: &Path) -> Result<ImmutableGlobs, BundleError> {
330    let path = project_root.join(DECLARATION_FILE);
331    let raw = match fs::read_to_string(&path) {
332        Ok(raw) => raw,
333        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(ImmutableGlobs::default()),
334        Err(e) => return Err(BundleError::Io(format!("reading {}: {e}", path.display()))),
335    };
336    #[derive(Deserialize)]
337    struct Sliver {
338        #[serde(default)]
339        build: Option<BuildSliver>,
340    }
341    #[derive(Deserialize)]
342    struct BuildSliver {
343        #[serde(default)]
344        immutable: Vec<String>,
345    }
346    let sliver: Sliver = toml::from_str(&raw)
347        .map_err(|e| BundleError::AssetPolicy(format!("parsing {}: {e}", path.display())))?;
348    ImmutableGlobs::new(&sliver.build.map(|b| b.immutable).unwrap_or_default())
349}
350
351/// True for a file [`finalize_served_dir`] wrote rather than one the build
352/// produced: the index itself, or a `<file>.br` / `<file>.gz` whose `<file>`
353/// exists beside it. `rel` is relative to `dir`. A publisher that walks a
354/// finalized tree for a CDN that negotiates encodings on its own uses this to
355/// skip them.
356pub fn is_finalize_artifact(dir: &Path, rel: &str) -> bool {
357    if rel.rsplit('/').next() == Some(ASSET_INDEX_FILE) {
358        return true;
359    }
360    Encoding::ALL.iter().any(|e| {
361        rel.strip_suffix(e.suffix())
362            .is_some_and(|base| !base.is_empty() && dir.join(base).is_file())
363    })
364}
365
366/// Finalize one component's build output for serving, in place: index every
367/// file, mark the declared-immutable ones, and write precompressed variants.
368///
369/// `project_root` holds the component's [`DECLARATION_FILE`]; `out_dir` is
370/// its `[build] out_dir`. Two shapes, told apart by what the build emitted:
371///
372/// - **A mesofact build** (`out_dir/html/` exists): the served root is
373///   `out_dir/html`, finalized with the declared patterns; `out_dir/hydrate`
374///   is its own served root (`/<build_id>/hydrate/…`), finalized with
375///   [`is_content_hashed_hydrate_output`].
376/// - **Anything else** (Trunk, a hand-rolled static tree): `out_dir` IS the
377///   served root, finalized with the declared patterns.
378///
379/// Called from every bundle-assembly entry point before the tree is
380/// collected, so the variants and index ride into the bundle like any other
381/// output file. Idempotent and cheap on a second run: a file whose sha256
382/// matches the existing index keeps its variants without recompressing, so
383/// re-deploying an unchanged 35 MB wasm costs one hash, not 80 s of brotli.
384pub fn finalize_component_output(project_root: &Path, out_dir: &Path) -> Result<(), BundleError> {
385    let policy = OutputPolicy::load(project_root, out_dir)?;
386    if policy.mesofact_build {
387        let html = out_dir.join("html");
388        finalize_served_dir(&html, &|rel| policy.is_immutable(&format!("html/{rel}")))?;
389        let hydrate = out_dir.join("hydrate");
390        if hydrate.is_dir() {
391            finalize_served_dir(&hydrate, &|rel| policy.is_immutable(&format!("hydrate/{rel}")))?;
392        }
393    } else if out_dir.is_dir() {
394        finalize_served_dir(out_dir, &|rel| policy.is_immutable(rel))?;
395    }
396    Ok(())
397}
398
399/// Which files of one component's build output are immutable, keyed by path
400/// relative to its `out_dir` — the single statement of the shape → rule
401/// mapping that [`finalize_component_output`] (bundle path) and yubaba's
402/// `r2_publish` (R2 path) both apply, so the two cannot drift.
403///
404/// For a mesofact build (`out_dir/html/` exists) `html/<rel>` is matched
405/// against the declared patterns, `hydrate/<rel>` by
406/// [`is_content_hashed_hydrate_output`], and everything else (`manifest.json`,
407/// `tag-index.json`, `server/`) is never immutable — each is rewritten in
408/// place by the next build. For any other output, `<rel>` is matched against
409/// the declared patterns directly.
410#[derive(Debug, Clone, Default)]
411pub struct OutputPolicy {
412    declared: ImmutableGlobs,
413    mesofact_build: bool,
414}
415
416impl OutputPolicy {
417    /// The policy for the component whose project root is `project_root` and
418    /// whose build output is `out_dir` (see [`declared_immutable`]).
419    pub fn load(project_root: &Path, out_dir: &Path) -> Result<Self, BundleError> {
420        Ok(Self {
421            declared: declared_immutable(project_root)?,
422            mesofact_build: out_dir.join("html").is_dir(),
423        })
424    }
425
426    /// `out_rel` is forward-slashed and relative to the component's `out_dir`.
427    pub fn is_immutable(&self, out_rel: &str) -> bool {
428        if !self.mesofact_build {
429            return self.declared.matches(out_rel);
430        }
431        if let Some(rel) = out_rel.strip_prefix("html/") {
432            self.declared.matches(rel)
433        } else if let Some(rel) = out_rel.strip_prefix("hydrate/") {
434            is_content_hashed_hydrate_output(rel)
435        } else {
436            false
437        }
438    }
439}
440
441/// Index and precompress every file under `dir`, writing
442/// `<dir>/`[`ASSET_INDEX_FILE`]. `immutable` decides each file's flag from its
443/// `dir`-relative path. Returns the index it wrote.
444pub fn finalize_served_dir(
445    dir: &Path,
446    immutable: &dyn Fn(&str) -> bool,
447) -> Result<AssetIndex, BundleError> {
448    let io = |what: &str, p: &Path, e: std::io::Error| {
449        BundleError::Io(format!("{what} {}: {e}", p.display()))
450    };
451
452    let mut files = BTreeSet::new();
453    walk_rel(dir, "", &mut files)?;
454
455    let prior = fs::read(dir.join(ASSET_INDEX_FILE))
456        .ok()
457        .and_then(|raw| AssetIndex::parse(&raw))
458        .map(|i| i.assets)
459        .unwrap_or_default();
460
461    // A file the previous pass indexed is gone (a rebuild that does not wipe
462    // its output dir): its variants are ours and now orphaned. Left in place,
463    // `x.js.br` with no `x.js` would be indexed as an asset of its own.
464    for (rel, entry) in &prior {
465        if files.contains(rel) {
466            continue;
467        }
468        for enc in &entry.encodings {
469            let variant = format!("{rel}{}", enc.suffix());
470            if files.remove(&variant) {
471                let path = dir.join(&variant);
472                fs::remove_file(&path).map_err(|e| io("removing orphaned", &path, e))?;
473            }
474        }
475    }
476
477    let mut assets = BTreeMap::new();
478    for rel in &files {
479        if is_variant_of_listed(rel, &files) || rel.rsplit('/').next() == Some(ASSET_INDEX_FILE) {
480            continue;
481        }
482        let path = dir.join(rel);
483        let bytes = fs::read(&path).map_err(|e| io("reading", &path, e))?;
484        let sha256 = sha256_hex(&bytes);
485        let size = bytes.len() as u64;
486        let previous = prior.get(rel).filter(|p| p.sha256 == sha256);
487
488        let mut encodings = Vec::new();
489        let worth_compressing = size >= MIN_COMPRESS_BYTES && is_compressible(rel);
490        for enc in Encoding::ALL {
491            let variant = dir.join(format!("{rel}{}", enc.suffix()));
492            if !worth_compressing {
493                // Only ever remove a variant this function created — the prior
494                // index lists it. A build that shipped its own `x.gz` keeps it.
495                if prior.get(rel).is_some_and(|p| p.encodings.contains(&enc)) && variant.is_file() {
496                    fs::remove_file(&variant).map_err(|e| io("removing stale", &variant, e))?;
497                }
498                continue;
499            }
500            if previous.is_some_and(|p| p.encodings.contains(&enc)) && variant.is_file() {
501                encodings.push(enc);
502                continue;
503            }
504            let compressed = compress(enc, &bytes)
505                .map_err(|e| io(&format!("{} compressing", enc.token()), &path, e))?;
506            if (compressed.len() as u64) * 100 <= size * MAX_KEPT_PERCENT {
507                fs::write(&variant, &compressed).map_err(|e| io("writing", &variant, e))?;
508                encodings.push(enc);
509            } else if variant.is_file() && prior.get(rel).is_some_and(|p| p.encodings.contains(&enc)) {
510                fs::remove_file(&variant).map_err(|e| io("removing stale", &variant, e))?;
511            }
512        }
513
514        assets.insert(
515            rel.clone(),
516            AssetEntry { sha256, size, immutable: immutable(rel), encodings },
517        );
518    }
519
520    let index = AssetIndex { version: ASSET_INDEX_VERSION, assets };
521    let json = serde_json::to_vec_pretty(&index)
522        .map_err(|e| BundleError::Io(format!("serializing asset index: {e}")))?;
523    let index_path = dir.join(ASSET_INDEX_FILE);
524    fs::write(&index_path, json).map_err(|e| io("writing", &index_path, e))?;
525    Ok(index)
526}
527
528/// `rel` is `<other>.br` / `<other>.gz` for some `<other>` in `files` — i.e. a
529/// variant of a file this pass indexes, not an asset in its own right.
530fn is_variant_of_listed(rel: &str, files: &BTreeSet<String>) -> bool {
531    Encoding::ALL
532        .iter()
533        .any(|e| rel.strip_suffix(e.suffix()).is_some_and(|base| files.contains(base)))
534}
535
536/// Text-like and wasm types that compress well. Images, fonts in woff/woff2
537/// and archives are already compressed; a variant of them is wasted bytes.
538fn is_compressible(rel: &str) -> bool {
539    let ext = rel.rsplit_once('.').map(|(_, e)| e.to_ascii_lowercase()).unwrap_or_default();
540    matches!(
541        ext.as_str(),
542        "html" | "htm" | "css" | "js" | "mjs" | "cjs" | "json" | "map" | "xml" | "svg" | "txt"
543            | "md" | "csv" | "sh" | "wasm" | "webmanifest" | "ico" | "ttf" | "otf"
544    )
545}
546
547fn compress(enc: Encoding, bytes: &[u8]) -> std::io::Result<Vec<u8>> {
548    match enc {
549        Encoding::Br => {
550            let large = bytes.len() as u64 >= BROTLI_MAX_QUALITY_BELOW;
551            let params = brotli::enc::BrotliEncoderParams {
552                quality: if large { 9 } else { 11 },
553                lgwin: 24,
554                ..Default::default()
555            };
556            let mut out = Vec::with_capacity(bytes.len() / 3);
557            brotli::BrotliCompress(&mut &bytes[..], &mut out, &params)?;
558            Ok(out)
559        }
560        Encoding::Gzip => {
561            let mut enc = flate2::write::GzEncoder::new(
562                Vec::with_capacity(bytes.len() / 2),
563                flate2::Compression::best(),
564            );
565            enc.write_all(bytes)?;
566            enc.finish()
567        }
568    }
569}
570
571fn walk_rel(abs: &Path, rel: &str, out: &mut BTreeSet<String>) -> Result<(), BundleError> {
572    let entries =
573        fs::read_dir(abs).map_err(|e| BundleError::Io(format!("reading {}: {e}", abs.display())))?;
574    for entry in entries {
575        let entry = entry.map_err(|e| BundleError::Io(format!("reading {}: {e}", abs.display())))?;
576        let name = entry.file_name().to_string_lossy().into_owned();
577        let child = if rel.is_empty() { name } else { format!("{rel}/{name}") };
578        let ty = entry
579            .file_type()
580            .map_err(|e| BundleError::Io(format!("stat {}: {e}", entry.path().display())))?;
581        if ty.is_dir() {
582            walk_rel(&entry.path(), &child, out)?;
583        } else if ty.is_file() {
584            out.insert(child);
585        }
586    }
587    Ok(())
588}
589
590#[cfg(test)]
591mod tests {
592    use super::*;
593
594    fn globs(p: &[&str]) -> ImmutableGlobs {
595        ImmutableGlobs::new(p).unwrap()
596    }
597
598    #[test]
599    fn globs_match_whole_paths_and_never_reach_into_snippets_by_accident() {
600        let g = globs(&["noise_table_browser-*.js", "*_bg.wasm"]);
601        assert!(g.matches("noise_table_browser-ccb80901df6f7edc.js"));
602        assert!(g.matches("noise_table_browser-ccb80901df6f7edc_bg.wasm"));
603        // Trunk's snippets dir looks hashed but is keyed on the crate, not the
604        // bytes — a bare pattern must not reach it (the R825-F1 gotcha).
605        assert!(!g.matches("snippets/noise_table_browser-0f1e2d3c/inline0.js"));
606        assert!(!g.matches("index.html"));
607        assert!(!g.matches("audio_worklet_processor.js"));
608    }
609
610    #[test]
611    fn double_star_spans_zero_or_more_directories() {
612        let g = globs(&["**/*_bg.wasm", "assets/**"]);
613        assert!(g.matches("app_bg.wasm"));
614        assert!(g.matches("a/b/app_bg.wasm"));
615        assert!(g.matches("assets/x.png"));
616        assert!(g.matches("assets/deep/x.png"));
617        assert!(!g.matches("other/x.png"));
618    }
619
620    #[test]
621    fn star_and_question_mark_stay_within_a_segment() {
622        let g = globs(&["a*z", "v?.js"]);
623        assert!(g.matches("az"));
624        assert!(g.matches("abcz"));
625        assert!(!g.matches("ab/cz"));
626        assert!(g.matches("v1.js"));
627        assert!(!g.matches("v12.js"));
628    }
629
630    #[test]
631    fn patterns_that_can_only_be_mistakes_are_refused() {
632        for bad in ["", "/abs.js", "a/../b", "a//b", "[ab].js", "./x"] {
633            assert!(ImmutableGlobs::new(&[bad]).is_err(), "{bad:?} should be refused");
634        }
635    }
636
637    #[test]
638    fn only_bundler_shaped_hydrate_names_are_content_hashed() {
639        for k in ["home.a1b2c3d4.js", "home.chunk-a1b2c3d4.js", "x.AbCd1234_-.js"] {
640            assert!(is_content_hashed_hydrate_output(k), "{k}");
641        }
642        for k in ["app.js", "app.min.js", "home.abc.js", "home.a1b2c3d4.css"] {
643            assert!(!is_content_hashed_hydrate_output(k), "{k}");
644        }
645    }
646
647    #[test]
648    fn declared_immutable_reads_the_build_table_and_refuses_bad_patterns() {
649        let dir = tempfile::tempdir().unwrap();
650        assert!(declared_immutable(dir.path()).unwrap().is_empty(), "no config file");
651
652        fs::write(
653            dir.path().join(DECLARATION_FILE),
654            "[build]\npublic_dir = \"public\"\nimmutable = [\"*_bg.wasm\"]\n[sources.x]\nscope = \"global\"\n",
655        )
656        .unwrap();
657        assert!(declared_immutable(dir.path()).unwrap().matches("x_bg.wasm"));
658
659        fs::write(dir.path().join(DECLARATION_FILE), "[build]\nimmutable = [\"/abs\"]\n").unwrap();
660        assert!(declared_immutable(dir.path()).is_err());
661
662        fs::write(dir.path().join(DECLARATION_FILE), "[build]\nimmutable = \"*.js\"\n").unwrap();
663        assert!(declared_immutable(dir.path()).is_err(), "a bare string is not a list");
664    }
665
666    fn js_body(n: usize) -> Vec<u8> {
667        "export const value = 'repetitive text compresses well';\n".repeat(n).into_bytes()
668    }
669
670    #[test]
671    fn finalize_indexes_marks_declared_files_and_writes_smaller_variants() {
672        let dir = tempfile::tempdir().unwrap();
673        let root = dir.path();
674        fs::write(root.join("app-0123abcd.js"), js_body(200)).unwrap();
675        fs::write(root.join("index.html"), b"<html>tiny</html>").unwrap();
676        fs::write(root.join("logo.png"), vec![7u8; 4096]).unwrap();
677
678        let index = finalize_served_dir(root, &|rel| rel.starts_with("app-")).unwrap();
679
680        let js = &index.assets["app-0123abcd.js"];
681        assert!(js.immutable);
682        assert_eq!(js.encodings, vec![Encoding::Br, Encoding::Gzip]);
683        assert_eq!(js.sha256, format!("{:x}", Sha256::digest(js_body(200))));
684        let br = fs::read(root.join("app-0123abcd.js.br")).unwrap();
685        let mut decoded = Vec::new();
686        brotli::BrotliDecompress(&mut &br[..], &mut decoded).unwrap();
687        assert_eq!(decoded, js_body(200), "the variant must decode to the identity bytes");
688
689        // Under MIN_COMPRESS_BYTES, and an incompressible type: no variants.
690        assert!(index.assets["index.html"].encodings.is_empty());
691        assert!(!index.assets["index.html"].immutable);
692        assert!(index.assets["logo.png"].encodings.is_empty());
693        assert!(!root.join("logo.png.br").exists());
694
695        // The index itself and the variants are not assets.
696        assert_eq!(index.assets.len(), 3);
697        let on_disk = AssetIndex::parse(&fs::read(root.join(ASSET_INDEX_FILE)).unwrap()).unwrap();
698        assert_eq!(on_disk, index);
699        assert!(is_finalize_artifact(root, "app-0123abcd.js.gz"));
700        assert!(is_finalize_artifact(root, ASSET_INDEX_FILE));
701        assert!(!is_finalize_artifact(root, "app-0123abcd.js"));
702    }
703
704    #[test]
705    fn refinalizing_reuses_variants_for_unchanged_bytes_and_replaces_them_for_changed_ones() {
706        let dir = tempfile::tempdir().unwrap();
707        let root = dir.path();
708        fs::write(root.join("a.js"), js_body(100)).unwrap();
709        finalize_served_dir(root, &|_| false).unwrap();
710
711        // Plant a sentinel in the variant: an unchanged source must keep it
712        // (proof nothing was recompressed).
713        fs::write(root.join("a.js.br"), b"sentinel").unwrap();
714        finalize_served_dir(root, &|_| false).unwrap();
715        assert_eq!(fs::read(root.join("a.js.br")).unwrap(), b"sentinel");
716
717        // Changed bytes ⇒ fresh variant.
718        fs::write(root.join("a.js"), js_body(150)).unwrap();
719        finalize_served_dir(root, &|_| false).unwrap();
720        let mut decoded = Vec::new();
721        brotli::BrotliDecompress(&mut &fs::read(root.join("a.js.br")).unwrap()[..], &mut decoded)
722            .unwrap();
723        assert_eq!(decoded, js_body(150));
724
725        // Shrunk below the threshold ⇒ the variants this function made are removed.
726        fs::write(root.join("a.js"), b"x").unwrap();
727        let index = finalize_served_dir(root, &|_| false).unwrap();
728        assert!(index.assets["a.js"].encodings.is_empty());
729        assert!(!root.join("a.js.br").exists());
730        assert!(!root.join("a.js.gz").exists());
731    }
732
733    #[test]
734    fn a_deleted_source_takes_its_variants_with_it() {
735        let dir = tempfile::tempdir().unwrap();
736        let root = dir.path();
737        fs::write(root.join("gone.js"), js_body(100)).unwrap();
738        fs::write(root.join("kept.js"), js_body(100)).unwrap();
739        finalize_served_dir(root, &|_| false).unwrap();
740        assert!(root.join("gone.js.br").exists());
741
742        fs::remove_file(root.join("gone.js")).unwrap();
743        let index = finalize_served_dir(root, &|_| false).unwrap();
744
745        assert!(!root.join("gone.js.br").exists());
746        assert!(!root.join("gone.js.gz").exists());
747        assert_eq!(index.assets.keys().collect::<Vec<_>>(), vec!["kept.js"]);
748    }
749
750    #[test]
751    fn a_shipped_archive_with_no_identity_sibling_is_an_asset_not_a_variant() {
752        let dir = tempfile::tempdir().unwrap();
753        fs::write(dir.path().join("data.tar.gz"), vec![1u8; 2048]).unwrap();
754        let index = finalize_served_dir(dir.path(), &|_| false).unwrap();
755        assert!(index.assets.contains_key("data.tar.gz"));
756    }
757
758    #[test]
759    fn component_output_finalizes_html_and_hydrate_roots_of_a_mesofact_build() {
760        let project = tempfile::tempdir().unwrap();
761        fs::write(project.path().join(DECLARATION_FILE), "[build]\nimmutable = [\"img/*\"]\n").unwrap();
762        let out = project.path().join("dist");
763        fs::create_dir_all(out.join("html/img")).unwrap();
764        fs::create_dir_all(out.join("hydrate")).unwrap();
765        fs::write(out.join("html/index.html"), js_body(50)).unwrap();
766        fs::write(out.join("html/img/a.png"), b"png").unwrap();
767        fs::write(out.join("hydrate/home.a1b2c3d4.js"), js_body(50)).unwrap();
768        fs::write(out.join("hydrate/polyfill.js"), js_body(50)).unwrap();
769
770        finalize_component_output(project.path(), &out).unwrap();
771
772        let html = AssetIndex::parse(&fs::read(out.join("html").join(ASSET_INDEX_FILE)).unwrap()).unwrap();
773        assert!(html.assets["img/a.png"].immutable);
774        assert!(!html.assets["index.html"].immutable);
775        let hydrate =
776            AssetIndex::parse(&fs::read(out.join("hydrate").join(ASSET_INDEX_FILE)).unwrap()).unwrap();
777        assert!(hydrate.assets["home.a1b2c3d4.js"].immutable);
778        assert!(!hydrate.assets["polyfill.js"].immutable);
779        assert!(!out.join(ASSET_INDEX_FILE).exists(), "a mesofact build's dist/ root is not served");
780    }
781
782    #[test]
783    fn etags_differ_per_representation() {
784        let e = AssetEntry { sha256: "ab".into(), size: 1, immutable: false, encodings: vec![] };
785        assert_eq!(e.etag(None), "\"ab\"");
786        assert_eq!(e.etag(Some(Encoding::Br)), "\"ab.br\"");
787        assert_eq!(e.etag(Some(Encoding::Gzip)), "\"ab.gzip\"");
788    }
789}