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