use std::path::{Component, Path, PathBuf};
use super::storage::StorageError;
#[must_use]
pub fn guess_content_type(name: &str) -> Option<&'static str> {
let extension = Path::new(name)
.extension()
.and_then(|value| value.to_str())?
.to_ascii_lowercase();
match extension.as_str() {
"txt" | "text" => Some("text/plain"),
"html" | "htm" => Some("text/html"),
"css" => Some("text/css"),
"csv" => Some("text/csv"),
"js" | "mjs" => Some("application/javascript"),
"json" => Some("application/json"),
"xml" => Some("application/xml"),
"yaml" | "yml" => Some("application/yaml"),
"pdf" => Some("application/pdf"),
"png" => Some("image/png"),
"jpg" | "jpeg" => Some("image/jpeg"),
"gif" => Some("image/gif"),
"svg" => Some("image/svg+xml"),
"webp" => Some("image/webp"),
_ => None,
}
}
pub fn normalize_relative_path(name: &str) -> Result<PathBuf, StorageError> {
let candidate = Path::new(name);
if candidate.as_os_str().is_empty() {
return Err(StorageError::SuspiciousFilename(name.to_string()));
}
let mut normalized = PathBuf::new();
for component in candidate.components() {
match component {
Component::CurDir => {}
Component::Normal(part) => normalized.push(part),
Component::ParentDir | Component::RootDir | Component::Prefix(_) => {
return Err(StorageError::SuspiciousFilename(name.to_string()));
}
}
}
if normalized.as_os_str().is_empty() {
return Err(StorageError::SuspiciousFilename(name.to_string()));
}
Ok(normalized)
}
pub fn normalize_storage_name(name: &str) -> Result<String, StorageError> {
let normalized = normalize_relative_path(name)?;
Ok(path_to_storage_name(&normalized))
}
#[must_use]
pub fn path_to_storage_name(path: &Path) -> String {
path.components()
.filter_map(|component| match component {
Component::Normal(part) => Some(part.to_string_lossy().into_owned()),
_ => None,
})
.collect::<Vec<_>>()
.join("/")
}
#[must_use]
pub fn build_url(base_url: &str, name: &str) -> String {
let trimmed_name = name.trim_start_matches('/').replace('\\', "/");
if base_url.is_empty() {
return trimmed_name;
}
let base = if base_url.ends_with('/') {
base_url.to_string()
} else {
format!("{base_url}/")
};
format!("{base}{trimmed_name}")
}
#[cfg(test)]
mod tests {
use super::{build_url, guess_content_type, normalize_relative_path, normalize_storage_name};
#[test]
fn guess_content_type_matches_common_extensions() {
assert_eq!(guess_content_type("notes.txt"), Some("text/plain"));
assert_eq!(guess_content_type("logo.PNG"), Some("image/png"));
assert_eq!(guess_content_type("data.json"), Some("application/json"));
}
#[test]
fn guess_content_type_returns_none_for_unknown_extension() {
assert_eq!(guess_content_type("archive.unknown"), None);
assert_eq!(guess_content_type("README"), None);
}
#[test]
fn normalize_relative_path_rejects_parent_components() {
let error = normalize_relative_path("../secret.txt").expect_err("expected rejection");
assert_eq!(error.to_string(), "suspicious filename: ../secret.txt");
}
#[test]
fn normalize_storage_name_uses_forward_slashes() {
let normalized = normalize_storage_name("nested/path/file.txt").expect("normalize name");
assert_eq!(normalized, "nested/path/file.txt");
}
#[test]
fn build_url_adds_missing_separator_once() {
assert_eq!(
build_url("/media", "avatars/me.png"),
"/media/avatars/me.png"
);
assert_eq!(
build_url("/media/", "/avatars/me.png"),
"/media/avatars/me.png"
);
}
}