use crate::error::{PathErrorExt, SsgError};
use crate::plugin::{Plugin, PluginContext};
use anyhow::Result;
use base64::{engine::general_purpose::STANDARD as BASE64, Engine as _};
use sha2::{Digest, Sha256};
use std::{fs, path::Path};
pub 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'";
#[must_use]
pub const fn computed_policy() -> &'static str {
DEFAULT_CSP_POLICY
}
#[derive(Debug, Clone, Copy, Default)]
pub struct CspPlugin;
impl CspPlugin {
#[must_use]
pub const fn new() -> Self {
Self
}
}
impl Plugin for CspPlugin {
fn name(&self) -> &'static str {
"csp"
}
fn has_transform(&self) -> bool {
true
}
fn transform_html(
&self,
html: &str,
path: &Path,
ctx: &PluginContext,
) -> Result<String, SsgError> {
let csp_dir = ctx.site_dir.join("_csp");
let (rewritten, extracted) =
extract_inline_blocks(html, &csp_dir, &ctx.site_dir)
.map_err(|e| SsgError::io(e, path))?;
if extracted > 0 {
let final_html = remove_unsafe_inline_from_csp(&rewritten);
Ok(final_html)
} else {
Ok(html.to_string())
}
}
fn after_compile(&self, ctx: &PluginContext) -> Result<(), SsgError> {
if !ctx.site_dir.exists() {
return Ok(());
}
let csp_dir = ctx.site_dir.join("_csp");
fs::create_dir_all(&csp_dir).with_path(&csp_dir)?;
Ok(())
}
}
fn extract_inline_blocks(
html: &str,
csp_dir: &Path,
site_dir: &Path,
) -> Result<(String, usize)> {
let mut result = html.to_string();
let mut count = 0;
while let Some((before, content, after)) =
find_inline_block(&result, "style")
{
let hash = fnv_hash(content.as_bytes());
let filename = format!("{hash:016x}.css");
let file_path = csp_dir.join(&filename);
fs::create_dir_all(csp_dir)?;
fs::write(&file_path, content.as_bytes())?;
let sri = compute_sri(content.as_bytes());
let rel_path = file_path
.strip_prefix(site_dir)
.unwrap_or(&file_path)
.to_string_lossy()
.replace('\\', "/");
let link_tag = format!(
"<link rel=\"stylesheet\" href=\"/{}\" integrity=\"{}\" crossorigin=\"anonymous\">",
rel_path, sri
);
result = format!("{before}{link_tag}{after}");
count += 1;
}
while let Some((before, opening_tag, content, after)) =
find_inline_script(&result)
{
let hash = fnv_hash(content.as_bytes());
let filename = format!("{hash:016x}.js");
let file_path = csp_dir.join(&filename);
fs::create_dir_all(csp_dir)?;
fs::write(&file_path, content.as_bytes())?;
let sri = compute_sri(content.as_bytes());
let rel_path = file_path
.strip_prefix(site_dir)
.unwrap_or(&file_path)
.to_string_lossy()
.replace('\\', "/");
let preserved = preserve_script_attrs(
&opening_tag,
&["src", "integrity", "crossorigin"],
);
let script_tag = if preserved.is_empty() {
format!(
"<script src=\"/{rel_path}\" integrity=\"{sri}\" crossorigin=\"anonymous\"></script>"
)
} else {
format!(
"<script {preserved} src=\"/{rel_path}\" integrity=\"{sri}\" crossorigin=\"anonymous\"></script>"
)
};
result = format!("{before}{script_tag}{after}");
count += 1;
}
Ok((result, count))
}
fn preserve_script_attrs(opening_tag: &str, drop: &[&str]) -> String {
use crate::util::html_rewriter::rewrite_html;
use lol_html::element;
use std::cell::RefCell;
use std::rc::Rc;
let fragment = format!("{opening_tag}</script>");
let collected: Rc<RefCell<Vec<(String, String)>>> =
Rc::new(RefCell::new(Vec::new()));
let collected_cb = Rc::clone(&collected);
let _ = rewrite_html(
&fragment,
vec![element!("script", move |el| {
for attr in el.attributes() {
collected_cb.borrow_mut().push((attr.name(), attr.value()));
}
Ok(())
})],
);
let drop_lower: Vec<String> =
drop.iter().map(|d| d.to_ascii_lowercase()).collect();
let parts: Vec<String> = collected
.borrow()
.iter()
.filter(|(name, _)| !drop_lower.contains(&name.to_ascii_lowercase()))
.map(|(name, value)| {
if value.is_empty() {
name.clone()
} else {
let escaped = value.replace('"', """);
format!("{name}=\"{escaped}\"")
}
})
.collect();
parts.join(" ")
}
fn find_inline_block<'a>(
html: &'a str,
tag: &str,
) -> Option<(&'a str, &'a str, &'a str)> {
let open = format!("<{tag}>");
let close = format!("</{tag}>");
let start = html.find(&open)?;
let content_start = start + open.len();
let content_end = html[content_start..].find(&close)? + content_start;
let end = content_end + close.len();
let content = &html[content_start..content_end];
if content.trim().is_empty() {
return None;
}
Some((&html[..start], content, &html[end..]))
}
fn find_inline_script(html: &str) -> Option<(String, String, String, String)> {
let mut search_from = 0;
loop {
let rest = &html[search_from..];
let start = rest.find("<script")?;
let abs_start = search_from + start;
let tag_end = html[abs_start..].find('>')? + abs_start;
let opening_tag = &html[abs_start..=tag_end];
if opening_tag.contains("application/ld+json")
|| opening_tag.contains("data-ssg-livereload")
|| opening_tag.contains("src=")
{
search_from = tag_end + 1;
continue;
}
let content_start = tag_end + 1;
let close_tag = "</script>";
let content_end =
html[content_start..].find(close_tag)? + content_start;
let end = content_end + close_tag.len();
let content = &html[content_start..content_end];
if content.trim().is_empty() {
search_from = end;
continue;
}
return Some((
html[..abs_start].to_string(),
opening_tag.to_string(),
content.to_string(),
html[end..].to_string(),
));
}
}
fn remove_unsafe_inline_from_csp(html: &str) -> String {
html.replace("'unsafe-inline'", "").replace(" ;", " ;")
}
#[must_use]
pub fn inject_csp_meta(html: &str, policy: &str) -> String {
use crate::util::html_rewriter::rewrite_html;
use lol_html::element;
use lol_html::html_content::ContentType;
use std::cell::Cell;
use std::rc::Rc;
let already_present = Rc::new(Cell::new(false));
let already_present_cb = Rc::clone(&already_present);
let detect = element!(
"meta[http-equiv=\"Content-Security-Policy\" i]",
move |_el| {
already_present_cb.set(true);
Ok(())
}
);
let _ = rewrite_html(html, vec![detect]);
if already_present.get() {
return html.to_string();
}
let policy = policy.to_string();
let injected = Rc::new(Cell::new(false));
let injected_cb = Rc::clone(&injected);
let head_handler = element!("head", move |el| {
let meta = format!(
"<meta http-equiv=\"Content-Security-Policy\" content=\"{policy}\">"
);
el.prepend(&meta, ContentType::Html);
injected_cb.set(true);
Ok(())
});
rewrite_html(html, vec![head_handler]).unwrap_or_else(|_| html.to_string())
}
fn fnv_hash(data: &[u8]) -> u64 {
let mut h: u64 = 0xcbf2_9ce4_8422_2325;
for &b in data {
h ^= u64::from(b);
h = h.wrapping_mul(0x0000_0100_0000_01b3);
}
h
}
fn compute_sri(data: &[u8]) -> String {
let mut hasher = Sha256::new();
hasher.update(data);
let bytes = hasher.finalize();
let b64 = BASE64.encode(bytes);
format!("sha256-{b64}")
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
use tempfile::tempdir;
#[test]
fn extract_style_block() {
let html = "<html><head><style>body { color: red; }</style></head><body></body></html>";
let dir = tempdir().unwrap();
let csp_dir = dir.path().join("_csp");
let (result, count) =
extract_inline_blocks(html, &csp_dir, dir.path()).unwrap();
assert_eq!(count, 1);
assert!(result.contains("<link rel=\"stylesheet\""));
assert!(result.contains("integrity="));
assert!(!result.contains("<style>"));
}
#[test]
fn extract_script_block() {
let html =
"<html><body><script>console.log('hi');</script></body></html>";
let dir = tempdir().unwrap();
let csp_dir = dir.path().join("_csp");
let (result, count) =
extract_inline_blocks(html, &csp_dir, dir.path()).unwrap();
assert_eq!(count, 1);
assert!(result.contains("<script src="));
assert!(result.contains("integrity="));
assert!(!result.contains("console.log"));
}
#[test]
fn skips_jsonld_scripts() {
let html = r#"<html><body><script type="application/ld+json">{"@type":"Thing"}</script></body></html>"#;
let dir = tempdir().unwrap();
let csp_dir = dir.path().join("_csp");
let (result, count) =
extract_inline_blocks(html, &csp_dir, dir.path()).unwrap();
assert_eq!(count, 0);
assert!(result.contains("application/ld+json"));
}
#[test]
fn skips_livereload_scripts() {
let html = r#"<html><body><script data-ssg-livereload>ws.connect();</script></body></html>"#;
let dir = tempdir().unwrap();
let csp_dir = dir.path().join("_csp");
let (result, count) =
extract_inline_blocks(html, &csp_dir, dir.path()).unwrap();
assert_eq!(count, 0);
assert!(result.contains("data-ssg-livereload"));
}
#[test]
fn skips_external_scripts() {
let html =
r#"<html><body><script src="/app.js"></script></body></html>"#;
let dir = tempdir().unwrap();
let csp_dir = dir.path().join("_csp");
let (result, count) =
extract_inline_blocks(html, &csp_dir, dir.path()).unwrap();
assert_eq!(count, 0);
assert_eq!(result, html);
}
#[test]
fn removes_unsafe_inline_from_csp() {
let html = r#"<meta content="script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'">"#;
let result = remove_unsafe_inline_from_csp(html);
assert!(!result.contains("unsafe-inline"));
}
#[test]
fn skips_empty_style_blocks() {
let html = "<html><head><style> </style></head></html>";
let dir = tempdir().unwrap();
let csp_dir = dir.path().join("_csp");
let (_, count) =
extract_inline_blocks(html, &csp_dir, dir.path()).unwrap();
assert_eq!(count, 0);
}
#[test]
fn csp_plugin_name() {
assert_eq!(CspPlugin.name(), "csp");
}
#[test]
fn csp_plugin_skips_missing_site_dir() {
let ctx = PluginContext::new(
Path::new("/tmp/c"),
Path::new("/tmp/b"),
Path::new("/nonexistent/site"),
Path::new("/tmp/t"),
);
assert!(CspPlugin.after_compile(&ctx).is_ok());
}
#[test]
fn csp_plugin_processes_html_files() {
let dir = tempdir().unwrap();
let site = dir.path().join("site");
fs::create_dir_all(&site).unwrap();
let html = "<html><head><style>body{color:red}</style></head><body><script>alert(1)</script></body></html>";
let ctx = PluginContext::new(dir.path(), dir.path(), &site, dir.path());
CspPlugin.after_compile(&ctx).unwrap();
let output = CspPlugin
.transform_html(html, &site.join("index.html"), &ctx)
.unwrap();
assert!(output.contains("<link rel=\"stylesheet\""));
assert!(output.contains("<script src="));
assert!(!output.contains("body{color:red}"));
assert!(!output.contains("alert(1)"));
assert!(site.join("_csp").exists());
}
#[test]
fn fnv_hash_deterministic() {
let h1 = fnv_hash(b"hello");
let h2 = fnv_hash(b"hello");
assert_eq!(h1, h2);
}
#[test]
fn fnv_hash_different_inputs() {
assert_ne!(fnv_hash(b"a"), fnv_hash(b"b"));
}
#[test]
fn compute_sri_format() {
let sri = compute_sri(b"test");
assert!(sri.starts_with("sha256-"));
}
#[test]
fn csp_plugin_new_constructs_unit_struct() {
let p = CspPlugin::new();
assert_eq!(p.name(), "csp");
assert!(p.has_transform());
}
#[test]
fn inject_csp_meta_adds_meta_when_absent() {
let html = "<html><head><title>T</title></head><body></body></html>";
let out = inject_csp_meta(html, "default-src 'self'");
assert!(out.contains("http-equiv=\"Content-Security-Policy\""));
assert!(out.contains("default-src 'self'"));
}
#[test]
fn inject_csp_meta_is_idempotent_when_meta_already_present() {
let html = r#"<html><head><meta http-equiv="Content-Security-Policy" content="default-src 'self'"></head></html>"#;
let out = inject_csp_meta(html, "script-src 'self'");
let count = out
.matches("http-equiv=\"Content-Security-Policy\"")
.count();
assert_eq!(count, 1, "must not duplicate CSP meta tag");
assert!(!out.contains("script-src 'self'"));
}
#[test]
fn inject_csp_meta_handles_no_head_gracefully() {
let html = "<html><body>no head</body></html>";
let out = inject_csp_meta(html, "default-src 'self'");
assert_eq!(out, html);
}
#[test]
fn preserve_script_attrs_keeps_type_module() {
let out = preserve_script_attrs(
r#"<script type="module">"#,
&["src", "integrity", "crossorigin"],
);
assert!(out.contains(r#"type="module""#), "got: {out}");
}
#[test]
fn preserve_script_attrs_keeps_boolean_async_defer() {
let out = preserve_script_attrs(
"<script async defer>",
&["src", "integrity", "crossorigin"],
);
assert!(out.contains("async"), "got: {out}");
assert!(out.contains("defer"), "got: {out}");
}
#[test]
fn preserve_script_attrs_drops_listed_attrs() {
let out = preserve_script_attrs(
r#"<script src="/x.js" integrity="sha384-foo" crossorigin="anonymous" data-id="9">"#,
&["src", "integrity", "crossorigin"],
);
assert!(!out.contains("src="), "got: {out}");
assert!(!out.contains("integrity="), "got: {out}");
assert!(!out.contains("crossorigin="), "got: {out}");
assert!(out.contains(r#"data-id="9""#), "got: {out}");
}
#[test]
fn preserve_script_attrs_empty_when_no_attrs() {
let out = preserve_script_attrs("<script>", &["src"]);
assert_eq!(out, "");
}
#[test]
fn extract_inline_script_preserves_type_module() {
let html = r#"<html><body><script type="module">import x from '/m.js';</script></body></html>"#;
let dir = tempdir().unwrap();
let csp_dir = dir.path().join("_csp");
let (out, count) =
extract_inline_blocks(html, &csp_dir, dir.path()).unwrap();
assert_eq!(count, 1);
assert!(out.contains(r#"type="module""#), "got: {out}");
assert!(out.contains("integrity="), "got: {out}");
}
#[test]
fn extract_inline_script_preserves_data_attrs() {
let html = r#"<html><body><script data-domain="example.com">window.x=1;</script></body></html>"#;
let dir = tempdir().unwrap();
let csp_dir = dir.path().join("_csp");
let (out, count) =
extract_inline_blocks(html, &csp_dir, dir.path()).unwrap();
assert_eq!(count, 1);
assert!(out.contains(r#"data-domain="example.com""#), "got: {out}");
}
#[test]
fn csp_plugin_transform_html_no_inline_blocks_returns_unchanged() {
let html =
"<html><head><title>X</title></head><body><p>hi</p></body></html>";
let dir = 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 out = CspPlugin
.transform_html(html, &site.join("index.html"), &ctx)
.unwrap();
assert_eq!(out, html);
}
}