//! 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) } /// Resolve a share target that is a single file (relative to the server root). /// Unlike [`resolve_path`], the target itself is the file — there is no /// directory root beneath it. pub fn resolve_file(server_root: &Path, rel: &str) -> Result { let full = server_root.join(rel); let full = full .canonicalize() .map_err(|e| match e.kind() { std::io::ErrorKind::NotFound => FsError::NotFound, _ => FsError::Forbidden, })?; ensure_within(server_root, &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); // Renaming onto itself is a no-op (the overwrite path below would // delete the file before the rename). if to == from { return Ok(()); } 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) } /// Overwrite an existing file's contents (the editor's save path). /// /// The file must already exist and be a regular file. If `expected_mtime` /// (whole unix seconds) is provided and differs from the file's current mtime, /// the file changed on disk since it was read → `Conflict` (409). Returns the /// file's new mtime (unix seconds) after a successful write. pub fn save_file( server_root: &Path, root_rel: &str, req_rel: &str, content: &[u8], expected_mtime: Option, ) -> Result { let full = resolve_path(server_root, root_rel, req_rel)?; // must exist let meta = std::fs::metadata(&full).map_err(|_| FsError::NotFound)?; if meta.is_dir() { return Err(FsError::NotADirectory); } if let Some(expected) = expected_mtime { let cur = meta .modified() .ok() .and_then(|t| t.duration_since(UNIX_EPOCH).ok()) .map(|d| d.as_secs() as i64) .unwrap_or(-1); if cur != expected { return Err(FsError::Conflict); } } std::fs::write(&full, content).map_err(|e| io_err(e, &full))?; // Read the new mtime so the client can anchor the next conflict check. let new_meta = std::fs::metadata(&full).map_err(|_| FsError::NotFound)?; let mtime = new_meta .modified() .ok() .and_then(|t| t.duration_since(UNIX_EPOCH).ok()) .map(|d| d.as_secs() as i64) .unwrap_or(0); Ok(mtime) } 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); // A no-op (item already at the destination) — treat as success. if to == from { return Ok(()); } // 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); // A no-op (item already at the destination) — treat as success. if to == from { return Ok(()); } 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); } } // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- #[cfg(test)] mod tests { use super::*; /// A temp dir used as the "server root" with a small fixture tree: /// /// ```text /// root/ /// docs/ /// inner/ /// hello.txt /// a.txt /// src/ /// main.rs /// file.txt /// ``` struct T { tmp: tempfile::TempDir, root: PathBuf, } impl T { fn new() -> Self { let tmp = tempfile::tempdir().unwrap(); let root = tmp.path().to_path_buf(); std::fs::create_dir_all(root.join("docs/inner")).unwrap(); std::fs::create_dir_all(root.join("src")).unwrap(); std::fs::write(root.join("docs/inner/hello.txt"), "hello").unwrap(); std::fs::write(root.join("docs/a.txt"), "a").unwrap(); std::fs::write(root.join("src/main.rs"), "fn main() {}").unwrap(); std::fs::write(root.join("file.txt"), "top file").unwrap(); Self { tmp, root } } /// A directory that lives *next to* the root (outside of it), for /// symlink/escape tests. The tempdir name is unique, so the sibling /// name is unique too. fn sibling(&self, name: &str) -> PathBuf { let base = self .tmp .path() .file_name() .unwrap() .to_string_lossy() .into_owned(); let p = self.tmp.path().with_file_name(format!("{base}-{name}")); std::fs::create_dir_all(&p).unwrap(); p } } // ---------- validate_name ---------- #[test] fn validate_name_accepts_simple_names() { for ok in ["a", "file.txt", "my folder", "Ünïcödé", "with-dash_1.2.3"] { assert!(validate_name(ok).is_ok(), "{ok:?} should be valid"); } } #[test] fn validate_name_rejects_traversal_and_paths() { for bad in [ "", ".", "..", "a/b", "a\\b", "a\0b", "/abs", "../x", "x/../y", "x/", "/x", ] { assert!(validate_name(bad).is_err(), "{bad:?} should be invalid"); } } // ---------- resolve_root ---------- #[test] fn resolve_root_whole_root_and_subdir() { let t = T::new(); let root = t.root.canonicalize().unwrap(); // "." means the whole root. assert_eq!(resolve_root(&root, ".").unwrap(), root); assert_eq!(resolve_root(&root, "docs").unwrap(), root.join("docs")); assert_eq!( resolve_root(&root, "docs/inner").unwrap(), root.join("docs/inner") ); } #[test] fn resolve_root_rejects_escape_and_missing() { let t = T::new(); let root = t.root.canonicalize().unwrap(); let sib = t.sibling("escape"); let sib_rel = sib .file_name() .unwrap() .to_string_lossy() .into_owned(); // Escapes that land on *existing* paths outside the root. for esc in ["..".to_string(), "docs/../..".to_string(), format!("../{sib_rel}")] { assert!( matches!(resolve_root(&root, &esc), Err(FsError::Forbidden)), "{esc:?} should be forbidden" ); } // Escapes to non-existing paths simply don't exist. for esc in ["../no-such-dir", "a/b/../../..", "nope"] { assert!( matches!(resolve_root(&root, &esc), Err(FsError::RootMissing)), "{esc:?} should be missing" ); } // A file is not a valid root. assert!(matches!( resolve_root(&root, "file.txt"), Err(FsError::RootMissing) )); } #[cfg(unix)] #[test] fn resolve_root_rejects_symlink_escape() { let t = T::new(); let root = t.root.canonicalize().unwrap(); let outside = t.sibling("outside"); std::os::unix::fs::symlink(&outside, root.join("link")).unwrap(); assert!(matches!( resolve_root(&root, "link"), Err(FsError::Forbidden) )); } // ---------- resolve_path ---------- #[test] fn resolve_path_traverses_inside_root() { let t = T::new(); let root = t.root.canonicalize().unwrap(); // Empty relative path → the root itself. assert_eq!(resolve_path(&root, ".", "").unwrap(), root); assert_eq!( resolve_path(&root, "docs", "inner/hello.txt").unwrap(), root.join("docs/inner/hello.txt") ); assert_eq!(resolve_path(&root, ".", "file.txt").unwrap(), root.join("file.txt")); } #[test] fn resolve_path_rejects_parent_traversal() { let t = T::new(); let root = t.root.canonicalize().unwrap(); for p in ["..", "../file.txt", "docs/../../file.txt", "a/../../b"] { assert!( matches!(resolve_path(&root, ".", p), Err(FsError::Forbidden)), "{p:?} should be forbidden" ); } } #[test] fn resolve_path_missing_is_not_found() { let t = T::new(); let root = t.root.canonicalize().unwrap(); assert!(matches!( resolve_path(&root, "docs", "nope.txt"), Err(FsError::NotFound) )); assert!(matches!( resolve_path(&root, "missing-root", ""), Err(FsError::RootMissing) )); } #[cfg(unix)] #[test] fn resolve_path_rejects_symlink_escape() { let t = T::new(); let root = t.root.canonicalize().unwrap(); let outside = t.sibling("outside"); let secret = outside.join("secret.txt"); std::fs::write(&secret, "top secret").unwrap(); std::os::unix::fs::symlink(&secret, root.join("evil")).unwrap(); assert!(matches!( resolve_path(&root, ".", "evil"), Err(FsError::Forbidden) )); // A symlink that stays inside the root is fine. std::os::unix::fs::symlink(root.join("file.txt"), root.join("alias")).unwrap(); assert_eq!( resolve_path(&root, ".", "alias").unwrap(), root.join("file.txt") ); } // ---------- resolve_file / resolve_dir ---------- #[test] fn resolve_file_targets_files() { let t = T::new(); let root = t.root.canonicalize().unwrap(); assert_eq!(resolve_file(&root, "file.txt").unwrap(), root.join("file.txt")); assert!(matches!( resolve_file(&root, "nope.txt"), Err(FsError::NotFound) )); // Escape to an existing sibling file. let sib = t.sibling("escape"); let sib_rel = sib .file_name() .unwrap() .to_string_lossy() .into_owned(); std::fs::write(sib.join("s.txt"), "x").unwrap(); assert!(matches!( resolve_file(&root, &format!("../{sib_rel}/s.txt")), Err(FsError::Forbidden) )); } #[test] fn resolve_dir_requires_existing_directory() { let t = T::new(); let root = t.root.canonicalize().unwrap(); assert_eq!(resolve_dir(&root, ".", "docs").unwrap(), root.join("docs")); assert!(matches!( resolve_dir(&root, ".", "file.txt"), Err(FsError::NotADirectory) )); assert!(matches!(resolve_dir(&root, ".", "nope"), Err(FsError::NotFound))); } // ---------- list_dir ---------- #[test] fn list_dir_sorts_folders_first_then_case_insensitive() { let t = T::new(); let d = t.root.join("sortme"); std::fs::create_dir_all(d.join("Zeta")).unwrap(); std::fs::create_dir_all(d.join("alpha-dir")).unwrap(); std::fs::write(d.join("b.txt"), "x").unwrap(); std::fs::write(d.join("A.txt"), "x").unwrap(); std::fs::write(d.join("C.md"), "x").unwrap(); let entries = list_dir(&d).unwrap(); let names: Vec<&str> = entries.iter().map(|e| e.name.as_str()).collect(); // Folders first (alpha-dir, Zeta), then files case-insensitively. assert_eq!(names, vec!["alpha-dir", "Zeta", "A.txt", "b.txt", "C.md"]); let a = &entries[2]; assert!(!a.is_dir); assert_eq!(a.size, 1); assert!(!a.mtime.is_empty()); } #[test] fn list_dir_error_cases() { let t = T::new(); let root = t.root.canonicalize().unwrap(); assert!(matches!( list_dir(&root.join("missing")), Err(FsError::NotFound) )); assert!(matches!( list_dir(&root.join("file.txt")), Err(FsError::NotADirectory) )); } #[cfg(unix)] #[test] fn list_dir_reports_broken_symlink_as_empty_file() { let t = T::new(); let d = t.root.join("withlink"); std::fs::create_dir_all(&d).unwrap(); std::os::unix::fs::symlink(d.join("does-not-exist"), d.join("broken")).unwrap(); let entries = list_dir(&d).unwrap(); assert_eq!(entries.len(), 1); assert_eq!(entries[0].name, "broken"); assert!(!entries[0].is_dir); assert_eq!(entries[0].size, 0); } // ---------- mkdir ---------- #[test] fn mkdir_creates_nested_dirs() { let t = T::new(); let root = t.root.canonicalize().unwrap(); // The parent must exist; "new" first, then "new/sub". mkdir(&root, ".", "new").unwrap(); assert!(root.join("new").is_dir()); mkdir(&root, ".", "new/sub").unwrap(); assert!(root.join("new/sub").is_dir()); } #[test] fn mkdir_rejects_conflict_and_bad_names() { let t = T::new(); let root = t.root.canonicalize().unwrap(); assert!(matches!( mkdir(&root, ".", "docs"), Err(FsError::Conflict) )); assert!(matches!( mkdir(&root, ".", "a/b/../../c"), Err(FsError::Forbidden) )); assert!(matches!( mkdir(&root, ".", "file.txt/x"), Err(FsError::Forbidden) // parent is a file → ENOTDIR )); } // ---------- rename ---------- #[test] fn rename_moves_file_and_dir() { let t = T::new(); let root = t.root.canonicalize().unwrap(); rename_item(&root, ".", "file.txt", "renamed.txt", false).unwrap(); assert!(!root.join("file.txt").exists()); assert_eq!( std::fs::read_to_string(root.join("renamed.txt")).unwrap(), "top file" ); rename_item(&root, ".", "docs", "docs2", false).unwrap(); assert!(root.join("docs2/inner/hello.txt").exists()); } #[test] fn rename_conflicts_and_overwrite() { let t = T::new(); let root = t.root.canonicalize().unwrap(); std::fs::write(root.join("other.txt"), "other").unwrap(); // Target file exists, no overwrite → conflict. assert!(matches!( rename_item(&root, ".", "file.txt", "other.txt", false), Err(FsError::Conflict) )); // Overwrite a file target → replaces it. rename_item(&root, ".", "file.txt", "other.txt", true).unwrap(); assert_eq!( std::fs::read_to_string(root.join("other.txt")).unwrap(), "top file" ); // A dir target is never overwritten, even with the flag. assert!(matches!( rename_item(&root, ".", "other.txt", "docs", true), Err(FsError::Conflict) )); // Renaming into a free slot works, then onto itself is a no-op. rename_item(&root, ".", "other.txt", "free.txt", false).unwrap(); assert!(root.join("free.txt").exists()); rename_item(&root, ".", "free.txt", "free.txt", false).unwrap(); assert!(root.join("free.txt").exists()); assert!(root.join("free.txt").is_file()); } #[test] fn rename_validates_new_name() { let t = T::new(); let root = t.root.canonicalize().unwrap(); for bad in ["a/b", "", ".", ".."] { assert!(matches!( rename_item(&root, ".", "file.txt", bad, false), Err(FsError::Invalid(_)) )); } assert!(matches!( rename_item(&root, ".", "missing", "x", false), Err(FsError::NotFound) )); } // ---------- remove ---------- #[test] fn remove_file_and_dir() { let t = T::new(); let root = t.root.canonicalize().unwrap(); assert_eq!(remove_item(&root, ".", "file.txt").unwrap(), false); assert!(!root.join("file.txt").exists()); assert_eq!(remove_item(&root, ".", "docs").unwrap(), true); assert!(!root.join("docs").exists()); assert!(matches!( remove_item(&root, ".", "file.txt"), Err(FsError::NotFound) )); } // ---------- save_file ---------- fn mtime_of(p: &Path) -> i64 { std::fs::metadata(p) .unwrap() .modified() .unwrap() .duration_since(std::time::UNIX_EPOCH) .unwrap() .as_secs() as i64 } #[test] fn save_file_updates_content_and_returns_new_mtime() { let t = T::new(); let root = t.root.canonicalize().unwrap(); let before = mtime_of(&root.join("file.txt")); // Sleep so the mtime actually advances (filesystem granularity). std::thread::sleep(std::time::Duration::from_millis(1100)); let new = save_file(&root, ".", "file.txt", b"brand new", Some(before)).unwrap(); assert_eq!(std::fs::read(root.join("file.txt")).unwrap(), b"brand new"); assert!(new >= before); // A second save with the *returned* mtime succeeds. let new2 = save_file(&root, ".", "file.txt", b"again", Some(new)).unwrap(); assert!(new2 >= new); // Without an expected mtime, always saves. let _ = save_file(&root, ".", "file.txt", b"force", None).unwrap(); assert_eq!(std::fs::read(root.join("file.txt")).unwrap(), b"force"); } #[test] fn save_file_conflict_on_stale_mtime() { let t = T::new(); let root = t.root.canonicalize().unwrap(); std::thread::sleep(std::time::Duration::from_millis(1100)); // The mtime we pass is older than the file's real mtime → conflict. assert!(matches!( save_file(&root, ".", "file.txt", b"x", Some(1)), Err(FsError::Conflict) )); } #[test] fn save_file_error_cases() { let t = T::new(); let root = t.root.canonicalize().unwrap(); assert!(matches!( save_file(&root, ".", "nope.txt", b"x", None), Err(FsError::NotFound) )); assert!(matches!( save_file(&root, ".", "docs", b"x", None), Err(FsError::NotADirectory) )); assert!(matches!( save_file(&root, ".", "../evil.txt", b"x", None), Err(FsError::Forbidden) )); } // ---------- move / copy ---------- #[test] fn move_file_and_dir_across_dirs() { let t = T::new(); let root = t.root.canonicalize().unwrap(); move_item(&root, ".", "file.txt", ".", "src", false).unwrap(); assert!(!root.join("file.txt").exists()); assert!(root.join("src/file.txt").exists()); move_item(&root, ".", "src", ".", "docs", false).unwrap(); assert!(root.join("docs/src/main.rs").exists()); assert!(!root.join("src").exists()); } #[test] fn move_refuses_into_self_and_conflicts() { let t = T::new(); let root = t.root.canonicalize().unwrap(); // A dir cannot be moved into itself or a descendant. assert!(matches!( move_item(&root, ".", "docs", ".", "docs", false), Err(FsError::Invalid(_)) )); assert!(matches!( move_item(&root, ".", "docs", ".", "docs/inner", false), Err(FsError::Invalid(_)) )); // A dir target always conflicts, even with overwrite: move the file // "x" into a folder that already contains a subfolder "x". std::fs::create_dir_all(root.join("mv/case/x")).unwrap(); std::fs::create_dir_all(root.join("mv/out")).unwrap(); std::fs::write(root.join("mv/out/x"), "a file named x").unwrap(); assert!(matches!( move_item(&root, ".", "mv/out/x", ".", "mv/case", true), Err(FsError::Conflict) )); // File onto file: conflict without overwrite, replaced with. std::fs::write(root.join("tmp-x.txt"), "x").unwrap(); std::fs::write(root.join("tmp-y.txt"), "y").unwrap(); std::fs::rename(&root.join("tmp-x.txt"), &root.join("tmp-target.txt")).unwrap(); std::fs::rename(&root.join("tmp-y.txt"), &root.join("tmp-target2.txt")).unwrap(); // Two distinct files with the same name in one folder. std::fs::create_dir_all(root.join("mv/dst")).unwrap(); std::fs::create_dir_all(root.join("mv/out2")).unwrap(); std::fs::write(root.join("mv/dst/dup.txt"), "old").unwrap(); std::fs::write(root.join("mv/out2/dup.txt"), "new").unwrap(); assert!(matches!( move_item(&root, ".", "mv/out2/dup.txt", ".", "mv/dst", false), Err(FsError::Conflict) )); move_item(&root, ".", "mv/out2/dup.txt", ".", "mv/dst", true).unwrap(); assert_eq!( std::fs::read_to_string(root.join("mv/dst/dup.txt")).unwrap(), "new" ); // Moving onto itself is a no-op success. move_item(&root, ".", "tmp-target.txt", ".", ".", false).unwrap(); assert!(root.join("tmp-target.txt").exists()); // Missing destination dir. assert!(matches!( move_item(&root, ".", "file.txt", ".", "nope", false), Err(FsError::NotFound) )); } #[test] fn copy_file_and_dir_preserves_mtime() { let t = T::new(); let root = t.root.canonicalize().unwrap(); let before = mtime_of(&root.join("file.txt")); copy_item(&root, ".", "file.txt", ".", "src", false).unwrap(); let copy = root.join("src/file.txt"); assert_eq!(std::fs::read(©).unwrap(), b"top file"); assert_eq!(mtime_of(©), before); // Dir copy. copy_item(&root, ".", "docs", ".", "src", false).unwrap(); assert_eq!( std::fs::read_to_string(root.join("src/docs/inner/hello.txt")).unwrap(), "hello" ); // Originals still there. assert!(root.join("file.txt").exists()); assert!(root.join("docs/a.txt").exists()); } #[test] fn copy_refuses_into_self_and_handles_conflict() { let t = T::new(); let root = t.root.canonicalize().unwrap(); assert!(matches!( copy_item(&root, ".", "docs", ".", "docs", false), Err(FsError::Invalid(_)) )); assert!(matches!( copy_item(&root, ".", "docs", ".", "docs/inner", false), Err(FsError::Invalid(_)) )); // First copy is fine, the second one conflicts, overwrite replaces. copy_item(&root, ".", "file.txt", ".", "src", false).unwrap(); assert!(matches!( copy_item(&root, ".", "file.txt", ".", "src", false), Err(FsError::Conflict) )); std::fs::write(root.join("file.txt"), "v2").unwrap(); copy_item(&root, ".", "file.txt", ".", "src", true).unwrap(); assert_eq!(std::fs::read_to_string(root.join("src/file.txt")).unwrap(), "v2"); // Copying onto itself is a no-op success. copy_item(&root, ".", "src/file.txt", ".", "src", false).unwrap(); assert_eq!(std::fs::read_to_string(root.join("src/file.txt")).unwrap(), "v2"); // Missing destination dir. assert!(matches!( copy_item(&root, ".", "file.txt", ".", "nope", false), Err(FsError::NotFound) )); } #[test] fn copy_recursive_missing_source() { let t = T::new(); let dst = t.tmp.path().join("dst"); assert!(matches!( copy_recursive(&t.root.join("nope"), &dst), Err(FsError::NotFound) )); } // ---------- is_within_or_eq ---------- #[test] fn is_within_or_eq_matrix() { let t = T::new(); let root = t.root.canonicalize().unwrap(); let docs = root.join("docs"); assert!(is_within_or_eq(&docs, &docs)); assert!(is_within_or_eq(&docs, &root.join("docs/inner"))); assert!(!is_within_or_eq(&docs, &root)); assert!(!is_within_or_eq(&docs, &root.join("src"))); } }