use crate::cmd::SriAlgorithm;
use crate::error::{PathErrorExt, SsgError};
use crate::plugin::{Plugin, PluginContext};
use anyhow::Result;
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'";
pub const DEFAULT_CSP_POLICY_TEMPLATE: &str = "default-src 'self'; script-src 'self'{script_hashes}; style-src 'self'{style_hashes}; img-src 'self' https: data:; font-src 'self' https:; connect-src 'self'; frame-ancestors 'none'";
#[must_use]
pub const fn computed_policy() -> &'static str {
DEFAULT_CSP_POLICY
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct PageCspHashes {
pub scripts: Vec<String>,
pub styles: Vec<String>,
}
impl PageCspHashes {
#[must_use]
pub const fn is_empty(&self) -> bool {
self.scripts.is_empty() && self.styles.is_empty()
}
}
#[must_use]
pub fn page_inline_hashes(html: &str) -> PageCspHashes {
let hash =
|content: &str| SriAlgorithm::Sha256.integrity(content.as_bytes());
let mut scripts = Vec::new();
for content in collect_inline_contents(html, "script") {
let h = hash(content);
if !scripts.contains(&h) {
scripts.push(h);
}
}
let mut styles = Vec::new();
for content in collect_inline_contents(html, "style") {
let h = hash(content);
if !styles.contains(&h) {
styles.push(h);
}
}
PageCspHashes { scripts, styles }
}
#[must_use]
pub fn render_policy_template(
template: &str,
script_hashes: &[String],
style_hashes: &[String],
) -> String {
let expand = |hashes: &[String]| -> String {
let mut out = String::new();
for h in hashes {
out.push_str(" '");
out.push_str(h);
out.push('\'');
}
out
};
template
.replace("{script_hashes}", &expand(script_hashes))
.replace("{style_hashes}", &expand(style_hashes))
}
#[must_use]
pub fn page_policy(html: &str) -> Option<String> {
let hashes = page_inline_hashes(html);
if hashes.is_empty() {
return None;
}
Some(render_policy_template(
DEFAULT_CSP_POLICY_TEMPLATE,
&hashes.scripts,
&hashes.styles,
))
}
fn collect_inline_contents<'a>(html: &'a str, tag: &str) -> Vec<&'a str> {
let open = format!("<{tag}");
let close = format!("</{tag}>");
let mut out = Vec::new();
let mut from = 0;
while let Some(pos) = html[from..].find(&open) {
let abs = from + pos;
let after_open = abs + open.len();
match html[after_open..].chars().next() {
Some(c) if c == '>' || c.is_ascii_whitespace() || c == '/' => {}
_ => {
from = after_open;
continue;
}
}
let Some(tag_end_rel) = html[abs..].find('>') else {
break;
};
let content_start = abs + tag_end_rel + 1;
let opening_tag = &html[abs..content_start];
if tag == "script" && opening_tag.contains("src=") {
from = content_start;
continue;
}
let Some(close_rel) = html[content_start..].find(&close) else {
break;
};
let content = &html[content_start..content_start + close_rel];
if !content.trim().is_empty() {
out.push(content);
}
from = content_start + close_rel + close.len();
}
out
}
#[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 sri_algorithm = ctx
.config
.as_ref()
.map_or_else(SriAlgorithm::default, |c| c.security.sri_algorithm);
let (rewritten, extracted) =
extract_inline_blocks(html, &csp_dir, &ctx.site_dir, sri_algorithm)
.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,
sri_algorithm: SriAlgorithm,
) -> 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(), sri_algorithm);
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(), sri_algorithm);
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_or_original(html, rewrite_html(html, vec![head_handler]))
}
fn rewrite_or_original(html: &str, res: Result<String, SsgError>) -> String {
res.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], sri_algorithm: SriAlgorithm) -> String {
sri_algorithm.integrity(data)
}
#[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(),
SriAlgorithm::default(),
)
.unwrap();
assert_eq!(count, 1);
assert!(result.contains("<link rel=\"stylesheet\""));
assert!(result.contains("integrity=\"sha384-"));
assert!(!result.contains("<style>"));
}
#[test]
fn extract_style_block_default_sha384_exact_vector() {
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(),
SriAlgorithm::default(),
)
.unwrap();
assert_eq!(count, 1);
assert!(
result.contains(
"integrity=\"sha384-BN8siYsJqlPeNsRFs2pYbTW0uiUBy9v6JVVKpHaS+KNqD0ZFotD5OFKMkI6/s6sb\""
),
"expected exact SHA-384 SRI vector; got: {result}"
);
}
#[test]
fn transform_html_sri_algorithm_config_override_emits_sha256() {
use crate::cmd::{SecurityConfig, SsgConfig};
let html = "<html><head><style>body { color: red; }</style></head><body></body></html>";
let dir = tempdir().unwrap();
let site = dir.path().join("site");
fs::create_dir_all(&site).unwrap();
let config = SsgConfig::builder()
.security(SecurityConfig {
sri_algorithm: SriAlgorithm::Sha256,
})
.build()
.unwrap();
let ctx = PluginContext::with_config(
dir.path(),
dir.path(),
&site,
dir.path(),
config,
);
let out = CspPlugin
.transform_html(html, &site.join("index.html"), &ctx)
.unwrap();
assert!(
out.contains(
"integrity=\"sha256-XeYlw2NVzOfB1UCIJqCyGr+0n7bA4fFslFpvKu84IAw=\""
),
"expected exact SHA-256 SRI vector; got: {out}"
);
assert!(!out.contains("sha384-"), "override must win: {out}");
}
#[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(),
SriAlgorithm::default(),
)
.unwrap();
assert_eq!(count, 1);
assert!(result.contains("<script src="));
assert!(result.contains("integrity=\"sha384-"));
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(),
SriAlgorithm::default(),
)
.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(),
SriAlgorithm::default(),
)
.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(),
SriAlgorithm::default(),
)
.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(),
SriAlgorithm::default(),
)
.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", SriAlgorithm::default());
assert!(sri.starts_with("sha384-"));
assert!(
compute_sri(b"test", SriAlgorithm::Sha256).starts_with("sha256-")
);
assert!(
compute_sri(b"test", SriAlgorithm::Sha512).starts_with("sha512-")
);
}
#[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(),
SriAlgorithm::default(),
)
.unwrap();
assert_eq!(count, 1);
assert!(out.contains(r#"type="module""#), "got: {out}");
assert!(out.contains("integrity=\"sha384-"), "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(),
SriAlgorithm::default(),
)
.unwrap();
assert_eq!(count, 1);
assert!(out.contains(r#"data-domain="example.com""#), "got: {out}");
}
const JSONLD_BODY: &str = r#"{"@type":"Thing"}"#;
#[test]
fn template_with_empty_slots_is_exactly_the_global_policy() {
assert_eq!(
render_policy_template(DEFAULT_CSP_POLICY_TEMPLATE, &[], &[]),
DEFAULT_CSP_POLICY
);
}
#[test]
fn page_inline_hashes_includes_jsonld_blocks() {
let html = format!(
r#"<html><body><script type="application/ld+json">{JSONLD_BODY}</script></body></html>"#
);
let hashes = page_inline_hashes(&html);
assert_eq!(hashes.scripts.len(), 1);
let expected = SriAlgorithm::Sha256.integrity(JSONLD_BODY.as_bytes());
assert_eq!(hashes.scripts[0], expected);
}
#[test]
fn page_inline_hashes_skips_external_scripts_and_empty_blocks() {
let html = r#"<html><body>
<script src="/app.js"></script>
<script> </script>
<style></style>
</body></html>"#;
assert!(page_inline_hashes(html).is_empty());
}
#[test]
fn page_inline_hashes_orders_by_document_position_and_dedups() {
let html =
"<script>aaa</script><script>bbb</script><script>aaa</script>";
let hashes = page_inline_hashes(html);
assert_eq!(hashes.scripts.len(), 2, "duplicate block deduped");
assert_eq!(
hashes.scripts[0],
SriAlgorithm::Sha256.integrity(b"aaa"),
"document order preserved"
);
assert_eq!(hashes.scripts[1], SriAlgorithm::Sha256.integrity(b"bbb"));
}
#[test]
fn page_inline_hashes_collects_styles_separately() {
let html = "<style>body{color:red}</style><script>alert(1)</script>";
let hashes = page_inline_hashes(html);
assert_eq!(hashes.styles.len(), 1);
assert_eq!(hashes.scripts.len(), 1);
assert_eq!(
hashes.styles[0],
SriAlgorithm::Sha256.integrity(b"body{color:red}")
);
}
#[test]
fn page_policy_is_hash_strict_never_unsafe_inline() {
let html = format!(
r#"<script type="application/ld+json">{JSONLD_BODY}</script>"#
);
let policy = page_policy(&html).expect("policy for inline JSON-LD");
let expected = SriAlgorithm::Sha256.integrity(JSONLD_BODY.as_bytes());
assert!(
policy.contains(&format!("script-src 'self' '{expected}'")),
"policy must carry the exact sha256 source: {policy}"
);
assert!(!policy.contains("unsafe-inline"));
}
#[test]
fn page_policy_none_without_inline_blocks() {
assert!(page_policy(
"<html><head><title>t</title></head><body>x</body></html>"
)
.is_none());
}
#[test]
fn page_policy_is_deterministic() {
let html = "<style>a{}</style><script>x=1</script>";
assert_eq!(page_policy(html), page_policy(html));
}
#[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);
}
#[test]
fn page_inline_hashes_dedupes_identical_style_blocks() {
let html = "<style>a{color:red}</style><style>a{color:red}</style>";
let hashes = page_inline_hashes(html);
assert_eq!(hashes.styles.len(), 1);
}
#[test]
fn collect_inline_contents_skips_prefix_tag_names() {
let html = "<styles>ignored</styles><style>a{}</style>";
let out = collect_inline_contents(html, "style");
assert_eq!(out, vec!["a{}"]);
}
#[test]
fn collect_inline_contents_accepts_slash_after_tag_name() {
let html = "<style/>a{}</style>";
let out = collect_inline_contents(html, "style");
assert_eq!(out, vec!["a{}"]);
}
#[test]
fn collect_inline_contents_stops_when_opening_tag_unterminated() {
let html = "<style media=all";
assert!(collect_inline_contents(html, "style").is_empty());
}
#[test]
fn collect_inline_contents_stops_when_close_tag_missing() {
let html = "<style>a{} no closing fence";
assert!(collect_inline_contents(html, "style").is_empty());
}
#[test]
fn find_inline_block_returns_none_without_close_tag() {
assert!(find_inline_block("<style>a{}", "style").is_none());
}
#[test]
fn find_inline_script_returns_none_when_opening_unterminated() {
assert!(find_inline_script("<script").is_none());
}
#[test]
fn find_inline_script_returns_none_without_close_tag() {
assert!(find_inline_script("<script>var x = 1;").is_none());
}
#[test]
fn find_inline_script_skips_empty_script_then_finds_real_one() {
let html = "<script> </script><script>var x = 1;</script>";
let (_, _, content, _) = find_inline_script(html).unwrap();
assert_eq!(content, "var x = 1;");
}
#[test]
fn rewrite_or_original_returns_input_on_error() {
let err = SsgError::io(
std::io::Error::other("synthetic rewrite failure"),
"<lol_html>",
);
assert_eq!(rewrite_or_original("<p>x</p>", Err(err)), "<p>x</p>");
}
#[test]
fn after_compile_fails_when_csp_dir_squatted_by_file() {
let dir = tempdir().unwrap();
let site = dir.path().join("site");
fs::create_dir_all(&site).unwrap();
fs::write(site.join("_csp"), "not a dir").unwrap();
let ctx = PluginContext::new(dir.path(), dir.path(), &site, dir.path());
let err = CspPlugin.after_compile(&ctx).unwrap_err();
assert!(!format!("{err}").is_empty());
}
#[test]
fn transform_html_fails_when_csp_dir_squatted_by_file() {
let dir = tempdir().unwrap();
let site = dir.path().join("site");
fs::create_dir_all(&site).unwrap();
fs::write(site.join("_csp"), "not a dir").unwrap();
let ctx = PluginContext::new(dir.path(), dir.path(), &site, dir.path());
let err = CspPlugin
.transform_html(
"<style>a{}</style>",
&site.join("index.html"),
&ctx,
)
.unwrap_err();
assert!(!format!("{err}").is_empty());
}
#[test]
fn extract_inline_blocks_script_dir_create_fails_when_squatted_by_file() {
let dir = tempdir().unwrap();
let site = dir.path().join("site");
fs::create_dir_all(&site).unwrap();
fs::write(site.join("_csp"), "not a dir").unwrap();
let res = extract_inline_blocks(
"<script>var x = 1;</script>",
&site.join("_csp"),
&site,
SriAlgorithm::default(),
);
assert!(res.is_err());
}
#[test]
fn extract_inline_blocks_style_write_fails_when_squatted_by_dir() {
let dir = tempdir().unwrap();
let site = dir.path().join("site");
let csp_dir = site.join("_csp");
let content = "a{color:red}";
let squat =
csp_dir.join(format!("{:016x}.css", fnv_hash(content.as_bytes())));
fs::create_dir_all(&squat).unwrap();
let res = extract_inline_blocks(
&format!("<style>{content}</style>"),
&csp_dir,
&site,
SriAlgorithm::default(),
);
assert!(res.is_err());
}
#[test]
fn extract_inline_blocks_script_write_fails_when_squatted_by_dir() {
let dir = tempdir().unwrap();
let site = dir.path().join("site");
let csp_dir = site.join("_csp");
let content = "var x = 1;";
let squat =
csp_dir.join(format!("{:016x}.js", fnv_hash(content.as_bytes())));
fs::create_dir_all(&squat).unwrap();
let res = extract_inline_blocks(
&format!("<script>{content}</script>"),
&csp_dir,
&site,
SriAlgorithm::default(),
);
assert!(res.is_err());
}
}