2026-02-14 01:29:34 +01:00
|
|
|
//! PathService - Infrastructure service for storage path management
|
|
|
|
|
//!
|
|
|
|
|
//! This service was moved from domain/services because it implements application traits
|
2026-02-14 18:10:37 +01:00
|
|
|
//! (StoragePort) and has file system dependencies (tokio::fs).
|
2026-02-14 01:29:34 +01:00
|
|
|
//!
|
|
|
|
|
//! StoragePath (Value Object) remains in domain/services/path_service.rs
|
|
|
|
|
|
|
|
|
|
use std::path::{Path, PathBuf};
|
|
|
|
|
use tokio::fs;
|
|
|
|
|
|
|
|
|
|
use crate::application::ports::outbound::StoragePort;
|
|
|
|
|
use crate::common::errors::{DomainError, ErrorKind};
|
|
|
|
|
use crate::domain::services::path_service::StoragePath;
|
|
|
|
|
|
|
|
|
|
/// Infrastructure service for handling storage path operations
|
|
|
|
|
pub struct PathService {
|
|
|
|
|
root_path: PathBuf,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl PathService {
|
|
|
|
|
/// Creates a new path service with a specific root
|
|
|
|
|
pub fn new(root_path: PathBuf) -> Self {
|
|
|
|
|
Self { root_path }
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-04 14:14:40 +01:00
|
|
|
/// Converts a domain path to an absolute physical path.
|
|
|
|
|
///
|
|
|
|
|
/// Returns an error if validation fails (defense-in-depth against traversal).
|
|
|
|
|
pub fn resolve_path(&self, storage_path: &StoragePath) -> Result<PathBuf, DomainError> {
|
|
|
|
|
self.validate_path(storage_path)?;
|
2026-02-14 01:29:34 +01:00
|
|
|
let mut path = self.root_path.clone();
|
|
|
|
|
for segment in storage_path.segments() {
|
|
|
|
|
path.push(segment);
|
|
|
|
|
}
|
2026-03-04 14:14:40 +01:00
|
|
|
// Final safety check: the resolved path must remain under root
|
|
|
|
|
if !path.starts_with(&self.root_path) {
|
|
|
|
|
return Err(DomainError::new(
|
|
|
|
|
ErrorKind::InvalidInput,
|
|
|
|
|
"Path",
|
|
|
|
|
format!("Resolved path escapes storage root: {}", path.display()),
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
Ok(path)
|
2026-02-14 01:29:34 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Converts a physical path to a domain path
|
|
|
|
|
pub fn to_storage_path(&self, physical_path: &Path) -> Option<StoragePath> {
|
|
|
|
|
physical_path
|
|
|
|
|
.strip_prefix(&self.root_path)
|
|
|
|
|
.ok()
|
|
|
|
|
.map(|rel_path| {
|
|
|
|
|
let segments: Vec<String> = rel_path
|
|
|
|
|
.components()
|
|
|
|
|
.filter_map(|c| match c {
|
|
|
|
|
std::path::Component::Normal(os_str) => {
|
|
|
|
|
Some(os_str.to_string_lossy().to_string())
|
|
|
|
|
}
|
|
|
|
|
_ => None,
|
|
|
|
|
})
|
|
|
|
|
.collect();
|
|
|
|
|
StoragePath::new(segments)
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Creates a file path within a folder
|
|
|
|
|
pub fn create_file_path(&self, folder_path: &StoragePath, file_name: &str) -> StoragePath {
|
|
|
|
|
folder_path.join(file_name)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Checks if a path is a direct child of another
|
|
|
|
|
pub fn is_direct_child(
|
|
|
|
|
&self,
|
|
|
|
|
parent_path: &StoragePath,
|
|
|
|
|
potential_child: &StoragePath,
|
|
|
|
|
) -> bool {
|
|
|
|
|
if let Some(child_parent) = potential_child.parent() {
|
|
|
|
|
&child_parent == parent_path
|
|
|
|
|
} else {
|
|
|
|
|
parent_path.is_empty()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Checks if a path is at the root
|
|
|
|
|
pub fn is_in_root(&self, path: &StoragePath) -> bool {
|
|
|
|
|
path.parent().is_none_or(|p| p.is_empty())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Gets the root path used by this service
|
|
|
|
|
pub fn get_root_path(&self) -> &Path {
|
|
|
|
|
&self.root_path
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Validates a path to ensure it doesn't contain dangerous components
|
|
|
|
|
pub fn validate_path(&self, path: &StoragePath) -> Result<(), DomainError> {
|
|
|
|
|
// Check for empty segments
|
|
|
|
|
if path.segments().iter().any(|s| s.is_empty()) {
|
|
|
|
|
return Err(DomainError::new(
|
|
|
|
|
ErrorKind::InvalidInput,
|
|
|
|
|
"Path",
|
2026-02-15 17:53:25 +01:00
|
|
|
format!("Path contains empty segments: {}", path),
|
2026-02-14 01:29:34 +01:00
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Check for dangerous characters
|
|
|
|
|
let dangerous_chars = ['\\', ':', '*', '?', '"', '<', '>', '|'];
|
|
|
|
|
for segment in path.segments() {
|
|
|
|
|
if segment.contains(&dangerous_chars[..]) {
|
|
|
|
|
return Err(DomainError::new(
|
|
|
|
|
ErrorKind::InvalidInput,
|
|
|
|
|
"Path",
|
|
|
|
|
format!("Path contains dangerous characters: {}", segment),
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Check that it doesn't start with . (hidden in Unix)
|
|
|
|
|
if segment.starts_with('.') && segment != ".well-known" {
|
|
|
|
|
return Err(DomainError::new(
|
|
|
|
|
ErrorKind::InvalidInput,
|
|
|
|
|
"Path",
|
|
|
|
|
format!("Path segments cannot start with dot: {}", segment),
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl StoragePort for PathService {
|
2026-03-04 14:14:40 +01:00
|
|
|
fn resolve_path(&self, storage_path: &StoragePath) -> Result<PathBuf, DomainError> {
|
|
|
|
|
// Delegate to inherent method which validates + bounds-checks
|
|
|
|
|
self.resolve_path(storage_path)
|
2026-02-14 01:29:34 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async fn ensure_directory(&self, storage_path: &StoragePath) -> Result<(), DomainError> {
|
2026-03-04 14:14:40 +01:00
|
|
|
// resolve_path already calls validate_path internally
|
|
|
|
|
let physical_path = self.resolve_path(storage_path)?;
|
2026-02-14 01:29:34 +01:00
|
|
|
|
2026-02-23 22:56:20 +01:00
|
|
|
// Check current state with a single async stat() — no worker blocking.
|
|
|
|
|
match fs::metadata(&physical_path).await {
|
|
|
|
|
Ok(meta) if meta.is_dir() => { /* already exists */ }
|
|
|
|
|
Ok(_) => {
|
|
|
|
|
return Err(DomainError::new(
|
|
|
|
|
ErrorKind::InvalidInput,
|
2026-02-14 01:29:34 +01:00
|
|
|
"Storage",
|
2026-02-23 22:56:20 +01:00
|
|
|
format!(
|
|
|
|
|
"Path exists but is not a directory: {}",
|
|
|
|
|
physical_path.display()
|
|
|
|
|
),
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
|
|
|
|
|
fs::create_dir_all(&physical_path).await.map_err(|e| {
|
|
|
|
|
DomainError::new(
|
|
|
|
|
ErrorKind::AccessDenied,
|
|
|
|
|
"Storage",
|
|
|
|
|
format!("Failed to create directory: {}", physical_path.display()),
|
|
|
|
|
)
|
|
|
|
|
.with_source(e)
|
|
|
|
|
})?;
|
|
|
|
|
|
|
|
|
|
tracing::debug!("Created directory: {}", physical_path.display());
|
|
|
|
|
}
|
|
|
|
|
Err(e) => {
|
|
|
|
|
return Err(DomainError::new(
|
|
|
|
|
ErrorKind::InternalError,
|
|
|
|
|
"Storage",
|
|
|
|
|
format!("Cannot stat {}: {e}", physical_path.display()),
|
|
|
|
|
));
|
|
|
|
|
}
|
2026-02-14 01:29:34 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async fn file_exists(&self, storage_path: &StoragePath) -> Result<bool, DomainError> {
|
2026-03-04 14:14:40 +01:00
|
|
|
let physical_path = self.resolve_path(storage_path)?;
|
2026-02-14 01:29:34 +01:00
|
|
|
|
2026-02-23 22:56:20 +01:00
|
|
|
// Single async stat() — no worker blocking, one syscall instead of two.
|
|
|
|
|
match fs::metadata(&physical_path).await {
|
|
|
|
|
Ok(meta) => Ok(meta.is_file()),
|
|
|
|
|
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(false),
|
|
|
|
|
Err(e) => Err(DomainError::new(
|
|
|
|
|
ErrorKind::InternalError,
|
|
|
|
|
"Storage",
|
|
|
|
|
format!("Cannot stat {}: {e}", physical_path.display()),
|
|
|
|
|
)),
|
|
|
|
|
}
|
2026-02-14 01:29:34 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async fn directory_exists(&self, storage_path: &StoragePath) -> Result<bool, DomainError> {
|
2026-03-04 14:14:40 +01:00
|
|
|
let physical_path = self.resolve_path(storage_path)?;
|
2026-02-14 01:29:34 +01:00
|
|
|
|
2026-02-23 22:56:20 +01:00
|
|
|
// Single async stat() — no worker blocking, one syscall instead of two.
|
|
|
|
|
match fs::metadata(&physical_path).await {
|
|
|
|
|
Ok(meta) => Ok(meta.is_dir()),
|
|
|
|
|
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(false),
|
|
|
|
|
Err(e) => Err(DomainError::new(
|
|
|
|
|
ErrorKind::InternalError,
|
|
|
|
|
"Storage",
|
|
|
|
|
format!("Cannot stat {}: {e}", physical_path.display()),
|
|
|
|
|
)),
|
|
|
|
|
}
|
2026-02-14 01:29:34 +01:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[cfg(test)]
|
|
|
|
|
mod tests {
|
|
|
|
|
use super::*;
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn test_resolve_path() {
|
|
|
|
|
let service = PathService::new(PathBuf::from("/storage"));
|
|
|
|
|
|
|
|
|
|
let storage_path = StoragePath::from_string("test/file.txt");
|
2026-03-04 14:14:40 +01:00
|
|
|
let absolute = service.resolve_path(&storage_path).unwrap();
|
2026-02-14 01:29:34 +01:00
|
|
|
|
|
|
|
|
assert_eq!(absolute, PathBuf::from("/storage/test/file.txt"));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn test_to_storage_path() {
|
|
|
|
|
let service = PathService::new(PathBuf::from("/storage"));
|
|
|
|
|
|
|
|
|
|
let physical_path = PathBuf::from("/storage/folder/file.txt");
|
|
|
|
|
let storage_path = service.to_storage_path(&physical_path).unwrap();
|
|
|
|
|
|
|
|
|
|
assert_eq!(storage_path.to_string(), "/folder/file.txt");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn test_is_in_root() {
|
|
|
|
|
let service = PathService::new(PathBuf::from("/storage"));
|
|
|
|
|
|
|
|
|
|
let root_path = StoragePath::from_string("file.txt");
|
|
|
|
|
let nested_path = StoragePath::from_string("folder/file.txt");
|
|
|
|
|
|
|
|
|
|
assert!(service.is_in_root(&root_path));
|
|
|
|
|
assert!(!service.is_in_root(&nested_path));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn test_is_direct_child() {
|
|
|
|
|
let service = PathService::new(PathBuf::from("/storage"));
|
|
|
|
|
|
|
|
|
|
let parent = StoragePath::from_string("folder");
|
|
|
|
|
let child = StoragePath::from_string("folder/file.txt");
|
|
|
|
|
let not_child = StoragePath::from_string("folder2/file.txt");
|
|
|
|
|
|
|
|
|
|
assert!(service.is_direct_child(&parent, &child));
|
|
|
|
|
assert!(!service.is_direct_child(&parent, ¬_child));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn test_create_file_path() {
|
|
|
|
|
let service = PathService::new(PathBuf::from("/storage"));
|
|
|
|
|
|
|
|
|
|
let folder_path = StoragePath::from_string("folder");
|
|
|
|
|
let file_path = service.create_file_path(&folder_path, "file.txt");
|
|
|
|
|
|
|
|
|
|
assert_eq!(file_path.to_string(), "/folder/file.txt");
|
|
|
|
|
}
|
2026-03-04 14:14:40 +01:00
|
|
|
|
|
|
|
|
// ── Path-traversal hardening tests (VULN-02) ──────────────
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn test_resolve_path_traversal_stripped_by_domain() {
|
|
|
|
|
// StoragePath::from_string already strips ".." segments (Solution A+E),
|
|
|
|
|
// so resolve_path receives a clean path.
|
|
|
|
|
let service = PathService::new(PathBuf::from("/storage"));
|
|
|
|
|
let path = StoragePath::from_string("../../etc/passwd");
|
|
|
|
|
let resolved = service.resolve_path(&path).unwrap();
|
|
|
|
|
assert_eq!(resolved, PathBuf::from("/storage/etc/passwd"));
|
|
|
|
|
assert!(resolved.starts_with("/storage"));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn test_resolve_path_normal_path_ok() {
|
|
|
|
|
let service = PathService::new(PathBuf::from("/storage"));
|
|
|
|
|
let path = StoragePath::from_string("users/alice/documents/report.pdf");
|
|
|
|
|
let resolved = service.resolve_path(&path).unwrap();
|
|
|
|
|
assert_eq!(
|
|
|
|
|
resolved,
|
|
|
|
|
PathBuf::from("/storage/users/alice/documents/report.pdf")
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn test_resolve_path_root_ok() {
|
|
|
|
|
let service = PathService::new(PathBuf::from("/storage"));
|
|
|
|
|
let path = StoragePath::root();
|
|
|
|
|
let resolved = service.resolve_path(&path).unwrap();
|
|
|
|
|
assert_eq!(resolved, PathBuf::from("/storage"));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn test_validate_path_rejects_dot_prefix() {
|
|
|
|
|
let service = PathService::new(PathBuf::from("/storage"));
|
|
|
|
|
// Manually construct a path with a dot-prefixed segment
|
|
|
|
|
// (from_string strips ".." but allows ".hidden")
|
|
|
|
|
let path = StoragePath::from_string("folder/.hidden/file.txt");
|
|
|
|
|
assert!(service.validate_path(&path).is_err());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn test_validate_path_allows_well_known() {
|
|
|
|
|
let service = PathService::new(PathBuf::from("/storage"));
|
|
|
|
|
let path = StoragePath::from_string("folder/.well-known/caldav");
|
|
|
|
|
assert!(service.validate_path(&path).is_ok());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn test_validate_path_rejects_dangerous_chars() {
|
|
|
|
|
let service = PathService::new(PathBuf::from("/storage"));
|
2026-03-04 23:55:08 +01:00
|
|
|
for dangerous in &[
|
|
|
|
|
"file:name",
|
|
|
|
|
"file*name",
|
|
|
|
|
"file?name",
|
|
|
|
|
"file<name",
|
|
|
|
|
"file>name",
|
|
|
|
|
"file|name",
|
|
|
|
|
"file\"name",
|
|
|
|
|
] {
|
2026-03-04 14:14:40 +01:00
|
|
|
let path = StoragePath::new(vec![dangerous.to_string()]);
|
|
|
|
|
assert!(
|
|
|
|
|
service.validate_path(&path).is_err(),
|
|
|
|
|
"validate_path should reject segment: {}",
|
|
|
|
|
dangerous
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-02-14 01:29:34 +01:00
|
|
|
}
|