2026-02-12 09:41:25 +01:00
|
|
|
//! StoragePath - Domain Value Object for representing storage paths
|
2026-02-14 01:29:34 +01:00
|
|
|
//!
|
2026-02-12 09:41:25 +01:00
|
|
|
//! This module contains only the StoragePath Value Object which is part of the pure domain.
|
2026-02-14 01:29:34 +01:00
|
|
|
//! PathService (which implements StoragePort and StorageMediator) was moved to
|
2026-02-12 09:41:25 +01:00
|
|
|
//! infrastructure/services/path_service.rs because it has file system dependencies.
|
2026-02-02 23:56:40 +01:00
|
|
|
|
|
|
|
|
use std::path::PathBuf;
|
2026-06-06 17:47:59 +02:00
|
|
|
use unicode_normalization::UnicodeNormalization;
|
|
|
|
|
|
|
|
|
|
/// NFC-normalize a single file or folder name component.
|
|
|
|
|
///
|
|
|
|
|
/// The storage layer (PostgreSQL `storage.files.name` and
|
|
|
|
|
/// `storage.folders.name`) compares bytes literally — there is no
|
|
|
|
|
/// Unicode-aware collation in either UNIQUE index. macOS APFS stores
|
|
|
|
|
/// filenames in NFD (decomposed: `é` = `e` + U+0301), while browsers
|
|
|
|
|
/// and most other clients post NFC (`é` = U+00E9). Without
|
|
|
|
|
/// normalization, the same logical filename can land as two distinct
|
|
|
|
|
/// rows: one from a web upload, one from a NextCloud desktop client
|
|
|
|
|
/// re-upload of the round-tripped name. The UNIQUE index does not
|
|
|
|
|
/// catch it because the bytes differ.
|
|
|
|
|
///
|
|
|
|
|
/// This function is called at every name-receiving boundary (entity
|
|
|
|
|
/// constructors, repository path lookups) so the database invariant
|
|
|
|
|
/// becomes "every stored name is NFC". A one-shot migration
|
|
|
|
|
/// (`migrate-nfc-filenames`) cleans up rows that pre-date this rule.
|
|
|
|
|
///
|
|
|
|
|
/// Pure function — no I/O, allocates one `String`.
|
|
|
|
|
pub fn normalize_storage_name(name: &str) -> String {
|
|
|
|
|
name.nfc().collect()
|
|
|
|
|
}
|
2026-02-02 23:56:40 +01:00
|
|
|
|
2026-05-08 00:11:02 +02:00
|
|
|
/// 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(())
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-12 09:41:25 +01:00
|
|
|
/// Represents a storage path in the domain (Value Object)
|
2025-03-19 00:44:27 +01:00
|
|
|
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
|
|
|
|
pub struct StoragePath {
|
|
|
|
|
segments: Vec<String>,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl StoragePath {
|
2026-03-04 14:14:40 +01:00
|
|
|
/// 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
|
2025-03-19 00:44:27 +01:00
|
|
|
pub fn new(segments: Vec<String>) -> Self {
|
2026-03-04 14:14:40 +01:00
|
|
|
Self {
|
2026-03-04 23:55:08 +01:00
|
|
|
segments: segments
|
|
|
|
|
.into_iter()
|
|
|
|
|
.filter(|s| Self::is_safe_segment(s))
|
|
|
|
|
.collect(),
|
2026-03-04 14:14:40 +01:00
|
|
|
}
|
2025-03-19 00:44:27 +01:00
|
|
|
}
|
2026-02-14 01:29:34 +01:00
|
|
|
|
2026-02-12 09:41:25 +01:00
|
|
|
/// Creates an empty path (root)
|
2025-03-19 00:44:27 +01:00
|
|
|
pub fn root() -> Self {
|
2026-02-14 01:29:34 +01:00
|
|
|
Self {
|
|
|
|
|
segments: Vec::new(),
|
|
|
|
|
}
|
2025-03-19 00:44:27 +01:00
|
|
|
}
|
2026-02-14 01:29:34 +01:00
|
|
|
|
2026-02-12 09:41:25 +01:00
|
|
|
/// Creates a path from a string with segments separated by /
|
2026-03-04 14:14:40 +01:00
|
|
|
///
|
|
|
|
|
/// Traversal segments (`.`, `..`) are silently stripped to prevent
|
|
|
|
|
/// path-traversal attacks.
|
2025-03-19 00:44:27 +01:00
|
|
|
pub fn from_string(path: &str) -> Self {
|
|
|
|
|
let segments = path
|
|
|
|
|
.split('/')
|
2026-03-04 14:14:40 +01:00
|
|
|
.filter(|s| Self::is_safe_segment(s))
|
2025-03-19 00:44:27 +01:00
|
|
|
.map(|s| s.to_string())
|
|
|
|
|
.collect();
|
|
|
|
|
Self { segments }
|
|
|
|
|
}
|
2026-02-14 01:29:34 +01:00
|
|
|
|
2026-02-12 09:41:25 +01:00
|
|
|
/// Creates a path from a PathBuf
|
2025-03-19 00:44:27 +01:00
|
|
|
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 }
|
|
|
|
|
}
|
2026-02-14 01:29:34 +01:00
|
|
|
|
2026-06-09 13:45:16 +00:00
|
|
|
/// Appends a segment to the path, consuming `self` so the existing
|
|
|
|
|
/// segment buffer is reused instead of deep-cloned.
|
2026-03-04 14:14:40 +01:00
|
|
|
///
|
|
|
|
|
/// Traversal segments (`.`, `..`) and segments containing `/` are
|
|
|
|
|
/// silently ignored to prevent path-traversal attacks.
|
2026-06-09 13:45:16 +00:00
|
|
|
pub fn join(mut self, segment: &str) -> Self {
|
2026-03-04 14:14:40 +01:00
|
|
|
if Self::is_safe_segment(segment) {
|
2026-06-09 13:45:16 +00:00
|
|
|
self.segments.push(segment.to_string());
|
2026-02-14 01:29:34 +01:00
|
|
|
}
|
2026-06-09 13:45:16 +00:00
|
|
|
self
|
2025-03-19 00:44:27 +01:00
|
|
|
}
|
2026-02-14 01:29:34 +01:00
|
|
|
|
2026-02-12 09:41:25 +01:00
|
|
|
/// Gets the file name (last segment)
|
2025-03-19 00:44:27 +01:00
|
|
|
pub fn file_name(&self) -> Option<String> {
|
|
|
|
|
self.segments.last().cloned()
|
|
|
|
|
}
|
2026-02-14 01:29:34 +01:00
|
|
|
|
2026-02-12 09:41:25 +01:00
|
|
|
/// Gets the parent directory path
|
2025-03-19 00:44:27 +01:00
|
|
|
pub fn parent(&self) -> Option<Self> {
|
|
|
|
|
if self.segments.is_empty() {
|
|
|
|
|
None
|
|
|
|
|
} else {
|
|
|
|
|
let parent_segments = self.segments[..self.segments.len() - 1].to_vec();
|
2026-02-14 01:29:34 +01:00
|
|
|
Some(Self {
|
|
|
|
|
segments: parent_segments,
|
|
|
|
|
})
|
2025-03-19 00:44:27 +01:00
|
|
|
}
|
|
|
|
|
}
|
2026-02-14 01:29:34 +01:00
|
|
|
|
2026-02-12 09:41:25 +01:00
|
|
|
/// Checks if the path is empty (is the root)
|
2025-03-19 00:44:27 +01:00
|
|
|
pub fn is_empty(&self) -> bool {
|
|
|
|
|
self.segments.is_empty()
|
|
|
|
|
}
|
2026-02-15 17:53:25 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl std::fmt::Display for StoragePath {
|
|
|
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
2025-03-19 00:44:27 +01:00
|
|
|
if self.segments.is_empty() {
|
2026-02-15 17:53:25 +01:00
|
|
|
write!(f, "/")
|
2025-03-19 00:44:27 +01:00
|
|
|
} else {
|
2026-02-15 17:53:25 +01:00
|
|
|
write!(f, "/{}", self.segments.join("/"))
|
2025-03-19 00:44:27 +01:00
|
|
|
}
|
|
|
|
|
}
|
2026-02-15 17:53:25 +01:00
|
|
|
}
|
2026-02-14 01:29:34 +01:00
|
|
|
|
2026-02-15 17:53:25 +01:00
|
|
|
impl StoragePath {
|
2026-02-12 09:41:25 +01:00
|
|
|
/// Returns the path representation as a string
|
2025-04-09 00:21:20 +02:00
|
|
|
pub fn as_str(&self) -> &str {
|
2026-02-12 09:41:25 +01:00
|
|
|
// 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
|
2025-04-09 00:21:20 +02:00
|
|
|
"/"
|
|
|
|
|
}
|
2026-02-14 01:29:34 +01:00
|
|
|
|
2026-02-12 09:41:25 +01:00
|
|
|
/// Gets the path segments
|
2025-03-19 00:44:27 +01:00
|
|
|
pub fn segments(&self) -> &[String] {
|
|
|
|
|
&self.segments
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[cfg(test)]
|
|
|
|
|
mod tests {
|
|
|
|
|
use super::*;
|
2026-02-14 01:29:34 +01:00
|
|
|
|
2025-03-19 00:44:27 +01:00
|
|
|
#[test]
|
2026-02-02 23:56:40 +01:00
|
|
|
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");
|
2025-03-19 00:44:27 +01:00
|
|
|
}
|
2026-02-14 01:29:34 +01:00
|
|
|
|
2025-03-19 00:44:27 +01:00
|
|
|
#[test]
|
2026-02-02 23:56:40 +01:00
|
|
|
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");
|
2025-03-19 00:44:27 +01:00
|
|
|
}
|
2026-02-14 01:29:34 +01:00
|
|
|
|
2025-03-19 00:44:27 +01:00
|
|
|
#[test]
|
2026-02-02 23:56:40 +01:00
|
|
|
fn test_storage_path_parent() {
|
|
|
|
|
let path = StoragePath::from_string("folder/file.txt");
|
|
|
|
|
let parent = path.parent().unwrap();
|
|
|
|
|
assert_eq!(parent.to_string(), "/folder");
|
2025-03-19 00:44:27 +01:00
|
|
|
}
|
2026-02-14 01:29:34 +01:00
|
|
|
|
2025-03-19 00:44:27 +01:00
|
|
|
#[test]
|
2026-02-02 23:56:40 +01:00
|
|
|
fn test_storage_path_root() {
|
|
|
|
|
let root = StoragePath::root();
|
|
|
|
|
assert!(root.is_empty());
|
|
|
|
|
assert_eq!(root.to_string(), "/");
|
2025-03-19 00:44:27 +01:00
|
|
|
}
|
2026-02-14 01:29:34 +01:00
|
|
|
|
2025-03-19 00:44:27 +01:00
|
|
|
#[test]
|
2026-02-02 23:56:40 +01:00
|
|
|
fn test_storage_path_file_name() {
|
|
|
|
|
let path = StoragePath::from_string("folder/file.txt");
|
|
|
|
|
assert_eq!(path.file_name(), Some("file.txt".to_string()));
|
2025-03-19 00:44:27 +01:00
|
|
|
}
|
2026-03-04 14:14:40 +01:00
|
|
|
|
|
|
|
|
// ── 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() {
|
2026-03-04 23:55:08 +01:00
|
|
|
let path = StoragePath::new(vec!["..".into(), "etc".into(), ".".into(), "passwd".into()]);
|
2026-03-04 14:14:40 +01:00
|
|
|
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()));
|
|
|
|
|
}
|
2026-06-06 17:47:59 +02:00
|
|
|
|
|
|
|
|
// ── NFC normalization tests ─────────────────────────────────
|
|
|
|
|
|
|
|
|
|
/// Plain ASCII names must round-trip identical bytes.
|
|
|
|
|
#[test]
|
|
|
|
|
fn test_normalize_ascii_unchanged() {
|
|
|
|
|
assert_eq!(normalize_storage_name("file.txt"), "file.txt");
|
|
|
|
|
assert_eq!(normalize_storage_name("My Documents"), "My Documents");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// The macOS APFS / NextCloud-desktop pathological case: `é`
|
|
|
|
|
/// decomposed as `e` + combining acute (U+0301). Stored bytes
|
|
|
|
|
/// `65 cc 81` collapse to NFC `c3 a9`.
|
|
|
|
|
#[test]
|
|
|
|
|
fn test_normalize_nfd_to_nfc() {
|
|
|
|
|
let nfd = "caf\u{0065}\u{0301}";
|
|
|
|
|
let nfc = "caf\u{00E9}";
|
|
|
|
|
assert_ne!(nfd.as_bytes(), nfc.as_bytes());
|
|
|
|
|
assert_eq!(normalize_storage_name(nfd), nfc);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Already-NFC input must round-trip unchanged. This is the
|
|
|
|
|
/// idempotence property the boundary normalization relies on.
|
|
|
|
|
#[test]
|
|
|
|
|
fn test_normalize_nfc_idempotent() {
|
|
|
|
|
let nfc = "Capture d\u{2019}\u{00E9}cran.png";
|
|
|
|
|
assert_eq!(normalize_storage_name(nfc), nfc);
|
|
|
|
|
// And applying twice is the same as once.
|
|
|
|
|
assert_eq!(normalize_storage_name(&normalize_storage_name(nfc)), nfc);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Multi-codepoint NFD sequences (combining acute + grave +
|
|
|
|
|
/// typographic apostrophe) all converge to a single NFC form.
|
|
|
|
|
#[test]
|
|
|
|
|
fn test_normalize_mixed_accents() {
|
|
|
|
|
let nfd = "Capture d\u{2019}\u{0065}\u{0301}cran a\u{0300}.png";
|
|
|
|
|
let nfc = "Capture d\u{2019}\u{00E9}cran \u{00E0}.png";
|
|
|
|
|
assert_eq!(normalize_storage_name(nfd), nfc);
|
|
|
|
|
}
|
2026-02-14 01:29:34 +01:00
|
|
|
}
|