use anyhow::{bail, Context, Result};
use std::path::{Component, Path, PathBuf};
pub fn normalize_to_relpath(absolute_path: &Path, repo_root: &Path) -> Result<PathBuf> {
let relative_path = absolute_path.strip_prefix(repo_root).with_context(|| {
format!(
"Path '{}' is outside repository root '{}'",
absolute_path.display(),
repo_root.display()
)
})?;
for component in relative_path.components() {
if matches!(component, Component::ParentDir) {
bail!(
"Path '{}' contains parent directory components (..), which is not allowed for security reasons",
relative_path.display()
);
}
}
Ok(relative_path.to_path_buf())
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::Path;
#[test]
fn test_simple_path_conversion() {
let repo_root = Path::new("/workspace");
let absolute_path = Path::new("/workspace/src/main.rs");
let result = normalize_to_relpath(absolute_path, repo_root).unwrap();
assert_eq!(result, Path::new("src/main.rs"));
}
#[test]
fn test_nested_path_conversion() {
let repo_root = Path::new("/workspace");
let absolute_path = Path::new("/workspace/packages/cli/src/commands/index.ts");
let result = normalize_to_relpath(absolute_path, repo_root).unwrap();
assert_eq!(result, Path::new("packages/cli/src/commands/index.ts"));
}
#[test]
fn test_path_outside_repo_root() {
let repo_root = Path::new("/workspace");
let outside_path = Path::new("/etc/passwd");
let result = normalize_to_relpath(outside_path, repo_root);
assert!(result.is_err());
let err_msg = result.unwrap_err().to_string();
assert!(err_msg.contains("outside repository root"));
assert!(err_msg.contains("/etc/passwd"));
assert!(err_msg.contains("/workspace"));
}
#[test]
fn test_path_with_parent_dir_components() {
let repo_root = Path::new("/workspace");
let absolute_path = Path::new("/workspace/src/../etc/passwd");
let normalized = absolute_path
.canonicalize()
.unwrap_or_else(|_| absolute_path.to_path_buf());
let result = normalize_to_relpath(&normalized, repo_root);
assert!(result.is_err());
}
#[test]
fn test_path_with_trailing_slash() {
let repo_root = Path::new("/workspace");
let absolute_path = Path::new("/workspace/src/lib.rs/");
let result = normalize_to_relpath(absolute_path, repo_root).unwrap();
assert_eq!(result, Path::new("src/lib.rs"));
}
#[test]
fn test_repo_root_itself() {
let repo_root = Path::new("/workspace");
let absolute_path = Path::new("/workspace");
let result = normalize_to_relpath(absolute_path, repo_root).unwrap();
assert_eq!(result, Path::new(""));
}
#[test]
fn test_deeply_nested_path() {
let repo_root = Path::new("/home/user/projects/myrepo");
let absolute_path =
Path::new("/home/user/projects/myrepo/crates/maproom/src/incremental/path_utils.rs");
let result = normalize_to_relpath(absolute_path, repo_root).unwrap();
assert_eq!(
result,
Path::new("crates/maproom/src/incremental/path_utils.rs")
);
}
#[test]
#[cfg(target_os = "windows")]
fn test_windows_paths() {
let repo_root = Path::new(r"C:\workspace");
let absolute_path = Path::new(r"C:\workspace\packages\cli\src\main.ts");
let result = normalize_to_relpath(absolute_path, repo_root).unwrap();
assert_eq!(result, Path::new(r"packages\cli\src\main.ts"));
}
#[test]
#[cfg(target_os = "windows")]
fn test_windows_path_outside_repo() {
let repo_root = Path::new(r"C:\workspace");
let outside_path = Path::new(r"D:\other\file.txt");
let result = normalize_to_relpath(outside_path, repo_root);
assert!(result.is_err());
let err_msg = result.unwrap_err().to_string();
assert!(err_msg.contains("outside repository root"));
}
#[test]
#[cfg(target_os = "windows")]
fn test_windows_unc_path() {
let repo_root = Path::new(r"\\server\share\workspace");
let absolute_path = Path::new(r"\\server\share\workspace\src\main.rs");
let result = normalize_to_relpath(absolute_path, repo_root).unwrap();
assert_eq!(result, Path::new(r"src\main.rs"));
}
}