//! Path validation utilities to prevent path traversal attacks. //! //! This module provides functions to validate and sanitize file paths, //! ensuring they remain within allowed root directories and don't contain //! malicious path traversal sequences. use std::path::{Path, PathBuf}; use crate::error::{PinakesError, Result}; /// Validates that a path is within one of the allowed root directories. /// /// This function: /// 1. Canonicalizes the path to resolve any symlinks and `..` sequences /// 2. Checks that the canonical path starts with one of the allowed roots /// 3. Returns the canonical path if valid, or an error if not /// /// # Security /// /// This prevents path traversal attacks where an attacker might try to /// access files outside the allowed directories using sequences like: /// - `../../../etc/passwd` /// - `/media/../../../etc/passwd` /// - Symlinks pointing outside allowed roots /// /// # Arguments /// /// * `path` - The path to validate /// * `allowed_roots` - List of allowed root directories /// /// # Returns /// /// The canonicalized path if valid, or a `PathNotAllowed` error if the path /// is outside all allowed roots. /// /// # Example /// /// ```no_run /// use std::path::PathBuf; /// /// use pinakes_core::path_validation::validate_path; /// /// let allowed_roots = vec![ /// PathBuf::from("/media"), /// PathBuf::from("/home/user/documents"), /// ]; /// let path = PathBuf::from("/media/music/song.mp3"); /// /// let validated = validate_path(&path, &allowed_roots).unwrap(); /// ``` pub fn validate_path( path: &Path, allowed_roots: &[PathBuf], ) -> Result { // Handle the case where no roots are configured if allowed_roots.is_empty() { return Err(PinakesError::PathNotAllowed( "no allowed roots configured".to_string(), )); } // First check if the path exists if !path.exists() { return Err(PinakesError::PathNotAllowed(format!( "path does not exist: {}", path.display() ))); } // Canonicalize to resolve symlinks and relative components let canonical = path.canonicalize().map_err(|e| { PinakesError::PathNotAllowed(format!( "failed to canonicalize path {}: {}", path.display(), e )) })?; // Check if the canonical path is within any allowed root let canonical_roots: Vec = allowed_roots .iter() .filter_map(|root| root.canonicalize().ok()) .collect(); if canonical_roots.is_empty() { return Err(PinakesError::PathNotAllowed( "no accessible allowed roots".to_string(), )); } let is_allowed = canonical_roots .iter() .any(|root| canonical.starts_with(root)); if is_allowed { Ok(canonical) } else { Err(PinakesError::PathNotAllowed(format!( "path {} is outside allowed roots", path.display() ))) } } /// Validates a path relative to a single root directory. /// /// This is a convenience wrapper for `validate_path` when you only have one /// root. pub fn validate_path_single_root(path: &Path, root: &Path) -> Result { validate_path(path, &[root.to_path_buf()]) } /// Checks if a path appears to contain traversal sequences without /// canonicalizing. /// /// This is a quick pre-check that can reject obviously malicious paths without /// hitting the filesystem. It should be used in addition to `validate_path`, /// not as a replacement. /// /// # Arguments /// /// * `path` - The path string to check /// /// # Returns /// /// `true` if the path appears safe (no obvious traversal sequences), /// `false` if it contains suspicious patterns. pub fn path_looks_safe(path: &str) -> bool { // Reject paths with obvious traversal patterns !path.contains("..") && !path.contains("//") && !path.starts_with('/') && path.chars().filter(|c| *c == '/').count() < 50 // Reasonable depth limit } /// Sanitizes a filename by removing or replacing dangerous characters. /// /// This removes: /// - Path separators (`/`, `\`) /// - Null bytes /// - Control characters /// - Leading dots (to prevent hidden files) /// /// # Arguments /// /// * `filename` - The filename to sanitize /// /// # Returns /// /// A sanitized filename safe for use on most filesystems. pub fn sanitize_filename(filename: &str) -> String { let sanitized: String = filename .chars() .filter(|c| { // Allow alphanumeric, common punctuation, and unicode letters c.is_alphanumeric() || matches!(*c, '-' | '_' | '.' | ' ' | '(' | ')' | '[' | ']') }) .collect(); // Remove leading dots to prevent hidden files let sanitized = sanitized.trim_start_matches('.'); // Remove leading/trailing whitespace let sanitized = sanitized.trim(); // Ensure the filename isn't empty after sanitization if sanitized.is_empty() { "unnamed".to_string() } else { sanitized.to_string() } } /// Joins a base path with a relative path safely. /// /// This ensures the resulting path doesn't escape the base directory /// through use of `..` or absolute paths in the relative component. /// /// # Arguments /// /// * `base` - The base directory /// * `relative` - The relative path to join /// /// # Returns /// /// The joined path if safe, or an error if the relative path would escape the /// base. pub fn safe_join(base: &Path, relative: &str) -> Result { // Reject absolute paths in the relative component if relative.starts_with('/') || relative.starts_with('\\') { return Err(PinakesError::PathNotAllowed( "relative path cannot be absolute".to_string(), )); } // Reject paths with .. traversal if relative.contains("..") { return Err(PinakesError::PathNotAllowed( "relative path cannot contain '..'".to_string(), )); } // Build the path and validate it stays within base let joined = base.join(relative); // Canonicalize base for comparison let canonical_base = base.canonicalize().map_err(|e| { PinakesError::PathNotAllowed(format!( "failed to canonicalize base {}: {}", base.display(), e )) })?; // The joined path might not exist yet, so we can't canonicalize it directly. // Instead, we check each component let mut current = canonical_base.clone(); for component in Path::new(relative).components() { use std::path::Component; match component { Component::Normal(name) => { current = current.join(name); }, Component::ParentDir => { return Err(PinakesError::PathNotAllowed( "path traversal detected".to_string(), )); }, Component::CurDir => continue, _ => { return Err(PinakesError::PathNotAllowed( "invalid path component".to_string(), )); }, } } Ok(joined) } #[cfg(test)] mod tests { use std::fs; use tempfile::TempDir; use super::*; fn setup_test_dirs() -> TempDir { let temp = TempDir::new().unwrap(); fs::create_dir_all(temp.path().join("allowed")).unwrap(); fs::create_dir_all(temp.path().join("forbidden")).unwrap(); fs::write(temp.path().join("allowed/file.txt"), "test").unwrap(); fs::write(temp.path().join("forbidden/secret.txt"), "secret").unwrap(); temp } #[test] fn test_validate_path_allowed() { let temp = setup_test_dirs(); let allowed_roots = vec![temp.path().join("allowed")]; let path = temp.path().join("allowed/file.txt"); let result = validate_path(&path, &allowed_roots); assert!(result.is_ok()); } #[test] fn test_validate_path_forbidden() { let temp = setup_test_dirs(); let allowed_roots = vec![temp.path().join("allowed")]; let path = temp.path().join("forbidden/secret.txt"); let result = validate_path(&path, &allowed_roots); assert!(result.is_err()); } #[test] fn test_validate_path_traversal() { let temp = setup_test_dirs(); let allowed_roots = vec![temp.path().join("allowed")]; let path = temp.path().join("allowed/../forbidden/secret.txt"); let result = validate_path(&path, &allowed_roots); assert!(result.is_err()); } #[test] fn test_sanitize_filename() { assert_eq!(sanitize_filename("normal.txt"), "normal.txt"); assert_eq!(sanitize_filename("../../../etc/passwd"), "etcpasswd"); assert_eq!(sanitize_filename(".hidden"), "hidden"); assert_eq!(sanitize_filename("filebad:chars"), "filewithbadchars"); assert_eq!(sanitize_filename(""), "unnamed"); assert_eq!(sanitize_filename("..."), "unnamed"); } #[test] fn test_path_looks_safe() { assert!(path_looks_safe("normal/path/file.txt")); assert!(!path_looks_safe("../../../etc/passwd")); assert!(!path_looks_safe("path//double/slash")); } #[test] fn test_safe_join() { let temp = TempDir::new().unwrap(); let base = temp.path(); // Valid join let result = safe_join(base, "subdir/file.txt"); assert!(result.is_ok()); // Traversal attempt let result = safe_join(base, "../etc/passwd"); assert!(result.is_err()); // Absolute path attempt let result = safe_join(base, "/etc/passwd"); assert!(result.is_err()); } }