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