Skip to main content

ssg/plugins/
csp.rs

1// Copyright © 2023 - 2026 Static Site Generator (SSG). All rights reserved.
2// SPDX-License-Identifier: Apache-2.0 OR MIT
3
4//! Content Security Policy hardening plugin.
5//!
6//! Extracts inline `<style>` and `<script>` blocks into external files
7//! with Subresource Integrity (SRI) hashes, eliminating the need for
8//! `'unsafe-inline'` in the Content-Security-Policy header.
9
10use crate::cmd::SriAlgorithm;
11use crate::error::{PathErrorExt, SsgError};
12use crate::plugin::{Plugin, PluginContext};
13use anyhow::Result;
14use std::{fs, path::Path};
15
16/// Canonical Content-Security-Policy string emitted by the CSP plugin.
17///
18/// Returned by [`computed_policy`] and consumed by downstream emitters
19/// (e.g. the `edge_headers` postprocess plugin) that need to forward
20/// the same policy as an HTTP header instead of a `<meta>` tag.
21///
22/// The string is intentionally `'unsafe-inline'`-free; this matches the
23/// post-extraction posture enforced by [`CspPlugin::transform_html`]
24/// and [`inject_csp_meta`] (which strip `'unsafe-inline'` from any
25/// preexisting `<meta>` policy on the way through).
26pub const DEFAULT_CSP_POLICY: &str = "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' https: data:; font-src 'self' https:; connect-src 'self'; frame-ancestors 'none'";
27
28/// Content-Security-Policy template with `{script_hashes}` and
29/// `{style_hashes}` slots (spec B4, v0.0.47 plan §3 item 2.4).
30///
31/// [`render_policy_template`] expands each slot into zero or more
32/// space-prefixed `'sha256-…'` source expressions. With both slots
33/// empty the rendered string is byte-identical to
34/// [`DEFAULT_CSP_POLICY`], so pages without inline blocks fall back
35/// to exactly the global policy — the invariant is pinned by a unit
36/// test in this module.
37///
38/// This constant is the single template notion for the CSP plugin; a
39/// future `[security.csp] template` knob in `ssg.toml` overrides it by
40/// passing the configured string to [`render_policy_template`] — the
41/// rendering path already accepts an arbitrary template.
42///
43/// # Examples
44///
45/// ```rust
46/// use ssg::csp::{
47///     render_policy_template, DEFAULT_CSP_POLICY, DEFAULT_CSP_POLICY_TEMPLATE,
48/// };
49///
50/// assert!(DEFAULT_CSP_POLICY_TEMPLATE.contains("{script_hashes}"));
51/// assert!(DEFAULT_CSP_POLICY_TEMPLATE.contains("{style_hashes}"));
52///
53/// // Both slots empty ⇒ byte-identical to the global policy.
54/// let rendered =
55///     render_policy_template(DEFAULT_CSP_POLICY_TEMPLATE, &[], &[]);
56/// assert_eq!(rendered, DEFAULT_CSP_POLICY);
57/// ```
58pub const DEFAULT_CSP_POLICY_TEMPLATE: &str = "default-src 'self'; script-src 'self'{script_hashes}; style-src 'self'{style_hashes}; img-src 'self' https: data:; font-src 'self' https:; connect-src 'self'; frame-ancestors 'none'";
59
60/// Returns the canonical Content-Security-Policy string that the CSP
61/// plugin's inline-extraction posture is designed to enforce.
62///
63/// This is the single source of truth for the CSP header value: every
64/// other emitter (deploy adapters, edge-headers postprocess plugin)
65/// reads from this function rather than recomputing or hardcoding the
66/// string, ensuring that platform-level HTTP headers and the
67/// `<meta http-equiv>` injection stay in lock-step.
68///
69/// Returns a borrowed reference to [`DEFAULT_CSP_POLICY`] today; the
70/// signature is intentionally a borrowed `&'static str` so callers can
71/// pass it directly into `format!` / `write!` without an extra
72/// allocation.
73///
74/// # Examples
75///
76/// ```rust
77/// use ssg::csp::computed_policy;
78///
79/// let policy = computed_policy();
80/// assert!(policy.contains("default-src"));
81/// ```
82#[must_use]
83pub const fn computed_policy() -> &'static str {
84    DEFAULT_CSP_POLICY
85}
86
87/// CSP source hashes for the inline blocks remaining on a single page
88/// (spec B4, plan §3 item 2.4).
89///
90/// Each entry is a bare `sha256-<base64>` token — the caller wraps it
91/// in single quotes when splicing it into a CSP directive. Hashes are
92/// listed in document order with duplicates removed, so the same
93/// input HTML always produces the same vector (determinism gate).
94///
95/// # Examples
96///
97/// ```rust
98/// use ssg::csp::page_inline_hashes;
99///
100/// let html = "<style>body{margin:0}</style><script>init()</script>";
101/// let hashes = page_inline_hashes(html);
102/// assert_eq!(hashes.scripts.len(), 1);
103/// assert_eq!(hashes.styles.len(), 1);
104/// assert!(!hashes.is_empty());
105/// ```
106#[derive(Debug, Clone, Default, PartialEq, Eq)]
107pub struct PageCspHashes {
108    /// Hashes of inline `<script>` bodies (no `src=` attribute),
109    /// including non-executable blocks such as JSON-LD structured
110    /// data — hash-listing them keeps the policy valid for UAs that
111    /// apply `script-src` to data blocks.
112    pub scripts: Vec<String>,
113    /// Hashes of inline `<style>` bodies.
114    pub styles: Vec<String>,
115}
116
117impl PageCspHashes {
118    /// Returns `true` when the page has no inline blocks at all.
119    ///
120    /// # Examples
121    ///
122    /// ```rust
123    /// use ssg::csp::PageCspHashes;
124    ///
125    /// assert!(PageCspHashes::default().is_empty());
126    /// ```
127    #[must_use]
128    pub const fn is_empty(&self) -> bool {
129        self.scripts.is_empty() && self.styles.is_empty()
130    }
131}
132
133/// Computes the CSP source hashes for every inline block on a page.
134///
135/// Unlike the extraction pass ([`CspPlugin::transform_html`]), this
136/// scan does **not** skip JSON-LD or livereload-marked scripts: it
137/// hashes whatever is still inline in the HTML it is given, because
138/// its consumers (the `edge_headers` postprocess plugin) run on the
139/// final page bytes and need the policy to match what actually ships.
140///
141/// CSP directive source hashes are always SHA-256 for the broadest UA
142/// compatibility; the `[security] sri_algorithm` knob governs only
143/// SRI `integrity=` attributes (see the `compute_sri` doc comment).
144///
145/// # Examples
146///
147/// ```rust
148/// use ssg::csp::page_inline_hashes;
149///
150/// let html = r#"<script type="application/ld+json">{"@type":"Thing"}</script>"#;
151/// let hashes = page_inline_hashes(html);
152/// assert_eq!(hashes.scripts.len(), 1);
153/// assert!(hashes.scripts[0].starts_with("sha256-"));
154/// assert!(hashes.styles.is_empty());
155/// ```
156#[must_use]
157pub fn page_inline_hashes(html: &str) -> PageCspHashes {
158    let hash =
159        |content: &str| SriAlgorithm::Sha256.integrity(content.as_bytes());
160
161    // One parse for both tags: see `collect_inline_script_and_style`.
162    let (raw_scripts, raw_styles) = collect_inline_script_and_style(html);
163
164    let dedup = |raw: Vec<String>| {
165        let mut out: Vec<String> = Vec::with_capacity(raw.len());
166        for content in raw {
167            let h = hash(&content);
168            if !out.contains(&h) {
169                out.push(h);
170            }
171        }
172        out
173    };
174
175    let scripts = dedup(raw_scripts);
176    let styles = dedup(raw_styles);
177
178    PageCspHashes { scripts, styles }
179}
180
181/// Renders a CSP policy template, expanding the `{script_hashes}` and
182/// `{style_hashes}` slots into space-prefixed `'sha256-…'` sources.
183///
184/// Empty slices render to an empty string, so a template rendered
185/// with no hashes reduces to its hash-free form (for
186/// [`DEFAULT_CSP_POLICY_TEMPLATE`] that is exactly
187/// [`DEFAULT_CSP_POLICY`]). The output never contains
188/// `'unsafe-inline'` unless the template itself does.
189///
190/// # Examples
191///
192/// ```rust
193/// use ssg::csp::{render_policy_template, DEFAULT_CSP_POLICY, DEFAULT_CSP_POLICY_TEMPLATE};
194///
195/// let empty = render_policy_template(DEFAULT_CSP_POLICY_TEMPLATE, &[], &[]);
196/// assert_eq!(empty, DEFAULT_CSP_POLICY);
197///
198/// let one = render_policy_template(
199///     DEFAULT_CSP_POLICY_TEMPLATE,
200///     &["sha256-abc".to_string()],
201///     &[],
202/// );
203/// assert!(one.contains("script-src 'self' 'sha256-abc';"));
204/// ```
205#[must_use]
206pub fn render_policy_template(
207    template: &str,
208    script_hashes: &[String],
209    style_hashes: &[String],
210) -> String {
211    let expand = |hashes: &[String]| -> String {
212        let mut out = String::new();
213        for h in hashes {
214            out.push_str(" '");
215            out.push_str(h);
216            out.push('\'');
217        }
218        out
219    };
220    template
221        .replace("{script_hashes}", &expand(script_hashes))
222        .replace("{style_hashes}", &expand(style_hashes))
223}
224
225/// Computes the per-page Content-Security-Policy for a built HTML
226/// page, or `None` when the page has no inline blocks and the global
227/// [`computed_policy`] applies unchanged (spec B4).
228///
229/// The returned policy is [`DEFAULT_CSP_POLICY_TEMPLATE`] rendered
230/// with the page's inline SHA-256 source hashes — hash-strict, never
231/// containing `'unsafe-inline'`.
232///
233/// # Examples
234///
235/// ```rust
236/// use ssg::csp::page_policy;
237///
238/// assert!(page_policy("<html><head></head><body></body></html>").is_none());
239///
240/// let html = r#"<script type="application/ld+json">{"@type":"Thing"}</script>"#;
241/// let policy = page_policy(html).expect("inline JSON-LD yields a policy");
242/// assert!(policy.contains("'sha256-"));
243/// assert!(!policy.contains("unsafe-inline"));
244/// ```
245#[must_use]
246pub fn page_policy(html: &str) -> Option<String> {
247    let hashes = page_inline_hashes(html);
248    if hashes.is_empty() {
249        return None;
250    }
251    Some(render_policy_template(
252        DEFAULT_CSP_POLICY_TEMPLATE,
253        &hashes.scripts,
254        &hashes.styles,
255    ))
256}
257
258/// Collects the raw inner contents of every non-empty inline
259/// `<tag>…</tag>` block, in document order. `<script>` elements with
260/// a `src=` attribute are skipped (they are external, not inline).
261/// Inline `<script>` and `<style>` bodies, collected in **one** parse.
262///
263/// `collect_inline_contents` walks the document once per tag, so hashing a
264/// page for CSP cost two full parses — the parser is the expensive part, and
265/// paying for it twice to read one document is waste, not safety.
266///
267/// Each element accumulates into its own slot and flushes on its end tag.
268/// Text arrives in chunks and adjacent elements would otherwise concatenate,
269/// which would hash two scripts as one and silently admit a policy that
270/// matches neither.
271fn collect_inline_script_and_style(html: &str) -> (Vec<String>, Vec<String>) {
272    use std::cell::RefCell;
273    use std::rc::Rc;
274
275    use lol_html::{element, end_tag, text};
276
277    use crate::util::html_rewriter::rewrite_html;
278
279    type Slot = Rc<RefCell<Option<String>>>;
280    type Sink = Rc<RefCell<Vec<String>>>;
281
282    fn handlers<'a>(
283        tag: &'a str,
284        slot: &Slot,
285        sink: &Sink,
286    ) -> Vec<(
287        std::borrow::Cow<'a, lol_html::Selector>,
288        lol_html::ElementContentHandlers<'a>,
289    )> {
290        let slot_el = Rc::clone(slot);
291        let sink_el = Rc::clone(sink);
292        let slot_tx = Rc::clone(slot);
293
294        let on_el = element!(tag, move |el| {
295            // `src=` means external: there is no inline body to hash.
296            if el.get_attribute("src").is_some() {
297                *slot_el.borrow_mut() = None;
298                return Ok(());
299            }
300            *slot_el.borrow_mut() = Some(String::new());
301            let sink = Rc::clone(&sink_el);
302            let slot = Rc::clone(&slot_el);
303            let _ = el.on_end_tag(end_tag!(move |_end| {
304                if let Some(body) = slot.borrow_mut().take() {
305                    if !body.trim().is_empty() {
306                        sink.borrow_mut().push(body);
307                    }
308                }
309                Ok(())
310            }));
311            Ok(())
312        });
313
314        let on_text = text!(tag, move |chunk| {
315            if let Some(buf) = slot_tx.borrow_mut().as_mut() {
316                buf.push_str(chunk.as_str());
317            }
318            Ok(())
319        });
320
321        vec![on_el, on_text]
322    }
323
324    let script_slot: Slot = Rc::new(RefCell::new(None));
325    let script_sink: Sink = Rc::new(RefCell::new(Vec::new()));
326    let style_slot: Slot = Rc::new(RefCell::new(None));
327    let style_sink: Sink = Rc::new(RefCell::new(Vec::new()));
328
329    let mut all = handlers("script", &script_slot, &script_sink);
330    all.extend(handlers("style", &style_slot, &style_sink));
331
332    let _ = rewrite_html(html, all);
333
334    let scripts = script_sink.borrow().clone();
335    let styles = style_sink.borrow().clone();
336    (scripts, styles)
337}
338
339/// Plugin that extracts inline styles/scripts to external files with SRI.
340///
341/// Runs in `transform_html`, after every `after_compile` hook, so
342/// [`MinifyPlugin`](crate::plugins::MinifyPlugin) has already run and
343/// never sees the files written here. For each HTML file:
344///
345/// 1. Finds `<style>…</style>` and `<script>…</script>` inline blocks
346/// 2. Writes each block to `_csp/<hash>.css` or `_csp/<hash>.js`,
347///    minifying stylesheets with [`minify_css`](crate::plugins::minify_css)
348///    on the way (scripts are written as they are)
349/// 3. Replaces the inline block with a `<link>`/`<script src>` tag
350///    including `integrity` and `crossorigin` attributes — SHA-384 by
351///    default, configurable via `[security] sri_algorithm` in
352///    `ssg.toml` (v0.0.47 plan §3 item 2.3)
353/// 4. Rewrites any `<meta>` CSP tags to remove `'unsafe-inline'`
354///
355/// Blocks with `type="application/ld+json"` or `data-ssg-livereload`
356/// attributes are skipped (structured data / dev-only scripts).
357///
358/// # Computed policy
359///
360/// The single string the plugin posture enforces is exposed via
361/// [`computed_policy`]; downstream HTTP-header emitters (e.g. the
362/// `edge_headers` postprocess plugin) call that function rather than
363/// hardcoding a CSP string of their own, so the policy stays in
364/// lock-step across `<meta>` injection and platform HTTP headers.
365#[derive(Debug, Clone, Copy, Default)]
366pub struct CspPlugin;
367
368impl CspPlugin {
369    /// Creates a new `CspPlugin`.
370    ///
371    /// # Examples
372    ///
373    /// ```rust
374    /// use ssg::csp::CspPlugin;
375    /// use ssg::plugin::Plugin;
376    ///
377    /// let p = CspPlugin::new();
378    /// assert_eq!(p.name(), "csp");
379    /// ```
380    #[must_use]
381    pub const fn new() -> Self {
382        Self
383    }
384}
385
386impl Plugin for CspPlugin {
387    fn name(&self) -> &'static str {
388        "csp"
389    }
390
391    fn has_transform(&self) -> bool {
392        true
393    }
394
395    fn transform_html(
396        &self,
397        html: &str,
398        path: &Path,
399        ctx: &PluginContext,
400    ) -> Result<String, SsgError> {
401        let csp_dir = ctx.site_dir.join("_csp");
402        // `[security] sri_algorithm` from ssg.toml; absent config ⇒
403        // SHA-384 (v0.0.47 plan §3 item 2.3).
404        let sri_algorithm = ctx
405            .config
406            .as_ref()
407            .map_or_else(SriAlgorithm::default, |c| c.security.sri_algorithm);
408        // Extracted assets are referenced root-absolutely. When the site
409        // is published under a sub-path (GitHub Pages project sites, and
410        // any reverse-proxy mount), `/_csp/…` resolves against the domain
411        // root and 404s, taking the whole stylesheet with it. Deriving the
412        // prefix from `base_url` keeps the reference correct at any mount
413        // point without the caller rewriting HTML afterwards.
414        let url_prefix = ctx
415            .config
416            .as_ref()
417            .map_or_else(String::new, |c| base_url_path_prefix(&c.base_url));
418        let (rewritten, extracted) = extract_inline_blocks(
419            html,
420            &csp_dir,
421            &ctx.site_dir,
422            sri_algorithm,
423            &url_prefix,
424        )
425        .map_err(|e| SsgError::io(e, path))?;
426
427        if extracted > 0 {
428            let final_html = remove_unsafe_inline_from_csp(&rewritten);
429            Ok(final_html)
430        } else {
431            Ok(html.to_string())
432        }
433    }
434
435    fn after_compile(&self, ctx: &PluginContext) -> Result<(), SsgError> {
436        if !ctx.site_dir.exists() {
437            return Ok(());
438        }
439
440        // Pre-create _csp/ dir so transform_html writers have the directory
441        let csp_dir = ctx.site_dir.join("_csp");
442        fs::create_dir_all(&csp_dir).with_path(&csp_dir)?;
443
444        Ok(())
445    }
446}
447
448/// Returns the path component of `base_url` as a prefix for
449/// root-absolute asset references, without a trailing slash.
450///
451/// `https://example.com` and `https://example.com/` both yield `""`, so a
452/// site at the domain root keeps emitting `/_csp/…` exactly as before.
453/// `https://example.com/apex` yields `"/apex"`, making the emitted
454/// reference `/apex/_csp/…`.
455pub(crate) fn base_url_path_prefix(base_url: &str) -> String {
456    let without_scheme = base_url
457        .split_once("://")
458        .map_or(base_url, |(_, rest)| rest);
459    let path = without_scheme
460        .find('/')
461        .map_or("", |i| &without_scheme[i..]);
462    let trimmed = path.trim_end_matches('/');
463    if trimmed == "/" {
464        String::new()
465    } else {
466        trimmed.to_string()
467    }
468}
469
470/// Extracts inline `<style>` and `<script>` blocks from HTML.
471///
472/// Returns `(rewritten_html, count_of_extracted_blocks)`.
473fn extract_inline_blocks(
474    html: &str,
475    csp_dir: &Path,
476    site_dir: &Path,
477    sri_algorithm: SriAlgorithm,
478    url_prefix: &str,
479) -> Result<(String, usize)> {
480    let mut result = html.to_string();
481    let mut count = 0;
482    let mut hoisted_links: Vec<String> = Vec::new();
483
484    // Extract <style>…</style> blocks
485    while let Some((before, content, after)) =
486        find_inline_block(&result, "style")
487    {
488        // MinifyPlugin has already run and leaves `<style>` contents alone,
489        // so this is the only place an inline stylesheet can be minified.
490        let content = crate::plugins::minify_css(content);
491        let hash = fnv_hash(content.as_bytes());
492        let filename = format!("{hash:016x}.css");
493        let file_path = csp_dir.join(&filename);
494
495        fs::create_dir_all(csp_dir)?;
496        fs::write(&file_path, content.as_bytes())?;
497
498        let sri = compute_sri(content.as_bytes(), sri_algorithm);
499        let rel_path = file_path
500            .strip_prefix(site_dir)
501            .unwrap_or(&file_path)
502            .to_string_lossy()
503            .replace('\\', "/");
504
505        let link_tag = format!(
506            "<link rel=\"stylesheet\" href=\"{}/{}\" integrity=\"{}\" crossorigin=\"anonymous\">",
507            url_prefix, rel_path, sri
508        );
509
510        // The `<link>` is collected rather than dropped where the
511        // `<style>` stood. Replacing in place put a stylesheet link in
512        // `<body>` whenever the block it replaced was there — which for
513        // every one of the nine published themes it was, twice per page.
514        // HTML_CodeSniffer reports that as WCAG H59.1, and a stylesheet
515        // discovered mid-body is a render-blocking fetch found late, so
516        // it is a loading problem as much as a conformance one.
517        hoisted_links.push(link_tag);
518        result = format!("{before}{after}");
519        count += 1;
520    }
521
522    // Put them at the end of `<head>`, in the order they were found, so
523    // the cascade between them is unchanged. They now precede anything
524    // in `<body>`, which is where a stylesheet belongs: a `<style>` in
525    // the body was already being applied after the head's, and any
526    // theme relying on that ordering would have been relying on
527    // markup the CSP pass was about to rewrite anyway.
528    if !hoisted_links.is_empty() {
529        let block = hoisted_links.concat();
530        // Parse rather than search for `</head>`. Splicing at the first byte
531        // match puts the links inside a commented-out `</head>`, where they
532        // are inert and nothing reports it (ssg#540 is the same fault found
533        // elsewhere). `inject_before_head_close` uses the HTML rewriter and
534        // returns its input unchanged when the document has no head, which
535        // is the one case the fallback below still has to cover.
536        let injected =
537            crate::util::head_dom::inject_before_head_close(&result, &block);
538        if injected == result {
539            // No `<head>`: leave them where a browser will still find
540            // them rather than dropping styling on the floor.
541            result.push_str(&block);
542        } else {
543            result = injected;
544        }
545    }
546
547    // Extract <script>…</script> blocks (skip JSON-LD and livereload)
548    while let Some((before, opening_tag, content, after)) =
549        find_inline_script(&result)
550    {
551        let hash = fnv_hash(content.as_bytes());
552        let filename = format!("{hash:016x}.js");
553        let file_path = csp_dir.join(&filename);
554
555        fs::create_dir_all(csp_dir)?;
556        fs::write(&file_path, content.as_bytes())?;
557
558        let sri = compute_sri(content.as_bytes(), sri_algorithm);
559        let rel_path = file_path
560            .strip_prefix(site_dir)
561            .unwrap_or(&file_path)
562            .to_string_lossy()
563            .replace('\\', "/");
564
565        // Preserve original attributes (e.g. type=module, async, defer,
566        // data-*), but override src/integrity/crossorigin since we are
567        // extracting the body to a new file and computing fresh SRI.
568        let preserved = preserve_script_attrs(
569            &opening_tag,
570            &["src", "integrity", "crossorigin"],
571        );
572        let script_tag = if preserved.is_empty() {
573            format!(
574                "<script src=\"{url_prefix}/{rel_path}\" integrity=\"{sri}\" crossorigin=\"anonymous\"></script>"
575            )
576        } else {
577            format!(
578                "<script {preserved} src=\"{url_prefix}/{rel_path}\" integrity=\"{sri}\" crossorigin=\"anonymous\"></script>"
579            )
580        };
581
582        result = format!("{before}{script_tag}{after}");
583        count += 1;
584    }
585
586    Ok((result, count))
587}
588
589/// Parses attributes out of an opening `<script …>` tag and returns a
590/// space-separated, normalised attribute string with `drop` attributes
591/// removed. Boolean attributes (no `=`) are emitted unchanged.
592///
593/// Uses `lol_html` so quoting, whitespace, and case-folding all match
594/// the HTML5 parser exactly — this is what the rest of the SSG
595/// pipeline relies on for tag rewriting.
596fn preserve_script_attrs(opening_tag: &str, drop: &[&str]) -> String {
597    use crate::util::html_rewriter::rewrite_html;
598    use lol_html::element;
599    use std::cell::RefCell;
600    use std::rc::Rc;
601
602    // lol_html needs a closed element to fire the handler; wrap the
603    // opening tag with an explicit close.
604    let fragment = format!("{opening_tag}</script>");
605    let collected: Rc<RefCell<Vec<(String, String)>>> =
606        Rc::new(RefCell::new(Vec::new()));
607    let collected_cb = Rc::clone(&collected);
608
609    let _ = rewrite_html(
610        &fragment,
611        vec![element!("script", move |el| {
612            for attr in el.attributes() {
613                collected_cb.borrow_mut().push((attr.name(), attr.value()));
614            }
615            Ok(())
616        })],
617    );
618
619    let drop_lower: Vec<String> =
620        drop.iter().map(|d| d.to_ascii_lowercase()).collect();
621
622    let parts: Vec<String> = collected
623        .borrow()
624        .iter()
625        .filter(|(name, _)| !drop_lower.contains(&name.to_ascii_lowercase()))
626        .map(|(name, value)| {
627            if value.is_empty() {
628                name.clone()
629            } else {
630                let escaped = value.replace('"', "&quot;");
631                format!("{name}=\"{escaped}\"")
632            }
633        })
634        .collect();
635    parts.join(" ")
636}
637
638/// Finds the first inline `<style>…</style>` block and returns
639/// `(html_before, style_content, html_after)`.
640fn find_inline_block<'a>(
641    html: &'a str,
642    tag: &str,
643) -> Option<(&'a str, &'a str, &'a str)> {
644    let open = format!("<{tag}>");
645    let close = format!("</{tag}>");
646
647    // Same hazard as `find_inline_script`: a commented-out block would be
648    // hoisted into a real external file and the page rewritten around it.
649    let comments = comment_spans(html);
650    let mut from = 0;
651    let start = loop {
652        let rel = html[from..].find(&open)?;
653        let abs = from + rel;
654        if inside_comment(&comments, abs) {
655            from = abs + open.len();
656            continue;
657        }
658        break abs;
659    };
660    let content_start = start + open.len();
661    let content_end = html[content_start..].find(&close)? + content_start;
662    let end = content_end + close.len();
663
664    let content = &html[content_start..content_end];
665    if content.trim().is_empty() {
666        return None;
667    }
668
669    Some((&html[..start], content, &html[end..]))
670}
671
672/// Finds the first inline `<script>…</script>` block, skipping:
673/// - `<script type="application/ld+json">` (structured data)
674/// - `<script data-ssg-livereload>` (dev-only)
675/// - `<script src="...">` (already external)
676///
677/// Returns `(before, opening_tag, content, after)` where `opening_tag`
678/// is the full `<script …>` including angle brackets — the caller uses
679/// it to preserve attributes such as `type=module`, `async`, `defer`,
680/// and `data-*` when rewriting the tag.
681/// Byte ranges covered by HTML comments, in document order.
682///
683/// The inline-block extractors reassemble the document by string surgery, so
684/// they cannot be swapped for a streaming parser without restructuring the
685/// whole extract-and-replace flow — on CSP/SRI code, which is not a change to
686/// make in passing. Masking the comment spans fixes the demonstrated defect
687/// exactly: a commented-out `<script>` was hoisted into a real external file
688/// and the page rewritten around it (ssg#570).
689fn comment_spans(html: &str) -> Vec<(usize, usize)> {
690    let bytes = html.as_bytes();
691    let mut spans = Vec::new();
692    let mut i = 0;
693    while let Some(rel) = html[i..].find("<!--") {
694        let start = i + rel;
695        let after = start + 4;
696        let end = html[after..]
697            .find("-->")
698            .map_or(bytes.len(), |r| after + r + 3);
699        spans.push((start, end));
700        i = end;
701        if i >= bytes.len() {
702            break;
703        }
704    }
705    spans
706}
707
708/// True when `pos` falls inside an HTML comment.
709fn inside_comment(spans: &[(usize, usize)], pos: usize) -> bool {
710    spans.iter().any(|&(s, e)| pos >= s && pos < e)
711}
712
713fn find_inline_script(html: &str) -> Option<(String, String, String, String)> {
714    let comments = comment_spans(html);
715    let mut search_from = 0;
716
717    loop {
718        let rest = &html[search_from..];
719        let start = rest.find("<script")?;
720        let abs_start = search_from + start;
721
722        // A `<script` inside a comment is not a script.
723        if inside_comment(&comments, abs_start) {
724            search_from = abs_start + "<script".len();
725            continue;
726        }
727
728        // Find the end of the opening tag
729        let tag_end = html[abs_start..].find('>')? + abs_start;
730        let opening_tag = &html[abs_start..=tag_end];
731
732        // Skip JSON-LD, livereload, and already-external scripts
733        if opening_tag.contains("application/ld+json")
734            || opening_tag.contains("data-ssg-livereload")
735            || opening_tag.contains("src=")
736        {
737            search_from = tag_end + 1;
738            continue;
739        }
740
741        let content_start = tag_end + 1;
742        let close_tag = "</script>";
743        let content_end =
744            html[content_start..].find(close_tag)? + content_start;
745        let end = content_end + close_tag.len();
746
747        let content = &html[content_start..content_end];
748        if content.trim().is_empty() {
749            search_from = end;
750            continue;
751        }
752
753        return Some((
754            html[..abs_start].to_string(),
755            opening_tag.to_string(),
756            content.to_string(),
757            html[end..].to_string(),
758        ));
759    }
760}
761
762/// Removes `'unsafe-inline'` from CSP `<meta>` tags in HTML.
763///
764/// Scoped to the policy value itself. A plain `html.replace` matched the token
765/// anywhere on the page, so a document that quoted `'unsafe-inline'` while
766/// explaining a policy — exactly what a security or documentation page does —
767/// had the words silently deleted from its prose.
768///
769/// The token is removed together with the whitespace that separated it. Only
770/// dropping the text turned `'self' 'unsafe-inline' 'unsafe-eval'` into
771/// `'self'  'unsafe-eval'`, and the old `"  ;" -> " ;"` pass tidied just the
772/// case where the token ended a directive.
773fn remove_unsafe_inline_from_csp(html: &str) -> String {
774    let mut out = String::with_capacity(html.len());
775    let mut rest = html;
776
777    while let Some(start) = find_csp_meta_content(rest) {
778        let (before, from_quote) = rest.split_at(start);
779        let Some(quote) = from_quote.chars().next() else {
780            break;
781        };
782        let Some(end_rel) = from_quote[1..].find(quote) else {
783            break;
784        };
785        let value = &from_quote[1..=end_rel];
786
787        out.push_str(before);
788        out.push(quote);
789        out.push_str(&strip_unsafe_inline_token(value));
790        out.push(quote);
791
792        rest = &from_quote[end_rel + 2..];
793    }
794
795    out.push_str(rest);
796    out
797}
798
799/// Removes the `'unsafe-inline'` source expression from one policy string,
800/// collapsing the separator it leaves behind.
801fn strip_unsafe_inline_token(policy: &str) -> String {
802    policy
803        .split(';')
804        .map(|directive| {
805            let kept: Vec<&str> = directive
806                .split_whitespace()
807                .filter(|t| *t != "'unsafe-inline'")
808                .collect();
809            if kept.is_empty() {
810                String::new()
811            } else {
812                // These policies are written with one space after each `;`,
813                // so keeping it means only the removed token changes.
814                let lead = if directive.starts_with(' ') { " " } else { "" };
815                format!("{lead}{}", kept.join(" "))
816            }
817        })
818        .collect::<Vec<_>>()
819        .join(";")
820}
821
822/// Locates the opening quote of a CSP `<meta>` tag's `content` attribute.
823///
824/// Returns an index into `html` pointing at the quote character itself, so the
825/// caller can read the value without re-parsing the tag.
826fn find_csp_meta_content(html: &str) -> Option<usize> {
827    let mut search_from = 0usize;
828    loop {
829        let tag_rel = html[search_from..].find("<meta")?;
830        let tag_start = search_from + tag_rel;
831        let tag_end = html[tag_start..].find('>').map(|i| tag_start + i)?;
832        let tag = &html[tag_start..tag_end];
833
834        if tag.to_ascii_lowercase().contains("content-security-policy") {
835            if let Some(attr_rel) = tag.find("content=") {
836                let after = tag_start + attr_rel + "content=".len();
837                if matches!(html.as_bytes().get(after), Some(b'"' | b'\'')) {
838                    return Some(after);
839                }
840            }
841        }
842        search_from = tag_end;
843    }
844}
845
846/// Directives a `<meta http-equiv>` policy must not carry.
847///
848/// The CSP specification only honours these when the policy arrives as
849/// an HTTP header. Delivered in a `<meta>` element a browser ignores
850/// them *and says so*: Chrome logs
851///
852/// > The Content Security Policy directive 'frame-ancestors' is ignored
853/// > when delivered via a `<meta>` element.
854///
855/// which is a console error on every page — Lighthouse fails
856/// `errors-in-console`, and the protection the directive was there to
857/// provide was never in force. Emitting it is worse than omitting it,
858/// because the policy reads as though the site is protected.
859///
860/// They stay in the policy used for header delivery, where they work.
861const META_INELIGIBLE_DIRECTIVES: [&str; 4] =
862    ["frame-ancestors", "report-uri", "report-to", "sandbox"];
863
864/// Strips the directives a `<meta>`-delivered policy cannot carry.
865///
866/// # Examples
867///
868/// ```
869/// use ssg::csp::policy_for_meta;
870///
871/// let p = "default-src 'self'; frame-ancestors 'none'";
872/// assert_eq!(policy_for_meta(p), "default-src 'self'");
873/// ```
874#[must_use]
875pub fn policy_for_meta(policy: &str) -> String {
876    policy
877        .split(';')
878        .map(str::trim)
879        .filter(|d| !d.is_empty())
880        .filter(|d| {
881            let name = d.split_whitespace().next().unwrap_or("");
882            !META_INELIGIBLE_DIRECTIVES
883                .iter()
884                .any(|bad| name.eq_ignore_ascii_case(bad))
885        })
886        .collect::<Vec<_>>()
887        .join("; ")
888}
889
890/// Inserts a `<meta http-equiv="Content-Security-Policy" content="...">`
891/// tag immediately after the `<head>` opening tag.
892///
893/// Uses `lol_html` so the insertion point is the correct one regardless
894/// of whitespace, comments, or `<title>` placement inside the head
895/// (issue #525 AC7). If the document already contains a CSP meta tag
896/// (either matching `policy` exactly or any other CSP policy), the
897/// input is returned unchanged so successive calls are idempotent.
898/// If no `<head>` element exists in the input, the function returns
899/// the input verbatim — this matches the convention used by the
900/// rest of the SSG HTML post-processors (no implicit head injection).
901///
902/// # Examples
903///
904/// ```rust
905/// use ssg::csp::inject_csp_meta;
906///
907/// let html = "<html><head><title>t</title></head></html>";
908/// let out = inject_csp_meta(html, "default-src 'self'");
909/// assert!(out.contains("Content-Security-Policy"));
910/// ```
911///
912/// # Errors
913///
914/// Returns the input unchanged when the underlying `lol_html` rewrite
915/// fails; in practice the only failure mode is allocation exhaustion.
916#[must_use]
917pub fn inject_csp_meta(html: &str, policy: &str) -> String {
918    use crate::util::html_rewriter::rewrite_html;
919    use lol_html::element;
920    use lol_html::html_content::ContentType;
921    use std::cell::Cell;
922    use std::rc::Rc;
923
924    // Idempotency: if a CSP meta already exists anywhere in the
925    // document, do nothing. lol_html visits comments-aware so this
926    // false-positive-free.
927    let already_present = Rc::new(Cell::new(false));
928    let already_present_cb = Rc::clone(&already_present);
929    let detect = element!(
930        "meta[http-equiv=\"Content-Security-Policy\" i]",
931        move |_el| {
932            already_present_cb.set(true);
933            Ok(())
934        }
935    );
936    let _ = rewrite_html(html, vec![detect]);
937    if already_present.get() {
938        return html.to_string();
939    }
940
941    // Header-only directives are dropped here, not upstream: the same
942    // policy is still correct for header delivery.
943    let policy = policy_for_meta(policy);
944    let injected = Rc::new(Cell::new(false));
945    let injected_cb = Rc::clone(&injected);
946    let head_handler = element!("head", move |el| {
947        let meta = format!(
948            "<meta http-equiv=\"Content-Security-Policy\" content=\"{policy}\">"
949        );
950        el.prepend(&meta, ContentType::Html);
951        injected_cb.set(true);
952        Ok(())
953    });
954
955    rewrite_or_original(html, rewrite_html(html, vec![head_handler]))
956}
957
958/// Unwraps a rewrite result, returning the original HTML unchanged
959/// when `lol_html` failed (in practice only allocation exhaustion).
960fn rewrite_or_original(html: &str, res: Result<String, SsgError>) -> String {
961    res.unwrap_or_else(|_| html.to_string())
962}
963
964/// FNV-1a 64-bit hash.
965fn fnv_hash(data: &[u8]) -> u64 {
966    let mut h: u64 = 0xcbf2_9ce4_8422_2325;
967    for &b in data {
968        h ^= u64::from(b);
969        h = h.wrapping_mul(0x0000_0100_0000_01b3);
970    }
971    h
972}
973
974/// Computes an SRI attribute value: `<algo>-<base64(digest)>`,
975/// SHA-384 by default.
976///
977/// IMPORTANT DISTINCTION (v0.0.47 plan §3 item 2.3): this algorithm
978/// knob governs only the `integrity=` **attribute** on externalized
979/// assets. CSP **directive source hashes** — the `'sha256-…'` entries
980/// inside a Content-Security-Policy header/meta value — must stay
981/// SHA-256 for the broadest UA compatibility. SRI attributes and CSP
982/// source hashes are different mechanisms; do not route CSP directive
983/// hashes through this function's configurable algorithm.
984fn compute_sri(data: &[u8], sri_algorithm: SriAlgorithm) -> String {
985    sri_algorithm.integrity(data)
986}
987
988#[cfg(test)]
989mod tests {
990
991    /// The `<style>` extractor carries the same hazard as the script one.
992    #[test]
993    fn inline_block_extraction_skips_a_commented_block() {
994        let html = concat!(
995            "<html><head>",
996            "<!-- <style>.commented{}</style> -->",
997            "<style>.real{}</style>",
998            "</head><body></body></html>"
999        );
1000        let (_, content, _) =
1001            find_inline_block(html, "style").expect("real style found");
1002        assert_eq!(
1003            content.trim(),
1004            ".real{}",
1005            "a commented-out style must not be hoisted: {content:?}"
1006        );
1007    }
1008
1009    /// ssg#570, second shape: the extractors that hoist inline blocks into
1010    /// external files scan for the same literal bytes. Matching a
1011    /// commented-out block would hoist the comment's body into a real file
1012    /// and rewrite the page around it — corrupting the document, not merely
1013    /// adding a spurious hash.
1014    #[test]
1015    fn inline_script_extraction_skips_a_commented_block() {
1016        let html = concat!(
1017            "<html><head>",
1018            "<!-- <script>commented()</script> -->",
1019            "<script>real()</script>",
1020            "</head><body></body></html>"
1021        );
1022        let found = find_inline_script(html);
1023        assert!(found.is_some(), "the real script should still be found");
1024        let (_, _, content, _) = found.unwrap();
1025        assert_eq!(
1026            content.trim(),
1027            "real()",
1028            "a commented-out script must not be hoisted: {content:?}"
1029        );
1030    }
1031
1032    /// ssg#570: a `<script>` inside an HTML comment is not a script. The
1033    /// byte scan collects it anyway, so CSP gains a hash for code that can
1034    /// never execute — and the policy silently drifts from the document it
1035    /// is meant to describe.
1036    #[test]
1037    fn inline_collection_skips_a_script_inside_a_comment() {
1038        let html = concat!(
1039            "<html><head>",
1040            "<!-- <script>commented()</script> -->",
1041            "<script>real()</script>",
1042            "</head><body></body></html>"
1043        );
1044        let found = collect_inline_script_and_style(html).0;
1045        assert_eq!(
1046            found,
1047            vec!["real()"],
1048            "a commented-out script must not be hashed: {found:?}"
1049        );
1050    }
1051
1052    use super::*;
1053    use tempfile::tempdir;
1054
1055    /// `frame-ancestors` in a `<meta>` policy is ignored by every
1056    /// browser and logged as a console error, so the protection was
1057    /// never in force while the policy read as though it were.
1058    #[test]
1059    fn a_meta_policy_drops_directives_meta_cannot_carry() {
1060        let out = policy_for_meta(DEFAULT_CSP_POLICY);
1061        assert!(
1062            !out.contains("frame-ancestors"),
1063            "frame-ancestors must not reach a meta policy: {out}"
1064        );
1065        for kept in ["default-src", "script-src", "style-src", "img-src"] {
1066            assert!(out.contains(kept), "{kept} must survive: {out}");
1067        }
1068        assert!(!out.ends_with(';'), "no dangling separator: {out}");
1069    }
1070
1071    #[test]
1072    fn policy_for_meta_drops_every_header_only_directive() {
1073        let p = "default-src 'self'; frame-ancestors 'none'; \
1074                 report-uri /r; report-to grp; sandbox allow-forms";
1075        assert_eq!(policy_for_meta(p), "default-src 'self'");
1076    }
1077
1078    /// The injected tag itself must be clean, not merely the helper.
1079    #[test]
1080    fn the_injected_meta_tag_carries_no_frame_ancestors() {
1081        let html = "<html><head><title>t</title></head><body></body></html>";
1082        let out = inject_csp_meta(html, DEFAULT_CSP_POLICY);
1083        assert!(out.contains("Content-Security-Policy"), "{out}");
1084        assert!(
1085            !out.contains("frame-ancestors"),
1086            "the emitted tag must not carry it: {out}"
1087        );
1088    }
1089
1090    /// A `<style>` block in the body used to leave its replacement
1091    /// `<link rel="stylesheet">` in the body. Every one of the nine
1092    /// published themes did exactly that, twice per page, and pa11y
1093    /// reported all eighteen as WCAG H59.1. A stylesheet found mid-body
1094    /// is also a render-blocking fetch discovered late.
1095    #[test]
1096    fn an_extracted_stylesheet_link_is_hoisted_into_head() {
1097        let dir = tempdir().expect("tempdir");
1098        let site = dir.path();
1099        let html = concat!(
1100            "<html><head><title>t</title></head>",
1101            "<body><p>copy</p><style>body{margin:0}</style></body></html>"
1102        );
1103        let (out, n) = extract_inline_blocks(
1104            html,
1105            &site.join("_csp"),
1106            site,
1107            SriAlgorithm::Sha384,
1108            "",
1109        )
1110        .expect("extract");
1111
1112        assert_eq!(n, 1, "one block should have been extracted");
1113        let head_end = out.find("</head>").expect("head");
1114        let link = out.find("<link rel=\"stylesheet\"").expect("link emitted");
1115        assert!(
1116            link < head_end,
1117            "the stylesheet link must land inside <head>, got: {out}"
1118        );
1119        assert!(
1120            !out[out.find("<body").unwrap()..]
1121                .contains("<link rel=\"stylesheet\""),
1122            "no stylesheet link may remain in <body>: {out}"
1123        );
1124    }
1125
1126    /// A `</head>` inside a comment is not the head's end tag.
1127    ///
1128    /// The hoist spliced at the first byte match of the literal string, so a
1129    /// commented-out `</head>` earlier in the document captured the links:
1130    /// they land inside the comment, inert, and the page loses its styling
1131    /// with no error anywhere. `head_dom` already parses instead of
1132    /// searching, and ssg#540 records the same fault being fixed there;
1133    /// this path was still doing it by hand.
1134    #[test]
1135    fn hoisted_links_ignore_a_head_close_inside_a_comment() {
1136        let dir = tempdir().expect("tempdir");
1137        let site = dir.path();
1138        let html = concat!(
1139            "<html><head><!-- </head> --><title>T</title></head><body>",
1140            "<style>.a{color:red}</style>",
1141            "</body></html>"
1142        );
1143        let (out, n) = extract_inline_blocks(
1144            html,
1145            &site.join("_csp"),
1146            site,
1147            SriAlgorithm::Sha384,
1148            "",
1149        )
1150        .expect("extract");
1151        assert_eq!(n, 1);
1152
1153        let link = out
1154            .find("<link rel=\"stylesheet\"")
1155            .expect("a stylesheet link was emitted");
1156        let comment_end = out.find("-->").expect("the comment survives");
1157        assert!(
1158            link > comment_end,
1159            "the link was spliced inside the comment, where it does nothing:\n{out}"
1160        );
1161    }
1162
1163    /// Two blocks keep their relative order, so the cascade between
1164    /// them survives the move.
1165    #[test]
1166    fn hoisted_links_keep_their_relative_order() {
1167        let dir = tempdir().expect("tempdir");
1168        let site = dir.path();
1169        let html = concat!(
1170            "<html><head></head><body>",
1171            "<style>.a{color:red}</style>",
1172            "<style>.b{color:blue}</style>",
1173            "</body></html>"
1174        );
1175        let (out, n) = extract_inline_blocks(
1176            html,
1177            &site.join("_csp"),
1178            site,
1179            SriAlgorithm::Sha384,
1180            "",
1181        )
1182        .expect("extract");
1183        assert_eq!(n, 2);
1184        // The file named first in the document must be linked first.
1185        let links: Vec<&str> = out
1186            .match_indices("<link rel=\"stylesheet\"")
1187            .map(|(i, _)| &out[i..i + 90])
1188            .collect();
1189        assert_eq!(links.len(), 2, "both links present: {out}");
1190        let a = fs::read_to_string(
1191            site.join("_csp").join(
1192                links[0]
1193                    .split("href=\"/")
1194                    .nth(1)
1195                    .and_then(|s| s.split('"').next())
1196                    .and_then(|s| s.rsplit('/').next())
1197                    .expect("first href"),
1198            ),
1199        )
1200        .expect("first file");
1201        assert!(a.contains("red"), "first link should be the first style");
1202    }
1203
1204    #[test]
1205    fn extract_style_block() {
1206        let html = "<html><head><style>body { color: red; }</style></head><body></body></html>";
1207        let dir = tempdir().unwrap();
1208        let csp_dir = dir.path().join("_csp");
1209
1210        let (result, count) = extract_inline_blocks(
1211            html,
1212            &csp_dir,
1213            dir.path(),
1214            SriAlgorithm::default(),
1215            "",
1216        )
1217        .unwrap();
1218
1219        assert_eq!(count, 1);
1220        assert!(result.contains("<link rel=\"stylesheet\""));
1221        // Default SRI algorithm is SHA-384 (v0.0.47 plan §3 item 2.3).
1222        assert!(result.contains("integrity=\"sha384-"));
1223        assert!(!result.contains("<style>"));
1224    }
1225
1226    #[test]
1227    fn an_extracted_stylesheet_is_minified() {
1228        // MinifyPlugin runs in `after_compile`, before this transform, and
1229        // leaves `<style>` contents alone, so nothing else ever minifies a
1230        // stylesheet that starts inline. One theme's came out at 210 KB.
1231        let css = "/* note */\nbody {\n  color: red;\n}\n";
1232        let html = format!(
1233            "<html><head><style>{css}</style></head><body></body></html>"
1234        );
1235        let dir = tempdir().unwrap();
1236        let csp_dir = dir.path().join("_csp");
1237
1238        let (result, count) = extract_inline_blocks(
1239            &html,
1240            &csp_dir,
1241            dir.path(),
1242            SriAlgorithm::default(),
1243            "",
1244        )
1245        .unwrap();
1246
1247        assert_eq!(count, 1);
1248        let written: Vec<String> = fs::read_dir(&csp_dir)
1249            .unwrap()
1250            .map(|e| fs::read_to_string(e.unwrap().path()).unwrap())
1251            .collect();
1252        assert_eq!(written, vec![crate::plugins::minify_css(css)]);
1253        assert!(!written[0].contains("note"), "comment kept: {}", written[0]);
1254        // The integrity value covers the bytes on disk, not the inline ones.
1255        let sri = compute_sri(written[0].as_bytes(), SriAlgorithm::default());
1256        assert!(
1257            result.contains(&sri),
1258            "SRI does not match the file: {result}"
1259        );
1260    }
1261
1262    #[test]
1263    fn extract_style_block_default_sha384_exact_vector() {
1264        // Known vector: the style body is minified to
1265        // `body{color:red;}` before it is written, so the integrity value
1266        // is exactly base64(SHA-384("body{color:red;}")).
1267        let html = "<html><head><style>body { color: red; }</style></head><body></body></html>";
1268        let dir = tempdir().unwrap();
1269        let csp_dir = dir.path().join("_csp");
1270
1271        let (result, count) = extract_inline_blocks(
1272            html,
1273            &csp_dir,
1274            dir.path(),
1275            SriAlgorithm::default(),
1276            "",
1277        )
1278        .unwrap();
1279
1280        assert_eq!(count, 1);
1281        assert!(
1282            result.contains(
1283                "integrity=\"sha384-3x51QY511qAMtrqY6a1lNefFvJ68/Z1IQtkFiRp4Y0PBSanRyJCC0HCjgr1zcYV2\""
1284            ),
1285            "expected exact SHA-384 SRI vector; got: {result}"
1286        );
1287    }
1288
1289    #[test]
1290    fn base_url_path_prefix_extracts_sub_path_mount_points() {
1291        assert_eq!(base_url_path_prefix("https://example.com"), "");
1292        assert_eq!(base_url_path_prefix("https://example.com/"), "");
1293        assert_eq!(base_url_path_prefix("https://example.com/apex"), "/apex");
1294        assert_eq!(base_url_path_prefix("https://example.com/apex/"), "/apex");
1295        assert_eq!(base_url_path_prefix("https://e.com/a/b/"), "/a/b");
1296        // Missing scheme still yields the path component.
1297        assert_eq!(base_url_path_prefix("example.com/apex"), "/apex");
1298    }
1299
1300    /// Regression: a site published under a sub-path emitted
1301    /// `href="/_csp/…"`, which resolves against the domain root and 404s,
1302    /// so the extracted stylesheet never loaded on GitHub Pages project
1303    /// sites.
1304    #[test]
1305    fn extracted_assets_are_prefixed_for_sub_path_deploys() {
1306        let dir = tempdir().unwrap();
1307        let site = dir.path().join("site");
1308        let csp = site.join("_csp");
1309        fs::create_dir_all(&csp).unwrap();
1310
1311        let (out, count) = extract_inline_blocks(
1312            "<style>body{color:red}</style><script>var x=1;</script>",
1313            &csp,
1314            &site,
1315            SriAlgorithm::default(),
1316            "/apex",
1317        )
1318        .unwrap();
1319
1320        assert_eq!(count, 2);
1321        assert!(
1322            out.contains("href=\"/apex/_csp/"),
1323            "stylesheet must carry the sub-path prefix: {out}"
1324        );
1325        assert!(
1326            out.contains("src=\"/apex/_csp/"),
1327            "script must carry the sub-path prefix: {out}"
1328        );
1329        assert!(
1330            !out.contains("href=\"/_csp/"),
1331            "no unprefixed root-absolute reference may survive: {out}"
1332        );
1333    }
1334
1335    #[test]
1336    fn transform_html_sri_algorithm_config_override_emits_sha256() {
1337        // `[security] sri_algorithm = "sha256"` back-compat knob
1338        // (v0.0.47 plan §3 item 2.3), wired through PluginContext
1339        // config: exact SHA-256 vector for "body{color:red;}", the
1340        // minified "body { color: red; }".
1341        use crate::cmd::{SecurityConfig, SsgConfig};
1342
1343        let html = "<html><head><style>body { color: red; }</style></head><body></body></html>";
1344        let dir = tempdir().unwrap();
1345        let site = dir.path().join("site");
1346        fs::create_dir_all(&site).unwrap();
1347
1348        let config = SsgConfig::builder()
1349            .security(SecurityConfig {
1350                sri_algorithm: SriAlgorithm::Sha256,
1351            })
1352            .build()
1353            .unwrap();
1354        let ctx = PluginContext::with_config(
1355            dir.path(),
1356            dir.path(),
1357            &site,
1358            dir.path(),
1359            config,
1360        );
1361        let out = CspPlugin
1362            .transform_html(html, &site.join("index.html"), &ctx)
1363            .unwrap();
1364        assert!(
1365            out.contains(
1366                "integrity=\"sha256-qGHujruJPON+QdsJZpjz7gs+HNZ4zSFz6Onf591nLc0=\""
1367            ),
1368            "expected exact SHA-256 SRI vector; got: {out}"
1369        );
1370        assert!(!out.contains("sha384-"), "override must win: {out}");
1371    }
1372
1373    #[test]
1374    fn extract_script_block() {
1375        let html =
1376            "<html><body><script>console.log('hi');</script></body></html>";
1377        let dir = tempdir().unwrap();
1378        let csp_dir = dir.path().join("_csp");
1379
1380        let (result, count) = extract_inline_blocks(
1381            html,
1382            &csp_dir,
1383            dir.path(),
1384            SriAlgorithm::default(),
1385            "",
1386        )
1387        .unwrap();
1388
1389        assert_eq!(count, 1);
1390        assert!(result.contains("<script src="));
1391        // Default SRI algorithm is SHA-384 (v0.0.47 plan §3 item 2.3).
1392        assert!(result.contains("integrity=\"sha384-"));
1393        assert!(!result.contains("console.log"));
1394    }
1395
1396    #[test]
1397    fn skips_jsonld_scripts() {
1398        let html = r#"<html><body><script type="application/ld+json">{"@type":"Thing"}</script></body></html>"#;
1399        let dir = tempdir().unwrap();
1400        let csp_dir = dir.path().join("_csp");
1401
1402        let (result, count) = extract_inline_blocks(
1403            html,
1404            &csp_dir,
1405            dir.path(),
1406            SriAlgorithm::default(),
1407            "",
1408        )
1409        .unwrap();
1410
1411        assert_eq!(count, 0);
1412        assert!(result.contains("application/ld+json"));
1413    }
1414
1415    #[test]
1416    fn skips_livereload_scripts() {
1417        let html = r#"<html><body><script data-ssg-livereload>ws.connect();</script></body></html>"#;
1418        let dir = tempdir().unwrap();
1419        let csp_dir = dir.path().join("_csp");
1420
1421        let (result, count) = extract_inline_blocks(
1422            html,
1423            &csp_dir,
1424            dir.path(),
1425            SriAlgorithm::default(),
1426            "",
1427        )
1428        .unwrap();
1429
1430        assert_eq!(count, 0);
1431        assert!(result.contains("data-ssg-livereload"));
1432    }
1433
1434    #[test]
1435    fn skips_external_scripts() {
1436        let html =
1437            r#"<html><body><script src="/app.js"></script></body></html>"#;
1438        let dir = tempdir().unwrap();
1439        let csp_dir = dir.path().join("_csp");
1440
1441        let (result, count) = extract_inline_blocks(
1442            html,
1443            &csp_dir,
1444            dir.path(),
1445            SriAlgorithm::default(),
1446            "",
1447        )
1448        .unwrap();
1449
1450        assert_eq!(count, 0);
1451        assert_eq!(result, html);
1452    }
1453
1454    #[test]
1455    fn removes_unsafe_inline_from_csp() {
1456        let html = r#"<meta http-equiv="Content-Security-Policy" content="script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'">"#;
1457        let result = remove_unsafe_inline_from_csp(html);
1458        assert!(!result.contains("unsafe-inline"));
1459    }
1460
1461    #[test]
1462    fn removing_unsafe_inline_leaves_no_double_space() {
1463        // The token sits between two others, so dropping just the text used to
1464        // leave `'self'  'unsafe-eval'` behind in every built page.
1465        let html = r#"<meta http-equiv="Content-Security-Policy" content="script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline' https://example.com">"#;
1466        let result = remove_unsafe_inline_from_csp(html);
1467        assert!(!result.contains("  "), "double space in {result}");
1468        assert!(result.contains("script-src 'self' 'unsafe-eval';"));
1469        assert!(result.contains("style-src 'self' https://example.com"));
1470    }
1471
1472    #[test]
1473    fn leaves_prose_mentioning_unsafe_inline_alone() {
1474        // A page explaining a policy quotes the token in its own text. The
1475        // previous whole-document replace deleted it from the prose.
1476        let html = concat!(
1477            r#"<meta http-equiv="Content-Security-Policy" content="script-src 'self' 'unsafe-inline'">"#,
1478            r#"<p>Set <code>script-src 'self' 'unsafe-inline'</code> to allow it.</p>"#
1479        );
1480        let result = remove_unsafe_inline_from_csp(html);
1481        assert!(
1482            result.contains("<code>script-src 'self' 'unsafe-inline'</code>"),
1483            "prose was rewritten: {result}"
1484        );
1485        // ...while the policy itself is still stripped.
1486        let meta_end = result.find("<p>").expect("prose follows the meta");
1487        assert!(!result[..meta_end].contains("unsafe-inline"));
1488    }
1489
1490    #[test]
1491    fn leaves_documents_without_a_csp_meta_untouched() {
1492        let html = r#"<p>The token 'unsafe-inline' weakens a policy.</p>"#;
1493        assert_eq!(remove_unsafe_inline_from_csp(html), html);
1494    }
1495
1496    #[test]
1497    fn skips_empty_style_blocks() {
1498        let html = "<html><head><style>  </style></head></html>";
1499        let dir = tempdir().unwrap();
1500        let csp_dir = dir.path().join("_csp");
1501
1502        let (_, count) = extract_inline_blocks(
1503            html,
1504            &csp_dir,
1505            dir.path(),
1506            SriAlgorithm::default(),
1507            "",
1508        )
1509        .unwrap();
1510        assert_eq!(count, 0);
1511    }
1512
1513    #[test]
1514    fn csp_plugin_name() {
1515        assert_eq!(CspPlugin.name(), "csp");
1516    }
1517
1518    #[test]
1519    fn csp_plugin_skips_missing_site_dir() {
1520        let ctx = PluginContext::new(
1521            Path::new("/tmp/c"),
1522            Path::new("/tmp/b"),
1523            Path::new("/nonexistent/site"),
1524            Path::new("/tmp/t"),
1525        );
1526        assert!(CspPlugin.after_compile(&ctx).is_ok());
1527    }
1528
1529    #[test]
1530    fn csp_plugin_processes_html_files() {
1531        let dir = tempdir().unwrap();
1532        let site = dir.path().join("site");
1533        fs::create_dir_all(&site).unwrap();
1534        let html = "<html><head><style>body{color:red}</style></head><body><script>alert(1)</script></body></html>";
1535
1536        let ctx = PluginContext::new(dir.path(), dir.path(), &site, dir.path());
1537        CspPlugin.after_compile(&ctx).unwrap();
1538
1539        let output = CspPlugin
1540            .transform_html(html, &site.join("index.html"), &ctx)
1541            .unwrap();
1542        assert!(output.contains("<link rel=\"stylesheet\""));
1543        assert!(output.contains("<script src="));
1544        assert!(!output.contains("body{color:red}"));
1545        assert!(!output.contains("alert(1)"));
1546        assert!(site.join("_csp").exists());
1547    }
1548
1549    #[test]
1550    fn fnv_hash_deterministic() {
1551        let h1 = fnv_hash(b"hello");
1552        let h2 = fnv_hash(b"hello");
1553        assert_eq!(h1, h2);
1554    }
1555
1556    #[test]
1557    fn fnv_hash_different_inputs() {
1558        assert_ne!(fnv_hash(b"a"), fnv_hash(b"b"));
1559    }
1560
1561    #[test]
1562    fn compute_sri_format() {
1563        // Default algorithm ⇒ sha384- prefix; explicit sha256/sha512
1564        // overrides carry their own prefixes.
1565        let sri = compute_sri(b"test", SriAlgorithm::default());
1566        assert!(sri.starts_with("sha384-"));
1567        assert!(
1568            compute_sri(b"test", SriAlgorithm::Sha256).starts_with("sha256-")
1569        );
1570        assert!(
1571            compute_sri(b"test", SriAlgorithm::Sha512).starts_with("sha512-")
1572        );
1573    }
1574
1575    // ── CspPlugin::new + inject_csp_meta coverage ───────────────────
1576
1577    #[test]
1578    fn csp_plugin_new_constructs_unit_struct() {
1579        let p = CspPlugin::new();
1580        assert_eq!(p.name(), "csp");
1581        assert!(p.has_transform());
1582    }
1583
1584    #[test]
1585    fn inject_csp_meta_adds_meta_when_absent() {
1586        let html = "<html><head><title>T</title></head><body></body></html>";
1587        let out = inject_csp_meta(html, "default-src 'self'");
1588        assert!(out.contains("http-equiv=\"Content-Security-Policy\""));
1589        assert!(out.contains("default-src 'self'"));
1590    }
1591
1592    #[test]
1593    fn inject_csp_meta_is_idempotent_when_meta_already_present() {
1594        let html = r#"<html><head><meta http-equiv="Content-Security-Policy" content="default-src 'self'"></head></html>"#;
1595        let out = inject_csp_meta(html, "script-src 'self'");
1596        // Should not duplicate.
1597        let count = out
1598            .matches("http-equiv=\"Content-Security-Policy\"")
1599            .count();
1600        assert_eq!(count, 1, "must not duplicate CSP meta tag");
1601        // And must not insert the new policy.
1602        assert!(!out.contains("script-src 'self'"));
1603    }
1604
1605    #[test]
1606    fn inject_csp_meta_handles_no_head_gracefully() {
1607        let html = "<html><body>no head</body></html>";
1608        let out = inject_csp_meta(html, "default-src 'self'");
1609        // Without a <head>, lol_html returns the input unchanged.
1610        assert_eq!(out, html);
1611    }
1612
1613    #[test]
1614    fn preserve_script_attrs_keeps_type_module() {
1615        let out = preserve_script_attrs(
1616            r#"<script type="module">"#,
1617            &["src", "integrity", "crossorigin"],
1618        );
1619        assert!(out.contains(r#"type="module""#), "got: {out}");
1620    }
1621
1622    #[test]
1623    fn preserve_script_attrs_keeps_boolean_async_defer() {
1624        let out = preserve_script_attrs(
1625            "<script async defer>",
1626            &["src", "integrity", "crossorigin"],
1627        );
1628        assert!(out.contains("async"), "got: {out}");
1629        assert!(out.contains("defer"), "got: {out}");
1630    }
1631
1632    #[test]
1633    fn preserve_script_attrs_drops_listed_attrs() {
1634        let out = preserve_script_attrs(
1635            r#"<script src="/x.js" integrity="sha384-foo" crossorigin="anonymous" data-id="9">"#,
1636            &["src", "integrity", "crossorigin"],
1637        );
1638        assert!(!out.contains("src="), "got: {out}");
1639        assert!(!out.contains("integrity="), "got: {out}");
1640        assert!(!out.contains("crossorigin="), "got: {out}");
1641        assert!(out.contains(r#"data-id="9""#), "got: {out}");
1642    }
1643
1644    #[test]
1645    fn preserve_script_attrs_empty_when_no_attrs() {
1646        let out = preserve_script_attrs("<script>", &["src"]);
1647        assert_eq!(out, "");
1648    }
1649
1650    #[test]
1651    fn extract_inline_script_preserves_type_module() {
1652        let html = r#"<html><body><script type="module">import x from '/m.js';</script></body></html>"#;
1653        let dir = tempdir().unwrap();
1654        let csp_dir = dir.path().join("_csp");
1655        let (out, count) = extract_inline_blocks(
1656            html,
1657            &csp_dir,
1658            dir.path(),
1659            SriAlgorithm::default(),
1660            "",
1661        )
1662        .unwrap();
1663        assert_eq!(count, 1);
1664        assert!(out.contains(r#"type="module""#), "got: {out}");
1665        assert!(out.contains("integrity=\"sha384-"), "got: {out}");
1666    }
1667
1668    #[test]
1669    fn extract_inline_script_preserves_data_attrs() {
1670        let html = r#"<html><body><script data-domain="example.com">window.x=1;</script></body></html>"#;
1671        let dir = tempdir().unwrap();
1672        let csp_dir = dir.path().join("_csp");
1673        let (out, count) = extract_inline_blocks(
1674            html,
1675            &csp_dir,
1676            dir.path(),
1677            SriAlgorithm::default(),
1678            "",
1679        )
1680        .unwrap();
1681        assert_eq!(count, 1);
1682        assert!(out.contains(r#"data-domain="example.com""#), "got: {out}");
1683    }
1684
1685    // ── per-page CSP (spec B4, plan §3 item 2.4) ────────────────────
1686
1687    /// The exact base64(SHA-256(...)) of `{"@type":"Thing"}` — the
1688    /// JSON-LD body used across the B4 tests.
1689    const JSONLD_BODY: &str = r#"{"@type":"Thing"}"#;
1690
1691    #[test]
1692    fn template_with_empty_slots_is_exactly_the_global_policy() {
1693        // Invariant promised by DEFAULT_CSP_POLICY_TEMPLATE's docs:
1694        // no hashes ⇒ byte-identical to DEFAULT_CSP_POLICY.
1695        assert_eq!(
1696            render_policy_template(DEFAULT_CSP_POLICY_TEMPLATE, &[], &[]),
1697            DEFAULT_CSP_POLICY
1698        );
1699    }
1700
1701    #[test]
1702    fn page_inline_hashes_includes_jsonld_blocks() {
1703        let html = format!(
1704            r#"<html><body><script type="application/ld+json">{JSONLD_BODY}</script></body></html>"#
1705        );
1706        let hashes = page_inline_hashes(&html);
1707        assert_eq!(hashes.scripts.len(), 1);
1708        let expected = SriAlgorithm::Sha256.integrity(JSONLD_BODY.as_bytes());
1709        assert_eq!(hashes.scripts[0], expected);
1710    }
1711
1712    #[test]
1713    fn page_inline_hashes_skips_external_scripts_and_empty_blocks() {
1714        let html = r#"<html><body>
1715            <script src="/app.js"></script>
1716            <script>   </script>
1717            <style></style>
1718        </body></html>"#;
1719        assert!(page_inline_hashes(html).is_empty());
1720    }
1721
1722    #[test]
1723    fn page_inline_hashes_orders_by_document_position_and_dedups() {
1724        let html =
1725            "<script>aaa</script><script>bbb</script><script>aaa</script>";
1726        let hashes = page_inline_hashes(html);
1727        assert_eq!(hashes.scripts.len(), 2, "duplicate block deduped");
1728        assert_eq!(
1729            hashes.scripts[0],
1730            SriAlgorithm::Sha256.integrity(b"aaa"),
1731            "document order preserved"
1732        );
1733        assert_eq!(hashes.scripts[1], SriAlgorithm::Sha256.integrity(b"bbb"));
1734    }
1735
1736    #[test]
1737    fn page_inline_hashes_collects_styles_separately() {
1738        let html = "<style>body{color:red}</style><script>alert(1)</script>";
1739        let hashes = page_inline_hashes(html);
1740        assert_eq!(hashes.styles.len(), 1);
1741        assert_eq!(hashes.scripts.len(), 1);
1742        assert_eq!(
1743            hashes.styles[0],
1744            SriAlgorithm::Sha256.integrity(b"body{color:red}")
1745        );
1746    }
1747
1748    #[test]
1749    fn page_policy_is_hash_strict_never_unsafe_inline() {
1750        // test_csp_strict analogue (spec B4 acceptance): when hashes
1751        // are present the policy carries them and no 'unsafe-inline'.
1752        let html = format!(
1753            r#"<script type="application/ld+json">{JSONLD_BODY}</script>"#
1754        );
1755        let policy = page_policy(&html).expect("policy for inline JSON-LD");
1756        let expected = SriAlgorithm::Sha256.integrity(JSONLD_BODY.as_bytes());
1757        assert!(
1758            policy.contains(&format!("script-src 'self' '{expected}'")),
1759            "policy must carry the exact sha256 source: {policy}"
1760        );
1761        assert!(!policy.contains("unsafe-inline"));
1762    }
1763
1764    #[test]
1765    fn page_policy_none_without_inline_blocks() {
1766        assert!(page_policy(
1767            "<html><head><title>t</title></head><body>x</body></html>"
1768        )
1769        .is_none());
1770    }
1771
1772    #[test]
1773    fn page_policy_is_deterministic() {
1774        let html = "<style>a{}</style><script>x=1</script>";
1775        assert_eq!(page_policy(html), page_policy(html));
1776    }
1777
1778    #[test]
1779    fn csp_plugin_transform_html_no_inline_blocks_returns_unchanged() {
1780        let html =
1781            "<html><head><title>X</title></head><body><p>hi</p></body></html>";
1782        let dir = tempdir().unwrap();
1783        let site = dir.path().join("site");
1784        fs::create_dir_all(&site).unwrap();
1785        let ctx = PluginContext::new(dir.path(), dir.path(), &site, dir.path());
1786        let out = CspPlugin
1787            .transform_html(html, &site.join("index.html"), &ctx)
1788            .unwrap();
1789        assert_eq!(out, html);
1790    }
1791
1792    // -------------------------------------------------------------------
1793    // Parser edge branches
1794    // -------------------------------------------------------------------
1795
1796    #[test]
1797    fn page_inline_hashes_dedupes_identical_style_blocks() {
1798        let html = "<style>a{color:red}</style><style>a{color:red}</style>";
1799        let hashes = page_inline_hashes(html);
1800        assert_eq!(hashes.styles.len(), 1);
1801    }
1802
1803    #[test]
1804    fn inline_collection_skips_prefix_tag_names() {
1805        // `<styles>` must not match a `<style` opener lookup.
1806        let html = "<styles>ignored</styles><style>a{}</style>";
1807        let out = collect_inline_script_and_style(html).1;
1808        assert_eq!(out, vec!["a{}"]);
1809    }
1810
1811    #[test]
1812    fn inline_collection_accepts_slash_after_tag_name() {
1813        // `<style/` is still a tag boundary for the opener check.
1814        let html = "<style/>a{}</style>";
1815        let out = collect_inline_script_and_style(html).1;
1816        assert_eq!(out, vec!["a{}"]);
1817    }
1818
1819    #[test]
1820    fn inline_collection_stops_when_opening_tag_unterminated() {
1821        let html = "<style media=all";
1822        assert!(
1823            collect_inline_script_and_style(html).1.is_empty(),
1824            "{:?}",
1825            collect_inline_script_and_style(html).1
1826        );
1827    }
1828
1829    #[test]
1830    fn inline_collection_stops_when_close_tag_missing() {
1831        let html = "<style>a{} no closing fence";
1832        assert!(
1833            collect_inline_script_and_style(html).1.is_empty(),
1834            "{:?}",
1835            collect_inline_script_and_style(html).1
1836        );
1837    }
1838
1839    #[test]
1840    fn find_inline_block_returns_none_without_close_tag() {
1841        assert!(find_inline_block("<style>a{}", "style").is_none());
1842    }
1843
1844    #[test]
1845    fn find_inline_script_returns_none_when_opening_unterminated() {
1846        assert!(find_inline_script("<script").is_none());
1847    }
1848
1849    #[test]
1850    fn find_inline_script_returns_none_without_close_tag() {
1851        assert!(find_inline_script("<script>var x = 1;").is_none());
1852    }
1853
1854    #[test]
1855    fn find_inline_script_skips_empty_script_then_finds_real_one() {
1856        let html = "<script>   </script><script>var x = 1;</script>";
1857        let (_, _, content, _) = find_inline_script(html).unwrap();
1858        assert_eq!(content, "var x = 1;");
1859    }
1860
1861    #[test]
1862    fn rewrite_or_original_returns_input_on_error() {
1863        let err = SsgError::io(
1864            std::io::Error::other("synthetic rewrite failure"),
1865            "<lol_html>",
1866        );
1867        assert_eq!(rewrite_or_original("<p>x</p>", Err(err)), "<p>x</p>");
1868    }
1869
1870    // -------------------------------------------------------------------
1871    // IO error branches
1872    // -------------------------------------------------------------------
1873
1874    #[test]
1875    fn after_compile_fails_when_csp_dir_squatted_by_file() {
1876        let dir = tempdir().unwrap();
1877        let site = dir.path().join("site");
1878        fs::create_dir_all(&site).unwrap();
1879        fs::write(site.join("_csp"), "not a dir").unwrap();
1880        let ctx = PluginContext::new(dir.path(), dir.path(), &site, dir.path());
1881        let err = CspPlugin.after_compile(&ctx).unwrap_err();
1882        assert!(!format!("{err}").is_empty(), "should not be empty");
1883    }
1884
1885    #[test]
1886    fn transform_html_fails_when_csp_dir_squatted_by_file() {
1887        // extract_inline_blocks' create_dir_all fails, and the io
1888        // error is wrapped with the page path.
1889        let dir = tempdir().unwrap();
1890        let site = dir.path().join("site");
1891        fs::create_dir_all(&site).unwrap();
1892        fs::write(site.join("_csp"), "not a dir").unwrap();
1893        let ctx = PluginContext::new(dir.path(), dir.path(), &site, dir.path());
1894        let err = CspPlugin
1895            .transform_html(
1896                "<style>a{}</style>",
1897                &site.join("index.html"),
1898                &ctx,
1899            )
1900            .unwrap_err();
1901        assert!(!format!("{err}").is_empty(), "should not be empty");
1902    }
1903
1904    #[test]
1905    fn extract_inline_blocks_script_dir_create_fails_when_squatted_by_file() {
1906        // Script-only page: the style loop never runs, so the script
1907        // loop's own create_dir_all hits the squatted `_csp` file.
1908        let dir = tempdir().unwrap();
1909        let site = dir.path().join("site");
1910        fs::create_dir_all(&site).unwrap();
1911        fs::write(site.join("_csp"), "not a dir").unwrap();
1912
1913        let res = extract_inline_blocks(
1914            "<script>var x = 1;</script>",
1915            &site.join("_csp"),
1916            &site,
1917            SriAlgorithm::default(),
1918            "",
1919        );
1920        assert!(res.is_err());
1921    }
1922
1923    #[test]
1924    fn extract_inline_blocks_style_write_fails_when_squatted_by_dir() {
1925        // The style payload filename is deterministic (FNV-1a of the
1926        // content); a directory squatting it makes fs::write fail.
1927        let dir = tempdir().unwrap();
1928        let site = dir.path().join("site");
1929        let csp_dir = site.join("_csp");
1930        let content = "a{color:red}";
1931        let squat =
1932            csp_dir.join(format!("{:016x}.css", fnv_hash(content.as_bytes())));
1933        fs::create_dir_all(&squat).unwrap();
1934
1935        let res = extract_inline_blocks(
1936            &format!("<style>{content}</style>"),
1937            &csp_dir,
1938            &site,
1939            SriAlgorithm::default(),
1940            "",
1941        );
1942        assert!(res.is_err());
1943    }
1944
1945    #[test]
1946    fn extract_inline_blocks_script_write_fails_when_squatted_by_dir() {
1947        let dir = tempdir().unwrap();
1948        let site = dir.path().join("site");
1949        let csp_dir = site.join("_csp");
1950        let content = "var x = 1;";
1951        let squat =
1952            csp_dir.join(format!("{:016x}.js", fnv_hash(content.as_bytes())));
1953        fs::create_dir_all(&squat).unwrap();
1954
1955        let res = extract_inline_blocks(
1956            &format!("<script>{content}</script>"),
1957            &csp_dir,
1958            &site,
1959            SriAlgorithm::default(),
1960            "",
1961        );
1962        assert!(res.is_err());
1963    }
1964}