use anyhow::{Context, Result, anyhow};
use std::path::{Component, Path, PathBuf};
pub fn validate_project_path(path: &Path, project_dir: &Path) -> Result<PathBuf> {
let canonical = safe_canonicalize(path)?;
let project_canonical = safe_canonicalize(project_dir)?;
if !canonical.starts_with(&project_canonical) {
return Err(anyhow!("Path '{}' escapes project directory", path.display()));
}
Ok(canonical)
}
pub fn safe_canonicalize(path: &Path) -> Result<PathBuf> {
if !path.exists() {
if let Some(parent) = path.parent()
&& parent.exists()
{
let canonical_parent = parent.canonicalize().with_context(|| {
format!("Failed to canonicalize parent of '{}'", path.display())
})?;
if let Some(file_name) = path.file_name() {
return Ok(canonical_parent.join(file_name));
}
}
return Err(anyhow!("Path does not exist: {}", path.display()));
}
path.canonicalize().with_context(|| format!("Failed to canonicalize path: {}", path.display()))
}
pub fn ensure_within_directory(path: &Path, boundary: &Path) -> Result<bool> {
let canonical_path = safe_canonicalize(path)?;
let canonical_boundary = safe_canonicalize(boundary)?;
Ok(canonical_path.starts_with(&canonical_boundary))
}
pub fn validate_no_traversal(path: &Path) -> Result<()> {
for component in path.components() {
match component {
Component::ParentDir => {
return Err(anyhow!(
"Path contains parent directory reference (..): {}",
path.display()
));
}
Component::RootDir => {
}
_ => {}
}
}
Ok(())
}
pub fn safe_relative_path(base: &Path, target: &Path) -> Result<PathBuf> {
let base_canonical = safe_canonicalize(base)?;
let target_canonical = safe_canonicalize(target)?;
target_canonical.strip_prefix(&base_canonical).map(std::path::Path::to_path_buf).map_err(|_| {
anyhow!("Cannot create relative path from {} to {}", base.display(), target.display())
})
}
pub fn ensure_directory_exists(dir: &Path) -> Result<PathBuf> {
if !dir.exists() {
std::fs::create_dir_all(dir)
.with_context(|| format!("Failed to create directory: {}", dir.display()))?;
}
safe_canonicalize(dir)
}
pub fn validate_resource_path(
path: &Path,
resource_type: &str,
project_dir: &Path,
) -> Result<PathBuf> {
validate_no_traversal(path)?;
let full_path = if path.is_absolute() {
path.to_path_buf()
} else {
project_dir.join(path)
};
let canonical_project = safe_canonicalize(project_dir)?;
if full_path.exists() {
validate_project_path(&full_path, project_dir)?;
} else {
if let Some(parent) = full_path.parent()
&& parent.exists()
{
let canonical_parent = safe_canonicalize(parent)?;
if !canonical_parent.starts_with(&canonical_project) {
return Err(anyhow!("Path '{}' escapes project directory", full_path.display()));
}
}
}
if resource_type != "directory" && full_path.extension().is_none_or(|ext| ext != "md") {
return Err(anyhow!(
"Invalid {} file: expected .md extension, got {}",
resource_type,
full_path.display()
));
}
Ok(full_path)
}
pub fn sanitize_file_name(name: &str) -> String {
name.chars().filter(|c| c.is_alphanumeric() || *c == '-' || *c == '_' || *c == '.').collect()
}
pub fn find_project_root(start_path: &Path) -> Result<PathBuf> {
let mut current = if start_path.is_file() {
start_path.parent().ok_or_else(|| anyhow!("Invalid start path"))?
} else {
start_path
};
loop {
if current.join("agpm.toml").exists() {
return safe_canonicalize(current);
}
match current.parent() {
Some(parent) => current = parent,
None => {
return Err(anyhow!(
"No agpm.toml found in any parent directory of {}",
start_path.display()
));
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::tempdir;
#[test]
fn test_validate_no_traversal() {
assert!(validate_no_traversal(Path::new("foo/bar")).is_ok());
assert!(validate_no_traversal(Path::new("/absolute/path")).is_ok());
assert!(validate_no_traversal(Path::new("./relative")).is_ok());
assert!(validate_no_traversal(Path::new("../parent")).is_err());
assert!(validate_no_traversal(Path::new("foo/../bar")).is_err());
assert!(validate_no_traversal(Path::new("../../escape")).is_err());
}
#[test]
fn test_sanitize_file_name() {
assert_eq!(sanitize_file_name("valid-name_123.md"), "valid-name_123.md");
assert_eq!(sanitize_file_name("bad/\\name<>:|?*"), "badname");
assert_eq!(sanitize_file_name("spaces are removed"), "spacesareremoved");
}
#[test]
fn test_validate_project_path() -> Result<()> {
let temp_dir = tempdir()?;
let project_dir = temp_dir.path();
let test_file = project_dir.join("test.md");
fs::write(&test_file, "test")?;
let result = validate_project_path(&test_file, project_dir)?;
let canonical_project = project_dir.canonicalize()?;
assert!(result.starts_with(&canonical_project));
let outside_path = temp_dir.path().parent().unwrap().join("outside.md");
assert!(validate_project_path(&outside_path, project_dir).is_err());
Ok(())
}
#[test]
fn test_ensure_directory_exists() -> Result<()> {
let temp_dir = tempdir()?;
let new_dir = temp_dir.path().join("new").join("nested").join("dir");
assert!(!new_dir.exists());
let result = ensure_directory_exists(&new_dir)?;
assert!(result.exists());
assert!(result.is_dir());
Ok(())
}
#[test]
fn test_find_project_root() -> Result<()> {
let temp_dir = tempdir()?;
let project_dir = temp_dir.path();
fs::write(project_dir.join("agpm.toml"), "[project]")?;
let nested = project_dir.join("src").join("nested");
fs::create_dir_all(&nested)?;
let found = find_project_root(&nested)?;
assert_eq!(found, project_dir.canonicalize()?);
let file_path = nested.join("file.rs");
fs::write(&file_path, "// test")?;
let found = find_project_root(&file_path)?;
assert_eq!(found, project_dir.canonicalize()?);
Ok(())
}
#[test]
fn test_safe_relative_path() -> Result<()> {
let temp_dir = tempdir()?;
let base = temp_dir.path();
let target = base.join("subdir").join("file.md");
fs::create_dir_all(target.parent().unwrap())?;
fs::write(&target, "test")?;
let relative = safe_relative_path(base, &target)?;
assert_eq!(relative, Path::new("subdir").join("file.md"));
Ok(())
}
#[test]
fn test_validate_resource_path() -> Result<()> {
let temp_dir = tempdir()?;
let project_dir = temp_dir.path();
let agent_path = Path::new("agents/my-agent.md");
validate_resource_path(agent_path, "agent", project_dir)?;
let wrong_ext = Path::new("agents/my-agent.txt");
let result = validate_resource_path(wrong_ext, "agent", project_dir);
assert!(result.is_err());
let traversal = Path::new("../outside/agent.md");
let result = validate_resource_path(traversal, "agent", project_dir);
assert!(result.is_err());
Ok(())
}
}