mini-docs 0.4.0

A minimal, secure build-time Markdown to HTML generator for the mini-* family.
Documentation
use std::path::{Component, Path, PathBuf};

use crate::error::DocError;

/// Confirms `relative` cannot escape `output_dir` before it is joined and written.
///
/// `relative` is expected to come from stripping `input_dir` off a path produced by
/// [`crate::walk`], which — because real directory entries can never be named `..`
/// and `PathBuf::join` never crosses a real filesystem boundary — cannot currently
/// contain a `ParentDir` or absolute component. Trusting "produced by our own walk"
/// as sufficient is exactly the kind of ambient assumption this function exists to
/// remove: it is the one place `output_dir.join(relative)` happens, so it is the one
/// place that must not assume its input is well-formed.
pub(crate) fn guard_output_path(output_dir: &Path, relative: &Path) -> Result<PathBuf, DocError> {
    for component in relative.components() {
        match component {
            Component::ParentDir | Component::RootDir | Component::Prefix(_) => {
                return Err(DocError::Escape(relative.display().to_string()));
            }
            Component::CurDir | Component::Normal(_) => {}
        }
    }

    let joined = output_dir.join(relative);
    if !joined.starts_with(output_dir) {
        return Err(DocError::Escape(relative.display().to_string()));
    }

    Ok(joined)
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn rejects_parent_dir_traversal() {
        let output_dir = Path::new("/srv/mini-docs-output");
        let malicious = Path::new("../../evil.html");

        let err = guard_output_path(output_dir, malicious).expect_err("traversal must be rejected");

        assert!(matches!(err, DocError::Escape(_)));
    }

    #[test]
    fn rejects_traversal_in_middle_of_path() {
        let output_dir = Path::new("/srv/mini-docs-output");
        let malicious = Path::new("guide/../../evil.html");

        let err = guard_output_path(output_dir, malicious)
            .expect_err("mid-path traversal must be rejected");

        assert!(matches!(err, DocError::Escape(_)));
    }

    #[test]
    fn rejects_absolute_relative_path() {
        let output_dir = Path::new("/srv/mini-docs-output");
        let malicious = Path::new("/etc/evil.html");

        let err =
            guard_output_path(output_dir, malicious).expect_err("absolute path must be rejected");

        assert!(matches!(err, DocError::Escape(_)));
    }

    #[test]
    fn accepts_ordinary_nested_relative_path() {
        let output_dir = Path::new("/srv/mini-docs-output");
        let ok_path = Path::new("guide/setup.html");

        let joined = guard_output_path(output_dir, ok_path).expect("ordinary path is fine");

        assert_eq!(joined, output_dir.join("guide/setup.html"));
    }
}