use std::fmt;
use crate::storage::error::StoragePathError;
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct StoragePath {
key: String,
}
impl StoragePath {
pub fn new(key: &str) -> Result<Self, StoragePathError> {
validate(key)?;
Ok(Self {
key: key.to_owned(),
})
}
#[must_use]
pub fn as_str(&self) -> &str {
&self.key
}
}
#[cfg(feature = "uploads")]
impl StoragePath {
pub fn from_filename(
filename: &str,
allowed: &crate::storage::filename::AllowedExtensions,
) -> Result<Self, crate::storage::error::FilenameError> {
let safe = crate::storage::filename::SafeFilename::parse(filename, allowed)?;
Self::new(&safe.to_string()).map_err(|_| crate::storage::error::FilenameError::Empty)
}
}
impl fmt::Display for StoragePath {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(&self.key)
}
}
impl AsRef<str> for StoragePath {
fn as_ref(&self) -> &str {
&self.key
}
}
fn validate(key: &str) -> Result<(), StoragePathError> {
if key.is_empty() {
return Err(StoragePathError::Empty);
}
if key.starts_with('/') {
return Err(StoragePathError::Absolute);
}
if key.contains('\\') {
return Err(StoragePathError::Backslash);
}
if key.bytes().any(|b| b < 0x20 || b == 0x7F) {
return Err(StoragePathError::ControlChar);
}
let trimmed = key.strip_suffix('/').unwrap_or(key);
for segment in trimmed.split('/') {
if segment == ".." {
return Err(StoragePathError::Traversal);
}
if segment.is_empty() {
return Err(StoragePathError::EmptySegment);
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn accepts_simple_key() {
assert!(StoragePath::new("hello.txt").is_ok());
}
#[test]
fn accepts_nested_key() {
assert!(StoragePath::new("photos/2026/img-001.jpg").is_ok());
}
#[test]
fn accepts_trailing_slash_for_list_prefix() {
assert!(StoragePath::new("photos/").is_ok());
}
#[test]
fn as_str_roundtrips() {
let key = "a/b/c.txt";
let path = StoragePath::new(key).unwrap();
assert_eq!(path.as_str(), key);
assert_eq!(path.to_string(), key);
assert_eq!(path.as_ref(), key);
}
#[test]
fn rejects_empty() {
assert_eq!(StoragePath::new("").unwrap_err(), StoragePathError::Empty);
}
#[test]
fn rejects_absolute() {
assert_eq!(
StoragePath::new("/etc/passwd").unwrap_err(),
StoragePathError::Absolute
);
}
#[test]
fn rejects_traversal_leading() {
assert_eq!(
StoragePath::new("../secret").unwrap_err(),
StoragePathError::Traversal
);
}
#[test]
fn rejects_traversal_middle() {
assert_eq!(
StoragePath::new("foo/../bar").unwrap_err(),
StoragePathError::Traversal
);
}
#[test]
fn rejects_backslash() {
assert_eq!(
StoragePath::new("foo\\bar").unwrap_err(),
StoragePathError::Backslash
);
}
#[test]
fn rejects_control_chars() {
assert_eq!(
StoragePath::new("foo\0bar").unwrap_err(),
StoragePathError::ControlChar
);
}
#[test]
fn rejects_double_slash() {
assert_eq!(
StoragePath::new("foo//bar").unwrap_err(),
StoragePathError::EmptySegment
);
}
#[test]
fn hostile_input_never_panics() {
let hostile = [
"",
"/",
"//",
"../",
"/..",
"..\\..",
"foo\0",
"foo/../../../etc/passwd",
"\x7F",
"foo//bar//baz",
];
for input in hostile {
let result = StoragePath::new(input);
assert!(result.is_err(), "expected rejection for {input:?}");
}
}
}