use std::path::{Path, PathBuf};
use crate::error::NikaError;
pub const DEFAULT_ARTIFACT_DIR: &str = ".nika/artifacts";
const MAX_PATH_LENGTH: usize = 4096;
pub fn validate_artifact_path(
artifact_dir: &Path,
output_path: &Path,
) -> Result<PathBuf, NikaError> {
let output_str = output_path.to_string_lossy();
if output_str.len() > MAX_PATH_LENGTH {
return Err(NikaError::ArtifactPathError {
path: output_str.to_string(),
reason: format!(
"Path exceeds maximum length of {} characters",
MAX_PATH_LENGTH
),
});
}
if output_path.is_absolute() {
return Err(NikaError::ArtifactPathError {
path: output_str.to_string(),
reason: "Absolute paths are not allowed in artifact output".to_string(),
});
}
let full_path = artifact_dir.join(output_path);
validate_path_components(output_path)?;
let normalized = normalize_path(&full_path);
let canonical_base = if artifact_dir.exists() {
artifact_dir
.canonicalize()
.map_err(|e| NikaError::ArtifactPathError {
path: artifact_dir.display().to_string(),
reason: format!("Failed to canonicalize artifact directory: {}", e),
})?
} else {
normalize_path(artifact_dir)
};
if !normalized.starts_with(&canonical_base) {
return Err(NikaError::ArtifactPathError {
path: output_str.to_string(),
reason: format!(
"Path traversal detected: '{}' would escape artifact directory '{}'",
output_path.display(),
artifact_dir.display()
),
});
}
Ok(full_path)
}
fn validate_path_components(path: &Path) -> Result<(), NikaError> {
for component in path.components() {
let component_str = component.as_os_str().to_string_lossy();
if component_str.contains('\0') {
return Err(NikaError::ArtifactPathError {
path: path.display().to_string(),
reason: "Path contains null bytes".to_string(),
});
}
if component_str.chars().any(|c| c.is_control() && c != '\t') {
return Err(NikaError::ArtifactPathError {
path: path.display().to_string(),
reason: "Path contains control characters".to_string(),
});
}
}
Ok(())
}
fn normalize_path(path: &Path) -> PathBuf {
let mut components: Vec<std::path::Component<'_>> = Vec::new();
for component in path.components() {
match component {
std::path::Component::ParentDir => {
match components.last() {
Some(std::path::Component::Normal(_)) => {
components.pop();
}
Some(std::path::Component::RootDir) | Some(std::path::Component::Prefix(_)) => {
}
_ => {
components.push(component);
}
}
}
std::path::Component::CurDir => {
}
_ => {
components.push(component);
}
}
}
components.iter().collect()
}
#[derive(Debug, Clone)]
pub struct PathBoundaryError {
#[allow(dead_code)] pub base_path: PathBuf,
pub target_path: PathBuf,
pub reason: String,
}
impl std::fmt::Display for PathBoundaryError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.reason)
}
}
impl std::error::Error for PathBoundaryError {}
pub fn validate_canonicalized_boundary(
base_path: &Path,
target_path: &Path,
) -> Result<(), PathBoundaryError> {
let canonical_base = base_path.canonicalize().map_err(|e| PathBoundaryError {
base_path: base_path.to_path_buf(),
target_path: target_path.to_path_buf(),
reason: format!("Cannot resolve base path '{}': {}", base_path.display(), e),
})?;
let canonical_target = target_path.canonicalize().map_err(|e| PathBoundaryError {
base_path: base_path.to_path_buf(),
target_path: target_path.to_path_buf(),
reason: format!(
"Cannot resolve target path '{}': {}",
target_path.display(),
e
),
})?;
if !canonical_target.starts_with(&canonical_base) {
return Err(PathBoundaryError {
base_path: base_path.to_path_buf(),
target_path: target_path.to_path_buf(),
reason: format!(
"Path traversal detected: '{}' is outside project boundary '{}'",
target_path.display(),
base_path.display()
),
});
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::tempdir;
#[test]
fn test_validate_artifact_path_simple() {
let artifact_dir = PathBuf::from("/project/artifacts");
let result = validate_artifact_path(&artifact_dir, Path::new("task1/output.json"));
assert!(result.is_ok());
assert_eq!(
result.unwrap(),
PathBuf::from("/project/artifacts/task1/output.json")
);
}
#[test]
fn test_validate_artifact_path_nested() {
let artifact_dir = PathBuf::from("/project/artifacts");
let result = validate_artifact_path(&artifact_dir, Path::new("2024/01/15/report.json"));
assert!(result.is_ok());
}
#[test]
fn test_validate_artifact_path_traversal_blocked() {
let artifact_dir = PathBuf::from("/project/artifacts");
let result = validate_artifact_path(&artifact_dir, Path::new("../../../etc/passwd"));
assert!(result.is_err());
let err = result.unwrap_err();
assert!(matches!(err, NikaError::ArtifactPathError { .. }));
}
#[test]
fn test_validate_artifact_path_absolute_rejected() {
let artifact_dir = PathBuf::from("/project/artifacts");
let result = validate_artifact_path(&artifact_dir, Path::new("/etc/passwd"));
assert!(result.is_err());
let err = result.unwrap_err();
if let NikaError::ArtifactPathError { reason, .. } = err {
assert!(reason.contains("Absolute paths"));
} else {
panic!("Expected ArtifactPathError");
}
}
#[test]
fn test_validate_artifact_path_null_byte_rejected() {
let artifact_dir = PathBuf::from("/project/artifacts");
let result = validate_artifact_path(&artifact_dir, Path::new("file\0.txt"));
assert!(result.is_err());
let err = result.unwrap_err();
if let NikaError::ArtifactPathError { reason, .. } = err {
assert!(reason.contains("null bytes"));
} else {
panic!("Expected ArtifactPathError");
}
}
#[test]
fn test_validate_path_components_clean() {
let result = validate_path_components(Path::new("task1/output.json"));
assert!(result.is_ok());
}
#[test]
fn test_validate_path_components_with_dots() {
let result = validate_path_components(Path::new("../parent"));
assert!(result.is_ok()); }
#[test]
fn test_normalize_path_removes_parent_refs() {
let path = PathBuf::from("/project/artifacts/../output");
let normalized = normalize_path(&path);
assert_eq!(normalized, PathBuf::from("/project/output"));
}
#[test]
fn test_normalize_path_removes_current_refs() {
let path = PathBuf::from("/project/./artifacts/./output");
let normalized = normalize_path(&path);
assert_eq!(normalized, PathBuf::from("/project/artifacts/output"));
}
#[test]
fn test_normalize_path_complex() {
let path = PathBuf::from("/project/a/b/../c/./d/../e");
let normalized = normalize_path(&path);
assert_eq!(normalized, PathBuf::from("/project/a/c/e"));
}
#[test]
fn test_max_path_length_enforced() {
let artifact_dir = PathBuf::from("/project/artifacts");
let long_path = "a".repeat(MAX_PATH_LENGTH + 1);
let result = validate_artifact_path(&artifact_dir, Path::new(&long_path));
assert!(result.is_err());
let err = result.unwrap_err();
if let NikaError::ArtifactPathError { reason, .. } = err {
assert!(reason.contains("maximum length"));
} else {
panic!("Expected ArtifactPathError");
}
}
#[test]
fn test_validate_with_existing_dir() {
let temp = tempdir().unwrap();
let artifact_dir = temp.path().join("artifacts");
fs::create_dir_all(&artifact_dir).unwrap();
let canonical_artifact_dir = artifact_dir.canonicalize().unwrap();
let result = validate_artifact_path(&canonical_artifact_dir, Path::new("output.json"));
assert!(result.is_ok());
}
#[test]
fn test_hidden_parent_escape() {
let artifact_dir = PathBuf::from("/project/artifacts");
let result = validate_artifact_path(&artifact_dir, Path::new("a/../../b"));
assert!(result.is_err());
}
#[cfg(unix)]
#[test]
fn test_validate_canonicalized_boundary_detects_symlink_escape() {
use std::os::unix::fs::symlink;
let temp = tempdir().unwrap();
let base_dir = temp.path().join("artifacts");
fs::create_dir_all(&base_dir).unwrap();
let escape_target = temp.path().join("outside");
fs::create_dir_all(&escape_target).unwrap();
let secret_file = escape_target.join("secret.txt");
fs::write(&secret_file, "sensitive data").unwrap();
let symlink_path = base_dir.join("evil");
symlink(&escape_target, &symlink_path).unwrap();
let result = validate_canonicalized_boundary(&base_dir, &symlink_path.join("secret.txt"));
assert!(
result.is_err(),
"validate_canonicalized_boundary must detect symlink-based escape"
);
assert!(
result.unwrap_err().reason.contains("traversal"),
"Error should mention path traversal"
);
}
#[cfg(unix)]
#[test]
fn test_validate_artifact_path_does_not_resolve_symlinks() {
use std::os::unix::fs::symlink;
let temp = tempdir().unwrap();
let artifact_dir = temp.path().join("artifacts");
fs::create_dir_all(&artifact_dir).unwrap();
let canonical_dir = artifact_dir.canonicalize().unwrap();
let escape_target = temp.path().join("outside");
fs::create_dir_all(&escape_target).unwrap();
let symlink_dir = canonical_dir.join("escape_link");
symlink(&escape_target, &symlink_dir).unwrap();
let result = validate_artifact_path(&canonical_dir, Path::new("escape_link/file.txt"));
assert!(
result.is_ok(),
"validate_artifact_path does not resolve symlinks (known limitation)"
);
}
#[test]
fn test_validate_artifact_path_dot_dot_in_middle() {
let artifact_dir = PathBuf::from("/project/artifacts");
let result = validate_artifact_path(&artifact_dir, Path::new("subdir/../../escape"));
assert!(
result.is_err(),
"Path with .. escaping via subdirectory must be blocked"
);
}
#[test]
fn test_validate_artifact_path_deep_traversal() {
let artifact_dir = PathBuf::from("/project/artifacts");
let result = validate_artifact_path(
&artifact_dir,
Path::new("a/b/c/d/../../../../../../../../etc/passwd"),
);
assert!(result.is_err(), "Deep path traversal must be blocked");
}
#[test]
fn test_validate_artifact_path_control_chars_blocked() {
let artifact_dir = PathBuf::from("/project/artifacts");
let result = validate_artifact_path(&artifact_dir, Path::new("file\r\ninjection"));
assert!(
result.is_err(),
"Control characters in path must be blocked"
);
}
#[test]
fn test_normalize_path_preserves_unresolvable_parent() {
let path = PathBuf::from("../../etc/passwd");
let normalized = normalize_path(&path);
assert_eq!(
normalized,
PathBuf::from("../../etc/passwd"),
"`..` at start of relative path must be preserved for boundary checks"
);
}
#[test]
fn test_normalize_path_absolute_root_clamp() {
let path = PathBuf::from("/a/../../b");
let normalized = normalize_path(&path);
assert_eq!(normalized, PathBuf::from("/b"));
}
#[test]
fn test_normalize_path_mixed_relative() {
let path = PathBuf::from("a/b/../../c/../../../etc");
let normalized = normalize_path(&path);
assert_eq!(normalized, PathBuf::from("../../etc"));
}
}