1use crate::cmd::SriAlgorithm;
11use crate::error::{PathErrorExt, SsgError};
12use crate::plugin::{Plugin, PluginContext};
13use anyhow::Result;
14use std::{fs, path::Path};
15
16pub 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'";
27
28pub 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'";
59
60#[must_use]
83pub const fn computed_policy() -> &'static str {
84 DEFAULT_CSP_POLICY
85}
86
87#[derive(Debug, Clone, Default, PartialEq, Eq)]
107pub struct PageCspHashes {
108 pub scripts: Vec<String>,
113 pub styles: Vec<String>,
115}
116
117impl PageCspHashes {
118 #[must_use]
128 pub const fn is_empty(&self) -> bool {
129 self.scripts.is_empty() && self.styles.is_empty()
130 }
131}
132
133#[must_use]
157pub fn page_inline_hashes(html: &str) -> PageCspHashes {
158 let hash =
159 |content: &str| SriAlgorithm::Sha256.integrity(content.as_bytes());
160
161 let (raw_scripts, raw_styles) = collect_inline_script_and_style(html);
163
164 let dedup = |raw: Vec<String>| {
165 let mut out: Vec<String> = Vec::with_capacity(raw.len());
166 for content in raw {
167 let h = hash(&content);
168 if !out.contains(&h) {
169 out.push(h);
170 }
171 }
172 out
173 };
174
175 let scripts = dedup(raw_scripts);
176 let styles = dedup(raw_styles);
177
178 PageCspHashes { scripts, styles }
179}
180
181#[must_use]
206pub fn render_policy_template(
207 template: &str,
208 script_hashes: &[String],
209 style_hashes: &[String],
210) -> String {
211 let expand = |hashes: &[String]| -> String {
212 let mut out = String::new();
213 for h in hashes {
214 out.push_str(" '");
215 out.push_str(h);
216 out.push('\'');
217 }
218 out
219 };
220 template
221 .replace("{script_hashes}", &expand(script_hashes))
222 .replace("{style_hashes}", &expand(style_hashes))
223}
224
225#[must_use]
246pub fn page_policy(html: &str) -> Option<String> {
247 let hashes = page_inline_hashes(html);
248 if hashes.is_empty() {
249 return None;
250 }
251 Some(render_policy_template(
252 DEFAULT_CSP_POLICY_TEMPLATE,
253 &hashes.scripts,
254 &hashes.styles,
255 ))
256}
257
258fn collect_inline_script_and_style(html: &str) -> (Vec<String>, Vec<String>) {
272 use std::cell::RefCell;
273 use std::rc::Rc;
274
275 use lol_html::{element, end_tag, text};
276
277 use crate::util::html_rewriter::rewrite_html;
278
279 type Slot = Rc<RefCell<Option<String>>>;
280 type Sink = Rc<RefCell<Vec<String>>>;
281
282 fn handlers<'a>(
283 tag: &'a str,
284 slot: &Slot,
285 sink: &Sink,
286 ) -> Vec<(
287 std::borrow::Cow<'a, lol_html::Selector>,
288 lol_html::ElementContentHandlers<'a>,
289 )> {
290 let slot_el = Rc::clone(slot);
291 let sink_el = Rc::clone(sink);
292 let slot_tx = Rc::clone(slot);
293
294 let on_el = element!(tag, move |el| {
295 if el.get_attribute("src").is_some() {
297 *slot_el.borrow_mut() = None;
298 return Ok(());
299 }
300 *slot_el.borrow_mut() = Some(String::new());
301 let sink = Rc::clone(&sink_el);
302 let slot = Rc::clone(&slot_el);
303 let _ = el.on_end_tag(end_tag!(move |_end| {
304 if let Some(body) = slot.borrow_mut().take() {
305 if !body.trim().is_empty() {
306 sink.borrow_mut().push(body);
307 }
308 }
309 Ok(())
310 }));
311 Ok(())
312 });
313
314 let on_text = text!(tag, move |chunk| {
315 if let Some(buf) = slot_tx.borrow_mut().as_mut() {
316 buf.push_str(chunk.as_str());
317 }
318 Ok(())
319 });
320
321 vec![on_el, on_text]
322 }
323
324 let script_slot: Slot = Rc::new(RefCell::new(None));
325 let script_sink: Sink = Rc::new(RefCell::new(Vec::new()));
326 let style_slot: Slot = Rc::new(RefCell::new(None));
327 let style_sink: Sink = Rc::new(RefCell::new(Vec::new()));
328
329 let mut all = handlers("script", &script_slot, &script_sink);
330 all.extend(handlers("style", &style_slot, &style_sink));
331
332 let _ = rewrite_html(html, all);
333
334 let scripts = script_sink.borrow().clone();
335 let styles = style_sink.borrow().clone();
336 (scripts, styles)
337}
338
339#[derive(Debug, Clone, Copy, Default)]
363pub struct CspPlugin;
364
365impl CspPlugin {
366 #[must_use]
378 pub const fn new() -> Self {
379 Self
380 }
381}
382
383impl Plugin for CspPlugin {
384 fn name(&self) -> &'static str {
385 "csp"
386 }
387
388 fn has_transform(&self) -> bool {
389 true
390 }
391
392 fn transform_html(
393 &self,
394 html: &str,
395 path: &Path,
396 ctx: &PluginContext,
397 ) -> Result<String, SsgError> {
398 let csp_dir = ctx.site_dir.join("_csp");
399 let sri_algorithm = ctx
402 .config
403 .as_ref()
404 .map_or_else(SriAlgorithm::default, |c| c.security.sri_algorithm);
405 let url_prefix = ctx
412 .config
413 .as_ref()
414 .map_or_else(String::new, |c| base_url_path_prefix(&c.base_url));
415 let (rewritten, extracted) = extract_inline_blocks(
416 html,
417 &csp_dir,
418 &ctx.site_dir,
419 sri_algorithm,
420 &url_prefix,
421 )
422 .map_err(|e| SsgError::io(e, path))?;
423
424 if extracted > 0 {
425 let final_html = remove_unsafe_inline_from_csp(&rewritten);
426 Ok(final_html)
427 } else {
428 Ok(html.to_string())
429 }
430 }
431
432 fn after_compile(&self, ctx: &PluginContext) -> Result<(), SsgError> {
433 if !ctx.site_dir.exists() {
434 return Ok(());
435 }
436
437 let csp_dir = ctx.site_dir.join("_csp");
439 fs::create_dir_all(&csp_dir).with_path(&csp_dir)?;
440
441 Ok(())
442 }
443}
444
445pub(crate) fn base_url_path_prefix(base_url: &str) -> String {
453 let without_scheme = base_url
454 .split_once("://")
455 .map_or(base_url, |(_, rest)| rest);
456 let path = without_scheme
457 .find('/')
458 .map_or("", |i| &without_scheme[i..]);
459 let trimmed = path.trim_end_matches('/');
460 if trimmed == "/" {
461 String::new()
462 } else {
463 trimmed.to_string()
464 }
465}
466
467fn extract_inline_blocks(
471 html: &str,
472 csp_dir: &Path,
473 site_dir: &Path,
474 sri_algorithm: SriAlgorithm,
475 url_prefix: &str,
476) -> Result<(String, usize)> {
477 let mut result = html.to_string();
478 let mut count = 0;
479 let mut hoisted_links: Vec<String> = Vec::new();
480
481 while let Some((before, content, after)) =
483 find_inline_block(&result, "style")
484 {
485 let hash = fnv_hash(content.as_bytes());
486 let filename = format!("{hash:016x}.css");
487 let file_path = csp_dir.join(&filename);
488
489 fs::create_dir_all(csp_dir)?;
490 fs::write(&file_path, content.as_bytes())?;
491
492 let sri = compute_sri(content.as_bytes(), sri_algorithm);
493 let rel_path = file_path
494 .strip_prefix(site_dir)
495 .unwrap_or(&file_path)
496 .to_string_lossy()
497 .replace('\\', "/");
498
499 let link_tag = format!(
500 "<link rel=\"stylesheet\" href=\"{}/{}\" integrity=\"{}\" crossorigin=\"anonymous\">",
501 url_prefix, rel_path, sri
502 );
503
504 hoisted_links.push(link_tag);
512 result = format!("{before}{after}");
513 count += 1;
514 }
515
516 if !hoisted_links.is_empty() {
523 let block = hoisted_links.concat();
524 let injected =
531 crate::util::head_dom::inject_before_head_close(&result, &block);
532 if injected == result {
533 result.push_str(&block);
536 } else {
537 result = injected;
538 }
539 }
540
541 while let Some((before, opening_tag, content, after)) =
543 find_inline_script(&result)
544 {
545 let hash = fnv_hash(content.as_bytes());
546 let filename = format!("{hash:016x}.js");
547 let file_path = csp_dir.join(&filename);
548
549 fs::create_dir_all(csp_dir)?;
550 fs::write(&file_path, content.as_bytes())?;
551
552 let sri = compute_sri(content.as_bytes(), sri_algorithm);
553 let rel_path = file_path
554 .strip_prefix(site_dir)
555 .unwrap_or(&file_path)
556 .to_string_lossy()
557 .replace('\\', "/");
558
559 let preserved = preserve_script_attrs(
563 &opening_tag,
564 &["src", "integrity", "crossorigin"],
565 );
566 let script_tag = if preserved.is_empty() {
567 format!(
568 "<script src=\"{url_prefix}/{rel_path}\" integrity=\"{sri}\" crossorigin=\"anonymous\"></script>"
569 )
570 } else {
571 format!(
572 "<script {preserved} src=\"{url_prefix}/{rel_path}\" integrity=\"{sri}\" crossorigin=\"anonymous\"></script>"
573 )
574 };
575
576 result = format!("{before}{script_tag}{after}");
577 count += 1;
578 }
579
580 Ok((result, count))
581}
582
583fn preserve_script_attrs(opening_tag: &str, drop: &[&str]) -> String {
591 use crate::util::html_rewriter::rewrite_html;
592 use lol_html::element;
593 use std::cell::RefCell;
594 use std::rc::Rc;
595
596 let fragment = format!("{opening_tag}</script>");
599 let collected: Rc<RefCell<Vec<(String, String)>>> =
600 Rc::new(RefCell::new(Vec::new()));
601 let collected_cb = Rc::clone(&collected);
602
603 let _ = rewrite_html(
604 &fragment,
605 vec![element!("script", move |el| {
606 for attr in el.attributes() {
607 collected_cb.borrow_mut().push((attr.name(), attr.value()));
608 }
609 Ok(())
610 })],
611 );
612
613 let drop_lower: Vec<String> =
614 drop.iter().map(|d| d.to_ascii_lowercase()).collect();
615
616 let parts: Vec<String> = collected
617 .borrow()
618 .iter()
619 .filter(|(name, _)| !drop_lower.contains(&name.to_ascii_lowercase()))
620 .map(|(name, value)| {
621 if value.is_empty() {
622 name.clone()
623 } else {
624 let escaped = value.replace('"', """);
625 format!("{name}=\"{escaped}\"")
626 }
627 })
628 .collect();
629 parts.join(" ")
630}
631
632fn find_inline_block<'a>(
635 html: &'a str,
636 tag: &str,
637) -> Option<(&'a str, &'a str, &'a str)> {
638 let open = format!("<{tag}>");
639 let close = format!("</{tag}>");
640
641 let comments = comment_spans(html);
644 let mut from = 0;
645 let start = loop {
646 let rel = html[from..].find(&open)?;
647 let abs = from + rel;
648 if inside_comment(&comments, abs) {
649 from = abs + open.len();
650 continue;
651 }
652 break abs;
653 };
654 let content_start = start + open.len();
655 let content_end = html[content_start..].find(&close)? + content_start;
656 let end = content_end + close.len();
657
658 let content = &html[content_start..content_end];
659 if content.trim().is_empty() {
660 return None;
661 }
662
663 Some((&html[..start], content, &html[end..]))
664}
665
666fn comment_spans(html: &str) -> Vec<(usize, usize)> {
684 let bytes = html.as_bytes();
685 let mut spans = Vec::new();
686 let mut i = 0;
687 while let Some(rel) = html[i..].find("<!--") {
688 let start = i + rel;
689 let after = start + 4;
690 let end = html[after..]
691 .find("-->")
692 .map_or(bytes.len(), |r| after + r + 3);
693 spans.push((start, end));
694 i = end;
695 if i >= bytes.len() {
696 break;
697 }
698 }
699 spans
700}
701
702fn inside_comment(spans: &[(usize, usize)], pos: usize) -> bool {
704 spans.iter().any(|&(s, e)| pos >= s && pos < e)
705}
706
707fn find_inline_script(html: &str) -> Option<(String, String, String, String)> {
708 let comments = comment_spans(html);
709 let mut search_from = 0;
710
711 loop {
712 let rest = &html[search_from..];
713 let start = rest.find("<script")?;
714 let abs_start = search_from + start;
715
716 if inside_comment(&comments, abs_start) {
718 search_from = abs_start + "<script".len();
719 continue;
720 }
721
722 let tag_end = html[abs_start..].find('>')? + abs_start;
724 let opening_tag = &html[abs_start..=tag_end];
725
726 if opening_tag.contains("application/ld+json")
728 || opening_tag.contains("data-ssg-livereload")
729 || opening_tag.contains("src=")
730 {
731 search_from = tag_end + 1;
732 continue;
733 }
734
735 let content_start = tag_end + 1;
736 let close_tag = "</script>";
737 let content_end =
738 html[content_start..].find(close_tag)? + content_start;
739 let end = content_end + close_tag.len();
740
741 let content = &html[content_start..content_end];
742 if content.trim().is_empty() {
743 search_from = end;
744 continue;
745 }
746
747 return Some((
748 html[..abs_start].to_string(),
749 opening_tag.to_string(),
750 content.to_string(),
751 html[end..].to_string(),
752 ));
753 }
754}
755
756fn remove_unsafe_inline_from_csp(html: &str) -> String {
768 let mut out = String::with_capacity(html.len());
769 let mut rest = html;
770
771 while let Some(start) = find_csp_meta_content(rest) {
772 let (before, from_quote) = rest.split_at(start);
773 let Some(quote) = from_quote.chars().next() else {
774 break;
775 };
776 let Some(end_rel) = from_quote[1..].find(quote) else {
777 break;
778 };
779 let value = &from_quote[1..=end_rel];
780
781 out.push_str(before);
782 out.push(quote);
783 out.push_str(&strip_unsafe_inline_token(value));
784 out.push(quote);
785
786 rest = &from_quote[end_rel + 2..];
787 }
788
789 out.push_str(rest);
790 out
791}
792
793fn strip_unsafe_inline_token(policy: &str) -> String {
796 policy
797 .split(';')
798 .map(|directive| {
799 let kept: Vec<&str> = directive
800 .split_whitespace()
801 .filter(|t| *t != "'unsafe-inline'")
802 .collect();
803 if kept.is_empty() {
804 String::new()
805 } else {
806 let lead = if directive.starts_with(' ') { " " } else { "" };
809 format!("{lead}{}", kept.join(" "))
810 }
811 })
812 .collect::<Vec<_>>()
813 .join(";")
814}
815
816fn find_csp_meta_content(html: &str) -> Option<usize> {
821 let mut search_from = 0usize;
822 loop {
823 let tag_rel = html[search_from..].find("<meta")?;
824 let tag_start = search_from + tag_rel;
825 let tag_end = html[tag_start..].find('>').map(|i| tag_start + i)?;
826 let tag = &html[tag_start..tag_end];
827
828 if tag.to_ascii_lowercase().contains("content-security-policy") {
829 if let Some(attr_rel) = tag.find("content=") {
830 let after = tag_start + attr_rel + "content=".len();
831 if matches!(html.as_bytes().get(after), Some(b'"' | b'\'')) {
832 return Some(after);
833 }
834 }
835 }
836 search_from = tag_end;
837 }
838}
839
840const META_INELIGIBLE_DIRECTIVES: [&str; 4] =
856 ["frame-ancestors", "report-uri", "report-to", "sandbox"];
857
858#[must_use]
869pub fn policy_for_meta(policy: &str) -> String {
870 policy
871 .split(';')
872 .map(str::trim)
873 .filter(|d| !d.is_empty())
874 .filter(|d| {
875 let name = d.split_whitespace().next().unwrap_or("");
876 !META_INELIGIBLE_DIRECTIVES
877 .iter()
878 .any(|bad| name.eq_ignore_ascii_case(bad))
879 })
880 .collect::<Vec<_>>()
881 .join("; ")
882}
883
884#[must_use]
911pub fn inject_csp_meta(html: &str, policy: &str) -> String {
912 use crate::util::html_rewriter::rewrite_html;
913 use lol_html::element;
914 use lol_html::html_content::ContentType;
915 use std::cell::Cell;
916 use std::rc::Rc;
917
918 let already_present = Rc::new(Cell::new(false));
922 let already_present_cb = Rc::clone(&already_present);
923 let detect = element!(
924 "meta[http-equiv=\"Content-Security-Policy\" i]",
925 move |_el| {
926 already_present_cb.set(true);
927 Ok(())
928 }
929 );
930 let _ = rewrite_html(html, vec![detect]);
931 if already_present.get() {
932 return html.to_string();
933 }
934
935 let policy = policy_for_meta(policy);
938 let injected = Rc::new(Cell::new(false));
939 let injected_cb = Rc::clone(&injected);
940 let head_handler = element!("head", move |el| {
941 let meta = format!(
942 "<meta http-equiv=\"Content-Security-Policy\" content=\"{policy}\">"
943 );
944 el.prepend(&meta, ContentType::Html);
945 injected_cb.set(true);
946 Ok(())
947 });
948
949 rewrite_or_original(html, rewrite_html(html, vec![head_handler]))
950}
951
952fn rewrite_or_original(html: &str, res: Result<String, SsgError>) -> String {
955 res.unwrap_or_else(|_| html.to_string())
956}
957
958fn fnv_hash(data: &[u8]) -> u64 {
960 let mut h: u64 = 0xcbf2_9ce4_8422_2325;
961 for &b in data {
962 h ^= u64::from(b);
963 h = h.wrapping_mul(0x0000_0100_0000_01b3);
964 }
965 h
966}
967
968fn compute_sri(data: &[u8], sri_algorithm: SriAlgorithm) -> String {
979 sri_algorithm.integrity(data)
980}
981
982#[cfg(test)]
983mod tests {
984
985 #[test]
987 fn inline_block_extraction_skips_a_commented_block() {
988 let html = concat!(
989 "<html><head>",
990 "<!-- <style>.commented{}</style> -->",
991 "<style>.real{}</style>",
992 "</head><body></body></html>"
993 );
994 let (_, content, _) =
995 find_inline_block(html, "style").expect("real style found");
996 assert_eq!(
997 content.trim(),
998 ".real{}",
999 "a commented-out style must not be hoisted: {content:?}"
1000 );
1001 }
1002
1003 #[test]
1009 fn inline_script_extraction_skips_a_commented_block() {
1010 let html = concat!(
1011 "<html><head>",
1012 "<!-- <script>commented()</script> -->",
1013 "<script>real()</script>",
1014 "</head><body></body></html>"
1015 );
1016 let found = find_inline_script(html);
1017 assert!(found.is_some(), "the real script should still be found");
1018 let (_, _, content, _) = found.unwrap();
1019 assert_eq!(
1020 content.trim(),
1021 "real()",
1022 "a commented-out script must not be hoisted: {content:?}"
1023 );
1024 }
1025
1026 #[test]
1031 fn inline_collection_skips_a_script_inside_a_comment() {
1032 let html = concat!(
1033 "<html><head>",
1034 "<!-- <script>commented()</script> -->",
1035 "<script>real()</script>",
1036 "</head><body></body></html>"
1037 );
1038 let found = collect_inline_script_and_style(html).0;
1039 assert_eq!(
1040 found,
1041 vec!["real()"],
1042 "a commented-out script must not be hashed: {found:?}"
1043 );
1044 }
1045
1046 use super::*;
1047 use tempfile::tempdir;
1048
1049 #[test]
1053 fn a_meta_policy_drops_directives_meta_cannot_carry() {
1054 let out = policy_for_meta(DEFAULT_CSP_POLICY);
1055 assert!(
1056 !out.contains("frame-ancestors"),
1057 "frame-ancestors must not reach a meta policy: {out}"
1058 );
1059 for kept in ["default-src", "script-src", "style-src", "img-src"] {
1060 assert!(out.contains(kept), "{kept} must survive: {out}");
1061 }
1062 assert!(!out.ends_with(';'), "no dangling separator: {out}");
1063 }
1064
1065 #[test]
1066 fn policy_for_meta_drops_every_header_only_directive() {
1067 let p = "default-src 'self'; frame-ancestors 'none'; \
1068 report-uri /r; report-to grp; sandbox allow-forms";
1069 assert_eq!(policy_for_meta(p), "default-src 'self'");
1070 }
1071
1072 #[test]
1074 fn the_injected_meta_tag_carries_no_frame_ancestors() {
1075 let html = "<html><head><title>t</title></head><body></body></html>";
1076 let out = inject_csp_meta(html, DEFAULT_CSP_POLICY);
1077 assert!(out.contains("Content-Security-Policy"), "{out}");
1078 assert!(
1079 !out.contains("frame-ancestors"),
1080 "the emitted tag must not carry it: {out}"
1081 );
1082 }
1083
1084 #[test]
1090 fn an_extracted_stylesheet_link_is_hoisted_into_head() {
1091 let dir = tempdir().expect("tempdir");
1092 let site = dir.path();
1093 let html = concat!(
1094 "<html><head><title>t</title></head>",
1095 "<body><p>copy</p><style>body{margin:0}</style></body></html>"
1096 );
1097 let (out, n) = extract_inline_blocks(
1098 html,
1099 &site.join("_csp"),
1100 site,
1101 SriAlgorithm::Sha384,
1102 "",
1103 )
1104 .expect("extract");
1105
1106 assert_eq!(n, 1, "one block should have been extracted");
1107 let head_end = out.find("</head>").expect("head");
1108 let link = out.find("<link rel=\"stylesheet\"").expect("link emitted");
1109 assert!(
1110 link < head_end,
1111 "the stylesheet link must land inside <head>, got: {out}"
1112 );
1113 assert!(
1114 !out[out.find("<body").unwrap()..]
1115 .contains("<link rel=\"stylesheet\""),
1116 "no stylesheet link may remain in <body>: {out}"
1117 );
1118 }
1119
1120 #[test]
1129 fn hoisted_links_ignore_a_head_close_inside_a_comment() {
1130 let dir = tempdir().expect("tempdir");
1131 let site = dir.path();
1132 let html = concat!(
1133 "<html><head><!-- </head> --><title>T</title></head><body>",
1134 "<style>.a{color:red}</style>",
1135 "</body></html>"
1136 );
1137 let (out, n) = extract_inline_blocks(
1138 html,
1139 &site.join("_csp"),
1140 site,
1141 SriAlgorithm::Sha384,
1142 "",
1143 )
1144 .expect("extract");
1145 assert_eq!(n, 1);
1146
1147 let link = out
1148 .find("<link rel=\"stylesheet\"")
1149 .expect("a stylesheet link was emitted");
1150 let comment_end = out.find("-->").expect("the comment survives");
1151 assert!(
1152 link > comment_end,
1153 "the link was spliced inside the comment, where it does nothing:\n{out}"
1154 );
1155 }
1156
1157 #[test]
1160 fn hoisted_links_keep_their_relative_order() {
1161 let dir = tempdir().expect("tempdir");
1162 let site = dir.path();
1163 let html = concat!(
1164 "<html><head></head><body>",
1165 "<style>.a{color:red}</style>",
1166 "<style>.b{color:blue}</style>",
1167 "</body></html>"
1168 );
1169 let (out, n) = extract_inline_blocks(
1170 html,
1171 &site.join("_csp"),
1172 site,
1173 SriAlgorithm::Sha384,
1174 "",
1175 )
1176 .expect("extract");
1177 assert_eq!(n, 2);
1178 let links: Vec<&str> = out
1180 .match_indices("<link rel=\"stylesheet\"")
1181 .map(|(i, _)| &out[i..i + 90])
1182 .collect();
1183 assert_eq!(links.len(), 2, "both links present: {out}");
1184 let a = fs::read_to_string(
1185 site.join("_csp").join(
1186 links[0]
1187 .split("href=\"/")
1188 .nth(1)
1189 .and_then(|s| s.split('"').next())
1190 .and_then(|s| s.rsplit('/').next())
1191 .expect("first href"),
1192 ),
1193 )
1194 .expect("first file");
1195 assert!(a.contains("red"), "first link should be the first style");
1196 }
1197
1198 #[test]
1199 fn extract_style_block() {
1200 let html = "<html><head><style>body { color: red; }</style></head><body></body></html>";
1201 let dir = tempdir().unwrap();
1202 let csp_dir = dir.path().join("_csp");
1203
1204 let (result, count) = extract_inline_blocks(
1205 html,
1206 &csp_dir,
1207 dir.path(),
1208 SriAlgorithm::default(),
1209 "",
1210 )
1211 .unwrap();
1212
1213 assert_eq!(count, 1);
1214 assert!(result.contains("<link rel=\"stylesheet\""));
1215 assert!(result.contains("integrity=\"sha384-"));
1217 assert!(!result.contains("<style>"));
1218 }
1219
1220 #[test]
1221 fn extract_style_block_default_sha384_exact_vector() {
1222 let html = "<html><head><style>body { color: red; }</style></head><body></body></html>";
1226 let dir = tempdir().unwrap();
1227 let csp_dir = dir.path().join("_csp");
1228
1229 let (result, count) = extract_inline_blocks(
1230 html,
1231 &csp_dir,
1232 dir.path(),
1233 SriAlgorithm::default(),
1234 "",
1235 )
1236 .unwrap();
1237
1238 assert_eq!(count, 1);
1239 assert!(
1240 result.contains(
1241 "integrity=\"sha384-BN8siYsJqlPeNsRFs2pYbTW0uiUBy9v6JVVKpHaS+KNqD0ZFotD5OFKMkI6/s6sb\""
1242 ),
1243 "expected exact SHA-384 SRI vector; got: {result}"
1244 );
1245 }
1246
1247 #[test]
1248 fn base_url_path_prefix_extracts_sub_path_mount_points() {
1249 assert_eq!(base_url_path_prefix("https://example.com"), "");
1250 assert_eq!(base_url_path_prefix("https://example.com/"), "");
1251 assert_eq!(base_url_path_prefix("https://example.com/apex"), "/apex");
1252 assert_eq!(base_url_path_prefix("https://example.com/apex/"), "/apex");
1253 assert_eq!(base_url_path_prefix("https://e.com/a/b/"), "/a/b");
1254 assert_eq!(base_url_path_prefix("example.com/apex"), "/apex");
1256 }
1257
1258 #[test]
1263 fn extracted_assets_are_prefixed_for_sub_path_deploys() {
1264 let dir = tempdir().unwrap();
1265 let site = dir.path().join("site");
1266 let csp = site.join("_csp");
1267 fs::create_dir_all(&csp).unwrap();
1268
1269 let (out, count) = extract_inline_blocks(
1270 "<style>body{color:red}</style><script>var x=1;</script>",
1271 &csp,
1272 &site,
1273 SriAlgorithm::default(),
1274 "/apex",
1275 )
1276 .unwrap();
1277
1278 assert_eq!(count, 2);
1279 assert!(
1280 out.contains("href=\"/apex/_csp/"),
1281 "stylesheet must carry the sub-path prefix: {out}"
1282 );
1283 assert!(
1284 out.contains("src=\"/apex/_csp/"),
1285 "script must carry the sub-path prefix: {out}"
1286 );
1287 assert!(
1288 !out.contains("href=\"/_csp/"),
1289 "no unprefixed root-absolute reference may survive: {out}"
1290 );
1291 }
1292
1293 #[test]
1294 fn transform_html_sri_algorithm_config_override_emits_sha256() {
1295 use crate::cmd::{SecurityConfig, SsgConfig};
1299
1300 let html = "<html><head><style>body { color: red; }</style></head><body></body></html>";
1301 let dir = tempdir().unwrap();
1302 let site = dir.path().join("site");
1303 fs::create_dir_all(&site).unwrap();
1304
1305 let config = SsgConfig::builder()
1306 .security(SecurityConfig {
1307 sri_algorithm: SriAlgorithm::Sha256,
1308 })
1309 .build()
1310 .unwrap();
1311 let ctx = PluginContext::with_config(
1312 dir.path(),
1313 dir.path(),
1314 &site,
1315 dir.path(),
1316 config,
1317 );
1318 let out = CspPlugin
1319 .transform_html(html, &site.join("index.html"), &ctx)
1320 .unwrap();
1321 assert!(
1322 out.contains(
1323 "integrity=\"sha256-XeYlw2NVzOfB1UCIJqCyGr+0n7bA4fFslFpvKu84IAw=\""
1324 ),
1325 "expected exact SHA-256 SRI vector; got: {out}"
1326 );
1327 assert!(!out.contains("sha384-"), "override must win: {out}");
1328 }
1329
1330 #[test]
1331 fn extract_script_block() {
1332 let html =
1333 "<html><body><script>console.log('hi');</script></body></html>";
1334 let dir = tempdir().unwrap();
1335 let csp_dir = dir.path().join("_csp");
1336
1337 let (result, count) = extract_inline_blocks(
1338 html,
1339 &csp_dir,
1340 dir.path(),
1341 SriAlgorithm::default(),
1342 "",
1343 )
1344 .unwrap();
1345
1346 assert_eq!(count, 1);
1347 assert!(result.contains("<script src="));
1348 assert!(result.contains("integrity=\"sha384-"));
1350 assert!(!result.contains("console.log"));
1351 }
1352
1353 #[test]
1354 fn skips_jsonld_scripts() {
1355 let html = r#"<html><body><script type="application/ld+json">{"@type":"Thing"}</script></body></html>"#;
1356 let dir = tempdir().unwrap();
1357 let csp_dir = dir.path().join("_csp");
1358
1359 let (result, count) = extract_inline_blocks(
1360 html,
1361 &csp_dir,
1362 dir.path(),
1363 SriAlgorithm::default(),
1364 "",
1365 )
1366 .unwrap();
1367
1368 assert_eq!(count, 0);
1369 assert!(result.contains("application/ld+json"));
1370 }
1371
1372 #[test]
1373 fn skips_livereload_scripts() {
1374 let html = r#"<html><body><script data-ssg-livereload>ws.connect();</script></body></html>"#;
1375 let dir = tempdir().unwrap();
1376 let csp_dir = dir.path().join("_csp");
1377
1378 let (result, count) = extract_inline_blocks(
1379 html,
1380 &csp_dir,
1381 dir.path(),
1382 SriAlgorithm::default(),
1383 "",
1384 )
1385 .unwrap();
1386
1387 assert_eq!(count, 0);
1388 assert!(result.contains("data-ssg-livereload"));
1389 }
1390
1391 #[test]
1392 fn skips_external_scripts() {
1393 let html =
1394 r#"<html><body><script src="/app.js"></script></body></html>"#;
1395 let dir = tempdir().unwrap();
1396 let csp_dir = dir.path().join("_csp");
1397
1398 let (result, count) = extract_inline_blocks(
1399 html,
1400 &csp_dir,
1401 dir.path(),
1402 SriAlgorithm::default(),
1403 "",
1404 )
1405 .unwrap();
1406
1407 assert_eq!(count, 0);
1408 assert_eq!(result, html);
1409 }
1410
1411 #[test]
1412 fn removes_unsafe_inline_from_csp() {
1413 let html = r#"<meta http-equiv="Content-Security-Policy" content="script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'">"#;
1414 let result = remove_unsafe_inline_from_csp(html);
1415 assert!(!result.contains("unsafe-inline"));
1416 }
1417
1418 #[test]
1419 fn removing_unsafe_inline_leaves_no_double_space() {
1420 let html = r#"<meta http-equiv="Content-Security-Policy" content="script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline' https://example.com">"#;
1423 let result = remove_unsafe_inline_from_csp(html);
1424 assert!(!result.contains(" "), "double space in {result}");
1425 assert!(result.contains("script-src 'self' 'unsafe-eval';"));
1426 assert!(result.contains("style-src 'self' https://example.com"));
1427 }
1428
1429 #[test]
1430 fn leaves_prose_mentioning_unsafe_inline_alone() {
1431 let html = concat!(
1434 r#"<meta http-equiv="Content-Security-Policy" content="script-src 'self' 'unsafe-inline'">"#,
1435 r#"<p>Set <code>script-src 'self' 'unsafe-inline'</code> to allow it.</p>"#
1436 );
1437 let result = remove_unsafe_inline_from_csp(html);
1438 assert!(
1439 result.contains("<code>script-src 'self' 'unsafe-inline'</code>"),
1440 "prose was rewritten: {result}"
1441 );
1442 let meta_end = result.find("<p>").expect("prose follows the meta");
1444 assert!(!result[..meta_end].contains("unsafe-inline"));
1445 }
1446
1447 #[test]
1448 fn leaves_documents_without_a_csp_meta_untouched() {
1449 let html = r#"<p>The token 'unsafe-inline' weakens a policy.</p>"#;
1450 assert_eq!(remove_unsafe_inline_from_csp(html), html);
1451 }
1452
1453 #[test]
1454 fn skips_empty_style_blocks() {
1455 let html = "<html><head><style> </style></head></html>";
1456 let dir = tempdir().unwrap();
1457 let csp_dir = dir.path().join("_csp");
1458
1459 let (_, count) = extract_inline_blocks(
1460 html,
1461 &csp_dir,
1462 dir.path(),
1463 SriAlgorithm::default(),
1464 "",
1465 )
1466 .unwrap();
1467 assert_eq!(count, 0);
1468 }
1469
1470 #[test]
1471 fn csp_plugin_name() {
1472 assert_eq!(CspPlugin.name(), "csp");
1473 }
1474
1475 #[test]
1476 fn csp_plugin_skips_missing_site_dir() {
1477 let ctx = PluginContext::new(
1478 Path::new("/tmp/c"),
1479 Path::new("/tmp/b"),
1480 Path::new("/nonexistent/site"),
1481 Path::new("/tmp/t"),
1482 );
1483 assert!(CspPlugin.after_compile(&ctx).is_ok());
1484 }
1485
1486 #[test]
1487 fn csp_plugin_processes_html_files() {
1488 let dir = tempdir().unwrap();
1489 let site = dir.path().join("site");
1490 fs::create_dir_all(&site).unwrap();
1491 let html = "<html><head><style>body{color:red}</style></head><body><script>alert(1)</script></body></html>";
1492
1493 let ctx = PluginContext::new(dir.path(), dir.path(), &site, dir.path());
1494 CspPlugin.after_compile(&ctx).unwrap();
1495
1496 let output = CspPlugin
1497 .transform_html(html, &site.join("index.html"), &ctx)
1498 .unwrap();
1499 assert!(output.contains("<link rel=\"stylesheet\""));
1500 assert!(output.contains("<script src="));
1501 assert!(!output.contains("body{color:red}"));
1502 assert!(!output.contains("alert(1)"));
1503 assert!(site.join("_csp").exists());
1504 }
1505
1506 #[test]
1507 fn fnv_hash_deterministic() {
1508 let h1 = fnv_hash(b"hello");
1509 let h2 = fnv_hash(b"hello");
1510 assert_eq!(h1, h2);
1511 }
1512
1513 #[test]
1514 fn fnv_hash_different_inputs() {
1515 assert_ne!(fnv_hash(b"a"), fnv_hash(b"b"));
1516 }
1517
1518 #[test]
1519 fn compute_sri_format() {
1520 let sri = compute_sri(b"test", SriAlgorithm::default());
1523 assert!(sri.starts_with("sha384-"));
1524 assert!(
1525 compute_sri(b"test", SriAlgorithm::Sha256).starts_with("sha256-")
1526 );
1527 assert!(
1528 compute_sri(b"test", SriAlgorithm::Sha512).starts_with("sha512-")
1529 );
1530 }
1531
1532 #[test]
1535 fn csp_plugin_new_constructs_unit_struct() {
1536 let p = CspPlugin::new();
1537 assert_eq!(p.name(), "csp");
1538 assert!(p.has_transform());
1539 }
1540
1541 #[test]
1542 fn inject_csp_meta_adds_meta_when_absent() {
1543 let html = "<html><head><title>T</title></head><body></body></html>";
1544 let out = inject_csp_meta(html, "default-src 'self'");
1545 assert!(out.contains("http-equiv=\"Content-Security-Policy\""));
1546 assert!(out.contains("default-src 'self'"));
1547 }
1548
1549 #[test]
1550 fn inject_csp_meta_is_idempotent_when_meta_already_present() {
1551 let html = r#"<html><head><meta http-equiv="Content-Security-Policy" content="default-src 'self'"></head></html>"#;
1552 let out = inject_csp_meta(html, "script-src 'self'");
1553 let count = out
1555 .matches("http-equiv=\"Content-Security-Policy\"")
1556 .count();
1557 assert_eq!(count, 1, "must not duplicate CSP meta tag");
1558 assert!(!out.contains("script-src 'self'"));
1560 }
1561
1562 #[test]
1563 fn inject_csp_meta_handles_no_head_gracefully() {
1564 let html = "<html><body>no head</body></html>";
1565 let out = inject_csp_meta(html, "default-src 'self'");
1566 assert_eq!(out, html);
1568 }
1569
1570 #[test]
1571 fn preserve_script_attrs_keeps_type_module() {
1572 let out = preserve_script_attrs(
1573 r#"<script type="module">"#,
1574 &["src", "integrity", "crossorigin"],
1575 );
1576 assert!(out.contains(r#"type="module""#), "got: {out}");
1577 }
1578
1579 #[test]
1580 fn preserve_script_attrs_keeps_boolean_async_defer() {
1581 let out = preserve_script_attrs(
1582 "<script async defer>",
1583 &["src", "integrity", "crossorigin"],
1584 );
1585 assert!(out.contains("async"), "got: {out}");
1586 assert!(out.contains("defer"), "got: {out}");
1587 }
1588
1589 #[test]
1590 fn preserve_script_attrs_drops_listed_attrs() {
1591 let out = preserve_script_attrs(
1592 r#"<script src="/x.js" integrity="sha384-foo" crossorigin="anonymous" data-id="9">"#,
1593 &["src", "integrity", "crossorigin"],
1594 );
1595 assert!(!out.contains("src="), "got: {out}");
1596 assert!(!out.contains("integrity="), "got: {out}");
1597 assert!(!out.contains("crossorigin="), "got: {out}");
1598 assert!(out.contains(r#"data-id="9""#), "got: {out}");
1599 }
1600
1601 #[test]
1602 fn preserve_script_attrs_empty_when_no_attrs() {
1603 let out = preserve_script_attrs("<script>", &["src"]);
1604 assert_eq!(out, "");
1605 }
1606
1607 #[test]
1608 fn extract_inline_script_preserves_type_module() {
1609 let html = r#"<html><body><script type="module">import x from '/m.js';</script></body></html>"#;
1610 let dir = tempdir().unwrap();
1611 let csp_dir = dir.path().join("_csp");
1612 let (out, count) = extract_inline_blocks(
1613 html,
1614 &csp_dir,
1615 dir.path(),
1616 SriAlgorithm::default(),
1617 "",
1618 )
1619 .unwrap();
1620 assert_eq!(count, 1);
1621 assert!(out.contains(r#"type="module""#), "got: {out}");
1622 assert!(out.contains("integrity=\"sha384-"), "got: {out}");
1623 }
1624
1625 #[test]
1626 fn extract_inline_script_preserves_data_attrs() {
1627 let html = r#"<html><body><script data-domain="example.com">window.x=1;</script></body></html>"#;
1628 let dir = tempdir().unwrap();
1629 let csp_dir = dir.path().join("_csp");
1630 let (out, count) = extract_inline_blocks(
1631 html,
1632 &csp_dir,
1633 dir.path(),
1634 SriAlgorithm::default(),
1635 "",
1636 )
1637 .unwrap();
1638 assert_eq!(count, 1);
1639 assert!(out.contains(r#"data-domain="example.com""#), "got: {out}");
1640 }
1641
1642 const JSONLD_BODY: &str = r#"{"@type":"Thing"}"#;
1647
1648 #[test]
1649 fn template_with_empty_slots_is_exactly_the_global_policy() {
1650 assert_eq!(
1653 render_policy_template(DEFAULT_CSP_POLICY_TEMPLATE, &[], &[]),
1654 DEFAULT_CSP_POLICY
1655 );
1656 }
1657
1658 #[test]
1659 fn page_inline_hashes_includes_jsonld_blocks() {
1660 let html = format!(
1661 r#"<html><body><script type="application/ld+json">{JSONLD_BODY}</script></body></html>"#
1662 );
1663 let hashes = page_inline_hashes(&html);
1664 assert_eq!(hashes.scripts.len(), 1);
1665 let expected = SriAlgorithm::Sha256.integrity(JSONLD_BODY.as_bytes());
1666 assert_eq!(hashes.scripts[0], expected);
1667 }
1668
1669 #[test]
1670 fn page_inline_hashes_skips_external_scripts_and_empty_blocks() {
1671 let html = r#"<html><body>
1672 <script src="/app.js"></script>
1673 <script> </script>
1674 <style></style>
1675 </body></html>"#;
1676 assert!(page_inline_hashes(html).is_empty());
1677 }
1678
1679 #[test]
1680 fn page_inline_hashes_orders_by_document_position_and_dedups() {
1681 let html =
1682 "<script>aaa</script><script>bbb</script><script>aaa</script>";
1683 let hashes = page_inline_hashes(html);
1684 assert_eq!(hashes.scripts.len(), 2, "duplicate block deduped");
1685 assert_eq!(
1686 hashes.scripts[0],
1687 SriAlgorithm::Sha256.integrity(b"aaa"),
1688 "document order preserved"
1689 );
1690 assert_eq!(hashes.scripts[1], SriAlgorithm::Sha256.integrity(b"bbb"));
1691 }
1692
1693 #[test]
1694 fn page_inline_hashes_collects_styles_separately() {
1695 let html = "<style>body{color:red}</style><script>alert(1)</script>";
1696 let hashes = page_inline_hashes(html);
1697 assert_eq!(hashes.styles.len(), 1);
1698 assert_eq!(hashes.scripts.len(), 1);
1699 assert_eq!(
1700 hashes.styles[0],
1701 SriAlgorithm::Sha256.integrity(b"body{color:red}")
1702 );
1703 }
1704
1705 #[test]
1706 fn page_policy_is_hash_strict_never_unsafe_inline() {
1707 let html = format!(
1710 r#"<script type="application/ld+json">{JSONLD_BODY}</script>"#
1711 );
1712 let policy = page_policy(&html).expect("policy for inline JSON-LD");
1713 let expected = SriAlgorithm::Sha256.integrity(JSONLD_BODY.as_bytes());
1714 assert!(
1715 policy.contains(&format!("script-src 'self' '{expected}'")),
1716 "policy must carry the exact sha256 source: {policy}"
1717 );
1718 assert!(!policy.contains("unsafe-inline"));
1719 }
1720
1721 #[test]
1722 fn page_policy_none_without_inline_blocks() {
1723 assert!(page_policy(
1724 "<html><head><title>t</title></head><body>x</body></html>"
1725 )
1726 .is_none());
1727 }
1728
1729 #[test]
1730 fn page_policy_is_deterministic() {
1731 let html = "<style>a{}</style><script>x=1</script>";
1732 assert_eq!(page_policy(html), page_policy(html));
1733 }
1734
1735 #[test]
1736 fn csp_plugin_transform_html_no_inline_blocks_returns_unchanged() {
1737 let html =
1738 "<html><head><title>X</title></head><body><p>hi</p></body></html>";
1739 let dir = tempdir().unwrap();
1740 let site = dir.path().join("site");
1741 fs::create_dir_all(&site).unwrap();
1742 let ctx = PluginContext::new(dir.path(), dir.path(), &site, dir.path());
1743 let out = CspPlugin
1744 .transform_html(html, &site.join("index.html"), &ctx)
1745 .unwrap();
1746 assert_eq!(out, html);
1747 }
1748
1749 #[test]
1754 fn page_inline_hashes_dedupes_identical_style_blocks() {
1755 let html = "<style>a{color:red}</style><style>a{color:red}</style>";
1756 let hashes = page_inline_hashes(html);
1757 assert_eq!(hashes.styles.len(), 1);
1758 }
1759
1760 #[test]
1761 fn inline_collection_skips_prefix_tag_names() {
1762 let html = "<styles>ignored</styles><style>a{}</style>";
1764 let out = collect_inline_script_and_style(html).1;
1765 assert_eq!(out, vec!["a{}"]);
1766 }
1767
1768 #[test]
1769 fn inline_collection_accepts_slash_after_tag_name() {
1770 let html = "<style/>a{}</style>";
1772 let out = collect_inline_script_and_style(html).1;
1773 assert_eq!(out, vec!["a{}"]);
1774 }
1775
1776 #[test]
1777 fn inline_collection_stops_when_opening_tag_unterminated() {
1778 let html = "<style media=all";
1779 assert!(collect_inline_script_and_style(html).1.is_empty());
1780 }
1781
1782 #[test]
1783 fn inline_collection_stops_when_close_tag_missing() {
1784 let html = "<style>a{} no closing fence";
1785 assert!(collect_inline_script_and_style(html).1.is_empty());
1786 }
1787
1788 #[test]
1789 fn find_inline_block_returns_none_without_close_tag() {
1790 assert!(find_inline_block("<style>a{}", "style").is_none());
1791 }
1792
1793 #[test]
1794 fn find_inline_script_returns_none_when_opening_unterminated() {
1795 assert!(find_inline_script("<script").is_none());
1796 }
1797
1798 #[test]
1799 fn find_inline_script_returns_none_without_close_tag() {
1800 assert!(find_inline_script("<script>var x = 1;").is_none());
1801 }
1802
1803 #[test]
1804 fn find_inline_script_skips_empty_script_then_finds_real_one() {
1805 let html = "<script> </script><script>var x = 1;</script>";
1806 let (_, _, content, _) = find_inline_script(html).unwrap();
1807 assert_eq!(content, "var x = 1;");
1808 }
1809
1810 #[test]
1811 fn rewrite_or_original_returns_input_on_error() {
1812 let err = SsgError::io(
1813 std::io::Error::other("synthetic rewrite failure"),
1814 "<lol_html>",
1815 );
1816 assert_eq!(rewrite_or_original("<p>x</p>", Err(err)), "<p>x</p>");
1817 }
1818
1819 #[test]
1824 fn after_compile_fails_when_csp_dir_squatted_by_file() {
1825 let dir = tempdir().unwrap();
1826 let site = dir.path().join("site");
1827 fs::create_dir_all(&site).unwrap();
1828 fs::write(site.join("_csp"), "not a dir").unwrap();
1829 let ctx = PluginContext::new(dir.path(), dir.path(), &site, dir.path());
1830 let err = CspPlugin.after_compile(&ctx).unwrap_err();
1831 assert!(!format!("{err}").is_empty());
1832 }
1833
1834 #[test]
1835 fn transform_html_fails_when_csp_dir_squatted_by_file() {
1836 let dir = tempdir().unwrap();
1839 let site = dir.path().join("site");
1840 fs::create_dir_all(&site).unwrap();
1841 fs::write(site.join("_csp"), "not a dir").unwrap();
1842 let ctx = PluginContext::new(dir.path(), dir.path(), &site, dir.path());
1843 let err = CspPlugin
1844 .transform_html(
1845 "<style>a{}</style>",
1846 &site.join("index.html"),
1847 &ctx,
1848 )
1849 .unwrap_err();
1850 assert!(!format!("{err}").is_empty());
1851 }
1852
1853 #[test]
1854 fn extract_inline_blocks_script_dir_create_fails_when_squatted_by_file() {
1855 let dir = tempdir().unwrap();
1858 let site = dir.path().join("site");
1859 fs::create_dir_all(&site).unwrap();
1860 fs::write(site.join("_csp"), "not a dir").unwrap();
1861
1862 let res = extract_inline_blocks(
1863 "<script>var x = 1;</script>",
1864 &site.join("_csp"),
1865 &site,
1866 SriAlgorithm::default(),
1867 "",
1868 );
1869 assert!(res.is_err());
1870 }
1871
1872 #[test]
1873 fn extract_inline_blocks_style_write_fails_when_squatted_by_dir() {
1874 let dir = tempdir().unwrap();
1877 let site = dir.path().join("site");
1878 let csp_dir = site.join("_csp");
1879 let content = "a{color:red}";
1880 let squat =
1881 csp_dir.join(format!("{:016x}.css", fnv_hash(content.as_bytes())));
1882 fs::create_dir_all(&squat).unwrap();
1883
1884 let res = extract_inline_blocks(
1885 &format!("<style>{content}</style>"),
1886 &csp_dir,
1887 &site,
1888 SriAlgorithm::default(),
1889 "",
1890 );
1891 assert!(res.is_err());
1892 }
1893
1894 #[test]
1895 fn extract_inline_blocks_script_write_fails_when_squatted_by_dir() {
1896 let dir = tempdir().unwrap();
1897 let site = dir.path().join("site");
1898 let csp_dir = site.join("_csp");
1899 let content = "var x = 1;";
1900 let squat =
1901 csp_dir.join(format!("{:016x}.js", fnv_hash(content.as_bytes())));
1902 fs::create_dir_all(&squat).unwrap();
1903
1904 let res = extract_inline_blocks(
1905 &format!("<script>{content}</script>"),
1906 &csp_dir,
1907 &site,
1908 SriAlgorithm::default(),
1909 "",
1910 );
1911 assert!(res.is_err());
1912 }
1913}