1use std::ffi::OsStr;
8use std::fs;
9use std::io::{self, Write};
10use std::path::{Component, Path, PathBuf};
11use std::sync::atomic::{AtomicU64, Ordering};
12
13use crate::core::{Node, Render, RenderOptions};
14use crate::document::Document;
15
16#[derive(Debug)]
32#[non_exhaustive]
33pub enum SsgError {
34 InvalidPath {
37 path: String,
39 reason: &'static str,
41 },
42 UnsafeCleanTarget {
44 path: PathBuf,
46 reason: &'static str,
48 },
49 Io {
51 path: PathBuf,
53 source: io::Error,
55 },
56 Pages(Vec<(String, SsgError)>),
58}
59
60impl std::fmt::Display for SsgError {
61 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
62 match self {
63 Self::InvalidPath { path, reason } => write!(f, "path {path:?} {reason}"),
64 Self::UnsafeCleanTarget { path, reason } => {
65 write!(f, "refusing to clean {}: {reason}", path.display())
66 }
67 Self::Io { path, source } => write!(f, "{}: {source}", path.display()),
68 Self::Pages(failures) => {
69 write!(f, "{} page(s) failed to generate:", failures.len())?;
70 for (path, error) in failures {
71 write!(f, "\n {path}: {error}")?;
72 }
73 Ok(())
74 }
75 }
76 }
77}
78
79impl std::error::Error for SsgError {
80 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
81 match self {
82 Self::Io { source, .. } => Some(source),
83 _ => None,
84 }
85 }
86}
87
88impl From<SsgError> for io::Error {
89 fn from(error: SsgError) -> Self {
90 match error {
91 SsgError::InvalidPath { .. } | SsgError::UnsafeCleanTarget { .. } => {
92 Self::new(io::ErrorKind::InvalidInput, error)
93 }
94 SsgError::Io { source, .. } => source,
95 SsgError::Pages(_) => Self::other(error),
96 }
97 }
98}
99
100type Result<T> = std::result::Result<T, SsgError>;
102
103pub const OUTPUT_MARKER: &str = ".molt-output";
117
118const MARKER_TEXT: &str = "Created by molt-framework. StaticSiteGenerator::clean() only empties \
120a directory that is missing, empty, or has this file.\n";
121
122fn io_error(path: &Path) -> impl FnOnce(io::Error) -> SsgError + '_ {
124 move |source| SsgError::Io {
125 path: path.to_path_buf(),
126 source,
127 }
128}
129
130#[derive(Debug, Clone)]
143pub struct StaticSiteGenerator {
144 output_directory: PathBuf,
145}
146
147impl StaticSiteGenerator {
148 pub fn new(output_directory: impl Into<PathBuf>) -> Self {
150 Self {
151 output_directory: output_directory.into(),
152 }
153 }
154
155 #[must_use]
157 pub fn output_directory(&self) -> &Path {
158 &self.output_directory
159 }
160
161 pub fn generate(&self, document: &Document, path: &str, options: &RenderOptions) -> Result<()> {
169 self.write_atomic(path, |file| {
170 let mut buffered = io::BufWriter::new(file);
171 document.render_to(&mut buffered, options)?;
172 buffered.flush()
173 })
174 }
175
176 pub fn generate_multiple(
187 &self,
188 documents: &[(Document, String)],
189 options: &RenderOptions,
190 ) -> Result<()> {
191 #[cfg(feature = "parallel")]
192 let results: Vec<(String, Result<()>)> = {
193 use rayon::prelude::*;
194 documents
195 .par_iter()
196 .map(|(doc, path)| (path.clone(), self.generate(doc, path, options)))
197 .collect()
198 };
199
200 #[cfg(not(feature = "parallel"))]
201 let results: Vec<(String, Result<()>)> = documents
202 .iter()
203 .map(|(doc, path)| (path.clone(), self.generate(doc, path, options)))
204 .collect();
205
206 collect_failures(results)
207 }
208
209 pub fn generate_page(
214 &self,
215 page: &Node,
216 path: &str,
217 options: &RenderOptions,
218 doctype: bool,
219 ) -> Result<()> {
220 let mut content = String::with_capacity(1024);
221 if doctype {
222 content.push_str("<!DOCTYPE html>\n");
223 }
224 page.write_into(&mut content, options, 0);
225 self.write_file(&content, path)
226 }
227
228 pub fn copy_asset(&self, from: impl AsRef<Path>, to: &str) -> Result<()> {
233 let destination = self.resolve(to)?;
234 if let Some(parent) = destination.parent() {
235 fs::create_dir_all(parent).map_err(io_error(parent))?;
236 }
237 self.mark_output()?;
238 let temporary = temporary_path(&destination);
241 if let Err(error) = fs::copy(from.as_ref(), &temporary) {
242 let _ = fs::remove_file(&temporary);
243 return Err(io_error(from.as_ref())(error));
244 }
245 if let Err(error) = fs::rename(&temporary, &destination) {
246 let _ = fs::remove_file(&temporary);
247 return Err(io_error(&destination)(error));
248 }
249 Ok(())
250 }
251
252 pub fn copy_dir(&self, from: impl AsRef<Path>, to: &str) -> Result<usize> {
285 self.copy_dir_where(from, to, |_| true)
286 }
287
288 pub fn copy_dir_where(
313 &self,
314 from: impl AsRef<Path>,
315 to: &str,
316 keep: impl Fn(&OsStr) -> bool,
317 ) -> Result<usize> {
318 if to.starts_with(['/', '\\']) {
322 self.resolve(to)?;
323 }
324 let prefix = to.trim_end_matches(['/', '\\']);
325 let prefix = if prefix == "." { "" } else { prefix };
326 if !prefix.is_empty() {
327 self.resolve(prefix)?;
328 }
329 let output = fs::canonicalize(&self.output_directory).ok();
330
331 let mut copied = 0;
332 let mut pending = vec![(from.as_ref().to_path_buf(), prefix.to_owned())];
335 while let Some((directory, relative)) = pending.pop() {
336 for entry in fs::read_dir(&directory).map_err(io_error(&directory))? {
337 let entry = entry.map_err(io_error(&directory))?;
338 let name = entry.file_name();
339 if !keep(&name) {
340 continue;
341 }
342 let path = entry.path();
343 let Some(name) = name.to_str() else {
344 return Err(SsgError::InvalidPath {
345 path: path.to_string_lossy().into_owned(),
346 reason: "is not valid UTF-8",
347 });
348 };
349 let target = if relative.is_empty() {
350 name.to_owned()
351 } else {
352 format!("{relative}/{name}")
353 };
354 let kind = entry.file_type().map_err(io_error(&path))?;
356 if kind.is_dir() {
357 if output.is_some() && fs::canonicalize(&path).ok() == output {
358 continue;
359 }
360 pending.push((path, target));
361 } else if kind.is_file() {
362 self.copy_asset(&path, &target)?;
363 copied += 1;
364 }
365 }
366 }
367 Ok(copied)
368 }
369
370 pub fn copy_asset_hashed(&self, from: impl AsRef<Path>, dir: &str) -> Result<String> {
420 let from = from.as_ref();
421 let bytes = fs::read(from).map_err(io_error(from))?;
422 let Some(name) = from.file_name().and_then(OsStr::to_str) else {
423 return Err(SsgError::InvalidPath {
424 path: from.to_string_lossy().into_owned(),
425 reason: "has no file name in UTF-8",
426 });
427 };
428
429 let hashed = match name.rsplit_once('.') {
431 Some((stem, extension)) if !stem.is_empty() => {
432 format!("{stem}.{:016x}.{extension}", fnv1a_64(&bytes))
433 }
434 _ => format!("{name}.{:016x}", fnv1a_64(&bytes)),
435 };
436 if dir.starts_with(['/', '\\']) {
439 self.resolve(dir)?;
440 }
441 let relative = dir
442 .split('/')
443 .filter(|part| !part.is_empty() && *part != ".")
444 .chain(std::iter::once(hashed.as_str()))
445 .collect::<Vec<_>>()
446 .join("/");
447
448 self.write_atomic(&relative, |file| file.write_all(&bytes))?;
449 Ok(format!("/{relative}"))
450 }
451
452 pub fn clean(&self, create_directory: bool) -> Result<()> {
487 guard_destructive_path(&self.output_directory)?;
488 guard_ownership(&self.output_directory)?;
489
490 let directory = &self.output_directory;
491 if directory.exists() {
492 fs::remove_dir_all(directory).map_err(io_error(directory))?;
493 }
494 if create_directory {
495 fs::create_dir_all(directory).map_err(io_error(directory))?;
496 self.mark_output()?;
497 }
498 Ok(())
499 }
500
501 fn mark_output(&self) -> Result<()> {
504 let marker = self.output_directory.join(OUTPUT_MARKER);
505 match fs::OpenOptions::new()
507 .write(true)
508 .create_new(true)
509 .open(&marker)
510 {
511 Ok(mut file) => file
512 .write_all(MARKER_TEXT.as_bytes())
513 .map_err(io_error(&marker)),
514 Err(error) if error.kind() == io::ErrorKind::AlreadyExists => Ok(()),
515 Err(error) => Err(io_error(&marker)(error)),
516 }
517 }
518
519 pub fn write_file(&self, content: &str, path: &str) -> Result<()> {
527 self.write_atomic(path, |file| file.write_all(content.as_bytes()))
528 }
529
530 fn write_atomic(
535 &self,
536 path: &str,
537 write_contents: impl FnOnce(&mut fs::File) -> io::Result<()>,
538 ) -> Result<()> {
539 let destination = self.resolve(path)?;
540 if let Some(parent) = destination.parent() {
541 fs::create_dir_all(parent).map_err(io_error(parent))?;
542 }
543 self.mark_output()?;
544
545 let temporary = temporary_path(&destination);
546 let mut file = fs::OpenOptions::new()
549 .write(true)
550 .create_new(true)
551 .open(&temporary)
552 .map_err(io_error(&temporary))?;
553 if let Err(error) = write_contents(&mut file) {
554 let _ = fs::remove_file(&temporary);
555 return Err(io_error(&destination)(error));
556 }
557 if let Err(error) = fs::rename(&temporary, &destination) {
558 let _ = fs::remove_file(&temporary);
559 return Err(io_error(&destination)(error));
560 }
561 Ok(())
562 }
563
564 fn resolve(&self, path: &str) -> Result<PathBuf> {
575 let reject = |reason: &'static str| {
576 Err(SsgError::InvalidPath {
577 path: path.to_owned(),
578 reason,
579 })
580 };
581
582 let candidate = Path::new(path);
583 let mut names = 0;
584 for component in candidate.components() {
585 match component {
586 Component::Normal(_) => names += 1,
587 Component::CurDir => {}
588 _ => return reject("escapes the output directory"),
589 }
590 }
591 if names == 0 || path.ends_with(['/', '\\']) {
592 return reject("does not name a file");
593 }
594
595 let mut resolved = self.output_directory.clone();
596 for component in candidate.components() {
597 if let Component::Normal(name) = component {
598 resolved.push(name);
599 if fs::symlink_metadata(&resolved).is_ok_and(|m| m.file_type().is_symlink()) {
601 return reject("passes through a symbolic link");
602 }
603 }
604 }
605 Ok(resolved)
606 }
607}
608
609fn fnv1a_64(bytes: &[u8]) -> u64 {
611 bytes.iter().fold(0xcbf2_9ce4_8422_2325_u64, |hash, byte| {
612 (hash ^ u64::from(*byte)).wrapping_mul(0x0100_0000_01b3)
613 })
614}
615
616fn temporary_path(destination: &Path) -> PathBuf {
622 static COUNTER: AtomicU64 = AtomicU64::new(0);
623 let name = destination
624 .file_name()
625 .map_or_else(|| "page".into(), |n| n.to_string_lossy().into_owned());
626 let unique = COUNTER.fetch_add(1, Ordering::Relaxed);
627 destination.with_file_name(format!(".{name}.molt-{}-{unique}.tmp", std::process::id()))
628}
629
630fn guard_destructive_path(path: &Path) -> Result<()> {
638 let reject = |reason: &'static str| {
639 Err(SsgError::UnsafeCleanTarget {
640 path: path.to_path_buf(),
641 reason,
642 })
643 };
644
645 if path.as_os_str().is_empty() {
646 return reject("the output directory is empty");
647 }
648 if path.parent().is_none() {
649 return reject("the output directory is a filesystem root");
650 }
651 if path.components().any(|c| c == Component::ParentDir) {
652 return reject("the output directory contains a `..` component");
653 }
654
655 let absolute = absolute_path(path).map_err(io_error(path))?;
656 let depth = absolute
657 .components()
658 .filter(|c| matches!(c, Component::Normal(_)))
659 .count();
660 if depth < 2 {
661 return reject("the output directory is a top-level directory");
662 }
663 if let Ok(cwd) = std::env::current_dir().and_then(fs::canonicalize) {
664 if cwd.starts_with(&absolute) {
665 return reject("the output directory is, or contains, the working directory");
666 }
667 }
668 let home = std::env::var_os("HOME").or_else(|| std::env::var_os("USERPROFILE"));
669 if let Some(home) = home.and_then(|h| fs::canonicalize(h).ok()) {
670 if home == absolute {
671 return reject("the output directory is the home directory");
672 }
673 }
674 Ok(())
675}
676
677fn guard_ownership(path: &Path) -> Result<()> {
682 let reject = |reason: &'static str| {
683 Err(SsgError::UnsafeCleanTarget {
684 path: path.to_path_buf(),
685 reason,
686 })
687 };
688
689 let Ok(metadata) = fs::symlink_metadata(path) else {
690 return Ok(());
691 };
692 if metadata.file_type().is_symlink() {
693 return reject("the output directory is a symbolic link");
694 }
695 if !metadata.is_dir() || path.join(OUTPUT_MARKER).symlink_metadata().is_ok() {
696 return Ok(());
697 }
698 let is_empty = fs::read_dir(path).map_err(io_error(path))?.next().is_none();
699 if is_empty {
700 Ok(())
701 } else {
702 reject(
703 "the output directory has contents but no `.molt-output` marker, so Molt did not \
704 make it; remove it yourself once, or create the marker file to hand it over",
705 )
706 }
707}
708
709fn absolute_path(path: &Path) -> io::Result<PathBuf> {
711 if let Ok(canonical) = fs::canonicalize(path) {
712 return Ok(canonical);
713 }
714 let mut absolute = fs::canonicalize(std::env::current_dir()?)?;
715 for component in path.components() {
716 match component {
717 Component::Normal(name) => absolute.push(name),
718 Component::RootDir | Component::Prefix(_) => {
719 absolute = PathBuf::from(component.as_os_str());
720 }
721 _ => {}
722 }
723 }
724 Ok(absolute)
725}
726
727fn collect_failures(results: Vec<(String, Result<()>)>) -> Result<()> {
729 let failures: Vec<(String, SsgError)> = results
730 .into_iter()
731 .filter_map(|(path, result)| result.err().map(|error| (path, error)))
732 .collect();
733
734 if failures.is_empty() {
735 Ok(())
736 } else {
737 Err(SsgError::Pages(failures))
738 }
739}
740
741#[cfg(test)]
742mod tests {
743 use super::*;
744 use crate::elements::{body, h1, head, html_tag, title};
745
746 struct TempDir(PathBuf);
748
749 impl TempDir {
750 fn new(name: &str) -> Self {
751 let path = std::env::temp_dir().join(format!("molt-{name}-{}", std::process::id()));
753 let _ = fs::remove_dir_all(&path);
754 fs::create_dir_all(&path).expect("temp dir is creatable");
755 Self(path)
756 }
757 }
758
759 impl Drop for TempDir {
760 fn drop(&mut self) {
761 let _ = fs::remove_dir_all(&self.0);
762 }
763 }
764
765 fn only_files_named(dir: &Path, names: &[&str]) -> bool {
768 let mut found: Vec<String> = fs::read_dir(dir)
769 .expect("readable")
770 .map(|e| e.expect("entry").file_name().to_string_lossy().into_owned())
771 .filter(|name| name != OUTPUT_MARKER)
772 .collect();
773 found.sort();
774 found == names
775 }
776
777 fn page(text: &str) -> Document {
778 Document::new(Some("en"))
779 .head_children([title().text(text)])
780 .body_children([h1().text(text)])
781 }
782
783 #[test]
784 fn generate_writes_a_rendered_document() {
785 let dir = TempDir::new("generate");
786 let site = StaticSiteGenerator::new(&dir.0);
787 site.generate(&page("Home"), "index.html", &RenderOptions::pretty())
788 .expect("written");
789
790 let written = fs::read_to_string(dir.0.join("index.html")).expect("readable");
791 assert!(written.starts_with("<!DOCTYPE html>"));
792 assert!(written.contains("<h1>Home</h1>"));
793 }
794
795 #[test]
797 fn generate_replaces_a_file_with_the_rendered_bytes() {
798 let dir = TempDir::new("stream");
799 let site = StaticSiteGenerator::new(&dir.0);
800 let destination = dir.0.join("index.html");
801 fs::write(&destination, "old").expect("seed");
802
803 let page = page("Home");
804 site.generate(&page, "index.html", &RenderOptions::pretty())
805 .expect("written");
806
807 let written = fs::read(destination).expect("readable");
808 assert_eq!(
809 written,
810 page.render_with(&RenderOptions::pretty()).into_bytes()
811 );
812 assert!(only_files_named(&dir.0, &["index.html"]));
813 }
814
815 #[test]
816 fn nested_paths_create_their_parent_directories() {
817 let dir = TempDir::new("nested");
818 let site = StaticSiteGenerator::new(&dir.0);
819 site.generate(
820 &page("Post"),
821 "blog/2026/post.html",
822 &RenderOptions::compact(),
823 )
824 .expect("written");
825 assert!(dir.0.join("blog/2026/post.html").exists());
826 }
827
828 #[test]
829 fn a_bare_page_can_be_written_without_a_doctype() {
830 let dir = TempDir::new("doctype");
831 let site = StaticSiteGenerator::new(&dir.0);
832 let node = Node::from(h1().text("Fragment"));
833
834 site.generate_page(&node, "with.html", &RenderOptions::compact(), true)
835 .expect("written");
836 site.generate_page(&node, "without.html", &RenderOptions::compact(), false)
837 .expect("written");
838
839 assert!(
840 fs::read_to_string(dir.0.join("with.html"))
841 .unwrap()
842 .starts_with("<!DOCTYPE")
843 );
844 assert!(
845 !fs::read_to_string(dir.0.join("without.html"))
846 .unwrap()
847 .contains("DOCTYPE")
848 );
849 }
850
851 #[test]
852 fn fnv1a_matches_the_published_vectors() {
853 assert_eq!(fnv1a_64(b""), 0xcbf2_9ce4_8422_2325);
854 assert_eq!(fnv1a_64(b"a"), 0xaf63_dc4c_8601_ec8c);
855 assert_eq!(fnv1a_64(b"foobar"), 0x8594_4171_f739_67e8);
856 }
857
858 #[test]
859 fn a_hashed_copy_is_named_by_its_content_and_returns_the_url() {
860 let dir = TempDir::new("hashed");
861 let site = StaticSiteGenerator::new(dir.0.join("out"));
862 let source = dir.0.join("site.css");
863
864 fs::write(&source, "body{color:red}").expect("written");
865 let red = site.copy_asset_hashed(&source, "css").expect("copied");
866 assert_eq!(red, "/css/site.c0e0533c847ec633.css");
867 assert_eq!(
868 fs::read_to_string(dir.0.join("out/css/site.c0e0533c847ec633.css")).expect("copy"),
869 "body{color:red}"
870 );
871
872 assert_eq!(site.copy_asset_hashed(&source, "css").expect("again"), red);
874 fs::write(&source, "body{color:blue}").expect("written");
875 let blue = site.copy_asset_hashed(&source, "css").expect("copied");
876 assert_eq!(blue, "/css/site.91db968e2dbb7470.css");
877 assert!(dir.0.join("out/css/site.c0e0533c847ec633.css").is_file());
878 assert!(only_files_named(
879 &dir.0.join("out/css"),
880 &["site.91db968e2dbb7470.css", "site.c0e0533c847ec633.css"]
881 ));
882 }
883
884 #[test]
885 fn a_hashed_name_keeps_the_last_extension_and_copes_with_none() {
886 let dir = TempDir::new("hashed-names");
887 let site = StaticSiteGenerator::new(dir.0.join("out"));
888 for (name, expected) in [
889 ("app.min.js", "/app.min.af63dc4c8601ec8c.js"),
890 ("LICENSE", "/LICENSE.af63dc4c8601ec8c"),
891 (".htaccess", "/.htaccess.af63dc4c8601ec8c"),
892 ] {
893 let source = dir.0.join(name);
894 fs::write(&source, "a").expect("written");
895 assert_eq!(
896 site.copy_asset_hashed(&source, "").expect("copied"),
897 expected
898 );
899 }
900 assert_eq!(
901 site.copy_asset_hashed(dir.0.join("LICENSE"), "./a//b/")
902 .expect("copied"),
903 "/a/b/LICENSE.af63dc4c8601ec8c"
904 );
905 }
906
907 #[test]
908 fn a_hashed_copy_refuses_a_destination_that_escapes_and_a_missing_source() {
909 let dir = TempDir::new("hashed-refused");
910 let site = StaticSiteGenerator::new(dir.0.join("out"));
911 let source = dir.0.join("a.css");
912 fs::write(&source, "a").expect("written");
913
914 for to in ["../x", "/abs", "/", "a/../../b"] {
915 let error = site.copy_asset_hashed(&source, to).expect_err("refused");
916 assert!(
917 matches!(error, SsgError::InvalidPath { .. }),
918 "{to}: {error}"
919 );
920 }
921 assert!(!dir.0.join("out").exists());
922 let error = site
923 .copy_asset_hashed(dir.0.join("missing.css"), "css")
924 .expect_err("fails");
925 assert!(matches!(error, SsgError::Io { .. }), "{error}");
926 }
927
928 #[test]
929 fn copy_asset_replaces_an_existing_destination() {
930 let dir = TempDir::new("assets");
931 let source = dir.0.join("source.css");
932 fs::write(&source, "body{}").expect("written");
933
934 let site = StaticSiteGenerator::new(dir.0.join("out"));
935 site.copy_asset(&source, "css/style.css").expect("copied");
936 fs::write(&source, "body{color:red}").expect("written");
937 site.copy_asset(&source, "css/style.css").expect("recopied");
938
939 let copied = fs::read_to_string(dir.0.join("out/css/style.css")).expect("readable");
940 assert_eq!(copied, "body{color:red}");
941 }
942
943 #[test]
944 fn clean_empties_the_output_directory() {
945 let dir = TempDir::new("clean");
946 let site = StaticSiteGenerator::new(dir.0.join("out"));
947 site.write_file("x", "a.html").expect("written");
948
949 site.clean(true).expect("cleaned");
950 assert!(dir.0.join("out").exists());
951 assert!(!dir.0.join("out/a.html").exists());
952 }
953
954 #[test]
958 fn clean_refuses_a_directory_it_did_not_make() {
959 let dir = TempDir::new("clean-foreign");
960 let foreign = dir.0.join("other-project/dist");
961 fs::create_dir_all(&foreign).expect("created");
962 fs::write(foreign.join("precious.txt"), "mine").expect("written");
963
964 let error = StaticSiteGenerator::new(&foreign)
965 .clean(true)
966 .expect_err("refused");
967 assert!(
968 matches!(error, SsgError::UnsafeCleanTarget { .. }),
969 "{error}"
970 );
971 assert!(error.to_string().contains(OUTPUT_MARKER), "{error}");
972 assert_eq!(
973 fs::read_to_string(foreign.join("precious.txt")).expect("kept"),
974 "mine"
975 );
976 }
977
978 #[test]
979 fn clean_empties_a_directory_that_has_the_marker() {
980 let dir = TempDir::new("clean-marked");
981 let out = dir.0.join("out");
982 fs::create_dir_all(&out).expect("created");
983 fs::write(out.join(OUTPUT_MARKER), "").expect("marker");
984 fs::write(out.join("old.html"), "old").expect("written");
985
986 StaticSiteGenerator::new(&out).clean(true).expect("cleaned");
987
988 assert!(!out.join("old.html").exists());
989 assert!(out.join(OUTPUT_MARKER).is_file(), "the marker is kept");
990 }
991
992 #[test]
993 fn clean_accepts_a_missing_or_an_empty_directory() {
994 let dir = TempDir::new("clean-empty");
995 let missing = StaticSiteGenerator::new(dir.0.join("missing"));
996 missing.clean(false).expect("nothing to remove");
997 assert!(!dir.0.join("missing").exists());
998 missing.clean(true).expect("created");
999 assert!(dir.0.join("missing").join(OUTPUT_MARKER).is_file());
1000
1001 fs::create_dir_all(dir.0.join("empty")).expect("created");
1002 StaticSiteGenerator::new(dir.0.join("empty"))
1003 .clean(true)
1004 .expect("an empty directory has nothing to lose");
1005 }
1006
1007 #[test]
1008 fn writing_a_page_or_an_asset_leaves_the_marker_and_keeps_it() {
1009 let dir = TempDir::new("marker-written");
1010 let site = StaticSiteGenerator::new(dir.0.join("out"));
1011 site.write_file("a", "a.html").expect("written");
1012 let marker = dir.0.join("out").join(OUTPUT_MARKER);
1013 assert!(
1014 fs::read_to_string(&marker)
1015 .expect("marker")
1016 .starts_with("Created by molt")
1017 );
1018
1019 fs::write(&marker, "adopted by hand").expect("edited");
1020 let source = dir.0.join("s.css");
1021 fs::write(&source, "b").expect("written");
1022 site.copy_asset(&source, "s.css").expect("copied");
1023 assert_eq!(
1024 fs::read_to_string(&marker).expect("kept"),
1025 "adopted by hand"
1026 );
1027 }
1028
1029 #[cfg(unix)]
1030 #[test]
1031 fn clean_refuses_an_output_directory_that_is_a_symlink() {
1032 let dir = TempDir::new("clean-link");
1033 let target = dir.0.join("target");
1034 fs::create_dir_all(&target).expect("created");
1035 fs::write(target.join(OUTPUT_MARKER), "").expect("marker");
1036 fs::write(target.join("x"), "x").expect("written");
1037 let link = dir.0.join("out");
1038 std::os::unix::fs::symlink(&target, &link).expect("linked");
1039
1040 let error = StaticSiteGenerator::new(&link)
1041 .clean(true)
1042 .expect_err("refused");
1043 assert!(
1044 matches!(error, SsgError::UnsafeCleanTarget { .. }),
1045 "{error}"
1046 );
1047 assert!(target.join("x").is_file());
1048 }
1049
1050 #[test]
1052 fn clean_refuses_an_unsafe_output_directory() {
1053 for unsafe_path in ["", "/"] {
1054 let site = StaticSiteGenerator::new(unsafe_path);
1055 assert!(
1056 site.clean(false).is_err(),
1057 "{unsafe_path:?} should be rejected"
1058 );
1059 }
1060 assert!(StaticSiteGenerator::new("dist/../..").clean(false).is_err());
1061 }
1062
1063 #[test]
1064 fn a_page_path_cannot_escape_the_output_directory() {
1065 let dir = TempDir::new("escape");
1066 let site = StaticSiteGenerator::new(&dir.0);
1067 assert!(site.write_file("x", "../escaped.html").is_err());
1068 assert!(site.write_file("x", "/etc/escaped.html").is_err());
1069 }
1070
1071 #[test]
1072 fn a_path_that_does_not_name_a_file_is_rejected() {
1073 let dir = TempDir::new("nameless");
1074 let site = StaticSiteGenerator::new(&dir.0);
1075 for path in ["", ".", "./", "a/", "a\\", "a/../../x.html", "C:\\x.html"] {
1076 if path.starts_with("C:") && !cfg!(windows) {
1078 continue;
1079 }
1080 let outcome = site.write_file("x", path);
1081 assert!(outcome.is_err(), "{path:?} should be rejected");
1082 }
1083 let parent = dir.0.parent().expect("temp dir has a parent");
1085 let name = dir
1086 .0
1087 .file_name()
1088 .expect("named")
1089 .to_string_lossy()
1090 .into_owned();
1091 assert!(!parent.join(format!("{name}.tmp")).exists());
1092 assert!(only_files_named(&dir.0, &[]));
1093 }
1094
1095 #[cfg(unix)]
1096 #[test]
1097 fn a_symlink_inside_the_output_directory_is_not_followed() {
1098 let dir = TempDir::new("symlink");
1099 let outside = dir.0.join("outside");
1100 fs::create_dir_all(&outside).expect("created");
1101 let out = dir.0.join("out");
1102 fs::create_dir_all(&out).expect("created");
1103 std::os::unix::fs::symlink(&outside, out.join("link")).expect("linked");
1104
1105 let site = StaticSiteGenerator::new(&out);
1106 assert!(site.write_file("x", "link/x.html").is_err());
1107 let asset = dir.0.join("a.css");
1108 fs::write(&asset, "a{}").expect("written");
1109 assert!(site.copy_asset(&asset, "link/a.css").is_err());
1110 assert!(only_files_named(&outside, &[]));
1111 }
1112
1113 #[test]
1115 fn pages_with_the_same_stem_do_not_share_a_temporary_file() {
1116 let dir = TempDir::new("stem");
1117 let site = StaticSiteGenerator::new(&dir.0);
1118 std::thread::scope(|scope| {
1119 for round in 0..8 {
1120 for name in ["index.html", "index.xml", "index.htm"] {
1121 let site = &site;
1122 scope.spawn(move || {
1123 site.write_file(&format!("{name}:{round}"), name)
1124 .expect("written");
1125 });
1126 }
1127 }
1128 });
1129 assert!(only_files_named(
1130 &dir.0,
1131 &["index.htm", "index.html", "index.xml"]
1132 ));
1133 for name in ["index.html", "index.xml", "index.htm"] {
1134 let content = fs::read_to_string(dir.0.join(name)).expect("readable");
1135 assert!(
1136 content.starts_with(&format!("{name}:")),
1137 "{name}: {content}"
1138 );
1139 }
1140 }
1141
1142 #[test]
1143 fn a_failed_asset_copy_keeps_the_previous_file() {
1144 let dir = TempDir::new("asset-atomic");
1145 let site = StaticSiteGenerator::new(dir.0.join("out"));
1146 let source = dir.0.join("source.css");
1147 fs::write(&source, "old").expect("written");
1148 site.copy_asset(&source, "style.css").expect("copied");
1149
1150 assert!(
1151 site.copy_asset(dir.0.join("missing.css"), "style.css")
1152 .is_err()
1153 );
1154 assert_eq!(
1155 fs::read_to_string(dir.0.join("out/style.css")).expect("readable"),
1156 "old"
1157 );
1158 assert!(only_files_named(&dir.0.join("out"), &["style.css"]));
1159 }
1160
1161 fn static_tree(base: &Path) -> PathBuf {
1163 let root = base.join("static");
1164 fs::create_dir_all(root.join("img")).expect("created");
1165 fs::create_dir_all(root.join(".cache")).expect("created");
1166 fs::write(root.join("app.css"), "a{}").expect("written");
1167 fs::write(root.join("img/logo.svg"), "<svg/>").expect("written");
1168 fs::write(root.join(".DS_Store"), "junk").expect("written");
1169 fs::write(root.join(".cache/x"), "x").expect("written");
1170 root
1171 }
1172
1173 #[test]
1174 fn copy_dir_copies_a_nested_tree_and_counts_the_files() {
1175 let dir = TempDir::new("copy-dir");
1176 let source = static_tree(&dir.0);
1177 let site = StaticSiteGenerator::new(dir.0.join("out"));
1178
1179 assert_eq!(site.copy_dir(&source, "assets").expect("copied"), 4);
1180 let out = dir.0.join("out/assets");
1181 assert_eq!(fs::read_to_string(out.join("app.css")).expect("css"), "a{}");
1182 assert_eq!(
1183 fs::read_to_string(out.join("img/logo.svg")).expect("svg"),
1184 "<svg/>"
1185 );
1186 assert!(out.join(".DS_Store").is_file());
1187 }
1188
1189 #[test]
1190 fn copy_dir_where_leaves_out_the_names_it_rejects_with_what_is_under_them() {
1191 let dir = TempDir::new("copy-dir-where");
1192 let source = static_tree(&dir.0);
1193 let site = StaticSiteGenerator::new(dir.0.join("out"));
1194
1195 let copied = site
1196 .copy_dir_where(&source, "", |name| name != ".DS_Store" && name != ".cache")
1197 .expect("copied");
1198
1199 assert_eq!(copied, 2);
1200 let out = dir.0.join("out");
1201 assert!(out.join("app.css").is_file() && out.join("img/logo.svg").is_file());
1202 assert!(!out.join(".DS_Store").exists() && !out.join(".cache").exists());
1203 }
1204
1205 #[test]
1206 fn copy_dir_replaces_existing_files_and_leaves_no_temporary_file() {
1207 let dir = TempDir::new("copy-dir-replace");
1208 let source = static_tree(&dir.0);
1209 let site = StaticSiteGenerator::new(dir.0.join("out"));
1210 site.write_file("old", "assets/app.css").expect("written");
1211
1212 site.copy_dir_where(&source, "assets", |name| name == "app.css")
1213 .expect("copied");
1214
1215 let out = dir.0.join("out/assets");
1216 assert_eq!(fs::read_to_string(out.join("app.css")).expect("css"), "a{}");
1217 assert!(only_files_named(&out, &["app.css"]));
1218 }
1219
1220 #[test]
1221 fn copy_dir_refuses_a_destination_that_escapes_before_copying_anything() {
1222 let dir = TempDir::new("copy-dir-escape");
1223 let source = static_tree(&dir.0);
1224 let site = StaticSiteGenerator::new(dir.0.join("out"));
1225
1226 for to in ["../escape", "/abs", "/", "a/../../b"] {
1227 let error = site.copy_dir(&source, to).expect_err("refused");
1228 assert!(
1229 matches!(error, SsgError::InvalidPath { .. }),
1230 "{to}: {error}"
1231 );
1232 }
1233 assert!(!dir.0.join("out").exists());
1234 assert!(!dir.0.join("escape").exists());
1235 }
1236
1237 #[test]
1238 fn copy_dir_reports_a_missing_source() {
1239 let dir = TempDir::new("copy-dir-missing");
1240 let site = StaticSiteGenerator::new(dir.0.join("out"));
1241 let error = site
1242 .copy_dir(dir.0.join("nope"), "assets")
1243 .expect_err("fails");
1244 assert!(matches!(error, SsgError::Io { .. }), "{error}");
1245 }
1246
1247 #[test]
1248 fn copy_dir_does_not_copy_the_output_directory_into_itself() {
1249 let dir = TempDir::new("copy-dir-self");
1250 let source = dir.0.join("project");
1251 fs::create_dir_all(source.join("dist")).expect("created");
1252 fs::write(source.join("a.txt"), "a").expect("written");
1253 fs::write(source.join("dist/old.html"), "old").expect("written");
1254 let site = StaticSiteGenerator::new(source.join("dist"));
1255
1256 assert_eq!(site.copy_dir(&source, "copy").expect("copied"), 1);
1257 assert!(source.join("dist/copy/a.txt").is_file());
1258 assert!(!source.join("dist/copy/dist").exists());
1259 }
1260
1261 #[cfg(unix)]
1262 #[test]
1263 fn copy_dir_skips_a_symlink_to_a_file_or_a_directory() {
1264 let dir = TempDir::new("copy-dir-links");
1265 let source = static_tree(&dir.0);
1266 let secret = dir.0.join("secret");
1267 fs::create_dir_all(&secret).expect("created");
1268 fs::write(secret.join("key"), "k").expect("written");
1269 std::os::unix::fs::symlink(secret.join("key"), source.join("link-file")).expect("linked");
1270 std::os::unix::fs::symlink(&secret, source.join("link-dir")).expect("linked");
1271 let site = StaticSiteGenerator::new(dir.0.join("out"));
1272
1273 assert_eq!(site.copy_dir(&source, "assets").expect("copied"), 4);
1274 let out = dir.0.join("out/assets");
1275 assert!(!out.join("link-file").exists() && !out.join("link-dir").exists());
1276 }
1277
1278 #[test]
1280 fn the_clean_guard_refuses_the_working_directory_and_what_contains_it() {
1281 let cwd = std::env::current_dir().expect("cwd");
1282 let mut unsafe_paths = vec![PathBuf::from("."), PathBuf::from("./"), cwd.clone()];
1283 unsafe_paths.extend(cwd.ancestors().skip(1).map(Path::to_path_buf));
1284 unsafe_paths.push(PathBuf::from("/usr"));
1285 if let Some(home) = std::env::var_os("HOME") {
1286 unsafe_paths.push(PathBuf::from(home));
1287 }
1288 for path in unsafe_paths {
1289 assert!(
1290 guard_destructive_path(&path).is_err(),
1291 "{path:?} should be refused"
1292 );
1293 }
1294 assert!(guard_destructive_path(&cwd.join("dist")).is_ok());
1296 assert!(guard_destructive_path(Path::new("dist")).is_ok());
1297 }
1298
1299 #[test]
1300 fn generate_multiple_writes_every_page() {
1301 let dir = TempDir::new("multiple");
1302 let site = StaticSiteGenerator::new(&dir.0);
1303 let documents = vec![
1304 (page("A"), "a.html".to_string()),
1305 (page("B"), "nested/b.html".to_string()),
1306 ];
1307
1308 site.generate_multiple(&documents, &RenderOptions::pretty())
1309 .expect("written");
1310 assert!(dir.0.join("a.html").exists());
1311 assert!(dir.0.join("nested/b.html").exists());
1312 }
1313
1314 #[test]
1315 fn generate_multiple_reports_every_failure_not_just_the_first() {
1316 let dir = TempDir::new("failures");
1317 let site = StaticSiteGenerator::new(&dir.0);
1318 let documents = vec![
1319 (page("ok"), "ok.html".to_string()),
1320 (page("bad"), "../one.html".to_string()),
1321 (page("bad"), "../two.html".to_string()),
1322 ];
1323
1324 let error = site
1325 .generate_multiple(&documents, &RenderOptions::compact())
1326 .expect_err("two pages are unwritable");
1327 let message = error.to_string();
1328 assert!(message.contains("../one.html"), "{message}");
1329 assert!(message.contains("../two.html"), "{message}");
1330
1331 let SsgError::Pages(failures) = error else {
1333 panic!("a bulk build reports Pages");
1334 };
1335 let paths: Vec<&str> = failures.iter().map(|(path, _)| path.as_str()).collect();
1336 assert_eq!(paths, ["../one.html", "../two.html"]);
1337 assert!(
1338 failures
1339 .iter()
1340 .all(|(_, e)| matches!(e, SsgError::InvalidPath { .. }))
1341 );
1342 }
1343
1344 #[test]
1345 fn each_failure_has_its_own_variant_and_converts_to_an_io_error() {
1346 use std::error::Error;
1347
1348 let dir = TempDir::new("variants");
1349 let site = StaticSiteGenerator::new(&dir.0);
1350
1351 let refused = site.write_file("x", "../out.html").expect_err("path");
1352 assert!(matches!(refused, SsgError::InvalidPath { ref path, .. } if path == "../out.html"));
1353 assert!(refused.source().is_none());
1354 assert_eq!(io::Error::from(refused).kind(), io::ErrorKind::InvalidInput);
1355
1356 let unsafe_clean = StaticSiteGenerator::new(".")
1357 .clean(false)
1358 .expect_err("guard");
1359 assert!(matches!(unsafe_clean, SsgError::UnsafeCleanTarget { .. }));
1360 assert_eq!(
1361 io::Error::from(unsafe_clean).kind(),
1362 io::ErrorKind::InvalidInput
1363 );
1364
1365 fs::create_dir_all(dir.0.join("page.html")).expect("created");
1367 let failed = site
1368 .write_file("x", "page.html")
1369 .expect_err("rename onto a directory");
1370 assert!(matches!(failed, SsgError::Io { .. }));
1371 assert!(failed.source().is_some());
1372 assert!(failed.to_string().contains("page.html"));
1373 assert!(
1374 only_files_named(&dir.0, &["page.html"]),
1375 "no temp left behind"
1376 );
1377 }
1378
1379 #[test]
1380 fn a_page_is_written_with_its_doctype() {
1381 let directory = TempDir::new("doctype-and-markup");
1382 let site = StaticSiteGenerator::new(&directory.0);
1383
1384 let page = Node::from(
1385 html_tag()
1386 .child(head().child(title().text("Home")))
1387 .child(body().child(h1().text("Hello"))),
1388 );
1389 site.generate_page(&page, "index.html", &RenderOptions::pretty(), true)
1390 .expect("write");
1391
1392 let written = fs::read_to_string(directory.0.join("index.html")).expect("read");
1393 assert!(written.starts_with("<!DOCTYPE html>\n"));
1394 assert!(written.contains("<title>Home</title>"));
1395 assert!(written.contains("<h1>Hello</h1>"));
1396 }
1397
1398 #[test]
1399 fn generate_multiple_writes_each_document_to_its_own_path() {
1400 let directory = TempDir::new("multiple-documents");
1401 let site = StaticSiteGenerator::new(&directory.0);
1402
1403 let pages = vec![
1404 (page("Home"), "index.html".to_string()),
1405 (page("About"), "about/index.html".to_string()),
1406 ];
1407 site.generate_multiple(&pages, &RenderOptions::compact())
1408 .expect("write");
1409
1410 assert!(
1411 fs::read_to_string(directory.0.join("index.html"))
1412 .expect("read")
1413 .contains("<h1>Home</h1>")
1414 );
1415 assert!(
1416 fs::read_to_string(directory.0.join("about/index.html"))
1417 .expect("read")
1418 .contains("<h1>About</h1>")
1419 );
1420 }
1421}