Files
Oxicloud/src/domain/services/path_service.rs
T
Dionisio f2d35ca792 feat: auto-persist JWT secret, remove setup token requirement
- JWT secret auto-generates and persists to <STORAGE_PATH>/.jwt_secret
- Remove setup token: first admin setup is open until system initialized
- Fix schema.sql: move CREATE EXTENSION pg_trgm/ltree to top
- Update login UI and auth.js to remove setup token fields
2026-03-05 22:12:53 +01:00

235 lines
7.1 KiB
Rust
Executable File

//! 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;
/// Represents a storage path in the domain (Value Object)
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct StoragePath {
segments: Vec<String>,
}
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<String>) -> 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<String> {
self.segments.last().cloned()
}
/// Gets the parent directory path
pub fn parent(&self) -> Option<Self> {
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()));
}
}