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 | #[error("already exists")] |
| 23 | Conflict, |
| 24 | #[error("{0}")] |
| 25 | Invalid(String), |
| 26 | } |
| 27 | |
| 28 | impl From<FsError> for ApiError { |
| 29 | fn from(e: FsError) -> Self { |
| 30 | use axum::http::StatusCode as S; |
| 31 | let status = match &e { |
| 32 | FsError::NotFound => S::NOT_FOUND, |
| 33 | FsError::NotADirectory => S::BAD_REQUEST, |
| 34 | FsError::Forbidden => S::FORBIDDEN, |
| 35 | FsError::RootMissing => S::NOT_FOUND, |
| 36 | FsError::Conflict => S::CONFLICT, |
| 37 | FsError::Invalid(_) => S::BAD_REQUEST, |
| 38 | }; |
| 39 | ApiError::new(status, e.to_string()) |
| 40 | } |
| 41 | } |
| 42 | |
| 43 | /// Resolve a user root (path relative to the server root) to a canonical |
| 44 | /// absolute path, verified to be inside the server root. |
| 45 | pub fn resolve_root(server_root: &Path, root_rel: &str) -> Result<PathBuf, FsError> { |
| 46 | let candidate = server_root.join(root_rel); |
| 47 | let canonical = candidate |
| 48 | .canonicalize() |
| 49 | .map_err(|_| FsError::RootMissing)?; |
| 50 | ensure_within(server_root, &canonical)?; |
| 51 | if !canonical.is_dir() { |
| 52 | return Err(FsError::RootMissing); |
| 53 | } |
| 54 | Ok(canonical) |
| 55 | } |
| 56 | |
| 57 | /// Resolve a requested path (relative to a user root) safely. |
| 58 | pub fn resolve_path(server_root: &Path, root_rel: &str, req_rel: &str) -> Result<PathBuf, FsError> { |
| 59 | let root_abs = resolve_root(server_root, root_rel)?; |
| 60 | let req = Path::new(req_rel); |
| 61 | for c in req.components() { |
| 62 | if matches!(c, Component::ParentDir) { |
| 63 | return Err(FsError::Forbidden); |
| 64 | } |
| 65 | } |
| 66 | let full = root_abs.join(req); |
| 67 | let full = full |
| 68 | .canonicalize() |
| 69 | .map_err(|e| match e.kind() { |
| 70 | std::io::ErrorKind::NotFound => FsError::NotFound, |
| 71 | _ => FsError::Forbidden, |
| 72 | })?; |
| 73 | ensure_within(&root_abs, &full)?; |
| 74 | Ok(full) |
| 75 | } |
| 76 | |
| 77 | fn ensure_within(base: &Path, p: &Path) -> Result<(), FsError> { |
| 78 | if p == base || p.starts_with(base) { |
| 79 | Ok(()) |
| 80 | } else { |
| 81 | Err(FsError::Forbidden) |
| 82 | } |
| 83 | } |
| 84 | |
| 85 | #[derive(Debug, Clone, serde::Serialize)] |
| 86 | pub struct Entry { |
| 87 | pub name: String, |
| 88 | pub is_dir: bool, |
| 89 | pub size: u64, |
| 90 | pub mtime: String, |
| 91 | } |
| 92 | |
| 93 | /// List a directory (blocking — call via spawn_blocking). |
| 94 | pub fn list_dir(dir: &Path) -> Result<Vec<Entry>, FsError> { |
| 95 | let rd = std::fs::read_dir(dir).map_err(|e| match e.kind() { |
| 96 | std::io::ErrorKind::NotFound => FsError::NotFound, |
| 97 | std::io::ErrorKind::NotADirectory => FsError::NotADirectory, |
| 98 | _ => FsError::Forbidden, |
| 99 | })?; |
| 100 | |
| 101 | let mut entries = Vec::new(); |
| 102 | for e in rd.flatten() { |
| 103 | let name = e.file_name().to_string_lossy().into_owned(); |
| 104 | // Follows symlinks; a broken link shows up as an empty file. |
| 105 | let meta = std::fs::metadata(e.path()); |
| 106 | let (is_dir, size, mtime) = match meta { |
| 107 | Ok(m) => (m.is_dir(), m.len(), mtime_str(&m)), |
| 108 | Err(_) => (false, 0, "1970-01-01T00:00:00Z".to_string()), |
| 109 | }; |
| 110 | entries.push(Entry { |
| 111 | name, |
| 112 | is_dir, |
| 113 | size, |
| 114 | mtime, |
| 115 | }); |
| 116 | } |
| 117 | |
| 118 | // Folders first, then case-insensitive name. |
| 119 | entries.sort_by(|a, b| { |
| 120 | b.is_dir |
| 121 | .cmp(&a.is_dir) |
| 122 | .then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase())) |
| 123 | .then_with(|| a.name.cmp(&b.name)) |
| 124 | }); |
| 125 | Ok(entries) |
| 126 | } |
| 127 | |
| 128 | fn mtime_str(m: &std::fs::Metadata) -> String { |
| 129 | let dt: Option<DateTime<chrono::Utc>> = m |
| 130 | .modified() |
| 131 | .ok() |
| 132 | .and_then(|t| t.duration_since(UNIX_EPOCH).ok()) |
| 133 | .and_then(|d| DateTime::from_timestamp(d.as_secs() as i64, 0)); |
| 134 | dt.map(|d| d.to_rfc3339_opts(chrono::SecondsFormat::Secs, true)) |
| 135 | .unwrap_or_else(|| "1970-01-01T00:00:00Z".to_string()) |
| 136 | } |
| 137 | |
| 138 | // --------------------------------------------------------------------------- |
| 139 | // Mutations (milestone 3): mkdir, rename, remove, move, copy, upload |
| 140 | // --------------------------------------------------------------------------- |
| 141 | |
| 142 | /// Resolve a directory that must exist (relative to a user root). Used as the |
| 143 | /// base for operations that target the *parent* of the item. |
| 144 | pub fn resolve_dir(server_root: &Path, root_rel: &str, req_rel: &str) -> Result<PathBuf, FsError> { |
| 145 | let full = resolve_path(server_root, root_rel, req_rel)?; |
| 146 | if !full.is_dir() { |
| 147 | return Err(FsError::NotADirectory); |
| 148 | } |
| 149 | Ok(full) |
| 150 | } |
| 151 | |
| 152 | /// Validate a new single-component name (for rename / new folder). |
| 153 | pub fn validate_name(name: &str) -> Result<(), FsError> { |
| 154 | let p = Path::new(name); |
| 155 | if name.is_empty() |
| 156 | || p.components().count() != 1 |
| 157 | || name == "." |
| 158 | || name == ".." |
| 159 | || name.contains(['/', '\\', '\0']) |
| 160 | { |
| 161 | return Err(FsError::Invalid("invalid name".to_string())); |
| 162 | } |
| 163 | Ok(()) |
| 164 | } |
| 165 | |
| 166 | /// Create a directory (and any missing parents) inside a user root. |
| 167 | pub fn mkdir(server_root: &Path, root_rel: &str, req_rel: &str) -> Result<(), FsError> { |
| 168 | let full = resolve_path_or_new(server_root, root_rel, req_rel)?; |
| 169 | if full.exists() { |
| 170 | return Err(FsError::Conflict); |
| 171 | } |
| 172 | std::fs::create_dir_all(&full).map_err(|e| io_err(e, &full))?; |
| 173 | Ok(()) |
| 174 | } |
| 175 | |
| 176 | /// Resolve a path that does not need to exist yet, but whose *parent* must. |
| 177 | fn resolve_path_or_new(server_root: &Path, root_rel: &str, req_rel: &str) -> Result<PathBuf, FsError> { |
| 178 | let root_abs = resolve_root(server_root, root_rel)?; |
| 179 | let req = Path::new(req_rel); |
| 180 | for c in req.components() { |
| 181 | if matches!(c, Component::ParentDir) { |
| 182 | return Err(FsError::Forbidden); |
| 183 | } |
| 184 | } |
| 185 | let full = root_abs.join(req); |
| 186 | // The parent must exist and stay inside the root. |
| 187 | let parent = full |
| 188 | .parent() |
| 189 | .filter(|p| !p.as_os_str().is_empty()) |
| 190 | .ok_or_else(|| FsError::Invalid("invalid path".to_string()))?; |
| 191 | let parent = parent.canonicalize().map_err(|e| io_err(e, parent))?; |
| 192 | ensure_within(&root_abs, &parent)?; |
| 193 | Ok(full) |
| 194 | } |
| 195 | |
| 196 | /// Rename (or move within the same directory) an item. |
| 197 | pub fn rename_item( |
| 198 | server_root: &Path, |
| 199 | root_rel: &str, |
| 200 | req_rel: &str, |
| 201 | new_name: &str, |
| 202 | overwrite: bool, |
| 203 | ) -> Result<(), FsError> { |
| 204 | validate_name(new_name)?; |
| 205 | let from = resolve_path(server_root, root_rel, req_rel)?; |
| 206 | let parent = from |
| 207 | .parent() |
| 208 | .ok_or_else(|| FsError::Invalid("invalid path".to_string()))?; |
| 209 | let to = parent.join(new_name); |
| 210 | if to.exists() { |
| 211 | if !overwrite || to.is_dir() || from.is_dir() { |
| 212 | return Err(FsError::Conflict); |
| 213 | } |
| 214 | std::fs::remove_file(&to).map_err(|e| io_err(e, &to))?; |
| 215 | } |
| 216 | std::fs::rename(&from, &to).map_err(|e| io_err(e, &to))?; |
| 217 | Ok(()) |
| 218 | } |
| 219 | |
| 220 | /// Delete a file or a directory tree. Returns whether it was a directory. |
| 221 | pub fn remove_item(server_root: &Path, root_rel: &str, req_rel: &str) -> Result<bool, FsError> { |
| 222 | let full = resolve_path(server_root, root_rel, req_rel)?; |
| 223 | let is_dir = full.is_dir(); |
| 224 | if is_dir { |
| 225 | std::fs::remove_dir_all(&full).map_err(|e| io_err(e, &full))?; |
| 226 | } else { |
| 227 | std::fs::remove_file(&full).map_err(|e| io_err(e, &full))?; |
| 228 | } |
| 229 | Ok(is_dir) |
| 230 | } |
| 231 | |
| 232 | fn io_err(e: std::io::Error, p: &Path) -> FsError { |
| 233 | tracing::warn!(error = %e, path = %p.display(), "filesystem error"); |
| 234 | match e.kind() { |
| 235 | std::io::ErrorKind::NotFound => FsError::NotFound, |
| 236 | _ => FsError::Forbidden, |
| 237 | } |
| 238 | } |
| 239 | |
| 240 | /// True if `a` is `b` or a descendant of `b` (both canonical). |
| 241 | fn is_within_or_eq(base: &Path, p: &Path) -> bool { |
| 242 | p == base || p.starts_with(base) |
| 243 | } |
| 244 | |
| 245 | /// Move an item (possibly across roots). `dst_dir_rel` is the destination |
| 246 | /// directory (relative to `dst_root_rel`); the item keeps its base name. |
| 247 | pub fn move_item( |
| 248 | server_root: &Path, |
| 249 | src_root_rel: &str, |
| 250 | src_rel: &str, |
| 251 | dst_root_rel: &str, |
| 252 | dst_dir_rel: &str, |
| 253 | overwrite: bool, |
| 254 | ) -> Result<(), FsError> { |
| 255 | let from = resolve_path(server_root, src_root_rel, src_rel)?; |
| 256 | let dst_dir = resolve_dir(server_root, dst_root_rel, dst_dir_rel)?; |
| 257 | let name = from |
| 258 | .file_name() |
| 259 | .ok_or_else(|| FsError::Invalid("invalid path".to_string()))? |
| 260 | .to_owned(); |
| 261 | let to = dst_dir.join(&name); |
| 262 | |
| 263 | // Refuse moving a directory into itself or a descendant. |
| 264 | if from.is_dir() && is_within_or_eq(&from, &dst_dir) { |
| 265 | return Err(FsError::Invalid( |
| 266 | "cannot move a folder into itself".to_string(), |
| 267 | )); |
| 268 | } |
| 269 | check_move_conflict(&to, &from, overwrite)?; |
| 270 | |
| 271 | match std::fs::rename(&from, &to) { |
| 272 | Ok(()) => Ok(()), |
| 273 | Err(e) if e.kind() == std::io::ErrorKind::CrossesDevices => { |
| 274 | copy_recursive(&from, &to)?; |
| 275 | if from.is_dir() { |
| 276 | std::fs::remove_dir_all(&from).map_err(|_| FsError::Forbidden)?; |
| 277 | } else { |
| 278 | std::fs::remove_file(&from).map_err(|_| FsError::Forbidden)?; |
| 279 | } |
| 280 | Ok(()) |
| 281 | } |
| 282 | Err(e) => Err(io_err(e, &to)), |
| 283 | } |
| 284 | } |
| 285 | |
| 286 | /// Copy an item (possibly across roots). |
| 287 | pub fn copy_item( |
| 288 | server_root: &Path, |
| 289 | src_root_rel: &str, |
| 290 | src_rel: &str, |
| 291 | dst_root_rel: &str, |
| 292 | dst_dir_rel: &str, |
| 293 | overwrite: bool, |
| 294 | ) -> Result<(), FsError> { |
| 295 | let from = resolve_path(server_root, src_root_rel, src_rel)?; |
| 296 | let dst_dir = resolve_dir(server_root, dst_root_rel, dst_dir_rel)?; |
| 297 | let name = from |
| 298 | .file_name() |
| 299 | .ok_or_else(|| FsError::Invalid("invalid path".to_string()))? |
| 300 | .to_owned(); |
| 301 | let to = dst_dir.join(&name); |
| 302 | |
| 303 | if from.is_dir() && is_within_or_eq(&from, &dst_dir) { |
| 304 | return Err(FsError::Invalid( |
| 305 | "cannot copy a folder into itself".to_string(), |
| 306 | )); |
| 307 | } |
| 308 | check_move_conflict(&to, &from, overwrite)?; |
| 309 | copy_recursive(&from, &to)?; |
| 310 | Ok(()) |
| 311 | } |
| 312 | |
| 313 | /// Conflict rules shared by move and copy: |
| 314 | /// - target is a directory → always conflict (no silent merge) |
| 315 | /// - target is a file → conflict unless overwriting a file with a file |
| 316 | fn check_move_conflict(to: &Path, from: &Path, overwrite: bool) -> Result<(), FsError> { |
| 317 | if to.exists() { |
| 318 | let to_dir = to.is_dir(); |
| 319 | let from_dir = from.is_dir(); |
| 320 | if to_dir || from_dir || !overwrite { |
| 321 | return Err(FsError::Conflict); |
| 322 | } |
| 323 | } |
| 324 | Ok(()) |
| 325 | } |
| 326 | |
| 327 | /// Recursively copy a file or directory tree, preserving mtime. |
| 328 | pub fn copy_recursive(src: &Path, dst: &Path) -> Result<(), FsError> { |
| 329 | let meta = std::fs::metadata(src).map_err(|e| io_err(e, src))?; |
| 330 | if meta.is_dir() { |
| 331 | std::fs::create_dir(dst).map_err(|e| io_err(e, dst))?; |
| 332 | for e in std::fs::read_dir(src).map_err(|e| io_err(e, src))?.flatten() { |
| 333 | copy_recursive(&e.path(), &dst.join(e.file_name()))?; |
| 334 | } |
| 335 | } else { |
| 336 | std::fs::copy(src, dst).map_err(|e| io_err(e, dst))?; |
| 337 | } |
| 338 | set_mtime(dst, meta.modified().ok()); |
| 339 | Ok(()) |
| 340 | } |
| 341 | |
| 342 | fn set_mtime(p: &Path, t: Option<std::time::SystemTime>) { |
| 343 | if let (Some(t), Ok(f)) = (t, std::fs::File::open(p)) { |
| 344 | let _ = f.set_modified(t); |
| 345 | } |
| 346 | } |
| 347 |