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