Skip to main content

molt/
ssg.rs

1//! Static site generation.
2//!
3//!
4//! Compiled out on `wasm32` regardless of the `ssg` feature — there is no filesystem
5//! there.
6
7use 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/// Why a static site operation failed.
17///
18/// A real error type rather than `io::Error::other(String)`, so a caller can tell an
19/// unsafe path from a full disk and can read which page of a bulk build failed. It
20/// converts into [`io::Error`] (`?` works in a function returning `io::Result`), keeping
21/// `InvalidInput` for the two refusals.
22///
23/// # Examples
24/// ```
25/// use molt::ssg::{SsgError, StaticSiteGenerator};
26///
27/// let site = StaticSiteGenerator::new("dist");
28/// let error = site.write_file("x", "../escape.html").expect_err("refused");
29/// assert!(matches!(error, SsgError::InvalidPath { .. }));
30/// ```
31#[derive(Debug)]
32#[non_exhaustive]
33pub enum SsgError {
34    /// A page or asset path that cannot be written under the output directory: a `..`, a
35    /// root or drive prefix, no file name, or a symbolic link on the way.
36    InvalidPath {
37        /// The path as given.
38        path: String,
39        /// What is wrong with it.
40        reason: &'static str,
41    },
42    /// `clean` refused to delete the output directory.
43    UnsafeCleanTarget {
44        /// The directory as given.
45        path: PathBuf,
46        /// Why it is too dangerous to remove.
47        reason: &'static str,
48    },
49    /// The filesystem failed.
50    Io {
51        /// The file or directory the operation was working on.
52        path: PathBuf,
53        /// The underlying error.
54        source: io::Error,
55    },
56    /// Several pages of a bulk build failed. Every failure is here, not just the first.
57    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
100/// Result alias for this module's operations.
101type Result<T> = std::result::Result<T, SsgError>;
102
103/// The name of the file a [`StaticSiteGenerator`] leaves in its output directory.
104///
105/// [`StaticSiteGenerator::clean`] deletes a directory only if it is missing, empty, or holds
106/// this file, so a relative output path that happens to name someone else's directory (the
107/// process may run from another project) is refused instead of emptied. Writing a page or
108/// an asset creates it. To let `clean` empty a directory Molt did not create, create the file
109/// yourself. It is an ordinary dotfile and is uploaded with the rest of the site; its
110/// contents are one line of text.
111///
112/// # Examples
113/// ```
114/// assert_eq!(molt::ssg::OUTPUT_MARKER, ".molt-output");
115/// ```
116pub const OUTPUT_MARKER: &str = ".molt-output";
117
118/// What the marker says, for whoever finds it in a deployed site.
119const MARKER_TEXT: &str = "Created by molt-framework. StaticSiteGenerator::clean() only empties \
120a directory that is missing, empty, or has this file.\n";
121
122/// Attaches the path an I/O operation was working on.
123fn 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/// Writes rendered pages and assets into an output directory.
131///
132/// # Examples
133/// ```no_run
134/// use molt::prelude::*;
135/// use molt::{Document, ssg::StaticSiteGenerator};
136///
137/// let site = StaticSiteGenerator::new("dist");
138/// site.clean(true)?;
139/// site.generate(&Document::new(Some("pt-BR")), "index.html", &RenderOptions::pretty())?;
140/// # Ok::<(), molt::ssg::SsgError>(())
141/// ```
142#[derive(Debug, Clone)]
143pub struct StaticSiteGenerator {
144    output_directory: PathBuf,
145}
146
147impl StaticSiteGenerator {
148    /// Creates a generator writing into `output_directory`.
149    pub fn new(output_directory: impl Into<PathBuf>) -> Self {
150        Self {
151            output_directory: output_directory.into(),
152        }
153    }
154
155    /// The directory pages are written into.
156    #[must_use]
157    pub fn output_directory(&self) -> &Path {
158        &self.output_directory
159    }
160
161    /// Renders a document to `path`, relative to the output directory.
162    ///
163    /// The page is written with [`Document::render_to`], section by section, into a
164    /// temporary file that is then renamed. The bytes match [`Document::render_with`].
165    ///
166    /// # Errors
167    /// Returns an error if the path escapes the output directory, or if the write fails.
168    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    /// Renders several documents.
177    ///
178    /// With the `parallel` feature the pages are rendered concurrently — sound because the
179    /// node tree is `Send + Sync`.
180    ///
181    /// **Every** failure is reported, not just the first: a bulk build that names one of
182    /// twelve broken pages is worse than useless.
183    ///
184    /// # Errors
185    /// Returns an error naming every page that failed.
186    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    /// Renders a bare node to `path`, optionally prefixed by the doctype.
210    ///
211    /// # Errors
212    /// Returns an error if the path is unsafe or the write fails.
213    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    /// Copies a file into the output directory, replacing any existing destination.
229    ///
230    /// # Errors
231    /// Returns an error if the destination is unsafe or the copy fails.
232    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        // Copy beside the destination and rename over it, so a failed copy leaves the old
239        // file in place instead of the delete-then-copy gap this used to have.
240        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    /// Copies a directory tree into the output directory, replacing files that already exist.
253    ///
254    /// `to` names the destination directory under the output directory; `""` or `"."` copies
255    /// into the output directory itself. Returns how many files were copied. Every file goes
256    /// through [`copy_asset`](Self::copy_asset), so each copy is atomic.
257    ///
258    /// Symbolic links in `from` are **not followed**: a link to a file or a directory is
259    /// skipped, because what it points at may lie outside the tree you meant to publish. The
260    /// output directory is skipped too if it sits inside `from`, so copying `.` cannot copy
261    /// the site into itself. Use [`copy_dir_where`](Self::copy_dir_where) to leave out names
262    /// such as `.DS_Store`.
263    ///
264    /// # Errors
265    /// Returns an error if `from` cannot be read, if `to` is unsafe (see
266    /// [`copy_asset`](Self::copy_asset)), if a name under `from` is not valid UTF-8, or if a
267    /// copy fails.
268    ///
269    /// # Examples
270    /// ```
271    /// use molt::ssg::StaticSiteGenerator;
272    ///
273    /// let base = std::env::temp_dir().join(format!("molt-copy-dir-{}", std::process::id()));
274    /// std::fs::create_dir_all(base.join("static/img"))?;
275    /// std::fs::write(base.join("static/app.css"), "a{}")?;
276    /// std::fs::write(base.join("static/img/logo.svg"), "<svg/>")?;
277    ///
278    /// let site = StaticSiteGenerator::new(base.join("dist"));
279    /// assert_eq!(site.copy_dir(base.join("static"), "assets")?, 2);
280    /// assert!(base.join("dist/assets/img/logo.svg").is_file());
281    /// # std::fs::remove_dir_all(&base)?;
282    /// # Ok::<(), Box<dyn std::error::Error>>(())
283    /// ```
284    pub fn copy_dir(&self, from: impl AsRef<Path>, to: &str) -> Result<usize> {
285        self.copy_dir_where(from, to, |_| true)
286    }
287
288    /// As [`copy_dir`](Self::copy_dir), copying only the entries `keep` accepts.
289    ///
290    /// `keep` sees each file or directory *name* (not the path). A directory it rejects is
291    /// skipped with everything under it.
292    ///
293    /// # Errors
294    /// The same as [`copy_dir`](Self::copy_dir).
295    ///
296    /// # Examples
297    /// ```
298    /// use molt::ssg::StaticSiteGenerator;
299    ///
300    /// let base = std::env::temp_dir().join(format!("molt-copy-where-{}", std::process::id()));
301    /// std::fs::create_dir_all(base.join("static"))?;
302    /// std::fs::write(base.join("static/app.css"), "a{}")?;
303    /// std::fs::write(base.join("static/.DS_Store"), "junk")?;
304    ///
305    /// let site = StaticSiteGenerator::new(base.join("dist"));
306    /// let copied = site.copy_dir_where(base.join("static"), "", |name| name != ".DS_Store")?;
307    /// assert_eq!(copied, 1);
308    /// assert!(!base.join("dist/.DS_Store").exists());
309    /// # std::fs::remove_dir_all(&base)?;
310    /// # Ok::<(), Box<dyn std::error::Error>>(())
311    /// ```
312    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        // Checked before anything is read, so an unsafe `to` copies nothing.
319        // Only a trailing slash is forgiven: a leading one is a root, which `resolve` refuses,
320        // and trimming it would turn `/etc` into the relative `etc`.
321        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        // An explicit stack, like the tree walkers: the depth of a directory is not ours to
333        // bound, and recursion would make it a stack limit.
334        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                // `file_type` does not follow links, which is what makes skipping them safe.
355                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    /// Copies a file into `dir` under a name that holds a hash of its content, and returns the
371    /// URL to use for it.
372    ///
373    /// `site.css` copied to `"css"` becomes `css/site.<hash>.css` and the result is
374    /// `"/css/site.<hash>.css"`, ready for [`stylesheet`](crate::elements::stylesheet) or
375    /// [`script_src`](crate::elements::script_src). The same bytes always give the same name and
376    /// different bytes a different one, so a cache can keep the file for a year
377    /// (`Cache-Control: public, max-age=31536000, immutable`) and a new build still reaches
378    /// every visitor. `dir` of `""` or `"."` is the output directory itself. The copy is atomic,
379    /// like [`copy_asset`](Self::copy_asset), and writes the bytes that were hashed, so a file
380    /// that changes mid-build cannot get the wrong name.
381    ///
382    /// The hash is 64-bit FNV-1a, written out in full as 16 hex digits. It names content for a
383    /// cache and is **not** a security measure: it is trivially forged. For a hash a browser
384    /// checks, see [`Element::csp_hash`](crate::core::Element::csp_hash).
385    ///
386    /// # What it does not do
387    ///
388    /// It renames one file and rewrites nothing. A module that imports another by relative
389    /// path (`import './motion.js'`) keeps asking for the old name, and so does a `url(...)` in
390    /// a stylesheet. Either concatenate those scripts into one file before copying it, or copy
391    /// the files they refer to first and write their returned URLs into the file you then copy.
392    /// An earlier version of the file stays in the directory until [`clean`](Self::clean).
393    /// The file name should be URL-safe, because it is returned as it is.
394    ///
395    /// # Errors
396    /// Returns an error if `from` cannot be read, its name is not valid UTF-8, or `dir` is
397    /// unsafe (see [`copy_asset`](Self::copy_asset)).
398    ///
399    /// # Examples
400    /// ```
401    /// use molt::prelude::*;
402    /// use molt::ssg::StaticSiteGenerator;
403    ///
404    /// let base = std::env::temp_dir().join(format!("molt-hashed-{}", std::process::id()));
405    /// std::fs::create_dir_all(&base)?;
406    /// std::fs::write(base.join("site.css"), "body{color:red}")?;
407    ///
408    /// let site = StaticSiteGenerator::new(base.join("dist"));
409    /// let url = site.copy_asset_hashed(base.join("site.css"), "css")?;
410    /// assert_eq!(url, "/css/site.c0e0533c847ec633.css");
411    /// assert_eq!(
412    ///     stylesheet(&url).render(),
413    ///     r#"<link href="/css/site.c0e0533c847ec633.css" rel="stylesheet">"#
414    /// );
415    /// assert!(base.join("dist/css/site.c0e0533c847ec633.css").is_file());
416    /// # std::fs::remove_dir_all(&base)?;
417    /// # Ok::<(), Box<dyn std::error::Error>>(())
418    /// ```
419    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        // A leading dot is part of the stem (`.htaccess`), not an extension.
430        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        // Splitting drops the empty segment a leading slash leaves, which would turn `/etc`
437        // into the relative `etc`; a root is refused by `resolve`, so ask it first.
438        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    /// Removes the output directory, optionally recreating it empty.
453    ///
454    /// Only a directory Molt can show is its own is emptied: one that does not exist, is
455    /// empty, or holds the [`OUTPUT_MARKER`] file that writing a page or an asset leaves
456    /// there. A relative path such as `"dist"` is resolved against the **current directory of
457    /// the process**, so the same code run from another project would otherwise empty that
458    /// project's `dist`.
459    ///
460    /// # Errors
461    /// Returns an error if the output directory is unsafe to delete — empty, a filesystem
462    /// root, a top-level directory, the home directory, the working directory or one of its
463    /// ancestors, or containing a `..` component, because removing it recursively would delete
464    /// whatever it points at. It also refuses a symbolic link, and a directory that has
465    /// contents but no [`OUTPUT_MARKER`]; for one made before the marker existed, remove it by
466    /// hand once or create the file.
467    ///
468    /// # Examples
469    /// ```
470    /// use molt::ssg::StaticSiteGenerator;
471    ///
472    /// let base = std::env::temp_dir().join(format!("molt-clean-{}", std::process::id()));
473    /// let site = StaticSiteGenerator::new(base.join("dist"));
474    /// site.write_file("<p>old</p>", "old.html")?; // leaves the marker behind
475    /// site.clean(true)?;
476    /// assert!(!base.join("dist/old.html").exists());
477    ///
478    /// // A directory the generator did not make is left alone.
479    /// std::fs::write(base.join("dist/keep.txt"), "mine")?;
480    /// std::fs::remove_file(base.join("dist").join(molt::ssg::OUTPUT_MARKER))?;
481    /// assert!(site.clean(true).is_err());
482    /// assert!(base.join("dist/keep.txt").exists());
483    /// # std::fs::remove_dir_all(&base)?;
484    /// # Ok::<(), Box<dyn std::error::Error>>(())
485    /// ```
486    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    /// Leaves [`OUTPUT_MARKER`] in the output directory, which must exist. An existing marker
502    /// is kept as it is.
503    fn mark_output(&self) -> Result<()> {
504        let marker = self.output_directory.join(OUTPUT_MARKER);
505        // `create_new` refuses an existing path, a planted symlink included.
506        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    /// Writes UTF-8 `content` to `path`, creating parent directories.
520    ///
521    /// The write is atomic: content goes to a temporary file in the same directory and is
522    /// then renamed, so a reader never sees a half-written page.
523    ///
524    /// # Errors
525    /// Returns an error if the path escapes the output directory or the write fails.
526    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    /// Writes through a temporary file in the destination directory, then renames.
531    ///
532    /// The closure receives the open temporary file. On any error the temporary file is
533    /// removed, so a reader never observes a half-written page under the final name.
534    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        // `create_new` refuses an existing path, symlinks included, so the temporary file
547        // can never be a pre-planted link that redirects the write.
548        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    /// Joins `path` onto the output directory, rejecting anything that escapes it.
565    ///
566    /// Only plain names are accepted: `..`, a root and a Windows drive prefix (`C:foo`
567    /// replaces the base on `join`) are refused, and so is a path with no file name
568    /// (`""`, `"."`, `"a/"`), which would name the output directory itself. A component
569    /// that already exists as a symlink is refused too, because writing through it lands
570    /// wherever the link points.
571    ///
572    /// The symlink check is not atomic with the write: someone who can create links inside
573    /// the output directory *while it is being generated* can still race it.
574    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                // Absent components cannot be links; only existing ones are inspected.
600                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
609/// 64-bit FNV-1a. Names content for a cache; it is not a security hash.
610fn 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
616/// A sibling of `destination` that no other writer will pick.
617///
618/// `with_extension` was the wrong tool: it *replaces* the extension, so `index.html` and
619/// `index.xml` both became `index.tmp` and two pages written in parallel shared one
620/// file. The full file name, the process id and a counter make the name unique.
621fn 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
630/// Rejects output directories that are dangerous to delete recursively.
631///
632/// Beyond the empty path, a filesystem root and `..`, this refuses the directory the
633/// process is running in or any ancestor of it (`"."` has a non-empty parent, which is how
634/// the old check let `clean(".")` through), the home directory, and anything fewer than two
635/// levels below the root (`/usr`, `/home`). The guard only reads the filesystem; it never
636/// removes anything, so it can be tested directly.
637fn 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
677/// Refuses to empty a directory that Molt cannot show is its own: a symbolic link, or one that
678/// has contents but no [`OUTPUT_MARKER`]. A missing or empty directory is fine, since there is
679/// nothing to lose. Like [`guard_destructive_path`] it only reads the filesystem, so it can be
680/// tested directly, without changing the working directory.
681fn 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
709/// `path` made absolute and, when it exists, with symlinks resolved.
710fn 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
727/// Turns per-page results into one error carrying every failure.
728fn 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    /// A throwaway directory that removes itself. Avoids a dev-dependency for six tests.
747    struct TempDir(PathBuf);
748
749    impl TempDir {
750        fn new(name: &str) -> Self {
751            // The process id keeps two test runs (or two checkouts) off the same directory.
752            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    /// Whether `dir` holds exactly these entries, i.e. no temporary file was left behind. The
766    /// marker is not one of them: it is meant to be there.
767    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    /// A generated file is the streamed render, replaces an existing one, and reports failures.
796    #[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        // Same bytes, same name; different bytes, a different one; the old file stays.
873        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    /// The report behind #115: `"dist"` is resolved against the current directory of the
955    /// process, so code run from another project pointed at that project's `dist`. A directory
956    /// with contents and no marker is not Molt's to empty, however the path was spelled.
957    #[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    /// `clean` refuses a directory that would be dangerous to remove.
1051    #[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            // `C:\x.html` is an ordinary file name on Unix, so it is only rejected on Windows.
1077            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        // Nothing was created beside the output directory by `with_extension`.
1084        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    /// `with_extension` made `index.html` and `index.xml` share `index.tmp`.
1114    #[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    /// Builds `static/` with a nested file, a junk file and a hidden directory.
1162    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    /// `Path::new(".").parent()` is `Some("")`, so the old guard let `clean(".")` through.
1279    #[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        // A directory of its own, even one that does not exist yet, is fine.
1295        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        // The failures are data, not only text.
1332        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        // A directory sits where the page should go, so the rename fails.
1366        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}