libmandoc-rs 0.10.0

Safe Rust interface to the vendored libmandoc parser and reference renderers
Documentation
//! Approved filesystem roots and source-tree include policy.

use super::*;

#[cfg(unix)]
#[test]
fn zstd_sources_keep_their_original_include_root() {
    let root = std::env::temp_dir().join(format!(
        "mant-zstd-include-mandoc-session-{}",
        process::id()
    ));
    let man1 = root.join("man1");
    fs::create_dir_all(&man1).expect("create temporary manual tree");
    let target = man1.join("target.1");
    fs::write(
        &target,
        ".TH ZSTD-INCLUDE 1\n.SH NAME\nzstd-include \\- included manual\n",
    )
    .expect("write included manual");
    let alias = man1.join("alias.1.zst");
    let compressed =
        zstd::stream::encode_all(b".so man1/target.1\n".as_slice(), 1).expect("compress alias");
    fs::write(&alias, compressed).expect("write compressed alias");

    let document = parse_file(&alias, true).expect("resolve include from zstd source");
    fs::remove_dir_all(root).expect("remove temporary manual tree");

    assert_eq!(document.macro_set, MacroSet::Man);
    assert_eq!(document.metadata.title.as_deref(), Some("ZSTD-INCLUDE"));
    assert!(document.metadata.has_body);
}

#[cfg(unix)]
#[test]
fn concurrent_source_tree_includes_keep_each_root_isolated() {
    const WORKERS: usize = 8;

    let root = std::env::temp_dir().join(format!(
        "libmandoc-rs-thread-local-includes-{}",
        process::id()
    ));
    let aliases: Vec<_> = (0..WORKERS)
        .map(|worker| {
            let tree = root.join(format!("tree-{worker}")).join("man1");
            fs::create_dir_all(&tree).expect("create isolated manual tree");
            fs::write(
                tree.join("target.1"),
                format!(
                    ".Dd August 19, 2026\n.Dt TLS-INCLUDE-{worker} 1\n.Os\n.Sh NAME\n.Nm tls-include-{worker}\n.Nd isolated include tree\n"
                ),
            )
            .expect("write included manual source");
            let alias = tree.join("alias.1");
            fs::write(&alias, ".so target.1\n").expect("write manual redirect");
            alias
        })
        .collect();

    let start = Arc::new(Barrier::new(WORKERS));
    let workers: Vec<_> = aliases
        .into_iter()
        .enumerate()
        .map(|(worker, alias)| {
            let start = Arc::clone(&start);
            std::thread::spawn(move || {
                start.wait();
                let document =
                    parse_file(&alias, true).expect("concurrent source-tree include must succeed");
                assert_eq!(
                    document.metadata.title.as_deref(),
                    Some(format!("TLS-INCLUDE-{worker}").as_str())
                );
            })
        })
        .collect();
    for worker in workers {
        worker.join().expect("include worker must not panic");
    }
    fs::remove_dir_all(root).expect("remove isolated manual trees");
}

#[cfg(unix)]
#[test]
fn source_relative_includes_do_not_change_process_cwd() {
    let root =
        std::env::temp_dir().join(format!("libmandoc-rs-relative-include-{}", process::id()));
    fs::create_dir_all(&root).expect("create temporary manual tree");
    let target = root.join("minimal-mdoc.1");
    fs::write(
        &target,
        ".Dd July 19, 2026\n.Dt INCLUDE-FIXTURE 1\n.Os\n.Sh NAME\ninclude-fixture\n",
    )
    .expect("write included source");
    let alias = root.join("alias-mdoc.1");
    fs::write(&alias, ".so minimal-mdoc.1\n").expect("write alias source");
    let cwd = std::env::current_dir().expect("current directory before parse");

    let document = parse_file(&alias, true).expect("resolve source-relative include");
    fs::remove_dir_all(root).expect("remove temporary manual tree");

    assert_eq!(document.macro_set, MacroSet::Mdoc);
    assert_eq!(document.metadata.title.as_deref(), Some("INCLUDE-FIXTURE"));
    assert_eq!(
        std::env::current_dir().expect("current directory after parse"),
        cwd
    );
}

#[test]
fn parser_only_expands_includes_when_policy_allows_a_root() {
    let base = std::env::temp_dir().join(format!(
        "libmandoc-rs-explicit-include-root-{}",
        process::id()
    ));
    let includes = base.join("includes");
    fs::create_dir_all(&includes).expect("create explicit include root");
    fs::write(
        includes.join("target.1"),
        ".TH EXPLICIT-ROOT 1\n.SH NAME\nexplicit-root \\- include fixture\n",
    )
    .expect("write included source");
    let alias = base.join("alias.1");
    fs::write(&alias, ".so target.1\n").expect("write alias source");

    let denied = Parser::default()
        .parse_file(&alias)
        .expect("parse alias without include expansion");
    let expanded = Parser::new(ParseOptions {
        includes: IncludePolicy::Root(includes),
        compression: Compression::Auto,
    })
    .parse_file(&alias)
    .expect("resolve alias against explicit root");
    fs::remove_dir_all(base).expect("remove temporary manual tree");

    assert_ne!(
        denied.document.metadata.title.as_deref(),
        Some("EXPLICIT-ROOT")
    );
    assert_eq!(
        expanded.document.metadata.title.as_deref(),
        Some("EXPLICIT-ROOT")
    );
}

