//! StoragePath - Domain Value Object for representing storage paths //! //! This module contains only the StoragePath Value Object which is part of the pure domain. //! PathService (which implements StoragePort and StorageMediator) was moved to //! infrastructure/services/path_service.rs because it has file system dependencies. use std::path::PathBuf; /// Validates a single file or folder name component. /// /// Returns `Err` with a human-readable reason if the name is rejected. /// Callers should wrap the reason into their own error type. pub fn validate_storage_name(name: &str) -> Result<(), &'static str> { if name.is_empty() { return Err("name cannot be empty"); } if name.contains('/') || name.contains('\\') { return Err("name must not contain '/' or '\\'"); } if name.contains('\0') { return Err("name must not contain null bytes"); } if name == "." || name == ".." { return Err("'.' and '..' are not valid names"); } Ok(()) } /// Represents a storage path in the domain (Value Object) #[derive(Debug, Clone, PartialEq, Eq, Default)] pub struct StoragePath { segments: Vec, } impl StoragePath { /// Checks whether a single segment is safe (no traversal, no slashes) fn is_safe_segment(s: &str) -> bool { !s.is_empty() && s != "." && s != ".." && !s.contains('/') } /// Creates a new storage path, silently dropping any traversal segments pub fn new(segments: Vec) -> Self { Self { segments: segments .into_iter() .filter(|s| Self::is_safe_segment(s)) .collect(), } } /// Creates an empty path (root) pub fn root() -> Self { Self { segments: Vec::new(), } } /// Creates a path from a string with segments separated by / /// /// Traversal segments (`.`, `..`) are silently stripped to prevent /// path-traversal attacks. pub fn from_string(path: &str) -> Self { let segments = path .split('/') .filter(|s| Self::is_safe_segment(s)) .map(|s| s.to_string()) .collect(); Self { segments } } /// Creates a path from a PathBuf pub fn from(path_buf: PathBuf) -> Self { let segments = path_buf .components() .filter_map(|c| match c { std::path::Component::Normal(os_str) => Some(os_str.to_string_lossy().to_string()), _ => None, }) .collect(); Self { segments } } /// Appends a segment to the path. /// /// Traversal segments (`.`, `..`) and segments containing `/` are /// silently ignored to prevent path-traversal attacks. pub fn join(&self, segment: &str) -> Self { let mut new_segments = self.segments.clone(); if Self::is_safe_segment(segment) { new_segments.push(segment.to_string()); } Self { segments: new_segments, } } /// Gets the file name (last segment) pub fn file_name(&self) -> Option { self.segments.last().cloned() } /// Gets the parent directory path pub fn parent(&self) -> Option { if self.segments.is_empty() { None } else { let parent_segments = self.segments[..self.segments.len() - 1].to_vec(); Some(Self { segments: parent_segments, }) } } /// Checks if the path is empty (is the root) pub fn is_empty(&self) -> bool { self.segments.is_empty() } } impl std::fmt::Display for StoragePath { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { if self.segments.is_empty() { write!(f, "/") } else { write!(f, "/{}", self.segments.join("/")) } } } impl StoragePath { /// Returns the path representation as a string pub fn as_str(&self) -> &str { // Note: The implementation should really store the string, // but here we do a temporary implementation that always returns "/" // This is only used for the get_folder_path_str implementation "/" } /// Gets the path segments pub fn segments(&self) -> &[String] { &self.segments } } #[cfg(test)] mod tests { use super::*; #[test] fn test_storage_path_from_string() { let path = StoragePath::from_string("folder/subfolder/file.txt"); assert_eq!(path.segments(), &["folder", "subfolder", "file.txt"]); assert_eq!(path.to_string(), "/folder/subfolder/file.txt"); } #[test] fn test_storage_path_join() { let path = StoragePath::from_string("folder"); let joined = path.join("file.txt"); assert_eq!(joined.to_string(), "/folder/file.txt"); } #[test] fn test_storage_path_parent() { let path = StoragePath::from_string("folder/file.txt"); let parent = path.parent().unwrap(); assert_eq!(parent.to_string(), "/folder"); } #[test] fn test_storage_path_root() { let root = StoragePath::root(); assert!(root.is_empty()); assert_eq!(root.to_string(), "/"); } #[test] fn test_storage_path_file_name() { let path = StoragePath::from_string("folder/file.txt"); assert_eq!(path.file_name(), Some("file.txt".to_string())); } // ── Path-traversal hardening tests (VULN-02) ────────────── #[test] fn test_from_string_strips_dot_dot() { let path = StoragePath::from_string("../../etc/passwd"); assert_eq!(path.segments(), &["etc", "passwd"]); } #[test] fn test_from_string_strips_single_dot() { let path = StoragePath::from_string("folder/./file.txt"); assert_eq!(path.segments(), &["folder", "file.txt"]); } #[test] fn test_from_string_strips_mixed_traversal() { let path = StoragePath::from_string("a/../b/./c/../../d"); assert_eq!(path.segments(), &["a", "b", "c", "d"]); } #[test] fn test_from_string_all_traversal_yields_root() { let path = StoragePath::from_string("../../.."); assert!(path.is_empty()); assert_eq!(path.to_string(), "/"); } #[test] fn test_new_strips_traversal_segments() { let path = StoragePath::new(vec!["..".into(), "etc".into(), ".".into(), "passwd".into()]); assert_eq!(path.segments(), &["etc", "passwd"]); } #[test] fn test_new_strips_empty_segments() { let path = StoragePath::new(vec!["a".into(), "".into(), "b".into()]); assert_eq!(path.segments(), &["a", "b"]); } #[test] fn test_join_rejects_dot_dot() { let base = StoragePath::from_string("folder"); let joined = base.join(".."); // ".." is silently ignored — path stays unchanged assert_eq!(joined.segments(), &["folder"]); } #[test] fn test_join_rejects_single_dot() { let base = StoragePath::from_string("folder"); let joined = base.join("."); assert_eq!(joined.segments(), &["folder"]); } #[test] fn test_join_rejects_slash_in_segment() { let base = StoragePath::from_string("folder"); let joined = base.join("sub/../../etc/passwd"); // Segment contains '/' → silently ignored assert_eq!(joined.segments(), &["folder"]); } #[test] fn test_from_pathbuf_strips_traversal() { let path = StoragePath::from(PathBuf::from("a/../b/./c")); // PathBuf Component::Normal only yields the normal parts // On most platforms this strips . and .. // but regardless, our from() only accepts Component::Normal assert!(!path.segments().contains(&"..".to_string())); assert!(!path.segments().contains(&".".to_string())); } }