//! 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, #[error("already exists")] Conflict, #[error("{0}")] Invalid(String), } 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, FsError::Conflict => S::CONFLICT, FsError::Invalid(_) => S::BAD_REQUEST, }; 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()) } // --------------------------------------------------------------------------- // Mutations (milestone 3): mkdir, rename, remove, move, copy, upload // --------------------------------------------------------------------------- /// Resolve a directory that must exist (relative to a user root). Used as the /// base for operations that target the *parent* of the item. pub fn resolve_dir(server_root: &Path, root_rel: &str, req_rel: &str) -> Result { let full = resolve_path(server_root, root_rel, req_rel)?; if !full.is_dir() { return Err(FsError::NotADirectory); } Ok(full) } /// Validate a new single-component name (for rename / new folder). pub fn validate_name(name: &str) -> Result<(), FsError> { let p = Path::new(name); if name.is_empty() || p.components().count() != 1 || name == "." || name == ".." || name.contains(['/', '\\', '\0']) { return Err(FsError::Invalid("invalid name".to_string())); } Ok(()) } /// Create a directory (and any missing parents) inside a user root. pub fn mkdir(server_root: &Path, root_rel: &str, req_rel: &str) -> Result<(), FsError> { let full = resolve_path_or_new(server_root, root_rel, req_rel)?; if full.exists() { return Err(FsError::Conflict); } std::fs::create_dir_all(&full).map_err(|e| io_err(e, &full))?; Ok(()) } /// Resolve a path that does not need to exist yet, but whose *parent* must. fn resolve_path_or_new(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); // The parent must exist and stay inside the root. let parent = full .parent() .filter(|p| !p.as_os_str().is_empty()) .ok_or_else(|| FsError::Invalid("invalid path".to_string()))?; let parent = parent.canonicalize().map_err(|e| io_err(e, parent))?; ensure_within(&root_abs, &parent)?; Ok(full) } /// Rename (or move within the same directory) an item. pub fn rename_item( server_root: &Path, root_rel: &str, req_rel: &str, new_name: &str, overwrite: bool, ) -> Result<(), FsError> { validate_name(new_name)?; let from = resolve_path(server_root, root_rel, req_rel)?; let parent = from .parent() .ok_or_else(|| FsError::Invalid("invalid path".to_string()))?; let to = parent.join(new_name); if to.exists() { if !overwrite || to.is_dir() || from.is_dir() { return Err(FsError::Conflict); } std::fs::remove_file(&to).map_err(|e| io_err(e, &to))?; } std::fs::rename(&from, &to).map_err(|e| io_err(e, &to))?; Ok(()) } /// Delete a file or a directory tree. Returns whether it was a directory. pub fn remove_item(server_root: &Path, root_rel: &str, req_rel: &str) -> Result { let full = resolve_path(server_root, root_rel, req_rel)?; let is_dir = full.is_dir(); if is_dir { std::fs::remove_dir_all(&full).map_err(|e| io_err(e, &full))?; } else { std::fs::remove_file(&full).map_err(|e| io_err(e, &full))?; } Ok(is_dir) } fn io_err(e: std::io::Error, p: &Path) -> FsError { tracing::warn!(error = %e, path = %p.display(), "filesystem error"); match e.kind() { std::io::ErrorKind::NotFound => FsError::NotFound, _ => FsError::Forbidden, } } /// True if `a` is `b` or a descendant of `b` (both canonical). fn is_within_or_eq(base: &Path, p: &Path) -> bool { p == base || p.starts_with(base) } /// Move an item (possibly across roots). `dst_dir_rel` is the destination /// directory (relative to `dst_root_rel`); the item keeps its base name. pub fn move_item( server_root: &Path, src_root_rel: &str, src_rel: &str, dst_root_rel: &str, dst_dir_rel: &str, overwrite: bool, ) -> Result<(), FsError> { let from = resolve_path(server_root, src_root_rel, src_rel)?; let dst_dir = resolve_dir(server_root, dst_root_rel, dst_dir_rel)?; let name = from .file_name() .ok_or_else(|| FsError::Invalid("invalid path".to_string()))? .to_owned(); let to = dst_dir.join(&name); // Refuse moving a directory into itself or a descendant. if from.is_dir() && is_within_or_eq(&from, &dst_dir) { return Err(FsError::Invalid( "cannot move a folder into itself".to_string(), )); } check_move_conflict(&to, &from, overwrite)?; match std::fs::rename(&from, &to) { Ok(()) => Ok(()), Err(e) if e.kind() == std::io::ErrorKind::CrossesDevices => { copy_recursive(&from, &to)?; if from.is_dir() { std::fs::remove_dir_all(&from).map_err(|_| FsError::Forbidden)?; } else { std::fs::remove_file(&from).map_err(|_| FsError::Forbidden)?; } Ok(()) } Err(e) => Err(io_err(e, &to)), } } /// Copy an item (possibly across roots). pub fn copy_item( server_root: &Path, src_root_rel: &str, src_rel: &str, dst_root_rel: &str, dst_dir_rel: &str, overwrite: bool, ) -> Result<(), FsError> { let from = resolve_path(server_root, src_root_rel, src_rel)?; let dst_dir = resolve_dir(server_root, dst_root_rel, dst_dir_rel)?; let name = from .file_name() .ok_or_else(|| FsError::Invalid("invalid path".to_string()))? .to_owned(); let to = dst_dir.join(&name); if from.is_dir() && is_within_or_eq(&from, &dst_dir) { return Err(FsError::Invalid( "cannot copy a folder into itself".to_string(), )); } check_move_conflict(&to, &from, overwrite)?; copy_recursive(&from, &to)?; Ok(()) } /// Conflict rules shared by move and copy: /// - target is a directory → always conflict (no silent merge) /// - target is a file → conflict unless overwriting a file with a file fn check_move_conflict(to: &Path, from: &Path, overwrite: bool) -> Result<(), FsError> { if to.exists() { let to_dir = to.is_dir(); let from_dir = from.is_dir(); if to_dir || from_dir || !overwrite { return Err(FsError::Conflict); } } Ok(()) } /// Recursively copy a file or directory tree, preserving mtime. pub fn copy_recursive(src: &Path, dst: &Path) -> Result<(), FsError> { let meta = std::fs::metadata(src).map_err(|e| io_err(e, src))?; if meta.is_dir() { std::fs::create_dir(dst).map_err(|e| io_err(e, dst))?; for e in std::fs::read_dir(src).map_err(|e| io_err(e, src))?.flatten() { copy_recursive(&e.path(), &dst.join(e.file_name()))?; } } else { std::fs::copy(src, dst).map_err(|e| io_err(e, dst))?; } set_mtime(dst, meta.modified().ok()); Ok(()) } fn set_mtime(p: &Path, t: Option) { if let (Some(t), Ok(f)) = (t, std::fs::File::open(p)) { let _ = f.set_modified(t); } }