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)]
366pub struct CspPlugin;
367
368impl CspPlugin {
369 #[must_use]
381 pub const fn new() -> Self {
382 Self
383 }
384}
385
386impl Plugin for CspPlugin {
387 fn name(&self) -> &'static str {
388 "csp"
389 }
390
391 fn has_transform(&self) -> bool {
392 true
393 }
394
395 fn transform_html(
396 &self,
397 html: &str,
398 path: &Path,
399 ctx: &PluginContext,
400 ) -> Result<String, SsgError> {
401 let csp_dir = ctx.site_dir.join("_csp");
402 let sri_algorithm = ctx
405 .config
406 .as_ref()
407 .map_or_else(SriAlgorithm::default, |c| c.security.sri_algorithm);
408 let url_prefix = ctx
415 .config
416 .as_ref()
417 .map_or_else(String::new, |c| base_url_path_prefix(&c.base_url));
418 let (rewritten, extracted) = extract_inline_blocks(
419 html,
420 &csp_dir,
421 &ctx.site_dir,
422 sri_algorithm,
423 &url_prefix,
424 )
425 .map_err(|e| SsgError::io(e, path))?;
426
427 if extracted > 0 {
428 let final_html = remove_unsafe_inline_from_csp(&rewritten);
429 Ok(final_html)
430 } else {
431 Ok(html.to_string())
432 }
433 }
434
435 fn after_compile(&self, ctx: &PluginContext) -> Result<(), SsgError> {
436 if !ctx.site_dir.exists() {
437 return Ok(());
438 }
439
440 let csp_dir = ctx.site_dir.join("_csp");
442 fs::create_dir_all(&csp_dir).with_path(&csp_dir)?;
443
444 Ok(())
445 }
446}
447
448pub(crate) fn base_url_path_prefix(base_url: &str) -> String {
456 let without_scheme = base_url
457 .split_once("://")
458 .map_or(base_url, |(_, rest)| rest);
459 let path = without_scheme
460 .find('/')
461 .map_or("", |i| &without_scheme[i..]);
462 let trimmed = path.trim_end_matches('/');
463 if trimmed == "/" {
464 String::new()
465 } else {
466 trimmed.to_string()
467 }
468}
469
470fn extract_inline_blocks(
474 html: &str,
475 csp_dir: &Path,
476 site_dir: &Path,
477 sri_algorithm: SriAlgorithm,
478 url_prefix: &str,
479) -> Result<(String, usize)> {
480 let mut result = html.to_string();
481 let mut count = 0;
482 let mut hoisted_links: Vec<String> = Vec::new();
483
484 while let Some((before, content, after)) =
486 find_inline_block(&result, "style")
487 {
488 let content = crate::plugins::minify_css(content);
491 let hash = fnv_hash(content.as_bytes());
492 let filename = format!("{hash:016x}.css");
493 let file_path = csp_dir.join(&filename);
494
495 fs::create_dir_all(csp_dir)?;
496 fs::write(&file_path, content.as_bytes())?;
497
498 let sri = compute_sri(content.as_bytes(), sri_algorithm);
499 let rel_path = file_path
500 .strip_prefix(site_dir)
501 .unwrap_or(&file_path)
502 .to_string_lossy()
503 .replace('\\', "/");
504
505 let link_tag = format!(
506 "<link rel=\"stylesheet\" href=\"{}/{}\" integrity=\"{}\" crossorigin=\"anonymous\">",
507 url_prefix, rel_path, sri
508 );
509
510 hoisted_links.push(link_tag);
518 result = format!("{before}{after}");
519 count += 1;
520 }
521
522 if !hoisted_links.is_empty() {
529 let block = hoisted_links.concat();
530 let injected =
537 crate::util::head_dom::inject_before_head_close(&result, &block);
538 if injected == result {
539 result.push_str(&block);
542 } else {
543 result = injected;
544 }
545 }
546
547 while let Some((before, opening_tag, content, after)) =
549 find_inline_script(&result)
550 {
551 let hash = fnv_hash(content.as_bytes());
552 let filename = format!("{hash:016x}.js");
553 let file_path = csp_dir.join(&filename);
554
555 fs::create_dir_all(csp_dir)?;
556 fs::write(&file_path, content.as_bytes())?;
557
558 let sri = compute_sri(content.as_bytes(), sri_algorithm);
559 let rel_path = file_path
560 .strip_prefix(site_dir)
561 .unwrap_or(&file_path)
562 .to_string_lossy()
563 .replace('\\', "/");
564
565 let preserved = preserve_script_attrs(
569 &opening_tag,
570 &["src", "integrity", "crossorigin"],
571 );
572 let script_tag = if preserved.is_empty() {
573 format!(
574 "<script src=\"{url_prefix}/{rel_path}\" integrity=\"{sri}\" crossorigin=\"anonymous\"></script>"
575 )
576 } else {
577 format!(
578 "<script {preserved} src=\"{url_prefix}/{rel_path}\" integrity=\"{sri}\" crossorigin=\"anonymous\"></script>"
579 )
580 };
581
582 result = format!("{before}{script_tag}{after}");
583 count += 1;
584 }
585
586 Ok((result, count))
587}
588
589fn preserve_script_attrs(opening_tag: &str, drop: &[&str]) -> String {
597 use crate::util::html_rewriter::rewrite_html;
598 use lol_html::element;
599 use std::cell::RefCell;
600 use std::rc::Rc;
601
602 let fragment = format!("{opening_tag}</script>");
605 let collected: Rc<RefCell<Vec<(String, String)>>> =
606 Rc::new(RefCell::new(Vec::new()));
607 let collected_cb = Rc::clone(&collected);
608
609 let _ = rewrite_html(
610 &fragment,
611 vec![element!("script", move |el| {
612 for attr in el.attributes() {
613 collected_cb.borrow_mut().push((attr.name(), attr.value()));
614 }
615 Ok(())
616 })],
617 );
618
619 let drop_lower: Vec<String> =
620 drop.iter().map(|d| d.to_ascii_lowercase()).collect();
621
622 let parts: Vec<String> = collected
623 .borrow()
624 .iter()
625 .filter(|(name, _)| !drop_lower.contains(&name.to_ascii_lowercase()))
626 .map(|(name, value)| {
627 if value.is_empty() {
628 name.clone()
629 } else {
630 let escaped = value.replace('"', """);
631 format!("{name}=\"{escaped}\"")
632 }
633 })
634 .collect();
635 parts.join(" ")
636}
637
638fn find_inline_block<'a>(
641 html: &'a str,
642 tag: &str,
643) -> Option<(&'a str, &'a str, &'a str)> {
644 let open = format!("<{tag}>");
645 let close = format!("</{tag}>");
646
647 let comments = comment_spans(html);
650 let mut from = 0;
651 let start = loop {
652 let rel = html[from..].find(&open)?;
653 let abs = from + rel;
654 if inside_comment(&comments, abs) {
655 from = abs + open.len();
656 continue;
657 }
658 break abs;
659 };
660 let content_start = start + open.len();
661 let content_end = html[content_start..].find(&close)? + content_start;
662 let end = content_end + close.len();
663
664 let content = &html[content_start..content_end];
665 if content.trim().is_empty() {
666 return None;
667 }
668
669 Some((&html[..start], content, &html[end..]))
670}
671
672fn comment_spans(html: &str) -> Vec<(usize, usize)> {
690 let bytes = html.as_bytes();
691 let mut spans = Vec::new();
692 let mut i = 0;
693 while let Some(rel) = html[i..].find("<!--") {
694 let start = i + rel;
695 let after = start + 4;
696 let end = html[after..]
697 .find("-->")
698 .map_or(bytes.len(), |r| after + r + 3);
699 spans.push((start, end));
700 i = end;
701 if i >= bytes.len() {
702 break;
703 }
704 }
705 spans
706}
707
708fn inside_comment(spans: &[(usize, usize)], pos: usize) -> bool {
710 spans.iter().any(|&(s, e)| pos >= s && pos < e)
711}
712
713fn find_inline_script(html: &str) -> Option<(String, String, String, String)> {
714 let comments = comment_spans(html);
715 let mut search_from = 0;
716
717 loop {
718 let rest = &html[search_from..];
719 let start = rest.find("<script")?;
720 let abs_start = search_from + start;
721
722 if inside_comment(&comments, abs_start) {
724 search_from = abs_start + "<script".len();
725 continue;
726 }
727
728 let tag_end = html[abs_start..].find('>')? + abs_start;
730 let opening_tag = &html[abs_start..=tag_end];
731
732 if opening_tag.contains("application/ld+json")
734 || opening_tag.contains("data-ssg-livereload")
735 || opening_tag.contains("src=")
736 {
737 search_from = tag_end + 1;
738 continue;
739 }
740
741 let content_start = tag_end + 1;
742 let close_tag = "</script>";
743 let content_end =
744 html[content_start..].find(close_tag)? + content_start;
745 let end = content_end + close_tag.len();
746
747 let content = &html[content_start..content_end];
748 if content.trim().is_empty() {
749 search_from = end;
750 continue;
751 }
752
753 return Some((
754 html[..abs_start].to_string(),
755 opening_tag.to_string(),
756 content.to_string(),
757 html[end..].to_string(),
758 ));
759 }
760}
761
762fn remove_unsafe_inline_from_csp(html: &str) -> String {
774 let mut out = String::with_capacity(html.len());
775 let mut rest = html;
776
777 while let Some(start) = find_csp_meta_content(rest) {
778 let (before, from_quote) = rest.split_at(start);
779 let Some(quote) = from_quote.chars().next() else {
780 break;
781 };
782 let Some(end_rel) = from_quote[1..].find(quote) else {
783 break;
784 };
785 let value = &from_quote[1..=end_rel];
786
787 out.push_str(before);
788 out.push(quote);
789 out.push_str(&strip_unsafe_inline_token(value));
790 out.push(quote);
791
792 rest = &from_quote[end_rel + 2..];
793 }
794
795 out.push_str(rest);
796 out
797}
798
799fn strip_unsafe_inline_token(policy: &str) -> String {
802 policy
803 .split(';')
804 .map(|directive| {
805 let kept: Vec<&str> = directive
806 .split_whitespace()
807 .filter(|t| *t != "'unsafe-inline'")
808 .collect();
809 if kept.is_empty() {
810 String::new()
811 } else {
812 let lead = if directive.starts_with(' ') { " " } else { "" };
815 format!("{lead}{}", kept.join(" "))
816 }
817 })
818 .collect::<Vec<_>>()
819 .join(";")
820}
821
822fn find_csp_meta_content(html: &str) -> Option<usize> {
827 let mut search_from = 0usize;
828 loop {
829 let tag_rel = html[search_from..].find("<meta")?;
830 let tag_start = search_from + tag_rel;
831 let tag_end = html[tag_start..].find('>').map(|i| tag_start + i)?;
832 let tag = &html[tag_start..tag_end];
833
834 if tag.to_ascii_lowercase().contains("content-security-policy") {
835 if let Some(attr_rel) = tag.find("content=") {
836 let after = tag_start + attr_rel + "content=".len();
837 if matches!(html.as_bytes().get(after), Some(b'"' | b'\'')) {
838 return Some(after);
839 }
840 }
841 }
842 search_from = tag_end;
843 }
844}
845
846const META_INELIGIBLE_DIRECTIVES: [&str; 4] =
862 ["frame-ancestors", "report-uri", "report-to", "sandbox"];
863
864#[must_use]
875pub fn policy_for_meta(policy: &str) -> String {
876 policy
877 .split(';')
878 .map(str::trim)
879 .filter(|d| !d.is_empty())
880 .filter(|d| {
881 let name = d.split_whitespace().next().unwrap_or("");
882 !META_INELIGIBLE_DIRECTIVES
883 .iter()
884 .any(|bad| name.eq_ignore_ascii_case(bad))
885 })
886 .collect::<Vec<_>>()
887 .join("; ")
888}
889
890#[must_use]
917pub fn inject_csp_meta(html: &str, policy: &str) -> String {
918 use crate::util::html_rewriter::rewrite_html;
919 use lol_html::element;
920 use lol_html::html_content::ContentType;
921 use std::cell::Cell;
922 use std::rc::Rc;
923
924 let already_present = Rc::new(Cell::new(false));
928 let already_present_cb = Rc::clone(&already_present);
929 let detect = element!(
930 "meta[http-equiv=\"Content-Security-Policy\" i]",
931 move |_el| {
932 already_present_cb.set(true);
933 Ok(())
934 }
935 );
936 let _ = rewrite_html(html, vec![detect]);
937 if already_present.get() {
938 return html.to_string();
939 }
940
941 let policy = policy_for_meta(policy);
944 let injected = Rc::new(Cell::new(false));
945 let injected_cb = Rc::clone(&injected);
946 let head_handler = element!("head", move |el| {
947 let meta = format!(
948 "<meta http-equiv=\"Content-Security-Policy\" content=\"{policy}\">"
949 );
950 el.prepend(&meta, ContentType::Html);
951 injected_cb.set(true);
952 Ok(())
953 });
954
955 rewrite_or_original(html, rewrite_html(html, vec![head_handler]))
956}
957
958fn rewrite_or_original(html: &str, res: Result<String, SsgError>) -> String {
961 res.unwrap_or_else(|_| html.to_string())
962}
963
964fn fnv_hash(data: &[u8]) -> u64 {
966 let mut h: u64 = 0xcbf2_9ce4_8422_2325;
967 for &b in data {
968 h ^= u64::from(b);
969 h = h.wrapping_mul(0x0000_0100_0000_01b3);
970 }
971 h
972}
973
974fn compute_sri(data: &[u8], sri_algorithm: SriAlgorithm) -> String {
985 sri_algorithm.integrity(data)
986}
987
988#[cfg(test)]
989mod tests {
990
991 #[test]
993 fn inline_block_extraction_skips_a_commented_block() {
994 let html = concat!(
995 "<html><head>",
996 "<!-- <style>.commented{}</style> -->",
997 "<style>.real{}</style>",
998 "</head><body></body></html>"
999 );
1000 let (_, content, _) =
1001 find_inline_block(html, "style").expect("real style found");
1002 assert_eq!(
1003 content.trim(),
1004 ".real{}",
1005 "a commented-out style must not be hoisted: {content:?}"
1006 );
1007 }
1008
1009 #[test]
1015 fn inline_script_extraction_skips_a_commented_block() {
1016 let html = concat!(
1017 "<html><head>",
1018 "<!-- <script>commented()</script> -->",
1019 "<script>real()</script>",
1020 "</head><body></body></html>"
1021 );
1022 let found = find_inline_script(html);
1023 assert!(found.is_some(), "the real script should still be found");
1024 let (_, _, content, _) = found.unwrap();
1025 assert_eq!(
1026 content.trim(),
1027 "real()",
1028 "a commented-out script must not be hoisted: {content:?}"
1029 );
1030 }
1031
1032 #[test]
1037 fn inline_collection_skips_a_script_inside_a_comment() {
1038 let html = concat!(
1039 "<html><head>",
1040 "<!-- <script>commented()</script> -->",
1041 "<script>real()</script>",
1042 "</head><body></body></html>"
1043 );
1044 let found = collect_inline_script_and_style(html).0;
1045 assert_eq!(
1046 found,
1047 vec!["real()"],
1048 "a commented-out script must not be hashed: {found:?}"
1049 );
1050 }
1051
1052 use super::*;
1053 use tempfile::tempdir;
1054
1055 #[test]
1059 fn a_meta_policy_drops_directives_meta_cannot_carry() {
1060 let out = policy_for_meta(DEFAULT_CSP_POLICY);
1061 assert!(
1062 !out.contains("frame-ancestors"),
1063 "frame-ancestors must not reach a meta policy: {out}"
1064 );
1065 for kept in ["default-src", "script-src", "style-src", "img-src"] {
1066 assert!(out.contains(kept), "{kept} must survive: {out}");
1067 }
1068 assert!(!out.ends_with(';'), "no dangling separator: {out}");
1069 }
1070
1071 #[test]
1072 fn policy_for_meta_drops_every_header_only_directive() {
1073 let p = "default-src 'self'; frame-ancestors 'none'; \
1074 report-uri /r; report-to grp; sandbox allow-forms";
1075 assert_eq!(policy_for_meta(p), "default-src 'self'");
1076 }
1077
1078 #[test]
1080 fn the_injected_meta_tag_carries_no_frame_ancestors() {
1081 let html = "<html><head><title>t</title></head><body></body></html>";
1082 let out = inject_csp_meta(html, DEFAULT_CSP_POLICY);
1083 assert!(out.contains("Content-Security-Policy"), "{out}");
1084 assert!(
1085 !out.contains("frame-ancestors"),
1086 "the emitted tag must not carry it: {out}"
1087 );
1088 }
1089
1090 #[test]
1096 fn an_extracted_stylesheet_link_is_hoisted_into_head() {
1097 let dir = tempdir().expect("tempdir");
1098 let site = dir.path();
1099 let html = concat!(
1100 "<html><head><title>t</title></head>",
1101 "<body><p>copy</p><style>body{margin:0}</style></body></html>"
1102 );
1103 let (out, n) = extract_inline_blocks(
1104 html,
1105 &site.join("_csp"),
1106 site,
1107 SriAlgorithm::Sha384,
1108 "",
1109 )
1110 .expect("extract");
1111
1112 assert_eq!(n, 1, "one block should have been extracted");
1113 let head_end = out.find("</head>").expect("head");
1114 let link = out.find("<link rel=\"stylesheet\"").expect("link emitted");
1115 assert!(
1116 link < head_end,
1117 "the stylesheet link must land inside <head>, got: {out}"
1118 );
1119 assert!(
1120 !out[out.find("<body").unwrap()..]
1121 .contains("<link rel=\"stylesheet\""),
1122 "no stylesheet link may remain in <body>: {out}"
1123 );
1124 }
1125
1126 #[test]
1135 fn hoisted_links_ignore_a_head_close_inside_a_comment() {
1136 let dir = tempdir().expect("tempdir");
1137 let site = dir.path();
1138 let html = concat!(
1139 "<html><head><!-- </head> --><title>T</title></head><body>",
1140 "<style>.a{color:red}</style>",
1141 "</body></html>"
1142 );
1143 let (out, n) = extract_inline_blocks(
1144 html,
1145 &site.join("_csp"),
1146 site,
1147 SriAlgorithm::Sha384,
1148 "",
1149 )
1150 .expect("extract");
1151 assert_eq!(n, 1);
1152
1153 let link = out
1154 .find("<link rel=\"stylesheet\"")
1155 .expect("a stylesheet link was emitted");
1156 let comment_end = out.find("-->").expect("the comment survives");
1157 assert!(
1158 link > comment_end,
1159 "the link was spliced inside the comment, where it does nothing:\n{out}"
1160 );
1161 }
1162
1163 #[test]
1166 fn hoisted_links_keep_their_relative_order() {
1167 let dir = tempdir().expect("tempdir");
1168 let site = dir.path();
1169 let html = concat!(
1170 "<html><head></head><body>",
1171 "<style>.a{color:red}</style>",
1172 "<style>.b{color:blue}</style>",
1173 "</body></html>"
1174 );
1175 let (out, n) = extract_inline_blocks(
1176 html,
1177 &site.join("_csp"),
1178 site,
1179 SriAlgorithm::Sha384,
1180 "",
1181 )
1182 .expect("extract");
1183 assert_eq!(n, 2);
1184 let links: Vec<&str> = out
1186 .match_indices("<link rel=\"stylesheet\"")
1187 .map(|(i, _)| &out[i..i + 90])
1188 .collect();
1189 assert_eq!(links.len(), 2, "both links present: {out}");
1190 let a = fs::read_to_string(
1191 site.join("_csp").join(
1192 links[0]
1193 .split("href=\"/")
1194 .nth(1)
1195 .and_then(|s| s.split('"').next())
1196 .and_then(|s| s.rsplit('/').next())
1197 .expect("first href"),
1198 ),
1199 )
1200 .expect("first file");
1201 assert!(a.contains("red"), "first link should be the first style");
1202 }
1203
1204 #[test]
1205 fn extract_style_block() {
1206 let html = "<html><head><style>body { color: red; }</style></head><body></body></html>";
1207 let dir = tempdir().unwrap();
1208 let csp_dir = dir.path().join("_csp");
1209
1210 let (result, count) = extract_inline_blocks(
1211 html,
1212 &csp_dir,
1213 dir.path(),
1214 SriAlgorithm::default(),
1215 "",
1216 )
1217 .unwrap();
1218
1219 assert_eq!(count, 1);
1220 assert!(result.contains("<link rel=\"stylesheet\""));
1221 assert!(result.contains("integrity=\"sha384-"));
1223 assert!(!result.contains("<style>"));
1224 }
1225
1226 #[test]
1227 fn an_extracted_stylesheet_is_minified() {
1228 let css = "/* note */\nbody {\n color: red;\n}\n";
1232 let html = format!(
1233 "<html><head><style>{css}</style></head><body></body></html>"
1234 );
1235 let dir = tempdir().unwrap();
1236 let csp_dir = dir.path().join("_csp");
1237
1238 let (result, count) = extract_inline_blocks(
1239 &html,
1240 &csp_dir,
1241 dir.path(),
1242 SriAlgorithm::default(),
1243 "",
1244 )
1245 .unwrap();
1246
1247 assert_eq!(count, 1);
1248 let written: Vec<String> = fs::read_dir(&csp_dir)
1249 .unwrap()
1250 .map(|e| fs::read_to_string(e.unwrap().path()).unwrap())
1251 .collect();
1252 assert_eq!(written, vec![crate::plugins::minify_css(css)]);
1253 assert!(!written[0].contains("note"), "comment kept: {}", written[0]);
1254 let sri = compute_sri(written[0].as_bytes(), SriAlgorithm::default());
1256 assert!(
1257 result.contains(&sri),
1258 "SRI does not match the file: {result}"
1259 );
1260 }
1261
1262 #[test]
1263 fn extract_style_block_default_sha384_exact_vector() {
1264 let html = "<html><head><style>body { color: red; }</style></head><body></body></html>";
1268 let dir = tempdir().unwrap();
1269 let csp_dir = dir.path().join("_csp");
1270
1271 let (result, count) = extract_inline_blocks(
1272 html,
1273 &csp_dir,
1274 dir.path(),
1275 SriAlgorithm::default(),
1276 "",
1277 )
1278 .unwrap();
1279
1280 assert_eq!(count, 1);
1281 assert!(
1282 result.contains(
1283 "integrity=\"sha384-3x51QY511qAMtrqY6a1lNefFvJ68/Z1IQtkFiRp4Y0PBSanRyJCC0HCjgr1zcYV2\""
1284 ),
1285 "expected exact SHA-384 SRI vector; got: {result}"
1286 );
1287 }
1288
1289 #[test]
1290 fn base_url_path_prefix_extracts_sub_path_mount_points() {
1291 assert_eq!(base_url_path_prefix("https://example.com"), "");
1292 assert_eq!(base_url_path_prefix("https://example.com/"), "");
1293 assert_eq!(base_url_path_prefix("https://example.com/apex"), "/apex");
1294 assert_eq!(base_url_path_prefix("https://example.com/apex/"), "/apex");
1295 assert_eq!(base_url_path_prefix("https://e.com/a/b/"), "/a/b");
1296 assert_eq!(base_url_path_prefix("example.com/apex"), "/apex");
1298 }
1299
1300 #[test]
1305 fn extracted_assets_are_prefixed_for_sub_path_deploys() {
1306 let dir = tempdir().unwrap();
1307 let site = dir.path().join("site");
1308 let csp = site.join("_csp");
1309 fs::create_dir_all(&csp).unwrap();
1310
1311 let (out, count) = extract_inline_blocks(
1312 "<style>body{color:red}</style><script>var x=1;</script>",
1313 &csp,
1314 &site,
1315 SriAlgorithm::default(),
1316 "/apex",
1317 )
1318 .unwrap();
1319
1320 assert_eq!(count, 2);
1321 assert!(
1322 out.contains("href=\"/apex/_csp/"),
1323 "stylesheet must carry the sub-path prefix: {out}"
1324 );
1325 assert!(
1326 out.contains("src=\"/apex/_csp/"),
1327 "script must carry the sub-path prefix: {out}"
1328 );
1329 assert!(
1330 !out.contains("href=\"/_csp/"),
1331 "no unprefixed root-absolute reference may survive: {out}"
1332 );
1333 }
1334
1335 #[test]
1336 fn transform_html_sri_algorithm_config_override_emits_sha256() {
1337 use crate::cmd::{SecurityConfig, SsgConfig};
1342
1343 let html = "<html><head><style>body { color: red; }</style></head><body></body></html>";
1344 let dir = tempdir().unwrap();
1345 let site = dir.path().join("site");
1346 fs::create_dir_all(&site).unwrap();
1347
1348 let config = SsgConfig::builder()
1349 .security(SecurityConfig {
1350 sri_algorithm: SriAlgorithm::Sha256,
1351 })
1352 .build()
1353 .unwrap();
1354 let ctx = PluginContext::with_config(
1355 dir.path(),
1356 dir.path(),
1357 &site,
1358 dir.path(),
1359 config,
1360 );
1361 let out = CspPlugin
1362 .transform_html(html, &site.join("index.html"), &ctx)
1363 .unwrap();
1364 assert!(
1365 out.contains(
1366 "integrity=\"sha256-qGHujruJPON+QdsJZpjz7gs+HNZ4zSFz6Onf591nLc0=\""
1367 ),
1368 "expected exact SHA-256 SRI vector; got: {out}"
1369 );
1370 assert!(!out.contains("sha384-"), "override must win: {out}");
1371 }
1372
1373 #[test]
1374 fn extract_script_block() {
1375 let html =
1376 "<html><body><script>console.log('hi');</script></body></html>";
1377 let dir = tempdir().unwrap();
1378 let csp_dir = dir.path().join("_csp");
1379
1380 let (result, count) = extract_inline_blocks(
1381 html,
1382 &csp_dir,
1383 dir.path(),
1384 SriAlgorithm::default(),
1385 "",
1386 )
1387 .unwrap();
1388
1389 assert_eq!(count, 1);
1390 assert!(result.contains("<script src="));
1391 assert!(result.contains("integrity=\"sha384-"));
1393 assert!(!result.contains("console.log"));
1394 }
1395
1396 #[test]
1397 fn skips_jsonld_scripts() {
1398 let html = r#"<html><body><script type="application/ld+json">{"@type":"Thing"}</script></body></html>"#;
1399 let dir = tempdir().unwrap();
1400 let csp_dir = dir.path().join("_csp");
1401
1402 let (result, count) = extract_inline_blocks(
1403 html,
1404 &csp_dir,
1405 dir.path(),
1406 SriAlgorithm::default(),
1407 "",
1408 )
1409 .unwrap();
1410
1411 assert_eq!(count, 0);
1412 assert!(result.contains("application/ld+json"));
1413 }
1414
1415 #[test]
1416 fn skips_livereload_scripts() {
1417 let html = r#"<html><body><script data-ssg-livereload>ws.connect();</script></body></html>"#;
1418 let dir = tempdir().unwrap();
1419 let csp_dir = dir.path().join("_csp");
1420
1421 let (result, count) = extract_inline_blocks(
1422 html,
1423 &csp_dir,
1424 dir.path(),
1425 SriAlgorithm::default(),
1426 "",
1427 )
1428 .unwrap();
1429
1430 assert_eq!(count, 0);
1431 assert!(result.contains("data-ssg-livereload"));
1432 }
1433
1434 #[test]
1435 fn skips_external_scripts() {
1436 let html =
1437 r#"<html><body><script src="/app.js"></script></body></html>"#;
1438 let dir = tempdir().unwrap();
1439 let csp_dir = dir.path().join("_csp");
1440
1441 let (result, count) = extract_inline_blocks(
1442 html,
1443 &csp_dir,
1444 dir.path(),
1445 SriAlgorithm::default(),
1446 "",
1447 )
1448 .unwrap();
1449
1450 assert_eq!(count, 0);
1451 assert_eq!(result, html);
1452 }
1453
1454 #[test]
1455 fn removes_unsafe_inline_from_csp() {
1456 let html = r#"<meta http-equiv="Content-Security-Policy" content="script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'">"#;
1457 let result = remove_unsafe_inline_from_csp(html);
1458 assert!(!result.contains("unsafe-inline"));
1459 }
1460
1461 #[test]
1462 fn removing_unsafe_inline_leaves_no_double_space() {
1463 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">"#;
1466 let result = remove_unsafe_inline_from_csp(html);
1467 assert!(!result.contains(" "), "double space in {result}");
1468 assert!(result.contains("script-src 'self' 'unsafe-eval';"));
1469 assert!(result.contains("style-src 'self' https://example.com"));
1470 }
1471
1472 #[test]
1473 fn leaves_prose_mentioning_unsafe_inline_alone() {
1474 let html = concat!(
1477 r#"<meta http-equiv="Content-Security-Policy" content="script-src 'self' 'unsafe-inline'">"#,
1478 r#"<p>Set <code>script-src 'self' 'unsafe-inline'</code> to allow it.</p>"#
1479 );
1480 let result = remove_unsafe_inline_from_csp(html);
1481 assert!(
1482 result.contains("<code>script-src 'self' 'unsafe-inline'</code>"),
1483 "prose was rewritten: {result}"
1484 );
1485 let meta_end = result.find("<p>").expect("prose follows the meta");
1487 assert!(!result[..meta_end].contains("unsafe-inline"));
1488 }
1489
1490 #[test]
1491 fn leaves_documents_without_a_csp_meta_untouched() {
1492 let html = r#"<p>The token 'unsafe-inline' weakens a policy.</p>"#;
1493 assert_eq!(remove_unsafe_inline_from_csp(html), html);
1494 }
1495
1496 #[test]
1497 fn skips_empty_style_blocks() {
1498 let html = "<html><head><style> </style></head></html>";
1499 let dir = tempdir().unwrap();
1500 let csp_dir = dir.path().join("_csp");
1501
1502 let (_, count) = extract_inline_blocks(
1503 html,
1504 &csp_dir,
1505 dir.path(),
1506 SriAlgorithm::default(),
1507 "",
1508 )
1509 .unwrap();
1510 assert_eq!(count, 0);
1511 }
1512
1513 #[test]
1514 fn csp_plugin_name() {
1515 assert_eq!(CspPlugin.name(), "csp");
1516 }
1517
1518 #[test]
1519 fn csp_plugin_skips_missing_site_dir() {
1520 let ctx = PluginContext::new(
1521 Path::new("/tmp/c"),
1522 Path::new("/tmp/b"),
1523 Path::new("/nonexistent/site"),
1524 Path::new("/tmp/t"),
1525 );
1526 assert!(CspPlugin.after_compile(&ctx).is_ok());
1527 }
1528
1529 #[test]
1530 fn csp_plugin_processes_html_files() {
1531 let dir = tempdir().unwrap();
1532 let site = dir.path().join("site");
1533 fs::create_dir_all(&site).unwrap();
1534 let html = "<html><head><style>body{color:red}</style></head><body><script>alert(1)</script></body></html>";
1535
1536 let ctx = PluginContext::new(dir.path(), dir.path(), &site, dir.path());
1537 CspPlugin.after_compile(&ctx).unwrap();
1538
1539 let output = CspPlugin
1540 .transform_html(html, &site.join("index.html"), &ctx)
1541 .unwrap();
1542 assert!(output.contains("<link rel=\"stylesheet\""));
1543 assert!(output.contains("<script src="));
1544 assert!(!output.contains("body{color:red}"));
1545 assert!(!output.contains("alert(1)"));
1546 assert!(site.join("_csp").exists());
1547 }
1548
1549 #[test]
1550 fn fnv_hash_deterministic() {
1551 let h1 = fnv_hash(b"hello");
1552 let h2 = fnv_hash(b"hello");
1553 assert_eq!(h1, h2);
1554 }
1555
1556 #[test]
1557 fn fnv_hash_different_inputs() {
1558 assert_ne!(fnv_hash(b"a"), fnv_hash(b"b"));
1559 }
1560
1561 #[test]
1562 fn compute_sri_format() {
1563 let sri = compute_sri(b"test", SriAlgorithm::default());
1566 assert!(sri.starts_with("sha384-"));
1567 assert!(
1568 compute_sri(b"test", SriAlgorithm::Sha256).starts_with("sha256-")
1569 );
1570 assert!(
1571 compute_sri(b"test", SriAlgorithm::Sha512).starts_with("sha512-")
1572 );
1573 }
1574
1575 #[test]
1578 fn csp_plugin_new_constructs_unit_struct() {
1579 let p = CspPlugin::new();
1580 assert_eq!(p.name(), "csp");
1581 assert!(p.has_transform());
1582 }
1583
1584 #[test]
1585 fn inject_csp_meta_adds_meta_when_absent() {
1586 let html = "<html><head><title>T</title></head><body></body></html>";
1587 let out = inject_csp_meta(html, "default-src 'self'");
1588 assert!(out.contains("http-equiv=\"Content-Security-Policy\""));
1589 assert!(out.contains("default-src 'self'"));
1590 }
1591
1592 #[test]
1593 fn inject_csp_meta_is_idempotent_when_meta_already_present() {
1594 let html = r#"<html><head><meta http-equiv="Content-Security-Policy" content="default-src 'self'"></head></html>"#;
1595 let out = inject_csp_meta(html, "script-src 'self'");
1596 let count = out
1598 .matches("http-equiv=\"Content-Security-Policy\"")
1599 .count();
1600 assert_eq!(count, 1, "must not duplicate CSP meta tag");
1601 assert!(!out.contains("script-src 'self'"));
1603 }
1604
1605 #[test]
1606 fn inject_csp_meta_handles_no_head_gracefully() {
1607 let html = "<html><body>no head</body></html>";
1608 let out = inject_csp_meta(html, "default-src 'self'");
1609 assert_eq!(out, html);
1611 }
1612
1613 #[test]
1614 fn preserve_script_attrs_keeps_type_module() {
1615 let out = preserve_script_attrs(
1616 r#"<script type="module">"#,
1617 &["src", "integrity", "crossorigin"],
1618 );
1619 assert!(out.contains(r#"type="module""#), "got: {out}");
1620 }
1621
1622 #[test]
1623 fn preserve_script_attrs_keeps_boolean_async_defer() {
1624 let out = preserve_script_attrs(
1625 "<script async defer>",
1626 &["src", "integrity", "crossorigin"],
1627 );
1628 assert!(out.contains("async"), "got: {out}");
1629 assert!(out.contains("defer"), "got: {out}");
1630 }
1631
1632 #[test]
1633 fn preserve_script_attrs_drops_listed_attrs() {
1634 let out = preserve_script_attrs(
1635 r#"<script src="/x.js" integrity="sha384-foo" crossorigin="anonymous" data-id="9">"#,
1636 &["src", "integrity", "crossorigin"],
1637 );
1638 assert!(!out.contains("src="), "got: {out}");
1639 assert!(!out.contains("integrity="), "got: {out}");
1640 assert!(!out.contains("crossorigin="), "got: {out}");
1641 assert!(out.contains(r#"data-id="9""#), "got: {out}");
1642 }
1643
1644 #[test]
1645 fn preserve_script_attrs_empty_when_no_attrs() {
1646 let out = preserve_script_attrs("<script>", &["src"]);
1647 assert_eq!(out, "");
1648 }
1649
1650 #[test]
1651 fn extract_inline_script_preserves_type_module() {
1652 let html = r#"<html><body><script type="module">import x from '/m.js';</script></body></html>"#;
1653 let dir = tempdir().unwrap();
1654 let csp_dir = dir.path().join("_csp");
1655 let (out, count) = extract_inline_blocks(
1656 html,
1657 &csp_dir,
1658 dir.path(),
1659 SriAlgorithm::default(),
1660 "",
1661 )
1662 .unwrap();
1663 assert_eq!(count, 1);
1664 assert!(out.contains(r#"type="module""#), "got: {out}");
1665 assert!(out.contains("integrity=\"sha384-"), "got: {out}");
1666 }
1667
1668 #[test]
1669 fn extract_inline_script_preserves_data_attrs() {
1670 let html = r#"<html><body><script data-domain="example.com">window.x=1;</script></body></html>"#;
1671 let dir = tempdir().unwrap();
1672 let csp_dir = dir.path().join("_csp");
1673 let (out, count) = extract_inline_blocks(
1674 html,
1675 &csp_dir,
1676 dir.path(),
1677 SriAlgorithm::default(),
1678 "",
1679 )
1680 .unwrap();
1681 assert_eq!(count, 1);
1682 assert!(out.contains(r#"data-domain="example.com""#), "got: {out}");
1683 }
1684
1685 const JSONLD_BODY: &str = r#"{"@type":"Thing"}"#;
1690
1691 #[test]
1692 fn template_with_empty_slots_is_exactly_the_global_policy() {
1693 assert_eq!(
1696 render_policy_template(DEFAULT_CSP_POLICY_TEMPLATE, &[], &[]),
1697 DEFAULT_CSP_POLICY
1698 );
1699 }
1700
1701 #[test]
1702 fn page_inline_hashes_includes_jsonld_blocks() {
1703 let html = format!(
1704 r#"<html><body><script type="application/ld+json">{JSONLD_BODY}</script></body></html>"#
1705 );
1706 let hashes = page_inline_hashes(&html);
1707 assert_eq!(hashes.scripts.len(), 1);
1708 let expected = SriAlgorithm::Sha256.integrity(JSONLD_BODY.as_bytes());
1709 assert_eq!(hashes.scripts[0], expected);
1710 }
1711
1712 #[test]
1713 fn page_inline_hashes_skips_external_scripts_and_empty_blocks() {
1714 let html = r#"<html><body>
1715 <script src="/app.js"></script>
1716 <script> </script>
1717 <style></style>
1718 </body></html>"#;
1719 assert!(page_inline_hashes(html).is_empty());
1720 }
1721
1722 #[test]
1723 fn page_inline_hashes_orders_by_document_position_and_dedups() {
1724 let html =
1725 "<script>aaa</script><script>bbb</script><script>aaa</script>";
1726 let hashes = page_inline_hashes(html);
1727 assert_eq!(hashes.scripts.len(), 2, "duplicate block deduped");
1728 assert_eq!(
1729 hashes.scripts[0],
1730 SriAlgorithm::Sha256.integrity(b"aaa"),
1731 "document order preserved"
1732 );
1733 assert_eq!(hashes.scripts[1], SriAlgorithm::Sha256.integrity(b"bbb"));
1734 }
1735
1736 #[test]
1737 fn page_inline_hashes_collects_styles_separately() {
1738 let html = "<style>body{color:red}</style><script>alert(1)</script>";
1739 let hashes = page_inline_hashes(html);
1740 assert_eq!(hashes.styles.len(), 1);
1741 assert_eq!(hashes.scripts.len(), 1);
1742 assert_eq!(
1743 hashes.styles[0],
1744 SriAlgorithm::Sha256.integrity(b"body{color:red}")
1745 );
1746 }
1747
1748 #[test]
1749 fn page_policy_is_hash_strict_never_unsafe_inline() {
1750 let html = format!(
1753 r#"<script type="application/ld+json">{JSONLD_BODY}</script>"#
1754 );
1755 let policy = page_policy(&html).expect("policy for inline JSON-LD");
1756 let expected = SriAlgorithm::Sha256.integrity(JSONLD_BODY.as_bytes());
1757 assert!(
1758 policy.contains(&format!("script-src 'self' '{expected}'")),
1759 "policy must carry the exact sha256 source: {policy}"
1760 );
1761 assert!(!policy.contains("unsafe-inline"));
1762 }
1763
1764 #[test]
1765 fn page_policy_none_without_inline_blocks() {
1766 assert!(page_policy(
1767 "<html><head><title>t</title></head><body>x</body></html>"
1768 )
1769 .is_none());
1770 }
1771
1772 #[test]
1773 fn page_policy_is_deterministic() {
1774 let html = "<style>a{}</style><script>x=1</script>";
1775 assert_eq!(page_policy(html), page_policy(html));
1776 }
1777
1778 #[test]
1779 fn csp_plugin_transform_html_no_inline_blocks_returns_unchanged() {
1780 let html =
1781 "<html><head><title>X</title></head><body><p>hi</p></body></html>";
1782 let dir = tempdir().unwrap();
1783 let site = dir.path().join("site");
1784 fs::create_dir_all(&site).unwrap();
1785 let ctx = PluginContext::new(dir.path(), dir.path(), &site, dir.path());
1786 let out = CspPlugin
1787 .transform_html(html, &site.join("index.html"), &ctx)
1788 .unwrap();
1789 assert_eq!(out, html);
1790 }
1791
1792 #[test]
1797 fn page_inline_hashes_dedupes_identical_style_blocks() {
1798 let html = "<style>a{color:red}</style><style>a{color:red}</style>";
1799 let hashes = page_inline_hashes(html);
1800 assert_eq!(hashes.styles.len(), 1);
1801 }
1802
1803 #[test]
1804 fn inline_collection_skips_prefix_tag_names() {
1805 let html = "<styles>ignored</styles><style>a{}</style>";
1807 let out = collect_inline_script_and_style(html).1;
1808 assert_eq!(out, vec!["a{}"]);
1809 }
1810
1811 #[test]
1812 fn inline_collection_accepts_slash_after_tag_name() {
1813 let html = "<style/>a{}</style>";
1815 let out = collect_inline_script_and_style(html).1;
1816 assert_eq!(out, vec!["a{}"]);
1817 }
1818
1819 #[test]
1820 fn inline_collection_stops_when_opening_tag_unterminated() {
1821 let html = "<style media=all";
1822 assert!(
1823 collect_inline_script_and_style(html).1.is_empty(),
1824 "{:?}",
1825 collect_inline_script_and_style(html).1
1826 );
1827 }
1828
1829 #[test]
1830 fn inline_collection_stops_when_close_tag_missing() {
1831 let html = "<style>a{} no closing fence";
1832 assert!(
1833 collect_inline_script_and_style(html).1.is_empty(),
1834 "{:?}",
1835 collect_inline_script_and_style(html).1
1836 );
1837 }
1838
1839 #[test]
1840 fn find_inline_block_returns_none_without_close_tag() {
1841 assert!(find_inline_block("<style>a{}", "style").is_none());
1842 }
1843
1844 #[test]
1845 fn find_inline_script_returns_none_when_opening_unterminated() {
1846 assert!(find_inline_script("<script").is_none());
1847 }
1848
1849 #[test]
1850 fn find_inline_script_returns_none_without_close_tag() {
1851 assert!(find_inline_script("<script>var x = 1;").is_none());
1852 }
1853
1854 #[test]
1855 fn find_inline_script_skips_empty_script_then_finds_real_one() {
1856 let html = "<script> </script><script>var x = 1;</script>";
1857 let (_, _, content, _) = find_inline_script(html).unwrap();
1858 assert_eq!(content, "var x = 1;");
1859 }
1860
1861 #[test]
1862 fn rewrite_or_original_returns_input_on_error() {
1863 let err = SsgError::io(
1864 std::io::Error::other("synthetic rewrite failure"),
1865 "<lol_html>",
1866 );
1867 assert_eq!(rewrite_or_original("<p>x</p>", Err(err)), "<p>x</p>");
1868 }
1869
1870 #[test]
1875 fn after_compile_fails_when_csp_dir_squatted_by_file() {
1876 let dir = tempdir().unwrap();
1877 let site = dir.path().join("site");
1878 fs::create_dir_all(&site).unwrap();
1879 fs::write(site.join("_csp"), "not a dir").unwrap();
1880 let ctx = PluginContext::new(dir.path(), dir.path(), &site, dir.path());
1881 let err = CspPlugin.after_compile(&ctx).unwrap_err();
1882 assert!(!format!("{err}").is_empty(), "should not be empty");
1883 }
1884
1885 #[test]
1886 fn transform_html_fails_when_csp_dir_squatted_by_file() {
1887 let dir = tempdir().unwrap();
1890 let site = dir.path().join("site");
1891 fs::create_dir_all(&site).unwrap();
1892 fs::write(site.join("_csp"), "not a dir").unwrap();
1893 let ctx = PluginContext::new(dir.path(), dir.path(), &site, dir.path());
1894 let err = CspPlugin
1895 .transform_html(
1896 "<style>a{}</style>",
1897 &site.join("index.html"),
1898 &ctx,
1899 )
1900 .unwrap_err();
1901 assert!(!format!("{err}").is_empty(), "should not be empty");
1902 }
1903
1904 #[test]
1905 fn extract_inline_blocks_script_dir_create_fails_when_squatted_by_file() {
1906 let dir = tempdir().unwrap();
1909 let site = dir.path().join("site");
1910 fs::create_dir_all(&site).unwrap();
1911 fs::write(site.join("_csp"), "not a dir").unwrap();
1912
1913 let res = extract_inline_blocks(
1914 "<script>var x = 1;</script>",
1915 &site.join("_csp"),
1916 &site,
1917 SriAlgorithm::default(),
1918 "",
1919 );
1920 assert!(res.is_err());
1921 }
1922
1923 #[test]
1924 fn extract_inline_blocks_style_write_fails_when_squatted_by_dir() {
1925 let dir = tempdir().unwrap();
1928 let site = dir.path().join("site");
1929 let csp_dir = site.join("_csp");
1930 let content = "a{color:red}";
1931 let squat =
1932 csp_dir.join(format!("{:016x}.css", fnv_hash(content.as_bytes())));
1933 fs::create_dir_all(&squat).unwrap();
1934
1935 let res = extract_inline_blocks(
1936 &format!("<style>{content}</style>"),
1937 &csp_dir,
1938 &site,
1939 SriAlgorithm::default(),
1940 "",
1941 );
1942 assert!(res.is_err());
1943 }
1944
1945 #[test]
1946 fn extract_inline_blocks_script_write_fails_when_squatted_by_dir() {
1947 let dir = tempdir().unwrap();
1948 let site = dir.path().join("site");
1949 let csp_dir = site.join("_csp");
1950 let content = "var x = 1;";
1951 let squat =
1952 csp_dir.join(format!("{:016x}.js", fnv_hash(content.as_bytes())));
1953 fs::create_dir_all(&squat).unwrap();
1954
1955 let res = extract_inline_blocks(
1956 &format!("<script>{content}</script>"),
1957 &csp_dir,
1958 &site,
1959 SriAlgorithm::default(),
1960 "",
1961 );
1962 assert!(res.is_err());
1963 }
1964}