#[test]
fn explicit_root_resolves_compressed_includes_beside_the_source() {
    use std::io::Write;

    use flate2::{Compression as GzipCompression, write::GzEncoder};

    let root = std::env::temp_dir().join(format!(
        "libmandoc-rs-compressed-relative-include-{}",
        process::id()
    ));
    let man1 = root.join("man1");
    fs::create_dir_all(&man1).expect("create explicit manual section");
    let mut target = GzEncoder::new(Vec::new(), GzipCompression::fast());
    target
        .write_all(b".SH INCLUDED\ncompressed relative content\n")
        .expect("compress included source");
    fs::write(
        man1.join("target.1.gz"),
        target.finish().expect("finish included source"),
    )
    .expect("write compressed included source");
    let mut explicit = GzEncoder::new(Vec::new(), GzipCompression::fast());
    explicit
        .write_all(b".SH EXPLICIT\nexplicit compressed content\n")
        .expect("compress explicitly named include");
    fs::write(
        man1.join("explicit.1.gz"),
        explicit.finish().expect("finish explicit include"),
    )
    .expect("write explicitly named compressed include");
    let source = man1.join("source.1.gz");
    let mut source_bytes = GzEncoder::new(Vec::new(), GzipCompression::fast());
    source_bytes
        .write_all(
            b".TH SOURCE 1\n.SH NAME\nsource \\- include fixture\n.so target.1\n.so explicit.1.gz\n",
        )
        .expect("compress source manual");
    fs::write(
        &source,
        source_bytes.finish().expect("finish source manual"),
    )
    .expect("write source manual");

    let report = Parser::new(ParseOptions {
        includes: IncludePolicy::Root(root.clone()),
        compression: Compression::Auto,
    })
    .parse_file(&source)
    .expect("resolve compressed include beside source");
    fs::remove_dir_all(root).expect("remove temporary manual tree");

    let mut visible = Vec::new();
    collect_visible_text(&report.document.root, &mut visible);
    assert!(visible.contains(&"compressed relative content"));
    assert!(visible.contains(&"explicit compressed content"));
    assert!(
        report
            .diagnostics
            .iter()
            .all(|diagnostic| { !diagnostic.message.contains(".so request failed") })
    );
}

#[test]
fn explicit_include_root_does_not_fall_back_to_process_cwd() {
    let identifier = format!("libmandoc-rs-ambient-{}", process::id());
    let cwd_target = std::env::current_dir()
        .expect("read test cwd")
        .join(format!("{identifier}.1"));
    fs::write(
        &cwd_target,
        ".TH AMBIENT 1\n.SH NAME\nambient \\- must not be included\n",
    )
    .expect("write ambient source");

    let base = std::env::temp_dir().join(format!("{identifier}-root"));
    fs::create_dir_all(&base).expect("create empty include root");
    let alias = base.join("alias.1");
    fs::write(&alias, format!(".so {identifier}.1\n")).expect("write alias source");

    let result = Parser::new(ParseOptions {
        includes: IncludePolicy::Root(base.clone()),
        compression: Compression::Auto,
    })
    .parse_file(&alias);
    fs::remove_file(cwd_target).expect("remove ambient source");
    fs::remove_dir_all(base).expect("remove temporary manual tree");

    match result {
        Ok(report) => assert_ne!(report.document.metadata.title.as_deref(), Some("AMBIENT")),
        Err(error) => assert_eq!(error.kind, crate::ParseErrorKind::Parse),
    }
}

#[cfg(unix)]
#[test]
fn explicit_include_root_rejects_linked_target_files() {
    use std::os::unix::fs::symlink;

    let base = std::env::temp_dir().join(format!(
        "libmandoc-rs-linked-include-target-{}",
        process::id()
    ));
    let includes = base.join("includes");
    fs::create_dir_all(&includes).expect("create explicit include root");
    let outside = base.join("outside.1");
    fs::write(
        &outside,
        ".TH OUTSIDE 1\n.SH NAME\noutside \\- must not be included\n",
    )
    .expect("write outside target");
    symlink(&outside, includes.join("target.1")).expect("link target outside root");
    let alias = base.join("alias.1");
    fs::write(&alias, ".so target.1\n").expect("write alias source");

    let result = Parser::new(ParseOptions {
        includes: IncludePolicy::Root(includes),
        compression: Compression::Auto,
    })
    .parse_file(&alias);
    fs::remove_dir_all(base).expect("remove temporary manual tree");

    match result {
        Ok(report) => assert_ne!(report.document.metadata.title.as_deref(), Some("OUTSIDE")),
        Err(error) => assert_eq!(error.kind, crate::ParseErrorKind::Parse),
    }
}

#[cfg(unix)]
#[test]
fn explicit_include_root_rejects_linked_intermediate_directories() {
    use std::os::unix::fs::symlink;

    let base = std::env::temp_dir().join(format!(
        "libmandoc-rs-linked-include-directory-{}",
        process::id()
    ));
    let includes = base.join("includes");
    let outside = base.join("outside");
    fs::create_dir_all(&includes).expect("create explicit include root");
    fs::create_dir_all(&outside).expect("create outside directory");
    fs::write(
        outside.join("target.1"),
        ".TH OUTSIDE-DIR 1\n.SH NAME\noutside-dir \\- must not be included\n",
    )
    .expect("write outside target");
    fs::write(outside.join("alias.1"), ".so target.1\n").expect("write alias source");
    symlink(&outside, includes.join("linked")).expect("link directory outside root");
    let alias = includes.join("linked/alias.1");

    let result = Parser::new(ParseOptions {
        includes: IncludePolicy::Root(includes),
        compression: Compression::Auto,
    })
    .parse_file(&alias);
    fs::remove_dir_all(base).expect("remove temporary manual tree");

    match result {
        Ok(report) => assert_ne!(
            report.document.metadata.title.as_deref(),
            Some("OUTSIDE-DIR")
        ),
        Err(error) => assert_eq!(error.kind, crate::ParseErrorKind::Parse),
    }
}