fs.rs
| 1 | //! Safe filesystem access: every operation resolves |
| 2 | //! `<server-root>/<user-root>/<requested-path>`, canonicalizes it and verifies |
| 3 | //! the result is still inside the user's root (blocks `..` and symlink escapes). |
| 4 | |
| 5 | use std::path::{Component, Path, PathBuf}; |
| 6 | use std::time::UNIX_EPOCH; |
| 7 | |
| 8 | use chrono::DateTime; |
| 9 | |
| 10 | use crate::error::ApiError; |
| 11 | |
| 12 | #[derive(Debug, thiserror::Error)] |
| 13 | pub enum FsError { |
| 14 | #[error("folder not found")] |
| 15 | NotFound, |
| 16 | #[error("not a folder")] |
| 17 | NotADirectory, |
| 18 | #[error("access denied")] |
| 19 | Forbidden, |
| 20 | #[error("the configured folder no longer exists")] |
| 21 | RootMissing, |
| 22 | } |
| 23 | |
| 24 | impl From<FsError> for ApiError { |
| 25 | fn from(e: FsError) -> Self { |
| 26 | use axum::http::StatusCode as S; |
| 27 | let status = match &e { |
| 28 | FsError::NotFound => S::NOT_FOUND, |
| 29 | FsError::NotADirectory => S::BAD_REQUEST, |
| 30 | FsError::Forbidden => S::FORBIDDEN, |
| 31 | FsError::RootMissing => S::NOT_FOUND, |
| 32 | }; |
| 33 | ApiError::new(status, e.to_string()) |
| 34 | } |
| 35 | } |
| 36 | |
| 37 | /// Resolve a user root (path relative to the server root) to a canonical |
| 38 | /// absolute path, verified to be inside the server root. |
| 39 | pub fn resolve_root(server_root: &Path, root_rel: &str) -> Result<PathBuf, FsError> { |
| 40 | let candidate = server_root.join(root_rel); |
| 41 | let canonical = candidate |
| 42 | .canonicalize() |
| 43 | .map_err(|_| FsError::RootMissing)?; |
| 44 | ensure_within(server_root, &canonical)?; |
| 45 | if !canonical.is_dir() { |
| 46 | return Err(FsError::RootMissing); |
| 47 | } |
| 48 | Ok(canonical) |
| 49 | } |
| 50 | |
| 51 | /// Resolve a requested path (relative to a user root) safely. |
| 52 | pub fn resolve_path(server_root: &Path, root_rel: &str, req_rel: &str) -> Result<PathBuf, FsError> { |
| 53 | let root_abs = resolve_root(server_root, root_rel)?; |
| 54 | let req = Path::new(req_rel); |
| 55 | for c in req.components() { |
| 56 | if matches!(c, Component::ParentDir) { |
| 57 | return Err(FsError::Forbidden); |
| 58 | } |
| 59 | } |
| 60 | let full = root_abs.join(req); |
| 61 | let full = full |
| 62 | .canonicalize() |
| 63 | .map_err(|e| match e.kind() { |
| 64 | std::io::ErrorKind::NotFound => FsError::NotFound, |
| 65 | _ => FsError::Forbidden, |
| 66 | })?; |
| 67 | ensure_within(&root_abs, &full)?; |
| 68 | Ok(full) |
| 69 | } |
| 70 | |
| 71 | fn ensure_within(base: &Path, p: &Path) -> Result<(), FsError> { |
| 72 | if p == base || p.starts_with(base) { |
| 73 | Ok(()) |
| 74 | } else { |
| 75 | Err(FsError::Forbidden) |
| 76 | } |
| 77 | } |
| 78 | |
| 79 | #[derive(Debug, Clone, serde::Serialize)] |
| 80 | pub struct Entry { |
| 81 | pub name: String, |
| 82 | pub is_dir: bool, |
| 83 | pub size: u64, |
| 84 | pub mtime: String, |
| 85 | } |
| 86 | |
| 87 | /// List a directory (blocking — call via spawn_blocking). |
| 88 | pub fn list_dir(dir: &Path) -> Result<Vec<Entry>, FsError> { |
| 89 | let rd = std::fs::read_dir(dir).map_err(|e| match e.kind() { |
| 90 | std::io::ErrorKind::NotFound => FsError::NotFound, |
| 91 | std::io::ErrorKind::NotADirectory => FsError::NotADirectory, |
| 92 | _ => FsError::Forbidden, |
| 93 | })?; |
| 94 | |
| 95 | let mut entries = Vec::new(); |
| 96 | for e in rd.flatten() { |
| 97 | let name = e.file_name().to_string_lossy().into_owned(); |
| 98 | // Follows symlinks; a broken link shows up as an empty file. |
| 99 | let meta = std::fs::metadata(e.path()); |
| 100 | let (is_dir, size, mtime) = match meta { |
| 101 | Ok(m) => (m.is_dir(), m.len(), mtime_str(&m)), |
| 102 | Err(_) => (false, 0, "1970-01-01T00:00:00Z".to_string()), |
| 103 | }; |
| 104 | entries.push(Entry { |
| 105 | name, |
| 106 | is_dir, |
| 107 | size, |
| 108 | mtime, |
| 109 | }); |
| 110 | } |
| 111 | |
| 112 | // Folders first, then case-insensitive name. |
| 113 | entries.sort_by(|a, b| { |
| 114 | b.is_dir |
| 115 | .cmp(&a.is_dir) |
| 116 | .then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase())) |
| 117 | .then_with(|| a.name.cmp(&b.name)) |
| 118 | }); |
| 119 | Ok(entries) |
| 120 | } |
| 121 | |
| 122 | fn mtime_str(m: &std::fs::Metadata) -> String { |
| 123 | let dt: Option<DateTime<chrono::Utc>> = m |
| 124 | .modified() |
| 125 | .ok() |
| 126 | .and_then(|t| t.duration_since(UNIX_EPOCH).ok()) |
| 127 | .and_then(|d| DateTime::from_timestamp(d.as_secs() as i64, 0)); |
| 128 | dt.map(|d| d.to_rfc3339_opts(chrono::SecondsFormat::Secs, true)) |
| 129 | .unwrap_or_else(|| "1970-01-01T00:00:00Z".to_string()) |
| 130 | } |
| 131 |