use std::path::{Component, Path, PathBuf};
use crate::tool::ToolError;
pub fn confine_path(root: impl AsRef<Path>, relative: &str) -> Result<PathBuf, ToolError> {
if relative.is_empty() || relative.chars().any(char::is_control) {
return Err(ToolError::not_found());
}
let normalized = relative.replace('\\', "/");
let mut clean = PathBuf::new();
for component in Path::new(&normalized).components() {
match component {
Component::Normal(part) => clean.push(part),
Component::CurDir => {}
Component::ParentDir | Component::RootDir | Component::Prefix(_) => {
return Err(ToolError::not_found());
}
}
}
if clean.as_os_str().is_empty() || (cfg!(windows) && normalized.contains(':')) {
return Err(ToolError::not_found());
}
let root = root
.as_ref()
.canonicalize()
.map_err(|_| ToolError::not_found())?;
let resolved = root
.join(&clean)
.canonicalize()
.map_err(|_| ToolError::not_found())?;
if resolved.starts_with(&root) {
Ok(resolved)
} else {
Err(ToolError::not_found())
}
}
#[cfg(test)]
mod tests {
use super::*;
fn workspace() -> PathBuf {
let dir = std::env::temp_dir().join(format!(
"flymcprust-confine-{}-{:?}",
std::process::id(),
std::thread::current().id()
));
std::fs::create_dir_all(dir.join("sub")).unwrap();
std::fs::write(dir.join("sub").join("a.txt"), "a").unwrap();
dir
}
#[test]
fn accepts_paths_inside_the_root() {
let root = workspace();
let p = confine_path(&root, "sub/a.txt").unwrap();
assert!(p.ends_with("a.txt"));
assert!(confine_path(&root, "./sub/./a.txt").is_ok());
assert!(confine_path(&root, "sub\\a.txt").is_ok());
}
#[test]
fn rejects_escapes() {
let root = workspace();
for bad in [
"",
"../outside",
"sub/../../outside",
"..\\outside",
"/etc/passwd",
"\\\\server\\share",
"C:\\Windows\\win.ini",
"sub/a.txt\0",
"sub/\na.txt",
"missing.txt",
] {
assert!(
confine_path(&root, bad).is_err(),
"{bad:?} must be rejected"
);
}
}
#[cfg(unix)]
#[test]
fn rejects_symlinks_leaving_the_root() {
let root = workspace();
let link = root.join("escape");
let _ = std::fs::remove_file(&link);
std::os::unix::fs::symlink("/", &link).unwrap();
assert!(confine_path(&root, "escape/etc").is_err());
}
}