use std::ffi::OsStr;
use std::fs;
use std::io::{self, Write};
use std::path::{Component, Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
use crate::core::{Node, Render, RenderOptions};
use crate::document::Document;
#[derive(Debug)]
#[non_exhaustive]
pub enum SsgError {
InvalidPath {
path: String,
reason: &'static str,
},
UnsafeCleanTarget {
path: PathBuf,
reason: &'static str,
},
Io {
path: PathBuf,
source: io::Error,
},
Pages(Vec<(String, SsgError)>),
}
impl std::fmt::Display for SsgError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::InvalidPath { path, reason } => write!(f, "path {path:?} {reason}"),
Self::UnsafeCleanTarget { path, reason } => {
write!(f, "refusing to clean {}: {reason}", path.display())
}
Self::Io { path, source } => write!(f, "{}: {source}", path.display()),
Self::Pages(failures) => {
write!(f, "{} page(s) failed to generate:", failures.len())?;
for (path, error) in failures {
write!(f, "\n {path}: {error}")?;
}
Ok(())
}
}
}
}
impl std::error::Error for SsgError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Self::Io { source, .. } => Some(source),
_ => None,
}
}
}
impl From<SsgError> for io::Error {
fn from(error: SsgError) -> Self {
match error {
SsgError::InvalidPath { .. } | SsgError::UnsafeCleanTarget { .. } => {
Self::new(io::ErrorKind::InvalidInput, error)
}
SsgError::Io { source, .. } => source,
SsgError::Pages(_) => Self::other(error),
}
}
}
type Result<T> = std::result::Result<T, SsgError>;
pub const OUTPUT_MARKER: &str = ".molt-output";
const MARKER_TEXT: &str = "Created by molt-framework. StaticSiteGenerator::clean() only empties \
a directory that is missing, empty, or has this file.\n";
fn io_error(path: &Path) -> impl FnOnce(io::Error) -> SsgError + '_ {
move |source| SsgError::Io {
path: path.to_path_buf(),
source,
}
}
#[derive(Debug, Clone)]
pub struct StaticSiteGenerator {
output_directory: PathBuf,
}
impl StaticSiteGenerator {
pub fn new(output_directory: impl Into<PathBuf>) -> Self {
Self {
output_directory: output_directory.into(),
}
}
#[must_use]
pub fn output_directory(&self) -> &Path {
&self.output_directory
}
pub fn generate(&self, document: &Document, path: &str, options: &RenderOptions) -> Result<()> {
self.write_atomic(path, |file| {
let mut buffered = io::BufWriter::new(file);
document.render_to(&mut buffered, options)?;
buffered.flush()
})
}
pub fn generate_multiple(
&self,
documents: &[(Document, String)],
options: &RenderOptions,
) -> Result<()> {
#[cfg(feature = "parallel")]
let results: Vec<(String, Result<()>)> = {
use rayon::prelude::*;
documents
.par_iter()
.map(|(doc, path)| (path.clone(), self.generate(doc, path, options)))
.collect()
};
#[cfg(not(feature = "parallel"))]
let results: Vec<(String, Result<()>)> = documents
.iter()
.map(|(doc, path)| (path.clone(), self.generate(doc, path, options)))
.collect();
collect_failures(results)
}
pub fn generate_page(
&self,
page: &Node,
path: &str,
options: &RenderOptions,
doctype: bool,
) -> Result<()> {
let mut content = String::with_capacity(1024);
if doctype {
content.push_str("<!DOCTYPE html>\n");
}
page.write_into(&mut content, options, 0);
self.write_file(&content, path)
}
pub fn copy_asset(&self, from: impl AsRef<Path>, to: &str) -> Result<()> {
let destination = self.resolve(to)?;
if let Some(parent) = destination.parent() {
fs::create_dir_all(parent).map_err(io_error(parent))?;
}
self.mark_output()?;
let temporary = temporary_path(&destination);
if let Err(error) = fs::copy(from.as_ref(), &temporary) {
let _ = fs::remove_file(&temporary);
return Err(io_error(from.as_ref())(error));
}
if let Err(error) = fs::rename(&temporary, &destination) {
let _ = fs::remove_file(&temporary);
return Err(io_error(&destination)(error));
}
Ok(())
}
pub fn copy_dir(&self, from: impl AsRef<Path>, to: &str) -> Result<usize> {
self.copy_dir_where(from, to, |_| true)
}
pub fn copy_dir_where(
&self,
from: impl AsRef<Path>,
to: &str,
keep: impl Fn(&OsStr) -> bool,
) -> Result<usize> {
if to.starts_with(['/', '\\']) {
self.resolve(to)?;
}
let prefix = to.trim_end_matches(['/', '\\']);
let prefix = if prefix == "." { "" } else { prefix };
if !prefix.is_empty() {
self.resolve(prefix)?;
}
let output = fs::canonicalize(&self.output_directory).ok();
let mut copied = 0;
let mut pending = vec![(from.as_ref().to_path_buf(), prefix.to_owned())];
while let Some((directory, relative)) = pending.pop() {
for entry in fs::read_dir(&directory).map_err(io_error(&directory))? {
let entry = entry.map_err(io_error(&directory))?;
let name = entry.file_name();
if !keep(&name) {
continue;
}
let path = entry.path();
let Some(name) = name.to_str() else {
return Err(SsgError::InvalidPath {
path: path.to_string_lossy().into_owned(),
reason: "is not valid UTF-8",
});
};
let target = if relative.is_empty() {
name.to_owned()
} else {
format!("{relative}/{name}")
};
let kind = entry.file_type().map_err(io_error(&path))?;
if kind.is_dir() {
if output.is_some() && fs::canonicalize(&path).ok() == output {
continue;
}
pending.push((path, target));
} else if kind.is_file() {
self.copy_asset(&path, &target)?;
copied += 1;
}
}
}
Ok(copied)
}
pub fn copy_asset_hashed(&self, from: impl AsRef<Path>, dir: &str) -> Result<String> {
let from = from.as_ref();
let bytes = fs::read(from).map_err(io_error(from))?;
let Some(name) = from.file_name().and_then(OsStr::to_str) else {
return Err(SsgError::InvalidPath {
path: from.to_string_lossy().into_owned(),
reason: "has no file name in UTF-8",
});
};
let hashed = match name.rsplit_once('.') {
Some((stem, extension)) if !stem.is_empty() => {
format!("{stem}.{:016x}.{extension}", fnv1a_64(&bytes))
}
_ => format!("{name}.{:016x}", fnv1a_64(&bytes)),
};
if dir.starts_with(['/', '\\']) {
self.resolve(dir)?;
}
let relative = dir
.split('/')
.filter(|part| !part.is_empty() && *part != ".")
.chain(std::iter::once(hashed.as_str()))
.collect::<Vec<_>>()
.join("/");
self.write_atomic(&relative, |file| file.write_all(&bytes))?;
Ok(format!("/{relative}"))
}
pub fn clean(&self, create_directory: bool) -> Result<()> {
guard_destructive_path(&self.output_directory)?;
guard_ownership(&self.output_directory)?;
let directory = &self.output_directory;
if directory.exists() {
fs::remove_dir_all(directory).map_err(io_error(directory))?;
}
if create_directory {
fs::create_dir_all(directory).map_err(io_error(directory))?;
self.mark_output()?;
}
Ok(())
}
fn mark_output(&self) -> Result<()> {
let marker = self.output_directory.join(OUTPUT_MARKER);
match fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(&marker)
{
Ok(mut file) => file
.write_all(MARKER_TEXT.as_bytes())
.map_err(io_error(&marker)),
Err(error) if error.kind() == io::ErrorKind::AlreadyExists => Ok(()),
Err(error) => Err(io_error(&marker)(error)),
}
}
pub fn write_file(&self, content: &str, path: &str) -> Result<()> {
self.write_atomic(path, |file| file.write_all(content.as_bytes()))
}
fn write_atomic(
&self,
path: &str,
write_contents: impl FnOnce(&mut fs::File) -> io::Result<()>,
) -> Result<()> {
let destination = self.resolve(path)?;
if let Some(parent) = destination.parent() {
fs::create_dir_all(parent).map_err(io_error(parent))?;
}
self.mark_output()?;
let temporary = temporary_path(&destination);
let mut file = fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(&temporary)
.map_err(io_error(&temporary))?;
if let Err(error) = write_contents(&mut file) {
let _ = fs::remove_file(&temporary);
return Err(io_error(&destination)(error));
}
if let Err(error) = fs::rename(&temporary, &destination) {
let _ = fs::remove_file(&temporary);
return Err(io_error(&destination)(error));
}
Ok(())
}
fn resolve(&self, path: &str) -> Result<PathBuf> {
let reject = |reason: &'static str| {
Err(SsgError::InvalidPath {
path: path.to_owned(),
reason,
})
};
let candidate = Path::new(path);
let mut names = 0;
for component in candidate.components() {
match component {
Component::Normal(_) => names += 1,
Component::CurDir => {}
_ => return reject("escapes the output directory"),
}
}
if names == 0 || path.ends_with(['/', '\\']) {
return reject("does not name a file");
}
let mut resolved = self.output_directory.clone();
for component in candidate.components() {
if let Component::Normal(name) = component {
resolved.push(name);
if fs::symlink_metadata(&resolved).is_ok_and(|m| m.file_type().is_symlink()) {
return reject("passes through a symbolic link");
}
}
}
Ok(resolved)
}
}
fn fnv1a_64(bytes: &[u8]) -> u64 {
bytes.iter().fold(0xcbf2_9ce4_8422_2325_u64, |hash, byte| {
(hash ^ u64::from(*byte)).wrapping_mul(0x0100_0000_01b3)
})
}
fn temporary_path(destination: &Path) -> PathBuf {
static COUNTER: AtomicU64 = AtomicU64::new(0);
let name = destination
.file_name()
.map_or_else(|| "page".into(), |n| n.to_string_lossy().into_owned());
let unique = COUNTER.fetch_add(1, Ordering::Relaxed);
destination.with_file_name(format!(".{name}.molt-{}-{unique}.tmp", std::process::id()))
}
fn guard_destructive_path(path: &Path) -> Result<()> {
let reject = |reason: &'static str| {
Err(SsgError::UnsafeCleanTarget {
path: path.to_path_buf(),
reason,
})
};
if path.as_os_str().is_empty() {
return reject("the output directory is empty");
}
if path.parent().is_none() {
return reject("the output directory is a filesystem root");
}
if path.components().any(|c| c == Component::ParentDir) {
return reject("the output directory contains a `..` component");
}
let absolute = absolute_path(path).map_err(io_error(path))?;
let depth = absolute
.components()
.filter(|c| matches!(c, Component::Normal(_)))
.count();
if depth < 2 {
return reject("the output directory is a top-level directory");
}
if let Ok(cwd) = std::env::current_dir().and_then(fs::canonicalize) {
if cwd.starts_with(&absolute) {
return reject("the output directory is, or contains, the working directory");
}
}
let home = std::env::var_os("HOME").or_else(|| std::env::var_os("USERPROFILE"));
if let Some(home) = home.and_then(|h| fs::canonicalize(h).ok()) {
if home == absolute {
return reject("the output directory is the home directory");
}
}
Ok(())
}
fn guard_ownership(path: &Path) -> Result<()> {
let reject = |reason: &'static str| {
Err(SsgError::UnsafeCleanTarget {
path: path.to_path_buf(),
reason,
})
};
let Ok(metadata) = fs::symlink_metadata(path) else {
return Ok(());
};
if metadata.file_type().is_symlink() {
return reject("the output directory is a symbolic link");
}
if !metadata.is_dir() || path.join(OUTPUT_MARKER).symlink_metadata().is_ok() {
return Ok(());
}
let is_empty = fs::read_dir(path).map_err(io_error(path))?.next().is_none();
if is_empty {
Ok(())
} else {
reject(
"the output directory has contents but no `.molt-output` marker, so Molt did not \
make it; remove it yourself once, or create the marker file to hand it over",
)
}
}
fn absolute_path(path: &Path) -> io::Result<PathBuf> {
if let Ok(canonical) = fs::canonicalize(path) {
return Ok(canonical);
}
let mut absolute = fs::canonicalize(std::env::current_dir()?)?;
for component in path.components() {
match component {
Component::Normal(name) => absolute.push(name),
Component::RootDir | Component::Prefix(_) => {
absolute = PathBuf::from(component.as_os_str());
}
_ => {}
}
}
Ok(absolute)
}
fn collect_failures(results: Vec<(String, Result<()>)>) -> Result<()> {
let failures: Vec<(String, SsgError)> = results
.into_iter()
.filter_map(|(path, result)| result.err().map(|error| (path, error)))
.collect();
if failures.is_empty() {
Ok(())
} else {
Err(SsgError::Pages(failures))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::elements::{body, h1, head, html_tag, title};
struct TempDir(PathBuf);
impl TempDir {
fn new(name: &str) -> Self {
let path = std::env::temp_dir().join(format!("molt-{name}-{}", std::process::id()));
let _ = fs::remove_dir_all(&path);
fs::create_dir_all(&path).expect("temp dir is creatable");
Self(path)
}
}
impl Drop for TempDir {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.0);
}
}
fn only_files_named(dir: &Path, names: &[&str]) -> bool {
let mut found: Vec<String> = fs::read_dir(dir)
.expect("readable")
.map(|e| e.expect("entry").file_name().to_string_lossy().into_owned())
.filter(|name| name != OUTPUT_MARKER)
.collect();
found.sort();
found == names
}
fn page(text: &str) -> Document {
Document::new(Some("en"))
.head_children([title().text(text)])
.body_children([h1().text(text)])
}
#[test]
fn generate_writes_a_rendered_document() {
let dir = TempDir::new("generate");
let site = StaticSiteGenerator::new(&dir.0);
site.generate(&page("Home"), "index.html", &RenderOptions::pretty())
.expect("written");
let written = fs::read_to_string(dir.0.join("index.html")).expect("readable");
assert!(written.starts_with("<!DOCTYPE html>"));
assert!(written.contains("<h1>Home</h1>"));
}
#[test]
fn generate_replaces_a_file_with_the_rendered_bytes() {
let dir = TempDir::new("stream");
let site = StaticSiteGenerator::new(&dir.0);
let destination = dir.0.join("index.html");
fs::write(&destination, "old").expect("seed");
let page = page("Home");
site.generate(&page, "index.html", &RenderOptions::pretty())
.expect("written");
let written = fs::read(destination).expect("readable");
assert_eq!(
written,
page.render_with(&RenderOptions::pretty()).into_bytes()
);
assert!(only_files_named(&dir.0, &["index.html"]));
}
#[test]
fn nested_paths_create_their_parent_directories() {
let dir = TempDir::new("nested");
let site = StaticSiteGenerator::new(&dir.0);
site.generate(
&page("Post"),
"blog/2026/post.html",
&RenderOptions::compact(),
)
.expect("written");
assert!(dir.0.join("blog/2026/post.html").exists());
}
#[test]
fn a_bare_page_can_be_written_without_a_doctype() {
let dir = TempDir::new("doctype");
let site = StaticSiteGenerator::new(&dir.0);
let node = Node::from(h1().text("Fragment"));
site.generate_page(&node, "with.html", &RenderOptions::compact(), true)
.expect("written");
site.generate_page(&node, "without.html", &RenderOptions::compact(), false)
.expect("written");
assert!(
fs::read_to_string(dir.0.join("with.html"))
.unwrap()
.starts_with("<!DOCTYPE")
);
assert!(
!fs::read_to_string(dir.0.join("without.html"))
.unwrap()
.contains("DOCTYPE")
);
}
#[test]
fn fnv1a_matches_the_published_vectors() {
assert_eq!(fnv1a_64(b""), 0xcbf2_9ce4_8422_2325);
assert_eq!(fnv1a_64(b"a"), 0xaf63_dc4c_8601_ec8c);
assert_eq!(fnv1a_64(b"foobar"), 0x8594_4171_f739_67e8);
}
#[test]
fn a_hashed_copy_is_named_by_its_content_and_returns_the_url() {
let dir = TempDir::new("hashed");
let site = StaticSiteGenerator::new(dir.0.join("out"));
let source = dir.0.join("site.css");
fs::write(&source, "body{color:red}").expect("written");
let red = site.copy_asset_hashed(&source, "css").expect("copied");
assert_eq!(red, "/css/site.c0e0533c847ec633.css");
assert_eq!(
fs::read_to_string(dir.0.join("out/css/site.c0e0533c847ec633.css")).expect("copy"),
"body{color:red}"
);
assert_eq!(site.copy_asset_hashed(&source, "css").expect("again"), red);
fs::write(&source, "body{color:blue}").expect("written");
let blue = site.copy_asset_hashed(&source, "css").expect("copied");
assert_eq!(blue, "/css/site.91db968e2dbb7470.css");
assert!(dir.0.join("out/css/site.c0e0533c847ec633.css").is_file());
assert!(only_files_named(
&dir.0.join("out/css"),
&["site.91db968e2dbb7470.css", "site.c0e0533c847ec633.css"]
));
}
#[test]
fn a_hashed_name_keeps_the_last_extension_and_copes_with_none() {
let dir = TempDir::new("hashed-names");
let site = StaticSiteGenerator::new(dir.0.join("out"));
for (name, expected) in [
("app.min.js", "/app.min.af63dc4c8601ec8c.js"),
("LICENSE", "/LICENSE.af63dc4c8601ec8c"),
(".htaccess", "/.htaccess.af63dc4c8601ec8c"),
] {
let source = dir.0.join(name);
fs::write(&source, "a").expect("written");
assert_eq!(
site.copy_asset_hashed(&source, "").expect("copied"),
expected
);
}
assert_eq!(
site.copy_asset_hashed(dir.0.join("LICENSE"), "./a//b/")
.expect("copied"),
"/a/b/LICENSE.af63dc4c8601ec8c"
);
}
#[test]
fn a_hashed_copy_refuses_a_destination_that_escapes_and_a_missing_source() {
let dir = TempDir::new("hashed-refused");
let site = StaticSiteGenerator::new(dir.0.join("out"));
let source = dir.0.join("a.css");
fs::write(&source, "a").expect("written");
for to in ["../x", "/abs", "/", "a/../../b"] {
let error = site.copy_asset_hashed(&source, to).expect_err("refused");
assert!(
matches!(error, SsgError::InvalidPath { .. }),
"{to}: {error}"
);
}
assert!(!dir.0.join("out").exists());
let error = site
.copy_asset_hashed(dir.0.join("missing.css"), "css")
.expect_err("fails");
assert!(matches!(error, SsgError::Io { .. }), "{error}");
}
#[test]
fn copy_asset_replaces_an_existing_destination() {
let dir = TempDir::new("assets");
let source = dir.0.join("source.css");
fs::write(&source, "body{}").expect("written");
let site = StaticSiteGenerator::new(dir.0.join("out"));
site.copy_asset(&source, "css/style.css").expect("copied");
fs::write(&source, "body{color:red}").expect("written");
site.copy_asset(&source, "css/style.css").expect("recopied");
let copied = fs::read_to_string(dir.0.join("out/css/style.css")).expect("readable");
assert_eq!(copied, "body{color:red}");
}
#[test]
fn clean_empties_the_output_directory() {
let dir = TempDir::new("clean");
let site = StaticSiteGenerator::new(dir.0.join("out"));
site.write_file("x", "a.html").expect("written");
site.clean(true).expect("cleaned");
assert!(dir.0.join("out").exists());
assert!(!dir.0.join("out/a.html").exists());
}
#[test]
fn clean_refuses_a_directory_it_did_not_make() {
let dir = TempDir::new("clean-foreign");
let foreign = dir.0.join("other-project/dist");
fs::create_dir_all(&foreign).expect("created");
fs::write(foreign.join("precious.txt"), "mine").expect("written");
let error = StaticSiteGenerator::new(&foreign)
.clean(true)
.expect_err("refused");
assert!(
matches!(error, SsgError::UnsafeCleanTarget { .. }),
"{error}"
);
assert!(error.to_string().contains(OUTPUT_MARKER), "{error}");
assert_eq!(
fs::read_to_string(foreign.join("precious.txt")).expect("kept"),
"mine"
);
}
#[test]
fn clean_empties_a_directory_that_has_the_marker() {
let dir = TempDir::new("clean-marked");
let out = dir.0.join("out");
fs::create_dir_all(&out).expect("created");
fs::write(out.join(OUTPUT_MARKER), "").expect("marker");
fs::write(out.join("old.html"), "old").expect("written");
StaticSiteGenerator::new(&out).clean(true).expect("cleaned");
assert!(!out.join("old.html").exists());
assert!(out.join(OUTPUT_MARKER).is_file(), "the marker is kept");
}
#[test]
fn clean_accepts_a_missing_or_an_empty_directory() {
let dir = TempDir::new("clean-empty");
let missing = StaticSiteGenerator::new(dir.0.join("missing"));
missing.clean(false).expect("nothing to remove");
assert!(!dir.0.join("missing").exists());
missing.clean(true).expect("created");
assert!(dir.0.join("missing").join(OUTPUT_MARKER).is_file());
fs::create_dir_all(dir.0.join("empty")).expect("created");
StaticSiteGenerator::new(dir.0.join("empty"))
.clean(true)
.expect("an empty directory has nothing to lose");
}
#[test]
fn writing_a_page_or_an_asset_leaves_the_marker_and_keeps_it() {
let dir = TempDir::new("marker-written");
let site = StaticSiteGenerator::new(dir.0.join("out"));
site.write_file("a", "a.html").expect("written");
let marker = dir.0.join("out").join(OUTPUT_MARKER);
assert!(
fs::read_to_string(&marker)
.expect("marker")
.starts_with("Created by molt")
);
fs::write(&marker, "adopted by hand").expect("edited");
let source = dir.0.join("s.css");
fs::write(&source, "b").expect("written");
site.copy_asset(&source, "s.css").expect("copied");
assert_eq!(
fs::read_to_string(&marker).expect("kept"),
"adopted by hand"
);
}
#[cfg(unix)]
#[test]
fn clean_refuses_an_output_directory_that_is_a_symlink() {
let dir = TempDir::new("clean-link");
let target = dir.0.join("target");
fs::create_dir_all(&target).expect("created");
fs::write(target.join(OUTPUT_MARKER), "").expect("marker");
fs::write(target.join("x"), "x").expect("written");
let link = dir.0.join("out");
std::os::unix::fs::symlink(&target, &link).expect("linked");
let error = StaticSiteGenerator::new(&link)
.clean(true)
.expect_err("refused");
assert!(
matches!(error, SsgError::UnsafeCleanTarget { .. }),
"{error}"
);
assert!(target.join("x").is_file());
}
#[test]
fn clean_refuses_an_unsafe_output_directory() {
for unsafe_path in ["", "/"] {
let site = StaticSiteGenerator::new(unsafe_path);
assert!(
site.clean(false).is_err(),
"{unsafe_path:?} should be rejected"
);
}
assert!(StaticSiteGenerator::new("dist/../..").clean(false).is_err());
}
#[test]
fn a_page_path_cannot_escape_the_output_directory() {
let dir = TempDir::new("escape");
let site = StaticSiteGenerator::new(&dir.0);
assert!(site.write_file("x", "../escaped.html").is_err());
assert!(site.write_file("x", "/etc/escaped.html").is_err());
}
#[test]
fn a_path_that_does_not_name_a_file_is_rejected() {
let dir = TempDir::new("nameless");
let site = StaticSiteGenerator::new(&dir.0);
for path in ["", ".", "./", "a/", "a\\", "a/../../x.html", "C:\\x.html"] {
if path.starts_with("C:") && !cfg!(windows) {
continue;
}
let outcome = site.write_file("x", path);
assert!(outcome.is_err(), "{path:?} should be rejected");
}
let parent = dir.0.parent().expect("temp dir has a parent");
let name = dir
.0
.file_name()
.expect("named")
.to_string_lossy()
.into_owned();
assert!(!parent.join(format!("{name}.tmp")).exists());
assert!(only_files_named(&dir.0, &[]));
}
#[cfg(unix)]
#[test]
fn a_symlink_inside_the_output_directory_is_not_followed() {
let dir = TempDir::new("symlink");
let outside = dir.0.join("outside");
fs::create_dir_all(&outside).expect("created");
let out = dir.0.join("out");
fs::create_dir_all(&out).expect("created");
std::os::unix::fs::symlink(&outside, out.join("link")).expect("linked");
let site = StaticSiteGenerator::new(&out);
assert!(site.write_file("x", "link/x.html").is_err());
let asset = dir.0.join("a.css");
fs::write(&asset, "a{}").expect("written");
assert!(site.copy_asset(&asset, "link/a.css").is_err());
assert!(only_files_named(&outside, &[]));
}
#[test]
fn pages_with_the_same_stem_do_not_share_a_temporary_file() {
let dir = TempDir::new("stem");
let site = StaticSiteGenerator::new(&dir.0);
std::thread::scope(|scope| {
for round in 0..8 {
for name in ["index.html", "index.xml", "index.htm"] {
let site = &site;
scope.spawn(move || {
site.write_file(&format!("{name}:{round}"), name)
.expect("written");
});
}
}
});
assert!(only_files_named(
&dir.0,
&["index.htm", "index.html", "index.xml"]
));
for name in ["index.html", "index.xml", "index.htm"] {
let content = fs::read_to_string(dir.0.join(name)).expect("readable");
assert!(
content.starts_with(&format!("{name}:")),
"{name}: {content}"
);
}
}
#[test]
fn a_failed_asset_copy_keeps_the_previous_file() {
let dir = TempDir::new("asset-atomic");
let site = StaticSiteGenerator::new(dir.0.join("out"));
let source = dir.0.join("source.css");
fs::write(&source, "old").expect("written");
site.copy_asset(&source, "style.css").expect("copied");
assert!(
site.copy_asset(dir.0.join("missing.css"), "style.css")
.is_err()
);
assert_eq!(
fs::read_to_string(dir.0.join("out/style.css")).expect("readable"),
"old"
);
assert!(only_files_named(&dir.0.join("out"), &["style.css"]));
}
fn static_tree(base: &Path) -> PathBuf {
let root = base.join("static");
fs::create_dir_all(root.join("img")).expect("created");
fs::create_dir_all(root.join(".cache")).expect("created");
fs::write(root.join("app.css"), "a{}").expect("written");
fs::write(root.join("img/logo.svg"), "<svg/>").expect("written");
fs::write(root.join(".DS_Store"), "junk").expect("written");
fs::write(root.join(".cache/x"), "x").expect("written");
root
}
#[test]
fn copy_dir_copies_a_nested_tree_and_counts_the_files() {
let dir = TempDir::new("copy-dir");
let source = static_tree(&dir.0);
let site = StaticSiteGenerator::new(dir.0.join("out"));
assert_eq!(site.copy_dir(&source, "assets").expect("copied"), 4);
let out = dir.0.join("out/assets");
assert_eq!(fs::read_to_string(out.join("app.css")).expect("css"), "a{}");
assert_eq!(
fs::read_to_string(out.join("img/logo.svg")).expect("svg"),
"<svg/>"
);
assert!(out.join(".DS_Store").is_file());
}
#[test]
fn copy_dir_where_leaves_out_the_names_it_rejects_with_what_is_under_them() {
let dir = TempDir::new("copy-dir-where");
let source = static_tree(&dir.0);
let site = StaticSiteGenerator::new(dir.0.join("out"));
let copied = site
.copy_dir_where(&source, "", |name| name != ".DS_Store" && name != ".cache")
.expect("copied");
assert_eq!(copied, 2);
let out = dir.0.join("out");
assert!(out.join("app.css").is_file() && out.join("img/logo.svg").is_file());
assert!(!out.join(".DS_Store").exists() && !out.join(".cache").exists());
}
#[test]
fn copy_dir_replaces_existing_files_and_leaves_no_temporary_file() {
let dir = TempDir::new("copy-dir-replace");
let source = static_tree(&dir.0);
let site = StaticSiteGenerator::new(dir.0.join("out"));
site.write_file("old", "assets/app.css").expect("written");
site.copy_dir_where(&source, "assets", |name| name == "app.css")
.expect("copied");
let out = dir.0.join("out/assets");
assert_eq!(fs::read_to_string(out.join("app.css")).expect("css"), "a{}");
assert!(only_files_named(&out, &["app.css"]));
}
#[test]
fn copy_dir_refuses_a_destination_that_escapes_before_copying_anything() {
let dir = TempDir::new("copy-dir-escape");
let source = static_tree(&dir.0);
let site = StaticSiteGenerator::new(dir.0.join("out"));
for to in ["../escape", "/abs", "/", "a/../../b"] {
let error = site.copy_dir(&source, to).expect_err("refused");
assert!(
matches!(error, SsgError::InvalidPath { .. }),
"{to}: {error}"
);
}
assert!(!dir.0.join("out").exists());
assert!(!dir.0.join("escape").exists());
}
#[test]
fn copy_dir_reports_a_missing_source() {
let dir = TempDir::new("copy-dir-missing");
let site = StaticSiteGenerator::new(dir.0.join("out"));
let error = site
.copy_dir(dir.0.join("nope"), "assets")
.expect_err("fails");
assert!(matches!(error, SsgError::Io { .. }), "{error}");
}
#[test]
fn copy_dir_does_not_copy_the_output_directory_into_itself() {
let dir = TempDir::new("copy-dir-self");
let source = dir.0.join("project");
fs::create_dir_all(source.join("dist")).expect("created");
fs::write(source.join("a.txt"), "a").expect("written");
fs::write(source.join("dist/old.html"), "old").expect("written");
let site = StaticSiteGenerator::new(source.join("dist"));
assert_eq!(site.copy_dir(&source, "copy").expect("copied"), 1);
assert!(source.join("dist/copy/a.txt").is_file());
assert!(!source.join("dist/copy/dist").exists());
}
#[cfg(unix)]
#[test]
fn copy_dir_skips_a_symlink_to_a_file_or_a_directory() {
let dir = TempDir::new("copy-dir-links");
let source = static_tree(&dir.0);
let secret = dir.0.join("secret");
fs::create_dir_all(&secret).expect("created");
fs::write(secret.join("key"), "k").expect("written");
std::os::unix::fs::symlink(secret.join("key"), source.join("link-file")).expect("linked");
std::os::unix::fs::symlink(&secret, source.join("link-dir")).expect("linked");
let site = StaticSiteGenerator::new(dir.0.join("out"));
assert_eq!(site.copy_dir(&source, "assets").expect("copied"), 4);
let out = dir.0.join("out/assets");
assert!(!out.join("link-file").exists() && !out.join("link-dir").exists());
}
#[test]
fn the_clean_guard_refuses_the_working_directory_and_what_contains_it() {
let cwd = std::env::current_dir().expect("cwd");
let mut unsafe_paths = vec![PathBuf::from("."), PathBuf::from("./"), cwd.clone()];
unsafe_paths.extend(cwd.ancestors().skip(1).map(Path::to_path_buf));
unsafe_paths.push(PathBuf::from("/usr"));
if let Some(home) = std::env::var_os("HOME") {
unsafe_paths.push(PathBuf::from(home));
}
for path in unsafe_paths {
assert!(
guard_destructive_path(&path).is_err(),
"{path:?} should be refused"
);
}
assert!(guard_destructive_path(&cwd.join("dist")).is_ok());
assert!(guard_destructive_path(Path::new("dist")).is_ok());
}
#[test]
fn generate_multiple_writes_every_page() {
let dir = TempDir::new("multiple");
let site = StaticSiteGenerator::new(&dir.0);
let documents = vec![
(page("A"), "a.html".to_string()),
(page("B"), "nested/b.html".to_string()),
];
site.generate_multiple(&documents, &RenderOptions::pretty())
.expect("written");
assert!(dir.0.join("a.html").exists());
assert!(dir.0.join("nested/b.html").exists());
}
#[test]
fn generate_multiple_reports_every_failure_not_just_the_first() {
let dir = TempDir::new("failures");
let site = StaticSiteGenerator::new(&dir.0);
let documents = vec![
(page("ok"), "ok.html".to_string()),
(page("bad"), "../one.html".to_string()),
(page("bad"), "../two.html".to_string()),
];
let error = site
.generate_multiple(&documents, &RenderOptions::compact())
.expect_err("two pages are unwritable");
let message = error.to_string();
assert!(message.contains("../one.html"), "{message}");
assert!(message.contains("../two.html"), "{message}");
let SsgError::Pages(failures) = error else {
panic!("a bulk build reports Pages");
};
let paths: Vec<&str> = failures.iter().map(|(path, _)| path.as_str()).collect();
assert_eq!(paths, ["../one.html", "../two.html"]);
assert!(
failures
.iter()
.all(|(_, e)| matches!(e, SsgError::InvalidPath { .. }))
);
}
#[test]
fn each_failure_has_its_own_variant_and_converts_to_an_io_error() {
use std::error::Error;
let dir = TempDir::new("variants");
let site = StaticSiteGenerator::new(&dir.0);
let refused = site.write_file("x", "../out.html").expect_err("path");
assert!(matches!(refused, SsgError::InvalidPath { ref path, .. } if path == "../out.html"));
assert!(refused.source().is_none());
assert_eq!(io::Error::from(refused).kind(), io::ErrorKind::InvalidInput);
let unsafe_clean = StaticSiteGenerator::new(".")
.clean(false)
.expect_err("guard");
assert!(matches!(unsafe_clean, SsgError::UnsafeCleanTarget { .. }));
assert_eq!(
io::Error::from(unsafe_clean).kind(),
io::ErrorKind::InvalidInput
);
fs::create_dir_all(dir.0.join("page.html")).expect("created");
let failed = site
.write_file("x", "page.html")
.expect_err("rename onto a directory");
assert!(matches!(failed, SsgError::Io { .. }));
assert!(failed.source().is_some());
assert!(failed.to_string().contains("page.html"));
assert!(
only_files_named(&dir.0, &["page.html"]),
"no temp left behind"
);
}
#[test]
fn a_page_is_written_with_its_doctype() {
let directory = TempDir::new("doctype-and-markup");
let site = StaticSiteGenerator::new(&directory.0);
let page = Node::from(
html_tag()
.child(head().child(title().text("Home")))
.child(body().child(h1().text("Hello"))),
);
site.generate_page(&page, "index.html", &RenderOptions::pretty(), true)
.expect("write");
let written = fs::read_to_string(directory.0.join("index.html")).expect("read");
assert!(written.starts_with("<!DOCTYPE html>\n"));
assert!(written.contains("<title>Home</title>"));
assert!(written.contains("<h1>Hello</h1>"));
}
#[test]
fn generate_multiple_writes_each_document_to_its_own_path() {
let directory = TempDir::new("multiple-documents");
let site = StaticSiteGenerator::new(&directory.0);
let pages = vec![
(page("Home"), "index.html".to_string()),
(page("About"), "about/index.html".to_string()),
];
site.generate_multiple(&pages, &RenderOptions::compact())
.expect("write");
assert!(
fs::read_to_string(directory.0.join("index.html"))
.expect("read")
.contains("<h1>Home</h1>")
);
assert!(
fs::read_to_string(directory.0.join("about/index.html"))
.expect("read")
.contains("<h1>About</h1>")
);
}
}