1use std::fs;
8use std::io::{self, Write};
9use std::path::{Component, Path, PathBuf};
10use std::sync::atomic::{AtomicU64, Ordering};
11
12use crate::core::{Node, Render, RenderOptions};
13use crate::document::Document;
14
15#[derive(Debug)]
31#[non_exhaustive]
32pub enum SsgError {
33 InvalidPath {
36 path: String,
38 reason: &'static str,
40 },
41 UnsafeCleanTarget {
43 path: PathBuf,
45 reason: &'static str,
47 },
48 Io {
50 path: PathBuf,
52 source: io::Error,
54 },
55 Pages(Vec<(String, SsgError)>),
57}
58
59impl std::fmt::Display for SsgError {
60 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
61 match self {
62 Self::InvalidPath { path, reason } => write!(f, "path {path:?} {reason}"),
63 Self::UnsafeCleanTarget { path, reason } => {
64 write!(f, "refusing to clean {}: {reason}", path.display())
65 }
66 Self::Io { path, source } => write!(f, "{}: {source}", path.display()),
67 Self::Pages(failures) => {
68 write!(f, "{} page(s) failed to generate:", failures.len())?;
69 for (path, error) in failures {
70 write!(f, "\n {path}: {error}")?;
71 }
72 Ok(())
73 }
74 }
75 }
76}
77
78impl std::error::Error for SsgError {
79 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
80 match self {
81 Self::Io { source, .. } => Some(source),
82 _ => None,
83 }
84 }
85}
86
87impl From<SsgError> for io::Error {
88 fn from(error: SsgError) -> Self {
89 match error {
90 SsgError::InvalidPath { .. } | SsgError::UnsafeCleanTarget { .. } => {
91 Self::new(io::ErrorKind::InvalidInput, error)
92 }
93 SsgError::Io { source, .. } => source,
94 SsgError::Pages(_) => Self::other(error),
95 }
96 }
97}
98
99type Result<T> = std::result::Result<T, SsgError>;
101
102fn io_error(path: &Path) -> impl FnOnce(io::Error) -> SsgError + '_ {
104 move |source| SsgError::Io {
105 path: path.to_path_buf(),
106 source,
107 }
108}
109
110#[derive(Debug, Clone)]
123pub struct StaticSiteGenerator {
124 output_directory: PathBuf,
125}
126
127impl StaticSiteGenerator {
128 pub fn new(output_directory: impl Into<PathBuf>) -> Self {
130 Self {
131 output_directory: output_directory.into(),
132 }
133 }
134
135 #[must_use]
137 pub fn output_directory(&self) -> &Path {
138 &self.output_directory
139 }
140
141 pub fn generate(&self, document: &Document, path: &str, options: &RenderOptions) -> Result<()> {
149 self.write_atomic(path, |file| {
150 let mut buffered = io::BufWriter::new(file);
151 document.render_to(&mut buffered, options)?;
152 buffered.flush()
153 })
154 }
155
156 pub fn generate_multiple(
167 &self,
168 documents: &[(Document, String)],
169 options: &RenderOptions,
170 ) -> Result<()> {
171 #[cfg(feature = "parallel")]
172 let results: Vec<(String, Result<()>)> = {
173 use rayon::prelude::*;
174 documents
175 .par_iter()
176 .map(|(doc, path)| (path.clone(), self.generate(doc, path, options)))
177 .collect()
178 };
179
180 #[cfg(not(feature = "parallel"))]
181 let results: Vec<(String, Result<()>)> = documents
182 .iter()
183 .map(|(doc, path)| (path.clone(), self.generate(doc, path, options)))
184 .collect();
185
186 collect_failures(results)
187 }
188
189 pub fn generate_page(
194 &self,
195 page: &Node,
196 path: &str,
197 options: &RenderOptions,
198 doctype: bool,
199 ) -> Result<()> {
200 let mut content = String::with_capacity(1024);
201 if doctype {
202 content.push_str("<!DOCTYPE html>\n");
203 }
204 page.write_into(&mut content, options, 0);
205 self.write_file(&content, path)
206 }
207
208 pub fn copy_asset(&self, from: impl AsRef<Path>, to: &str) -> Result<()> {
213 let destination = self.resolve(to)?;
214 if let Some(parent) = destination.parent() {
215 fs::create_dir_all(parent).map_err(io_error(parent))?;
216 }
217 let temporary = temporary_path(&destination);
220 if let Err(error) = fs::copy(from.as_ref(), &temporary) {
221 let _ = fs::remove_file(&temporary);
222 return Err(io_error(from.as_ref())(error));
223 }
224 if let Err(error) = fs::rename(&temporary, &destination) {
225 let _ = fs::remove_file(&temporary);
226 return Err(io_error(&destination)(error));
227 }
228 Ok(())
229 }
230
231 pub fn clean(&self, create_directory: bool) -> Result<()> {
239 guard_destructive_path(&self.output_directory)?;
240
241 let directory = &self.output_directory;
242 if directory.exists() {
243 fs::remove_dir_all(directory).map_err(io_error(directory))?;
244 }
245 if create_directory {
246 fs::create_dir_all(directory).map_err(io_error(directory))?;
247 }
248 Ok(())
249 }
250
251 pub fn write_file(&self, content: &str, path: &str) -> Result<()> {
259 self.write_atomic(path, |file| file.write_all(content.as_bytes()))
260 }
261
262 fn write_atomic(
267 &self,
268 path: &str,
269 write_contents: impl FnOnce(&mut fs::File) -> io::Result<()>,
270 ) -> Result<()> {
271 let destination = self.resolve(path)?;
272 if let Some(parent) = destination.parent() {
273 fs::create_dir_all(parent).map_err(io_error(parent))?;
274 }
275
276 let temporary = temporary_path(&destination);
277 let mut file = fs::OpenOptions::new()
280 .write(true)
281 .create_new(true)
282 .open(&temporary)
283 .map_err(io_error(&temporary))?;
284 if let Err(error) = write_contents(&mut file) {
285 let _ = fs::remove_file(&temporary);
286 return Err(io_error(&destination)(error));
287 }
288 if let Err(error) = fs::rename(&temporary, &destination) {
289 let _ = fs::remove_file(&temporary);
290 return Err(io_error(&destination)(error));
291 }
292 Ok(())
293 }
294
295 fn resolve(&self, path: &str) -> Result<PathBuf> {
306 let reject = |reason: &'static str| {
307 Err(SsgError::InvalidPath {
308 path: path.to_owned(),
309 reason,
310 })
311 };
312
313 let candidate = Path::new(path);
314 let mut names = 0;
315 for component in candidate.components() {
316 match component {
317 Component::Normal(_) => names += 1,
318 Component::CurDir => {}
319 _ => return reject("escapes the output directory"),
320 }
321 }
322 if names == 0 || path.ends_with(['/', '\\']) {
323 return reject("does not name a file");
324 }
325
326 let mut resolved = self.output_directory.clone();
327 for component in candidate.components() {
328 if let Component::Normal(name) = component {
329 resolved.push(name);
330 if fs::symlink_metadata(&resolved).is_ok_and(|m| m.file_type().is_symlink()) {
332 return reject("passes through a symbolic link");
333 }
334 }
335 }
336 Ok(resolved)
337 }
338}
339
340fn temporary_path(destination: &Path) -> PathBuf {
346 static COUNTER: AtomicU64 = AtomicU64::new(0);
347 let name = destination
348 .file_name()
349 .map_or_else(|| "page".into(), |n| n.to_string_lossy().into_owned());
350 let unique = COUNTER.fetch_add(1, Ordering::Relaxed);
351 destination.with_file_name(format!(".{name}.molt-{}-{unique}.tmp", std::process::id()))
352}
353
354fn guard_destructive_path(path: &Path) -> Result<()> {
362 let reject = |reason: &'static str| {
363 Err(SsgError::UnsafeCleanTarget {
364 path: path.to_path_buf(),
365 reason,
366 })
367 };
368
369 if path.as_os_str().is_empty() {
370 return reject("the output directory is empty");
371 }
372 if path.parent().is_none() {
373 return reject("the output directory is a filesystem root");
374 }
375 if path.components().any(|c| c == Component::ParentDir) {
376 return reject("the output directory contains a `..` component");
377 }
378
379 let absolute = absolute_path(path).map_err(io_error(path))?;
380 let depth = absolute
381 .components()
382 .filter(|c| matches!(c, Component::Normal(_)))
383 .count();
384 if depth < 2 {
385 return reject("the output directory is a top-level directory");
386 }
387 if let Ok(cwd) = std::env::current_dir().and_then(fs::canonicalize) {
388 if cwd.starts_with(&absolute) {
389 return reject("the output directory is, or contains, the working directory");
390 }
391 }
392 let home = std::env::var_os("HOME").or_else(|| std::env::var_os("USERPROFILE"));
393 if let Some(home) = home.and_then(|h| fs::canonicalize(h).ok()) {
394 if home == absolute {
395 return reject("the output directory is the home directory");
396 }
397 }
398 Ok(())
399}
400
401fn absolute_path(path: &Path) -> io::Result<PathBuf> {
403 if let Ok(canonical) = fs::canonicalize(path) {
404 return Ok(canonical);
405 }
406 let mut absolute = fs::canonicalize(std::env::current_dir()?)?;
407 for component in path.components() {
408 match component {
409 Component::Normal(name) => absolute.push(name),
410 Component::RootDir | Component::Prefix(_) => {
411 absolute = PathBuf::from(component.as_os_str());
412 }
413 _ => {}
414 }
415 }
416 Ok(absolute)
417}
418
419fn collect_failures(results: Vec<(String, Result<()>)>) -> Result<()> {
421 let failures: Vec<(String, SsgError)> = results
422 .into_iter()
423 .filter_map(|(path, result)| result.err().map(|error| (path, error)))
424 .collect();
425
426 if failures.is_empty() {
427 Ok(())
428 } else {
429 Err(SsgError::Pages(failures))
430 }
431}
432
433#[cfg(test)]
434mod tests {
435 use super::*;
436 use crate::elements::{body, h1, head, html_tag, title};
437
438 struct TempDir(PathBuf);
440
441 impl TempDir {
442 fn new(name: &str) -> Self {
443 let path = std::env::temp_dir().join(format!("molt-{name}-{}", std::process::id()));
445 let _ = fs::remove_dir_all(&path);
446 fs::create_dir_all(&path).expect("temp dir is creatable");
447 Self(path)
448 }
449 }
450
451 impl Drop for TempDir {
452 fn drop(&mut self) {
453 let _ = fs::remove_dir_all(&self.0);
454 }
455 }
456
457 fn only_files_named(dir: &Path, names: &[&str]) -> bool {
459 let mut found: Vec<String> = fs::read_dir(dir)
460 .expect("readable")
461 .map(|e| e.expect("entry").file_name().to_string_lossy().into_owned())
462 .collect();
463 found.sort();
464 found == names
465 }
466
467 fn page(text: &str) -> Document {
468 Document::new(Some("en"))
469 .head_children([title().text(text)])
470 .body_children([h1().text(text)])
471 }
472
473 #[test]
474 fn generate_writes_a_rendered_document() {
475 let dir = TempDir::new("generate");
476 let site = StaticSiteGenerator::new(&dir.0);
477 site.generate(&page("Home"), "index.html", &RenderOptions::pretty())
478 .expect("written");
479
480 let written = fs::read_to_string(dir.0.join("index.html")).expect("readable");
481 assert!(written.starts_with("<!DOCTYPE html>"));
482 assert!(written.contains("<h1>Home</h1>"));
483 }
484
485 #[test]
487 fn generate_replaces_a_file_with_the_rendered_bytes() {
488 let dir = TempDir::new("stream");
489 let site = StaticSiteGenerator::new(&dir.0);
490 let destination = dir.0.join("index.html");
491 fs::write(&destination, "old").expect("seed");
492
493 let page = page("Home");
494 site.generate(&page, "index.html", &RenderOptions::pretty())
495 .expect("written");
496
497 let written = fs::read(destination).expect("readable");
498 assert_eq!(
499 written,
500 page.render_with(&RenderOptions::pretty()).into_bytes()
501 );
502 assert!(only_files_named(&dir.0, &["index.html"]));
503 }
504
505 #[test]
506 fn nested_paths_create_their_parent_directories() {
507 let dir = TempDir::new("nested");
508 let site = StaticSiteGenerator::new(&dir.0);
509 site.generate(
510 &page("Post"),
511 "blog/2026/post.html",
512 &RenderOptions::compact(),
513 )
514 .expect("written");
515 assert!(dir.0.join("blog/2026/post.html").exists());
516 }
517
518 #[test]
519 fn a_bare_page_can_be_written_without_a_doctype() {
520 let dir = TempDir::new("doctype");
521 let site = StaticSiteGenerator::new(&dir.0);
522 let node = Node::from(h1().text("Fragment"));
523
524 site.generate_page(&node, "with.html", &RenderOptions::compact(), true)
525 .expect("written");
526 site.generate_page(&node, "without.html", &RenderOptions::compact(), false)
527 .expect("written");
528
529 assert!(
530 fs::read_to_string(dir.0.join("with.html"))
531 .unwrap()
532 .starts_with("<!DOCTYPE")
533 );
534 assert!(
535 !fs::read_to_string(dir.0.join("without.html"))
536 .unwrap()
537 .contains("DOCTYPE")
538 );
539 }
540
541 #[test]
542 fn copy_asset_replaces_an_existing_destination() {
543 let dir = TempDir::new("assets");
544 let source = dir.0.join("source.css");
545 fs::write(&source, "body{}").expect("written");
546
547 let site = StaticSiteGenerator::new(dir.0.join("out"));
548 site.copy_asset(&source, "css/style.css").expect("copied");
549 fs::write(&source, "body{color:red}").expect("written");
550 site.copy_asset(&source, "css/style.css").expect("recopied");
551
552 let copied = fs::read_to_string(dir.0.join("out/css/style.css")).expect("readable");
553 assert_eq!(copied, "body{color:red}");
554 }
555
556 #[test]
557 fn clean_empties_the_output_directory() {
558 let dir = TempDir::new("clean");
559 let site = StaticSiteGenerator::new(dir.0.join("out"));
560 site.write_file("x", "a.html").expect("written");
561
562 site.clean(true).expect("cleaned");
563 assert!(dir.0.join("out").exists());
564 assert!(!dir.0.join("out/a.html").exists());
565 }
566
567 #[test]
569 fn clean_refuses_an_unsafe_output_directory() {
570 for unsafe_path in ["", "/"] {
571 let site = StaticSiteGenerator::new(unsafe_path);
572 assert!(
573 site.clean(false).is_err(),
574 "{unsafe_path:?} should be rejected"
575 );
576 }
577 assert!(StaticSiteGenerator::new("dist/../..").clean(false).is_err());
578 }
579
580 #[test]
581 fn a_page_path_cannot_escape_the_output_directory() {
582 let dir = TempDir::new("escape");
583 let site = StaticSiteGenerator::new(&dir.0);
584 assert!(site.write_file("x", "../escaped.html").is_err());
585 assert!(site.write_file("x", "/etc/escaped.html").is_err());
586 }
587
588 #[test]
589 fn a_path_that_does_not_name_a_file_is_rejected() {
590 let dir = TempDir::new("nameless");
591 let site = StaticSiteGenerator::new(&dir.0);
592 for path in ["", ".", "./", "a/", "a\\", "a/../../x.html", "C:\\x.html"] {
593 if path.starts_with("C:") && !cfg!(windows) {
595 continue;
596 }
597 let outcome = site.write_file("x", path);
598 assert!(outcome.is_err(), "{path:?} should be rejected");
599 }
600 let parent = dir.0.parent().expect("temp dir has a parent");
602 let name = dir
603 .0
604 .file_name()
605 .expect("named")
606 .to_string_lossy()
607 .into_owned();
608 assert!(!parent.join(format!("{name}.tmp")).exists());
609 assert!(only_files_named(&dir.0, &[]));
610 }
611
612 #[cfg(unix)]
613 #[test]
614 fn a_symlink_inside_the_output_directory_is_not_followed() {
615 let dir = TempDir::new("symlink");
616 let outside = dir.0.join("outside");
617 fs::create_dir_all(&outside).expect("created");
618 let out = dir.0.join("out");
619 fs::create_dir_all(&out).expect("created");
620 std::os::unix::fs::symlink(&outside, out.join("link")).expect("linked");
621
622 let site = StaticSiteGenerator::new(&out);
623 assert!(site.write_file("x", "link/x.html").is_err());
624 let asset = dir.0.join("a.css");
625 fs::write(&asset, "a{}").expect("written");
626 assert!(site.copy_asset(&asset, "link/a.css").is_err());
627 assert!(only_files_named(&outside, &[]));
628 }
629
630 #[test]
632 fn pages_with_the_same_stem_do_not_share_a_temporary_file() {
633 let dir = TempDir::new("stem");
634 let site = StaticSiteGenerator::new(&dir.0);
635 std::thread::scope(|scope| {
636 for round in 0..8 {
637 for name in ["index.html", "index.xml", "index.htm"] {
638 let site = &site;
639 scope.spawn(move || {
640 site.write_file(&format!("{name}:{round}"), name)
641 .expect("written");
642 });
643 }
644 }
645 });
646 assert!(only_files_named(
647 &dir.0,
648 &["index.htm", "index.html", "index.xml"]
649 ));
650 for name in ["index.html", "index.xml", "index.htm"] {
651 let content = fs::read_to_string(dir.0.join(name)).expect("readable");
652 assert!(
653 content.starts_with(&format!("{name}:")),
654 "{name}: {content}"
655 );
656 }
657 }
658
659 #[test]
660 fn a_failed_asset_copy_keeps_the_previous_file() {
661 let dir = TempDir::new("asset-atomic");
662 let site = StaticSiteGenerator::new(dir.0.join("out"));
663 let source = dir.0.join("source.css");
664 fs::write(&source, "old").expect("written");
665 site.copy_asset(&source, "style.css").expect("copied");
666
667 assert!(
668 site.copy_asset(dir.0.join("missing.css"), "style.css")
669 .is_err()
670 );
671 assert_eq!(
672 fs::read_to_string(dir.0.join("out/style.css")).expect("readable"),
673 "old"
674 );
675 assert!(only_files_named(&dir.0.join("out"), &["style.css"]));
676 }
677
678 #[test]
680 fn the_clean_guard_refuses_the_working_directory_and_what_contains_it() {
681 let cwd = std::env::current_dir().expect("cwd");
682 let mut unsafe_paths = vec![PathBuf::from("."), PathBuf::from("./"), cwd.clone()];
683 unsafe_paths.extend(cwd.ancestors().skip(1).map(Path::to_path_buf));
684 unsafe_paths.push(PathBuf::from("/usr"));
685 if let Some(home) = std::env::var_os("HOME") {
686 unsafe_paths.push(PathBuf::from(home));
687 }
688 for path in unsafe_paths {
689 assert!(
690 guard_destructive_path(&path).is_err(),
691 "{path:?} should be refused"
692 );
693 }
694 assert!(guard_destructive_path(&cwd.join("dist")).is_ok());
696 assert!(guard_destructive_path(Path::new("dist")).is_ok());
697 }
698
699 #[test]
700 fn generate_multiple_writes_every_page() {
701 let dir = TempDir::new("multiple");
702 let site = StaticSiteGenerator::new(&dir.0);
703 let documents = vec![
704 (page("A"), "a.html".to_string()),
705 (page("B"), "nested/b.html".to_string()),
706 ];
707
708 site.generate_multiple(&documents, &RenderOptions::pretty())
709 .expect("written");
710 assert!(dir.0.join("a.html").exists());
711 assert!(dir.0.join("nested/b.html").exists());
712 }
713
714 #[test]
715 fn generate_multiple_reports_every_failure_not_just_the_first() {
716 let dir = TempDir::new("failures");
717 let site = StaticSiteGenerator::new(&dir.0);
718 let documents = vec![
719 (page("ok"), "ok.html".to_string()),
720 (page("bad"), "../one.html".to_string()),
721 (page("bad"), "../two.html".to_string()),
722 ];
723
724 let error = site
725 .generate_multiple(&documents, &RenderOptions::compact())
726 .expect_err("two pages are unwritable");
727 let message = error.to_string();
728 assert!(message.contains("../one.html"), "{message}");
729 assert!(message.contains("../two.html"), "{message}");
730
731 let SsgError::Pages(failures) = error else {
733 panic!("a bulk build reports Pages");
734 };
735 let paths: Vec<&str> = failures.iter().map(|(path, _)| path.as_str()).collect();
736 assert_eq!(paths, ["../one.html", "../two.html"]);
737 assert!(
738 failures
739 .iter()
740 .all(|(_, e)| matches!(e, SsgError::InvalidPath { .. }))
741 );
742 }
743
744 #[test]
745 fn each_failure_has_its_own_variant_and_converts_to_an_io_error() {
746 use std::error::Error;
747
748 let dir = TempDir::new("variants");
749 let site = StaticSiteGenerator::new(&dir.0);
750
751 let refused = site.write_file("x", "../out.html").expect_err("path");
752 assert!(matches!(refused, SsgError::InvalidPath { ref path, .. } if path == "../out.html"));
753 assert!(refused.source().is_none());
754 assert_eq!(io::Error::from(refused).kind(), io::ErrorKind::InvalidInput);
755
756 let unsafe_clean = StaticSiteGenerator::new(".")
757 .clean(false)
758 .expect_err("guard");
759 assert!(matches!(unsafe_clean, SsgError::UnsafeCleanTarget { .. }));
760 assert_eq!(
761 io::Error::from(unsafe_clean).kind(),
762 io::ErrorKind::InvalidInput
763 );
764
765 fs::create_dir_all(dir.0.join("page.html")).expect("created");
767 let failed = site
768 .write_file("x", "page.html")
769 .expect_err("rename onto a directory");
770 assert!(matches!(failed, SsgError::Io { .. }));
771 assert!(failed.source().is_some());
772 assert!(failed.to_string().contains("page.html"));
773 assert!(
774 only_files_named(&dir.0, &["page.html"]),
775 "no temp left behind"
776 );
777 }
778
779 #[test]
780 fn a_page_is_written_with_its_doctype() {
781 let directory = TempDir::new("doctype-and-markup");
782 let site = StaticSiteGenerator::new(&directory.0);
783
784 let page = Node::from(
785 html_tag()
786 .child(head().child(title().text("Home")))
787 .child(body().child(h1().text("Hello"))),
788 );
789 site.generate_page(&page, "index.html", &RenderOptions::pretty(), true)
790 .expect("write");
791
792 let written = fs::read_to_string(directory.0.join("index.html")).expect("read");
793 assert!(written.starts_with("<!DOCTYPE html>\n"));
794 assert!(written.contains("<title>Home</title>"));
795 assert!(written.contains("<h1>Hello</h1>"));
796 }
797
798 #[test]
799 fn generate_multiple_writes_each_document_to_its_own_path() {
800 let directory = TempDir::new("multiple-documents");
801 let site = StaticSiteGenerator::new(&directory.0);
802
803 let pages = vec![
804 (page("Home"), "index.html".to_string()),
805 (page("About"), "about/index.html".to_string()),
806 ];
807 site.generate_multiple(&pages, &RenderOptions::compact())
808 .expect("write");
809
810 assert!(
811 fs::read_to_string(directory.0.join("index.html"))
812 .expect("read")
813 .contains("<h1>Home</h1>")
814 );
815 assert!(
816 fs::read_to_string(directory.0.join("about/index.html"))
817 .expect("read")
818 .contains("<h1>About</h1>")
819 );
820 }
821}