Skip to main content

ssg/plugins/postprocess/edge_headers/
mod.rs

1// Copyright © 2023 - 2026 Static Site Generator (SSG). All rights reserved.
2// SPDX-License-Identifier: Apache-2.0 OR MIT
3
4//! PQC-aware edge-runtime header emitter (issue #550).
5//!
6//! Emits per-platform header configuration files for Cloudflare
7//! Workers (`wrangler-headers.toml`), Netlify (`_headers`), and Vercel
8//! (`vercel-headers.json`) so the deployed site lands with TLS,
9//! Permissions-Policy, X-Content-Type-Options, Referrer-Policy and the
10//! site's computed Content-Security-Policy already locked in.
11//!
12//! ## Scope (locked-in)
13//!
14//! - **Static emit only.** No live TLS probing at build time.
15//! - **Five baseline headers.** [`baseline_headers`] is the source of
16//!   truth; per-target emitters render those keys/values into the
17//!   platform-specific syntax. Anything else (cache-control,
18//!   per-route headers) lives in the existing deploy adapter — the
19//!   edge-headers emitter is intentionally orthogonal to deploy
20//!   target generation.
21//! - **CSP comes from the CSP plugin.** The `Content-Security-Policy`
22//!   value is sourced from [`crate::csp::computed_policy`]; the
23//!   emitter does **not** recompute or hardcode the string. AC7.
24//! - **PQC documentation comment** in every emitted file naming the
25//!   X25519+ML-KEM-768 hybrid key-exchange suite (CDN handles the
26//!   actual negotiation — we just document and link). AC6.
27//! - **Per-target overrides** via `[edge_headers.overrides]` in
28//!   `ssg.toml`; case-insensitive on the header key, last-write-wins
29//!   per target. AC5.
30//!
31//! ## Per-page CSP (spec B4, v0.0.47 plan §3 item 2.4)
32//!
33//! Pages that still carry inline blocks after the CSP plugin's
34//! extraction pass (JSON-LD structured data, chiefly) get a
35//! **per-path** `Content-Security-Policy` entry in the Netlify
36//! `_headers` and Vercel `vercel-headers.json` outputs, built from
37//! [`crate::csp::page_policy`] — the hash-strict rendering of
38//! [`crate::csp::DEFAULT_CSP_POLICY_TEMPLATE`] with that page's
39//! SHA-256 inline source hashes. Pages without inline blocks fall
40//! back to the global `/*` policy.
41//!
42//! ### Ordering contract
43//!
44//! The build pipeline runs every plugin's `after_compile` hook
45//! *before* the fused `transform_html` pass, so per-page hashes
46//! cannot be computed in `after_compile` (the CSP plugin's inline
47//! extraction and the minifier have not run yet). Instead:
48//!
49//! 1. `after_compile` emits the platform files with the **global**
50//!    policy only (deterministic fallback, also the final state for
51//!    sites with zero inline blocks) and resets this build's
52//!    per-page registry.
53//! 2. `transform_html` — registered **after** `CspPlugin` and
54//!    `MinifyPlugin` in `register_default_plugins`, so it observes
55//!    the final shipped bytes of each page — records the page's
56//!    policy and re-emits the platform files. Any future transform
57//!    plugin that injects inline `<script>`/`<style>` content must
58//!    register *before* `edge-headers` or its blocks will not be
59//!    hashed.
60//!
61//! ### Determinism
62//!
63//! Per-page policies accumulate in a `BTreeMap` keyed by URL path, so
64//! rendered output is sorted regardless of rayon scheduling. Every
65//! page inserts its entry before writing a full snapshot **under the
66//! same mutex**, so the chronologically last write — the one that
67//! survives on disk — contains every page's entry. No sidecar file is
68//! written and nothing extra ships in the site output
69//! (`determinism.yml` byte-hashes the result).
70//!
71//! ## File layout
72//!
73//! ```text
74//! dist/
75//! ├── _headers                           # Netlify (AC2)
76//! └── .ssg/edge/
77//!     ├── wrangler-headers.toml          # Cloudflare (AC1)
78//!     └── vercel-headers.json            # Vercel (AC3)
79//! ```
80//!
81//! Cloudflare and Vercel files live under `.ssg/edge/` so they don't
82//! clash with any user-managed `wrangler.toml`/`vercel.json` at the
83//! site root; the leading comment block on each file documents the
84//! intended merge path.
85
86pub(crate) mod cloudflare;
87pub(crate) mod netlify;
88pub(crate) mod vercel;
89
90use crate::cmd::EdgeHeadersConfig;
91use crate::error::{PathErrorExt, SsgError};
92use crate::plugin::{Plugin, PluginContext};
93use std::collections::BTreeMap;
94use std::fs;
95use std::path::{Path, PathBuf};
96use std::sync::{LazyLock, Mutex, PoisonError};
97
98/// Per-build registry of per-page CSP policies, keyed by `site_dir`
99/// so concurrent builds (and the test suite's tempdir sites) never
100/// observe each other's pages. Inner map: URL path → policy string.
101///
102/// Plugin instances are stateless unit structs and the pipeline
103/// offers no post-transform hook, so this module-level registry is
104/// the coordination point between the fused transform pass and the
105/// emitted platform files (see the module docs' ordering contract).
106/// `after_compile` clears the current site's entry at the start of
107/// every build, so watch-mode rebuilds never accumulate stale pages.
108static PAGE_CSP_REGISTRY: LazyLock<
109    Mutex<BTreeMap<PathBuf, BTreeMap<String, String>>>,
110> = LazyLock::new(|| Mutex::new(BTreeMap::new()));
111
112/// Baseline header set emitted by every target.
113///
114/// Order matters: emitters render headers in iteration order so the
115/// resulting files are deterministic across rebuilds (golden-test
116/// friendly). The five baseline keys are:
117///
118/// 1. `Strict-Transport-Security` — 2-year `max-age`, preload-ready
119/// 2. `Content-Security-Policy`   — sourced from [`crate::csp::computed_policy`]
120/// 3. `X-Content-Type-Options`    — `nosniff`
121/// 4. `Referrer-Policy`           — `strict-origin-when-cross-origin`
122/// 5. `Permissions-Policy`        — camera/geolocation/microphone off
123///
124/// # Examples
125///
126/// ```
127/// use ssg::postprocess::edge_headers::baseline_headers;
128/// let baseline = baseline_headers();
129/// assert_eq!(baseline.len(), 5);
130/// assert_eq!(baseline[0].0, "Strict-Transport-Security");
131/// ```
132#[must_use]
133pub fn baseline_headers() -> [(&'static str, String); 5] {
134    [
135        (
136            "Strict-Transport-Security",
137            "max-age=63072000; includeSubDomains; preload".to_string(),
138        ),
139        (
140            "Content-Security-Policy",
141            crate::csp::computed_policy().to_string(),
142        ),
143        ("X-Content-Type-Options", "nosniff".to_string()),
144        (
145            "Referrer-Policy",
146            "strict-origin-when-cross-origin".to_string(),
147        ),
148        (
149            "Permissions-Policy",
150            "camera=(), geolocation=(), microphone=()".to_string(),
151        ),
152    ]
153}
154
155/// Merges baseline headers with case-insensitive overrides.
156///
157/// Returns an ordered `Vec<(String, String)>` so emitters render in
158/// the same deterministic order as [`baseline_headers`]. Overrides are
159/// matched by lowercased key; values replace the baseline verbatim.
160/// Overrides referencing a header name **not** present in the baseline
161/// are appended after the baseline so site authors can add e.g.
162/// `Cross-Origin-Opener-Policy` without us hardcoding it.
163///
164/// # Examples
165///
166/// ```
167/// use std::collections::BTreeMap;
168/// use ssg::postprocess::edge_headers::merged_headers;
169/// let merged = merged_headers(&BTreeMap::new());
170/// assert_eq!(merged.len(), 5);
171/// assert_eq!(merged[0].0, "Strict-Transport-Security");
172/// ```
173#[must_use]
174pub fn merged_headers(
175    overrides: &BTreeMap<String, String>,
176) -> Vec<(String, String)> {
177    let baseline = baseline_headers();
178    let mut lower_overrides: BTreeMap<String, (String, String)> = overrides
179        .iter()
180        .map(|(k, v)| (k.to_ascii_lowercase(), (k.clone(), v.clone())))
181        .collect();
182
183    let mut out: Vec<(String, String)> = Vec::with_capacity(baseline.len());
184    for (key, default_value) in baseline {
185        let key_lc = key.to_ascii_lowercase();
186        if let Some((_orig_key, override_value)) =
187            lower_overrides.remove(&key_lc)
188        {
189            out.push((key.to_string(), override_value));
190        } else {
191            out.push((key.to_string(), default_value));
192        }
193    }
194    // Append any extra (non-baseline) overrides in deterministic
195    // (alphabetical) order.
196    for (_lc, (orig, value)) in lower_overrides {
197        out.push((orig, value));
198    }
199    out
200}
201
202/// PQC documentation snippet appended to every emitted file as a
203/// platform-appropriate comment block (TOML `#`, plain-text `#`, JSON
204/// `_pqc_note` key). Names the recommended hybrid key-exchange suite
205/// and links each platform's TLS configuration page. AC6.
206pub(crate) const PQC_NOTE_LINES: &[&str] = &[
207    "PQC posture: TLS 1.3 with the X25519+ML-KEM-768 hybrid",
208    "key-exchange suite (RFC 9420 / draft-ietf-tls-hybrid-design).",
209    "Cloudflare auto-negotiates as of mid-2026; Netlify is",
210    "behind an opt-in in the platform dashboard; Vercel surfaces",
211    "the suite once the upstream CDN (Cloudflare/AWS) enables it.",
212    "Configure at the platform level — this file is documentation",
213    "of the recommended posture, not a runtime knob.",
214    "Cloudflare:    https://developers.cloudflare.com/ssl/post-quantum-cryptography/",
215    "Netlify:       https://docs.netlify.com/edge-functions/overview/",
216    "Vercel:        https://vercel.com/docs/edge-network/headers",
217];
218
219/// Postprocess plugin that emits per-platform edge header config.
220///
221/// Reads [`crate::cmd::EdgeHeadersConfig`] off the plugin context's
222/// `config.edge_headers` field; for each recognised entry in
223/// `targets`, invokes the corresponding emitter. The plugin is a
224/// no-op when `targets` is empty, when `config` is `None`, or when
225/// `site_dir` does not yet exist.
226///
227/// # Examples
228///
229/// ```
230/// use ssg::plugin::Plugin;
231/// use ssg::postprocess::edge_headers::EdgeHeadersPlugin;
232/// assert_eq!(EdgeHeadersPlugin::new().name(), "edge-headers");
233/// ```
234#[derive(Debug, Clone, Copy, Default)]
235pub struct EdgeHeadersPlugin;
236
237impl EdgeHeadersPlugin {
238    /// Creates a new `EdgeHeadersPlugin`.
239    ///
240    /// # Examples
241    ///
242    /// ```
243    /// use ssg::postprocess::edge_headers::EdgeHeadersPlugin;
244    /// let plugin = EdgeHeadersPlugin::new();
245    /// let _copy: EdgeHeadersPlugin = plugin;
246    /// ```
247    #[must_use]
248    pub const fn new() -> Self {
249        Self
250    }
251}
252
253impl Plugin for EdgeHeadersPlugin {
254    fn name(&self) -> &'static str {
255        "edge-headers"
256    }
257
258    fn after_compile(&self, ctx: &PluginContext) -> Result<(), SsgError> {
259        // New build: forget the previous build's per-page policies
260        // for this site before anything else (watch-mode rebuilds
261        // must never accumulate pages that no longer exist).
262        {
263            let mut registry = PAGE_CSP_REGISTRY
264                .lock()
265                .unwrap_or_else(PoisonError::into_inner);
266            let _ = registry.remove(&ctx.site_dir);
267        }
268
269        if !ctx.site_dir.exists() {
270            return Ok(());
271        }
272
273        let Some(cfg) = ctx.config.as_ref() else {
274            return Ok(());
275        };
276        let edge = &cfg.edge_headers;
277        if !edge.is_enabled() {
278            return Ok(());
279        }
280
281        // Global-policy emission (also the final state for sites with
282        // zero inline blocks); the fused transform pass below re-emits
283        // with per-page CSP entries as it discovers them.
284        emit_targets(ctx, edge, &BTreeMap::new())
285    }
286
287    fn has_transform(&self) -> bool {
288        true
289    }
290
291    /// Pass-through transform that records the page's hash-strict CSP
292    /// (spec B4) and re-emits the platform files. Returns the input
293    /// HTML unchanged — this hook is an observation point, not a
294    /// rewrite; it runs last in the fused pass so it sees the final
295    /// shipped bytes (post-CSP-extraction, post-minify).
296    fn transform_html(
297        &self,
298        html: &str,
299        path: &Path,
300        ctx: &PluginContext,
301    ) -> Result<String, SsgError> {
302        let Some(cfg) = ctx.config.as_ref() else {
303            return Ok(html.to_string());
304        };
305        let edge = &cfg.edge_headers;
306        if !edge.is_enabled() || !ctx.site_dir.exists() {
307            return Ok(html.to_string());
308        }
309
310        let Some(policy) = crate::csp::page_policy(html) else {
311            // No inline blocks — the global `/*` policy applies.
312            return Ok(html.to_string());
313        };
314
315        let url = url_path_for(path, &ctx.site_dir);
316
317        // Insert + snapshot + write under one lock: a later snapshot
318        // can then never be overwritten by an earlier one, so the
319        // chronologically last write contains every page (module docs,
320        // "Determinism").
321        let mut registry = PAGE_CSP_REGISTRY
322            .lock()
323            .unwrap_or_else(PoisonError::into_inner);
324        let pages = registry.entry(ctx.site_dir.clone()).or_default();
325        let _ = pages.insert(url, policy);
326        emit_targets(ctx, edge, pages)?;
327
328        Ok(html.to_string())
329    }
330}
331
332/// Renders and writes every configured platform target.
333///
334/// `page_csp` maps site-relative URL paths (`/blog/post/`) to their
335/// hash-strict per-page CSP; an empty map emits the global policy
336/// only. Iteration order is the `BTreeMap`'s sorted order, keeping
337/// the emitted files byte-deterministic across rebuilds.
338fn emit_targets(
339    ctx: &PluginContext,
340    edge: &EdgeHeadersConfig,
341    page_csp: &BTreeMap<String, String>,
342) -> Result<(), SsgError> {
343    let headers = merged_headers(&edge.overrides);
344
345    // Cloudflare and Vercel artifacts go under `.ssg/edge/` so they
346    // don't collide with any user-owned wrangler.toml / vercel.json
347    // at the site root.
348    let edge_dir = ctx.site_dir.join(".ssg").join("edge");
349
350    for target in &edge.targets {
351        match target.to_ascii_lowercase().as_str() {
352            "cloudflare" => {
353                fs::create_dir_all(&edge_dir).with_path(&edge_dir)?;
354                let out_path = edge_dir.join("wrangler-headers.toml");
355                let body = cloudflare::render(&headers);
356                fs::write(&out_path, body).with_path(&out_path)?;
357                log::info!("[edge-headers] wrote {}", out_path.display());
358            }
359            "netlify" => {
360                let out_path = ctx.site_dir.join("_headers");
361                let body = netlify::render(&headers, page_csp);
362                fs::write(&out_path, body).with_path(&out_path)?;
363                log::info!("[edge-headers] wrote {}", out_path.display());
364            }
365            "vercel" => {
366                fs::create_dir_all(&edge_dir).with_path(&edge_dir)?;
367                let out_path = edge_dir.join("vercel-headers.json");
368                let body = vercel_render(&headers, page_csp).map_err(|e| {
369                    SsgError::io(
370                        std::io::Error::other(e.to_string()),
371                        &out_path,
372                    )
373                })?;
374                fs::write(&out_path, body).with_path(&out_path)?;
375                log::info!("[edge-headers] wrote {}", out_path.display());
376            }
377            other => {
378                log::warn!(
379                    "[edge-headers] unknown target `{other}` — skipping"
380                );
381            }
382        }
383    }
384
385    Ok(())
386}
387
388/// Delegates to [`vercel::render`] with a fault-injection hook so
389/// tests can drive the error-mapping branch (serialising the vercel
390/// header JSON cannot fail in practice).
391fn vercel_render(
392    headers: &[(String, String)],
393    page_csp: &BTreeMap<String, String>,
394) -> Result<String, serde_json::Error> {
395    fail_point!("postprocess::vercel-render", |_| Err(
396        <serde_json::Error as serde::ser::Error>::custom(
397            "injected: postprocess::vercel-render"
398        )
399    ));
400    vercel::render(headers, page_csp)
401}
402
403/// Maps a built HTML file path to its served URL path.
404///
405/// `index.html` collapses to its directory (`blog/post/index.html` →
406/// `/blog/post/`, root `index.html` → `/`); any other file keeps its
407/// name (`about.html` → `/about.html`). Backslashes are normalised so
408/// Windows builds emit identical files (determinism gate).
409fn url_path_for(path: &Path, site_dir: &Path) -> String {
410    let rel = path
411        .strip_prefix(site_dir)
412        .unwrap_or(path)
413        .to_string_lossy()
414        .replace('\\', "/");
415    if rel == "index.html" {
416        "/".to_string()
417    } else if let Some(dir) = rel.strip_suffix("/index.html") {
418        format!("/{dir}/")
419    } else {
420        format!("/{rel}")
421    }
422}
423
424#[cfg(test)]
425mod tests {
426    use super::*;
427
428    fn make_overrides(pairs: &[(&str, &str)]) -> BTreeMap<String, String> {
429        pairs
430            .iter()
431            .map(|(k, v)| ((*k).to_string(), (*v).to_string()))
432            .collect()
433    }
434
435    #[test]
436    fn baseline_headers_includes_five_canonical_entries() {
437        let baseline = baseline_headers();
438        let keys: Vec<&str> = baseline.iter().map(|(k, _)| *k).collect();
439        assert_eq!(
440            keys,
441            vec![
442                "Strict-Transport-Security",
443                "Content-Security-Policy",
444                "X-Content-Type-Options",
445                "Referrer-Policy",
446                "Permissions-Policy",
447            ]
448        );
449    }
450
451    #[test]
452    fn baseline_csp_comes_from_csp_plugin_not_hardcoded() {
453        // AC7: CSP value must equal the value exposed by the CSP
454        // plugin's computed_policy() function.
455        let baseline = baseline_headers();
456        let csp = baseline
457            .iter()
458            .find(|(k, _)| *k == "Content-Security-Policy")
459            .map(|(_, v)| v.as_str())
460            .expect("baseline must contain CSP");
461        assert_eq!(csp, crate::csp::computed_policy());
462    }
463
464    #[test]
465    fn baseline_hsts_is_2_year_preload_ready() {
466        let baseline = baseline_headers();
467        let hsts = baseline
468            .iter()
469            .find(|(k, _)| *k == "Strict-Transport-Security")
470            .map(|(_, v)| v.as_str())
471            .unwrap();
472        assert!(hsts.contains("max-age=63072000"));
473        assert!(hsts.contains("includeSubDomains"));
474        assert!(hsts.contains("preload"));
475    }
476
477    #[test]
478    fn merged_preserves_baseline_when_no_overrides() {
479        let merged = merged_headers(&BTreeMap::new());
480        assert_eq!(merged.len(), 5);
481        assert_eq!(merged[0].0, "Strict-Transport-Security");
482    }
483
484    #[test]
485    fn merged_applies_case_insensitive_override() {
486        // AC5: override must replace a single header and preserve all
487        // others — and the lookup is case-insensitive.
488        let overrides =
489            make_overrides(&[("permissions-policy", "geolocation=(self)")]);
490        let merged = merged_headers(&overrides);
491        let pp = merged
492            .iter()
493            .find(|(k, _)| k == "Permissions-Policy")
494            .map(|(_, v)| v.as_str())
495            .unwrap();
496        assert_eq!(pp, "geolocation=(self)");
497
498        // Other defaults must be untouched.
499        let hsts = merged
500            .iter()
501            .find(|(k, _)| k == "Strict-Transport-Security")
502            .map(|(_, v)| v.as_str())
503            .unwrap();
504        assert!(hsts.contains("max-age=63072000"));
505    }
506
507    #[test]
508    fn merged_uppercase_override_key_still_matches_baseline() {
509        let overrides = make_overrides(&[("PERMISSIONS-POLICY", "camera=*")]);
510        let merged = merged_headers(&overrides);
511        let pp = merged
512            .iter()
513            .find(|(k, _)| k == "Permissions-Policy")
514            .unwrap();
515        assert_eq!(pp.1, "camera=*");
516    }
517
518    #[test]
519    fn merged_appends_non_baseline_overrides() {
520        let overrides =
521            make_overrides(&[("Cross-Origin-Opener-Policy", "same-origin")]);
522        let merged = merged_headers(&overrides);
523        assert_eq!(merged.len(), 6);
524        assert!(merged
525            .iter()
526            .any(|(k, v)| k == "Cross-Origin-Opener-Policy"
527                && v == "same-origin"));
528    }
529
530    #[test]
531    fn no_duplicate_csp_in_baseline() {
532        // AC7: there should never be more than one Content-Security-Policy.
533        let baseline = baseline_headers();
534        let csp_count = baseline
535            .iter()
536            .filter(|(k, _)| k.eq_ignore_ascii_case("Content-Security-Policy"))
537            .count();
538        assert_eq!(csp_count, 1);
539    }
540
541    #[test]
542    fn plugin_name_is_stable() {
543        assert_eq!(EdgeHeadersPlugin.name(), "edge-headers");
544    }
545
546    #[test]
547    fn after_compile_is_noop_when_site_dir_missing() {
548        let ctx = PluginContext::new(
549            Path::new("/tmp/c"),
550            Path::new("/tmp/b"),
551            Path::new("/nonexistent/site-xyz"),
552            Path::new("/tmp/t"),
553        );
554        assert!(EdgeHeadersPlugin.after_compile(&ctx).is_ok());
555    }
556
557    #[test]
558    fn after_compile_is_noop_when_config_missing() {
559        let dir = tempfile::tempdir().unwrap();
560        let site = dir.path().join("site");
561        fs::create_dir_all(&site).unwrap();
562        let ctx = PluginContext::new(dir.path(), dir.path(), &site, dir.path());
563        // No config set → early-return.
564        EdgeHeadersPlugin.after_compile(&ctx).unwrap();
565        assert!(!site.join("_headers").exists());
566        assert!(!site.join(".ssg/edge").exists());
567    }
568
569    #[test]
570    fn after_compile_skips_when_no_targets_configured() {
571        let dir = tempfile::tempdir().unwrap();
572        let site = dir.path().join("site");
573        fs::create_dir_all(&site).unwrap();
574        let cfg = crate::cmd::SsgConfig::builder()
575            .site_name("t".to_string())
576            .base_url("http://example.com".to_string())
577            .build()
578            .unwrap();
579        let ctx = PluginContext::with_config(
580            dir.path(),
581            dir.path(),
582            &site,
583            dir.path(),
584            cfg,
585        );
586        EdgeHeadersPlugin.after_compile(&ctx).unwrap();
587        assert!(!site.join("_headers").exists());
588    }
589
590    fn cfg_with_targets(targets: Vec<&str>) -> crate::cmd::SsgConfig {
591        let mut edge = EdgeHeadersConfig::default();
592        edge.targets = targets.into_iter().map(String::from).collect();
593        crate::cmd::SsgConfig::builder()
594            .site_name("t".to_string())
595            .base_url("http://example.com".to_string())
596            .edge_headers(edge)
597            .build()
598            .unwrap()
599    }
600
601    #[test]
602    fn after_compile_cloudflare_target_writes_wrangler_headers() {
603        let dir = tempfile::tempdir().unwrap();
604        let site = dir.path().join("site");
605        fs::create_dir_all(&site).unwrap();
606        let cfg = cfg_with_targets(vec!["cloudflare"]);
607        let ctx = PluginContext::with_config(
608            dir.path(),
609            dir.path(),
610            &site,
611            dir.path(),
612            cfg,
613        );
614        EdgeHeadersPlugin.after_compile(&ctx).unwrap();
615        let written = site.join(".ssg/edge/wrangler-headers.toml");
616        assert!(written.exists(), "wrangler-headers.toml must be written");
617        let body = fs::read_to_string(&written).unwrap();
618        assert!(body.contains("Strict-Transport-Security"));
619    }
620
621    #[test]
622    fn after_compile_netlify_target_writes_underscore_headers() {
623        let dir = tempfile::tempdir().unwrap();
624        let site = dir.path().join("site");
625        fs::create_dir_all(&site).unwrap();
626        let cfg = cfg_with_targets(vec!["netlify"]);
627        let ctx = PluginContext::with_config(
628            dir.path(),
629            dir.path(),
630            &site,
631            dir.path(),
632            cfg,
633        );
634        EdgeHeadersPlugin.after_compile(&ctx).unwrap();
635        let written = site.join("_headers");
636        assert!(written.exists());
637        let body = fs::read_to_string(&written).unwrap();
638        assert!(body.contains("Strict-Transport-Security"));
639    }
640
641    #[test]
642    // Failpoints are process-global: this test reaches `vercel_render`
643    // expecting success, so it must never run concurrently with
644    // `fault_tests`'s injected `postprocess::vercel-render` failure —
645    // joins that test's `#[serial]` lock as `#[parallel]` on the same
646    // key (mirrors the convention in `core::cache`'s fault-injection
647    // tests).
648    #[serial_test::parallel(vercel_render_fp)]
649    fn after_compile_vercel_target_writes_json() {
650        let dir = tempfile::tempdir().unwrap();
651        let site = dir.path().join("site");
652        fs::create_dir_all(&site).unwrap();
653        let cfg = cfg_with_targets(vec!["vercel"]);
654        let ctx = PluginContext::with_config(
655            dir.path(),
656            dir.path(),
657            &site,
658            dir.path(),
659            cfg,
660        );
661        EdgeHeadersPlugin.after_compile(&ctx).unwrap();
662        let written = site.join(".ssg/edge/vercel-headers.json");
663        assert!(written.exists());
664        let body = fs::read_to_string(&written).unwrap();
665        assert!(body.contains("Strict-Transport-Security"));
666    }
667
668    #[test]
669    fn after_compile_unknown_target_is_warned_and_skipped() {
670        let dir = tempfile::tempdir().unwrap();
671        let site = dir.path().join("site");
672        fs::create_dir_all(&site).unwrap();
673        let cfg = cfg_with_targets(vec!["unknown-cdn"]);
674        let ctx = PluginContext::with_config(
675            dir.path(),
676            dir.path(),
677            &site,
678            dir.path(),
679            cfg,
680        );
681        EdgeHeadersPlugin.after_compile(&ctx).unwrap();
682        assert!(!site.join("_headers").exists());
683        assert!(!site.join(".ssg/edge").exists());
684    }
685
686    #[test]
687    fn after_compile_target_name_is_case_insensitive() {
688        let dir = tempfile::tempdir().unwrap();
689        let site = dir.path().join("site");
690        fs::create_dir_all(&site).unwrap();
691        let cfg = cfg_with_targets(vec!["CloudFlare"]);
692        let ctx = PluginContext::with_config(
693            dir.path(),
694            dir.path(),
695            &site,
696            dir.path(),
697            cfg,
698        );
699        EdgeHeadersPlugin.after_compile(&ctx).unwrap();
700        assert!(site.join(".ssg/edge/wrangler-headers.toml").exists());
701    }
702
703    #[test]
704    #[serial_test::parallel(vercel_render_fp)]
705    fn after_compile_all_three_targets_emit_all_three_artefacts() {
706        let dir = tempfile::tempdir().unwrap();
707        let site = dir.path().join("site");
708        fs::create_dir_all(&site).unwrap();
709        let cfg = cfg_with_targets(vec!["cloudflare", "netlify", "vercel"]);
710        let ctx = PluginContext::with_config(
711            dir.path(),
712            dir.path(),
713            &site,
714            dir.path(),
715            cfg,
716        );
717        EdgeHeadersPlugin.after_compile(&ctx).unwrap();
718        assert!(site.join("_headers").exists());
719        assert!(site.join(".ssg/edge/wrangler-headers.toml").exists());
720        assert!(site.join(".ssg/edge/vercel-headers.json").exists());
721    }
722
723    #[test]
724    fn new_constructs_unit() {
725        let _ = EdgeHeadersPlugin::new();
726    }
727
728    // ── per-page CSP wiring (spec B4, plan §3 item 2.4) ─────────────
729
730    #[test]
731    fn url_path_for_maps_index_and_plain_pages() {
732        let site = Path::new("/tmp/site");
733        assert_eq!(url_path_for(&site.join("index.html"), site), "/");
734        assert_eq!(
735            url_path_for(&site.join("blog/post/index.html"), site),
736            "/blog/post/"
737        );
738        assert_eq!(url_path_for(&site.join("about.html"), site), "/about.html");
739    }
740
741    #[test]
742    #[serial_test::parallel(vercel_render_fp)]
743    fn transform_records_page_policy_into_platform_files() {
744        // spec B4 acceptance: a page with inline JSON-LD gets a
745        // per-path entry carrying that block's exact sha256, in both
746        // _headers and vercel-headers.json.
747        let dir = tempfile::tempdir().unwrap();
748        let site = dir.path().join("site");
749        fs::create_dir_all(&site).unwrap();
750        let cfg = cfg_with_targets(vec!["netlify", "vercel"]);
751        let ctx = PluginContext::with_config(
752            dir.path(),
753            dir.path(),
754            &site,
755            dir.path(),
756            cfg,
757        );
758
759        let plugin = EdgeHeadersPlugin::new();
760        plugin.after_compile(&ctx).unwrap();
761
762        let jsonld = r#"{"@type":"BlogPosting","headline":"x"}"#;
763        let html = format!(
764            r#"<html><head><script type="application/ld+json">{jsonld}</script></head><body>b</body></html>"#
765        );
766        let page = site.join("blog/post/index.html");
767        let out = plugin.transform_html(&html, &page, &ctx).unwrap();
768        assert_eq!(out, html, "transform must be a pass-through");
769
770        let expected_hash =
771            crate::cmd::SriAlgorithm::Sha256.integrity(jsonld.as_bytes());
772
773        let headers_body = fs::read_to_string(site.join("_headers")).unwrap();
774        assert!(
775            headers_body.contains("/blog/post/\n"),
776            "per-path group missing: {headers_body}"
777        );
778        assert!(
779            headers_body
780                .contains(&format!("script-src 'self' '{expected_hash}'")),
781            "exact sha256 source missing: {headers_body}"
782        );
783        // test_csp_strict analogue: hash-strict, no 'unsafe-inline'.
784        assert!(!headers_body.contains("unsafe-inline"));
785
786        let vercel_body =
787            fs::read_to_string(site.join(".ssg/edge/vercel-headers.json"))
788                .unwrap();
789        let parsed: serde_json::Value =
790            serde_json::from_str(&vercel_body).unwrap();
791        let groups = parsed["headers"].as_array().unwrap();
792        let page_group = groups
793            .iter()
794            .find(|g| g["source"].as_str() == Some("/blog/post/"))
795            .expect("per-page vercel route present");
796        let value = page_group["headers"][0]["value"].as_str().unwrap();
797        assert!(value.contains(&format!("'{expected_hash}'")));
798        assert!(!value.contains("unsafe-inline"));
799    }
800
801    #[test]
802    fn transform_without_inline_blocks_keeps_global_files_untouched() {
803        let dir = tempfile::tempdir().unwrap();
804        let site = dir.path().join("site");
805        fs::create_dir_all(&site).unwrap();
806        let cfg = cfg_with_targets(vec!["netlify"]);
807        let ctx = PluginContext::with_config(
808            dir.path(),
809            dir.path(),
810            &site,
811            dir.path(),
812            cfg,
813        );
814        let plugin = EdgeHeadersPlugin::new();
815        plugin.after_compile(&ctx).unwrap();
816        let before = fs::read_to_string(site.join("_headers")).unwrap();
817
818        let html = "<html><head></head><body>plain</body></html>";
819        let out = plugin
820            .transform_html(html, &site.join("index.html"), &ctx)
821            .unwrap();
822        assert_eq!(out, html);
823
824        let after = fs::read_to_string(site.join("_headers")).unwrap();
825        assert_eq!(before, after, "no inline blocks ⇒ no re-emit");
826    }
827
828    #[test]
829    fn transform_is_noop_when_disabled_or_unconfigured() {
830        let dir = tempfile::tempdir().unwrap();
831        let site = dir.path().join("site");
832        fs::create_dir_all(&site).unwrap();
833        // No config at all.
834        let ctx = PluginContext::new(dir.path(), dir.path(), &site, dir.path());
835        let html = "<script>x=1</script>";
836        let out = EdgeHeadersPlugin::new()
837            .transform_html(html, &site.join("index.html"), &ctx)
838            .unwrap();
839        assert_eq!(out, html);
840        assert!(!site.join("_headers").exists());
841    }
842
843    #[test]
844    fn after_compile_resets_previous_builds_page_registry() {
845        // Watch-mode contract: a rebuild must not carry forward pages
846        // from the previous build.
847        let dir = tempfile::tempdir().unwrap();
848        let site = dir.path().join("site");
849        fs::create_dir_all(&site).unwrap();
850        let cfg = cfg_with_targets(vec!["netlify"]);
851        let ctx = PluginContext::with_config(
852            dir.path(),
853            dir.path(),
854            &site,
855            dir.path(),
856            cfg,
857        );
858        let plugin = EdgeHeadersPlugin::new();
859
860        plugin.after_compile(&ctx).unwrap();
861        let html = "<html><head><script>x=1</script></head></html>";
862        let _ = plugin
863            .transform_html(html, &site.join("old/index.html"), &ctx)
864            .unwrap();
865        assert!(fs::read_to_string(site.join("_headers"))
866            .unwrap()
867            .contains("/old/"));
868
869        // Second build: after_compile resets; the stale /old/ entry
870        // must be gone from the re-emitted global file.
871        plugin.after_compile(&ctx).unwrap();
872        let body = fs::read_to_string(site.join("_headers")).unwrap();
873        assert!(!body.contains("/old/"), "stale page survived reset: {body}");
874    }
875
876    #[test]
877    fn two_pages_accumulate_sorted_entries() {
878        let dir = tempfile::tempdir().unwrap();
879        let site = dir.path().join("site");
880        fs::create_dir_all(&site).unwrap();
881        let cfg = cfg_with_targets(vec!["netlify"]);
882        let ctx = PluginContext::with_config(
883            dir.path(),
884            dir.path(),
885            &site,
886            dir.path(),
887            cfg,
888        );
889        let plugin = EdgeHeadersPlugin::new();
890        plugin.after_compile(&ctx).unwrap();
891
892        let html_a = "<html><head><script>a=1</script></head></html>";
893        let html_b = "<html><head><script>b=2</script></head></html>";
894        let _ = plugin
895            .transform_html(html_b, &site.join("zeta/index.html"), &ctx)
896            .unwrap();
897        let _ = plugin
898            .transform_html(html_a, &site.join("alpha/index.html"), &ctx)
899            .unwrap();
900
901        let body = fs::read_to_string(site.join("_headers")).unwrap();
902        let i_alpha = body.find("/alpha/").unwrap();
903        let i_zeta = body.find("/zeta/").unwrap();
904        assert!(
905            i_alpha < i_zeta,
906            "entries must be sorted regardless of insertion order"
907        );
908    }
909
910    // -----------------------------------------------------------------
911    // emit_targets error paths (directory/file collisions)
912    // -----------------------------------------------------------------
913
914    fn edge_cfg(targets: &[&str]) -> EdgeHeadersConfig {
915        let mut edge = EdgeHeadersConfig::default();
916        edge.targets = targets.iter().map(|t| (*t).to_string()).collect();
917        edge
918    }
919
920    #[test]
921    fn emit_targets_cloudflare_errors_when_ssg_dir_is_a_file() {
922        let dir = tempfile::tempdir().unwrap();
923        let site = dir.path().join("site");
924        fs::create_dir_all(&site).unwrap();
925        // A file named .ssg blocks create_dir_all(.ssg/edge).
926        fs::write(site.join(".ssg"), "not a dir").unwrap();
927        let ctx = PluginContext::new(dir.path(), dir.path(), &site, dir.path());
928        let err =
929            emit_targets(&ctx, &edge_cfg(&["cloudflare"]), &BTreeMap::new())
930                .unwrap_err();
931        assert!(format!("{err}").contains(".ssg"));
932    }
933
934    #[test]
935    fn emit_targets_cloudflare_errors_when_output_is_a_directory() {
936        let dir = tempfile::tempdir().unwrap();
937        let site = dir.path().join("site");
938        fs::create_dir_all(site.join(".ssg/edge/wrangler-headers.toml"))
939            .unwrap();
940        let ctx = PluginContext::new(dir.path(), dir.path(), &site, dir.path());
941        let err =
942            emit_targets(&ctx, &edge_cfg(&["cloudflare"]), &BTreeMap::new())
943                .unwrap_err();
944        assert!(format!("{err}").contains("wrangler-headers.toml"));
945    }
946
947    #[test]
948    fn emit_targets_netlify_errors_when_headers_is_a_directory() {
949        let dir = tempfile::tempdir().unwrap();
950        let site = dir.path().join("site");
951        fs::create_dir_all(site.join("_headers")).unwrap();
952        let ctx = PluginContext::new(dir.path(), dir.path(), &site, dir.path());
953        let err = emit_targets(&ctx, &edge_cfg(&["netlify"]), &BTreeMap::new())
954            .unwrap_err();
955        assert!(format!("{err}").contains("_headers"));
956    }
957
958    #[test]
959    fn emit_targets_vercel_errors_when_ssg_dir_is_a_file() {
960        let dir = tempfile::tempdir().unwrap();
961        let site = dir.path().join("site");
962        fs::create_dir_all(&site).unwrap();
963        fs::write(site.join(".ssg"), "not a dir").unwrap();
964        let ctx = PluginContext::new(dir.path(), dir.path(), &site, dir.path());
965        let err = emit_targets(&ctx, &edge_cfg(&["vercel"]), &BTreeMap::new())
966            .unwrap_err();
967        assert!(format!("{err}").contains(".ssg"));
968    }
969
970    #[test]
971    fn emit_targets_vercel_errors_when_output_is_a_directory() {
972        let dir = tempfile::tempdir().unwrap();
973        let site = dir.path().join("site");
974        fs::create_dir_all(site.join(".ssg/edge/vercel-headers.json")).unwrap();
975        let ctx = PluginContext::new(dir.path(), dir.path(), &site, dir.path());
976        let err = emit_targets(&ctx, &edge_cfg(&["vercel"]), &BTreeMap::new())
977            .unwrap_err();
978        assert!(format!("{err}").contains("vercel-headers.json"));
979    }
980
981    // -----------------------------------------------------------------
982    // transform_html: emit failure propagates
983    // -----------------------------------------------------------------
984
985    #[test]
986    fn transform_html_propagates_emit_failure() {
987        let dir = tempfile::tempdir().unwrap();
988        let site = dir.path().join("site");
989        // `_headers` exists as a directory → netlify write fails.
990        fs::create_dir_all(site.join("_headers")).unwrap();
991        let cfg = cfg_with_targets(vec!["netlify"]);
992        let ctx = PluginContext::with_config(
993            dir.path(),
994            dir.path(),
995            &site,
996            dir.path(),
997            cfg,
998        );
999        // Inline script gives the page a hash-strict CSP so the
1000        // transform reaches emit_targets.
1001        let html = "<html><head><script>var x = 1;</script></head><body></body></html>";
1002        let err = EdgeHeadersPlugin
1003            .transform_html(html, &site.join("index.html"), &ctx)
1004            .unwrap_err();
1005        assert!(format!("{err}").contains("_headers"));
1006    }
1007
1008    // -----------------------------------------------------------------
1009    // PAGE_CSP_REGISTRY poisoned-lock recovery
1010    // -----------------------------------------------------------------
1011
1012    #[test]
1013    fn after_compile_and_transform_recover_from_poisoned_registry_lock() {
1014        // `.lock().unwrap_or_else(PoisonError::into_inner)` is a
1015        // defensive recovery arm that only runs if some other thread
1016        // panicked while holding the lock — no ordinary single-threaded
1017        // test reaches it. Deliberately poison the (process-global)
1018        // registry from a spawned thread so both call sites
1019        // (after_compile's reset, transform_html's insert) exercise
1020        // their recovery branch instead of a real production bug.
1021        let poisoned = std::thread::spawn(|| {
1022            let _guard = PAGE_CSP_REGISTRY.lock().unwrap();
1023            panic!("intentional poison for coverage of the recovery arm");
1024        })
1025        .join();
1026        assert!(poisoned.is_err(), "spawned thread must have panicked");
1027
1028        let dir = tempfile::tempdir().unwrap();
1029        let site = dir.path().join("site");
1030        fs::create_dir_all(&site).unwrap();
1031        let cfg = cfg_with_targets(vec!["netlify"]);
1032        let ctx = PluginContext::with_config(
1033            dir.path(),
1034            dir.path(),
1035            &site,
1036            dir.path(),
1037            cfg,
1038        );
1039        let plugin = EdgeHeadersPlugin::new();
1040
1041        // Must not panic despite the poisoned lock.
1042        plugin.after_compile(&ctx).unwrap();
1043
1044        let html = "<html><head><script>x=1</script></head></html>";
1045        let out = plugin
1046            .transform_html(html, &site.join("index.html"), &ctx)
1047            .unwrap();
1048        assert_eq!(out, html);
1049    }
1050}
1051
1052#[cfg(all(test, feature = "test-fault-injection"))]
1053mod fault_tests {
1054    use super::*;
1055
1056    /// RAII guard that disables a failpoint on drop.
1057    struct FailGuard(&'static str);
1058
1059    impl Drop for FailGuard {
1060        fn drop(&mut self) {
1061            let _ = fail::cfg(self.0, "off");
1062        }
1063    }
1064
1065    fn cfg_with_targets(targets: Vec<&str>) -> crate::cmd::SsgConfig {
1066        let mut edge = EdgeHeadersConfig::default();
1067        edge.targets = targets.into_iter().map(String::from).collect();
1068        crate::cmd::SsgConfig::builder()
1069            .site_name("t".to_string())
1070            .base_url("http://example.com".to_string())
1071            .edge_headers(edge)
1072            .build()
1073            .unwrap()
1074    }
1075
1076    #[test]
1077    #[serial_test::serial(vercel_render_fp)]
1078    fn after_compile_vercel_maps_serialize_failure_to_io_error() {
1079        let _guard = FailGuard("postprocess::vercel-render");
1080        fail::cfg("postprocess::vercel-render", "return")
1081            .expect("activate failpoint");
1082
1083        let dir = tempfile::tempdir().unwrap();
1084        let site = dir.path().join("site");
1085        fs::create_dir_all(&site).unwrap();
1086        let cfg = cfg_with_targets(vec!["vercel"]);
1087        let ctx = PluginContext::with_config(
1088            dir.path(),
1089            dir.path(),
1090            &site,
1091            dir.path(),
1092            cfg,
1093        );
1094        let err = EdgeHeadersPlugin
1095            .after_compile(&ctx)
1096            .expect_err("injected serialize failure must propagate");
1097        let msg = format!("{err}");
1098        assert!(msg.contains("vercel-headers.json"), "got: {msg}");
1099        assert!(
1100            msg.contains("injected: postprocess::vercel-render"),
1101            "got: {msg}"
1102        );
1103    }
1104}