use std::path::{Component, Path, PathBuf};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AbsolutePathPolicy {
Reject,
AllowIfContained,
AllowAdditionalRoots(Vec<PathBuf>),
}
fn system_temp_directory_roots() -> Vec<PathBuf> {
let mut roots: Vec<PathBuf> = vec![std::env::temp_dir()];
#[cfg(unix)]
{
push_unique(&mut roots, PathBuf::from("/tmp"));
push_unique(&mut roots, PathBuf::from("/var/tmp"));
if let Ok(v) = std::env::var("TMPDIR") {
push_unique(&mut roots, PathBuf::from(v));
}
}
#[cfg(windows)]
{
for var in ["TEMP", "TMP"] {
if let Ok(v) = std::env::var(var) {
push_unique(&mut roots, PathBuf::from(v));
}
}
}
roots
}
fn push_unique(roots: &mut Vec<PathBuf>, p: PathBuf) {
if !roots.contains(&p) {
roots.push(p);
}
}
impl AbsolutePathPolicy {
pub fn allow_workspace_and_temp_dir() -> Self {
AbsolutePathPolicy::AllowAdditionalRoots(system_temp_directory_roots())
}
pub fn allow_additional_roots(roots: impl IntoIterator<Item = PathBuf>) -> Self {
AbsolutePathPolicy::AllowAdditionalRoots(roots.into_iter().collect())
}
}
#[derive(Debug)]
pub enum ContainmentError {
AbsolutePath(String),
Escaped {
path: String,
root: String,
},
Canonicalize {
path: String,
source: std::io::Error,
},
}
impl std::fmt::Display for ContainmentError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ContainmentError::AbsolutePath(p) => write!(f, "absolute paths are not allowed: {p}"),
ContainmentError::Escaped { path, root } => {
write!(
f,
"path escapes workspace directory: {path} (workspace: {root})"
)
}
ContainmentError::Canonicalize { path, source } => {
write!(f, "failed to canonicalize path: {path}: {source}")
}
}
}
}
impl std::error::Error for ContainmentError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
ContainmentError::Canonicalize { source, .. } => Some(source),
_ => None,
}
}
}
#[derive(Debug, Clone)]
pub struct PathGuard {
root: PathBuf,
canon_root: PathBuf,
absolute_policy: AbsolutePathPolicy,
}
impl PathGuard {
pub fn new(
root: PathBuf,
absolute_policy: AbsolutePathPolicy,
) -> Result<Self, ContainmentError> {
Self::new_with_policy(root, absolute_policy)
}
pub fn check_path(&self, path: &str) -> Result<PathBuf, ContainmentError> {
let p = Path::new(path);
if p.is_absolute() {
match &self.absolute_policy {
AbsolutePathPolicy::Reject => {
return Err(ContainmentError::AbsolutePath(path.to_string()));
}
AbsolutePathPolicy::AllowIfContained => {
let roots = vec![self.canon_root.clone()];
return self.check_resolved_absolute(path, p, &roots);
}
AbsolutePathPolicy::AllowAdditionalRoots(extra) => {
let mut allowed = vec![self.canon_root.clone()];
for r in extra {
if let Ok(c) = r.canonicalize() {
allowed.push(c);
}
}
return self.check_resolved_absolute(path, p, &allowed);
}
}
}
validate_relative_depth(path, p)?;
self.check_resolved_relative(path)
}
pub fn root(&self) -> &Path {
&self.root
}
pub fn canon_root(&self) -> &Path {
&self.canon_root
}
pub fn would_allow(&self, path: &str) -> bool {
self.check_path(path).is_ok()
}
fn check_resolved_absolute(
&self,
path: &str,
p: &Path,
allowed_roots: &[PathBuf],
) -> Result<PathBuf, ContainmentError> {
let canon = canonicalize_or_ancestor(p).map_err(|e| ContainmentError::Canonicalize {
path: path.to_string(),
source: e,
})?;
let contained = allowed_roots.iter().any(|r| canon.starts_with(r));
if !contained {
return Err(ContainmentError::Escaped {
path: path.to_string(),
root: self.root.display().to_string(),
});
}
Ok(canon)
}
fn check_resolved_relative(&self, path: &str) -> Result<PathBuf, ContainmentError> {
let joined = self.root.join(path);
let canon =
canonicalize_or_ancestor(&joined).map_err(|e| ContainmentError::Canonicalize {
path: path.to_string(),
source: e,
})?;
if !canon.starts_with(&self.canon_root) {
return Err(ContainmentError::Escaped {
path: path.to_string(),
root: self.root.display().to_string(),
});
}
Ok(canon)
}
}
pub struct PathGuardBuilder {
root: PathBuf,
policy: AbsolutePathPolicy,
}
impl PathGuard {
pub fn builder(root: PathBuf) -> PathGuardBuilder {
PathGuardBuilder {
root,
policy: AbsolutePathPolicy::Reject,
}
}
pub fn new_with_policy(
root: PathBuf,
policy: AbsolutePathPolicy,
) -> Result<Self, ContainmentError> {
let canon_root = root
.canonicalize()
.map_err(|e| ContainmentError::Canonicalize {
path: root.display().to_string(),
source: e,
})?;
Ok(Self {
root,
canon_root,
absolute_policy: policy,
})
}
}
impl PathGuardBuilder {
pub fn allow_temp_directory(mut self) -> Self {
let temps = system_temp_directory_roots();
self.policy = match self.policy {
AbsolutePathPolicy::Reject | AbsolutePathPolicy::AllowIfContained => {
AbsolutePathPolicy::AllowAdditionalRoots(temps)
}
AbsolutePathPolicy::AllowAdditionalRoots(mut roots) => {
for t in temps {
push_unique(&mut roots, t);
}
AbsolutePathPolicy::AllowAdditionalRoots(roots)
}
};
self
}
pub fn allow_root(mut self, additional: impl Into<PathBuf>) -> Self {
let extra = additional.into();
self.policy = match self.policy {
AbsolutePathPolicy::Reject | AbsolutePathPolicy::AllowIfContained => {
AbsolutePathPolicy::AllowAdditionalRoots(vec![extra])
}
AbsolutePathPolicy::AllowAdditionalRoots(mut roots) => {
push_unique(&mut roots, extra);
AbsolutePathPolicy::AllowAdditionalRoots(roots)
}
};
self
}
pub fn build(self) -> Result<PathGuard, ContainmentError> {
PathGuard::new_with_policy(self.root, self.policy)
}
}
fn validate_relative_depth(path: &str, p: &Path) -> Result<(), ContainmentError> {
let mut depth: i32 = 0;
for component in p.components() {
match component {
Component::ParentDir => {
depth -= 1;
if depth < 0 {
return Err(ContainmentError::Escaped {
path: path.to_string(),
root: String::new(),
});
}
}
Component::Normal(_) => {
depth += 1;
}
Component::CurDir => {}
_ => {
return Err(ContainmentError::Escaped {
path: path.to_string(),
root: String::new(),
});
}
}
}
Ok(())
}
fn normalize_lexical(path: &Path) -> PathBuf {
use std::path::Component;
let mut parts: Vec<Component<'_>> = Vec::new();
for c in path.components() {
match c {
Component::ParentDir => {
if matches!(parts.last(), Some(Component::Normal(_))) {
parts.pop();
} else {
parts.push(c);
}
}
Component::CurDir => { }
_ => parts.push(c),
}
}
parts.iter().collect()
}
fn canonicalize_or_ancestor(path: &Path) -> std::io::Result<PathBuf> {
let normalized = normalize_lexical(path);
let path = normalized.as_path();
if path.exists() {
return path.canonicalize();
}
let mut ancestor = path;
let mut tail_components = Vec::new();
loop {
match ancestor.parent() {
Some(p) if p.exists() => {
if let Some(file_name) = ancestor.file_name() {
tail_components.push(file_name.to_os_string());
}
let canon_ancestor = p.canonicalize()?;
let mut result = canon_ancestor;
for c in tail_components.into_iter().rev() {
result.push(c);
}
return Ok(result);
}
Some(p) => {
if let Some(file_name) = ancestor.file_name() {
tail_components.push(file_name.to_os_string());
}
ancestor = p;
}
None => return path.canonicalize(),
}
}
}
const _: () = {
fn _assert<T: Send + Sync>() {}
let _ = _assert::<PathGuard>;
let _ = _assert::<AbsolutePathPolicy>;
let _ = _assert::<ContainmentError>;
};
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
#[test]
fn relative_path_within_workspace() {
let dir = tempfile::TempDir::new().unwrap();
fs::write(dir.path().join("file.txt"), "ok").unwrap();
let guard = PathGuard::new(dir.path().to_path_buf(), AbsolutePathPolicy::Reject).unwrap();
guard.check_path("file.txt").unwrap();
}
#[test]
fn relative_path_with_safe_parent_traversal() {
let dir = tempfile::TempDir::new().unwrap();
fs::create_dir_all(dir.path().join("src")).unwrap();
fs::write(dir.path().join("Cargo.toml"), "ok").unwrap();
let guard = PathGuard::new(dir.path().to_path_buf(), AbsolutePathPolicy::Reject).unwrap();
guard.check_path("src/../Cargo.toml").unwrap();
}
#[test]
fn relative_path_escaping_workspace() {
let dir = tempfile::TempDir::new().unwrap();
let guard = PathGuard::new(dir.path().to_path_buf(), AbsolutePathPolicy::Reject).unwrap();
let err = guard.check_path("../../etc/passwd").unwrap_err();
assert!(matches!(err, ContainmentError::Escaped { .. }));
}
#[test]
fn absolute_path_with_allow_if_contained() {
let dir = tempfile::TempDir::new().unwrap();
fs::write(dir.path().join("inside.txt"), "ok").unwrap();
let guard = PathGuard::new(
dir.path().to_path_buf(),
AbsolutePathPolicy::AllowIfContained,
)
.unwrap();
let abs = dir.path().join("inside.txt");
guard.check_path(abs.to_str().unwrap()).unwrap();
}
fn outside_absolute_path() -> &'static str {
#[cfg(unix)]
{
"/etc/passwd"
}
#[cfg(windows)]
{
"C:\\Windows\\System32\\notepad.exe"
}
}
#[test]
fn absolute_path_outside_workspace_with_allow_if_contained() {
let dir = tempfile::TempDir::new().unwrap();
let guard = PathGuard::new(
dir.path().to_path_buf(),
AbsolutePathPolicy::AllowIfContained,
)
.unwrap();
let err = guard.check_path(outside_absolute_path()).unwrap_err();
assert!(matches!(err, ContainmentError::Escaped { .. }));
}
#[test]
fn absolute_path_with_reject_policy() {
let dir = tempfile::TempDir::new().unwrap();
let guard = PathGuard::new(dir.path().to_path_buf(), AbsolutePathPolicy::Reject).unwrap();
let err = guard.check_path(outside_absolute_path()).unwrap_err();
assert!(matches!(err, ContainmentError::AbsolutePath(_)));
}
#[cfg(unix)]
#[test]
fn symlink_escaping_workspace() {
let dir = tempfile::TempDir::new().unwrap();
let link = dir.path().join("escape");
std::os::unix::fs::symlink("/tmp", &link).unwrap();
let guard = PathGuard::new(dir.path().to_path_buf(), AbsolutePathPolicy::Reject).unwrap();
let err = guard.check_path("escape").unwrap_err();
assert!(matches!(err, ContainmentError::Escaped { .. }));
}
#[test]
fn nonexistent_file_in_existing_directory() {
let dir = tempfile::TempDir::new().unwrap();
let guard = PathGuard::new(dir.path().to_path_buf(), AbsolutePathPolicy::Reject).unwrap();
let result = guard.check_path("new_file.txt");
assert!(
result.is_ok(),
"non-existent file with safe ancestor should be allowed"
);
}
#[test]
fn empty_path() {
let dir = tempfile::TempDir::new().unwrap();
let guard = PathGuard::new(dir.path().to_path_buf(), AbsolutePathPolicy::Reject).unwrap();
guard.check_path("").unwrap();
}
#[test]
fn single_parent_rejected() {
let dir = tempfile::TempDir::new().unwrap();
let guard = PathGuard::new(dir.path().to_path_buf(), AbsolutePathPolicy::Reject).unwrap();
assert!(guard.check_path("..").is_err());
}
#[test]
fn deep_traversal_rejected() {
let dir = tempfile::TempDir::new().unwrap();
let guard = PathGuard::new(dir.path().to_path_buf(), AbsolutePathPolicy::Reject).unwrap();
assert!(guard.check_path("a/b/../../../escape").is_err());
}
#[test]
fn dot_relative_path_allowed() {
let dir = tempfile::TempDir::new().unwrap();
fs::create_dir_all(dir.path().join("foo")).unwrap();
fs::write(dir.path().join("foo/bar.json"), "{}").unwrap();
let guard = PathGuard::new(dir.path().to_path_buf(), AbsolutePathPolicy::Reject).unwrap();
guard.check_path("./foo/bar.json").unwrap();
}
#[cfg(unix)]
#[test]
fn new_file_through_symlink_dir_rejected() {
let dir = tempfile::TempDir::new().unwrap();
let link = dir.path().join("link_dir");
std::os::unix::fs::symlink("/tmp", &link).unwrap();
let guard = PathGuard::new(dir.path().to_path_buf(), AbsolutePathPolicy::Reject).unwrap();
let err = guard.check_path("link_dir/new_file.txt").unwrap_err();
assert!(
matches!(err, ContainmentError::Escaped { .. }),
"new file through symlink escaping workspace should be rejected"
);
}
#[test]
fn check_path_returns_canonicalized_path() {
let dir = tempfile::TempDir::new().unwrap();
fs::write(dir.path().join("test.txt"), "ok").unwrap();
let guard = PathGuard::new(dir.path().to_path_buf(), AbsolutePathPolicy::Reject).unwrap();
let resolved = guard.check_path("test.txt").unwrap();
assert!(resolved.is_absolute());
assert!(resolved.ends_with("test.txt"));
}
#[test]
fn root_and_canon_root_accessors() {
let dir = tempfile::TempDir::new().unwrap();
let guard = PathGuard::new(dir.path().to_path_buf(), AbsolutePathPolicy::Reject).unwrap();
assert_eq!(guard.root(), dir.path());
assert!(guard.canon_root().is_absolute());
}
#[test]
fn new_with_nonexistent_root_returns_canonicalize_error() {
let err = PathGuard::new(
PathBuf::from("/nonexistent_patchloom_test_dir_xyz"),
AbsolutePathPolicy::Reject,
)
.unwrap_err();
assert!(matches!(err, ContainmentError::Canonicalize { .. }));
let msg = err.to_string();
assert!(
msg.contains("failed to canonicalize"),
"expected canonicalize message, got: {msg}"
);
}
#[test]
fn error_display_absolute_path() {
let err = ContainmentError::AbsolutePath("/etc/passwd".to_string());
let msg = err.to_string();
assert!(
msg.contains("absolute paths are not allowed") && msg.contains("/etc/passwd"),
"unexpected message: {msg}"
);
}
#[test]
fn error_display_escaped() {
let err = ContainmentError::Escaped {
path: "../../secret".to_string(),
root: "/home/user".to_string(),
};
let msg = err.to_string();
assert!(
msg.contains("escapes workspace") && msg.contains("../../secret"),
"unexpected message: {msg}"
);
}
#[test]
fn error_source_canonicalize_returns_inner_error() {
use std::error::Error;
let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "gone");
let err = ContainmentError::Canonicalize {
path: "test".to_string(),
source: io_err,
};
let _src = err
.source()
.expect("Canonicalize error must carry inner io source");
let msg = err.to_string();
assert!(msg.contains("failed to canonicalize"), "got: {msg}");
}
#[test]
fn error_source_non_canonicalize_returns_none() {
use std::error::Error;
let err = ContainmentError::AbsolutePath("/x".to_string());
assert!(err.source().is_none());
let err2 = ContainmentError::Escaped {
path: "..".to_string(),
root: "/r".to_string(),
};
assert!(err2.source().is_none());
}
#[test]
fn allow_additional_roots_allows_temp_and_extra() {
let dir = tempfile::TempDir::new().unwrap();
let temp = std::env::temp_dir();
let guard = PathGuard::new(
dir.path().to_path_buf(),
AbsolutePathPolicy::AllowAdditionalRoots(vec![temp.clone()]),
)
.unwrap();
let tmp_file = temp.join("patchloom_test_extra.txt");
std::fs::write(&tmp_file, "ok").unwrap();
let res = guard.check_path(tmp_file.to_str().unwrap());
res.unwrap();
let _ = std::fs::remove_file(&tmp_file);
}
#[test]
fn allow_additional_roots_still_rejects_outside() {
let dir = tempfile::TempDir::new().unwrap();
let guard = PathGuard::new(
dir.path().to_path_buf(),
AbsolutePathPolicy::allow_workspace_and_temp_dir(),
)
.unwrap();
let err = guard.check_path("/etc/passwd").unwrap_err();
assert!(matches!(err, ContainmentError::Escaped { .. }));
}
#[test]
fn builder_allows_temp() {
let dir = tempfile::TempDir::new().unwrap();
let guard = PathGuard::builder(dir.path().to_path_buf())
.allow_temp_directory()
.build()
.unwrap();
let temp = std::env::temp_dir().join("patchloom_builder_test.txt");
std::fs::write(&temp, "ok").unwrap();
guard.check_path(temp.to_str().unwrap()).unwrap();
let _ = std::fs::remove_file(&temp);
}
#[cfg(unix)]
#[test]
fn builder_allows_literal_tmp_path_unix() {
let dir = tempfile::TempDir::new().unwrap();
let guard = PathGuard::builder(dir.path().to_path_buf())
.allow_temp_directory()
.build()
.unwrap();
let tmp_path = format!("/tmp/patchloom_781_test_{}.txt", std::process::id());
let _ = std::fs::remove_file(&tmp_path);
std::fs::write(&tmp_path, "data for 781").unwrap();
let res = guard.check_path(&tmp_path);
assert!(
res.is_ok(),
"literal /tmp path must be allowed: {:?}",
res.err()
);
let _ = std::fs::remove_file(&tmp_path);
}
#[cfg(unix)]
#[test]
fn builder_allows_nonexistent_under_literal_tmp() {
let dir = tempfile::TempDir::new().unwrap();
let guard = PathGuard::builder(dir.path().to_path_buf())
.allow_temp_directory()
.build()
.unwrap();
let tmp_path = format!("/tmp/patchloom_781_nonexist_{}.txt", std::process::id());
let res = guard.check_path(&tmp_path);
assert!(
res.is_ok(),
"non-existing /tmp path must be allowed via ancestor: {:?}",
res.err()
);
}
#[test]
fn allow_workspace_and_temp_dir_allows_std_temp() {
let dir = tempfile::TempDir::new().unwrap();
let guard = PathGuard::new(
dir.path().to_path_buf(),
AbsolutePathPolicy::allow_workspace_and_temp_dir(),
)
.unwrap();
let temp = std::env::temp_dir().join("patchloom_awtd_std.txt");
std::fs::write(&temp, "ok").unwrap();
guard.check_path(temp.to_str().unwrap()).unwrap();
let _ = std::fs::remove_file(&temp);
}
#[cfg(unix)]
#[test]
fn allow_workspace_and_temp_dir_allows_literal_tmp() {
let dir = tempfile::TempDir::new().unwrap();
let guard = PathGuard::new(
dir.path().to_path_buf(),
AbsolutePathPolicy::allow_workspace_and_temp_dir(),
)
.unwrap();
let tmp_path = format!("/tmp/patchloom_781_awtd_{}.txt", std::process::id());
let _ = std::fs::remove_file(&tmp_path);
std::fs::write(&tmp_path, "data").unwrap();
guard.check_path(&tmp_path).unwrap();
let _ = std::fs::remove_file(&tmp_path);
}
#[test]
fn would_allow_works() {
let dir = tempfile::TempDir::new().unwrap();
let guard = PathGuard::new(dir.path().to_path_buf(), AbsolutePathPolicy::Reject).unwrap();
assert!(guard.would_allow("foo.txt"));
assert!(!guard.would_allow("../escape"));
}
#[test]
fn system_temp_directory_roots_contains_expected_entries() {
let roots = system_temp_directory_roots();
assert!(!roots.is_empty());
#[cfg(unix)]
{
assert!(roots.iter().any(
|r| r == std::path::Path::new("/tmp") || r == std::path::Path::new("/var/tmp")
));
let stdt = std::env::temp_dir();
assert!(roots.iter().any(|r| r == &stdt));
}
}
#[cfg(unix)]
#[test]
fn allow_temp_rejects_escape_from_tmp_via_parent() {
let dir = tempfile::TempDir::new().unwrap();
let guard = PathGuard::builder(dir.path().to_path_buf())
.allow_temp_directory()
.build()
.unwrap();
let escape_path = "/tmp/../etc/passwd";
let err = guard.check_path(escape_path).unwrap_err();
assert!(
matches!(err, ContainmentError::Escaped { .. }),
"expected Escaped for escape from /tmp root via .., got {:?}",
err
);
}
#[test]
fn dotdot_in_nonexistent_absolute_path_rejected() {
let base = tempfile::TempDir::new().unwrap();
let workspace = base.path().join("workspace");
let outside = base.path().join("outside");
fs::create_dir(&workspace).unwrap();
fs::create_dir(&outside).unwrap();
fs::write(outside.join("secret.txt"), "sensitive").unwrap();
let guard =
PathGuard::new(workspace.clone(), AbsolutePathPolicy::AllowIfContained).unwrap();
let escape = workspace
.join("nonexistent")
.join("..")
.join("..")
.join("outside")
.join("secret.txt");
let result = guard.check_path(escape.to_str().unwrap());
assert!(
matches!(result, Err(ContainmentError::Escaped { .. })),
"path with .. through non-existent dir should be rejected, got: {:?}",
result,
);
}
#[test]
fn dotdot_in_nonexistent_path_rejected_with_additional_roots() {
let base = tempfile::TempDir::new().unwrap();
let workspace = base.path().join("workspace");
let extra_root = base.path().join("extra");
let outside = base.path().join("outside");
fs::create_dir(&workspace).unwrap();
fs::create_dir(&extra_root).unwrap();
fs::create_dir(&outside).unwrap();
fs::write(outside.join("data.txt"), "sensitive").unwrap();
let guard = PathGuard::new(
workspace.clone(),
AbsolutePathPolicy::AllowAdditionalRoots(vec![extra_root]),
)
.unwrap();
let escape = workspace
.join("nonexistent")
.join("..")
.join("..")
.join("outside")
.join("data.txt");
let result = guard.check_path(escape.to_str().unwrap());
assert!(
matches!(result, Err(ContainmentError::Escaped { .. })),
"dotdot escape via additional-roots should be rejected, got: {:?}",
result,
);
}
}