Skip to main content

ssg/plugins/
assets.rs

1// Copyright © 2023 - 2026 Static Site Generator (SSG). All rights reserved.
2// SPDX-License-Identifier: Apache-2.0 OR MIT
3
4//! Asset optimization: fingerprinting, SRI hashes, and basic minification.
5//!
6//! Provides cache-busting via content-hash filenames and Subresource
7//! Integrity attributes for CSS and JS files.
8
9use crate::cmd::SriAlgorithm;
10use crate::error::{PathErrorExt, SsgError};
11use crate::plugin::{Plugin, PluginContext};
12use sha2::{Digest, Sha256};
13use std::{
14    collections::HashMap,
15    fs,
16    path::{Path, PathBuf},
17};
18
19mod html_refs;
20mod js_minify;
21
22use html_refs::rewrite_asset_refs;
23pub use js_minify::minify_js;
24
25/// Plugin that fingerprints CSS/JS assets and rewrites HTML references.
26///
27/// Runs in `after_compile`:
28/// 1. Hash each `.css` and `.js` file (SHA-256, first 8 hex chars)
29/// 2. Rename: `style.css` → `style.a1b2c3d4.css`
30/// 3. Rewrite all HTML `<link>` and `<script>` references
31/// 4. Add `integrity` and `crossorigin` attributes (SRI)
32///
33/// The SRI digest algorithm defaults to SHA-384 and is configurable
34/// via `[security] sri_algorithm` in `ssg.toml` (v0.0.47 plan §3
35/// item 2.3). The cache-busting filename fingerprint stays SHA-256
36/// regardless — it is not a security control.
37#[derive(Debug, Clone, Copy)]
38pub struct FingerprintPlugin;
39
40impl Plugin for FingerprintPlugin {
41    fn name(&self) -> &'static str {
42        "fingerprint"
43    }
44
45    fn after_compile(&self, ctx: &PluginContext) -> Result<(), SsgError> {
46        if !ctx.site_dir.exists() {
47            return Ok(());
48        }
49
50        let all_assets = collect_assets(&ctx.site_dir)?;
51        if all_assets.is_empty() {
52            return Ok(());
53        }
54
55        // Three-pass fingerprinting (resolves the CSS-url() problem
56        // surfaced in the v0.0.39 audit):
57        //
58        //   1. Hash + rename non-CSS assets first (images, fonts,
59        //      JS). Build the first-stage manifest.
60        //   2. Walk every CSS file. Patch any `url(...)` references
61        //      that resolve to an entry in the first-stage manifest
62        //      so they point at the new fingerprinted name. THEN
63        //      hash + rename the CSS — its SRI hash now covers the
64        //      post-rewrite content.
65        //   3. Walk every HTML file and rewrite `<link href>`,
66        //      `<script src>`, `<img src>`, etc. against the full
67        //      manifest, attaching `integrity` + `crossorigin` for
68        //      CSS/JS where SRI is meaningful.
69        //
70        // Without this split, CSS `url(/images/logo.png)` would
71        // 404 after `logo.png` was renamed to `logo.<hash>.png`.
72
73        let (css_files, non_css): (Vec<_>, Vec<_>) = all_assets
74            .into_iter()
75            .partition(|p| p.extension().is_some_and(|e| e == "css"));
76
77        // `[security] sri_algorithm` from ssg.toml; absent config ⇒
78        // SHA-384 (v0.0.47 plan §3 item 2.3).
79        let sri_algorithm = ctx
80            .config
81            .as_ref()
82            .map_or_else(SriAlgorithm::default, |c| c.security.sri_algorithm);
83
84        let mut manifest =
85            fingerprint_assets(&non_css, &ctx.site_dir, sri_algorithm)?;
86
87        for css_path in &css_files {
88            rewrite_css_urls_inplace(css_path, &ctx.site_dir, &manifest)?;
89        }
90
91        let css_manifest =
92            fingerprint_assets(&css_files, &ctx.site_dir, sri_algorithm)?;
93        manifest.extend(css_manifest);
94
95        rewrite_html_references(&ctx.site_dir, &manifest)?;
96
97        log::info!(
98            "[fingerprint] Processed {} asset(s) across {} CSS + {} other",
99            manifest.len(),
100            css_files.len(),
101            manifest.len() - css_files.len()
102        );
103        Ok(())
104    }
105}
106
107/// Fingerprints all asset files: computes hash, renames, and builds the manifest.
108fn fingerprint_assets(
109    assets: &[PathBuf],
110    site_dir: &Path,
111    sri_algorithm: SriAlgorithm,
112) -> Result<HashMap<String, AssetInfo>, SsgError> {
113    let mut manifest = HashMap::new();
114
115    for asset_path in assets {
116        let info = fingerprint_file(asset_path, site_dir, sri_algorithm)?;
117        let _ = manifest.insert(info.0, info.1);
118    }
119
120    Ok(manifest)
121}
122
123/// Fingerprints a single asset file: hash, rename, return (`old_rel`, `AssetInfo`).
124fn fingerprint_file(
125    asset_path: &Path,
126    site_dir: &Path,
127    sri_algorithm: SriAlgorithm,
128) -> Result<(String, AssetInfo), SsgError> {
129    let mut content = fs::read(asset_path).with_path(asset_path)?;
130    let ext = asset_path
131        .extension()
132        .unwrap_or_default()
133        .to_string_lossy()
134        .to_string();
135    let mut minified = false;
136
137    if ext == "css" {
138        if let Ok(css_str) = std::str::from_utf8(&content) {
139            content = minify_css(css_str).into_bytes();
140            minified = true;
141        }
142    } else if ext == "js" || ext == "mjs" {
143        if let Ok(js_str) = std::str::from_utf8(&content) {
144            content = minify_js(js_str).into_bytes();
145            minified = true;
146        }
147    }
148
149    let hash = sha256_hex(&content);
150    let short_hash = &hash[..8];
151
152    let stem = asset_path.file_stem().unwrap_or_default().to_string_lossy();
153    let new_name = format!("{stem}.{short_hash}.{ext}");
154    let new_path = asset_path.with_file_name(&new_name);
155
156    let sri = sri_algorithm.integrity(&content);
157
158    if minified {
159        fs::write(&new_path, &content).with_path(&new_path)?;
160    } else {
161        let _ = fs::copy(asset_path, &new_path).with_path(asset_path)?;
162    }
163
164    // Fingerprinting is a rename, not a copy. Leaving the source in place
165    // shipped every stylesheet and script twice — once fingerprinted and
166    // minified, once not — and the unfingerprinted copy stayed reachable at
167    // a stable URL, so anything still pointing at it received content that
168    // no `integrity` attribute covered.
169    //
170    // Guarded against the degenerate case where the hash lands on the name
171    // the file already has, which would otherwise delete the asset.
172    if new_path != asset_path {
173        // The failpoint precedes the removal so an injected error exercises
174        // the same branch a real `remove_file` failure would: the
175        // fingerprinted copy is already on disk, and the original must be
176        // left in place rather than half-removed.
177        fail_point!("assets::remove-original", |_| Err(SsgError::Validation {
178            field: "assets".to_string(),
179            message: "injected: assets::remove-original".to_string(),
180        }));
181        fs::remove_file(asset_path).with_path(asset_path)?;
182    }
183
184    let rel_old = asset_path
185        .strip_prefix(site_dir)
186        .unwrap_or(asset_path)
187        .to_string_lossy()
188        .replace('\\', "/");
189    let rel_new = new_path
190        .strip_prefix(site_dir)
191        .unwrap_or(&new_path)
192        .to_string_lossy()
193        .replace('\\', "/");
194
195    Ok((
196        rel_old,
197        AssetInfo {
198            fingerprinted: rel_new,
199            sri,
200        },
201    ))
202}
203
204/// Rewrites HTML files to use fingerprinted asset references.
205fn rewrite_html_references(
206    site_dir: &Path,
207    manifest: &HashMap<String, AssetInfo>,
208) -> Result<(), SsgError> {
209    let html_files = collect_html_files(site_dir)?;
210    for html_path in &html_files {
211        let html = fs::read_to_string(html_path).with_path(html_path)?;
212        let rewritten = rewrite_asset_refs(&html, manifest)?;
213        if rewritten != html {
214            fs::write(html_path, rewritten).with_path(html_path)?;
215        }
216    }
217    Ok(())
218}
219
220#[derive(Debug, Clone)]
221struct AssetInfo {
222    fingerprinted: String,
223    sri: String,
224}
225
226/// Rewrites every `url(...)` reference in a CSS file in place,
227/// pointing each one at the fingerprinted name from `manifest` if
228/// the URL resolves to a known asset.
229///
230/// Resolution rules:
231///
232/// - `url(/foo.png)` — absolute from the site root; lookup key is
233///   `foo.png`.
234/// - `url(./foo.png)`, `url(../images/foo.png)` — resolved against
235///   the CSS file's parent directory, then made site-relative.
236/// - `url(images/foo.png)` (bare, no `/` or `./`) — same as the
237///   relative case above.
238/// - URLs containing `://` (full URLs) and `data:` URIs are left
239///   untouched.
240///
241/// Output URLs are written as **absolute, site-rooted paths**
242/// (`/foo.<hash>.png`) regardless of the original form. This is
243/// valid CSS and unambiguous; it deliberately trades a tiny bit of
244/// stylistic preservation for correctness.
245///
246/// Quote forms handled: `url(x)`, `url("x")`, `url('x')`. URLs with
247/// query strings or fragments (e.g. `url(foo.svg#icon)`,
248/// `url(foo.css?v=1)`) preserve the suffix on the rewritten URL.
249fn rewrite_css_urls(
250    css: &str,
251    css_path: &Path,
252    site_dir: &Path,
253    manifest: &HashMap<String, AssetInfo>,
254) -> String {
255    let css_dir = css_path.parent().unwrap_or(css_path);
256    let mut out = String::with_capacity(css.len());
257    let mut remaining = css;
258
259    while let Some(idx) = remaining.find("url(") {
260        out.push_str(&remaining[..idx]);
261        let after_open = &remaining[idx + 4..]; // past "url("
262        let Some(close_idx) = after_open.find(')') else {
263            // Unterminated url(...) — leave the rest unchanged.
264            out.push_str("url(");
265            out.push_str(after_open);
266            return out;
267        };
268        let raw = &after_open[..close_idx];
269        let rest = &after_open[close_idx + 1..];
270
271        // Strip optional surrounding quotes.
272        let trimmed = raw.trim();
273        let (quote, inner) = if let Some(s) = trimmed.strip_prefix('"') {
274            ('"', s.strip_suffix('"').unwrap_or(s))
275        } else if let Some(s) = trimmed.strip_prefix('\'') {
276            ('\'', s.strip_suffix('\'').unwrap_or(s))
277        } else {
278            ('\0', trimmed)
279        };
280
281        // Split off ?query or #fragment so we don't try to resolve them.
282        let (url, suffix) = if let Some(i) = inner.find(['?', '#']) {
283            (&inner[..i], &inner[i..])
284        } else {
285            (inner, "")
286        };
287
288        let resolved = resolve_css_url(url, css_dir, site_dir);
289        let hit = resolved.and_then(|key| manifest.get(&key).map(|i| (key, i)));
290
291        out.push_str("url(");
292        if let Some((_, info)) = hit {
293            // Emit absolute /<fingerprinted>(suffix).
294            let new_url = format!("/{}{}", info.fingerprinted, suffix);
295            if quote != '\0' {
296                out.push(quote);
297            }
298            out.push_str(&new_url);
299            if quote != '\0' {
300                out.push(quote);
301            }
302        } else {
303            // No manifest hit — emit the original verbatim.
304            out.push_str(raw);
305        }
306        out.push(')');
307
308        remaining = rest;
309    }
310
311    out.push_str(remaining);
312    out
313}
314
315/// Resolves a CSS URL to a site-relative manifest key.
316///
317/// Returns `None` for full URLs (`http://`, `https://`, `//`),
318/// `data:` URIs, and paths that escape the site directory.
319fn resolve_css_url(
320    url: &str,
321    css_dir: &Path,
322    site_dir: &Path,
323) -> Option<String> {
324    let trimmed = url.trim();
325    if trimmed.is_empty()
326        || trimmed.starts_with("data:")
327        || trimmed.starts_with("http://")
328        || trimmed.starts_with("https://")
329        || trimmed.starts_with("//")
330    {
331        return None;
332    }
333
334    // Express the URL relative to the site directory, then resolve it
335    // lexically (no filesystem access, so already-renamed targets
336    // still resolve). Anything that climbs above the site directory,
337    // even transiently, is refused.
338    let relative = if let Some(stripped) = trimmed.strip_prefix('/') {
339        PathBuf::from(stripped)
340    } else {
341        css_dir.strip_prefix(site_dir).ok()?.join(trimmed)
342    };
343    // The manifest stores keys relative to site_dir (no leading slash).
344    let rel = commons::path::safe_join(Path::new(""), relative).ok()?;
345    Some(rel.to_string_lossy().replace('\\', "/"))
346}
347
348/// Reads, rewrites, and writes a CSS file in place if any of its
349/// `url(...)` references resolve to a manifest entry.
350fn rewrite_css_urls_inplace(
351    css_path: &Path,
352    site_dir: &Path,
353    manifest: &HashMap<String, AssetInfo>,
354) -> Result<(), SsgError> {
355    let css = fs::read_to_string(css_path).with_path(css_path)?;
356    let rewritten = rewrite_css_urls(&css, css_path, site_dir, manifest);
357    if rewritten != css {
358        fs::write(css_path, rewritten).with_path(css_path)?;
359    }
360    Ok(())
361}
362
363/// SHA-256 hash as a 64-char hex string.
364///
365/// Used only for the cache-busting fingerprint suffix
366/// (`name.<hash>.ext`); the first 8 hex characters of this output are
367/// taken as the short content fingerprint. The `integrity` attribute
368/// is computed separately via [`SriAlgorithm::integrity`] (SHA-384 by
369/// default — v0.0.47 plan §3 item 2.3), so the filename fingerprint
370/// deliberately stays SHA-256: it is a cache key, not a security
371/// control.
372fn sha256_hex(data: &[u8]) -> String {
373    let mut hasher = Sha256::new();
374    hasher.update(data);
375    let bytes = hasher.finalize();
376    let mut s = String::with_capacity(64);
377    for b in bytes {
378        use std::fmt::Write as _;
379        let _ = write!(s, "{b:02x}");
380    }
381    s
382}
383
384/// Minimal CSS minifier that removes comments and compresses whitespace.
385/// Whether the innermost open block holds declarations rather than
386/// further rules.
387///
388/// Empty stack means top level, which is a selector or at-rule prelude.
389/// Otherwise the innermost entry says what opened the block: an at-rule
390/// prelude (`false` here — its content is rules) or a selector (`true` —
391/// its content is declarations).
392const fn in_value_context(block_stack: &[bool]) -> bool {
393    matches!(block_stack.last(), Some(false))
394}
395
396/// Minifies CSS by collapsing insignificant whitespace and dropping
397/// comments.
398///
399/// ssg's own implementation rather than a dependency: minification rewrites
400/// every stylesheet the generator emits, so a bug here is a bug on every
401/// page, and a parser that "improves" a selector can silently change what it
402/// matches. This pass only removes what cannot matter - it never rewrites a
403/// selector, a value or a string.
404#[must_use]
405pub fn minify_css(css: &str) -> String {
406    let mut result = String::with_capacity(css.len());
407    let mut chars = css.chars().peekable();
408    let mut in_comment = false;
409    let mut in_string = None;
410
411    while let Some(ch) = chars.next() {
412        if in_comment {
413            if ch == '*' && chars.peek() == Some(&'/') {
414                let _ = chars.next();
415                in_comment = false;
416            }
417            continue;
418        }
419
420        if let Some(quote) = in_string {
421            result.push(ch);
422            if ch == quote {
423                let mut backslashes = 0;
424                let mut temp = result.len() as isize - 2;
425                while temp >= 0 && result.as_bytes()[temp as usize] == b'\\' {
426                    backslashes += 1;
427                    temp -= 1;
428                }
429                if backslashes % 2 == 0 {
430                    in_string = None;
431                }
432            }
433            continue;
434        }
435
436        if ch == '/' && chars.peek() == Some(&'*') {
437            let _ = chars.next();
438            in_comment = true;
439            continue;
440        }
441
442        if ch == '\'' || ch == '"' {
443            in_string = Some(ch);
444            result.push(ch);
445            continue;
446        }
447
448        if ch.is_whitespace() {
449            result.push(' ');
450            continue;
451        }
452
453        result.push(ch);
454    }
455
456    let mut clean = String::with_capacity(result.len());
457    let chars: Vec<char> = result.chars().collect();
458    let mut i = 0;
459    // Quote state is tracked here too. The first pass copies string contents
460    // verbatim, but this pass then walked the same buffer without knowing
461    // where strings were, so `content: "  two  spaces  "` came out as
462    // `content:"twospaces"` — the minifier rewriting authored text.
463    let mut in_string: Option<char> = None;
464    // Whether the cursor is inside a declaration block. Outside one the
465    // text is a selector or an at-rule prelude, and there a space is
466    // usually a descendant combinator rather than padding.
467    //
468    // The two contexts need opposite defaults, which is what the
469    // character-class heuristic below could not express. In a value,
470    // spaces are noise apart from a few known cases. In a selector,
471    // dropping a space does not tighten the match, it changes it:
472    //
473    //     .a :hover     -> .a:hover      a different element
474    //     .a ::before   -> .a::before    a different element
475    //     a[href] span  -> a[href]span   matches nothing at all
476    //     [x] [y]       -> [x][y]        one element, not two
477    //     :not(.a) .b   -> :not(.a).b    one element, not two
478    //     #a *          -> #a*           invalid, rule discarded
479    //
480    // Every one of those was being emitted. The last cost three themes
481    // their search-widget override, silently.
482    // One entry per open brace: true when that block was opened by an
483    // at-rule prelude. The distinction matters because a block's content
484    // is declarations only if a *selector* opened it — inside
485    // `@media (…) { … }` the content is more rules, and their selectors
486    // need the same treatment as top-level ones.
487    //
488    // A bool was not enough. With a single in/out flag, everything
489    // nested in a media query was read as a value, and
490    // `:root:not([data-theme="light"]) .theme-toggle` lost the space
491    // after `)` — descendant became compound, and five themes lost their
492    // dark-mode icon rule.
493    let mut block_stack: Vec<bool> = Vec::new();
494    // Whether the prelude being read starts with `@`.
495    let mut prelude_is_at_rule = false;
496    while i < chars.len() {
497        let ch = chars[i];
498        if in_string.is_none() {
499            match ch {
500                '@' if !in_value_context(&block_stack) => {
501                    prelude_is_at_rule = true;
502                }
503                '{' => {
504                    block_stack.push(prelude_is_at_rule);
505                    prelude_is_at_rule = false;
506                }
507                '}' => {
508                    let _ = block_stack.pop();
509                    prelude_is_at_rule = false;
510                }
511                ';' if !in_value_context(&block_stack) => {
512                    prelude_is_at_rule = false;
513                }
514                _ => {}
515            }
516        }
517        let in_block = in_value_context(&block_stack);
518
519        if let Some(q) = in_string {
520            clean.push(ch);
521            // A quote closes the string unless it is backslash-escaped.
522            if ch == q {
523                let mut back = 0usize;
524                let mut k = i;
525                while k > 0 && chars[k - 1] == '\\' {
526                    back += 1;
527                    k -= 1;
528                }
529                if back.is_multiple_of(2) {
530                    in_string = None;
531                }
532            }
533            i += 1;
534            continue;
535        }
536        if ch == '\'' || ch == '"' {
537            in_string = Some(ch);
538            clean.push(ch);
539            i += 1;
540            continue;
541        }
542
543        if ch == ' ' {
544            // A run of whitespace is judged as one gap: its neighbours are
545            // the last character *emitted* and the next *non-space*
546            // character. Judged space by space, `body   {` kept two of its
547            // three, and in a declaration neither space of
548            // `1.125rem  + 0.55vw` saw a word on both sides, so the one
549            // before `+` was dropped and the clamp() became invalid. The
550            // same flaw turned `margin: 0  -1px` into `0-1px` and
551            // `@media (a)  and (b)` into `(a)and (b)`.
552            let mut j = i;
553            while j < chars.len() && chars[j] == ' ' {
554                j += 1;
555            }
556            let prev = clean.chars().next_back();
557            let next = chars.get(j).copied();
558            i = j;
559            if !in_block {
560                // Selector or at-rule prelude. Keep one space unless it
561                // sits against punctuation that already separates the
562                // tokens: a combinator, a comma, or a brace. Keeping it
563                // around `>` would be valid too, but those few bytes are
564                // worth reclaiming where the meaning cannot change.
565                let separator = |c: Option<char>| {
566                    matches!(c, Some('{' | '}' | ',' | '>' | '~' | '+' | ';'))
567                        || c.is_none()
568                };
569                if !separator(prev) && !separator(next) && prev != Some(' ') {
570                    clean.push(' ');
571                }
572                continue;
573            }
574
575            // `+` counts as a word character here for one reason: CSS math
576            // functions require whitespace around `+` and `-`, and dropping it
577            // does not merely lengthen the output, it invalidates the
578            // declaration. `clamp(2.07rem, 1.75rem + 1.6vw, 3.13rem)` became
579            // `clamp(2.07rem,1.75rem+1.6vw,3.13rem)`, which every browser
580            // rejects — so every fluid type step in every theme silently fell
581            // back and headings rendered at the body size.
582            //
583            // `*` is in both sets for the same class of reason, found the
584            // hard way. It needs no space inside `calc()`, but in a
585            // selector it is the universal selector and the space before
586            // it is a descendant combinator: `#btn *` became `#btn*`,
587            // which matches nothing. Three themes override the search
588            // widget with `#ssg-search-btn * { color: … !important }`,
589            // and all three overrides were silently discarded — the
590            // widget kept the generator's own colour and failed AAA
591            // contrast against the theme's surface. Keeping the space in
592            // `calc(2 * 3)` costs two bytes and is valid.
593            //
594            // `-` was already in both sets and so was never affected;
595            // `/` needs no surrounding space and stays collapsible.
596            // Keeping the space in a `.a + .b` selector too costs two
597            // bytes and is valid.
598            // Media and support queries put a required space either side of
599            // their combinators: `@media (a) and (b)` collapsed to
600            // `@media(a)and(b)`, which is invalid and silently disables the
601            // whole query. Only single-condition queries are in the themes
602            // today, so nothing shipped broken — but the first compound query
603            // anyone wrote would have.
604            let joins_prelude_tokens = match (prev, next) {
605                (Some(')'), Some(n)) => n.is_ascii_alphabetic(),
606                (Some(p), Some('(')) => p.is_ascii_alphabetic(),
607                _ => false,
608            };
609
610            // In a math function `+` and `-` are operators only when
611            // whitespace surrounds them: `calc(var(--x) + 2px)` collapsed to
612            // `calc(var(--x)+ 2px)` is a parse error, not a shorter spelling.
613            // A value can end in `)` and the operand after one can open with
614            // `(`, and neither is a "word" character below, so those two
615            // boundaries have to be named.
616            let math_operator_boundary = matches!(
617                (prev, next),
618                (Some(')'), Some('+' | '-')) | (Some('+' | '-'), Some('('))
619            );
620
621            let is_needed = joins_prelude_tokens
622                || math_operator_boundary
623                || (prev.is_some_and(is_css_word_char)
624                    && next.is_some_and(is_css_word_char));
625            if is_needed {
626                clean.push(' ');
627            }
628            continue;
629        }
630        clean.push(ch);
631        i += 1;
632    }
633
634    clean.trim().to_string()
635}
636
637/// Whether `c` belongs to a token that a following or preceding token
638/// would merge with if the space between them were dropped. See the
639/// `minify_css` value branch for why `+`, `-` and `*` are included.
640fn is_css_word_char(c: char) -> bool {
641    c.is_alphanumeric()
642        || matches!(c, '-' | '+' | '_' | '#' | '.' | '@' | '%' | '*' | '$')
643}
644
645/// Asset extensions we content-fingerprint. Matches the
646/// "content-addressable asset pipeline" intent of issue #468:
647/// CSS/JS for code, common raster + vector image formats for art,
648/// font formats for typography. Each gets a `name.hash.ext` rename
649/// and an SRI hash; deploy configs serve them with
650/// `Cache-Control: public, max-age=31536000, immutable`.
651const FINGERPRINTED_EXTENSIONS: &[&str] = &[
652    "css", "js", "mjs", "png", "jpg", "jpeg", "webp", "avif", "gif", "svg",
653    "woff", "woff2", "ttf", "otf",
654];
655
656/// Collects every fingerprintable asset from site dir.
657/// Directories whose assets must keep their authored filenames.
658///
659/// `_islands/` holds the island loader and the component bundles it pulls in
660/// with a *dynamic* `import()` built at runtime from the component name.
661/// A static rewriter cannot see that construction, so fingerprinting the
662/// bundles renamed the files without updating the only thing that resolves
663/// them — every island 404'd on hydration. The loader tag in the HTML has
664/// the same problem, since the islands plugin emits it after this pass.
665///
666/// These files are already effectively immutable per build; skipping them
667/// costs a cache-busting opportunity and buys a working feature.
668const UNFINGERPRINTED_DIRS: &[&str] = &["_islands"];
669
670fn collect_assets(dir: &Path) -> Result<Vec<PathBuf>, SsgError> {
671    let all = crate::walk::walk_files_multi(dir, FINGERPRINTED_EXTENSIONS)?;
672    Ok(all
673        .into_iter()
674        .filter(|path| {
675            !path.components().any(|c| {
676                UNFINGERPRINTED_DIRS
677                    .iter()
678                    .any(|d| c.as_os_str() == std::ffi::OsStr::new(d))
679            })
680        })
681        .collect())
682}
683
684fn collect_html_files(dir: &Path) -> Result<Vec<PathBuf>, SsgError> {
685    crate::walk::walk_files(dir, "html")
686}
687
688#[cfg(test)]
689mod tests {
690    use super::*;
691    use tempfile::tempdir;
692
693    #[test]
694    fn test_minify_css() {
695        let input = "body {\n  color: red;\n  background-color: #ffffff; /* comment */\n}";
696        let expected = "body{color:red;background-color:#ffffff;}";
697        assert_eq!(minify_css(input), expected);
698    }
699
700    #[test]
701    fn test_minify_js() {
702        let input = "const x = 5; // comment\n/* multi\ncomment */\nconst y = 10;\nconsole.log(x + y);";
703        let expected = "const x=5;const y=10;console.log(x+y);";
704        assert_eq!(minify_js(input), expected);
705    }
706
707    #[test]
708    fn test_sha256_hex_deterministic() {
709        let h1 = sha256_hex(b"hello");
710        let h2 = sha256_hex(b"hello");
711        assert_eq!(h1, h2);
712        // Real SHA-256 is 32 bytes → 64 hex chars.
713        assert_eq!(h1.len(), 64);
714    }
715
716    #[test]
717    fn test_sha256_hex_known_vectors() {
718        // Verifies real SHA-256 is in use, not an FNV placeholder.
719        // Empty input — well-known SHA-256("") digest.
720        assert_eq!(
721            sha256_hex(b""),
722            "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
723        );
724        // "abc" — the canonical NIST test vector.
725        assert_eq!(
726            sha256_hex(b"abc"),
727            "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
728        );
729    }
730
731    #[test]
732    fn test_sri_default_algorithm_known_vector() {
733        // SHA-384("") base64-encoded — the canonical empty-input
734        // digest. The default SRI algorithm is SHA-384 (v0.0.47 plan
735        // §3 item 2.3).
736        assert_eq!(
737            SriAlgorithm::default().integrity(b""),
738            "sha384-OLBgp1GsljhM2TJ+sbHjaiH9txEUvgdDTAzHv2P24donTt6/529l+9Ua0vFImLlb"
739        );
740    }
741
742    #[test]
743    fn test_sri_sha256_override_known_vector() {
744        // SHA-256("") base64-encoded is the canonical 47DEQpj8... value.
745        assert_eq!(
746            SriAlgorithm::Sha256.integrity(b""),
747            "sha256-47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU="
748        );
749    }
750
751    #[test]
752    fn test_sha256_hex_varies() {
753        let h1 = sha256_hex(b"hello");
754        let h2 = sha256_hex(b"world");
755        assert_ne!(h1, h2);
756    }
757
758    #[test]
759    #[serial_test::parallel(assets_failpoint)]
760    fn test_fingerprint_plugin() {
761        let dir = tempdir().unwrap();
762        let site = dir.path().join("site");
763        fs::create_dir_all(&site).unwrap();
764
765        // Create a CSS file
766        fs::write(site.join("style.css"), "body { color: red; }").unwrap();
767
768        // Create HTML that references it
769        let html = r#"<html><head><link rel="stylesheet" href="style.css"></head><body></body></html>"#;
770        fs::write(site.join("index.html"), html).unwrap();
771
772        let ctx = PluginContext::new(dir.path(), dir.path(), &site, dir.path());
773        FingerprintPlugin.after_compile(&ctx).unwrap();
774
775        // Original file should be gone
776        assert!(!site.join("style.css").exists());
777
778        // Fingerprinted file should exist
779        let entries: Vec<_> = fs::read_dir(&site)
780            .unwrap()
781            .filter_map(Result::ok)
782            .filter(|e| {
783                e.path()
784                    .file_name()
785                    .unwrap()
786                    .to_string_lossy()
787                    .starts_with("style.")
788                    && e.path().extension().is_some_and(|e| e == "css")
789            })
790            .collect();
791        assert_eq!(entries.len(), 1);
792
793        // HTML should reference the fingerprinted file with a SHA-384
794        // integrity attribute (the default — v0.0.47 plan §3 item 2.3).
795        let output = fs::read_to_string(site.join("index.html")).unwrap();
796        assert!(output.contains("integrity=\"sha384-"));
797        assert!(output.contains("crossorigin=\"anonymous\""));
798        assert!(!output.contains("href=\"style.css\""));
799    }
800
801    #[test]
802    #[serial_test::parallel(assets_failpoint)]
803    fn default_sri_is_sha384_with_exact_known_vector() {
804        // End-to-end through the plugin with NO config: the JS body
805        // survives minification byte-for-byte ("console.log(1);" has
806        // no removable whitespace/comments), so the emitted integrity
807        // attribute must be exactly base64(SHA-384("console.log(1);")).
808        let dir = tempdir().unwrap();
809        let site = dir.path().join("site");
810        fs::create_dir_all(&site).unwrap();
811        fs::write(site.join("app.js"), "console.log(1);").unwrap();
812        fs::write(
813            site.join("index.html"),
814            r#"<html><head><script src="app.js"></script></head></html>"#,
815        )
816        .unwrap();
817
818        let ctx = PluginContext::new(dir.path(), dir.path(), &site, dir.path());
819        FingerprintPlugin.after_compile(&ctx).unwrap();
820
821        let html = fs::read_to_string(site.join("index.html")).unwrap();
822        assert!(
823            html.contains(
824                "integrity=\"sha384-JawyHuhqEMFMvdtX+VHylbI0hfJp2F7nvwFVRqqfuOoK5oW7TG/7V11Zs7zeFWIE\""
825            ),
826            "expected exact SHA-384 SRI vector; got: {html}"
827        );
828    }
829
830    #[test]
831    #[serial_test::parallel(assets_failpoint)]
832    fn sri_algorithm_config_override_emits_sha256() {
833        // `[security] sri_algorithm = "sha256"` back-compat knob
834        // (v0.0.47 plan §3 item 2.3): the exact SHA-256 vector for
835        // "console.log(1);" must be emitted instead of SHA-384.
836        use crate::cmd::{SecurityConfig, SsgConfig};
837
838        let dir = tempdir().unwrap();
839        let site = dir.path().join("site");
840        fs::create_dir_all(&site).unwrap();
841        fs::write(site.join("app.js"), "console.log(1);").unwrap();
842        fs::write(
843            site.join("index.html"),
844            r#"<html><head><script src="app.js"></script></head></html>"#,
845        )
846        .unwrap();
847
848        let config = SsgConfig::builder()
849            .security(SecurityConfig {
850                sri_algorithm: SriAlgorithm::Sha256,
851            })
852            .build()
853            .unwrap();
854        let ctx = PluginContext::with_config(
855            dir.path(),
856            dir.path(),
857            &site,
858            dir.path(),
859            config,
860        );
861        FingerprintPlugin.after_compile(&ctx).unwrap();
862
863        let html = fs::read_to_string(site.join("index.html")).unwrap();
864        assert!(
865            html.contains(
866                "integrity=\"sha256-NcFG924SlHfGQGG8hFEeEJDz1NgFlxPmZj3Us1sfdkI=\""
867            ),
868            "expected exact SHA-256 SRI vector; got: {html}"
869        );
870        assert!(!html.contains("sha384-"), "override must win: {html}");
871    }
872
873    #[test]
874    fn name_returns_static_fingerprint_identifier() {
875        assert_eq!(FingerprintPlugin.name(), "fingerprint");
876    }
877
878    #[test]
879    fn after_compile_missing_site_dir_returns_ok() {
880        // Line 34: `!ctx.site_dir.exists()` early return.
881        let dir = tempdir().unwrap();
882        let missing = dir.path().join("missing");
883        let ctx =
884            PluginContext::new(dir.path(), dir.path(), &missing, dir.path());
885        FingerprintPlugin.after_compile(&ctx).unwrap();
886        assert!(!missing.exists());
887    }
888
889    #[test]
890    fn after_compile_no_assets_short_circuits() {
891        // Line 40: `assets.is_empty()` early return — site with
892        // HTML but no CSS/JS.
893        let dir = tempdir().unwrap();
894        let site = dir.path().join("site");
895        fs::create_dir_all(&site).unwrap();
896        fs::write(site.join("index.html"), "<p></p>").unwrap();
897
898        let ctx = PluginContext::new(dir.path(), dir.path(), &site, dir.path());
899        FingerprintPlugin.after_compile(&ctx).unwrap();
900        // HTML untouched.
901        assert_eq!(
902            fs::read_to_string(site.join("index.html")).unwrap(),
903            "<p></p>"
904        );
905    }
906
907    #[test]
908    #[serial_test::parallel(assets_failpoint)]
909    fn after_compile_fingerprint_absolute_path_href() {
910        // Covers the `old_ref_slash` variant (with leading /) in
911        // rewrite_asset_refs — absolute-path stylesheet links.
912        let dir = tempdir().unwrap();
913        let site = dir.path().join("site");
914        fs::create_dir_all(&site).unwrap();
915        fs::write(site.join("app.js"), "console.log(1);").unwrap();
916        fs::write(
917            site.join("index.html"),
918            r#"<html><head><script src="/app.js"></script></head></html>"#,
919        )
920        .unwrap();
921
922        let ctx = PluginContext::new(dir.path(), dir.path(), &site, dir.path());
923        FingerprintPlugin.after_compile(&ctx).unwrap();
924        let html = fs::read_to_string(site.join("index.html")).unwrap();
925        // Default algorithm is SHA-384 (v0.0.47 plan §3 item 2.3).
926        assert!(html.contains("integrity=\"sha384-"));
927    }
928
929    #[test]
930    fn collect_assets_picks_up_fingerprintable_extensions() {
931        // Issue #468 widened the fingerprinted set from {css, js}
932        // to also include images and fonts. css/js/png/woff2 are in;
933        // html/txt/md are out.
934        let dir = tempdir().unwrap();
935        fs::write(dir.path().join("a.css"), "").unwrap();
936        fs::write(dir.path().join("b.js"), "").unwrap();
937        fs::write(dir.path().join("c.html"), "").unwrap();
938        fs::write(dir.path().join("d.png"), "").unwrap();
939        fs::write(dir.path().join("e.woff2"), "").unwrap();
940        fs::write(dir.path().join("f.txt"), "").unwrap();
941        let files = collect_assets(dir.path()).unwrap();
942        // 4 fingerprintable: css, js, png, woff2.
943        assert_eq!(files.len(), 4);
944    }
945
946    #[test]
947    fn collect_assets_recurses_into_subdirectories() {
948        let dir = tempdir().unwrap();
949        let nested = dir.path().join("vendor");
950        fs::create_dir(&nested).unwrap();
951        fs::write(dir.path().join("top.css"), "").unwrap();
952        fs::write(nested.join("lib.js"), "").unwrap();
953        let files = collect_assets(dir.path()).unwrap();
954        assert_eq!(files.len(), 2);
955    }
956
957    #[test]
958    fn collect_html_files_filters_non_html() {
959        let dir = tempdir().unwrap();
960        fs::write(dir.path().join("a.html"), "").unwrap();
961        fs::write(dir.path().join("b.css"), "").unwrap();
962        let files = collect_html_files(dir.path()).unwrap();
963        assert_eq!(files.len(), 1);
964    }
965
966    #[test]
967    fn sha256_hex_produces_64_hex_chars() {
968        assert_eq!(sha256_hex(b"abc").len(), 64);
969        assert_eq!(sha256_hex(b"").len(), 64);
970    }
971
972    #[test]
973    fn sri_integrity_is_nonempty_for_input() {
974        assert!(
975            !SriAlgorithm::default().integrity(b"hello").is_empty(),
976            "should not be empty"
977        );
978    }
979
980    #[test]
981    fn sri_integrity_payload_lengths_per_algorithm() {
982        // "sha384-" (7) + SHA-384 → 48 raw bytes → base64 = 64 chars.
983        assert_eq!(SriAlgorithm::Sha384.integrity(b"hello").len(), 7 + 64);
984        // "sha256-" (7) + SHA-256 → 32 raw bytes → base64 = 44 chars.
985        assert_eq!(SriAlgorithm::Sha256.integrity(b"hello").len(), 7 + 44);
986        // "sha512-" (7) + SHA-512 → 64 raw bytes → base64 = 88 chars.
987        assert_eq!(SriAlgorithm::Sha512.integrity(b"hello").len(), 7 + 88);
988    }
989
990    #[test]
991    fn rewrite_asset_refs_adds_sri_only_to_link_and_script() {
992        let mut manifest = HashMap::new();
993        for (name, fp) in [
994            ("style.css", "style.abc12345.css"),
995            ("app.js", "app.0badf00d.js"),
996            ("logo.png", "logo.c0ffee00.png"),
997        ] {
998            let _ = manifest.insert(
999                name.to_string(),
1000                AssetInfo {
1001                    fingerprinted: fp.to_string(),
1002                    sri: format!("sha384-{name}"),
1003                },
1004            );
1005        }
1006        let html = concat!(
1007            r#"<link rel="stylesheet" href="/style.css">"#,
1008            r#"<script src="/app.js" defer></script>"#,
1009            r#"<meta property="og:image" content="/logo.png">"#,
1010            r#"<img src="/logo.png" alt="">"#,
1011            r#"<script type="application/ld+json">{"image":"/logo.png","url":"/app.js"}</script>"#,
1012        );
1013        let out = rewrite_asset_refs(html, &manifest).unwrap();
1014        assert!(out.contains(
1015            r#"<link rel="stylesheet" href="/style.abc12345.css" integrity="sha384-style.css" crossorigin="anonymous">"#
1016        ), "{out}");
1017        assert!(out.contains(
1018            r#"<script src="/app.0badf00d.js" defer integrity="sha384-app.js" crossorigin="anonymous"></script>"#
1019        ), "{out}");
1020        assert!(
1021            out.contains(
1022                r#"<meta property="og:image" content="/logo.c0ffee00.png">"#
1023            ),
1024            "{out}"
1025        );
1026        assert!(
1027            out.contains(r#"<img src="/logo.c0ffee00.png" alt="">"#),
1028            "{out}"
1029        );
1030        assert_eq!(out.matches("integrity=").count(), 2, "{out}");
1031        let ld = out
1032            .split(r#"<script type="application/ld+json">"#)
1033            .nth(1)
1034            .and_then(|s| s.split("</script>").next())
1035            .expect("ld+json block");
1036        let value: serde_json::Value =
1037            serde_json::from_str(ld).expect("JSON-LD still parses");
1038        assert_eq!(value["image"], "/logo.c0ffee00.png");
1039        assert_eq!(value["url"], "/app.0badf00d.js");
1040    }
1041
1042    #[test]
1043    fn test_rewrite_asset_refs() {
1044        let mut manifest = HashMap::new();
1045        let _ = manifest.insert(
1046            "style.css".to_string(),
1047            AssetInfo {
1048                fingerprinted: "style.abc12345.css".to_string(),
1049                sri: "sha384-xyz".to_string(),
1050            },
1051        );
1052
1053        let html = r#"<link rel="stylesheet" href="style.css">"#;
1054        let result = rewrite_asset_refs(html, &manifest).unwrap();
1055        assert!(result.contains("style.abc12345.css"));
1056        assert!(result.contains("integrity=\"sha384-xyz\""));
1057    }
1058
1059    // ── CSS url() rewriting (resolves audit item #2) ───────────────
1060
1061    fn css_manifest() -> HashMap<String, AssetInfo> {
1062        let mut m = HashMap::new();
1063        let _ = m.insert(
1064            "images/logo.png".to_string(),
1065            AssetInfo {
1066                fingerprinted: "images/logo.deadbeef.png".to_string(),
1067                sri: String::new(),
1068            },
1069        );
1070        let _ = m.insert(
1071            "fonts/sans.woff2".to_string(),
1072            AssetInfo {
1073                fingerprinted: "fonts/sans.cafef00d.woff2".to_string(),
1074                sri: String::new(),
1075            },
1076        );
1077        m
1078    }
1079
1080    #[test]
1081    fn rewrite_css_urls_handles_absolute_path() {
1082        let dir = tempdir().unwrap();
1083        let css_path = dir.path().join("assets/style.css");
1084        let css = "body { background: url(/images/logo.png); }";
1085        let out = rewrite_css_urls(css, &css_path, dir.path(), &css_manifest());
1086        assert!(out.contains("url(/images/logo.deadbeef.png)"));
1087        assert!(!out.contains("logo.png)"));
1088    }
1089
1090    #[test]
1091    fn rewrite_css_urls_handles_relative_path() {
1092        let dir = tempdir().unwrap();
1093        let css_path = dir.path().join("assets/style.css");
1094        let css = "body { background: url(../images/logo.png); }";
1095        let out = rewrite_css_urls(css, &css_path, dir.path(), &css_manifest());
1096        assert!(out.contains("url(/images/logo.deadbeef.png)"));
1097    }
1098
1099    #[test]
1100    fn rewrite_css_urls_handles_double_quotes() {
1101        let dir = tempdir().unwrap();
1102        let css_path = dir.path().join("style.css");
1103        let css = r#"body { background: url("/images/logo.png"); }"#;
1104        let out = rewrite_css_urls(css, &css_path, dir.path(), &css_manifest());
1105        assert!(out.contains(r#"url("/images/logo.deadbeef.png")"#));
1106    }
1107
1108    #[test]
1109    fn rewrite_css_urls_handles_single_quotes() {
1110        let dir = tempdir().unwrap();
1111        let css_path = dir.path().join("style.css");
1112        let css = "body { background: url('/images/logo.png'); }";
1113        let out = rewrite_css_urls(css, &css_path, dir.path(), &css_manifest());
1114        assert!(out.contains("url('/images/logo.deadbeef.png')"));
1115    }
1116
1117    #[test]
1118    fn rewrite_css_urls_preserves_query_and_fragment() {
1119        let dir = tempdir().unwrap();
1120        let css_path = dir.path().join("style.css");
1121        let css = "@font-face { src: url(/fonts/sans.woff2?v=1#hint); }";
1122        let out = rewrite_css_urls(css, &css_path, dir.path(), &css_manifest());
1123        assert!(out.contains("/fonts/sans.cafef00d.woff2?v=1#hint"));
1124    }
1125
1126    #[test]
1127    fn rewrite_css_urls_skips_external_and_data_urls() {
1128        let dir = tempdir().unwrap();
1129        let css_path = dir.path().join("style.css");
1130        let css = r#"
1131            a { background: url(https://cdn.example.com/x.png); }
1132            b { background: url(//cdn.example.com/y.png); }
1133            c { background: url(data:image/svg+xml,<svg/>); }
1134        "#;
1135        let out = rewrite_css_urls(css, &css_path, dir.path(), &css_manifest());
1136        // All three URLs are left untouched.
1137        assert!(out.contains("https://cdn.example.com/x.png"));
1138        assert!(out.contains("//cdn.example.com/y.png"));
1139        assert!(out.contains("data:image/svg+xml"));
1140    }
1141
1142    #[test]
1143    fn rewrite_css_urls_no_change_when_url_not_in_manifest() {
1144        let dir = tempdir().unwrap();
1145        let css_path = dir.path().join("style.css");
1146        let css = "body { background: url(/images/missing.png); }";
1147        let out = rewrite_css_urls(css, &css_path, dir.path(), &css_manifest());
1148        assert_eq!(out, css);
1149    }
1150
1151    #[test]
1152    fn rewrite_css_urls_unterminated_url_does_not_panic() {
1153        let dir = tempdir().unwrap();
1154        let css_path = dir.path().join("style.css");
1155        let css = "body { background: url(/images/logo.png";
1156        let out = rewrite_css_urls(css, &css_path, dir.path(), &css_manifest());
1157        assert!(!out.is_empty(), "should not be empty");
1158    }
1159
1160    #[test]
1161    #[serial_test::parallel(assets_failpoint)]
1162    fn after_compile_rewrites_css_url_to_fingerprinted_image() {
1163        // End-to-end: drop a CSS file referencing a PNG, run the
1164        // plugin, and confirm the produced CSS points at the
1165        // fingerprinted PNG name.
1166        let dir = tempdir().unwrap();
1167        let site = dir.path().join("site");
1168        fs::create_dir_all(site.join("images")).unwrap();
1169        // 1×1 transparent PNG (the smallest valid PNG bytes).
1170        let png_bytes: &[u8] = &[
1171            0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00, 0x00,
1172            0x0D, 0x49, 0x48, 0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00,
1173            0x00, 0x01, 0x08, 0x06, 0x00, 0x00, 0x00, 0x1F, 0x15, 0xC4, 0x89,
1174            0x00, 0x00, 0x00, 0x0D, 0x49, 0x44, 0x41, 0x54, 0x78, 0x9C, 0x63,
1175            0x00, 0x01, 0x00, 0x00, 0x05, 0x00, 0x01, 0x0D, 0x0A, 0x2D, 0xB4,
1176            0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4E, 0x44, 0xAE, 0x42, 0x60,
1177            0x82,
1178        ];
1179        fs::write(site.join("images/logo.png"), png_bytes).unwrap();
1180        fs::write(
1181            site.join("style.css"),
1182            "body { background: url(/images/logo.png); }",
1183        )
1184        .unwrap();
1185        fs::write(
1186            site.join("index.html"),
1187            r#"<html><head><link rel="stylesheet" href="style.css"></head><body></body></html>"#,
1188        )
1189        .unwrap();
1190
1191        let ctx = PluginContext::new(dir.path(), dir.path(), &site, dir.path());
1192        FingerprintPlugin.after_compile(&ctx).unwrap();
1193
1194        // Find the renamed CSS and verify its url() points at the
1195        // renamed PNG (not the original `logo.png` filename).
1196        let mut css_text = None;
1197        for entry in fs::read_dir(&site).unwrap().flatten() {
1198            let p = entry.path();
1199            if p.extension().is_some_and(|e| e == "css") {
1200                css_text = Some(fs::read_to_string(&p).unwrap());
1201            }
1202        }
1203        let css_text = css_text.expect("renamed CSS file present");
1204        assert!(
1205            css_text.contains("/images/logo."),
1206            "rewritten CSS should reference renamed PNG: {css_text}"
1207        );
1208        assert!(css_text.contains(".png"), "still ends in .png: {css_text}");
1209        // Crucial: the URL is no longer the original `/images/logo.png`
1210        // — it's `/images/logo.<hash>.png`.
1211        assert!(
1212            !css_text.contains("/images/logo.png)"),
1213            "must no longer point at the un-fingerprinted PNG: {css_text}"
1214        );
1215    }
1216
1217    #[test]
1218    fn test_fingerprint_file_missing_returns_io_error() {
1219        let dir = tempdir().unwrap();
1220        let missing = dir.path().join("missing.css");
1221        let res =
1222            fingerprint_file(&missing, dir.path(), SriAlgorithm::default());
1223        assert!(res.is_err());
1224        let err = res.unwrap_err();
1225        // Branch-free variant + path check (`matches!`/`if let` would
1226        // leave never-taken arms as uncovered regions).
1227        let debug = format!("{err:?}");
1228        assert!(debug.contains("Io"));
1229        assert!(debug.contains("missing.css"));
1230    }
1231
1232    #[test]
1233    fn test_rewrite_css_urls_inplace_missing_returns_io_error() {
1234        let dir = tempdir().unwrap();
1235        let missing = dir.path().join("missing.css");
1236        let manifest = HashMap::new();
1237        let res = rewrite_css_urls_inplace(&missing, dir.path(), &manifest);
1238        assert!(res.is_err());
1239        let err = res.unwrap_err();
1240        // Branch-free variant + path check (see note above).
1241        let debug = format!("{err:?}");
1242        assert!(debug.contains("Io"));
1243        assert!(debug.contains("missing.css"));
1244    }
1245
1246    // -------------------------------------------------------------------
1247    // Minifier edge branches
1248    // -------------------------------------------------------------------
1249
1250    /// Everything this minifier emits must still be valid CSS.
1251    ///
1252    /// This is the gate that was missing when `clamp(2.07rem, 1.75rem + 1.6vw,
1253    /// 3.13rem)` became `clamp(...,1.75rem+1.6vw,...)`: valid-looking output,
1254    /// rejected by every browser, and every heading in nine themes silently
1255    /// fell back to the body size.
1256    ///
1257    /// The oracle used to be `lightningcss`. It is gone: even as a
1258    /// dev-dependency it pulled `parcel_sourcemap` and `rkyv 0.7.46`, which
1259    /// carries a published advisory, into the graph. A CSS parser is a large
1260    /// thing to trust for one assertion, so the rules that the failure
1261    /// actually broke are checked directly instead.
1262    #[test]
1263    fn minified_css_stays_valid() {
1264        // Constructs chosen because each has a whitespace or delimiter rule
1265        // that a naive collapser gets wrong.
1266        let corpus = [
1267            "h1 { font-size: clamp(2.07rem, 1.75rem + 1.6vw, 3.13rem); }",
1268            "a { width: calc(100% - 2rem); height: calc(10px*2); }",
1269            "p { margin: 0 -1px 0 -1px; }",
1270            "@media (min-width: 48rem) and (max-width: 64rem) { .a { color: red } }",
1271            ".g { grid-template-columns: repeat(auto-fill, minmax(12rem, 1fr)); }",
1272            ".s { background: url(\"a b.png\"); content: \"a { b } c\"; }",
1273            ":root { --x: 1px; --y: calc(var(--x) + 2px); }",
1274            "@supports (display: grid) { .h { display: grid } }",
1275            ".t { transition: color .2s ease-in-out, background .3s; }",
1276            "@font-face { font-family: \"X\"; src: url(x.woff2) format(\"woff2\"); }",
1277            ".n:not(.a, .b) > .c ~ .d + .e { color: #fff }",
1278            ".u { inset: 0 auto auto 0; aspect-ratio: 16 / 9; }",
1279        ];
1280
1281        for css in corpus {
1282            let out = minify_css(css);
1283            assert_balanced(&out, css);
1284            assert_math_operators_keep_their_spaces(&out, css);
1285        }
1286    }
1287
1288    /// Braces, parens, brackets and quotes must still pair up.
1289    fn assert_balanced(out: &str, src: &str) {
1290        let (mut braces, mut parens, mut brackets) = (0i32, 0i32, 0i32);
1291        let mut quote: Option<char> = None;
1292        let mut prev = '\0';
1293        for c in out.chars() {
1294            if let Some(q) = quote {
1295                if c == q && prev != '\\' {
1296                    quote = None;
1297                }
1298            } else {
1299                match c {
1300                    '"' | '\'' => quote = Some(c),
1301                    '{' => braces += 1,
1302                    '}' => braces -= 1,
1303                    '(' => parens += 1,
1304                    ')' => parens -= 1,
1305                    '[' => brackets += 1,
1306                    ']' => brackets -= 1,
1307                    _ => {}
1308                }
1309                assert!(
1310                    braces >= 0 && parens >= 0 && brackets >= 0,
1311                    "unbalanced delimiter in\n  in:  {src}\n  out: {out}"
1312                );
1313            }
1314            prev = c;
1315        }
1316        assert!(
1317            braces == 0 && parens == 0 && brackets == 0 && quote.is_none(),
1318            "unclosed delimiter in\n  in:  {src}\n  out: {out}"
1319        );
1320    }
1321
1322    /// Inside a math function, `+` and `-` are operators only when surrounded
1323    /// by whitespace. `calc(1.75rem+1.6vw)` is not a tighter spelling of
1324    /// `calc(1.75rem + 1.6vw)` - it is a parse error, and this is exactly how
1325    /// nine themes lost every heading size.
1326    fn assert_math_operators_keep_their_spaces(out: &str, src: &str) {
1327        for func in ["calc(", "clamp(", "min(", "max("] {
1328            let mut from = 0;
1329            while let Some(at) = out[from..].find(func) {
1330                let open = from + at + func.len() - 1;
1331                let mut depth = 0i32;
1332                let mut close = open;
1333                for (i, c) in out[open..].char_indices() {
1334                    match c {
1335                        '(' => depth += 1,
1336                        ')' => {
1337                            depth -= 1;
1338                            if depth == 0 {
1339                                close = open + i;
1340                                break;
1341                            }
1342                        }
1343                        _ => {}
1344                    }
1345                }
1346                let body: Vec<char> = out[open + 1..close].chars().collect();
1347                for (i, &c) in body.iter().enumerate() {
1348                    if c != '+' && c != '-' {
1349                        continue;
1350                    }
1351                    // A leading sign, or one right after `(` or `,`, is part
1352                    // of the number rather than an operator.
1353                    let prev =
1354                        body[..i].iter().rev().find(|c| !c.is_whitespace());
1355                    if !matches!(prev, Some(p) if p.is_alphanumeric() || *p == '%' || *p == ')')
1356                    {
1357                        continue;
1358                    }
1359                    assert!(
1360                        body.get(i.wrapping_sub(1)).is_some_and(|c| c.is_whitespace())
1361                            && body.get(i + 1).is_some_and(|c| c.is_whitespace()),
1362                        "`{c}` lost the whitespace that makes it an operator in {func}…)\n  in:  {src}\n  out: {out}"
1363                    );
1364                }
1365                from = close.max(from + at + 1);
1366            }
1367        }
1368    }
1369
1370    /// Probe: which selector shapes survive minification intact?
1371    ///
1372    /// The space between two compound selectors is a descendant
1373    /// combinator. Dropping it does not tighten the selector, it changes
1374    /// what it matches — usually to nothing. This walks the shapes a
1375    /// stylesheet actually contains and asserts each one survives.
1376    /// At-rule preludes are not selectors but live in the same
1377    /// out-of-block context, and their spaces are equally load-bearing:
1378    /// `@media (a) and (b)` collapsed to `@media(a)and(b)` is invalid
1379    /// and silently disables the whole query.
1380    #[test]
1381    fn minify_css_preserves_at_rule_preludes() {
1382        let cases = [
1383            "@media (min-width: 40rem)",
1384            "@media screen and (min-width: 40rem)",
1385            "@media (prefers-color-scheme: dark)",
1386            "@media (min-width: 40rem) and (max-width: 60rem)",
1387            "@supports (display: grid)",
1388            "@media (color-gamut: p3)",
1389            "@media not all and (monochrome)",
1390        ];
1391        let mut broken = Vec::new();
1392        for prelude in cases {
1393            let out = minify_css(&format!("{prelude} {{ .a {{color:red}} }}"));
1394            if !out.starts_with(prelude) {
1395                broken.push(format!("`{prelude}` -> `{out}`"));
1396            }
1397        }
1398        assert!(
1399            broken.is_empty(),
1400            "at-rule preludes mangled:\n  {}",
1401            broken.join("\n  ")
1402        );
1403    }
1404
1405    /// The whole point is that nothing anyone actually wrote is
1406    /// changed. Every selector in every published theme is round-tripped
1407    /// through the minifier and must come back meaning the same thing.
1408    #[test]
1409    fn minify_css_is_faithful_on_selectors_from_the_published_themes() {
1410        // Shapes taken from the nine themes, including the one that
1411        // cost three of them their search-widget override.
1412        let corpus = [
1413            "#ssg-search-btn, #ssg-search-btn *",
1414            ".prose a[href^=\"http\"]::after",
1415            ":root:not([data-theme=\"light\"])",
1416            ".card:hover .card-title",
1417            "nav[aria-label] ul li a",
1418            ".tmux-pane .pane-content .tree-node",
1419            "html[data-theme=\"dark\"] .btn-primary:focus-visible",
1420            ".a > .b ~ .c + .d",
1421            "li:nth-child(2n + 1) > span",
1422        ];
1423        let mut broken = Vec::new();
1424        for sel in corpus {
1425            let out = minify_css(&format!("{sel} {{color:red}}"));
1426            let got = out.trim_end_matches("{color:red}");
1427            let want: String = sel
1428                .replace(" > ", ">")
1429                .replace(" ~ ", "~")
1430                .replace(" + ", "+")
1431                .replace(", ", ",");
1432            if got != want {
1433                broken.push(format!(
1434                    "`{sel}`\n      -> `{got}`\n      want `{want}`"
1435                ));
1436            }
1437        }
1438        assert!(
1439            broken.is_empty(),
1440            "theme selectors changed:\n  {}",
1441            broken.join("\n  ")
1442        );
1443    }
1444
1445    #[test]
1446    fn minify_css_preserves_every_descendant_combinator() {
1447        let cases = [
1448            ("#a *", "universal descendant"),
1449            ("[data-x] [data-y]", "attribute then attribute"),
1450            (":not(.a) .b", "functional pseudo then class"),
1451            (".a :hover", "descendant pseudo-class"),
1452            (".a ::before", "descendant pseudo-element"),
1453            ("a[href] span", "attribute then element"),
1454            ("li:nth-child(2) a", "functional pseudo then element"),
1455            ("* html .a", "universal first"),
1456            (".a .b", "class then class"),
1457            (".a *:focus", "universal with pseudo"),
1458        ];
1459        let mut broken = Vec::new();
1460        for (sel, label) in cases {
1461            let out = minify_css(&format!("{sel} {{color:red}}"));
1462            let got = out.trim_end_matches("{color:red}");
1463            if got != sel {
1464                broken.push(format!("{label}: `{sel}` -> `{got}`"));
1465            }
1466        }
1467        assert!(
1468            broken.is_empty(),
1469            "these selectors did not survive minification:\n  {}",
1470            broken.join("\n  ")
1471        );
1472    }
1473
1474    /// A descendant combinator before the universal selector is a
1475    /// space that carries meaning. Dropping it turns `#btn *` into
1476    /// `#btn*`, which parses as garbage and matches nothing — the rule
1477    /// is not tightened, it is discarded.
1478    ///
1479    /// Three published themes override the search widget with exactly
1480    /// this shape, and all three overrides were being thrown away: the
1481    /// widget kept the generator's own text colour and failed AAA
1482    /// contrast against the theme's own surface. Nothing errored; the
1483    /// rule simply stopped existing.
1484    #[test]
1485    fn minify_css_keeps_the_space_before_a_universal_selector() {
1486        let out = minify_css("#btn, #btn * { color: red !important; }");
1487        assert!(
1488            out.contains("#btn *"),
1489            "the descendant combinator must survive: {out}"
1490        );
1491        assert!(
1492            !out.contains("#btn*"),
1493            "must not produce the selector-eating form: {out}"
1494        );
1495    }
1496
1497    /// The same character inside `calc()` needs no space, but keeping
1498    /// one is valid and costs two bytes — far cheaper than the class of
1499    /// bug above.
1500    #[test]
1501    fn minify_css_leaves_calc_with_a_star_valid() {
1502        let out = minify_css(".a { width: calc(2px * 3); }");
1503        assert!(
1504            out.contains("calc(2px * 3)") || out.contains("calc(2px*3)"),
1505            "calc must stay valid either way: {out}"
1506        );
1507    }
1508
1509    /// Minifying twice must equal minifying once.
1510    ///
1511    /// A non-idempotent pass is a latent corruption: assets are re-minified
1512    /// on rebuilds, and each pass would degrade the file a little further.
1513    #[test]
1514    fn minify_css_is_idempotent() {
1515        let corpus = [
1516            "h1 { font-size: clamp(2.07rem, 1.75rem + 1.6vw, 3.13rem); }",
1517            "a { width: calc(100% - 2rem); }",
1518            "p { margin: 0 -1px; }",
1519            "/* c */ .x { color: red } /* d */",
1520            ".s { content: \"a { b } c\"; }",
1521        ];
1522        for css in corpus {
1523            let once = minify_css(css);
1524            let twice = minify_css(&once);
1525            assert_eq!(once, twice, "not idempotent for: {css}");
1526        }
1527    }
1528
1529    /// Whitespace inside a string or template literal is content.
1530    #[test]
1531    fn minify_js_preserves_whitespace_inside_string_literals() {
1532        let js = "var m = \"0px 0px -10% 0px\";\nvar c = 'x - y';\nvar e = 'a \\' b';\nvar t = `  a\n  b`;";
1533        let out = minify_js(js);
1534        assert!(out.contains("\"0px 0px -10% 0px\""), "{out}");
1535        assert!(out.contains("'x - y'"), "{out}");
1536        assert!(out.contains("'a \\' b'"), "{out}");
1537        assert!(out.contains("`  a\n  b`"), "{out}");
1538        assert_eq!(minify_js(&out), out, "idempotent");
1539    }
1540
1541    /// The same for JavaScript: two passes must agree.
1542    #[test]
1543    fn minify_js_is_idempotent() {
1544        let corpus = [
1545            "const a = 1; // trailing\nconst b = a / 2;",
1546            "let s = \"a // not a comment\";",
1547            "function f() { return 1 /* mid */ + 2; }",
1548            "const re = /ab+c/g;",
1549        ];
1550        for js in corpus {
1551            let once = minify_js(js);
1552            let twice = minify_js(&once);
1553            assert_eq!(once, twice, "not idempotent for: {js}");
1554        }
1555    }
1556
1557    /// Minification must never invent or destroy a string literal's contents.
1558    #[test]
1559    fn minify_css_preserves_string_literals_verbatim() {
1560        let out = minify_css(".a::after { content: \"  two  spaces  \"; }");
1561        assert!(
1562            out.contains("\"  two  spaces  \""),
1563            "string literal was rewritten: {out}"
1564        );
1565    }
1566
1567    #[test]
1568    fn minify_css_keeps_whitespace_around_plus_in_math() {
1569        // CSS math requires whitespace around `+`. Collapsing it does not
1570        // shorten the declaration, it voids it: browsers drop the whole
1571        // value, so every heading fell back to the inherited size.
1572        let css = "h1 { font-size: clamp(2.07rem, 1.75rem + 1.6vw, 3.13rem); }";
1573        let out = minify_css(css);
1574        assert!(
1575            out.contains("1.75rem + 1.6vw"),
1576            "whitespace around `+` was dropped: {out}"
1577        );
1578    }
1579
1580    #[test]
1581    fn minify_css_keeps_math_whitespace_after_a_run_of_spaces() {
1582        // Aligned columns put two spaces before the operator. Judged space
1583        // by space, neither saw a word on both sides and the one before `+`
1584        // was dropped: `1.125rem+ 0.5556vw`, which voids the clamp().
1585        let css = "h1 { font-size: clamp(1.25rem,   1.125rem  + 0.5556vw, 1.625rem); }";
1586        let out = minify_css(css);
1587        assert!(out.contains("1.125rem + 0.5556vw"), "got: {out}");
1588        assert!(!out.contains("rem+"), "space before `+` dropped: {out}");
1589        let css = "a { width: calc(100%   -   2rem); }";
1590        assert!(minify_css(css).contains("100% - 2rem"));
1591    }
1592
1593    #[test]
1594    fn minify_css_keeps_list_separator_after_a_run_of_spaces() {
1595        let out = minify_css("p { margin: 0   -1px; }");
1596        assert!(out.contains("0 -1px"), "got: {out}");
1597        let out = minify_css(
1598            "@media (min-width: 1px)   and   (max-width: 2px) { a { b: c } }",
1599        );
1600        assert!(out.contains(") and ("), "got: {out}");
1601    }
1602
1603    #[test]
1604    fn minify_css_keeps_whitespace_around_minus_in_math() {
1605        let css = "a { width: calc(100% - 2rem); }";
1606        let out = minify_css(css);
1607        assert!(out.contains("100% - 2rem"), "got: {out}");
1608    }
1609
1610    mod math_whitespace_properties {
1611        use super::minify_css;
1612        use proptest::prelude::*;
1613
1614        proptest! {
1615            #![proptest_config(ProptestConfig::with_cases(1000))]
1616
1617            /// Whatever run of spaces surrounds a math operator, the
1618            /// minified clamp() keeps exactly one space either side of it,
1619            /// and minifying the output again changes nothing.
1620            #[test]
1621            fn clamp_operator_spacing_survives_whitespace_runs(
1622                a in 0u16..100, b in 0u16..100, c in 0u16..100, d in 0u16..100,
1623                op in prop::sample::select(vec!['+', '-']),
1624                w1 in " {1,4}", w2 in " {1,4}", w3 in " {1,4}",
1625            ) {
1626                let css = format!(
1627                    "h1 {{ font-size: clamp({a}rem,{w1}{b}rem{w2}{op}{w3}{c}vw, {d}rem); }}"
1628                );
1629                let out = minify_css(&css);
1630                let expected = format!("{b}rem {op} {c}vw");
1631                prop_assert!(out.contains(&expected), "got: {}", out);
1632                prop_assert!(out.contains(&format!("rem {op} ")), "got: {}", out);
1633                prop_assert_eq!(minify_css(&out), out);
1634            }
1635        }
1636    }
1637
1638    #[test]
1639    fn minify_css_still_collapses_ordinary_whitespace() {
1640        let out = minify_css("body   {   color :  red ;  }");
1641        assert!(!out.contains("  "), "double space survived: {out}");
1642        assert!(out.contains("red"), "got: {out}");
1643    }
1644
1645    #[test]
1646    fn minify_css_keeps_space_before_negative_value_in_a_list() {
1647        // `margin: 0 -1px` must not become `0-1px`.
1648        let out = minify_css("p { margin: 0 -1px; }");
1649        assert!(out.contains("0 -1px"), "got: {out}");
1650    }
1651
1652    #[test]
1653    fn minify_css_handles_escaped_quote_inside_string() {
1654        // The escaped quote must not close the string; the final real
1655        // quote does (even backslash count).
1656        let input = "a{content:\"x\\\"y\";}";
1657        let out = minify_css(input);
1658        assert!(out.contains("\"x\\\"y\""));
1659    }
1660
1661    #[test]
1662    fn minify_js_handles_escaped_quote_inside_string() {
1663        let input = "const s = \"a\\\"b\";";
1664        let out = minify_js(input);
1665        assert!(out.contains("\"a\\\"b\""));
1666    }
1667
1668    #[test]
1669    fn minify_js_preserves_division_operator() {
1670        // `/` not followed by `*` or `/` is plain division.
1671        assert_eq!(minify_js("const x = a / b;"), "const x=a/b;");
1672    }
1673
1674    #[test]
1675    fn minify_js_leading_comment_produces_leading_newline_branch() {
1676        // A file that opens with a line comment pushes '\n' into an
1677        // empty result, so the clean pass sees whitespace at i == 0
1678        // (prev == None).
1679        assert_eq!(minify_js("// c\nvar x = 1;"), "var x=1;");
1680    }
1681
1682    #[test]
1683    fn minify_css_handles_leading_and_trailing_whitespace() {
1684        // Exercises the clean-up pass's `_ => false` catch-all at both
1685        // ends: i == 0 (prev == None) and i == last (next == None).
1686        assert_eq!(minify_css(" body { color: red; } "), "body{color:red;}");
1687    }
1688
1689    #[test]
1690    fn minify_js_trailing_space_after_word_char_is_dropped() {
1691        // `prev` is a word char but `next == None` (end of input) —
1692        // the specific `_ => false` catch-all arm, distinct from the
1693        // ordinary "not both word chars" (Some, Some) case.
1694        assert_eq!(minify_js(" var x = 1 "), "var x=1");
1695    }
1696
1697    // -------------------------------------------------------------------
1698    // resolve_css_url edge branches
1699    // -------------------------------------------------------------------
1700
1701    #[test]
1702    fn resolve_css_url_relative_css_dir_hits_curdir_and_escape() {
1703        // A relative css_dir keeps the leading `./` CurDir component,
1704        // and the resolved path cannot start with the absolute
1705        // site_dir prefix, so the URL is rejected.
1706        let site = Path::new("/abs/site");
1707        let out = resolve_css_url("img.png", Path::new("./css"), site);
1708        assert!(out.is_none());
1709    }
1710
1711    #[test]
1712    fn resolve_css_url_keeps_dotted_names_and_inside_backtracks() {
1713        let site = Path::new("/abs/site");
1714        let css_dir = site.join("css");
1715        assert_eq!(
1716            resolve_css_url("../img/a..b.png", &css_dir, site).as_deref(),
1717            Some("img/a..b.png")
1718        );
1719        assert_eq!(
1720            resolve_css_url("/img/./x/../logo.svg", &css_dir, site).as_deref(),
1721            Some("img/logo.svg")
1722        );
1723    }
1724
1725    #[test]
1726    fn resolve_css_url_refuses_climbing_out_even_to_come_back() {
1727        // `css/../../site/x.png` leaves the site directory before
1728        // re-entering it by name; that is still an escape.
1729        let site = Path::new("/abs/site");
1730        let css_dir = site.join("css");
1731        for url in ["../../x.png", "../../site/x.png", "/../site/x.png"] {
1732            assert_eq!(resolve_css_url(url, &css_dir, site), None, "{url}");
1733        }
1734    }
1735
1736    #[test]
1737    fn resolve_css_url_rejects_paths_escaping_site_dir() {
1738        let dir = tempdir().unwrap();
1739        let site = dir.path();
1740        let css_dir = site.join("css");
1741        let out = resolve_css_url("/../../etc/passwd", &css_dir, site);
1742        assert!(out.is_none());
1743    }
1744
1745    // -------------------------------------------------------------------
1746    // fingerprint_file — rename/write error branches
1747    // -------------------------------------------------------------------
1748
1749    #[test]
1750    fn fingerprint_file_write_fails_when_new_path_squatted_by_dir() {
1751        // For minified CSS the fingerprinted name is deterministic:
1752        // sha256(minified). A directory squatting it makes fs::write
1753        // fail.
1754        let dir = tempdir().unwrap();
1755        let css_path = dir.path().join("style.css");
1756        let css = "body { color: red; }";
1757        fs::write(&css_path, css).unwrap();
1758        let hash = sha256_hex(minify_css(css).as_bytes());
1759        let squat = dir.path().join(format!("style.{}.css", &hash[..8]));
1760        fs::create_dir_all(squat.join("keep")).unwrap();
1761
1762        let res =
1763            fingerprint_file(&css_path, dir.path(), SriAlgorithm::default());
1764        assert!(res.is_err());
1765    }
1766
1767    #[test]
1768    fn fingerprint_file_rename_fails_when_new_path_is_nonempty_dir() {
1769        // Non-minified assets go through fs::rename, which fails when
1770        // the target is a non-empty directory.
1771        let dir = tempdir().unwrap();
1772        let png_path = dir.path().join("img.png");
1773        fs::write(&png_path, b"png-bytes").unwrap();
1774        let hash = sha256_hex(b"png-bytes");
1775        let squat = dir.path().join(format!("img.{}.png", &hash[..8]));
1776        fs::create_dir_all(squat.join("keep")).unwrap();
1777
1778        let res =
1779            fingerprint_file(&png_path, dir.path(), SriAlgorithm::default());
1780        assert!(res.is_err());
1781    }
1782
1783    #[test]
1784    fn fingerprint_file_non_utf8_css_is_renamed_not_minified() {
1785        let dir = tempdir().unwrap();
1786        let css_path = dir.path().join("bin.css");
1787        fs::write(&css_path, [0xFF, 0xFE, 0x00, 0x9F]).unwrap();
1788        let (rel_old, info) =
1789            fingerprint_file(&css_path, dir.path(), SriAlgorithm::default())
1790                .unwrap();
1791        assert_eq!(rel_old, "bin.css");
1792        assert!(info.fingerprinted.ends_with(".css"));
1793        assert!(!css_path.exists(), "original renamed away");
1794    }
1795
1796    #[test]
1797    fn fingerprint_file_non_utf8_js_is_renamed_not_minified() {
1798        let dir = tempdir().unwrap();
1799        let js_path = dir.path().join("bin.js");
1800        fs::write(&js_path, [0xFF, 0xFE, 0x00, 0x9F]).unwrap();
1801        let (rel_old, info) =
1802            fingerprint_file(&js_path, dir.path(), SriAlgorithm::default())
1803                .unwrap();
1804        assert_eq!(rel_old, "bin.js");
1805        assert!(info.fingerprinted.ends_with(".js"));
1806    }
1807
1808    // -------------------------------------------------------------------
1809    // after_compile / helpers — IO error propagation
1810    // -------------------------------------------------------------------
1811
1812    fn plugin_ctx(root: &Path, site: &Path) -> PluginContext {
1813        PluginContext::new(root, root, site, root)
1814    }
1815
1816    #[test]
1817    #[cfg(unix)]
1818    fn after_compile_fails_when_site_has_unreadable_subdir() {
1819        use std::os::unix::fs::PermissionsExt;
1820        let dir = tempdir().unwrap();
1821        let site = dir.path().join("site");
1822        let locked = site.join("locked");
1823        fs::create_dir_all(&locked).unwrap();
1824        fs::set_permissions(&locked, fs::Permissions::from_mode(0o000))
1825            .unwrap();
1826
1827        let res =
1828            FingerprintPlugin.after_compile(&plugin_ctx(dir.path(), &site));
1829
1830        let _ = fs::set_permissions(&locked, fs::Permissions::from_mode(0o755));
1831        // Root CI runners bypass perms; only assert when it errored.
1832        if let Err(e) = res {
1833            assert!(!format!("{e}").is_empty(), "should not be empty");
1834        }
1835    }
1836
1837    #[test]
1838    fn after_compile_propagates_non_css_fingerprint_error() {
1839        // Squat the PNG's fingerprinted name so stage 1 fails.
1840        let dir = tempdir().unwrap();
1841        let site = dir.path().join("site");
1842        fs::create_dir_all(&site).unwrap();
1843        fs::write(site.join("img.png"), b"png-bytes").unwrap();
1844        let hash = sha256_hex(b"png-bytes");
1845        let squat = site.join(format!("img.{}.png", &hash[..8]));
1846        fs::create_dir_all(squat.join("keep")).unwrap();
1847
1848        let err = FingerprintPlugin
1849            .after_compile(&plugin_ctx(dir.path(), &site))
1850            .unwrap_err();
1851        assert!(!format!("{err}").is_empty(), "should not be empty");
1852    }
1853
1854    #[test]
1855    fn after_compile_propagates_css_fingerprint_error() {
1856        // Squat the CSS's fingerprinted name so stage 2 fails after
1857        // the url() rewrite pass succeeded.
1858        let dir = tempdir().unwrap();
1859        let site = dir.path().join("site");
1860        fs::create_dir_all(&site).unwrap();
1861        let css = "body { color: blue; }";
1862        fs::write(site.join("style.css"), css).unwrap();
1863        let hash = sha256_hex(minify_css(css).as_bytes());
1864        let squat = site.join(format!("style.{}.css", &hash[..8]));
1865        fs::create_dir_all(squat.join("keep")).unwrap();
1866
1867        let err = FingerprintPlugin
1868            .after_compile(&plugin_ctx(dir.path(), &site))
1869            .unwrap_err();
1870        assert!(!format!("{err}").is_empty(), "should not be empty");
1871    }
1872
1873    #[test]
1874    #[cfg(unix)]
1875    fn after_compile_propagates_unreadable_css_error() {
1876        use std::os::unix::fs::PermissionsExt;
1877        let dir = tempdir().unwrap();
1878        let site = dir.path().join("site");
1879        fs::create_dir_all(&site).unwrap();
1880        let css_path = site.join("style.css");
1881        fs::write(&css_path, "body{}").unwrap();
1882        fs::set_permissions(&css_path, fs::Permissions::from_mode(0o000))
1883            .unwrap();
1884
1885        let res =
1886            FingerprintPlugin.after_compile(&plugin_ctx(dir.path(), &site));
1887
1888        let _ =
1889            fs::set_permissions(&css_path, fs::Permissions::from_mode(0o644));
1890        if let Err(e) = res {
1891            assert!(!format!("{e}").is_empty(), "should not be empty");
1892        }
1893    }
1894
1895    #[test]
1896    #[cfg(unix)]
1897    fn after_compile_fails_when_html_is_unreadable() {
1898        use std::os::unix::fs::PermissionsExt;
1899        let dir = tempdir().unwrap();
1900        let site = dir.path().join("site");
1901        fs::create_dir_all(&site).unwrap();
1902        fs::write(site.join("img.png"), b"png-bytes").unwrap();
1903        let html = site.join("index.html");
1904        fs::write(&html, "<img src=\"/img.png\">").unwrap();
1905        fs::set_permissions(&html, fs::Permissions::from_mode(0o000)).unwrap();
1906
1907        let res =
1908            FingerprintPlugin.after_compile(&plugin_ctx(dir.path(), &site));
1909
1910        let _ = fs::set_permissions(&html, fs::Permissions::from_mode(0o644));
1911        if let Err(e) = res {
1912            assert!(!format!("{e}").is_empty(), "should not be empty");
1913        }
1914    }
1915
1916    #[test]
1917    #[cfg(unix)]
1918    fn after_compile_fails_when_html_is_readonly() {
1919        use std::os::unix::fs::PermissionsExt;
1920        let dir = tempdir().unwrap();
1921        let site = dir.path().join("site");
1922        fs::create_dir_all(&site).unwrap();
1923        fs::write(site.join("img.png"), b"png-bytes").unwrap();
1924        let html = site.join("index.html");
1925        fs::write(&html, "<img src=\"/img.png\">").unwrap();
1926        fs::set_permissions(&html, fs::Permissions::from_mode(0o444)).unwrap();
1927
1928        let res =
1929            FingerprintPlugin.after_compile(&plugin_ctx(dir.path(), &site));
1930
1931        let _ = fs::set_permissions(&html, fs::Permissions::from_mode(0o644));
1932        if let Err(e) = res {
1933            assert!(!format!("{e}").is_empty(), "should not be empty");
1934        }
1935    }
1936
1937    #[test]
1938    #[cfg(unix)]
1939    fn rewrite_html_references_fails_on_unreadable_subdir() {
1940        use std::os::unix::fs::PermissionsExt;
1941        let dir = tempdir().unwrap();
1942        let site = dir.path().join("site");
1943        let locked = site.join("locked");
1944        fs::create_dir_all(&locked).unwrap();
1945        fs::set_permissions(&locked, fs::Permissions::from_mode(0o000))
1946            .unwrap();
1947
1948        let res = rewrite_html_references(&site, &HashMap::new());
1949
1950        let _ = fs::set_permissions(&locked, fs::Permissions::from_mode(0o755));
1951        if let Err(e) = res {
1952            assert!(!format!("{e}").is_empty(), "should not be empty");
1953        }
1954    }
1955
1956    #[test]
1957    #[cfg(unix)]
1958    fn rewrite_css_urls_inplace_write_error_on_readonly_css() {
1959        use std::os::unix::fs::PermissionsExt;
1960        let dir = tempdir().unwrap();
1961        let site = dir.path();
1962        let css_path = site.join("style.css");
1963        fs::write(&css_path, "a{background:url(/img.png);}").unwrap();
1964        let mut manifest = HashMap::new();
1965        let _ = manifest.insert(
1966            "img.png".to_string(),
1967            AssetInfo {
1968                fingerprinted: "img.deadbeef.png".to_string(),
1969                sri: "sha384-x".to_string(),
1970            },
1971        );
1972        fs::set_permissions(&css_path, fs::Permissions::from_mode(0o444))
1973            .unwrap();
1974
1975        let res = rewrite_css_urls_inplace(&css_path, site, &manifest);
1976
1977        let _ =
1978            fs::set_permissions(&css_path, fs::Permissions::from_mode(0o644));
1979        if let Err(e) = res {
1980            assert!(!format!("{e}").is_empty(), "should not be empty");
1981        }
1982    }
1983
1984    #[test]
1985    fn fingerprint_assets_propagates_missing_file_error() {
1986        let dir = tempdir().unwrap();
1987        let missing = vec![dir.path().join("nope.css")];
1988        let res =
1989            fingerprint_assets(&missing, dir.path(), SriAlgorithm::default());
1990        assert!(res.is_err());
1991    }
1992}
1993
1994// =========================================================================
1995// Fault injection — `assets::remove-original` covers the
1996// `fs::remove_file(asset_path)` failure path for minified CSS/JS
1997// assets. The rename-based path (non-CSS/JS, or non-UTF-8 content)
1998// uses `fs::rename` instead and can't hit this failpoint; genuinely
1999// making the *original* file un-removable after it has already been
2000// successfully read, minified, and rewritten to its fingerprinted
2001// name is impractical to construct without fault injection (e.g. a
2002// concurrent deletion race), so this is the only way to exercise it.
2003// =========================================================================
2004#[cfg(all(test, feature = "test-fault-injection"))]
2005mod fault_tests {
2006    use super::*;
2007    use tempfile::tempdir;
2008
2009    /// RAII guard that disables a failpoint on drop.
2010    struct FailGuard(&'static str);
2011
2012    impl Drop for FailGuard {
2013        fn drop(&mut self) {
2014            let _ = fail::cfg(self.0, "off");
2015        }
2016    }
2017
2018    #[test]
2019    #[serial_test::serial(assets_failpoint)]
2020    fn remove_original_failpoint_propagates() {
2021        let _guard = FailGuard("assets::remove-original");
2022        fail::cfg("assets::remove-original", "return")
2023            .expect("activate failpoint");
2024
2025        let dir = tempdir().unwrap();
2026        let css_path = dir.path().join("style.css");
2027        fs::write(&css_path, "body { color: red; }").unwrap();
2028
2029        let err =
2030            fingerprint_file(&css_path, dir.path(), SriAlgorithm::default())
2031                .expect_err("injected removal failure must propagate");
2032        assert!(
2033            format!("{err:?}").contains("injected: assets::remove-original")
2034        );
2035        // The fingerprinted file was already written before the
2036        // injected failure; the original is left in place too since
2037        // removal never ran.
2038        assert!(css_path.exists());
2039    }
2040}