1pub(crate) mod cloudflare;
87pub(crate) mod netlify;
88pub(crate) mod vercel;
89
90use crate::cmd::EdgeHeadersConfig;
91use crate::error::{PathErrorExt, SsgError};
92use crate::plugin::{Plugin, PluginContext};
93use std::collections::BTreeMap;
94use std::fs;
95use std::path::{Path, PathBuf};
96use std::sync::{LazyLock, Mutex, PoisonError};
97
98static PAGE_CSP_REGISTRY: LazyLock<
109 Mutex<BTreeMap<PathBuf, BTreeMap<String, String>>>,
110> = LazyLock::new(|| Mutex::new(BTreeMap::new()));
111
112#[must_use]
133pub fn baseline_headers() -> [(&'static str, String); 5] {
134 [
135 (
136 "Strict-Transport-Security",
137 "max-age=63072000; includeSubDomains; preload".to_string(),
138 ),
139 (
140 "Content-Security-Policy",
141 crate::csp::computed_policy().to_string(),
142 ),
143 ("X-Content-Type-Options", "nosniff".to_string()),
144 (
145 "Referrer-Policy",
146 "strict-origin-when-cross-origin".to_string(),
147 ),
148 (
149 "Permissions-Policy",
150 "camera=(), geolocation=(), microphone=()".to_string(),
151 ),
152 ]
153}
154
155#[must_use]
174pub fn merged_headers(
175 overrides: &BTreeMap<String, String>,
176) -> Vec<(String, String)> {
177 let baseline = baseline_headers();
178 let mut lower_overrides: BTreeMap<String, (String, String)> = overrides
179 .iter()
180 .map(|(k, v)| (k.to_ascii_lowercase(), (k.clone(), v.clone())))
181 .collect();
182
183 let mut out: Vec<(String, String)> = Vec::with_capacity(baseline.len());
184 for (key, default_value) in baseline {
185 let key_lc = key.to_ascii_lowercase();
186 if let Some((_orig_key, override_value)) =
187 lower_overrides.remove(&key_lc)
188 {
189 out.push((key.to_string(), override_value));
190 } else {
191 out.push((key.to_string(), default_value));
192 }
193 }
194 for (_lc, (orig, value)) in lower_overrides {
197 out.push((orig, value));
198 }
199 out
200}
201
202pub(crate) const PQC_NOTE_LINES: &[&str] = &[
207 "PQC posture: TLS 1.3 with the X25519+ML-KEM-768 hybrid",
208 "key-exchange suite (RFC 9420 / draft-ietf-tls-hybrid-design).",
209 "Cloudflare auto-negotiates as of mid-2026; Netlify is",
210 "behind an opt-in in the platform dashboard; Vercel surfaces",
211 "the suite once the upstream CDN (Cloudflare/AWS) enables it.",
212 "Configure at the platform level — this file is documentation",
213 "of the recommended posture, not a runtime knob.",
214 "Cloudflare: https://developers.cloudflare.com/ssl/post-quantum-cryptography/",
215 "Netlify: https://docs.netlify.com/edge-functions/overview/",
216 "Vercel: https://vercel.com/docs/edge-network/headers",
217];
218
219#[derive(Debug, Clone, Copy, Default)]
235pub struct EdgeHeadersPlugin;
236
237impl EdgeHeadersPlugin {
238 #[must_use]
248 pub const fn new() -> Self {
249 Self
250 }
251}
252
253impl Plugin for EdgeHeadersPlugin {
254 fn name(&self) -> &'static str {
255 "edge-headers"
256 }
257
258 fn after_compile(&self, ctx: &PluginContext) -> Result<(), SsgError> {
259 {
263 let mut registry = PAGE_CSP_REGISTRY
264 .lock()
265 .unwrap_or_else(PoisonError::into_inner);
266 let _ = registry.remove(&ctx.site_dir);
267 }
268
269 if !ctx.site_dir.exists() {
270 return Ok(());
271 }
272
273 let Some(cfg) = ctx.config.as_ref() else {
274 return Ok(());
275 };
276 let edge = &cfg.edge_headers;
277 if !edge.is_enabled() {
278 return Ok(());
279 }
280
281 emit_targets(ctx, edge, &BTreeMap::new())
285 }
286
287 fn has_transform(&self) -> bool {
288 true
289 }
290
291 fn transform_html(
297 &self,
298 html: &str,
299 path: &Path,
300 ctx: &PluginContext,
301 ) -> Result<String, SsgError> {
302 let Some(cfg) = ctx.config.as_ref() else {
303 return Ok(html.to_string());
304 };
305 let edge = &cfg.edge_headers;
306 if !edge.is_enabled() || !ctx.site_dir.exists() {
307 return Ok(html.to_string());
308 }
309
310 let Some(policy) = crate::csp::page_policy(html) else {
311 return Ok(html.to_string());
313 };
314
315 let url = url_path_for(path, &ctx.site_dir);
316
317 let mut registry = PAGE_CSP_REGISTRY
322 .lock()
323 .unwrap_or_else(PoisonError::into_inner);
324 let pages = registry.entry(ctx.site_dir.clone()).or_default();
325 let _ = pages.insert(url, policy);
326 emit_targets(ctx, edge, pages)?;
327
328 Ok(html.to_string())
329 }
330}
331
332fn emit_targets(
339 ctx: &PluginContext,
340 edge: &EdgeHeadersConfig,
341 page_csp: &BTreeMap<String, String>,
342) -> Result<(), SsgError> {
343 let headers = merged_headers(&edge.overrides);
344
345 let edge_dir = ctx.site_dir.join(".ssg").join("edge");
349
350 for target in &edge.targets {
351 match target.to_ascii_lowercase().as_str() {
352 "cloudflare" => {
353 fs::create_dir_all(&edge_dir).with_path(&edge_dir)?;
354 let out_path = edge_dir.join("wrangler-headers.toml");
355 let body = cloudflare::render(&headers);
356 fs::write(&out_path, body).with_path(&out_path)?;
357 log::info!("[edge-headers] wrote {}", out_path.display());
358 }
359 "netlify" => {
360 let out_path = ctx.site_dir.join("_headers");
361 let body = netlify::render(&headers, page_csp);
362 fs::write(&out_path, body).with_path(&out_path)?;
363 log::info!("[edge-headers] wrote {}", out_path.display());
364 }
365 "vercel" => {
366 fs::create_dir_all(&edge_dir).with_path(&edge_dir)?;
367 let out_path = edge_dir.join("vercel-headers.json");
368 let body = vercel_render(&headers, page_csp).map_err(|e| {
369 SsgError::io(
370 std::io::Error::other(e.to_string()),
371 &out_path,
372 )
373 })?;
374 fs::write(&out_path, body).with_path(&out_path)?;
375 log::info!("[edge-headers] wrote {}", out_path.display());
376 }
377 other => {
378 log::warn!(
379 "[edge-headers] unknown target `{other}` — skipping"
380 );
381 }
382 }
383 }
384
385 Ok(())
386}
387
388fn vercel_render(
392 headers: &[(String, String)],
393 page_csp: &BTreeMap<String, String>,
394) -> Result<String, serde_json::Error> {
395 fail_point!("postprocess::vercel-render", |_| Err(
396 <serde_json::Error as serde::ser::Error>::custom(
397 "injected: postprocess::vercel-render"
398 )
399 ));
400 vercel::render(headers, page_csp)
401}
402
403fn url_path_for(path: &Path, site_dir: &Path) -> String {
410 let rel = path
411 .strip_prefix(site_dir)
412 .unwrap_or(path)
413 .to_string_lossy()
414 .replace('\\', "/");
415 if rel == "index.html" {
416 "/".to_string()
417 } else if let Some(dir) = rel.strip_suffix("/index.html") {
418 format!("/{dir}/")
419 } else {
420 format!("/{rel}")
421 }
422}
423
424#[cfg(test)]
425mod tests {
426 use super::*;
427
428 fn make_overrides(pairs: &[(&str, &str)]) -> BTreeMap<String, String> {
429 pairs
430 .iter()
431 .map(|(k, v)| ((*k).to_string(), (*v).to_string()))
432 .collect()
433 }
434
435 #[test]
436 fn baseline_headers_includes_five_canonical_entries() {
437 let baseline = baseline_headers();
438 let keys: Vec<&str> = baseline.iter().map(|(k, _)| *k).collect();
439 assert_eq!(
440 keys,
441 vec![
442 "Strict-Transport-Security",
443 "Content-Security-Policy",
444 "X-Content-Type-Options",
445 "Referrer-Policy",
446 "Permissions-Policy",
447 ]
448 );
449 }
450
451 #[test]
452 fn baseline_csp_comes_from_csp_plugin_not_hardcoded() {
453 let baseline = baseline_headers();
456 let csp = baseline
457 .iter()
458 .find(|(k, _)| *k == "Content-Security-Policy")
459 .map(|(_, v)| v.as_str())
460 .expect("baseline must contain CSP");
461 assert_eq!(csp, crate::csp::computed_policy());
462 }
463
464 #[test]
465 fn baseline_hsts_is_2_year_preload_ready() {
466 let baseline = baseline_headers();
467 let hsts = baseline
468 .iter()
469 .find(|(k, _)| *k == "Strict-Transport-Security")
470 .map(|(_, v)| v.as_str())
471 .unwrap();
472 assert!(hsts.contains("max-age=63072000"));
473 assert!(hsts.contains("includeSubDomains"));
474 assert!(hsts.contains("preload"));
475 }
476
477 #[test]
478 fn merged_preserves_baseline_when_no_overrides() {
479 let merged = merged_headers(&BTreeMap::new());
480 assert_eq!(merged.len(), 5);
481 assert_eq!(merged[0].0, "Strict-Transport-Security");
482 }
483
484 #[test]
485 fn merged_applies_case_insensitive_override() {
486 let overrides =
489 make_overrides(&[("permissions-policy", "geolocation=(self)")]);
490 let merged = merged_headers(&overrides);
491 let pp = merged
492 .iter()
493 .find(|(k, _)| k == "Permissions-Policy")
494 .map(|(_, v)| v.as_str())
495 .unwrap();
496 assert_eq!(pp, "geolocation=(self)");
497
498 let hsts = merged
500 .iter()
501 .find(|(k, _)| k == "Strict-Transport-Security")
502 .map(|(_, v)| v.as_str())
503 .unwrap();
504 assert!(hsts.contains("max-age=63072000"));
505 }
506
507 #[test]
508 fn merged_uppercase_override_key_still_matches_baseline() {
509 let overrides = make_overrides(&[("PERMISSIONS-POLICY", "camera=*")]);
510 let merged = merged_headers(&overrides);
511 let pp = merged
512 .iter()
513 .find(|(k, _)| k == "Permissions-Policy")
514 .unwrap();
515 assert_eq!(pp.1, "camera=*");
516 }
517
518 #[test]
519 fn merged_appends_non_baseline_overrides() {
520 let overrides =
521 make_overrides(&[("Cross-Origin-Opener-Policy", "same-origin")]);
522 let merged = merged_headers(&overrides);
523 assert_eq!(merged.len(), 6);
524 assert!(merged
525 .iter()
526 .any(|(k, v)| k == "Cross-Origin-Opener-Policy"
527 && v == "same-origin"));
528 }
529
530 #[test]
531 fn no_duplicate_csp_in_baseline() {
532 let baseline = baseline_headers();
534 let csp_count = baseline
535 .iter()
536 .filter(|(k, _)| k.eq_ignore_ascii_case("Content-Security-Policy"))
537 .count();
538 assert_eq!(csp_count, 1);
539 }
540
541 #[test]
542 fn plugin_name_is_stable() {
543 assert_eq!(EdgeHeadersPlugin.name(), "edge-headers");
544 }
545
546 #[test]
547 fn after_compile_is_noop_when_site_dir_missing() {
548 let ctx = PluginContext::new(
549 Path::new("/tmp/c"),
550 Path::new("/tmp/b"),
551 Path::new("/nonexistent/site-xyz"),
552 Path::new("/tmp/t"),
553 );
554 assert!(EdgeHeadersPlugin.after_compile(&ctx).is_ok());
555 }
556
557 #[test]
558 fn after_compile_is_noop_when_config_missing() {
559 let dir = tempfile::tempdir().unwrap();
560 let site = dir.path().join("site");
561 fs::create_dir_all(&site).unwrap();
562 let ctx = PluginContext::new(dir.path(), dir.path(), &site, dir.path());
563 EdgeHeadersPlugin.after_compile(&ctx).unwrap();
565 assert!(!site.join("_headers").exists());
566 assert!(!site.join(".ssg/edge").exists());
567 }
568
569 #[test]
570 fn after_compile_skips_when_no_targets_configured() {
571 let dir = tempfile::tempdir().unwrap();
572 let site = dir.path().join("site");
573 fs::create_dir_all(&site).unwrap();
574 let cfg = crate::cmd::SsgConfig::builder()
575 .site_name("t".to_string())
576 .base_url("http://example.com".to_string())
577 .build()
578 .unwrap();
579 let ctx = PluginContext::with_config(
580 dir.path(),
581 dir.path(),
582 &site,
583 dir.path(),
584 cfg,
585 );
586 EdgeHeadersPlugin.after_compile(&ctx).unwrap();
587 assert!(!site.join("_headers").exists());
588 }
589
590 fn cfg_with_targets(targets: Vec<&str>) -> crate::cmd::SsgConfig {
591 let mut edge = EdgeHeadersConfig::default();
592 edge.targets = targets.into_iter().map(String::from).collect();
593 crate::cmd::SsgConfig::builder()
594 .site_name("t".to_string())
595 .base_url("http://example.com".to_string())
596 .edge_headers(edge)
597 .build()
598 .unwrap()
599 }
600
601 #[test]
602 fn after_compile_cloudflare_target_writes_wrangler_headers() {
603 let dir = tempfile::tempdir().unwrap();
604 let site = dir.path().join("site");
605 fs::create_dir_all(&site).unwrap();
606 let cfg = cfg_with_targets(vec!["cloudflare"]);
607 let ctx = PluginContext::with_config(
608 dir.path(),
609 dir.path(),
610 &site,
611 dir.path(),
612 cfg,
613 );
614 EdgeHeadersPlugin.after_compile(&ctx).unwrap();
615 let written = site.join(".ssg/edge/wrangler-headers.toml");
616 assert!(written.exists(), "wrangler-headers.toml must be written");
617 let body = fs::read_to_string(&written).unwrap();
618 assert!(body.contains("Strict-Transport-Security"));
619 }
620
621 #[test]
622 fn after_compile_netlify_target_writes_underscore_headers() {
623 let dir = tempfile::tempdir().unwrap();
624 let site = dir.path().join("site");
625 fs::create_dir_all(&site).unwrap();
626 let cfg = cfg_with_targets(vec!["netlify"]);
627 let ctx = PluginContext::with_config(
628 dir.path(),
629 dir.path(),
630 &site,
631 dir.path(),
632 cfg,
633 );
634 EdgeHeadersPlugin.after_compile(&ctx).unwrap();
635 let written = site.join("_headers");
636 assert!(written.exists());
637 let body = fs::read_to_string(&written).unwrap();
638 assert!(body.contains("Strict-Transport-Security"));
639 }
640
641 #[test]
642 #[serial_test::parallel(vercel_render_fp)]
649 fn after_compile_vercel_target_writes_json() {
650 let dir = tempfile::tempdir().unwrap();
651 let site = dir.path().join("site");
652 fs::create_dir_all(&site).unwrap();
653 let cfg = cfg_with_targets(vec!["vercel"]);
654 let ctx = PluginContext::with_config(
655 dir.path(),
656 dir.path(),
657 &site,
658 dir.path(),
659 cfg,
660 );
661 EdgeHeadersPlugin.after_compile(&ctx).unwrap();
662 let written = site.join(".ssg/edge/vercel-headers.json");
663 assert!(written.exists());
664 let body = fs::read_to_string(&written).unwrap();
665 assert!(body.contains("Strict-Transport-Security"));
666 }
667
668 #[test]
669 fn after_compile_unknown_target_is_warned_and_skipped() {
670 let dir = tempfile::tempdir().unwrap();
671 let site = dir.path().join("site");
672 fs::create_dir_all(&site).unwrap();
673 let cfg = cfg_with_targets(vec!["unknown-cdn"]);
674 let ctx = PluginContext::with_config(
675 dir.path(),
676 dir.path(),
677 &site,
678 dir.path(),
679 cfg,
680 );
681 EdgeHeadersPlugin.after_compile(&ctx).unwrap();
682 assert!(!site.join("_headers").exists());
683 assert!(!site.join(".ssg/edge").exists());
684 }
685
686 #[test]
687 fn after_compile_target_name_is_case_insensitive() {
688 let dir = tempfile::tempdir().unwrap();
689 let site = dir.path().join("site");
690 fs::create_dir_all(&site).unwrap();
691 let cfg = cfg_with_targets(vec!["CloudFlare"]);
692 let ctx = PluginContext::with_config(
693 dir.path(),
694 dir.path(),
695 &site,
696 dir.path(),
697 cfg,
698 );
699 EdgeHeadersPlugin.after_compile(&ctx).unwrap();
700 assert!(site.join(".ssg/edge/wrangler-headers.toml").exists());
701 }
702
703 #[test]
704 #[serial_test::parallel(vercel_render_fp)]
705 fn after_compile_all_three_targets_emit_all_three_artefacts() {
706 let dir = tempfile::tempdir().unwrap();
707 let site = dir.path().join("site");
708 fs::create_dir_all(&site).unwrap();
709 let cfg = cfg_with_targets(vec!["cloudflare", "netlify", "vercel"]);
710 let ctx = PluginContext::with_config(
711 dir.path(),
712 dir.path(),
713 &site,
714 dir.path(),
715 cfg,
716 );
717 EdgeHeadersPlugin.after_compile(&ctx).unwrap();
718 assert!(site.join("_headers").exists());
719 assert!(site.join(".ssg/edge/wrangler-headers.toml").exists());
720 assert!(site.join(".ssg/edge/vercel-headers.json").exists());
721 }
722
723 #[test]
724 fn new_constructs_unit() {
725 let _ = EdgeHeadersPlugin::new();
726 }
727
728 #[test]
731 fn url_path_for_maps_index_and_plain_pages() {
732 let site = Path::new("/tmp/site");
733 assert_eq!(url_path_for(&site.join("index.html"), site), "/");
734 assert_eq!(
735 url_path_for(&site.join("blog/post/index.html"), site),
736 "/blog/post/"
737 );
738 assert_eq!(url_path_for(&site.join("about.html"), site), "/about.html");
739 }
740
741 #[test]
742 #[serial_test::parallel(vercel_render_fp)]
743 fn transform_records_page_policy_into_platform_files() {
744 let dir = tempfile::tempdir().unwrap();
748 let site = dir.path().join("site");
749 fs::create_dir_all(&site).unwrap();
750 let cfg = cfg_with_targets(vec!["netlify", "vercel"]);
751 let ctx = PluginContext::with_config(
752 dir.path(),
753 dir.path(),
754 &site,
755 dir.path(),
756 cfg,
757 );
758
759 let plugin = EdgeHeadersPlugin::new();
760 plugin.after_compile(&ctx).unwrap();
761
762 let jsonld = r#"{"@type":"BlogPosting","headline":"x"}"#;
763 let html = format!(
764 r#"<html><head><script type="application/ld+json">{jsonld}</script></head><body>b</body></html>"#
765 );
766 let page = site.join("blog/post/index.html");
767 let out = plugin.transform_html(&html, &page, &ctx).unwrap();
768 assert_eq!(out, html, "transform must be a pass-through");
769
770 let expected_hash =
771 crate::cmd::SriAlgorithm::Sha256.integrity(jsonld.as_bytes());
772
773 let headers_body = fs::read_to_string(site.join("_headers")).unwrap();
774 assert!(
775 headers_body.contains("/blog/post/\n"),
776 "per-path group missing: {headers_body}"
777 );
778 assert!(
779 headers_body
780 .contains(&format!("script-src 'self' '{expected_hash}'")),
781 "exact sha256 source missing: {headers_body}"
782 );
783 assert!(!headers_body.contains("unsafe-inline"));
785
786 let vercel_body =
787 fs::read_to_string(site.join(".ssg/edge/vercel-headers.json"))
788 .unwrap();
789 let parsed: serde_json::Value =
790 serde_json::from_str(&vercel_body).unwrap();
791 let groups = parsed["headers"].as_array().unwrap();
792 let page_group = groups
793 .iter()
794 .find(|g| g["source"].as_str() == Some("/blog/post/"))
795 .expect("per-page vercel route present");
796 let value = page_group["headers"][0]["value"].as_str().unwrap();
797 assert!(value.contains(&format!("'{expected_hash}'")));
798 assert!(!value.contains("unsafe-inline"));
799 }
800
801 #[test]
802 fn transform_without_inline_blocks_keeps_global_files_untouched() {
803 let dir = tempfile::tempdir().unwrap();
804 let site = dir.path().join("site");
805 fs::create_dir_all(&site).unwrap();
806 let cfg = cfg_with_targets(vec!["netlify"]);
807 let ctx = PluginContext::with_config(
808 dir.path(),
809 dir.path(),
810 &site,
811 dir.path(),
812 cfg,
813 );
814 let plugin = EdgeHeadersPlugin::new();
815 plugin.after_compile(&ctx).unwrap();
816 let before = fs::read_to_string(site.join("_headers")).unwrap();
817
818 let html = "<html><head></head><body>plain</body></html>";
819 let out = plugin
820 .transform_html(html, &site.join("index.html"), &ctx)
821 .unwrap();
822 assert_eq!(out, html);
823
824 let after = fs::read_to_string(site.join("_headers")).unwrap();
825 assert_eq!(before, after, "no inline blocks ⇒ no re-emit");
826 }
827
828 #[test]
829 fn transform_is_noop_when_disabled_or_unconfigured() {
830 let dir = tempfile::tempdir().unwrap();
831 let site = dir.path().join("site");
832 fs::create_dir_all(&site).unwrap();
833 let ctx = PluginContext::new(dir.path(), dir.path(), &site, dir.path());
835 let html = "<script>x=1</script>";
836 let out = EdgeHeadersPlugin::new()
837 .transform_html(html, &site.join("index.html"), &ctx)
838 .unwrap();
839 assert_eq!(out, html);
840 assert!(!site.join("_headers").exists());
841 }
842
843 #[test]
844 fn after_compile_resets_previous_builds_page_registry() {
845 let dir = tempfile::tempdir().unwrap();
848 let site = dir.path().join("site");
849 fs::create_dir_all(&site).unwrap();
850 let cfg = cfg_with_targets(vec!["netlify"]);
851 let ctx = PluginContext::with_config(
852 dir.path(),
853 dir.path(),
854 &site,
855 dir.path(),
856 cfg,
857 );
858 let plugin = EdgeHeadersPlugin::new();
859
860 plugin.after_compile(&ctx).unwrap();
861 let html = "<html><head><script>x=1</script></head></html>";
862 let _ = plugin
863 .transform_html(html, &site.join("old/index.html"), &ctx)
864 .unwrap();
865 assert!(fs::read_to_string(site.join("_headers"))
866 .unwrap()
867 .contains("/old/"));
868
869 plugin.after_compile(&ctx).unwrap();
872 let body = fs::read_to_string(site.join("_headers")).unwrap();
873 assert!(!body.contains("/old/"), "stale page survived reset: {body}");
874 }
875
876 #[test]
877 fn two_pages_accumulate_sorted_entries() {
878 let dir = tempfile::tempdir().unwrap();
879 let site = dir.path().join("site");
880 fs::create_dir_all(&site).unwrap();
881 let cfg = cfg_with_targets(vec!["netlify"]);
882 let ctx = PluginContext::with_config(
883 dir.path(),
884 dir.path(),
885 &site,
886 dir.path(),
887 cfg,
888 );
889 let plugin = EdgeHeadersPlugin::new();
890 plugin.after_compile(&ctx).unwrap();
891
892 let html_a = "<html><head><script>a=1</script></head></html>";
893 let html_b = "<html><head><script>b=2</script></head></html>";
894 let _ = plugin
895 .transform_html(html_b, &site.join("zeta/index.html"), &ctx)
896 .unwrap();
897 let _ = plugin
898 .transform_html(html_a, &site.join("alpha/index.html"), &ctx)
899 .unwrap();
900
901 let body = fs::read_to_string(site.join("_headers")).unwrap();
902 let i_alpha = body.find("/alpha/").unwrap();
903 let i_zeta = body.find("/zeta/").unwrap();
904 assert!(
905 i_alpha < i_zeta,
906 "entries must be sorted regardless of insertion order"
907 );
908 }
909
910 fn edge_cfg(targets: &[&str]) -> EdgeHeadersConfig {
915 let mut edge = EdgeHeadersConfig::default();
916 edge.targets = targets.iter().map(|t| (*t).to_string()).collect();
917 edge
918 }
919
920 #[test]
921 fn emit_targets_cloudflare_errors_when_ssg_dir_is_a_file() {
922 let dir = tempfile::tempdir().unwrap();
923 let site = dir.path().join("site");
924 fs::create_dir_all(&site).unwrap();
925 fs::write(site.join(".ssg"), "not a dir").unwrap();
927 let ctx = PluginContext::new(dir.path(), dir.path(), &site, dir.path());
928 let err =
929 emit_targets(&ctx, &edge_cfg(&["cloudflare"]), &BTreeMap::new())
930 .unwrap_err();
931 assert!(format!("{err}").contains(".ssg"));
932 }
933
934 #[test]
935 fn emit_targets_cloudflare_errors_when_output_is_a_directory() {
936 let dir = tempfile::tempdir().unwrap();
937 let site = dir.path().join("site");
938 fs::create_dir_all(site.join(".ssg/edge/wrangler-headers.toml"))
939 .unwrap();
940 let ctx = PluginContext::new(dir.path(), dir.path(), &site, dir.path());
941 let err =
942 emit_targets(&ctx, &edge_cfg(&["cloudflare"]), &BTreeMap::new())
943 .unwrap_err();
944 assert!(format!("{err}").contains("wrangler-headers.toml"));
945 }
946
947 #[test]
948 fn emit_targets_netlify_errors_when_headers_is_a_directory() {
949 let dir = tempfile::tempdir().unwrap();
950 let site = dir.path().join("site");
951 fs::create_dir_all(site.join("_headers")).unwrap();
952 let ctx = PluginContext::new(dir.path(), dir.path(), &site, dir.path());
953 let err = emit_targets(&ctx, &edge_cfg(&["netlify"]), &BTreeMap::new())
954 .unwrap_err();
955 assert!(format!("{err}").contains("_headers"));
956 }
957
958 #[test]
959 fn emit_targets_vercel_errors_when_ssg_dir_is_a_file() {
960 let dir = tempfile::tempdir().unwrap();
961 let site = dir.path().join("site");
962 fs::create_dir_all(&site).unwrap();
963 fs::write(site.join(".ssg"), "not a dir").unwrap();
964 let ctx = PluginContext::new(dir.path(), dir.path(), &site, dir.path());
965 let err = emit_targets(&ctx, &edge_cfg(&["vercel"]), &BTreeMap::new())
966 .unwrap_err();
967 assert!(format!("{err}").contains(".ssg"));
968 }
969
970 #[test]
971 fn emit_targets_vercel_errors_when_output_is_a_directory() {
972 let dir = tempfile::tempdir().unwrap();
973 let site = dir.path().join("site");
974 fs::create_dir_all(site.join(".ssg/edge/vercel-headers.json")).unwrap();
975 let ctx = PluginContext::new(dir.path(), dir.path(), &site, dir.path());
976 let err = emit_targets(&ctx, &edge_cfg(&["vercel"]), &BTreeMap::new())
977 .unwrap_err();
978 assert!(format!("{err}").contains("vercel-headers.json"));
979 }
980
981 #[test]
986 fn transform_html_propagates_emit_failure() {
987 let dir = tempfile::tempdir().unwrap();
988 let site = dir.path().join("site");
989 fs::create_dir_all(site.join("_headers")).unwrap();
991 let cfg = cfg_with_targets(vec!["netlify"]);
992 let ctx = PluginContext::with_config(
993 dir.path(),
994 dir.path(),
995 &site,
996 dir.path(),
997 cfg,
998 );
999 let html = "<html><head><script>var x = 1;</script></head><body></body></html>";
1002 let err = EdgeHeadersPlugin
1003 .transform_html(html, &site.join("index.html"), &ctx)
1004 .unwrap_err();
1005 assert!(format!("{err}").contains("_headers"));
1006 }
1007
1008 #[test]
1013 fn after_compile_and_transform_recover_from_poisoned_registry_lock() {
1014 let poisoned = std::thread::spawn(|| {
1022 let _guard = PAGE_CSP_REGISTRY.lock().unwrap();
1023 panic!("intentional poison for coverage of the recovery arm");
1024 })
1025 .join();
1026 assert!(poisoned.is_err(), "spawned thread must have panicked");
1027
1028 let dir = tempfile::tempdir().unwrap();
1029 let site = dir.path().join("site");
1030 fs::create_dir_all(&site).unwrap();
1031 let cfg = cfg_with_targets(vec!["netlify"]);
1032 let ctx = PluginContext::with_config(
1033 dir.path(),
1034 dir.path(),
1035 &site,
1036 dir.path(),
1037 cfg,
1038 );
1039 let plugin = EdgeHeadersPlugin::new();
1040
1041 plugin.after_compile(&ctx).unwrap();
1043
1044 let html = "<html><head><script>x=1</script></head></html>";
1045 let out = plugin
1046 .transform_html(html, &site.join("index.html"), &ctx)
1047 .unwrap();
1048 assert_eq!(out, html);
1049 }
1050}
1051
1052#[cfg(all(test, feature = "test-fault-injection"))]
1053mod fault_tests {
1054 use super::*;
1055
1056 struct FailGuard(&'static str);
1058
1059 impl Drop for FailGuard {
1060 fn drop(&mut self) {
1061 let _ = fail::cfg(self.0, "off");
1062 }
1063 }
1064
1065 fn cfg_with_targets(targets: Vec<&str>) -> crate::cmd::SsgConfig {
1066 let mut edge = EdgeHeadersConfig::default();
1067 edge.targets = targets.into_iter().map(String::from).collect();
1068 crate::cmd::SsgConfig::builder()
1069 .site_name("t".to_string())
1070 .base_url("http://example.com".to_string())
1071 .edge_headers(edge)
1072 .build()
1073 .unwrap()
1074 }
1075
1076 #[test]
1077 #[serial_test::serial(vercel_render_fp)]
1078 fn after_compile_vercel_maps_serialize_failure_to_io_error() {
1079 let _guard = FailGuard("postprocess::vercel-render");
1080 fail::cfg("postprocess::vercel-render", "return")
1081 .expect("activate failpoint");
1082
1083 let dir = tempfile::tempdir().unwrap();
1084 let site = dir.path().join("site");
1085 fs::create_dir_all(&site).unwrap();
1086 let cfg = cfg_with_targets(vec!["vercel"]);
1087 let ctx = PluginContext::with_config(
1088 dir.path(),
1089 dir.path(),
1090 &site,
1091 dir.path(),
1092 cfg,
1093 );
1094 let err = EdgeHeadersPlugin
1095 .after_compile(&ctx)
1096 .expect_err("injected serialize failure must propagate");
1097 let msg = format!("{err}");
1098 assert!(msg.contains("vercel-headers.json"), "got: {msg}");
1099 assert!(
1100 msg.contains("injected: postprocess::vercel-render"),
1101 "got: {msg}"
1102 );
1103 }
1104}