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_dir) = home::home_dir() {
return path.replacen('~', home_dir.to_str().unwrap_or(""), 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 std::fs;
use tempfile::TempDir;
#[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()));
}
}