Skip to main content

ssg/
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::plugin::{Plugin, PluginContext};
10use anyhow::{Context, Result};
11use base64::{engine::general_purpose::STANDARD as BASE64, Engine as _};
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#[derive(Debug, Clone, Copy)]
27pub struct FingerprintPlugin;
28
29impl Plugin for FingerprintPlugin {
30    fn name(&self) -> &'static str {
31        "fingerprint"
32    }
33
34    fn after_compile(&self, ctx: &PluginContext) -> Result<()> {
35        if !ctx.site_dir.exists() {
36            return Ok(());
37        }
38
39        let all_assets = collect_assets(&ctx.site_dir)?;
40        if all_assets.is_empty() {
41            return Ok(());
42        }
43
44        // Three-pass fingerprinting (resolves the CSS-url() problem
45        // surfaced in the v0.0.39 audit):
46        //
47        //   1. Hash + rename non-CSS assets first (images, fonts,
48        //      JS). Build the first-stage manifest.
49        //   2. Walk every CSS file. Patch any `url(...)` references
50        //      that resolve to an entry in the first-stage manifest
51        //      so they point at the new fingerprinted name. THEN
52        //      hash + rename the CSS — its SRI hash now covers the
53        //      post-rewrite content.
54        //   3. Walk every HTML file and rewrite `<link href>`,
55        //      `<script src>`, `<img src>`, etc. against the full
56        //      manifest, attaching `integrity` + `crossorigin` for
57        //      CSS/JS where SRI is meaningful.
58        //
59        // Without this split, CSS `url(/images/logo.png)` would
60        // 404 after `logo.png` was renamed to `logo.<hash>.png`.
61
62        let (css_files, non_css): (Vec<_>, Vec<_>) = all_assets
63            .into_iter()
64            .partition(|p| p.extension().is_some_and(|e| e == "css"));
65
66        let mut manifest = fingerprint_assets(&non_css, &ctx.site_dir)?;
67
68        for css_path in &css_files {
69            rewrite_css_urls_inplace(css_path, &ctx.site_dir, &manifest)?;
70        }
71
72        let css_manifest = fingerprint_assets(&css_files, &ctx.site_dir)?;
73        manifest.extend(css_manifest);
74
75        rewrite_html_references(&ctx.site_dir, &manifest)?;
76
77        log::info!(
78            "[fingerprint] Processed {} asset(s) across {} CSS + {} other",
79            manifest.len(),
80            css_files.len(),
81            manifest.len() - css_files.len()
82        );
83        Ok(())
84    }
85}
86
87/// Fingerprints all asset files: computes hash, renames, and builds the manifest.
88fn fingerprint_assets(
89    assets: &[PathBuf],
90    site_dir: &Path,
91) -> Result<HashMap<String, AssetInfo>> {
92    let mut manifest = HashMap::new();
93
94    for asset_path in assets {
95        let info = fingerprint_file(asset_path, site_dir)?;
96        let _ = manifest.insert(info.0, info.1);
97    }
98
99    Ok(manifest)
100}
101
102/// Fingerprints a single asset file: hash, rename, return (`old_rel`, `AssetInfo`).
103fn fingerprint_file(
104    asset_path: &Path,
105    site_dir: &Path,
106) -> Result<(String, AssetInfo)> {
107    let content = fs::read(asset_path)?;
108    let hash = sha256_hex(&content);
109    let short_hash = &hash[..8];
110
111    let stem = asset_path.file_stem().unwrap_or_default().to_string_lossy();
112    let ext = asset_path.extension().unwrap_or_default().to_string_lossy();
113    let new_name = format!("{stem}.{short_hash}.{ext}");
114    let new_path = asset_path.with_file_name(&new_name);
115
116    let sri = format!("sha256-{}", sri_base64(&content));
117
118    fs::rename(asset_path, &new_path).with_context(|| {
119        format!("Failed to rename {}", asset_path.display())
120    })?;
121
122    let rel_old = asset_path
123        .strip_prefix(site_dir)
124        .unwrap_or(asset_path)
125        .to_string_lossy()
126        .replace('\\', "/");
127    let rel_new = new_path
128        .strip_prefix(site_dir)
129        .unwrap_or(&new_path)
130        .to_string_lossy()
131        .replace('\\', "/");
132
133    Ok((
134        rel_old,
135        AssetInfo {
136            fingerprinted: rel_new,
137            sri,
138        },
139    ))
140}
141
142/// Rewrites HTML files to use fingerprinted asset references.
143fn rewrite_html_references(
144    site_dir: &Path,
145    manifest: &HashMap<String, AssetInfo>,
146) -> Result<()> {
147    let html_files = collect_html_files(site_dir)?;
148    for html_path in &html_files {
149        let html = fs::read_to_string(html_path)?;
150        let rewritten = rewrite_asset_refs(&html, manifest);
151        if rewritten != html {
152            fs::write(html_path, rewritten)?;
153        }
154    }
155    Ok(())
156}
157
158#[derive(Debug, Clone)]
159struct AssetInfo {
160    fingerprinted: String,
161    sri: String,
162}
163
164/// Rewrites every `url(...)` reference in a CSS file in place,
165/// pointing each one at the fingerprinted name from `manifest` if
166/// the URL resolves to a known asset.
167///
168/// Resolution rules:
169///
170/// - `url(/foo.png)` — absolute from the site root; lookup key is
171///   `foo.png`.
172/// - `url(./foo.png)`, `url(../images/foo.png)` — resolved against
173///   the CSS file's parent directory, then made site-relative.
174/// - `url(images/foo.png)` (bare, no `/` or `./`) — same as the
175///   relative case above.
176/// - URLs containing `://` (full URLs) and `data:` URIs are left
177///   untouched.
178///
179/// Output URLs are written as **absolute, site-rooted paths**
180/// (`/foo.<hash>.png`) regardless of the original form. This is
181/// valid CSS and unambiguous; it deliberately trades a tiny bit of
182/// stylistic preservation for correctness.
183///
184/// Quote forms handled: `url(x)`, `url("x")`, `url('x')`. URLs with
185/// query strings or fragments (e.g. `url(foo.svg#icon)`,
186/// `url(foo.css?v=1)`) preserve the suffix on the rewritten URL.
187fn rewrite_css_urls(
188    css: &str,
189    css_path: &Path,
190    site_dir: &Path,
191    manifest: &HashMap<String, AssetInfo>,
192) -> String {
193    let css_dir = css_path.parent().unwrap_or(css_path);
194    let mut out = String::with_capacity(css.len());
195    let mut remaining = css;
196
197    while let Some(idx) = remaining.find("url(") {
198        out.push_str(&remaining[..idx]);
199        let after_open = &remaining[idx + 4..]; // past "url("
200        let Some(close_idx) = after_open.find(')') else {
201            // Unterminated url(...) — leave the rest unchanged.
202            out.push_str("url(");
203            out.push_str(after_open);
204            return out;
205        };
206        let raw = &after_open[..close_idx];
207        let rest = &after_open[close_idx + 1..];
208
209        // Strip optional surrounding quotes.
210        let trimmed = raw.trim();
211        let (quote, inner) = if let Some(s) = trimmed.strip_prefix('"') {
212            ('"', s.strip_suffix('"').unwrap_or(s))
213        } else if let Some(s) = trimmed.strip_prefix('\'') {
214            ('\'', s.strip_suffix('\'').unwrap_or(s))
215        } else {
216            ('\0', trimmed)
217        };
218
219        // Split off ?query or #fragment so we don't try to resolve them.
220        let (url, suffix) = if let Some(i) = inner.find(['?', '#']) {
221            (&inner[..i], &inner[i..])
222        } else {
223            (inner, "")
224        };
225
226        let resolved = resolve_css_url(url, css_dir, site_dir);
227        let hit = resolved.and_then(|key| manifest.get(&key).map(|i| (key, i)));
228
229        out.push_str("url(");
230        if let Some((_, info)) = hit {
231            // Emit absolute /<fingerprinted>(suffix).
232            let new_url = format!("/{}{}", info.fingerprinted, suffix);
233            if quote != '\0' {
234                out.push(quote);
235            }
236            out.push_str(&new_url);
237            if quote != '\0' {
238                out.push(quote);
239            }
240        } else {
241            // No manifest hit — emit the original verbatim.
242            out.push_str(raw);
243        }
244        out.push(')');
245
246        remaining = rest;
247    }
248
249    out.push_str(remaining);
250    out
251}
252
253/// Resolves a CSS URL to a site-relative manifest key.
254///
255/// Returns `None` for full URLs (`http://`, `https://`, `//`),
256/// `data:` URIs, and paths that escape the site directory.
257fn resolve_css_url(
258    url: &str,
259    css_dir: &Path,
260    site_dir: &Path,
261) -> Option<String> {
262    let trimmed = url.trim();
263    if trimmed.is_empty()
264        || trimmed.starts_with("data:")
265        || trimmed.starts_with("http://")
266        || trimmed.starts_with("https://")
267        || trimmed.starts_with("//")
268    {
269        return None;
270    }
271
272    // Build the absolute on-disk path.
273    let candidate = if let Some(stripped) = trimmed.strip_prefix('/') {
274        site_dir.join(stripped)
275    } else {
276        css_dir.join(trimmed)
277    };
278
279    // Logical canonicalisation: collapse `..` and `.` without
280    // touching the filesystem so non-existent (already-renamed)
281    // targets still resolve.
282    let mut components: Vec<&std::ffi::OsStr> = Vec::new();
283    for c in candidate.components() {
284        match c {
285            std::path::Component::CurDir => {}
286            std::path::Component::ParentDir => {
287                let _ = components.pop();
288            }
289            std::path::Component::Normal(s) => components.push(s),
290            std::path::Component::RootDir | std::path::Component::Prefix(_) => {
291                components.clear();
292            }
293        }
294    }
295    let mut resolved = PathBuf::new();
296    for c in components {
297        resolved.push(c);
298    }
299
300    // The manifest stores keys relative to site_dir (no leading slash).
301    let site_components: Vec<&std::ffi::OsStr> = site_dir
302        .components()
303        .filter_map(|c| match c {
304            std::path::Component::Normal(s) => Some(s),
305            _ => None,
306        })
307        .collect();
308    let resolved_components: Vec<&std::ffi::OsStr> = resolved
309        .components()
310        .filter_map(|c| match c {
311            std::path::Component::Normal(s) => Some(s),
312            _ => None,
313        })
314        .collect();
315
316    if resolved_components.len() < site_components.len()
317        || resolved_components[..site_components.len()] != site_components[..]
318    {
319        return None;
320    }
321
322    let rel: PathBuf = resolved_components[site_components.len()..]
323        .iter()
324        .collect();
325    Some(rel.to_string_lossy().replace('\\', "/"))
326}
327
328/// Reads, rewrites, and writes a CSS file in place if any of its
329/// `url(...)` references resolve to a manifest entry.
330fn rewrite_css_urls_inplace(
331    css_path: &Path,
332    site_dir: &Path,
333    manifest: &HashMap<String, AssetInfo>,
334) -> Result<()> {
335    let css = fs::read_to_string(css_path).with_context(|| {
336        format!("Failed to read CSS {}", css_path.display())
337    })?;
338    let rewritten = rewrite_css_urls(&css, css_path, site_dir, manifest);
339    if rewritten != css {
340        fs::write(css_path, rewritten).with_context(|| {
341            format!("Failed to write rewritten CSS {}", css_path.display())
342        })?;
343    }
344    Ok(())
345}
346
347/// Rewrites asset references in HTML and adds SRI attributes.
348fn rewrite_asset_refs(
349    html: &str,
350    manifest: &HashMap<String, AssetInfo>,
351) -> String {
352    let mut result = html.to_string();
353    for (old_path, info) in manifest {
354        // Replace href="old" with href="new" integrity="..." crossorigin="anonymous"
355        let old_ref = format!("\"{old_path}\"");
356        let old_ref_slash = format!("\"/{old_path}\"");
357        let new_ref = format!(
358            "\"{}\" integrity=\"{}\" crossorigin=\"anonymous\"",
359            info.fingerprinted, info.sri
360        );
361        let new_ref_slash = format!(
362            "\"/{}\" integrity=\"{}\" crossorigin=\"anonymous\"",
363            info.fingerprinted, info.sri
364        );
365
366        result = result.replace(&old_ref, &new_ref);
367        result = result.replace(&old_ref_slash, &new_ref_slash);
368    }
369    result
370}
371
372/// SHA-256 hash as a 64-char hex string.
373///
374/// Used for the cache-busting fingerprint suffix (`name.<hash>.ext`).
375/// The first 8 hex characters of this output are taken as the
376/// short content fingerprint; the full digest is also reused as the
377/// raw input to [`sri_base64`] for the `integrity="sha256-..."`
378/// attribute.
379fn sha256_hex(data: &[u8]) -> String {
380    let mut hasher = Sha256::new();
381    hasher.update(data);
382    let bytes = hasher.finalize();
383    let mut s = String::with_capacity(64);
384    for b in bytes {
385        use std::fmt::Write as _;
386        let _ = write!(s, "{b:02x}");
387    }
388    s
389}
390
391/// Returns the canonical Subresource Integrity payload for `data`.
392///
393/// Browsers compare the `integrity` attribute against `base64(SHA-256(body))`
394/// per the [W3C SRI spec](https://www.w3.org/TR/SRI/#the-integrity-attribute).
395/// Combined with the `sha256-` prefix at the call site, the resulting
396/// attribute is exactly what a browser will validate the response body
397/// against.
398fn sri_base64(data: &[u8]) -> String {
399    let mut hasher = Sha256::new();
400    hasher.update(data);
401    let bytes = hasher.finalize();
402    BASE64.encode(bytes)
403}
404
405/// Asset extensions we content-fingerprint. Matches the
406/// "content-addressable asset pipeline" intent of issue #468:
407/// CSS/JS for code, common raster + vector image formats for art,
408/// font formats for typography. Each gets a `name.hash.ext` rename
409/// and an SRI hash; deploy configs serve them with
410/// `Cache-Control: public, max-age=31536000, immutable`.
411const FINGERPRINTED_EXTENSIONS: &[&str] = &[
412    "css", "js", "mjs", "png", "jpg", "jpeg", "webp", "avif", "gif", "svg",
413    "woff", "woff2", "ttf", "otf",
414];
415
416/// Collects every fingerprintable asset from site dir.
417fn collect_assets(dir: &Path) -> Result<Vec<PathBuf>> {
418    crate::walk::walk_files_multi(dir, FINGERPRINTED_EXTENSIONS)
419}
420
421fn collect_html_files(dir: &Path) -> Result<Vec<PathBuf>> {
422    crate::walk::walk_files(dir, "html")
423}
424
425#[cfg(test)]
426#[allow(clippy::unwrap_used, clippy::expect_used)]
427mod tests {
428    use super::*;
429    use tempfile::tempdir;
430
431    #[test]
432    fn test_sha256_hex_deterministic() {
433        let h1 = sha256_hex(b"hello");
434        let h2 = sha256_hex(b"hello");
435        assert_eq!(h1, h2);
436        // Real SHA-256 is 32 bytes → 64 hex chars.
437        assert_eq!(h1.len(), 64);
438    }
439
440    #[test]
441    fn test_sha256_hex_known_vectors() {
442        // Verifies real SHA-256 is in use, not an FNV placeholder.
443        // Empty input — well-known SHA-256("") digest.
444        assert_eq!(
445            sha256_hex(b""),
446            "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
447        );
448        // "abc" — the canonical NIST test vector.
449        assert_eq!(
450            sha256_hex(b"abc"),
451            "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
452        );
453    }
454
455    #[test]
456    fn test_sri_base64_known_vector() {
457        // SHA-256("") base64-encoded is the canonical 47ZHRWlj-... value.
458        assert_eq!(
459            sri_base64(b""),
460            "47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU="
461        );
462    }
463
464    #[test]
465    fn test_sha256_hex_varies() {
466        let h1 = sha256_hex(b"hello");
467        let h2 = sha256_hex(b"world");
468        assert_ne!(h1, h2);
469    }
470
471    #[test]
472    fn test_fingerprint_plugin() {
473        let dir = tempdir().unwrap();
474        let site = dir.path().join("site");
475        fs::create_dir_all(&site).unwrap();
476
477        // Create a CSS file
478        fs::write(site.join("style.css"), "body { color: red; }").unwrap();
479
480        // Create HTML that references it
481        let html = r#"<html><head><link rel="stylesheet" href="style.css"></head><body></body></html>"#;
482        fs::write(site.join("index.html"), html).unwrap();
483
484        let ctx = PluginContext::new(dir.path(), dir.path(), &site, dir.path());
485        FingerprintPlugin.after_compile(&ctx).unwrap();
486
487        // Original file should be gone
488        assert!(!site.join("style.css").exists());
489
490        // Fingerprinted file should exist
491        let entries: Vec<_> = fs::read_dir(&site)
492            .unwrap()
493            .filter_map(std::result::Result::ok)
494            .filter(|e| {
495                e.path()
496                    .file_name()
497                    .unwrap()
498                    .to_string_lossy()
499                    .starts_with("style.")
500                    && e.path().extension().is_some_and(|e| e == "css")
501            })
502            .collect();
503        assert_eq!(entries.len(), 1);
504
505        // HTML should reference the fingerprinted file
506        let output = fs::read_to_string(site.join("index.html")).unwrap();
507        assert!(output.contains("integrity="));
508        assert!(output.contains("crossorigin=\"anonymous\""));
509        assert!(!output.contains("href=\"style.css\""));
510    }
511
512    #[test]
513    fn name_returns_static_fingerprint_identifier() {
514        assert_eq!(FingerprintPlugin.name(), "fingerprint");
515    }
516
517    #[test]
518    fn after_compile_missing_site_dir_returns_ok() {
519        // Line 34: `!ctx.site_dir.exists()` early return.
520        let dir = tempdir().unwrap();
521        let missing = dir.path().join("missing");
522        let ctx =
523            PluginContext::new(dir.path(), dir.path(), &missing, dir.path());
524        FingerprintPlugin.after_compile(&ctx).unwrap();
525        assert!(!missing.exists());
526    }
527
528    #[test]
529    fn after_compile_no_assets_short_circuits() {
530        // Line 40: `assets.is_empty()` early return — site with
531        // HTML but no CSS/JS.
532        let dir = tempdir().unwrap();
533        let site = dir.path().join("site");
534        fs::create_dir_all(&site).unwrap();
535        fs::write(site.join("index.html"), "<p></p>").unwrap();
536
537        let ctx = PluginContext::new(dir.path(), dir.path(), &site, dir.path());
538        FingerprintPlugin.after_compile(&ctx).unwrap();
539        // HTML untouched.
540        assert_eq!(
541            fs::read_to_string(site.join("index.html")).unwrap(),
542            "<p></p>"
543        );
544    }
545
546    #[test]
547    fn after_compile_fingerprint_absolute_path_href() {
548        // Covers the `old_ref_slash` variant (with leading /) in
549        // rewrite_asset_refs — absolute-path stylesheet links.
550        let dir = tempdir().unwrap();
551        let site = dir.path().join("site");
552        fs::create_dir_all(&site).unwrap();
553        fs::write(site.join("app.js"), "console.log(1);").unwrap();
554        fs::write(
555            site.join("index.html"),
556            r#"<html><head><script src="/app.js"></script></head></html>"#,
557        )
558        .unwrap();
559
560        let ctx = PluginContext::new(dir.path(), dir.path(), &site, dir.path());
561        FingerprintPlugin.after_compile(&ctx).unwrap();
562        let html = fs::read_to_string(site.join("index.html")).unwrap();
563        assert!(html.contains("integrity="));
564    }
565
566    #[test]
567    fn collect_assets_picks_up_fingerprintable_extensions() {
568        // Issue #468 widened the fingerprinted set from {css, js}
569        // to also include images and fonts. css/js/png/woff2 are in;
570        // html/txt/md are out.
571        let dir = tempdir().unwrap();
572        fs::write(dir.path().join("a.css"), "").unwrap();
573        fs::write(dir.path().join("b.js"), "").unwrap();
574        fs::write(dir.path().join("c.html"), "").unwrap();
575        fs::write(dir.path().join("d.png"), "").unwrap();
576        fs::write(dir.path().join("e.woff2"), "").unwrap();
577        fs::write(dir.path().join("f.txt"), "").unwrap();
578        let files = collect_assets(dir.path()).unwrap();
579        // 4 fingerprintable: css, js, png, woff2.
580        assert_eq!(files.len(), 4);
581    }
582
583    #[test]
584    fn collect_assets_recurses_into_subdirectories() {
585        let dir = tempdir().unwrap();
586        let nested = dir.path().join("vendor");
587        fs::create_dir(&nested).unwrap();
588        fs::write(dir.path().join("top.css"), "").unwrap();
589        fs::write(nested.join("lib.js"), "").unwrap();
590        let files = collect_assets(dir.path()).unwrap();
591        assert_eq!(files.len(), 2);
592    }
593
594    #[test]
595    fn collect_html_files_filters_non_html() {
596        let dir = tempdir().unwrap();
597        fs::write(dir.path().join("a.html"), "").unwrap();
598        fs::write(dir.path().join("b.css"), "").unwrap();
599        let files = collect_html_files(dir.path()).unwrap();
600        assert_eq!(files.len(), 1);
601    }
602
603    #[test]
604    fn sha256_hex_produces_64_hex_chars() {
605        assert_eq!(sha256_hex(b"abc").len(), 64);
606        assert_eq!(sha256_hex(b"").len(), 64);
607    }
608
609    #[test]
610    fn sri_base64_is_nonempty_for_input() {
611        assert!(!sri_base64(b"hello").is_empty());
612    }
613
614    #[test]
615    fn sri_base64_emits_44_char_payload() {
616        // SHA-256 → 32 raw bytes → base64 with padding = 44 chars.
617        assert_eq!(sri_base64(b"hello").len(), 44);
618        assert_eq!(sri_base64(b"").len(), 44);
619    }
620
621    #[test]
622    fn test_rewrite_asset_refs() {
623        let mut manifest = HashMap::new();
624        let _ = manifest.insert(
625            "style.css".to_string(),
626            AssetInfo {
627                fingerprinted: "style.abc12345.css".to_string(),
628                sri: "sha256-xyz".to_string(),
629            },
630        );
631
632        let html = r#"<link rel="stylesheet" href="style.css">"#;
633        let result = rewrite_asset_refs(html, &manifest);
634        assert!(result.contains("style.abc12345.css"));
635        assert!(result.contains("integrity=\"sha256-xyz\""));
636    }
637
638    // ── CSS url() rewriting (resolves audit item #2) ───────────────
639
640    fn css_manifest() -> HashMap<String, AssetInfo> {
641        let mut m = HashMap::new();
642        let _ = m.insert(
643            "images/logo.png".to_string(),
644            AssetInfo {
645                fingerprinted: "images/logo.deadbeef.png".to_string(),
646                sri: String::new(),
647            },
648        );
649        let _ = m.insert(
650            "fonts/sans.woff2".to_string(),
651            AssetInfo {
652                fingerprinted: "fonts/sans.cafef00d.woff2".to_string(),
653                sri: String::new(),
654            },
655        );
656        m
657    }
658
659    #[test]
660    fn rewrite_css_urls_handles_absolute_path() {
661        let dir = tempdir().unwrap();
662        let css_path = dir.path().join("assets/style.css");
663        let css = "body { background: url(/images/logo.png); }";
664        let out = rewrite_css_urls(css, &css_path, dir.path(), &css_manifest());
665        assert!(out.contains("url(/images/logo.deadbeef.png)"));
666        assert!(!out.contains("logo.png)"));
667    }
668
669    #[test]
670    fn rewrite_css_urls_handles_relative_path() {
671        let dir = tempdir().unwrap();
672        let css_path = dir.path().join("assets/style.css");
673        let css = "body { background: url(../images/logo.png); }";
674        let out = rewrite_css_urls(css, &css_path, dir.path(), &css_manifest());
675        assert!(out.contains("url(/images/logo.deadbeef.png)"));
676    }
677
678    #[test]
679    fn rewrite_css_urls_handles_double_quotes() {
680        let dir = tempdir().unwrap();
681        let css_path = dir.path().join("style.css");
682        let css = r#"body { background: url("/images/logo.png"); }"#;
683        let out = rewrite_css_urls(css, &css_path, dir.path(), &css_manifest());
684        assert!(out.contains(r#"url("/images/logo.deadbeef.png")"#));
685    }
686
687    #[test]
688    fn rewrite_css_urls_handles_single_quotes() {
689        let dir = tempdir().unwrap();
690        let css_path = dir.path().join("style.css");
691        let css = "body { background: url('/images/logo.png'); }";
692        let out = rewrite_css_urls(css, &css_path, dir.path(), &css_manifest());
693        assert!(out.contains("url('/images/logo.deadbeef.png')"));
694    }
695
696    #[test]
697    fn rewrite_css_urls_preserves_query_and_fragment() {
698        let dir = tempdir().unwrap();
699        let css_path = dir.path().join("style.css");
700        let css = "@font-face { src: url(/fonts/sans.woff2?v=1#hint); }";
701        let out = rewrite_css_urls(css, &css_path, dir.path(), &css_manifest());
702        assert!(out.contains("/fonts/sans.cafef00d.woff2?v=1#hint"));
703    }
704
705    #[test]
706    fn rewrite_css_urls_skips_external_and_data_urls() {
707        let dir = tempdir().unwrap();
708        let css_path = dir.path().join("style.css");
709        let css = r#"
710            a { background: url(https://cdn.example.com/x.png); }
711            b { background: url(//cdn.example.com/y.png); }
712            c { background: url(data:image/svg+xml,<svg/>); }
713        "#;
714        let out = rewrite_css_urls(css, &css_path, dir.path(), &css_manifest());
715        // All three URLs are left untouched.
716        assert!(out.contains("https://cdn.example.com/x.png"));
717        assert!(out.contains("//cdn.example.com/y.png"));
718        assert!(out.contains("data:image/svg+xml"));
719    }
720
721    #[test]
722    fn rewrite_css_urls_no_change_when_url_not_in_manifest() {
723        let dir = tempdir().unwrap();
724        let css_path = dir.path().join("style.css");
725        let css = "body { background: url(/images/missing.png); }";
726        let out = rewrite_css_urls(css, &css_path, dir.path(), &css_manifest());
727        assert_eq!(out, css);
728    }
729
730    #[test]
731    fn rewrite_css_urls_unterminated_url_does_not_panic() {
732        let dir = tempdir().unwrap();
733        let css_path = dir.path().join("style.css");
734        let css = "body { background: url(/images/logo.png";
735        let out = rewrite_css_urls(css, &css_path, dir.path(), &css_manifest());
736        assert!(!out.is_empty());
737    }
738
739    #[test]
740    fn after_compile_rewrites_css_url_to_fingerprinted_image() {
741        // End-to-end: drop a CSS file referencing a PNG, run the
742        // plugin, and confirm the produced CSS points at the
743        // fingerprinted PNG name.
744        let dir = tempdir().unwrap();
745        let site = dir.path().join("site");
746        fs::create_dir_all(site.join("images")).unwrap();
747        // 1×1 transparent PNG (the smallest valid PNG bytes).
748        let png_bytes: &[u8] = &[
749            0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00, 0x00,
750            0x0D, 0x49, 0x48, 0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00,
751            0x00, 0x01, 0x08, 0x06, 0x00, 0x00, 0x00, 0x1F, 0x15, 0xC4, 0x89,
752            0x00, 0x00, 0x00, 0x0D, 0x49, 0x44, 0x41, 0x54, 0x78, 0x9C, 0x63,
753            0x00, 0x01, 0x00, 0x00, 0x05, 0x00, 0x01, 0x0D, 0x0A, 0x2D, 0xB4,
754            0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4E, 0x44, 0xAE, 0x42, 0x60,
755            0x82,
756        ];
757        fs::write(site.join("images/logo.png"), png_bytes).unwrap();
758        fs::write(
759            site.join("style.css"),
760            "body { background: url(/images/logo.png); }",
761        )
762        .unwrap();
763        fs::write(
764            site.join("index.html"),
765            r#"<html><head><link rel="stylesheet" href="style.css"></head><body></body></html>"#,
766        )
767        .unwrap();
768
769        let ctx = PluginContext::new(dir.path(), dir.path(), &site, dir.path());
770        FingerprintPlugin.after_compile(&ctx).unwrap();
771
772        // Find the renamed CSS and verify its url() points at the
773        // renamed PNG (not the original `logo.png` filename).
774        let mut css_text = None;
775        for entry in fs::read_dir(&site).unwrap().flatten() {
776            let p = entry.path();
777            if p.extension().is_some_and(|e| e == "css") {
778                css_text = Some(fs::read_to_string(&p).unwrap());
779            }
780        }
781        let css_text = css_text.expect("renamed CSS file present");
782        assert!(
783            css_text.contains("/images/logo."),
784            "rewritten CSS should reference renamed PNG: {css_text}"
785        );
786        assert!(css_text.contains(".png"), "still ends in .png: {css_text}");
787        // Crucial: the URL is no longer the original `/images/logo.png`
788        // — it's `/images/logo.<hash>.png`.
789        assert!(
790            !css_text.contains("/images/logo.png)"),
791            "must no longer point at the un-fingerprinted PNG: {css_text}"
792        );
793    }
794}