//! Safe filesystem access: every operation resolves //! `//`, canonicalizes it and verifies //! the result is still inside the user's root (blocks `..` and symlink escapes). use std::path::{Component, Path, PathBuf}; use std::time::UNIX_EPOCH; use chrono::DateTime; use crate::error::ApiError; #[derive(Debug, thiserror::Error)] pub enum FsError { #[error("folder not found")] NotFound, #[error("not a folder")] NotADirectory, #[error("access denied")] Forbidden, #[error("the configured folder no longer exists")] RootMissing, } impl From for ApiError { fn from(e: FsError) -> Self { use axum::http::StatusCode as S; let status = match &e { FsError::NotFound => S::NOT_FOUND, FsError::NotADirectory => S::BAD_REQUEST, FsError::Forbidden => S::FORBIDDEN, FsError::RootMissing => S::NOT_FOUND, }; ApiError::new(status, e.to_string()) } } /// Resolve a user root (path relative to the server root) to a canonical /// absolute path, verified to be inside the server root. pub fn resolve_root(server_root: &Path, root_rel: &str) -> Result { let candidate = server_root.join(root_rel); let canonical = candidate .canonicalize() .map_err(|_| FsError::RootMissing)?; ensure_within(server_root, &canonical)?; if !canonical.is_dir() { return Err(FsError::RootMissing); } Ok(canonical) } /// Resolve a requested path (relative to a user root) safely. pub fn resolve_path(server_root: &Path, root_rel: &str, req_rel: &str) -> Result { let root_abs = resolve_root(server_root, root_rel)?; let req = Path::new(req_rel); for c in req.components() { if matches!(c, Component::ParentDir) { return Err(FsError::Forbidden); } } let full = root_abs.join(req); let full = full .canonicalize() .map_err(|e| match e.kind() { std::io::ErrorKind::NotFound => FsError::NotFound, _ => FsError::Forbidden, })?; ensure_within(&root_abs, &full)?; Ok(full) } fn ensure_within(base: &Path, p: &Path) -> Result<(), FsError> { if p == base || p.starts_with(base) { Ok(()) } else { Err(FsError::Forbidden) } } #[derive(Debug, Clone, serde::Serialize)] pub struct Entry { pub name: String, pub is_dir: bool, pub size: u64, pub mtime: String, } /// List a directory (blocking — call via spawn_blocking). pub fn list_dir(dir: &Path) -> Result, FsError> { let rd = std::fs::read_dir(dir).map_err(|e| match e.kind() { std::io::ErrorKind::NotFound => FsError::NotFound, std::io::ErrorKind::NotADirectory => FsError::NotADirectory, _ => FsError::Forbidden, })?; let mut entries = Vec::new(); for e in rd.flatten() { let name = e.file_name().to_string_lossy().into_owned(); // Follows symlinks; a broken link shows up as an empty file. let meta = std::fs::metadata(e.path()); let (is_dir, size, mtime) = match meta { Ok(m) => (m.is_dir(), m.len(), mtime_str(&m)), Err(_) => (false, 0, "1970-01-01T00:00:00Z".to_string()), }; entries.push(Entry { name, is_dir, size, mtime, }); } // Folders first, then case-insensitive name. entries.sort_by(|a, b| { b.is_dir .cmp(&a.is_dir) .then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase())) .then_with(|| a.name.cmp(&b.name)) }); Ok(entries) } fn mtime_str(m: &std::fs::Metadata) -> String { let dt: Option> = m .modified() .ok() .and_then(|t| t.duration_since(UNIX_EPOCH).ok()) .and_then(|d| DateTime::from_timestamp(d.as_secs() as i64, 0)); dt.map(|d| d.to_rfc3339_opts(chrono::SecondsFormat::Secs, true)) .unwrap_or_else(|| "1970-01-01T00:00:00Z".to_string()) }