quillmark 0.108.2

A schema-driven document engine: turn Markdown into a fully typeset document with a Quill
Documentation
//! Filesystem loading for quills, kept here so fs access stays out of the
//! fs-agnostic core.

use std::collections::HashMap;
use std::error::Error as StdError;
use std::path::{Path, PathBuf};

use quillmark_core::{Diagnostic, FileTreeNode, Quill, QuillIgnore, RenderError, Severity};

/// Load a quill from a filesystem directory. Honours a root `.quillignore`,
/// else a default ignore set.
///
/// A pure config load: the declared backend is resolved later, at render time.
/// For an in-memory tree, call [`Quill::from_tree`].
pub fn quill_from_path<P: AsRef<Path>>(path: P) -> Result<Quill, RenderError> {
    quill_from_path_with_warnings(path).map(|(quill, _)| quill)
}

/// [`quill_from_path`], keeping the config's advisory diagnostics rather than
/// dropping them.
pub fn quill_from_path_with_warnings<P: AsRef<Path>>(
    path: P,
) -> Result<(Quill, Vec<Diagnostic>), RenderError> {
    let tree = load_tree_from_path(path.as_ref()).map_err(|e| {
        RenderError::from_diag(
            Diagnostic::new(Severity::Error, format!("Failed to load quill: {}", e))
                .with_code("quill::load_failed".to_string()),
        )
    })?;
    Quill::from_tree_with_warnings(tree).map_err(RenderError::new)
}

/// Walk a filesystem path into an in-memory [`FileTreeNode`], for a caller that
/// wants to edit the tree before [`Quill::from_tree`] reads it. Honours a root
/// `.quillignore`, else a default ignore set (`.git/`, `target/`, …).
pub fn tree_from_path<P: AsRef<Path>>(
    path: P,
) -> Result<FileTreeNode, Box<dyn StdError + Send + Sync>> {
    load_tree_from_path(path.as_ref())
}

fn load_tree_from_path(path: &Path) -> Result<FileTreeNode, Box<dyn StdError + Send + Sync>> {
    use std::fs;

    let quillignore_path = path.join(".quillignore");
    let ignore = if quillignore_path.exists() {
        let content = fs::read_to_string(&quillignore_path)
            .map_err(|e| format!("Failed to read .quillignore: {}", e))?;
        QuillIgnore::from_content(&content)
    } else {
        QuillIgnore::default()
    };

    load_dir(path, path, &ignore)
}

/// Bounds one oversize file from an untrusted bundle; neither file count nor
/// aggregate bytes is capped.
const MAX_QUILL_FILE_SIZE: u64 = 50 * 1024 * 1024;

fn load_dir(
    current_dir: &Path,
    base_dir: &Path,
    ignore: &QuillIgnore,
) -> Result<FileTreeNode, Box<dyn StdError + Send + Sync>> {
    use std::fs;

    if !current_dir.exists() {
        return Ok(FileTreeNode::Directory {
            files: HashMap::new(),
        });
    }

    let mut files = HashMap::new();
    for entry in fs::read_dir(current_dir)? {
        let entry = entry?;
        let path = entry.path();
        let relative_path: PathBuf = path
            .strip_prefix(base_dir)
            .map_err(|e| format!("Failed to get relative path: {}", e))?
            .to_path_buf();

        if ignore.is_ignored(&relative_path) {
            continue;
        }

        let filename = path
            .file_name()
            .and_then(|n| n.to_str())
            .ok_or_else(|| format!("Invalid filename: {}", path.display()))?
            .to_string();

        // symlink_metadata, so a crafted bundle cannot point a symlink at a
        // sensitive host file and pull it into the tree the backend reads.
        let meta = std::fs::symlink_metadata(&path)
            .map_err(|e| format!("Failed to stat '{}': {}", path.display(), e))?;
        let file_type = meta.file_type();

        if file_type.is_symlink() {
            continue;
        } else if file_type.is_file() {
            if meta.len() > MAX_QUILL_FILE_SIZE {
                return Err(format!(
                    "File '{}' exceeds the {} MiB quill file-size limit",
                    path.display(),
                    MAX_QUILL_FILE_SIZE / (1024 * 1024)
                )
                .into());
            }
            let contents = fs::read(&path)
                .map_err(|e| format!("Failed to read file '{}': {}", path.display(), e))?;
            files.insert(filename, FileTreeNode::File { contents });
        } else if file_type.is_dir() {
            let subdir_tree = load_dir(&path, base_dir, ignore)?;
            files.insert(filename, subdir_tree);
        }
    }

    Ok(FileTreeNode::Directory { files })
}

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

    #[test]
    #[cfg(unix)]
    fn load_dir_skips_symlinks() {
        use std::io::Write;

        let dir = tempfile::tempdir().unwrap();
        let root = dir.path();

        let mut f = std::fs::File::create(root.join("real.txt")).unwrap();
        f.write_all(b"ok").unwrap();

        let secret = dir.path().join("secret.txt");
        std::fs::write(&secret, b"TOPSECRET").unwrap();
        std::os::unix::fs::symlink(&secret, root.join("leak.txt")).unwrap();

        let tree = load_tree_from_path(root).unwrap();
        assert!(tree.get_file("leak.txt").is_none());
        assert_eq!(tree.get_file("real.txt"), Some(&b"ok"[..]));
    }
}