pub(crate) mod cloudflare;
pub(crate) mod netlify;
pub(crate) mod vercel;
use crate::error::{PathErrorExt, SsgError};
use crate::plugin::{Plugin, PluginContext};
use std::collections::BTreeMap;
use std::fs;
#[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> {
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(());
}
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);
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).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(())
}
}
#[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(
std::path::Path::new("/tmp/c"),
std::path::Path::new("/tmp/b"),
std::path::Path::new("/nonexistent/site-xyz"),
std::path::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 = crate::cmd::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(), "{}", written.display());
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]
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]
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();
}
}