pub(crate) mod cloudflare;
pub(crate) mod netlify;
pub(crate) mod vercel;
use crate::cmd::EdgeHeadersConfig;
use crate::error::{PathErrorExt, SsgError};
use crate::plugin::{Plugin, PluginContext};
use std::collections::BTreeMap;
use std::fs;
use std::path::{Path, PathBuf};
use std::sync::{LazyLock, Mutex, PoisonError};
static PAGE_CSP_REGISTRY: LazyLock<
Mutex<BTreeMap<PathBuf, BTreeMap<String, String>>>,
> = LazyLock::new(|| Mutex::new(BTreeMap::new()));
#[must_use]
pub fn baseline_headers() -> [(&'static str, String); 5] {
[
(
"Strict-Transport-Security",
"max-age=63072000; includeSubDomains; preload".to_string(),
),
(
"Content-Security-Policy",
crate::csp::computed_policy().to_string(),
),
("X-Content-Type-Options", "nosniff".to_string()),
(
"Referrer-Policy",
"strict-origin-when-cross-origin".to_string(),
),
(
"Permissions-Policy",
"camera=(), geolocation=(), microphone=()".to_string(),
),
]
}
#[must_use]
pub fn merged_headers(
overrides: &BTreeMap<String, String>,
) -> Vec<(String, String)> {
let baseline = baseline_headers();
let mut lower_overrides: BTreeMap<String, (String, String)> = overrides
.iter()
.map(|(k, v)| (k.to_ascii_lowercase(), (k.clone(), v.clone())))
.collect();
let mut out: Vec<(String, String)> = Vec::with_capacity(baseline.len());
for (key, default_value) in baseline {
let key_lc = key.to_ascii_lowercase();
if let Some((_orig_key, override_value)) =
lower_overrides.remove(&key_lc)
{
out.push((key.to_string(), override_value));
} else {
out.push((key.to_string(), default_value));
}
}
for (_lc, (orig, value)) in lower_overrides {
out.push((orig, value));
}
out
}
pub(crate) const PQC_NOTE_LINES: &[&str] = &[
"PQC posture: TLS 1.3 with the X25519+ML-KEM-768 hybrid",
"key-exchange suite (RFC 9420 / draft-ietf-tls-hybrid-design).",
"Cloudflare auto-negotiates as of mid-2026; Netlify is",
"behind an opt-in in the platform dashboard; Vercel surfaces",
"the suite once the upstream CDN (Cloudflare/AWS) enables it.",
"Configure at the platform level — this file is documentation",
"of the recommended posture, not a runtime knob.",
"Cloudflare: https://developers.cloudflare.com/ssl/post-quantum-cryptography/",
"Netlify: https://docs.netlify.com/edge-functions/overview/",
"Vercel: https://vercel.com/docs/edge-network/headers",
];
#[derive(Debug, Clone, Copy, Default)]
pub struct EdgeHeadersPlugin;
impl EdgeHeadersPlugin {
#[must_use]
pub const fn new() -> Self {
Self
}
}
impl Plugin for EdgeHeadersPlugin {
fn name(&self) -> &'static str {
"edge-headers"
}
fn after_compile(&self, ctx: &PluginContext) -> Result<(), SsgError> {
{
let mut registry = PAGE_CSP_REGISTRY
.lock()
.unwrap_or_else(PoisonError::into_inner);
let _ = registry.remove(&ctx.site_dir);
}
if !ctx.site_dir.exists() {
return Ok(());
}
let Some(cfg) = ctx.config.as_ref() else {
return Ok(());
};
let edge = &cfg.edge_headers;
if !edge.is_enabled() {
return Ok(());
}
emit_targets(ctx, edge, &BTreeMap::new())
}
fn has_transform(&self) -> bool {
true
}
fn transform_html(
&self,
html: &str,
path: &Path,
ctx: &PluginContext,
) -> Result<String, SsgError> {
let Some(cfg) = ctx.config.as_ref() else {
return Ok(html.to_string());
};
let edge = &cfg.edge_headers;
if !edge.is_enabled() || !ctx.site_dir.exists() {
return Ok(html.to_string());
}
let Some(policy) = crate::csp::page_policy(html) else {
return Ok(html.to_string());
};
let url = url_path_for(path, &ctx.site_dir);
let mut registry = PAGE_CSP_REGISTRY
.lock()
.unwrap_or_else(PoisonError::into_inner);
let pages = registry.entry(ctx.site_dir.clone()).or_default();
let _ = pages.insert(url, policy);
emit_targets(ctx, edge, pages)?;
Ok(html.to_string())
}
}
fn emit_targets(
ctx: &PluginContext,
edge: &EdgeHeadersConfig,
page_csp: &BTreeMap<String, String>,
) -> Result<(), SsgError> {
let headers = merged_headers(&edge.overrides);
let edge_dir = ctx.site_dir.join(".ssg").join("edge");
for target in &edge.targets {
match target.to_ascii_lowercase().as_str() {
"cloudflare" => {
fs::create_dir_all(&edge_dir).with_path(&edge_dir)?;
let out_path = edge_dir.join("wrangler-headers.toml");
let body = cloudflare::render(&headers);
fs::write(&out_path, body).with_path(&out_path)?;
log::info!("[edge-headers] wrote {}", out_path.display());
}
"netlify" => {
let out_path = ctx.site_dir.join("_headers");
let body = netlify::render(&headers, page_csp);
fs::write(&out_path, body).with_path(&out_path)?;
log::info!("[edge-headers] wrote {}", out_path.display());
}
"vercel" => {
fs::create_dir_all(&edge_dir).with_path(&edge_dir)?;
let out_path = edge_dir.join("vercel-headers.json");
let body = vercel_render(&headers, page_csp).map_err(|e| {
SsgError::io(
std::io::Error::other(e.to_string()),
&out_path,
)
})?;
fs::write(&out_path, body).with_path(&out_path)?;
log::info!("[edge-headers] wrote {}", out_path.display());
}
other => {
log::warn!(
"[edge-headers] unknown target `{other}` — skipping"
);
}
}
}
Ok(())
}
fn vercel_render(
headers: &[(String, String)],
page_csp: &BTreeMap<String, String>,
) -> Result<String, serde_json::Error> {
fail_point!("postprocess::vercel-render", |_| Err(
<serde_json::Error as serde::ser::Error>::custom(
"injected: postprocess::vercel-render"
)
));
vercel::render(headers, page_csp)
}
fn url_path_for(path: &Path, site_dir: &Path) -> String {
let rel = path
.strip_prefix(site_dir)
.unwrap_or(path)
.to_string_lossy()
.replace('\\', "/");
if rel == "index.html" {
"/".to_string()
} else if let Some(dir) = rel.strip_suffix("/index.html") {
format!("/{dir}/")
} else {
format!("/{rel}")
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
fn make_overrides(pairs: &[(&str, &str)]) -> BTreeMap<String, String> {
pairs
.iter()
.map(|(k, v)| ((*k).to_string(), (*v).to_string()))
.collect()
}
#[test]
fn baseline_headers_includes_five_canonical_entries() {
let baseline = baseline_headers();
let keys: Vec<&str> = baseline.iter().map(|(k, _)| *k).collect();
assert_eq!(
keys,
vec![
"Strict-Transport-Security",
"Content-Security-Policy",
"X-Content-Type-Options",
"Referrer-Policy",
"Permissions-Policy",
]
);
}
#[test]
fn baseline_csp_comes_from_csp_plugin_not_hardcoded() {
let baseline = baseline_headers();
let csp = baseline
.iter()
.find(|(k, _)| *k == "Content-Security-Policy")
.map(|(_, v)| v.as_str())
.expect("baseline must contain CSP");
assert_eq!(csp, crate::csp::computed_policy());
}
#[test]
fn baseline_hsts_is_2_year_preload_ready() {
let baseline = baseline_headers();
let hsts = baseline
.iter()
.find(|(k, _)| *k == "Strict-Transport-Security")
.map(|(_, v)| v.as_str())
.unwrap();
assert!(hsts.contains("max-age=63072000"));
assert!(hsts.contains("includeSubDomains"));
assert!(hsts.contains("preload"));
}
#[test]
fn merged_preserves_baseline_when_no_overrides() {
let merged = merged_headers(&BTreeMap::new());
assert_eq!(merged.len(), 5);
assert_eq!(merged[0].0, "Strict-Transport-Security");
}
#[test]
fn merged_applies_case_insensitive_override() {
let overrides =
make_overrides(&[("permissions-policy", "geolocation=(self)")]);
let merged = merged_headers(&overrides);
let pp = merged
.iter()
.find(|(k, _)| k == "Permissions-Policy")
.map(|(_, v)| v.as_str())
.unwrap();
assert_eq!(pp, "geolocation=(self)");
let hsts = merged
.iter()
.find(|(k, _)| k == "Strict-Transport-Security")
.map(|(_, v)| v.as_str())
.unwrap();
assert!(hsts.contains("max-age=63072000"));
}
#[test]
fn merged_uppercase_override_key_still_matches_baseline() {
let overrides = make_overrides(&[("PERMISSIONS-POLICY", "camera=*")]);
let merged = merged_headers(&overrides);
let pp = merged
.iter()
.find(|(k, _)| k == "Permissions-Policy")
.unwrap();
assert_eq!(pp.1, "camera=*");
}
#[test]
fn merged_appends_non_baseline_overrides() {
let overrides =
make_overrides(&[("Cross-Origin-Opener-Policy", "same-origin")]);
let merged = merged_headers(&overrides);
assert_eq!(merged.len(), 6);
assert!(merged
.iter()
.any(|(k, v)| k == "Cross-Origin-Opener-Policy"
&& v == "same-origin"));
}
#[test]
fn no_duplicate_csp_in_baseline() {
let baseline = baseline_headers();
let csp_count = baseline
.iter()
.filter(|(k, _)| k.eq_ignore_ascii_case("Content-Security-Policy"))
.count();
assert_eq!(csp_count, 1);
}
#[test]
fn plugin_name_is_stable() {
assert_eq!(EdgeHeadersPlugin.name(), "edge-headers");
}
#[test]
fn after_compile_is_noop_when_site_dir_missing() {
let ctx = PluginContext::new(
Path::new("/tmp/c"),
Path::new("/tmp/b"),
Path::new("/nonexistent/site-xyz"),
Path::new("/tmp/t"),
);
assert!(EdgeHeadersPlugin.after_compile(&ctx).is_ok());
}
#[test]
fn after_compile_is_noop_when_config_missing() {
let dir = tempfile::tempdir().unwrap();
let site = dir.path().join("site");
fs::create_dir_all(&site).unwrap();
let ctx = PluginContext::new(dir.path(), dir.path(), &site, dir.path());
EdgeHeadersPlugin.after_compile(&ctx).unwrap();
assert!(!site.join("_headers").exists());
assert!(!site.join(".ssg/edge").exists());
}
#[test]
fn after_compile_skips_when_no_targets_configured() {
let dir = tempfile::tempdir().unwrap();
let site = dir.path().join("site");
fs::create_dir_all(&site).unwrap();
let cfg = crate::cmd::SsgConfig::builder()
.site_name("t".to_string())
.base_url("http://example.com".to_string())
.build()
.unwrap();
let ctx = PluginContext::with_config(
dir.path(),
dir.path(),
&site,
dir.path(),
cfg,
);
EdgeHeadersPlugin.after_compile(&ctx).unwrap();
assert!(!site.join("_headers").exists());
}
fn cfg_with_targets(targets: Vec<&str>) -> crate::cmd::SsgConfig {
let mut edge = EdgeHeadersConfig::default();
edge.targets = targets.into_iter().map(String::from).collect();
crate::cmd::SsgConfig::builder()
.site_name("t".to_string())
.base_url("http://example.com".to_string())
.edge_headers(edge)
.build()
.unwrap()
}
#[test]
fn after_compile_cloudflare_target_writes_wrangler_headers() {
let dir = tempfile::tempdir().unwrap();
let site = dir.path().join("site");
fs::create_dir_all(&site).unwrap();
let cfg = cfg_with_targets(vec!["cloudflare"]);
let ctx = PluginContext::with_config(
dir.path(),
dir.path(),
&site,
dir.path(),
cfg,
);
EdgeHeadersPlugin.after_compile(&ctx).unwrap();
let written = site.join(".ssg/edge/wrangler-headers.toml");
assert!(written.exists(), "wrangler-headers.toml must be written");
let body = fs::read_to_string(&written).unwrap();
assert!(body.contains("Strict-Transport-Security"));
}
#[test]
fn after_compile_netlify_target_writes_underscore_headers() {
let dir = tempfile::tempdir().unwrap();
let site = dir.path().join("site");
fs::create_dir_all(&site).unwrap();
let cfg = cfg_with_targets(vec!["netlify"]);
let ctx = PluginContext::with_config(
dir.path(),
dir.path(),
&site,
dir.path(),
cfg,
);
EdgeHeadersPlugin.after_compile(&ctx).unwrap();
let written = site.join("_headers");
assert!(written.exists());
let body = fs::read_to_string(&written).unwrap();
assert!(body.contains("Strict-Transport-Security"));
}
#[test]
#[serial_test::parallel(vercel_render_fp)]
fn after_compile_vercel_target_writes_json() {
let dir = tempfile::tempdir().unwrap();
let site = dir.path().join("site");
fs::create_dir_all(&site).unwrap();
let cfg = cfg_with_targets(vec!["vercel"]);
let ctx = PluginContext::with_config(
dir.path(),
dir.path(),
&site,
dir.path(),
cfg,
);
EdgeHeadersPlugin.after_compile(&ctx).unwrap();
let written = site.join(".ssg/edge/vercel-headers.json");
assert!(written.exists());
let body = fs::read_to_string(&written).unwrap();
assert!(body.contains("Strict-Transport-Security"));
}
#[test]
fn after_compile_unknown_target_is_warned_and_skipped() {
let dir = tempfile::tempdir().unwrap();
let site = dir.path().join("site");
fs::create_dir_all(&site).unwrap();
let cfg = cfg_with_targets(vec!["unknown-cdn"]);
let ctx = PluginContext::with_config(
dir.path(),
dir.path(),
&site,
dir.path(),
cfg,
);
EdgeHeadersPlugin.after_compile(&ctx).unwrap();
assert!(!site.join("_headers").exists());
assert!(!site.join(".ssg/edge").exists());
}
#[test]
fn after_compile_target_name_is_case_insensitive() {
let dir = tempfile::tempdir().unwrap();
let site = dir.path().join("site");
fs::create_dir_all(&site).unwrap();
let cfg = cfg_with_targets(vec!["CloudFlare"]);
let ctx = PluginContext::with_config(
dir.path(),
dir.path(),
&site,
dir.path(),
cfg,
);
EdgeHeadersPlugin.after_compile(&ctx).unwrap();
assert!(site.join(".ssg/edge/wrangler-headers.toml").exists());
}
#[test]
#[serial_test::parallel(vercel_render_fp)]
fn after_compile_all_three_targets_emit_all_three_artefacts() {
let dir = tempfile::tempdir().unwrap();
let site = dir.path().join("site");
fs::create_dir_all(&site).unwrap();
let cfg = cfg_with_targets(vec!["cloudflare", "netlify", "vercel"]);
let ctx = PluginContext::with_config(
dir.path(),
dir.path(),
&site,
dir.path(),
cfg,
);
EdgeHeadersPlugin.after_compile(&ctx).unwrap();
assert!(site.join("_headers").exists());
assert!(site.join(".ssg/edge/wrangler-headers.toml").exists());
assert!(site.join(".ssg/edge/vercel-headers.json").exists());
}
#[test]
fn new_constructs_unit() {
let _ = EdgeHeadersPlugin::new();
}
#[test]
fn url_path_for_maps_index_and_plain_pages() {
let site = Path::new("/tmp/site");
assert_eq!(url_path_for(&site.join("index.html"), site), "/");
assert_eq!(
url_path_for(&site.join("blog/post/index.html"), site),
"/blog/post/"
);
assert_eq!(url_path_for(&site.join("about.html"), site), "/about.html");
}
#[test]
#[serial_test::parallel(vercel_render_fp)]
fn transform_records_page_policy_into_platform_files() {
let dir = tempfile::tempdir().unwrap();
let site = dir.path().join("site");
fs::create_dir_all(&site).unwrap();
let cfg = cfg_with_targets(vec!["netlify", "vercel"]);
let ctx = PluginContext::with_config(
dir.path(),
dir.path(),
&site,
dir.path(),
cfg,
);
let plugin = EdgeHeadersPlugin::new();
plugin.after_compile(&ctx).unwrap();
let jsonld = r#"{"@type":"BlogPosting","headline":"x"}"#;
let html = format!(
r#"<html><head><script type="application/ld+json">{jsonld}</script></head><body>b</body></html>"#
);
let page = site.join("blog/post/index.html");
let out = plugin.transform_html(&html, &page, &ctx).unwrap();
assert_eq!(out, html, "transform must be a pass-through");
let expected_hash =
crate::cmd::SriAlgorithm::Sha256.integrity(jsonld.as_bytes());
let headers_body = fs::read_to_string(site.join("_headers")).unwrap();
assert!(
headers_body.contains("/blog/post/\n"),
"per-path group missing: {headers_body}"
);
assert!(
headers_body
.contains(&format!("script-src 'self' '{expected_hash}'")),
"exact sha256 source missing: {headers_body}"
);
assert!(!headers_body.contains("unsafe-inline"));
let vercel_body =
fs::read_to_string(site.join(".ssg/edge/vercel-headers.json"))
.unwrap();
let parsed: serde_json::Value =
serde_json::from_str(&vercel_body).unwrap();
let groups = parsed["headers"].as_array().unwrap();
let page_group = groups
.iter()
.find(|g| g["source"].as_str() == Some("/blog/post/"))
.expect("per-page vercel route present");
let value = page_group["headers"][0]["value"].as_str().unwrap();
assert!(value.contains(&format!("'{expected_hash}'")));
assert!(!value.contains("unsafe-inline"));
}
#[test]
fn transform_without_inline_blocks_keeps_global_files_untouched() {
let dir = tempfile::tempdir().unwrap();
let site = dir.path().join("site");
fs::create_dir_all(&site).unwrap();
let cfg = cfg_with_targets(vec!["netlify"]);
let ctx = PluginContext::with_config(
dir.path(),
dir.path(),
&site,
dir.path(),
cfg,
);
let plugin = EdgeHeadersPlugin::new();
plugin.after_compile(&ctx).unwrap();
let before = fs::read_to_string(site.join("_headers")).unwrap();
let html = "<html><head></head><body>plain</body></html>";
let out = plugin
.transform_html(html, &site.join("index.html"), &ctx)
.unwrap();
assert_eq!(out, html);
let after = fs::read_to_string(site.join("_headers")).unwrap();
assert_eq!(before, after, "no inline blocks ⇒ no re-emit");
}
#[test]
fn transform_is_noop_when_disabled_or_unconfigured() {
let dir = tempfile::tempdir().unwrap();
let site = dir.path().join("site");
fs::create_dir_all(&site).unwrap();
let ctx = PluginContext::new(dir.path(), dir.path(), &site, dir.path());
let html = "<script>x=1</script>";
let out = EdgeHeadersPlugin::new()
.transform_html(html, &site.join("index.html"), &ctx)
.unwrap();
assert_eq!(out, html);
assert!(!site.join("_headers").exists());
}
#[test]
fn after_compile_resets_previous_builds_page_registry() {
let dir = tempfile::tempdir().unwrap();
let site = dir.path().join("site");
fs::create_dir_all(&site).unwrap();
let cfg = cfg_with_targets(vec!["netlify"]);
let ctx = PluginContext::with_config(
dir.path(),
dir.path(),
&site,
dir.path(),
cfg,
);
let plugin = EdgeHeadersPlugin::new();
plugin.after_compile(&ctx).unwrap();
let html = "<html><head><script>x=1</script></head></html>";
let _ = plugin
.transform_html(html, &site.join("old/index.html"), &ctx)
.unwrap();
assert!(fs::read_to_string(site.join("_headers"))
.unwrap()
.contains("/old/"));
plugin.after_compile(&ctx).unwrap();
let body = fs::read_to_string(site.join("_headers")).unwrap();
assert!(!body.contains("/old/"), "stale page survived reset: {body}");
}
#[test]
fn two_pages_accumulate_sorted_entries() {
let dir = tempfile::tempdir().unwrap();
let site = dir.path().join("site");
fs::create_dir_all(&site).unwrap();
let cfg = cfg_with_targets(vec!["netlify"]);
let ctx = PluginContext::with_config(
dir.path(),
dir.path(),
&site,
dir.path(),
cfg,
);
let plugin = EdgeHeadersPlugin::new();
plugin.after_compile(&ctx).unwrap();
let html_a = "<html><head><script>a=1</script></head></html>";
let html_b = "<html><head><script>b=2</script></head></html>";
let _ = plugin
.transform_html(html_b, &site.join("zeta/index.html"), &ctx)
.unwrap();
let _ = plugin
.transform_html(html_a, &site.join("alpha/index.html"), &ctx)
.unwrap();
let body = fs::read_to_string(site.join("_headers")).unwrap();
let i_alpha = body.find("/alpha/").unwrap();
let i_zeta = body.find("/zeta/").unwrap();
assert!(
i_alpha < i_zeta,
"entries must be sorted regardless of insertion order"
);
}
fn edge_cfg(targets: &[&str]) -> EdgeHeadersConfig {
let mut edge = EdgeHeadersConfig::default();
edge.targets = targets.iter().map(|t| (*t).to_string()).collect();
edge
}
#[test]
fn emit_targets_cloudflare_errors_when_ssg_dir_is_a_file() {
let dir = tempfile::tempdir().unwrap();
let site = dir.path().join("site");
fs::create_dir_all(&site).unwrap();
fs::write(site.join(".ssg"), "not a dir").unwrap();
let ctx = PluginContext::new(dir.path(), dir.path(), &site, dir.path());
let err =
emit_targets(&ctx, &edge_cfg(&["cloudflare"]), &BTreeMap::new())
.unwrap_err();
assert!(format!("{err}").contains(".ssg"));
}
#[test]
fn emit_targets_cloudflare_errors_when_output_is_a_directory() {
let dir = tempfile::tempdir().unwrap();
let site = dir.path().join("site");
fs::create_dir_all(site.join(".ssg/edge/wrangler-headers.toml"))
.unwrap();
let ctx = PluginContext::new(dir.path(), dir.path(), &site, dir.path());
let err =
emit_targets(&ctx, &edge_cfg(&["cloudflare"]), &BTreeMap::new())
.unwrap_err();
assert!(format!("{err}").contains("wrangler-headers.toml"));
}
#[test]
fn emit_targets_netlify_errors_when_headers_is_a_directory() {
let dir = tempfile::tempdir().unwrap();
let site = dir.path().join("site");
fs::create_dir_all(site.join("_headers")).unwrap();
let ctx = PluginContext::new(dir.path(), dir.path(), &site, dir.path());
let err = emit_targets(&ctx, &edge_cfg(&["netlify"]), &BTreeMap::new())
.unwrap_err();
assert!(format!("{err}").contains("_headers"));
}
#[test]
fn emit_targets_vercel_errors_when_ssg_dir_is_a_file() {
let dir = tempfile::tempdir().unwrap();
let site = dir.path().join("site");
fs::create_dir_all(&site).unwrap();
fs::write(site.join(".ssg"), "not a dir").unwrap();
let ctx = PluginContext::new(dir.path(), dir.path(), &site, dir.path());
let err = emit_targets(&ctx, &edge_cfg(&["vercel"]), &BTreeMap::new())
.unwrap_err();
assert!(format!("{err}").contains(".ssg"));
}
#[test]
fn emit_targets_vercel_errors_when_output_is_a_directory() {
let dir = tempfile::tempdir().unwrap();
let site = dir.path().join("site");
fs::create_dir_all(site.join(".ssg/edge/vercel-headers.json")).unwrap();
let ctx = PluginContext::new(dir.path(), dir.path(), &site, dir.path());
let err = emit_targets(&ctx, &edge_cfg(&["vercel"]), &BTreeMap::new())
.unwrap_err();
assert!(format!("{err}").contains("vercel-headers.json"));
}
#[test]
fn transform_html_propagates_emit_failure() {
let dir = tempfile::tempdir().unwrap();
let site = dir.path().join("site");
fs::create_dir_all(site.join("_headers")).unwrap();
let cfg = cfg_with_targets(vec!["netlify"]);
let ctx = PluginContext::with_config(
dir.path(),
dir.path(),
&site,
dir.path(),
cfg,
);
let html = "<html><head><script>var x = 1;</script></head><body></body></html>";
let err = EdgeHeadersPlugin
.transform_html(html, &site.join("index.html"), &ctx)
.unwrap_err();
assert!(format!("{err}").contains("_headers"));
}
#[test]
fn after_compile_and_transform_recover_from_poisoned_registry_lock() {
let poisoned = std::thread::spawn(|| {
let _guard = PAGE_CSP_REGISTRY.lock().unwrap();
panic!("intentional poison for coverage of the recovery arm");
})
.join();
assert!(poisoned.is_err(), "spawned thread must have panicked");
let dir = tempfile::tempdir().unwrap();
let site = dir.path().join("site");
fs::create_dir_all(&site).unwrap();
let cfg = cfg_with_targets(vec!["netlify"]);
let ctx = PluginContext::with_config(
dir.path(),
dir.path(),
&site,
dir.path(),
cfg,
);
let plugin = EdgeHeadersPlugin::new();
plugin.after_compile(&ctx).unwrap();
let html = "<html><head><script>x=1</script></head></html>";
let out = plugin
.transform_html(html, &site.join("index.html"), &ctx)
.unwrap();
assert_eq!(out, html);
}
}
#[cfg(all(test, feature = "test-fault-injection"))]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod fault_tests {
use super::*;
struct FailGuard(&'static str);
impl Drop for FailGuard {
fn drop(&mut self) {
let _ = fail::cfg(self.0, "off");
}
}
fn cfg_with_targets(targets: Vec<&str>) -> crate::cmd::SsgConfig {
let mut edge = EdgeHeadersConfig::default();
edge.targets = targets.into_iter().map(String::from).collect();
crate::cmd::SsgConfig::builder()
.site_name("t".to_string())
.base_url("http://example.com".to_string())
.edge_headers(edge)
.build()
.unwrap()
}
#[test]
#[serial_test::serial(vercel_render_fp)]
fn after_compile_vercel_maps_serialize_failure_to_io_error() {
let _guard = FailGuard("postprocess::vercel-render");
fail::cfg("postprocess::vercel-render", "return")
.expect("activate failpoint");
let dir = tempfile::tempdir().unwrap();
let site = dir.path().join("site");
fs::create_dir_all(&site).unwrap();
let cfg = cfg_with_targets(vec!["vercel"]);
let ctx = PluginContext::with_config(
dir.path(),
dir.path(),
&site,
dir.path(),
cfg,
);
let err = EdgeHeadersPlugin
.after_compile(&ctx)
.expect_err("injected serialize failure must propagate");
let msg = format!("{err}");
assert!(msg.contains("vercel-headers.json"), "got: {msg}");
assert!(
msg.contains("injected: postprocess::vercel-render"),
"got: {msg}"
);
}
}