use std::path::{Path, PathBuf};
pub fn canonical_dir(dir: &str) -> Option<PathBuf> {
std::fs::canonicalize(dir).ok().filter(|p| p.is_dir())
}
pub fn confine(candidate: &str, base: Option<&Path>) -> Option<PathBuf> {
let base = base?;
let canonical_candidate = std::fs::canonicalize(candidate).ok()?;
if canonical_candidate.starts_with(base) {
Some(canonical_candidate)
} else {
log::debug!("refused: {} resolves outside {}", candidate, base.display());
None
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn canonical_dir_none_for_missing_path() {
assert!(canonical_dir("/does/not/exist/anywhere").is_none());
}
#[test]
fn canonical_dir_none_for_a_file_not_a_directory() {
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("f.txt");
std::fs::write(&file, "x").unwrap();
assert!(canonical_dir(file.to_str().unwrap()).is_none());
}
#[test]
fn confine_accepts_a_candidate_inside_base() {
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("f.txt");
std::fs::write(&file, "x").unwrap();
let base = canonical_dir(dir.path().to_str().unwrap()).unwrap();
assert!(confine(file.to_str().unwrap(), Some(&base)).is_some());
}
#[test]
fn confine_refuses_a_candidate_outside_base() {
let outer = tempfile::tempdir().unwrap();
let inner = outer.path().join("inner");
std::fs::create_dir(&inner).unwrap();
let outside = outer.path().join("outside.txt");
std::fs::write(&outside, "x").unwrap();
let base = canonical_dir(inner.to_str().unwrap()).unwrap();
assert!(confine(outside.to_str().unwrap(), Some(&base)).is_none());
}
#[test]
fn confine_refuses_when_base_is_none() {
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("f.txt");
std::fs::write(&file, "x").unwrap();
assert!(confine(file.to_str().unwrap(), None).is_none());
}
#[test]
fn confine_refuses_a_missing_candidate() {
let dir = tempfile::tempdir().unwrap();
let base = canonical_dir(dir.path().to_str().unwrap()).unwrap();
let missing = dir.path().join("nope.txt");
assert!(confine(missing.to_str().unwrap(), Some(&base)).is_none());
}
#[cfg(unix)]
#[test]
fn confine_refuses_a_symlink_escaping_base() {
let outer = tempfile::tempdir().unwrap();
let inner = outer.path().join("inner");
std::fs::create_dir(&inner).unwrap();
let outside = outer.path().join("outside.txt");
std::fs::write(&outside, "secret").unwrap();
let link = inner.join("link.txt");
std::os::unix::fs::symlink(&outside, &link).unwrap();
let base = canonical_dir(inner.to_str().unwrap()).unwrap();
assert!(confine(link.to_str().unwrap(), Some(&base)).is_none());
}
}