use std::path::{Component, Path, PathBuf};
pub fn resolve_within(root: &Path, candidate: &Path) -> std::io::Result<PathBuf> {
if candidate.is_absolute() {
return Err(std::io::Error::new(
std::io::ErrorKind::PermissionDenied,
format!(
"absolute path not allowed in this context: {}",
candidate.display()
),
));
}
let mut stack: Vec<std::ffi::OsString> = Vec::new();
for component in candidate.components() {
match component {
Component::Prefix(_) | Component::RootDir => {
return Err(std::io::Error::new(
std::io::ErrorKind::PermissionDenied,
format!(
"absolute or prefix segment not allowed: {}",
candidate.display()
),
));
}
Component::CurDir => {
continue;
}
Component::ParentDir => {
if stack.pop().is_none() {
return Err(std::io::Error::new(
std::io::ErrorKind::PermissionDenied,
format!(
"path traversal rejected: {} escapes the project root via `..`",
candidate.display()
),
));
}
}
Component::Normal(seg) => {
stack.push(seg.to_os_string());
}
}
}
let mut out = root.to_path_buf();
for seg in stack {
out.push(seg);
}
Ok(out)
}
pub fn resolve_within_str(root: &Path, candidate: &str) -> std::io::Result<PathBuf> {
resolve_within(root, Path::new(candidate))
}
pub fn resolve_within_or_absolute(
root: &Path,
candidate: &Path,
) -> std::io::Result<PathBuf> {
if candidate.is_absolute() {
return Ok(candidate.to_path_buf());
}
resolve_within(root, candidate)
}
#[cfg(test)]
mod tests {
use super::*;
fn root() -> PathBuf {
PathBuf::from("/tmp/inkhaven-test-root")
}
#[test]
fn plain_relative_path_resolves_under_root() {
let out = resolve_within(&root(), Path::new("books/ch1/opening.typ")).unwrap();
assert_eq!(out, root().join("books").join("ch1").join("opening.typ"));
}
#[test]
fn dot_segment_is_stripped() {
let out = resolve_within(&root(), Path::new("./books/./ch1/opening.typ")).unwrap();
assert_eq!(out, root().join("books").join("ch1").join("opening.typ"));
}
#[test]
fn inner_double_dot_is_normalised() {
let out = resolve_within(&root(), Path::new("books/ch1/../ch2/opening.typ")).unwrap();
assert_eq!(out, root().join("books").join("ch2").join("opening.typ"));
}
#[test]
fn double_dot_above_root_rejected() {
let err = resolve_within(&root(), Path::new("../etc/passwd")).unwrap_err();
assert_eq!(err.kind(), std::io::ErrorKind::PermissionDenied);
}
#[test]
fn deep_double_dot_escape_rejected() {
let err = resolve_within(&root(), Path::new("a/b/../../../etc/passwd")).unwrap_err();
assert_eq!(err.kind(), std::io::ErrorKind::PermissionDenied);
}
#[test]
fn absolute_path_rejected() {
let err = resolve_within(&root(), Path::new("/etc/passwd")).unwrap_err();
assert_eq!(err.kind(), std::io::ErrorKind::PermissionDenied);
assert!(err.to_string().contains("absolute path"));
}
#[test]
fn str_wrapper_delegates() {
let out = resolve_within_str(&root(), "books/ch1/opening.typ").unwrap();
assert_eq!(out, root().join("books").join("ch1").join("opening.typ"));
}
#[test]
fn empty_path_resolves_to_root() {
let out = resolve_within(&root(), Path::new("")).unwrap();
assert_eq!(out, root());
}
#[test]
fn windows_prefix_rejected() {
let out = resolve_within(&root(), Path::new("normal")).unwrap();
assert_eq!(out, root().join("normal"));
}
}