use std::ffi::{OsStr, OsString};
use std::io::{Read, Write};
use std::path::{Component, Path, PathBuf};
use cap_std::ambient_authority;
use cap_std::fs::{Dir, OpenOptions};
use globset::{Glob, GlobSet, GlobSetBuilder};
use serde::{Deserialize, Serialize};
use crate::domain::common::{CorpusConfig, expand_tilde};
use crate::domain::corpus::scan;
#[derive(Debug)]
pub struct SandboxError(String);
impl SandboxError {
pub fn new(msg: impl Into<String>) -> Self {
Self(msg.into())
}
pub fn into_inner(self) -> String {
self.0
}
pub fn as_str(&self) -> &str {
&self.0
}
}
impl std::fmt::Display for SandboxError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
impl std::error::Error for SandboxError {}
impl From<SandboxError> for String {
fn from(err: SandboxError) -> Self {
err.0
}
}
pub fn pick_corpus(
corpora: &[CorpusConfig],
requested: Option<&str>,
) -> Result<CorpusConfig, SandboxError> {
if let Some(name) = requested {
return corpora
.iter()
.find(|c| c.name == name)
.cloned()
.ok_or_else(|| SandboxError::new(format!("corpus {name:?} not found in config")));
}
match corpora {
[] => Err(SandboxError::new(
"no corpora configured; add [[corpus]] or [[repository]] to config",
)),
[only] => Ok(only.clone()),
_ => Err(SandboxError::new(
"corpus required when multiple corpora configured; pass corpus",
)),
}
}
pub fn first_corpus_root(corpus: &CorpusConfig) -> Result<PathBuf, SandboxError> {
let raw = corpus
.paths
.first()
.ok_or_else(|| SandboxError::new(format!("corpus {:?} has no paths", corpus.name)))?;
Ok(expand_tilde(raw))
}
pub fn safe_relative_path(raw: &str) -> Result<PathBuf, SandboxError> {
let path = Path::new(raw);
if path.as_os_str().is_empty() || path.is_absolute() {
return Err(SandboxError::new("path must be a non-empty relative path"));
}
if raw.contains('\0') {
return Err(SandboxError::new("path contains a NUL byte"));
}
if raw.ends_with('/') || raw == "." || raw.ends_with("/.") {
return Err(SandboxError::new("path must name a file"));
}
if raw.starts_with("./") || raw.contains("/./") {
return Err(SandboxError::new(
"path must contain only normal file components",
));
}
if !matches!(path.components().next_back(), Some(Component::Normal(_))) {
return Err(SandboxError::new("path must name a file"));
}
if path
.components()
.any(|c| !matches!(c, Component::Normal(_)))
{
return Err(SandboxError::new(
"path must contain only normal file components",
));
}
Ok(path.to_path_buf())
}
pub fn ensure_corpus_allows_file(corpus: &CorpusConfig, path: &Path) -> Result<(), SandboxError> {
let include = build_globset(&corpus.globs).map_err(|e| SandboxError::new(e.to_string()))?;
if matches!(include.as_ref(), Some(inc) if !inc.is_match(path)) {
return Err(SandboxError::new("path is not included by corpus globs"));
}
let exclude = build_globset(&corpus.exclude).map_err(|e| SandboxError::new(e.to_string()))?;
if matches!(exclude.as_ref(), Some(ex) if ex.is_match(path)) {
return Err(SandboxError::new("path is excluded by corpus rules"));
}
Ok(())
}
pub fn build_globset(patterns: &[String]) -> anyhow::Result<Option<GlobSet>> {
if patterns.is_empty() {
return Ok(None);
}
let mut builder = GlobSetBuilder::new();
for pattern in patterns {
let glob = Glob::new(pattern)?;
builder.add(glob);
}
Ok(Some(builder.build()?))
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct FileEntry {
pub path: String,
pub absolute_path: String,
}
pub fn list_corpus_files(corpus: &CorpusConfig) -> anyhow::Result<Vec<FileEntry>> {
let roots: Vec<PathBuf> = corpus
.paths
.iter()
.map(|p| {
let expanded = expand_tilde(p);
canonicalize_or_log(expanded)
})
.collect();
let mut entries: Vec<FileEntry> = scan(corpus)
.map_err(anyhow::Error::from)?
.into_iter()
.map(|(file, _)| {
let absolute = file.into_path_buf();
let relative = roots
.iter()
.find_map(|r| absolute.strip_prefix(r).ok())
.unwrap_or(absolute.as_path());
FileEntry {
path: relative.to_string_lossy().into_owned(),
absolute_path: absolute.to_string_lossy().into_owned(),
}
})
.collect();
entries.sort_by(|a, b| a.path.cmp(&b.path));
Ok(entries)
}
fn canonicalize_or_log(expanded: PathBuf) -> PathBuf {
match std::fs::canonicalize(&expanded) {
Ok(canonical) => canonical,
Err(e) => {
tracing::debug!(
target: "hallouminate::corpus",
path = %expanded.display(),
error = %e,
"canonicalize failed; using non-canonical path (corpus root may not exist yet)"
);
expanded
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct TreeNode {
pub path: String,
pub absolute_path: String,
pub files: Vec<FileEntry>,
pub subdirs: Vec<TreeNode>,
}
pub fn build_corpus_tree(corpus: &CorpusConfig) -> anyhow::Result<TreeNode> {
let raw_root = corpus
.paths
.first()
.ok_or_else(|| anyhow::anyhow!("corpus {:?} has no paths", corpus.name))?;
let expanded = expand_tilde(raw_root);
let root_abs = canonicalize_or_log(expanded);
let files = list_corpus_files(corpus)?;
let mut node = TreeNode {
path: String::new(),
absolute_path: root_abs.to_string_lossy().into_owned(),
files: Vec::new(),
subdirs: Vec::new(),
};
for entry in files {
let Ok(rel_path) = Path::new(&entry.absolute_path).strip_prefix(&root_abs) else {
continue;
};
let rel_path = rel_path.to_path_buf();
insert_into_tree(&mut node, &rel_path, entry, &root_abs);
}
sort_tree(&mut node);
Ok(node)
}
fn insert_into_tree(node: &mut TreeNode, rel: &Path, entry: FileEntry, root_abs: &Path) {
let components: Vec<&OsStr> = rel
.components()
.filter_map(|c| match c {
Component::Normal(s) => Some(s),
_ => None,
})
.collect();
if components.is_empty() {
return;
}
if components.len() == 1 {
node.files.push(entry);
return;
}
let head = components[0];
let head_str = head.to_string_lossy().into_owned();
let child = match node.subdirs.iter().position(|n| {
Path::new(&n.path)
.components()
.next_back()
.map(|c| matches!(c, Component::Normal(s) if s == head))
.unwrap_or(false)
}) {
Some(idx) => &mut node.subdirs[idx],
None => {
let child_rel = if node.path.is_empty() {
head_str.clone()
} else {
format!("{}/{}", node.path, head_str)
};
let child_abs = root_abs.join(&child_rel);
node.subdirs.push(TreeNode {
path: child_rel,
absolute_path: child_abs.to_string_lossy().into_owned(),
files: Vec::new(),
subdirs: Vec::new(),
});
node.subdirs.last_mut().expect("just pushed")
}
};
let tail: PathBuf = components[1..].iter().collect();
insert_into_tree(child, &tail, entry, root_abs);
}
fn sort_tree(node: &mut TreeNode) {
node.files.sort_by(|a, b| a.path.cmp(&b.path));
node.subdirs.sort_by(|a, b| a.path.cmp(&b.path));
for child in &mut node.subdirs {
sort_tree(child);
}
}
#[derive(Debug)]
pub enum WriteErrorKind {
Exists,
Symlink,
InvalidPath,
Io,
}
#[derive(Debug)]
pub struct WriteError {
pub kind: WriteErrorKind,
pub source: std::io::Error,
}
impl WriteError {
pub fn new(kind: WriteErrorKind, source: std::io::Error) -> Self {
Self { kind, source }
}
}
pub fn atomic_write_no_follow(
root: &Path,
relative: &Path,
content: &[u8],
overwrite: bool,
) -> Result<PathBuf, WriteError> {
std::fs::create_dir_all(root).map_err(|e| WriteError::new(WriteErrorKind::Io, e))?;
let names = normal_components(relative)?;
let file_name = names
.last()
.ok_or_else(|| invalid_path_error("path must name a file"))?;
let parent = open_parent_dir(root, &names[..names.len() - 1])?;
if overwrite {
atomic_replace(&parent, file_name.as_os_str(), content)?;
} else {
write_new_file(&parent, file_name.as_os_str(), content)?;
}
Ok(root.join(relative))
}
pub fn read_no_follow(root: &Path, relative: &Path) -> Result<Vec<u8>, WriteError> {
let names = normal_components(relative)?;
let file_name = names
.last()
.ok_or_else(|| invalid_path_error("path must name a file"))?;
let parent = open_parent_dir(root, &names[..names.len() - 1])?;
reject_symlink_leaf(&parent, file_name.as_os_str())?;
let cap_file = parent
.open(file_name.as_os_str())
.map_err(classify_path_io_error)?;
let mut file = cap_file.into_std();
let mut buf = Vec::new();
file.read_to_end(&mut buf)
.map_err(|e| WriteError::new(WriteErrorKind::Io, e))?;
Ok(buf)
}
pub fn resolve_read_root(corpus: &CorpusConfig, relative: &Path) -> Result<PathBuf, WriteError> {
let names = normal_components(relative)?;
let (dirs, file) = names.split_at(names.len() - 1);
let file_name = file
.first()
.ok_or_else(|| invalid_path_error("path must name a file"))?;
let mut last_err: Option<WriteError> = None;
for raw in &corpus.paths {
let root = expand_tilde(raw);
match probe_leaf_exists(&root, dirs, file_name.as_os_str()) {
Ok(true) => return Ok(root),
Ok(false) => last_err = Some(not_found_error()),
Err(e) if matches!(e.kind, WriteErrorKind::Symlink) => return Err(e),
Err(e) => last_err = Some(e),
}
}
Err(last_err.unwrap_or_else(not_found_error))
}
fn probe_leaf_exists(
root: &Path,
dirs: &[OsString],
file_name: &OsStr,
) -> Result<bool, WriteError> {
let mut current = match Dir::open_ambient_dir(root, ambient_authority()) {
Ok(d) => d,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(false),
Err(e) => return Err(WriteError::new(WriteErrorKind::Io, e)),
};
for dir in dirs {
match current.symlink_metadata(dir.as_os_str()) {
Ok(meta) => {
if meta.file_type().is_symlink() {
return Err(WriteError::new(WriteErrorKind::Symlink, symlink_io_error()));
}
if !meta.is_dir() {
return Ok(false);
}
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(false),
Err(e) => return Err(classify_path_io_error(e)),
}
current = match current.open_dir(dir.as_os_str()) {
Ok(d) => d,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(false),
Err(e) => return Err(classify_path_io_error(e)),
};
}
match current.symlink_metadata(file_name) {
Ok(meta) => {
let ft = meta.file_type();
if ft.is_symlink() {
Err(WriteError::new(WriteErrorKind::Symlink, symlink_io_error()))
} else {
Ok(ft.is_file())
}
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(false),
Err(e) => Err(classify_path_io_error(e)),
}
}
fn not_found_error() -> WriteError {
WriteError::new(
WriteErrorKind::Io,
std::io::Error::from(std::io::ErrorKind::NotFound),
)
}
pub fn delete_no_follow(root: &Path, relative: &Path) -> Result<(), WriteError> {
let names = normal_components(relative)?;
let file_name = names
.last()
.ok_or_else(|| invalid_path_error("path must name a file"))?;
let parent = open_parent_dir(root, &names[..names.len() - 1])?;
let meta = parent
.symlink_metadata(file_name.as_os_str())
.map_err(classify_create_io_error)?;
let ft = meta.file_type();
if ft.is_symlink() {
return Err(WriteError::new(WriteErrorKind::Symlink, symlink_io_error()));
}
if !ft.is_file() {
return Err(invalid_path_error("target is not a regular file"));
}
parent
.remove_file(file_name.as_os_str())
.map_err(|e| WriteError::new(WriteErrorKind::Io, e))
}
fn normal_components(path: &Path) -> Result<Vec<OsString>, WriteError> {
let mut out = Vec::new();
for component in path.components() {
match component {
Component::Normal(name) => out.push(name.to_os_string()),
_ => {
return Err(invalid_path_error(
"path must contain only normal file components",
));
}
}
}
if out.is_empty() {
return Err(invalid_path_error("path must name a file"));
}
Ok(out)
}
fn open_parent_dir(root: &Path, dirs: &[OsString]) -> Result<Dir, WriteError> {
let mut current = open_root_dir(root)?;
for dir in dirs {
current = open_or_create_child_dir(¤t, dir.as_os_str())?;
}
Ok(current)
}
fn open_root_dir(root: &Path) -> Result<Dir, WriteError> {
Dir::open_ambient_dir(root, ambient_authority())
.map_err(|e| WriteError::new(WriteErrorKind::Io, e))
}
fn open_or_create_child_dir(parent: &Dir, name: &OsStr) -> Result<Dir, WriteError> {
match parent.symlink_metadata(name) {
Ok(meta) => {
if meta.file_type().is_symlink() {
return Err(WriteError::new(WriteErrorKind::Symlink, symlink_io_error()));
}
if !meta.is_dir() {
return Err(invalid_path_error("path component is not a directory"));
}
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
#[cfg(unix)]
let created = {
use cap_std::fs::{DirBuilder, DirBuilderExt};
let mut builder = DirBuilder::new();
builder.mode(0o755);
parent.create_dir_with(name, &builder)
};
#[cfg(not(unix))]
let created = parent.create_dir(name);
match created {
Ok(()) => {}
Err(create_err) if create_err.kind() == std::io::ErrorKind::AlreadyExists => {
}
Err(create_err) => return Err(classify_path_io_error(create_err)),
}
}
Err(e) => return Err(classify_path_io_error(e)),
}
parent.open_dir(name).map_err(classify_path_io_error)
}
fn reject_symlink_leaf(parent: &Dir, name: &OsStr) -> Result<(), WriteError> {
match parent.symlink_metadata(name) {
Ok(meta) => {
if meta.file_type().is_symlink() {
Err(WriteError::new(WriteErrorKind::Symlink, symlink_io_error()))
} else {
Ok(())
}
}
Err(e) => Err(classify_path_io_error(e)),
}
}
fn write_new_file(parent: &Dir, name: &OsStr, content: &[u8]) -> Result<(), WriteError> {
let mut opts = OpenOptions::new();
opts.write(true).create_new(true);
let cap_file = parent
.open_with(name, &opts)
.map_err(classify_create_io_error)?;
let mut file = cap_file.into_std();
write_and_sync(&mut file, content)?;
drop(file);
fsync_dir(parent)
}
fn atomic_replace(parent: &Dir, name: &OsStr, content: &[u8]) -> Result<(), WriteError> {
validate_replace_target(parent, name)?;
let (temp_name, mut file) = create_temp_file(parent, name)?;
if let Err(err) = write_and_sync(&mut file, content) {
cleanup_temp(parent, &temp_name);
return Err(err);
}
drop(file);
if let Err(e) = parent.rename(temp_name.as_os_str(), parent, name) {
cleanup_temp(parent, &temp_name);
return Err(classify_create_io_error(e));
}
fsync_dir(parent)
}
fn validate_replace_target(parent: &Dir, name: &OsStr) -> Result<(), WriteError> {
match parent.symlink_metadata(name) {
Ok(meta) => {
let ft = meta.file_type();
if ft.is_symlink() {
Err(WriteError::new(WriteErrorKind::Symlink, symlink_io_error()))
} else if ft.is_file() {
Ok(())
} else {
Err(invalid_path_error("target is not a regular file"))
}
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(e) => Err(classify_create_io_error(e)),
}
}
fn create_temp_file(parent: &Dir, name: &OsStr) -> Result<(OsString, std::fs::File), WriteError> {
for attempt in 0..100 {
let mut temp = OsString::from(".");
temp.push(name);
temp.push(format!(
".hallouminate-{}-{attempt}.tmp",
std::process::id()
));
let mut opts = OpenOptions::new();
opts.write(true).create_new(true);
match parent.open_with(temp.as_os_str(), &opts) {
Ok(cap_file) => return Ok((temp, cap_file.into_std())),
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => continue,
Err(e) => return Err(classify_create_io_error(e)),
}
}
Err(WriteError::new(
WriteErrorKind::Io,
std::io::Error::new(
std::io::ErrorKind::AlreadyExists,
"temporary filename collision",
),
))
}
fn write_and_sync(file: &mut std::fs::File, content: &[u8]) -> Result<(), WriteError> {
file.write_all(content)
.and_then(|_| file.sync_all())
.map_err(|e| WriteError::new(WriteErrorKind::Io, e))
}
fn fsync_dir(dir: &Dir) -> Result<(), WriteError> {
let dot = dir
.open(".")
.map_err(|e| WriteError::new(WriteErrorKind::Io, e))?;
dot.into_std()
.sync_all()
.map_err(|e| WriteError::new(WriteErrorKind::Io, e))
}
fn cleanup_temp(parent: &Dir, name: &OsStr) {
let _ = parent.remove_file(name);
}
const ELOOP: i32 = rustix::io::Errno::LOOP.raw_os_error();
const ENOTDIR: i32 = rustix::io::Errno::NOTDIR.raw_os_error();
const EISDIR: i32 = rustix::io::Errno::ISDIR.raw_os_error();
const EINVAL: i32 = rustix::io::Errno::INVAL.raw_os_error();
fn classify_path_io_error(err: std::io::Error) -> WriteError {
if let Some(raw) = err.raw_os_error() {
if raw == ELOOP {
return WriteError::new(WriteErrorKind::Symlink, err);
}
if raw == ENOTDIR || raw == EINVAL {
return WriteError::new(WriteErrorKind::InvalidPath, err);
}
}
if err.kind() == std::io::ErrorKind::InvalidInput {
return WriteError::new(WriteErrorKind::InvalidPath, err);
}
WriteError::new(WriteErrorKind::Io, err)
}
fn classify_create_io_error(err: std::io::Error) -> WriteError {
if let Some(raw) = err.raw_os_error() {
if raw == ELOOP {
return WriteError::new(WriteErrorKind::Symlink, err);
}
if raw == ENOTDIR || raw == EISDIR || raw == EINVAL {
return WriteError::new(WriteErrorKind::InvalidPath, err);
}
}
if err.kind() == std::io::ErrorKind::AlreadyExists {
return WriteError::new(WriteErrorKind::Exists, err);
}
if err.kind() == std::io::ErrorKind::InvalidInput {
return WriteError::new(WriteErrorKind::InvalidPath, err);
}
WriteError::new(WriteErrorKind::Io, err)
}
fn symlink_io_error() -> std::io::Error {
std::io::Error::from_raw_os_error(ELOOP)
}
fn invalid_path_error(msg: &'static str) -> WriteError {
WriteError::new(
WriteErrorKind::InvalidPath,
std::io::Error::new(std::io::ErrorKind::InvalidInput, msg),
)
}
#[cfg(test)]
mod tests {
use super::*;
fn cfg(name: &str, paths: Vec<&str>) -> CorpusConfig {
CorpusConfig {
name: name.to_string(),
paths: paths.into_iter().map(String::from).collect(),
globs: Vec::new(),
exclude: Vec::new(),
global: false,
}
}
#[test]
fn safe_relative_path_rejects_empty_string() {
let err = safe_relative_path("").expect_err("empty must fail");
assert!(err.as_str().contains("non-empty"), "got: {err}");
}
#[test]
fn safe_relative_path_rejects_absolute_path() {
let err = safe_relative_path("/etc/passwd").expect_err("absolute must fail");
assert!(err.as_str().contains("non-empty relative"), "got: {err}");
}
#[test]
fn safe_relative_path_rejects_parent_dir_component() {
let err = safe_relative_path("../escape.md").expect_err("parent dir must fail");
assert!(err.as_str().contains("normal"), "got: {err}");
}
#[test]
fn safe_relative_path_rejects_embedded_parent_dir() {
let err = safe_relative_path("docs/../../etc/passwd").expect_err("embedded `..` must fail");
assert!(err.as_str().contains("normal"), "got: {err}");
}
#[test]
fn safe_relative_path_rejects_current_dir_only() {
let err = safe_relative_path(".").expect_err("`.` must fail");
assert!(err.as_str().contains("must name a file"), "got: {err}");
}
#[test]
fn safe_relative_path_rejects_trailing_slash() {
let err = safe_relative_path("docs/").expect_err("trailing slash must fail");
assert!(err.as_str().contains("must name a file"), "got: {err}");
}
#[test]
fn safe_relative_path_rejects_leading_dot_slash() {
let err = safe_relative_path("./file.md").expect_err("`./` prefix must fail");
assert!(err.as_str().contains("normal"), "got: {err}");
}
#[test]
fn safe_relative_path_rejects_embedded_dot_segment() {
let err = safe_relative_path("docs/./file.md").expect_err("`/./` must fail");
assert!(err.as_str().contains("normal"), "got: {err}");
}
#[test]
fn safe_relative_path_rejects_trailing_dot_segment() {
let err = safe_relative_path("docs/.").expect_err("trailing `/.` must fail");
assert!(err.as_str().contains("must name a file"), "got: {err}");
}
#[test]
fn safe_relative_path_rejects_nul_byte() {
let err = safe_relative_path("file\0.md").expect_err("NUL byte must fail");
assert!(err.as_str().contains("NUL byte"), "got: {err}");
}
#[test]
fn safe_relative_path_accepts_simple_filename() {
let p = safe_relative_path("file.md").expect("simple name must pass");
assert_eq!(p, Path::new("file.md"));
}
#[test]
fn safe_relative_path_accepts_nested_relative_path() {
let p = safe_relative_path("wiki/concepts/attention.md").expect("nested must pass");
assert_eq!(p, Path::new("wiki/concepts/attention.md"));
}
#[test]
fn pick_corpus_returns_only_corpus_when_unspecified() {
let docs = cfg("docs", vec!["/docs"]);
let got = pick_corpus(std::slice::from_ref(&docs), None).expect("single must pass");
assert_eq!(got.name, "docs");
}
#[test]
fn pick_corpus_errors_when_unspecified_and_multiple_configured() {
let a = cfg("a", vec!["/a"]);
let b = cfg("b", vec!["/b"]);
let err = pick_corpus(&[a, b], None).expect_err("ambiguous must fail");
assert!(err.as_str().contains("corpus required"), "got: {err}");
}
#[test]
fn pick_corpus_errors_when_unspecified_and_no_corpora_configured() {
let err = pick_corpus(&[], None).expect_err("empty must fail");
let msg = err.as_str();
assert!(msg.contains("no corpora configured"), "got: {msg}");
assert!(msg.contains("[[corpus]]"), "got: {msg}");
assert!(msg.contains("[[repository]]"), "got: {msg}");
}
#[test]
fn pick_corpus_returns_named_corpus_when_present() {
let a = cfg("a", vec!["/a"]);
let b = cfg("b", vec!["/b"]);
let got = pick_corpus(&[a, b], Some("b")).expect("named must pass");
assert_eq!(got.name, "b");
}
#[test]
fn pick_corpus_errors_when_named_corpus_missing_with_quoted_name() {
let docs = cfg("docs", vec!["/docs"]);
let err = pick_corpus(std::slice::from_ref(&docs), Some("missing"))
.expect_err("unknown name must fail");
let msg = err.as_str();
assert!(msg.contains("\"missing\""), "got: {msg}");
assert!(msg.contains("not found"), "got: {msg}");
}
#[test]
fn ensure_corpus_allows_file_passes_when_no_globs_or_excludes_configured() {
let corpus = cfg("docs", vec!["/docs"]);
ensure_corpus_allows_file(&corpus, Path::new("/docs/anything.md"))
.expect("empty rules accept everything");
}
#[test]
fn ensure_corpus_allows_file_rejects_non_matching_include_glob() {
let mut corpus = cfg("docs", vec!["/docs"]);
corpus.globs = vec!["**/*.md".to_string()];
let err = ensure_corpus_allows_file(&corpus, Path::new("/docs/note.txt"))
.expect_err("txt excluded");
assert!(err.as_str().contains("not included"), "got: {err}");
}
#[test]
fn ensure_corpus_allows_file_rejects_path_matching_exclude_glob() {
let mut corpus = cfg("docs", vec!["/docs"]);
corpus.globs = vec!["**/*.md".to_string()];
corpus.exclude = vec!["**/drafts/**".to_string()];
let err = ensure_corpus_allows_file(&corpus, Path::new("/docs/drafts/wip.md"))
.expect_err("drafts must be excluded");
assert!(err.as_str().contains("excluded"), "got: {err}");
}
#[test]
fn ensure_corpus_allows_file_accepts_path_matching_include_and_not_exclude() {
let mut corpus = cfg("docs", vec!["/docs"]);
corpus.globs = vec!["**/*.md".to_string()];
corpus.exclude = vec!["**/drafts/**".to_string()];
ensure_corpus_allows_file(&corpus, Path::new("/docs/concepts/attention.md"))
.expect("matching path must pass");
}
#[test]
fn first_corpus_root_returns_expanded_first_path() {
let corpus = cfg("docs", vec!["/abs/cheese"]);
let got = first_corpus_root(&corpus).expect("present must pass");
assert_eq!(got, PathBuf::from("/abs/cheese"));
}
#[test]
fn first_corpus_root_errors_when_paths_empty() {
let corpus = cfg("docs", vec![]);
let err = first_corpus_root(&corpus).expect_err("empty must fail");
assert!(err.as_str().contains("has no paths"), "got: {err}");
}
#[test]
fn atomic_write_no_follow_creates_parent_dirs_inside_root() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
let written = atomic_write_no_follow(root, Path::new("a/b/c/leaf.md"), b"# leaf\n", false)
.expect("first write");
assert!(written.exists(), "file landed: {}", written.display());
assert_eq!(std::fs::read_to_string(&written).unwrap(), "# leaf\n");
}
#[test]
fn atomic_write_no_follow_rejects_intermediate_symlink_without_creating_outside_dirs() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("root");
std::fs::create_dir(&root).unwrap();
let outside = tmp.path().join("outside");
std::fs::create_dir(&outside).unwrap();
std::os::unix::fs::symlink(&outside, root.join("escape")).unwrap();
let err = atomic_write_no_follow(&root, Path::new("escape/pwned.md"), b"x", false)
.expect_err("symlinked intermediate dir must bounce");
assert!(
matches!(err.kind, WriteErrorKind::Symlink),
"expected Symlink, got {err:?}"
);
assert!(
!outside.join("pwned.md").exists(),
"writer must not punch through the symlink"
);
}
#[test]
fn atomic_write_no_follow_rejects_final_component_symlink() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
let outside = tmp.path().parent().unwrap().join("outside-leaf");
let _ = std::fs::remove_file(&outside);
std::fs::write(&outside, b"original").unwrap();
std::os::unix::fs::symlink(&outside, root.join("link.md")).unwrap();
let err = atomic_write_no_follow(root, Path::new("link.md"), b"new", true)
.expect_err("final symlink must bounce");
assert!(matches!(err.kind, WriteErrorKind::Symlink), "{err:?}");
assert_eq!(std::fs::read_to_string(&outside).unwrap(), "original");
let _ = std::fs::remove_file(&outside);
}
#[test]
fn atomic_write_no_follow_refuses_existing_without_overwrite() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
atomic_write_no_follow(root, Path::new("x.md"), b"first", false).unwrap();
let err = atomic_write_no_follow(root, Path::new("x.md"), b"second", false)
.expect_err("second write must EEXIST");
assert!(matches!(err.kind, WriteErrorKind::Exists), "{err:?}");
}
#[test]
fn atomic_write_no_follow_classifies_nul_byte_filename_as_invalid_path() {
use std::ffi::OsString;
use std::os::unix::ffi::OsStringExt;
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
let nul_name = OsString::from_vec(b"bad\0name.md".to_vec());
let rel = PathBuf::from(nul_name);
let err = atomic_write_no_follow(root, &rel, b"x", false)
.expect_err("NUL in component must fail");
assert!(
matches!(err.kind, WriteErrorKind::InvalidPath),
"expected InvalidPath, got {err:?}"
);
}
#[test]
fn atomic_write_no_follow_overwrites_when_requested() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
atomic_write_no_follow(root, Path::new("x.md"), b"first", false).unwrap();
atomic_write_no_follow(root, Path::new("x.md"), b"second", true).unwrap();
assert_eq!(
std::fs::read_to_string(root.join("x.md")).unwrap(),
"second"
);
}
#[test]
fn read_no_follow_returns_contents_when_path_is_plain_file() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
std::fs::create_dir_all(root.join("a/b")).unwrap();
std::fs::write(root.join("a/b/leaf.md"), b"# hardened\n").unwrap();
let got = read_no_follow(root, Path::new("a/b/leaf.md")).expect("read");
assert_eq!(got, b"# hardened\n");
}
#[test]
fn read_no_follow_rejects_symlinked_leaf_with_symlink_kind() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("root");
std::fs::create_dir(&root).unwrap();
let target = root.join("real.md");
std::fs::write(&target, b"target\n").unwrap();
std::os::unix::fs::symlink(&target, root.join("link.md")).unwrap();
let err =
read_no_follow(&root, Path::new("link.md")).expect_err("symlinked leaf must bounce");
assert!(matches!(err.kind, WriteErrorKind::Symlink), "{err:?}");
}
#[test]
fn read_no_follow_rejects_symlinked_intermediate_dir() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("root");
std::fs::create_dir(&root).unwrap();
let outside = tmp.path().join("outside");
std::fs::create_dir(&outside).unwrap();
std::fs::write(outside.join("secret.md"), b"shh\n").unwrap();
std::os::unix::fs::symlink(&outside, root.join("escape")).unwrap();
let err = read_no_follow(&root, Path::new("escape/secret.md"))
.expect_err("intermediate symlink must bounce");
assert!(matches!(err.kind, WriteErrorKind::Symlink), "{err:?}");
}
#[test]
fn read_no_follow_classifies_missing_file_as_invalid_path() {
let tmp = tempfile::tempdir().unwrap();
let err = read_no_follow(tmp.path(), Path::new("missing.md"))
.expect_err("missing file must fail");
assert!(matches!(err.kind, WriteErrorKind::Io), "{err:?}");
assert_eq!(
err.source.kind(),
std::io::ErrorKind::NotFound,
"source kind: {err:?}"
);
}
#[test]
fn resolve_read_root_single_root_returns_that_root_when_file_present() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("only");
std::fs::create_dir_all(root.join("notes")).unwrap();
std::fs::write(root.join("notes/a.md"), b"# a\n").unwrap();
let corpus = cfg("docs", vec![root.to_str().unwrap()]);
let got =
resolve_read_root(&corpus, Path::new("notes/a.md")).expect("present file resolves");
assert_eq!(got, root);
}
#[test]
fn resolve_read_root_finds_file_under_a_non_first_root() {
let tmp = tempfile::tempdir().unwrap();
let first = tmp.path().join("first");
let second = tmp.path().join("second");
std::fs::create_dir_all(&first).unwrap();
std::fs::create_dir_all(&second).unwrap();
std::fs::write(second.join("only-here.md"), b"# here\n").unwrap();
let corpus = cfg(
"docs",
vec![first.to_str().unwrap(), second.to_str().unwrap()],
);
let got =
resolve_read_root(&corpus, Path::new("only-here.md")).expect("file under paths[1]");
assert_eq!(
got, second,
"must resolve to the root that actually holds the file"
);
}
#[test]
fn resolve_read_root_prefers_earlier_root_on_collision() {
let tmp = tempfile::tempdir().unwrap();
let first = tmp.path().join("first");
let second = tmp.path().join("second");
std::fs::create_dir_all(&first).unwrap();
std::fs::create_dir_all(&second).unwrap();
std::fs::write(first.join("dup.md"), b"first\n").unwrap();
std::fs::write(second.join("dup.md"), b"second\n").unwrap();
let corpus = cfg(
"docs",
vec![first.to_str().unwrap(), second.to_str().unwrap()],
);
let got = resolve_read_root(&corpus, Path::new("dup.md")).expect("present");
assert_eq!(got, first);
}
#[test]
fn resolve_read_root_missing_in_all_roots_is_notfound_io() {
let tmp = tempfile::tempdir().unwrap();
let first = tmp.path().join("first");
let second = tmp.path().join("second");
std::fs::create_dir_all(&first).unwrap();
std::fs::create_dir_all(&second).unwrap();
let corpus = cfg(
"docs",
vec![first.to_str().unwrap(), second.to_str().unwrap()],
);
let err = resolve_read_root(&corpus, Path::new("nope.md")).expect_err("absent everywhere");
assert!(matches!(err.kind, WriteErrorKind::Io), "{err:?}");
assert_eq!(err.source.kind(), std::io::ErrorKind::NotFound, "{err:?}");
}
#[test]
fn resolve_read_root_symlinked_leaf_bounces_without_falling_through() {
let tmp = tempfile::tempdir().unwrap();
let first = tmp.path().join("first");
let second = tmp.path().join("second");
std::fs::create_dir_all(&first).unwrap();
std::fs::create_dir_all(&second).unwrap();
let outside = tmp.path().join("outside.md");
std::fs::write(&outside, b"secret\n").unwrap();
std::os::unix::fs::symlink(&outside, first.join("link.md")).unwrap();
std::fs::write(second.join("link.md"), b"real\n").unwrap();
let corpus = cfg(
"docs",
vec![first.to_str().unwrap(), second.to_str().unwrap()],
);
let err =
resolve_read_root(&corpus, Path::new("link.md")).expect_err("symlinked leaf bounces");
assert!(matches!(err.kind, WriteErrorKind::Symlink), "{err:?}");
}
#[test]
fn resolve_read_root_does_not_create_directories_while_probing() {
let tmp = tempfile::tempdir().unwrap();
let first = tmp.path().join("first");
let second = tmp.path().join("second");
std::fs::create_dir_all(&first).unwrap();
std::fs::create_dir_all(second.join("deep/nested")).unwrap();
std::fs::write(second.join("deep/nested/leaf.md"), b"x\n").unwrap();
let corpus = cfg(
"docs",
vec![first.to_str().unwrap(), second.to_str().unwrap()],
);
resolve_read_root(&corpus, Path::new("deep/nested/leaf.md"))
.expect("resolves under second");
assert!(
!first.join("deep").exists(),
"probing the first root must not create the nested parent dirs"
);
}
#[test]
fn delete_no_follow_removes_existing_regular_file() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
std::fs::create_dir_all(root.join("sub")).unwrap();
let target = root.join("sub/doomed.md");
std::fs::write(&target, b"bye\n").unwrap();
delete_no_follow(root, Path::new("sub/doomed.md")).expect("delete");
assert!(!target.exists(), "file must be gone");
}
#[test]
fn delete_no_follow_rejects_symlinked_leaf_without_unlinking_target() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("root");
std::fs::create_dir(&root).unwrap();
let target = tmp.path().join("target.md");
std::fs::write(&target, b"keep me\n").unwrap();
std::os::unix::fs::symlink(&target, root.join("link.md")).unwrap();
let err =
delete_no_follow(&root, Path::new("link.md")).expect_err("symlinked leaf must bounce");
assert!(matches!(err.kind, WriteErrorKind::Symlink), "{err:?}");
assert!(target.exists(), "target must survive");
assert!(root.join("link.md").exists(), "symlink must survive too");
}
#[test]
fn delete_no_follow_rejects_symlinked_intermediate_dir() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("root");
std::fs::create_dir(&root).unwrap();
let outside = tmp.path().join("outside");
std::fs::create_dir(&outside).unwrap();
std::fs::write(outside.join("victim.md"), b"keep\n").unwrap();
std::os::unix::fs::symlink(&outside, root.join("escape")).unwrap();
let err = delete_no_follow(&root, Path::new("escape/victim.md"))
.expect_err("intermediate symlink must bounce");
assert!(matches!(err.kind, WriteErrorKind::Symlink), "{err:?}");
assert!(
outside.join("victim.md").exists(),
"victim file must survive the refused unlink"
);
}
#[test]
fn delete_no_follow_rejects_directory_target_as_invalid_path() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
std::fs::create_dir(root.join("notafile")).unwrap();
let err = delete_no_follow(root, Path::new("notafile")).expect_err("dir target must fail");
assert!(matches!(err.kind, WriteErrorKind::InvalidPath), "{err:?}");
}
#[test]
fn atomic_write_no_follow_rejects_regular_file_as_intermediate_dir_component() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
std::fs::write(root.join("a"), b"i am a file\n").unwrap();
let err = atomic_write_no_follow(root, Path::new("a/b.md"), b"x", false)
.expect_err("file-as-dir-component must fail");
assert!(matches!(err.kind, WriteErrorKind::InvalidPath), "{err:?}");
}
#[cfg(unix)]
fn with_permissive_umask<R>(body: impl FnOnce() -> R) -> R {
use rustix::fs::Mode;
use rustix::process::umask;
use std::sync::Mutex;
static UMASK_LOCK: Mutex<()> = Mutex::new(());
struct Restore(Mode);
impl Drop for Restore {
fn drop(&mut self) {
umask(self.0);
}
}
let _serialize = UMASK_LOCK
.lock()
.unwrap_or_else(|poison| poison.into_inner());
let _restore = Restore(umask(Mode::empty()));
body()
}
#[cfg(unix)]
#[test]
fn atomic_write_no_follow_caps_intermediate_dir_mode_at_0o755_under_permissive_umask() {
use std::os::unix::fs::PermissionsExt;
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
with_permissive_umask(|| {
atomic_write_no_follow(root, Path::new("perm/leaf.md"), b"x", false)
.expect("write through a fresh intermediate dir");
});
let mode = std::fs::metadata(root.join("perm"))
.unwrap()
.permissions()
.mode()
& 0o777;
assert_eq!(
mode, 0o755,
"intermediate dir must be capped at 0o755, got {mode:o}"
);
}
#[cfg(unix)]
#[test]
fn atomic_write_no_follow_caps_every_nested_intermediate_dir_at_0o755() {
use std::os::unix::fs::PermissionsExt;
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
with_permissive_umask(|| {
atomic_write_no_follow(root, Path::new("a/b/c/leaf.md"), b"x", false)
.expect("write through three fresh intermediate dirs");
});
for rel in ["a", "a/b", "a/b/c"] {
let mode = std::fs::metadata(root.join(rel))
.unwrap()
.permissions()
.mode()
& 0o777;
assert_eq!(mode, 0o755, "{rel} must be capped at 0o755, got {mode:o}");
}
}
#[test]
fn atomic_write_no_follow_durable_write_round_trips_through_fsync_path() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
atomic_write_no_follow(root, Path::new("durable/leaf.md"), b"sync me\n", false)
.expect("first write must survive the fsync_dir call");
atomic_write_no_follow(root, Path::new("durable/leaf.md"), b"and again\n", true)
.expect("overwrite must survive the second fsync_dir call");
assert_eq!(
std::fs::read(root.join("durable/leaf.md")).unwrap(),
b"and again\n"
);
}
#[test]
fn list_corpus_files_returns_sorted_relative_markdown_paths() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
std::fs::create_dir_all(root.join("sub")).unwrap();
std::fs::write(root.join("b.md"), "b").unwrap();
std::fs::write(root.join("a.md"), "a").unwrap();
std::fs::write(root.join("sub/c.md"), "c").unwrap();
std::fs::write(root.join("skip.txt"), "x").unwrap();
let corpus = CorpusConfig {
name: "docs".into(),
paths: vec![root.to_string_lossy().into_owned()],
globs: vec!["**/*.md".into()],
exclude: vec![],
global: false,
};
let entries = list_corpus_files(&corpus).unwrap();
let paths: Vec<&str> = entries.iter().map(|e| e.path.as_str()).collect();
assert_eq!(paths, vec!["a.md", "b.md", "sub/c.md"]);
}
#[test]
fn canonicalize_or_log_falls_back_to_input_for_missing_path() {
let tmp = tempfile::tempdir().unwrap();
let missing = tmp.path().join("not-created-yet");
assert!(!missing.exists(), "precondition: path must not exist");
let got = canonicalize_or_log(missing.clone());
assert_eq!(
got, missing,
"fallback must return the input path unchanged"
);
}
#[test]
fn canonicalize_or_log_returns_canonical_path_for_existing_dir() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().to_path_buf();
let expected = std::fs::canonicalize(&root).expect("existing dir canonicalizes");
let got = canonicalize_or_log(root);
assert_eq!(
got, expected,
"existing dir must resolve to its canonical path"
);
}
#[test]
fn list_corpus_files_returns_empty_for_freshly_created_empty_corpus() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("fresh-wiki");
std::fs::create_dir_all(&root).unwrap();
let corpus = CorpusConfig {
name: "fresh".into(),
paths: vec![root.to_string_lossy().into_owned()],
globs: vec!["**/*.md".into()],
exclude: vec![],
global: false,
};
let entries = list_corpus_files(&corpus).expect("empty fresh corpus must list cleanly");
assert!(entries.is_empty(), "no markdown written yet");
}
#[test]
fn build_corpus_tree_groups_files_by_directory() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
std::fs::create_dir_all(root.join("adapters")).unwrap();
std::fs::create_dir_all(root.join("adapters/nested")).unwrap();
std::fs::write(root.join("top.md"), "# Top").unwrap();
std::fs::write(root.join("adapters/index.md"), "# Adapters").unwrap();
std::fs::write(root.join("adapters/lance.md"), "# Lance").unwrap();
std::fs::write(root.join("adapters/nested/deep.md"), "# Deep").unwrap();
let corpus = CorpusConfig {
name: "docs".into(),
paths: vec![root.to_string_lossy().into_owned()],
globs: vec!["**/*.md".into()],
exclude: vec![],
global: false,
};
let tree = build_corpus_tree(&corpus).unwrap();
assert_eq!(tree.path, "");
assert_eq!(tree.files.len(), 1, "one top-level file");
assert_eq!(tree.files[0].path, "top.md");
assert_eq!(tree.subdirs.len(), 1, "one subdir at root");
let adapters = &tree.subdirs[0];
assert_eq!(adapters.path, "adapters");
assert_eq!(adapters.files.len(), 2);
assert_eq!(adapters.subdirs.len(), 1);
assert_eq!(adapters.subdirs[0].path, "adapters/nested");
assert_eq!(adapters.subdirs[0].files[0].path, "adapters/nested/deep.md");
}
#[test]
fn build_corpus_tree_returns_root_only_for_empty_corpus() {
let tmp = tempfile::tempdir().unwrap();
let corpus = CorpusConfig {
name: "empty".into(),
paths: vec![tmp.path().to_string_lossy().into_owned()],
globs: vec!["**/*.md".into()],
exclude: vec![],
global: false,
};
let tree = build_corpus_tree(&corpus).unwrap();
assert_eq!(tree.path, "");
assert!(tree.files.is_empty());
assert!(tree.subdirs.is_empty());
}
#[test]
fn build_corpus_tree_drops_files_outside_first_root_for_multi_root_corpus() {
let tmp = tempfile::tempdir().unwrap();
let first = tmp.path().join("first");
let second = tmp.path().join("second");
std::fs::create_dir_all(&first).unwrap();
std::fs::create_dir_all(&second).unwrap();
std::fs::write(first.join("a.md"), "# A").unwrap();
std::fs::write(second.join("b.md"), "# B").unwrap();
let corpus = CorpusConfig {
name: "multi".into(),
paths: vec![
first.to_string_lossy().into_owned(),
second.to_string_lossy().into_owned(),
],
globs: vec!["**/*.md".into()],
exclude: vec![],
global: false,
};
let tree = build_corpus_tree(&corpus).unwrap();
assert_eq!(tree.path, "");
assert!(
tree.subdirs.is_empty(),
"non-first-root file must not fan out"
);
assert_eq!(
tree.files.len(),
1,
"only the first-root file lands in the tree"
);
assert_eq!(tree.files[0].path, "a.md");
}
}