use std::path::{Component, Path, PathBuf};
#[derive(Debug, Clone)]
pub enum PathPolicy {
Sandbox { root: PathBuf },
RelativeOnly,
BlockForbidden,
}
#[derive(Debug, Clone)]
pub struct PathWitness {
pub raw_path: String,
pub source_path: PathBuf,
pub resolved_path: Option<PathBuf>,
pub policy: String,
pub accepted: bool,
pub rejection_code: Option<String>,
pub sandbox_root: Option<PathBuf>,
}
pub fn resolve_and_validate(
raw_path: &str,
source_path: &Path,
policy: &PathPolicy,
) -> Result<(PathBuf, PathWitness), String> {
let policy_name = match policy {
PathPolicy::Sandbox { .. } => "Sandbox",
PathPolicy::RelativeOnly => "RelativeOnly",
PathPolicy::BlockForbidden => "BlockForbidden",
};
let sandbox_root = match policy {
PathPolicy::Sandbox { root } => Some(root.clone()),
_ => None,
};
let make_witness =
|resolved: Option<PathBuf>, accepted: bool, code: Option<&str>| PathWitness {
raw_path: raw_path.to_owned(),
source_path: source_path.to_path_buf(),
resolved_path: resolved,
policy: policy_name.to_owned(),
accepted,
rejection_code: code.map(str::to_owned),
sandbox_root: sandbox_root.clone(),
};
if raw_path.contains('\0') {
return Err("null_byte_detected".to_owned());
}
if matches!(policy, PathPolicy::RelativeOnly) && Path::new(raw_path).is_absolute() {
return Err("relative_only_escape:absolute_path_forbidden".to_owned());
}
let normalised = raw_path.replace('\\', "/");
let p = Path::new(&normalised);
let has_traversal = p.components().any(|c| c == Component::ParentDir)
|| normalised.split('/').any(|seg| seg == "..");
if has_traversal {
return Err("path_traversal_detected".to_owned());
}
let p = Path::new(raw_path);
let base_dir = source_path.parent().unwrap_or_else(|| Path::new("."));
let resolved = if p.is_absolute() { p.to_path_buf() } else { base_dir.join(p) };
let resolved = clean_path(&resolved);
match policy {
PathPolicy::BlockForbidden => {
let forbidden = ["/etc", "/dev", "/proc", "/sys", "/var/run", ".git"];
for prefix in &forbidden {
let fp = Path::new(prefix);
if resolved.starts_with(fp) || raw_path.starts_with(prefix) {
return Err("forbidden_path".to_owned());
}
}
}
PathPolicy::Sandbox { root } => {
let clean_root = clean_path(root);
if !resolved.starts_with(&clean_root) {
return Err(format!("sandbox_escape:{}", raw_path));
}
}
PathPolicy::RelativeOnly => {
let clean_base = clean_path(base_dir);
if !resolved.starts_with(&clean_base) {
return Err(format!("relative_only_escape:{}", raw_path));
}
}
}
let witness = make_witness(Some(resolved.clone()), true, None);
Ok((resolved, witness))
}
fn clean_path(p: &Path) -> PathBuf {
let mut out = PathBuf::new();
for component in p.components() {
match component {
Component::CurDir => {}
Component::ParentDir => {
out.pop();
}
c => out.push(c),
}
}
out
}