fs.rs
⎇
Raw
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
5use std::path::{Component, Path, PathBuf};
6use std::time::UNIX_EPOCH;
7
8use chrono::DateTime;
9
10use crate::error::ApiError;
11
12#[derive(Debug, thiserror::Error)]
13pub 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
28impl 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.
45pub 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.
58pub 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/// Resolve a share target that is a single file (relative to the server root).
78/// Unlike [`resolve_path`], the target itself is the file — there is no
79/// directory root beneath it.
80pub fn resolve_file(server_root: &Path, rel: &str) -> Result<PathBuf, FsError> {
81 let full = server_root.join(rel);
82 let full = full
83 .canonicalize()
84 .map_err(|e| match e.kind() {
85 std::io::ErrorKind::NotFound => FsError::NotFound,
86 _ => FsError::Forbidden,
87 })?;
88 ensure_within(server_root, &full)?;
89 Ok(full)
90}
91
92fn ensure_within(base: &Path, p: &Path) -> Result<(), FsError> {
93 if p == base || p.starts_with(base) {
94 Ok(())
95 } else {
96 Err(FsError::Forbidden)
97 }
98}
99
100#[derive(Debug, Clone, serde::Serialize)]
101pub struct Entry {
102 pub name: String,
103 pub is_dir: bool,
104 pub size: u64,
105 pub mtime: String,
106}
107
108/// List a directory (blocking — call via spawn_blocking).
109pub fn list_dir(dir: &Path) -> Result<Vec<Entry>, FsError> {
110 let rd = std::fs::read_dir(dir).map_err(|e| match e.kind() {
111 std::io::ErrorKind::NotFound => FsError::NotFound,
112 std::io::ErrorKind::NotADirectory => FsError::NotADirectory,
113 _ => FsError::Forbidden,
114 })?;
115
116 let mut entries = Vec::new();
117 for e in rd.flatten() {
118 let name = e.file_name().to_string_lossy().into_owned();
119 // Follows symlinks; a broken link shows up as an empty file.
120 let meta = std::fs::metadata(e.path());
121 let (is_dir, size, mtime) = match meta {
122 Ok(m) => (m.is_dir(), m.len(), mtime_str(&m)),
123 Err(_) => (false, 0, "1970-01-01T00:00:00Z".to_string()),
124 };
125 entries.push(Entry {
126 name,
127 is_dir,
128 size,
129 mtime,
130 });
131 }
132
133 // Folders first, then case-insensitive name.
134 entries.sort_by(|a, b| {
135 b.is_dir
136 .cmp(&a.is_dir)
137 .then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase()))
138 .then_with(|| a.name.cmp(&b.name))
139 });
140 Ok(entries)
141}
142
143fn mtime_str(m: &std::fs::Metadata) -> String {
144 let dt: Option<DateTime<chrono::Utc>> = m
145 .modified()
146 .ok()
147 .and_then(|t| t.duration_since(UNIX_EPOCH).ok())
148 .and_then(|d| DateTime::from_timestamp(d.as_secs() as i64, 0));
149 dt.map(|d| d.to_rfc3339_opts(chrono::SecondsFormat::Secs, true))
150 .unwrap_or_else(|| "1970-01-01T00:00:00Z".to_string())
151}
152
153// ---------------------------------------------------------------------------
154// Mutations (milestone 3): mkdir, rename, remove, move, copy, upload
155// ---------------------------------------------------------------------------
156
157/// Resolve a directory that must exist (relative to a user root). Used as the
158/// base for operations that target the *parent* of the item.
159pub fn resolve_dir(server_root: &Path, root_rel: &str, req_rel: &str) -> Result<PathBuf, FsError> {
160 let full = resolve_path(server_root, root_rel, req_rel)?;
161 if !full.is_dir() {
162 return Err(FsError::NotADirectory);
163 }
164 Ok(full)
165}
166
167/// Validate a new single-component name (for rename / new folder).
168pub fn validate_name(name: &str) -> Result<(), FsError> {
169 let p = Path::new(name);
170 if name.is_empty()
171 || p.components().count() != 1
172 || name == "."
173 || name == ".."
174 || name.contains(['/', '\\', '\0'])
175 {
176 return Err(FsError::Invalid("invalid name".to_string()));
177 }
178 Ok(())
179}
180
181/// Create a directory (and any missing parents) inside a user root.
182pub fn mkdir(server_root: &Path, root_rel: &str, req_rel: &str) -> Result<(), FsError> {
183 let full = resolve_path_or_new(server_root, root_rel, req_rel)?;
184 if full.exists() {
185 return Err(FsError::Conflict);
186 }
187 std::fs::create_dir_all(&full).map_err(|e| io_err(e, &full))?;
188 Ok(())
189}
190
191/// Resolve a path that does not need to exist yet, but whose *parent* must.
192fn resolve_path_or_new(server_root: &Path, root_rel: &str, req_rel: &str) -> Result<PathBuf, FsError> {
193 let root_abs = resolve_root(server_root, root_rel)?;
194 let req = Path::new(req_rel);
195 for c in req.components() {
196 if matches!(c, Component::ParentDir) {
197 return Err(FsError::Forbidden);
198 }
199 }
200 let full = root_abs.join(req);
201 // The parent must exist and stay inside the root.
202 let parent = full
203 .parent()
204 .filter(|p| !p.as_os_str().is_empty())
205 .ok_or_else(|| FsError::Invalid("invalid path".to_string()))?;
206 let parent = parent.canonicalize().map_err(|e| io_err(e, parent))?;
207 ensure_within(&root_abs, &parent)?;
208 Ok(full)
209}
210
211/// Rename (or move within the same directory) an item.
212pub fn rename_item(
213 server_root: &Path,
214 root_rel: &str,
215 req_rel: &str,
216 new_name: &str,
217 overwrite: bool,
218) -> Result<(), FsError> {
219 validate_name(new_name)?;
220 let from = resolve_path(server_root, root_rel, req_rel)?;
221 let parent = from
222 .parent()
223 .ok_or_else(|| FsError::Invalid("invalid path".to_string()))?;
224 let to = parent.join(new_name);
225 // Renaming onto itself is a no-op (the overwrite path below would
226 // delete the file before the rename).
227 if to == from {
228 return Ok(());
229 }
230 if to.exists() {
231 if !overwrite || to.is_dir() || from.is_dir() {
232 return Err(FsError::Conflict);
233 }
234 std::fs::remove_file(&to).map_err(|e| io_err(e, &to))?;
235 }
236 std::fs::rename(&from, &to).map_err(|e| io_err(e, &to))?;
237 Ok(())
238}
239
240/// Delete a file or a directory tree. Returns whether it was a directory.
241pub fn remove_item(server_root: &Path, root_rel: &str, req_rel: &str) -> Result<bool, FsError> {
242 let full = resolve_path(server_root, root_rel, req_rel)?;
243 let is_dir = full.is_dir();
244 if is_dir {
245 std::fs::remove_dir_all(&full).map_err(|e| io_err(e, &full))?;
246 } else {
247 std::fs::remove_file(&full).map_err(|e| io_err(e, &full))?;
248 }
249 Ok(is_dir)
250}
251
252/// Overwrite an existing file's contents (the editor's save path).
253///
254/// The file must already exist and be a regular file. If `expected_mtime`
255/// (whole unix seconds) is provided and differs from the file's current mtime,
256/// the file changed on disk since it was read → `Conflict` (409). Returns the
257/// file's new mtime (unix seconds) after a successful write.
258pub fn save_file(
259 server_root: &Path,
260 root_rel: &str,
261 req_rel: &str,
262 content: &[u8],
263 expected_mtime: Option<i64>,
264) -> Result<i64, FsError> {
265 let full = resolve_path(server_root, root_rel, req_rel)?; // must exist
266 let meta = std::fs::metadata(&full).map_err(|_| FsError::NotFound)?;
267 if meta.is_dir() {
268 return Err(FsError::NotADirectory);
269 }
270 if let Some(expected) = expected_mtime {
271 let cur = meta
272 .modified()
273 .ok()
274 .and_then(|t| t.duration_since(UNIX_EPOCH).ok())
275 .map(|d| d.as_secs() as i64)
276 .unwrap_or(-1);
277 if cur != expected {
278 return Err(FsError::Conflict);
279 }
280 }
281 std::fs::write(&full, content).map_err(|e| io_err(e, &full))?;
282 // Read the new mtime so the client can anchor the next conflict check.
283 let new_meta = std::fs::metadata(&full).map_err(|_| FsError::NotFound)?;
284 let mtime = new_meta
285 .modified()
286 .ok()
287 .and_then(|t| t.duration_since(UNIX_EPOCH).ok())
288 .map(|d| d.as_secs() as i64)
289 .unwrap_or(0);
290 Ok(mtime)
291}
292
293fn io_err(e: std::io::Error, p: &Path) -> FsError {
294 tracing::warn!(error = %e, path = %p.display(), "filesystem error");
295 match e.kind() {
296 std::io::ErrorKind::NotFound => FsError::NotFound,
297 _ => FsError::Forbidden,
298 }
299}
300
301/// True if `a` is `b` or a descendant of `b` (both canonical).
302fn is_within_or_eq(base: &Path, p: &Path) -> bool {
303 p == base || p.starts_with(base)
304}
305
306/// Move an item (possibly across roots). `dst_dir_rel` is the destination
307/// directory (relative to `dst_root_rel`); the item keeps its base name.
308pub fn move_item(
309 server_root: &Path,
310 src_root_rel: &str,
311 src_rel: &str,
312 dst_root_rel: &str,
313 dst_dir_rel: &str,
314 overwrite: bool,
315) -> Result<(), FsError> {
316 let from = resolve_path(server_root, src_root_rel, src_rel)?;
317 let dst_dir = resolve_dir(server_root, dst_root_rel, dst_dir_rel)?;
318 let name = from
319 .file_name()
320 .ok_or_else(|| FsError::Invalid("invalid path".to_string()))?
321 .to_owned();
322 let to = dst_dir.join(&name);
323
324 // A no-op (item already at the destination) — treat as success.
325 if to == from {
326 return Ok(());
327 }
328
329 // Refuse moving a directory into itself or a descendant.
330 if from.is_dir() && is_within_or_eq(&from, &dst_dir) {
331 return Err(FsError::Invalid(
332 "cannot move a folder into itself".to_string(),
333 ));
334 }
335 check_move_conflict(&to, &from, overwrite)?;
336
337 match std::fs::rename(&from, &to) {
338 Ok(()) => Ok(()),
339 Err(e) if e.kind() == std::io::ErrorKind::CrossesDevices => {
340 copy_recursive(&from, &to)?;
341 if from.is_dir() {
342 std::fs::remove_dir_all(&from).map_err(|_| FsError::Forbidden)?;
343 } else {
344 std::fs::remove_file(&from).map_err(|_| FsError::Forbidden)?;
345 }
346 Ok(())
347 }
348 Err(e) => Err(io_err(e, &to)),
349 }
350}
351
352/// Copy an item (possibly across roots).
353pub fn copy_item(
354 server_root: &Path,
355 src_root_rel: &str,
356 src_rel: &str,
357 dst_root_rel: &str,
358 dst_dir_rel: &str,
359 overwrite: bool,
360) -> Result<(), FsError> {
361 let from = resolve_path(server_root, src_root_rel, src_rel)?;
362 let dst_dir = resolve_dir(server_root, dst_root_rel, dst_dir_rel)?;
363 let name = from
364 .file_name()
365 .ok_or_else(|| FsError::Invalid("invalid path".to_string()))?
366 .to_owned();
367 let to = dst_dir.join(&name);
368
369 // A no-op (item already at the destination) — treat as success.
370 if to == from {
371 return Ok(());
372 }
373
374 if from.is_dir() && is_within_or_eq(&from, &dst_dir) {
375 return Err(FsError::Invalid(
376 "cannot copy a folder into itself".to_string(),
377 ));
378 }
379 check_move_conflict(&to, &from, overwrite)?;
380 copy_recursive(&from, &to)?;
381 Ok(())
382}
383
384/// Conflict rules shared by move and copy:
385/// - target is a directory → always conflict (no silent merge)
386/// - target is a file → conflict unless overwriting a file with a file
387fn check_move_conflict(to: &Path, from: &Path, overwrite: bool) -> Result<(), FsError> {
388 if to.exists() {
389 let to_dir = to.is_dir();
390 let from_dir = from.is_dir();
391 if to_dir || from_dir || !overwrite {
392 return Err(FsError::Conflict);
393 }
394 }
395 Ok(())
396}
397
398/// Recursively copy a file or directory tree, preserving mtime.
399pub fn copy_recursive(src: &Path, dst: &Path) -> Result<(), FsError> {
400 let meta = std::fs::metadata(src).map_err(|e| io_err(e, src))?;
401 if meta.is_dir() {
402 std::fs::create_dir(dst).map_err(|e| io_err(e, dst))?;
403 for e in std::fs::read_dir(src).map_err(|e| io_err(e, src))?.flatten() {
404 copy_recursive(&e.path(), &dst.join(e.file_name()))?;
405 }
406 } else {
407 std::fs::copy(src, dst).map_err(|e| io_err(e, dst))?;
408 }
409 set_mtime(dst, meta.modified().ok());
410 Ok(())
411}
412
413fn set_mtime(p: &Path, t: Option<std::time::SystemTime>) {
414 if let (Some(t), Ok(f)) = (t, std::fs::File::open(p)) {
415 let _ = f.set_modified(t);
416 }
417}
418
419// ---------------------------------------------------------------------------
420// Tests
421// ---------------------------------------------------------------------------
422
423#[cfg(test)]
424mod tests {
425 use super::*;
426
427 /// A temp dir used as the "server root" with a small fixture tree:
428 ///
429 /// ```text
430 /// root/
431 /// docs/
432 /// inner/
433 /// hello.txt
434 /// a.txt
435 /// src/
436 /// main.rs
437 /// file.txt
438 /// ```
439 struct T {
440 tmp: tempfile::TempDir,
441 root: PathBuf,
442 }
443
444 impl T {
445 fn new() -> Self {
446 let tmp = tempfile::tempdir().unwrap();
447 let root = tmp.path().to_path_buf();
448 std::fs::create_dir_all(root.join("docs/inner")).unwrap();
449 std::fs::create_dir_all(root.join("src")).unwrap();
450 std::fs::write(root.join("docs/inner/hello.txt"), "hello").unwrap();
451 std::fs::write(root.join("docs/a.txt"), "a").unwrap();
452 std::fs::write(root.join("src/main.rs"), "fn main() {}").unwrap();
453 std::fs::write(root.join("file.txt"), "top file").unwrap();
454 Self { tmp, root }
455 }
456
457 /// A directory that lives *next to* the root (outside of it), for
458 /// symlink/escape tests. The tempdir name is unique, so the sibling
459 /// name is unique too.
460 fn sibling(&self, name: &str) -> PathBuf {
461 let base = self
462 .tmp
463 .path()
464 .file_name()
465 .unwrap()
466 .to_string_lossy()
467 .into_owned();
468 let p = self.tmp.path().with_file_name(format!("{base}-{name}"));
469 std::fs::create_dir_all(&p).unwrap();
470 p
471 }
472 }
473
474 // ---------- validate_name ----------
475
476 #[test]
477 fn validate_name_accepts_simple_names() {
478 for ok in ["a", "file.txt", "my folder", "Ünïcödé", "with-dash_1.2.3"] {
479 assert!(validate_name(ok).is_ok(), "{ok:?} should be valid");
480 }
481 }
482
483 #[test]
484 fn validate_name_rejects_traversal_and_paths() {
485 for bad in [
486 "", ".", "..", "a/b", "a\\b", "a\0b", "/abs", "../x", "x/../y", "x/", "/x",
487 ] {
488 assert!(validate_name(bad).is_err(), "{bad:?} should be invalid");
489 }
490 }
491
492 // ---------- resolve_root ----------
493
494 #[test]
495 fn resolve_root_whole_root_and_subdir() {
496 let t = T::new();
497 let root = t.root.canonicalize().unwrap();
498 // "." means the whole root.
499 assert_eq!(resolve_root(&root, ".").unwrap(), root);
500 assert_eq!(resolve_root(&root, "docs").unwrap(), root.join("docs"));
501 assert_eq!(
502 resolve_root(&root, "docs/inner").unwrap(),
503 root.join("docs/inner")
504 );
505 }
506
507 #[test]
508 fn resolve_root_rejects_escape_and_missing() {
509 let t = T::new();
510 let root = t.root.canonicalize().unwrap();
511 let sib = t.sibling("escape");
512 let sib_rel = sib
513 .file_name()
514 .unwrap()
515 .to_string_lossy()
516 .into_owned();
517 // Escapes that land on *existing* paths outside the root.
518 for esc in ["..".to_string(), "docs/../..".to_string(), format!("../{sib_rel}")] {
519 assert!(
520 matches!(resolve_root(&root, &esc), Err(FsError::Forbidden)),
521 "{esc:?} should be forbidden"
522 );
523 }
524 // Escapes to non-existing paths simply don't exist.
525 for esc in ["../no-such-dir", "a/b/../../..", "nope"] {
526 assert!(
527 matches!(resolve_root(&root, &esc), Err(FsError::RootMissing)),
528 "{esc:?} should be missing"
529 );
530 }
531 // A file is not a valid root.
532 assert!(matches!(
533 resolve_root(&root, "file.txt"),
534 Err(FsError::RootMissing)
535 ));
536 }
537
538 #[cfg(unix)]
539 #[test]
540 fn resolve_root_rejects_symlink_escape() {
541 let t = T::new();
542 let root = t.root.canonicalize().unwrap();
543 let outside = t.sibling("outside");
544 std::os::unix::fs::symlink(&outside, root.join("link")).unwrap();
545 assert!(matches!(
546 resolve_root(&root, "link"),
547 Err(FsError::Forbidden)
548 ));
549 }
550
551 // ---------- resolve_path ----------
552
553 #[test]
554 fn resolve_path_traverses_inside_root() {
555 let t = T::new();
556 let root = t.root.canonicalize().unwrap();
557 // Empty relative path → the root itself.
558 assert_eq!(resolve_path(&root, ".", "").unwrap(), root);
559 assert_eq!(
560 resolve_path(&root, "docs", "inner/hello.txt").unwrap(),
561 root.join("docs/inner/hello.txt")
562 );
563 assert_eq!(resolve_path(&root, ".", "file.txt").unwrap(), root.join("file.txt"));
564 }
565
566 #[test]
567 fn resolve_path_rejects_parent_traversal() {
568 let t = T::new();
569 let root = t.root.canonicalize().unwrap();
570 for p in ["..", "../file.txt", "docs/../../file.txt", "a/../../b"] {
571 assert!(
572 matches!(resolve_path(&root, ".", p), Err(FsError::Forbidden)),
573 "{p:?} should be forbidden"
574 );
575 }
576 }
577
578 #[test]
579 fn resolve_path_missing_is_not_found() {
580 let t = T::new();
581 let root = t.root.canonicalize().unwrap();
582 assert!(matches!(
583 resolve_path(&root, "docs", "nope.txt"),
584 Err(FsError::NotFound)
585 ));
586 assert!(matches!(
587 resolve_path(&root, "missing-root", ""),
588 Err(FsError::RootMissing)
589 ));
590 }
591
592 #[cfg(unix)]
593 #[test]
594 fn resolve_path_rejects_symlink_escape() {
595 let t = T::new();
596 let root = t.root.canonicalize().unwrap();
597 let outside = t.sibling("outside");
598 let secret = outside.join("secret.txt");
599 std::fs::write(&secret, "top secret").unwrap();
600 std::os::unix::fs::symlink(&secret, root.join("evil")).unwrap();
601 assert!(matches!(
602 resolve_path(&root, ".", "evil"),
603 Err(FsError::Forbidden)
604 ));
605 // A symlink that stays inside the root is fine.
606 std::os::unix::fs::symlink(root.join("file.txt"), root.join("alias")).unwrap();
607 assert_eq!(
608 resolve_path(&root, ".", "alias").unwrap(),
609 root.join("file.txt")
610 );
611 }
612
613 // ---------- resolve_file / resolve_dir ----------
614
615 #[test]
616 fn resolve_file_targets_files() {
617 let t = T::new();
618 let root = t.root.canonicalize().unwrap();
619 assert_eq!(resolve_file(&root, "file.txt").unwrap(), root.join("file.txt"));
620 assert!(matches!(
621 resolve_file(&root, "nope.txt"),
622 Err(FsError::NotFound)
623 ));
624 // Escape to an existing sibling file.
625 let sib = t.sibling("escape");
626 let sib_rel = sib
627 .file_name()
628 .unwrap()
629 .to_string_lossy()
630 .into_owned();
631 std::fs::write(sib.join("s.txt"), "x").unwrap();
632 assert!(matches!(
633 resolve_file(&root, &format!("../{sib_rel}/s.txt")),
634 Err(FsError::Forbidden)
635 ));
636 }
637
638 #[test]
639 fn resolve_dir_requires_existing_directory() {
640 let t = T::new();
641 let root = t.root.canonicalize().unwrap();
642 assert_eq!(resolve_dir(&root, ".", "docs").unwrap(), root.join("docs"));
643 assert!(matches!(
644 resolve_dir(&root, ".", "file.txt"),
645 Err(FsError::NotADirectory)
646 ));
647 assert!(matches!(resolve_dir(&root, ".", "nope"), Err(FsError::NotFound)));
648 }
649
650 // ---------- list_dir ----------
651
652 #[test]
653 fn list_dir_sorts_folders_first_then_case_insensitive() {
654 let t = T::new();
655 let d = t.root.join("sortme");
656 std::fs::create_dir_all(d.join("Zeta")).unwrap();
657 std::fs::create_dir_all(d.join("alpha-dir")).unwrap();
658 std::fs::write(d.join("b.txt"), "x").unwrap();
659 std::fs::write(d.join("A.txt"), "x").unwrap();
660 std::fs::write(d.join("C.md"), "x").unwrap();
661 let entries = list_dir(&d).unwrap();
662 let names: Vec<&str> = entries.iter().map(|e| e.name.as_str()).collect();
663 // Folders first (alpha-dir, Zeta), then files case-insensitively.
664 assert_eq!(names, vec!["alpha-dir", "Zeta", "A.txt", "b.txt", "C.md"]);
665 let a = &entries[2];
666 assert!(!a.is_dir);
667 assert_eq!(a.size, 1);
668 assert!(!a.mtime.is_empty());
669 }
670
671 #[test]
672 fn list_dir_error_cases() {
673 let t = T::new();
674 let root = t.root.canonicalize().unwrap();
675 assert!(matches!(
676 list_dir(&root.join("missing")),
677 Err(FsError::NotFound)
678 ));
679 assert!(matches!(
680 list_dir(&root.join("file.txt")),
681 Err(FsError::NotADirectory)
682 ));
683 }
684
685 #[cfg(unix)]
686 #[test]
687 fn list_dir_reports_broken_symlink_as_empty_file() {
688 let t = T::new();
689 let d = t.root.join("withlink");
690 std::fs::create_dir_all(&d).unwrap();
691 std::os::unix::fs::symlink(d.join("does-not-exist"), d.join("broken")).unwrap();
692 let entries = list_dir(&d).unwrap();
693 assert_eq!(entries.len(), 1);
694 assert_eq!(entries[0].name, "broken");
695 assert!(!entries[0].is_dir);
696 assert_eq!(entries[0].size, 0);
697 }
698
699 // ---------- mkdir ----------
700
701 #[test]
702 fn mkdir_creates_nested_dirs() {
703 let t = T::new();
704 let root = t.root.canonicalize().unwrap();
705 // The parent must exist; "new" first, then "new/sub".
706 mkdir(&root, ".", "new").unwrap();
707 assert!(root.join("new").is_dir());
708 mkdir(&root, ".", "new/sub").unwrap();
709 assert!(root.join("new/sub").is_dir());
710 }
711
712 #[test]
713 fn mkdir_rejects_conflict_and_bad_names() {
714 let t = T::new();
715 let root = t.root.canonicalize().unwrap();
716 assert!(matches!(
717 mkdir(&root, ".", "docs"),
718 Err(FsError::Conflict)
719 ));
720 assert!(matches!(
721 mkdir(&root, ".", "a/b/../../c"),
722 Err(FsError::Forbidden)
723 ));
724 assert!(matches!(
725 mkdir(&root, ".", "file.txt/x"),
726 Err(FsError::Forbidden) // parent is a file → ENOTDIR
727 ));
728 }
729
730 // ---------- rename ----------
731
732 #[test]
733 fn rename_moves_file_and_dir() {
734 let t = T::new();
735 let root = t.root.canonicalize().unwrap();
736 rename_item(&root, ".", "file.txt", "renamed.txt", false).unwrap();
737 assert!(!root.join("file.txt").exists());
738 assert_eq!(
739 std::fs::read_to_string(root.join("renamed.txt")).unwrap(),
740 "top file"
741 );
742 rename_item(&root, ".", "docs", "docs2", false).unwrap();
743 assert!(root.join("docs2/inner/hello.txt").exists());
744 }
745
746 #[test]
747 fn rename_conflicts_and_overwrite() {
748 let t = T::new();
749 let root = t.root.canonicalize().unwrap();
750 std::fs::write(root.join("other.txt"), "other").unwrap();
751 // Target file exists, no overwrite → conflict.
752 assert!(matches!(
753 rename_item(&root, ".", "file.txt", "other.txt", false),
754 Err(FsError::Conflict)
755 ));
756 // Overwrite a file target → replaces it.
757 rename_item(&root, ".", "file.txt", "other.txt", true).unwrap();
758 assert_eq!(
759 std::fs::read_to_string(root.join("other.txt")).unwrap(),
760 "top file"
761 );
762 // A dir target is never overwritten, even with the flag.
763 assert!(matches!(
764 rename_item(&root, ".", "other.txt", "docs", true),
765 Err(FsError::Conflict)
766 ));
767 // Renaming into a free slot works, then onto itself is a no-op.
768 rename_item(&root, ".", "other.txt", "free.txt", false).unwrap();
769 assert!(root.join("free.txt").exists());
770 rename_item(&root, ".", "free.txt", "free.txt", false).unwrap();
771 assert!(root.join("free.txt").exists());
772 assert!(root.join("free.txt").is_file());
773 }
774
775 #[test]
776 fn rename_validates_new_name() {
777 let t = T::new();
778 let root = t.root.canonicalize().unwrap();
779 for bad in ["a/b", "", ".", ".."] {
780 assert!(matches!(
781 rename_item(&root, ".", "file.txt", bad, false),
782 Err(FsError::Invalid(_))
783 ));
784 }
785 assert!(matches!(
786 rename_item(&root, ".", "missing", "x", false),
787 Err(FsError::NotFound)
788 ));
789 }
790
791 // ---------- remove ----------
792
793 #[test]
794 fn remove_file_and_dir() {
795 let t = T::new();
796 let root = t.root.canonicalize().unwrap();
797 assert_eq!(remove_item(&root, ".", "file.txt").unwrap(), false);
798 assert!(!root.join("file.txt").exists());
799 assert_eq!(remove_item(&root, ".", "docs").unwrap(), true);
800 assert!(!root.join("docs").exists());
801 assert!(matches!(
802 remove_item(&root, ".", "file.txt"),
803 Err(FsError::NotFound)
804 ));
805 }
806
807 // ---------- save_file ----------
808
809 fn mtime_of(p: &Path) -> i64 {
810 std::fs::metadata(p)
811 .unwrap()
812 .modified()
813 .unwrap()
814 .duration_since(std::time::UNIX_EPOCH)
815 .unwrap()
816 .as_secs() as i64
817 }
818
819 #[test]
820 fn save_file_updates_content_and_returns_new_mtime() {
821 let t = T::new();
822 let root = t.root.canonicalize().unwrap();
823 let before = mtime_of(&root.join("file.txt"));
824 // Sleep so the mtime actually advances (filesystem granularity).
825 std::thread::sleep(std::time::Duration::from_millis(1100));
826 let new = save_file(&root, ".", "file.txt", b"brand new", Some(before)).unwrap();
827 assert_eq!(std::fs::read(root.join("file.txt")).unwrap(), b"brand new");
828 assert!(new >= before);
829 // A second save with the *returned* mtime succeeds.
830 let new2 = save_file(&root, ".", "file.txt", b"again", Some(new)).unwrap();
831 assert!(new2 >= new);
832 // Without an expected mtime, always saves.
833 let _ = save_file(&root, ".", "file.txt", b"force", None).unwrap();
834 assert_eq!(std::fs::read(root.join("file.txt")).unwrap(), b"force");
835 }
836
837 #[test]
838 fn save_file_conflict_on_stale_mtime() {
839 let t = T::new();
840 let root = t.root.canonicalize().unwrap();
841 std::thread::sleep(std::time::Duration::from_millis(1100));
842 // The mtime we pass is older than the file's real mtime → conflict.
843 assert!(matches!(
844 save_file(&root, ".", "file.txt", b"x", Some(1)),
845 Err(FsError::Conflict)
846 ));
847 }
848
849 #[test]
850 fn save_file_error_cases() {
851 let t = T::new();
852 let root = t.root.canonicalize().unwrap();
853 assert!(matches!(
854 save_file(&root, ".", "nope.txt", b"x", None),
855 Err(FsError::NotFound)
856 ));
857 assert!(matches!(
858 save_file(&root, ".", "docs", b"x", None),
859 Err(FsError::NotADirectory)
860 ));
861 assert!(matches!(
862 save_file(&root, ".", "../evil.txt", b"x", None),
863 Err(FsError::Forbidden)
864 ));
865 }
866
867 // ---------- move / copy ----------
868
869 #[test]
870 fn move_file_and_dir_across_dirs() {
871 let t = T::new();
872 let root = t.root.canonicalize().unwrap();
873 move_item(&root, ".", "file.txt", ".", "src", false).unwrap();
874 assert!(!root.join("file.txt").exists());
875 assert!(root.join("src/file.txt").exists());
876 move_item(&root, ".", "src", ".", "docs", false).unwrap();
877 assert!(root.join("docs/src/main.rs").exists());
878 assert!(!root.join("src").exists());
879 }
880
881 #[test]
882 fn move_refuses_into_self_and_conflicts() {
883 let t = T::new();
884 let root = t.root.canonicalize().unwrap();
885 // A dir cannot be moved into itself or a descendant.
886 assert!(matches!(
887 move_item(&root, ".", "docs", ".", "docs", false),
888 Err(FsError::Invalid(_))
889 ));
890 assert!(matches!(
891 move_item(&root, ".", "docs", ".", "docs/inner", false),
892 Err(FsError::Invalid(_))
893 ));
894 // A dir target always conflicts, even with overwrite: move the file
895 // "x" into a folder that already contains a subfolder "x".
896 std::fs::create_dir_all(root.join("mv/case/x")).unwrap();
897 std::fs::create_dir_all(root.join("mv/out")).unwrap();
898 std::fs::write(root.join("mv/out/x"), "a file named x").unwrap();
899 assert!(matches!(
900 move_item(&root, ".", "mv/out/x", ".", "mv/case", true),
901 Err(FsError::Conflict)
902 ));
903 // File onto file: conflict without overwrite, replaced with.
904 std::fs::write(root.join("tmp-x.txt"), "x").unwrap();
905 std::fs::write(root.join("tmp-y.txt"), "y").unwrap();
906 std::fs::rename(&root.join("tmp-x.txt"), &root.join("tmp-target.txt")).unwrap();
907 std::fs::rename(&root.join("tmp-y.txt"), &root.join("tmp-target2.txt")).unwrap();
908 // Two distinct files with the same name in one folder.
909 std::fs::create_dir_all(root.join("mv/dst")).unwrap();
910 std::fs::create_dir_all(root.join("mv/out2")).unwrap();
911 std::fs::write(root.join("mv/dst/dup.txt"), "old").unwrap();
912 std::fs::write(root.join("mv/out2/dup.txt"), "new").unwrap();
913 assert!(matches!(
914 move_item(&root, ".", "mv/out2/dup.txt", ".", "mv/dst", false),
915 Err(FsError::Conflict)
916 ));
917 move_item(&root, ".", "mv/out2/dup.txt", ".", "mv/dst", true).unwrap();
918 assert_eq!(
919 std::fs::read_to_string(root.join("mv/dst/dup.txt")).unwrap(),
920 "new"
921 );
922 // Moving onto itself is a no-op success.
923 move_item(&root, ".", "tmp-target.txt", ".", ".", false).unwrap();
924 assert!(root.join("tmp-target.txt").exists());
925 // Missing destination dir.
926 assert!(matches!(
927 move_item(&root, ".", "file.txt", ".", "nope", false),
928 Err(FsError::NotFound)
929 ));
930 }
931
932 #[test]
933 fn copy_file_and_dir_preserves_mtime() {
934 let t = T::new();
935 let root = t.root.canonicalize().unwrap();
936 let before = mtime_of(&root.join("file.txt"));
937 copy_item(&root, ".", "file.txt", ".", "src", false).unwrap();
938 let copy = root.join("src/file.txt");
939 assert_eq!(std::fs::read(&copy).unwrap(), b"top file");
940 assert_eq!(mtime_of(&copy), before);
941 // Dir copy.
942 copy_item(&root, ".", "docs", ".", "src", false).unwrap();
943 assert_eq!(
944 std::fs::read_to_string(root.join("src/docs/inner/hello.txt")).unwrap(),
945 "hello"
946 );
947 // Originals still there.
948 assert!(root.join("file.txt").exists());
949 assert!(root.join("docs/a.txt").exists());
950 }
951
952 #[test]
953 fn copy_refuses_into_self_and_handles_conflict() {
954 let t = T::new();
955 let root = t.root.canonicalize().unwrap();
956 assert!(matches!(
957 copy_item(&root, ".", "docs", ".", "docs", false),
958 Err(FsError::Invalid(_))
959 ));
960 assert!(matches!(
961 copy_item(&root, ".", "docs", ".", "docs/inner", false),
962 Err(FsError::Invalid(_))
963 ));
964 // First copy is fine, the second one conflicts, overwrite replaces.
965 copy_item(&root, ".", "file.txt", ".", "src", false).unwrap();
966 assert!(matches!(
967 copy_item(&root, ".", "file.txt", ".", "src", false),
968 Err(FsError::Conflict)
969 ));
970 std::fs::write(root.join("file.txt"), "v2").unwrap();
971 copy_item(&root, ".", "file.txt", ".", "src", true).unwrap();
972 assert_eq!(std::fs::read_to_string(root.join("src/file.txt")).unwrap(), "v2");
973 // Copying onto itself is a no-op success.
974 copy_item(&root, ".", "src/file.txt", ".", "src", false).unwrap();
975 assert_eq!(std::fs::read_to_string(root.join("src/file.txt")).unwrap(), "v2");
976 // Missing destination dir.
977 assert!(matches!(
978 copy_item(&root, ".", "file.txt", ".", "nope", false),
979 Err(FsError::NotFound)
980 ));
981 }
982
983 #[test]
984 fn copy_recursive_missing_source() {
985 let t = T::new();
986 let dst = t.tmp.path().join("dst");
987 assert!(matches!(
988 copy_recursive(&t.root.join("nope"), &dst),
989 Err(FsError::NotFound)
990 ));
991 }
992
993 // ---------- is_within_or_eq ----------
994
995 #[test]
996 fn is_within_or_eq_matrix() {
997 let t = T::new();
998 let root = t.root.canonicalize().unwrap();
999 let docs = root.join("docs");
1000 assert!(is_within_or_eq(&docs, &docs));
1001 assert!(is_within_or_eq(&docs, &root.join("docs/inner")));
1002 assert!(!is_within_or_eq(&docs, &root));
1003 assert!(!is_within_or_eq(&docs, &root.join("src")));
1004 }
1005}
1006