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 resolve_artifact_dir(
workflow_dir: &Path,
configured_dir: Option<&str>,
) -> Result<PathBuf, NikaError> {
let dir_str = configured_dir.unwrap_or(DEFAULT_ARTIFACT_DIR);
if dir_str.len() > MAX_PATH_LENGTH {
return Err(NikaError::ArtifactPathError {
path: dir_str.to_string(),
reason: format!(
"Path exceeds maximum length of {} characters",
MAX_PATH_LENGTH
),
});
}
let resolved = workflow_dir.join(dir_str);
validate_path_boundary(workflow_dir, &resolved)?;
Ok(resolved)
}
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 normalized = PathBuf::new();
for component in path.components() {
match component {
std::path::Component::ParentDir => {
normalized.pop();
}
std::path::Component::CurDir => {
}
_ => {
normalized.push(component);
}
}
}
normalized
}
fn validate_path_boundary(base_path: &Path, target_path: &Path) -> Result<(), NikaError> {
let normalized_base = normalize_path(base_path);
let normalized_target = normalize_path(target_path);
if !normalized_target.starts_with(&normalized_base) {
return Err(NikaError::ArtifactPathError {
path: target_path.display().to_string(),
reason: format!(
"Path '{}' is outside allowed boundary '{}'",
target_path.display(),
base_path.display()
),
});
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::tempdir;
#[test]
fn test_resolve_artifact_dir_default() {
let workflow_dir = PathBuf::from("/project");
let result = resolve_artifact_dir(&workflow_dir, None);
assert!(result.is_ok());
assert_eq!(result.unwrap(), PathBuf::from("/project/.nika/artifacts"));
}
#[test]
fn test_resolve_artifact_dir_custom() {
let workflow_dir = PathBuf::from("/project");
let result = resolve_artifact_dir(&workflow_dir, Some("output"));
assert!(result.is_ok());
assert_eq!(result.unwrap(), PathBuf::from("/project/output"));
}
#[test]
fn test_resolve_artifact_dir_nested() {
let workflow_dir = PathBuf::from("/project");
let result = resolve_artifact_dir(&workflow_dir, Some("build/artifacts"));
assert!(result.is_ok());
assert_eq!(result.unwrap(), PathBuf::from("/project/build/artifacts"));
}
#[test]
fn test_resolve_artifact_dir_path_traversal() {
let workflow_dir = PathBuf::from("/project");
let result = resolve_artifact_dir(&workflow_dir, Some("../outside"));
assert!(result.is_err());
let err = result.unwrap_err();
assert!(matches!(err, NikaError::ArtifactPathError { .. }));
}
#[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());
}
}