nika_engine/io/
security.rs1use std::path::{Path, PathBuf};
25
26use crate::error::NikaError;
27
28pub const DEFAULT_ARTIFACT_DIR: &str = ".nika/artifacts";
30
31const MAX_PATH_LENGTH: usize = 4096;
33
34pub fn validate_artifact_path(
60 artifact_dir: &Path,
61 output_path: &Path,
62) -> Result<PathBuf, NikaError> {
63 let output_str = output_path.to_string_lossy();
64
65 if output_str.len() > MAX_PATH_LENGTH {
67 return Err(NikaError::ArtifactPathError {
68 path: output_str.to_string(),
69 reason: format!(
70 "Path exceeds maximum length of {} characters",
71 MAX_PATH_LENGTH
72 ),
73 });
74 }
75
76 if output_path.is_absolute() {
78 return Err(NikaError::ArtifactPathError {
79 path: output_str.to_string(),
80 reason: "Absolute paths are not allowed in artifact output".to_string(),
81 });
82 }
83
84 let full_path = artifact_dir.join(output_path);
86
87 validate_path_components(output_path)?;
90
91 let normalized = normalize_path(&full_path);
94
95 let canonical_base = if artifact_dir.exists() {
97 artifact_dir
98 .canonicalize()
99 .map_err(|e| NikaError::ArtifactPathError {
100 path: artifact_dir.display().to_string(),
101 reason: format!("Failed to canonicalize artifact directory: {}", e),
102 })?
103 } else {
104 normalize_path(artifact_dir)
106 };
107
108 if !normalized.starts_with(&canonical_base) {
110 return Err(NikaError::ArtifactPathError {
111 path: output_str.to_string(),
112 reason: format!(
113 "Path traversal detected: '{}' would escape artifact directory '{}'",
114 output_path.display(),
115 artifact_dir.display()
116 ),
117 });
118 }
119
120 Ok(full_path)
121}
122
123fn validate_path_components(path: &Path) -> Result<(), NikaError> {
130 for component in path.components() {
131 let component_str = component.as_os_str().to_string_lossy();
132
133 if component_str.contains('\0') {
135 return Err(NikaError::ArtifactPathError {
136 path: path.display().to_string(),
137 reason: "Path contains null bytes".to_string(),
138 });
139 }
140
141 if component_str.chars().any(|c| c.is_control() && c != '\t') {
143 return Err(NikaError::ArtifactPathError {
144 path: path.display().to_string(),
145 reason: "Path contains control characters".to_string(),
146 });
147 }
148 }
149
150 Ok(())
151}
152
153fn normalize_path(path: &Path) -> PathBuf {
163 let mut components: Vec<std::path::Component<'_>> = Vec::new();
164
165 for component in path.components() {
166 match component {
167 std::path::Component::ParentDir => {
168 match components.last() {
172 Some(std::path::Component::Normal(_)) => {
173 components.pop();
174 }
175 Some(std::path::Component::RootDir) | Some(std::path::Component::Prefix(_)) => {
176 }
178 _ => {
179 components.push(component);
181 }
182 }
183 }
184 std::path::Component::CurDir => {
185 }
187 _ => {
188 components.push(component);
189 }
190 }
191 }
192
193 components.iter().collect()
194}
195
196#[derive(Debug, Clone)]
200pub struct PathBoundaryError {
201 pub target_path: PathBuf,
203 pub reason: String,
205}
206
207impl std::fmt::Display for PathBoundaryError {
208 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
209 write!(f, "{}", self.reason)
210 }
211}
212
213impl std::error::Error for PathBoundaryError {}
214
215pub fn validate_canonicalized_boundary(
245 base_path: &Path,
246 target_path: &Path,
247) -> Result<(), PathBoundaryError> {
248 let canonical_base = base_path.canonicalize().map_err(|e| PathBoundaryError {
249 target_path: target_path.to_path_buf(),
250 reason: format!("Cannot resolve base path '{}': {}", base_path.display(), e),
251 })?;
252
253 let canonical_target = target_path.canonicalize().map_err(|e| PathBoundaryError {
254 target_path: target_path.to_path_buf(),
255 reason: format!(
256 "Cannot resolve target path '{}': {}",
257 target_path.display(),
258 e
259 ),
260 })?;
261
262 if !canonical_target.starts_with(&canonical_base) {
263 return Err(PathBoundaryError {
264 target_path: target_path.to_path_buf(),
265 reason: format!(
266 "Path traversal detected: '{}' is outside project boundary '{}'",
267 target_path.display(),
268 base_path.display()
269 ),
270 });
271 }
272
273 Ok(())
274}
275
276#[cfg(test)]
277mod tests {
278 use super::*;
279 use std::fs;
280 use tempfile::tempdir;
281
282 #[test]
283 fn test_validate_artifact_path_simple() {
284 let artifact_dir = PathBuf::from("/project/artifacts");
285 let result = validate_artifact_path(&artifact_dir, Path::new("task1/output.json"));
286 assert!(result.is_ok());
287 assert_eq!(
288 result.unwrap(),
289 PathBuf::from("/project/artifacts/task1/output.json")
290 );
291 }
292
293 #[test]
294 fn test_validate_artifact_path_nested() {
295 let artifact_dir = PathBuf::from("/project/artifacts");
296 let result = validate_artifact_path(&artifact_dir, Path::new("2024/01/15/report.json"));
297 assert!(result.is_ok());
298 }
299
300 #[test]
301 fn test_validate_artifact_path_traversal_blocked() {
302 let artifact_dir = PathBuf::from("/project/artifacts");
303 let result = validate_artifact_path(&artifact_dir, Path::new("../../../etc/passwd"));
304 assert!(result.is_err());
305 let err = result.unwrap_err();
306 assert!(matches!(err, NikaError::ArtifactPathError { .. }));
307 }
308
309 #[test]
310 fn test_validate_artifact_path_absolute_rejected() {
311 let artifact_dir = PathBuf::from("/project/artifacts");
312 let result = validate_artifact_path(&artifact_dir, Path::new("/etc/passwd"));
313 assert!(result.is_err());
314 let err = result.unwrap_err();
315 if let NikaError::ArtifactPathError { reason, .. } = err {
316 assert!(reason.contains("Absolute paths"));
317 } else {
318 panic!("Expected ArtifactPathError");
319 }
320 }
321
322 #[test]
323 fn test_validate_artifact_path_null_byte_rejected() {
324 let artifact_dir = PathBuf::from("/project/artifacts");
325 let result = validate_artifact_path(&artifact_dir, Path::new("file\0.txt"));
326 assert!(result.is_err());
327 let err = result.unwrap_err();
328 if let NikaError::ArtifactPathError { reason, .. } = err {
329 assert!(reason.contains("null bytes"));
330 } else {
331 panic!("Expected ArtifactPathError");
332 }
333 }
334
335 #[test]
336 fn test_validate_path_components_clean() {
337 let result = validate_path_components(Path::new("task1/output.json"));
338 assert!(result.is_ok());
339 }
340
341 #[test]
342 fn test_validate_path_components_with_dots() {
343 let result = validate_path_components(Path::new("../parent"));
344 assert!(result.is_ok()); }
346
347 #[test]
348 fn test_normalize_path_removes_parent_refs() {
349 let path = PathBuf::from("/project/artifacts/../output");
350 let normalized = normalize_path(&path);
351 assert_eq!(normalized, PathBuf::from("/project/output"));
352 }
353
354 #[test]
355 fn test_normalize_path_removes_current_refs() {
356 let path = PathBuf::from("/project/./artifacts/./output");
357 let normalized = normalize_path(&path);
358 assert_eq!(normalized, PathBuf::from("/project/artifacts/output"));
359 }
360
361 #[test]
362 fn test_normalize_path_complex() {
363 let path = PathBuf::from("/project/a/b/../c/./d/../e");
364 let normalized = normalize_path(&path);
365 assert_eq!(normalized, PathBuf::from("/project/a/c/e"));
366 }
367
368 #[test]
369 fn test_max_path_length_enforced() {
370 let artifact_dir = PathBuf::from("/project/artifacts");
371 let long_path = "a".repeat(MAX_PATH_LENGTH + 1);
372 let result = validate_artifact_path(&artifact_dir, Path::new(&long_path));
373 assert!(result.is_err());
374 let err = result.unwrap_err();
375 if let NikaError::ArtifactPathError { reason, .. } = err {
376 assert!(reason.contains("maximum length"));
377 } else {
378 panic!("Expected ArtifactPathError");
379 }
380 }
381
382 #[test]
383 fn test_validate_with_existing_dir() {
384 let temp = tempdir().unwrap();
385 let artifact_dir = temp.path().join("artifacts");
386 fs::create_dir_all(&artifact_dir).unwrap();
387
388 let canonical_artifact_dir = artifact_dir.canonicalize().unwrap();
390 let result = validate_artifact_path(&canonical_artifact_dir, Path::new("output.json"));
391 assert!(result.is_ok());
392 }
393
394 #[test]
395 fn test_hidden_parent_escape() {
396 let artifact_dir = PathBuf::from("/project/artifacts");
397 let result = validate_artifact_path(&artifact_dir, Path::new("a/../../b"));
399 assert!(result.is_err());
400 }
401
402 #[cfg(unix)]
407 #[test]
408 fn test_validate_canonicalized_boundary_detects_symlink_escape() {
409 use std::os::unix::fs::symlink;
410
411 let temp = tempdir().unwrap();
412 let base_dir = temp.path().join("artifacts");
413 fs::create_dir_all(&base_dir).unwrap();
414
415 let escape_target = temp.path().join("outside");
416 fs::create_dir_all(&escape_target).unwrap();
417 let secret_file = escape_target.join("secret.txt");
418 fs::write(&secret_file, "sensitive data").unwrap();
419
420 let symlink_path = base_dir.join("evil");
421 symlink(&escape_target, &symlink_path).unwrap();
422
423 let result = validate_canonicalized_boundary(&base_dir, &symlink_path.join("secret.txt"));
424 assert!(
425 result.is_err(),
426 "validate_canonicalized_boundary must detect symlink-based escape"
427 );
428 assert!(
429 result.unwrap_err().reason.contains("traversal"),
430 "Error should mention path traversal"
431 );
432 }
433
434 #[cfg(unix)]
435 #[test]
436 fn test_validate_artifact_path_does_not_resolve_symlinks() {
437 use std::os::unix::fs::symlink;
441
442 let temp = tempdir().unwrap();
443 let artifact_dir = temp.path().join("artifacts");
444 fs::create_dir_all(&artifact_dir).unwrap();
445 let canonical_dir = artifact_dir.canonicalize().unwrap();
446
447 let escape_target = temp.path().join("outside");
448 fs::create_dir_all(&escape_target).unwrap();
449 let symlink_dir = canonical_dir.join("escape_link");
450 symlink(&escape_target, &symlink_dir).unwrap();
451
452 let result = validate_artifact_path(&canonical_dir, Path::new("escape_link/file.txt"));
453 assert!(
454 result.is_ok(),
455 "validate_artifact_path does not resolve symlinks (known limitation)"
456 );
457 }
458
459 #[test]
460 fn test_validate_artifact_path_dot_dot_in_middle() {
461 let artifact_dir = PathBuf::from("/project/artifacts");
462 let result = validate_artifact_path(&artifact_dir, Path::new("subdir/../../escape"));
463 assert!(
464 result.is_err(),
465 "Path with .. escaping via subdirectory must be blocked"
466 );
467 }
468
469 #[test]
470 fn test_validate_artifact_path_deep_traversal() {
471 let artifact_dir = PathBuf::from("/project/artifacts");
472 let result = validate_artifact_path(
473 &artifact_dir,
474 Path::new("a/b/c/d/../../../../../../../../etc/passwd"),
475 );
476 assert!(result.is_err(), "Deep path traversal must be blocked");
477 }
478
479 #[test]
480 fn test_validate_artifact_path_control_chars_blocked() {
481 let artifact_dir = PathBuf::from("/project/artifacts");
482 let result = validate_artifact_path(&artifact_dir, Path::new("file\r\ninjection"));
483 assert!(
484 result.is_err(),
485 "Control characters in path must be blocked"
486 );
487 }
488
489 #[test]
490 fn test_normalize_path_preserves_unresolvable_parent() {
491 let path = PathBuf::from("../../etc/passwd");
493 let normalized = normalize_path(&path);
494 assert_eq!(
495 normalized,
496 PathBuf::from("../../etc/passwd"),
497 "`..` at start of relative path must be preserved for boundary checks"
498 );
499 }
500
501 #[test]
502 fn test_normalize_path_absolute_root_clamp() {
503 let path = PathBuf::from("/a/../../b");
505 let normalized = normalize_path(&path);
506 assert_eq!(normalized, PathBuf::from("/b"));
507 }
508
509 #[test]
510 fn test_normalize_path_mixed_relative() {
511 let path = PathBuf::from("a/b/../../c/../../../etc");
513 let normalized = normalize_path(&path);
514 assert_eq!(normalized, PathBuf::from("../../etc"));
516 }
517}