use std::path::{Path, PathBuf};
#[must_use]
pub fn normalize_path(path: &std::path::Path) -> PathBuf {
use std::path::Component;
let mut parts: Vec<Component<'_>> = Vec::new();
for component in path.components() {
match component {
Component::CurDir => { }
Component::ParentDir => {
if matches!(parts.last(), Some(Component::Normal(_))) {
parts.pop();
}
}
other => parts.push(other),
}
}
parts.iter().collect()
}
pub fn canonicalize_path(path: &std::path::Path) -> std::io::Result<PathBuf> {
std::fs::canonicalize(path)
}
fn resolve_path_preserving_symlinks(path: &Path) -> PathBuf {
match canonicalize_path(path) {
Ok(p) => return p,
Err(e) => {
tracing::trace!(path = %path.display(), error = %e, "resolve_path_preserving_symlinks: canonicalize failed; falling back to best-effort resolution");
}
}
let normalized = normalize_path(path);
let mut existing = normalized.as_path();
let mut remainder = Vec::new();
while !existing.exists() {
if let Some(name) = existing.file_name() {
remainder.push(name.to_os_string());
}
match existing.parent() {
Some(parent) if parent != existing => existing = parent,
_ => break,
}
}
if existing.exists()
&& let Ok(mut canonical_base) = canonicalize_path(existing)
{
for component in remainder.into_iter().rev() {
canonical_base.push(component);
}
return normalize_path(&canonical_base);
}
normalized
}
#[must_use]
pub fn is_path_in_workspace(candidate: impl AsRef<Path>, workspaces: &[PathBuf]) -> bool {
let candidate_path = candidate.as_ref();
let resolved_candidate = resolve_path_preserving_symlinks(candidate_path);
workspaces.iter().any(|ws| {
let resolved_ws = resolve_path_preserving_symlinks(ws);
resolved_candidate.starts_with(&resolved_ws)
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn is_path_in_workspace_accepts_path() {
let workspaces = [PathBuf::from("/workspace")];
let p = std::path::Path::new("/workspace/src/main.rs");
assert!(is_path_in_workspace(p, &workspaces));
}
#[test]
fn is_path_in_workspace_accepts_pathbuf() {
let workspaces = [PathBuf::from("/workspace")];
let p = PathBuf::from("/workspace/src/main.rs");
assert!(is_path_in_workspace(p, &workspaces));
}
#[test]
fn canonicalize_path_returns_absolute_for_existing_path() {
let dir = tempfile::tempdir().unwrap();
let result = canonicalize_path(dir.path()).unwrap();
assert!(result.is_absolute());
}
#[test]
fn canonicalize_path_resolves_dot_dot() {
let dir = tempfile::tempdir().unwrap();
let child = dir.path().join("sub");
std::fs::create_dir(&child).unwrap();
let with_dotdot = child.join("..");
let result = canonicalize_path(&with_dotdot).unwrap();
let expected = canonicalize_path(dir.path()).unwrap();
assert_eq!(result, expected);
}
#[test]
fn canonicalize_path_errors_on_nonexistent_path() {
let err =
canonicalize_path(Path::new("/tmp/agy_bridge_test_nonexistent_9x7z")).unwrap_err();
assert_eq!(err.kind(), std::io::ErrorKind::NotFound);
}
#[test]
fn canonicalize_path_resolves_symlinks() {
let dir = tempfile::tempdir().unwrap();
let real_file = dir.path().join("real.txt");
std::fs::write(&real_file, b"data").unwrap();
let link = dir.path().join("link.txt");
std::os::unix::fs::symlink(&real_file, &link).unwrap();
let resolved = canonicalize_path(&link).unwrap();
let expected = canonicalize_path(&real_file).unwrap();
assert_eq!(resolved, expected);
}
#[test]
fn normalize_path_removes_dot() {
let result = normalize_path(Path::new("/a/./b/./c"));
assert_eq!(result, PathBuf::from("/a/b/c"));
}
#[test]
fn normalize_path_resolves_parent() {
let result = normalize_path(Path::new("/a/b/../c"));
assert_eq!(result, PathBuf::from("/a/c"));
}
#[test]
fn normalize_path_does_not_go_above_root() {
let result = normalize_path(Path::new("/a/../../b"));
assert_eq!(result, PathBuf::from("/b"));
}
#[test]
fn normalize_path_preserves_absolute_simple_path() {
let result = normalize_path(Path::new("/usr/local/bin"));
assert_eq!(result, PathBuf::from("/usr/local/bin"));
}
#[cfg(unix)]
#[test]
fn symlink_traversal_blocked_even_for_non_existent_target_file() {
let workspace_dir = tempfile::tempdir().unwrap();
let outside_dir = tempfile::tempdir().unwrap();
let symlink_in_ws = workspace_dir.path().join("escaped_dir");
std::os::unix::fs::symlink(outside_dir.path(), &symlink_in_ws).unwrap();
let candidate = symlink_in_ws.join("new_file.txt");
let workspaces = vec![workspace_dir.path().to_path_buf()];
assert!(
!is_path_in_workspace(&candidate, &workspaces),
"symlink inside workspace pointing outside must not allow creating new files outside workspace"
);
}
}