use std::io;
use std::path::{Path, PathBuf};
#[derive(Debug)]
pub enum PathSecurityError {
PathTraversal { path: PathBuf, workspace: PathBuf },
SymlinkEscape { path: PathBuf, target: PathBuf, workspace: PathBuf },
CanonicalizationFailed { path: PathBuf, error: io::Error },
}
impl std::fmt::Display for PathSecurityError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
PathSecurityError::PathTraversal { path, workspace } => {
write!(
f,
"Path traversal detected: '{}' escapes workspace '{}'",
path.display(),
workspace.display()
)
}
PathSecurityError::SymlinkEscape { path, target, workspace } => {
write!(
f,
"Symlink escape detected: '{}' points to '{}' outside workspace '{}'",
path.display(),
target.display(),
workspace.display()
)
}
PathSecurityError::CanonicalizationFailed { path, error } => {
write!(f, "Failed to resolve path '{}': {}", path.display(), error)
}
}
}
}
impl std::error::Error for PathSecurityError {}
pub fn validate_path_in_workspace(
path: &Path,
workspace_root: &Path,
) -> Result<PathBuf, PathSecurityError> {
let canonical_workspace = workspace_root.canonicalize().map_err(|e| {
PathSecurityError::CanonicalizationFailed { path: workspace_root.to_path_buf(), error: e }
})?;
if let Ok(metadata) = std::fs::symlink_metadata(path) {
if metadata.file_type().is_symlink() {
let target = std::fs::read_link(path).map_err(|e| {
PathSecurityError::CanonicalizationFailed { path: path.to_path_buf(), error: e }
})?;
let absolute_target = if target.is_absolute() {
target
} else {
path.parent().unwrap_or(Path::new("/")).join(&target)
};
let canonical_target = absolute_target.canonicalize().map_err(|e| {
PathSecurityError::CanonicalizationFailed {
path: absolute_target.clone(),
error: e,
}
})?;
if !canonical_target.starts_with(&canonical_workspace) {
return Err(PathSecurityError::SymlinkEscape {
path: path.to_path_buf(),
target: canonical_target,
workspace: canonical_workspace,
});
}
}
}
match path.canonicalize() {
Ok(canonical_path) => {
if canonical_path.starts_with(&canonical_workspace) {
Ok(canonical_path)
} else {
Err(PathSecurityError::PathTraversal {
path: path.to_path_buf(),
workspace: canonical_workspace,
})
}
}
Err(e) if e.kind() == io::ErrorKind::NotFound => {
resolve_new_path(path, &canonical_workspace)
}
Err(e) => {
Err(PathSecurityError::CanonicalizationFailed { path: path.to_path_buf(), error: e })
}
}
}
fn resolve_new_path(path: &Path, canonical_workspace: &Path) -> Result<PathBuf, PathSecurityError> {
let traversal = || PathSecurityError::PathTraversal {
path: path.to_path_buf(),
workspace: canonical_workspace.to_path_buf(),
};
let mut existing = path;
loop {
if std::fs::symlink_metadata(existing).is_ok() {
break;
}
match existing.parent() {
Some(parent) => existing = parent,
None => break,
}
}
let canonical_base = existing.canonicalize().map_err(|e| {
PathSecurityError::CanonicalizationFailed { path: existing.to_path_buf(), error: e }
})?;
let remainder = path.strip_prefix(existing).map_err(|_| traversal())?;
let mut resolved = canonical_base;
for component in remainder.components() {
match component {
std::path::Component::Normal(c) => resolved.push(c),
std::path::Component::ParentDir => {
resolved.pop();
}
std::path::Component::CurDir => {}
_ => return Err(traversal()),
}
}
if resolved.starts_with(canonical_workspace) {
Ok(resolved)
} else {
Err(traversal())
}
}
pub fn is_symlink(path: &Path) -> bool {
std::fs::symlink_metadata(path).is_ok_and(|m| m.file_type().is_symlink())
}
pub fn expand_user(path: &str) -> String {
if path.starts_with('~') {
if let Some(home_str) = home::home_dir().as_deref().and_then(Path::to_str) {
return path.replacen('~', home_str, 1);
}
}
path.to_string()
}
pub fn ensure_directory_exists(path: &Path) -> std::io::Result<()> {
if !path.exists() {
std::fs::create_dir_all(path)?;
}
Ok(())
}
pub fn resolve_in_workspace(
path: &str,
cwd: &Path,
workspace_root: &Path,
) -> Result<PathBuf, PathSecurityError> {
if path.trim().is_empty() {
return validate_path_in_workspace(workspace_root, workspace_root);
}
let expanded = expand_user(path);
let candidate = if Path::new(&expanded).is_absolute() {
PathBuf::from(expanded)
} else {
cwd.join(expanded)
};
validate_path_in_workspace(&candidate, workspace_root)
}
pub fn glob_matches(pattern: &glob::Pattern, relative: &Path) -> bool {
let opts = glob::MatchOptions { require_literal_separator: true, ..glob::MatchOptions::new() };
if pattern.matches_path_with(relative, opts) {
return true;
}
relative
.file_name()
.is_some_and(|name| pattern.matches_with(name.to_string_lossy().as_ref(), opts))
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used)]
use super::*;
use proptest::prelude::*;
use std::fs;
use tempfile::TempDir;
proptest! {
#![proptest_config(ProptestConfig::with_cases(256))]
#[test]
fn resolve_in_workspace_ok_implies_contained(
segments in prop::collection::vec(
prop_oneof![Just("..".to_string()), Just(".".to_string()), "[a-zA-Z0-9_]{1,5}"],
0..10,
)
) {
let ws = std::env::temp_dir().canonicalize().unwrap();
let rel = segments.join("/");
if let Ok(resolved) = resolve_in_workspace(&rel, &ws, &ws) {
prop_assert!(
resolved.starts_with(&ws),
"resolve_in_workspace({rel:?}) escaped to {resolved:?}"
);
}
}
#[test]
fn validate_ok_implies_contained_any_input(s in ".*") {
let ws = std::env::temp_dir().canonicalize().unwrap();
if let Ok(p) = validate_path_in_workspace(Path::new(&s), &ws) {
prop_assert!(p.starts_with(&ws), "accepted escaping path {p:?} from input {s:?}");
}
}
}
#[test]
fn expand_user_leaves_non_tilde_paths_untouched() {
assert_eq!(expand_user("/abs/path"), "/abs/path");
assert_eq!(expand_user("rel/path"), "rel/path");
}
#[test]
fn expand_user_never_maps_tilde_to_root() {
let out = expand_user("~/sub");
assert_ne!(out, "/sub", "~/sub must not become the filesystem root");
if let Some(home) = home::home_dir().and_then(|h| h.to_str().map(String::from)) {
assert_eq!(out, format!("{home}/sub"));
}
}
#[test]
fn allows_existing_file_in_workspace() {
let ws = TempDir::new().unwrap();
let f = ws.path().join("a.txt");
fs::write(&f, "x").unwrap();
let v = validate_path_in_workspace(&f, ws.path()).unwrap();
assert!(v.starts_with(ws.path().canonicalize().unwrap()));
}
#[test]
fn allows_new_file_in_nested_nonexistent_dir() {
let ws = TempDir::new().unwrap();
let f = ws.path().join("new/deep/dir/file.txt");
let v = validate_path_in_workspace(&f, ws.path()).unwrap();
assert!(v.starts_with(ws.path().canonicalize().unwrap()));
assert!(v.ends_with("new/deep/dir/file.txt"));
}
#[test]
fn rejects_traversal_via_dotdot_in_new_path() {
let ws = TempDir::new().unwrap();
let f = ws.path().join("nope/../../etc/passwd");
assert!(matches!(
validate_path_in_workspace(&f, ws.path()),
Err(PathSecurityError::PathTraversal { .. })
));
}
#[test]
fn rejects_existing_path_outside_workspace() {
let ws = TempDir::new().unwrap();
let outside = TempDir::new().unwrap();
let f = outside.path().join("secret.txt");
fs::write(&f, "s").unwrap();
assert!(matches!(
validate_path_in_workspace(&f, ws.path()),
Err(PathSecurityError::PathTraversal { .. })
));
}
#[cfg(unix)]
#[test]
fn rejects_symlink_escaping_workspace() {
use std::os::unix::fs::symlink;
let ws = TempDir::new().unwrap();
let outside = TempDir::new().unwrap();
let secret = outside.path().join("secret.txt");
fs::write(&secret, "s").unwrap();
let link = ws.path().join("link.txt");
symlink(&secret, &link).unwrap();
assert!(matches!(
validate_path_in_workspace(&link, ws.path()),
Err(PathSecurityError::SymlinkEscape { .. })
));
}
#[cfg(unix)]
#[test]
fn rejects_new_file_through_dangling_symlink_dir() {
use std::os::unix::fs::symlink;
let ws = TempDir::new().unwrap();
let outside = TempDir::new().unwrap();
let link = ws.path().join("evil");
symlink(outside.path().join("nonexistent"), &link).unwrap();
let f = link.join("file.txt");
assert!(validate_path_in_workspace(&f, ws.path()).is_err());
}
#[cfg(unix)]
#[test]
fn allows_new_file_through_internal_symlink_dir() {
use std::os::unix::fs::symlink;
let ws = TempDir::new().unwrap();
let real = ws.path().join("real");
fs::create_dir(&real).unwrap();
let link = ws.path().join("link");
symlink(&real, &link).unwrap();
let f = link.join("file.txt");
let v = validate_path_in_workspace(&f, ws.path()).unwrap();
assert!(v.starts_with(ws.path().canonicalize().unwrap()));
}
}