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