use std::path::{Component, Path, PathBuf};
pub fn safe_canonicalize(path: &Path) -> std::io::Result<PathBuf> {
dunce::canonicalize(path)
}
#[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) = safe_canonicalize(r) {
allowed.push(c);
}
}
return self.check_resolved_absolute(path, p, &allowed);
}
}
}
validate_relative_depth(path, p, &self.root)?;
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: dunce::simplified(Path::new(path))
.to_string_lossy()
.into_owned(),
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: dunce::simplified(Path::new(path))
.to_string_lossy()
.into_owned(),
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 = safe_canonicalize(&root).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, root: &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: root.display().to_string(),
});
}
}
Component::Normal(_) => {
depth += 1;
}
Component::CurDir => {}
_ => {
return Err(ContainmentError::Escaped {
path: path.to_string(),
root: root.display().to_string(),
});
}
}
}
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 safe_canonicalize(path);
}
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 = safe_canonicalize(p)?;
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 safe_canonicalize(path),
}
}
}
const _: () = {
fn _assert<T: Send + Sync>() {}
let _ = _assert::<PathGuard>;
let _ = _assert::<AbsolutePathPolicy>;
let _ = _assert::<ContainmentError>;
};
#[cfg(test)]
#[path = "containment_tests.rs"]
mod tests;