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::cmp::Ordering;
6use std::io::Read;
7use std::path::{Component, Path, PathBuf};
8use std::time::UNIX_EPOCH;
9
10use chrono::DateTime;
11
12use api_types::{Entry, FileKind, FilesResp, SortKey};
13
14use crate::error::ApiError;
15
16#[derive(Debug)]
17pub enum FsError {
18 NotFound,
19 NotADirectory,
20 Forbidden,
21 RootMissing,
22 Conflict,
23 Invalid(String),
24}
25
26impl From<FsError> for ApiError {
27 fn from(e: FsError) -> Self {
28 use axum::http::StatusCode as S;
29 match e {
30 FsError::NotFound => {
31 ApiError::localized(S::NOT_FOUND, "folder not found", "err_fs_not_found")
32 }
33 FsError::NotADirectory => {
34 ApiError::localized(S::BAD_REQUEST, "not a folder", "err_fs_not_a_dir")
35 }
36 FsError::Forbidden => {
37 ApiError::localized(S::FORBIDDEN, "access denied", "err_fs_forbidden")
38 }
39 FsError::RootMissing => ApiError::localized(
40 S::NOT_FOUND,
41 "the configured folder no longer exists",
42 "err_fs_root_missing",
43 ),
44 FsError::Conflict => {
45 ApiError::localized(S::CONFLICT, "already exists", "err_fs_conflict")
46 }
47 // Dynamic message (e.g. an invalid name), not a fixed string.
48 FsError::Invalid(msg) => ApiError::new(S::BAD_REQUEST, msg),
49 }
50 }
51}
52
53/// Resolve a user root (path relative to the server root) to a canonical
54/// absolute path, verified to be inside the server root.
55pub fn resolve_root(server_root: &Path, root_rel: &str) -> Result<PathBuf, FsError> {
56 let candidate = server_root.join(root_rel);
57 let canonical = candidate.canonicalize().map_err(|_| FsError::RootMissing)?;
58 ensure_within(server_root, &canonical)?;
59 if !canonical.is_dir() {
60 return Err(FsError::RootMissing);
61 }
62 Ok(canonical)
63}
64
65/// Resolve a requested path (relative to a user root) safely.
66pub fn resolve_path(server_root: &Path, root_rel: &str, req_rel: &str) -> Result<PathBuf, FsError> {
67 let (root_abs, full) = join_under_root(server_root, root_rel, req_rel)?;
68 let full = canonical(&full)?;
69 ensure_within(&root_abs, &full)?;
70 Ok(full)
71}
72
73/// The canonical root and the request joined onto it, not yet resolved.
74fn join_under_root(
75 server_root: &Path,
76 root_rel: &str,
77 req_rel: &str,
78) -> Result<(PathBuf, PathBuf), FsError> {
79 let root_abs = resolve_root(server_root, root_rel)?;
80 let req = Path::new(req_rel);
81 if req.components().any(|c| c == Component::ParentDir) {
82 return Err(FsError::Forbidden);
83 }
84 let full = root_abs.join(req);
85 Ok((root_abs, full))
86}
87
88/// Resolve a share target that is a single file (relative to the server root).
89/// Unlike [`resolve_path`], the target itself is the file — there is no
90/// directory root beneath it.
91pub fn resolve_file(server_root: &Path, rel: &str) -> Result<PathBuf, FsError> {
92 let full = server_root.join(rel);
93 let full = canonical(&full)?;
94 ensure_within(server_root, &full)?;
95 Ok(full)
96}
97
98/// Whether the entry at `p` is itself a directory. A symlink is not, however
99/// its target looks: an operation on the name must not recurse into a tree the
100/// request never named.
101fn entry_is_dir(p: &Path) -> bool {
102 std::fs::symlink_metadata(p).is_ok_and(|m| m.is_dir())
103}
104
105/// Whether a name is taken. Unlike `Path::exists`, a dangling symlink counts:
106/// it still occupies the name.
107fn entry_exists(p: &Path) -> bool {
108 std::fs::symlink_metadata(p).is_ok()
109}
110
111/// `canonicalize`, with a missing path as [`FsError::NotFound`] and every
112/// other failure as [`FsError::Forbidden`].
113fn canonical(p: &Path) -> Result<PathBuf, FsError> {
114 p.canonicalize().map_err(|e| match e.kind() {
115 std::io::ErrorKind::NotFound => FsError::NotFound,
116 _ => FsError::Forbidden,
117 })
118}
119
120fn ensure_within(base: &Path, p: &Path) -> Result<(), FsError> {
121 if is_within_or_eq(base, p) {
122 Ok(())
123 } else {
124 Err(FsError::Forbidden)
125 }
126}
127
128/// Which slice of a folder [`list_dir`] returns, and in what order.
129#[derive(Clone, Debug, Default)]
130pub struct ListOpts {
131 pub sort: SortKey,
132 pub desc: bool,
133 pub offset: usize,
134 /// Clamped to `1..=MAX_LIST_ENTRIES`; `None` = the cap.
135 pub limit: Option<usize>,
136 pub dirs_only: bool,
137 /// Return the page that holds this name; `offset` when it is missing.
138 pub around: Option<String>,
139}
140
141/// List one page of a directory (blocking — call via spawn_blocking).
142pub fn list_dir(dir: &Path, opts: ListOpts) -> Result<FilesResp, FsError> {
143 let rd = std::fs::read_dir(dir).map_err(|e| match e.kind() {
144 std::io::ErrorKind::NotFound => FsError::NotFound,
145 std::io::ErrorKind::NotADirectory => FsError::NotADirectory,
146 _ => FsError::Forbidden,
147 })?;
148
149 // Pass 1: names only. Walking the directory stream is inherently
150 // sequential, but it is also the only part that has to be: every field
151 // below comes from a per-entry syscall, which pass 2 can do in parallel.
152 // The values here are the fallbacks used when that syscall fails.
153 let mut rows: Vec<(Entry, PathBuf)> = Vec::new();
154 for e in rd.flatten() {
155 let entry = Entry {
156 name: e.file_name().to_string_lossy().into_owned(),
157 is_dir: false,
158 size: 0,
159 mtime: EPOCH_MTIME.to_string(),
160 kind: FileKind::Binary,
161 };
162 rows.push((entry, e.path()));
163 }
164
165 // Pass 2: metadata, which the sort needs.
166 describe_rows(&mut rows, fill_meta);
167 if opts.dirs_only {
168 rows.retain(|(e, _)| e.is_dir);
169 }
170 // After pass 2, so the parallel fan-out cannot affect the order.
171 rows.sort_by(|(a, _), (b, _)| cmp_entries(a, b, opts.sort, opts.desc));
172
173 let total = rows.len();
174 let limit = opts
175 .limit
176 .unwrap_or(api_types::MAX_LIST_ENTRIES)
177 .clamp(1, api_types::MAX_LIST_ENTRIES);
178 let at = opts
179 .around
180 .as_deref()
181 .and_then(|n| rows.iter().position(|(e, _)| e.name == n));
182 // Past the end happens when a delete empties the last page.
183 let offset = if let Some(i) = at {
184 i / limit * limit
185 } else if opts.offset < total {
186 opts.offset
187 } else {
188 total.saturating_sub(1) / limit * limit
189 };
190 let mut page: Vec<(Entry, PathBuf)> = rows.into_iter().skip(offset).take(limit).collect();
191
192 // Pass 3: the content sniff, only for the page. It reads file data, which
193 // on a spinning disk costs a seek per file.
194 describe_rows(&mut page, fill_kind);
195
196 Ok(FilesResp {
197 entries: page.into_iter().map(|(e, _)| e).collect(),
198 total,
199 offset,
200 })
201}
202
203/// The listing order: folders first, then `key`. Folders have no
204/// meaningful size, so a size order keeps them in name order.
205fn cmp_entries(a: &Entry, b: &Entry, key: SortKey, desc: bool) -> Ordering {
206 let dir = |o: Ordering| if desc { o.reverse() } else { o };
207 b.is_dir.cmp(&a.is_dir).then_with(|| match key {
208 SortKey::Name => dir(cmp_name(a, b)),
209 SortKey::Size if a.is_dir => cmp_name(a, b),
210 SortKey::Size => dir(a.size.cmp(&b.size)).then_with(|| cmp_name(a, b)),
211 // RFC 3339 UTC with a fixed width, so bytewise order is
212 // chronological order.
213 SortKey::Modified => dir(a.mtime.cmp(&b.mtime)).then_with(|| cmp_name(a, b)),
214 })
215}
216
217/// Case-insensitive, with the raw name as the tie-breaker so two names that
218/// differ only in case keep a stable order.
219fn cmp_name(a: &Entry, b: &Entry) -> Ordering {
220 fn lower(s: &str) -> impl Iterator<Item = char> + '_ {
221 s.chars().flat_map(char::to_lowercase)
222 }
223 lower(&a.name)
224 .cmp(lower(&b.name))
225 .then_with(|| a.name.cmp(&b.name))
226}
227
228// ---------------------------------------------------------------------------
229// Content sniffing
230// ---------------------------------------------------------------------------
231
232/// How many leading bytes we read to classify a file. Every magic number
233/// `infer` knows lives in the first few dozen bytes; 256 also gives the
234/// text/binary heuristic enough to work with. Measured at ~4.6 µs per file,
235/// against ~1.4 µs for the `metadata` call in the same pass.
236const SNIFF_BYTES: usize = 256;
237
238/// Entries per thread, and the point below which parallelism is not worth it.
239/// Measured on a 22-core machine: at 50 entries fan-out is a wash (thread
240/// spawn costs about as much as the work), at 200 it is already 2x.
241const SNIFF_CHUNK: usize = 256;
242
243/// Process-wide ceiling on threads spawned for sniffing, so many concurrent
244/// listings of large directories cannot multiply into a thread explosion.
245/// One listing alone can use the whole budget; the next one degrades to fewer
246/// threads, and eventually to serial, instead of queueing.
247static SNIFF_BUDGET: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
248
249fn sniff_budget_total() -> usize {
250 static TOTAL: std::sync::LazyLock<usize> = std::sync::LazyLock::new(|| {
251 std::thread::available_parallelism()
252 .map(|n| n.get())
253 .unwrap_or(1)
254 });
255 *TOTAL
256}
257
258/// Claimed helper threads, returned to [`SNIFF_BUDGET`] on drop.
259struct SniffPermit(usize);
260
261impl SniffPermit {
262 /// Claim up to `want` helper threads, or fewer when the budget is thin.
263 fn claim(want: usize) -> Self {
264 use std::sync::atomic::Ordering;
265 let total = sniff_budget_total();
266 let mut granted = 0;
267 let _ = SNIFF_BUDGET.fetch_update(Ordering::AcqRel, Ordering::Acquire, |in_flight| {
268 granted = want.min(total.saturating_sub(in_flight));
269 (granted > 0).then_some(in_flight + granted)
270 });
271 Self(granted)
272 }
273}
274
275impl Drop for SniffPermit {
276 fn drop(&mut self) {
277 if self.0 > 0 {
278 SNIFF_BUDGET.fetch_sub(self.0, std::sync::atomic::Ordering::AcqRel);
279 }
280 }
281}
282
283/// Metadata for one row. Follows symlinks; a broken link keeps the caller's
284/// fallbacks and so shows up as an empty file.
285fn fill_meta(entry: &mut Entry, path: &Path) {
286 if let Ok(m) = std::fs::metadata(path) {
287 entry.is_dir = m.is_dir();
288 entry.size = m.len();
289 entry.mtime = mtime_str(&m);
290 }
291}
292
293/// The content sniff for one row. Needs `is_dir`, so it runs after
294/// [`fill_meta`].
295fn fill_kind(entry: &mut Entry, path: &Path) {
296 entry.kind = detect_kind(path, entry.is_dir);
297}
298
299/// Apply `one` to each row, fanning the work out across threads for big
300/// directories.
301///
302/// The calling thread takes a chunk too, so `n` helper threads process `n + 1`
303/// chunks and a zero-thread grant is simply the serial path.
304fn describe_rows(rows: &mut [(Entry, PathBuf)], one: fn(&mut Entry, &Path)) {
305 fn describe(rows: &mut [(Entry, PathBuf)], one: fn(&mut Entry, &Path)) {
306 for (entry, path) in rows {
307 one(entry, path);
308 }
309 }
310
311 if rows.len() < SNIFF_CHUNK {
312 describe(rows, one);
313 return;
314 }
315 // One chunk per helper thread plus one for this thread.
316 let want = rows.len().div_ceil(SNIFF_CHUNK).saturating_sub(1);
317 let permit = SniffPermit::claim(want);
318 if permit.0 == 0 {
319 describe(rows, one);
320 return;
321 }
322 let chunk = rows.len().div_ceil(permit.0 + 1);
323 std::thread::scope(|s| {
324 let mut rest = rows;
325 // Hand every chunk but the last to a helper thread.
326 while rest.len() > chunk {
327 let (head, tail) = rest.split_at_mut(chunk);
328 s.spawn(move || describe(head, one));
329 rest = tail;
330 }
331 describe(rest, one);
332 });
333}
334
335/// Classify a directory entry by reading its first [`SNIFF_BYTES`] bytes.
336///
337/// Blocking — called from `list_dir` (itself under `spawn_blocking`), on
338/// several threads at once for large directories. An unreadable file is
339/// reported as [`FileKind::Binary`] rather than failing the whole listing.
340///
341// ponytail: one open() per entry, fanned out but not cached. Measured warm on
342// 22 cores: 5000 entries take 29 ms serially and 6.9 ms across threads. The
343// remaining ceiling is a cold cache or a network filesystem (NFS/SMB), where
344// each entry costs a round trip. If that shows up: cache by (dev, ino, mtime),
345// or skip the sniff for zero-byte files.
346pub fn detect_kind(path: &Path, is_dir: bool) -> FileKind {
347 if is_dir {
348 return FileKind::Dir;
349 }
350 let mut head = [0u8; SNIFF_BYTES];
351 // A read error is *not* the same as an empty file: an empty file is text
352 // (it opens in the editor), an unreadable one gets no viewer offered.
353 match std::fs::File::open(path).and_then(|mut f| f.read(&mut head)) {
354 Ok(n) => kind_from_bytes(&head[..n], path),
355 Err(_) => FileKind::Binary,
356 }
357}
358
359/// The pure half of [`detect_kind`], so it can be unit-tested without a disk.
360///
361/// `path` is consulted only for the SVG case: SVG is XML text with no magic
362/// number, but browsers render it as an image, so the extension is the only
363/// thing that can tell us to offer an image preview.
364fn kind_from_bytes(head: &[u8], path: &Path) -> FileKind {
365 if let Some(t) = infer::get(head) {
366 // PDF is filed under `Archive` by `infer`, so match the MIME first.
367 if t.mime_type() == "application/pdf" {
368 return FileKind::Pdf;
369 }
370 return match t.matcher_type() {
371 infer::MatcherType::Image => FileKind::Image,
372 infer::MatcherType::Video => FileKind::Video,
373 infer::MatcherType::Audio => FileKind::Audio,
374 infer::MatcherType::Archive => FileKind::Archive,
375 infer::MatcherType::Text => FileKind::Text,
376 // App / Book / Font / Doc / Custom: recognized, but nothing we
377 // can show in the browser.
378 _ => FileKind::Binary,
379 };
380 }
381 if !looks_like_text(head) {
382 return FileKind::Binary;
383 }
384 let ext = path
385 .extension()
386 .map(|e| e.to_string_lossy().to_lowercase())
387 .unwrap_or_default();
388 if ext == "svg" {
389 FileKind::Image
390 } else {
391 FileKind::Text
392 }
393}
394
395/// Text heuristic for the files `infer` has no signature for (plain text,
396/// source code, most config formats): no NUL byte, and the head decodes as
397/// UTF-8 once a truncated trailing character is discounted.
398///
399/// An empty file counts as text — it opens in the editor, which is what you
400/// want for a file you just created.
401fn looks_like_text(head: &[u8]) -> bool {
402 if head.contains(&0) {
403 return false;
404 }
405 match std::str::from_utf8(head) {
406 Ok(_) => true,
407 // A multi-byte character cut in half by the read boundary is fine;
408 // anything else is not text. `error_len() == None` means "unexpected
409 // end of input", i.e. truncation.
410 Err(e) => e.error_len().is_none() && e.valid_up_to() + 4 > head.len(),
411 }
412}
413
414/// Reported when a file's modification time is unavailable or unrepresentable.
415const EPOCH_MTIME: &str = "1970-01-01T00:00:00Z";
416
417/// A file's modification time in whole unix seconds, or `None` when the
418/// platform cannot report one. The single place that converts a `SystemTime`.
419pub fn mtime_secs(m: &std::fs::Metadata) -> Option<i64> {
420 m.modified()
421 .ok()
422 .and_then(|t| t.duration_since(UNIX_EPOCH).ok())
423 .map(|d| d.as_secs() as i64)
424}
425
426fn mtime_str(m: &std::fs::Metadata) -> String {
427 let dt: Option<DateTime<chrono::Utc>> =
428 mtime_secs(m).and_then(|s| DateTime::from_timestamp(s, 0));
429 dt.map(|d| d.to_rfc3339_opts(chrono::SecondsFormat::Secs, true))
430 .unwrap_or_else(|| EPOCH_MTIME.to_string())
431}
432
433// ---------------------------------------------------------------------------
434// Mutations (milestone 3): mkdir, rename, remove, move, copy, upload
435// ---------------------------------------------------------------------------
436
437/// Resolve a directory that must exist (relative to a user root). Used as the
438/// base for operations that target the *parent* of the item.
439pub fn resolve_dir(server_root: &Path, root_rel: &str, req_rel: &str) -> Result<PathBuf, FsError> {
440 let full = resolve_path(server_root, root_rel, req_rel)?;
441 if !full.is_dir() {
442 return Err(FsError::NotADirectory);
443 }
444 Ok(full)
445}
446
447/// Validate a new single-component name (for rename / new folder).
448fn validate_component(name: &str) -> Result<(), FsError> {
449 let p = Path::new(name);
450 if name.is_empty()
451 || p.components().count() != 1
452 || name == "."
453 || name == ".."
454 || name.contains(['/', '\\', '\0'])
455 {
456 return Err(FsError::Invalid("invalid name".to_string()));
457 }
458 Ok(())
459}
460
461/// Create a directory (and any missing parents) inside a user root.
462pub fn mkdir(server_root: &Path, root_rel: &str, req_rel: &str) -> Result<(), FsError> {
463 let full = resolve_entry(server_root, root_rel, req_rel)?;
464 if full.exists() {
465 return Err(FsError::Conflict);
466 }
467 std::fs::create_dir_all(&full).map_err(|e| io_err(e, &full))?;
468 Ok(())
469}
470
471/// Create an empty file. The parent must exist; the file must not.
472pub fn create_file(server_root: &Path, root_rel: &str, req_rel: &str) -> Result<(), FsError> {
473 let full = resolve_entry(server_root, root_rel, req_rel)?;
474 // create_new = O_EXCL: fails on an existing path, does not follow a symlink.
475 std::fs::File::create_new(&full).map_err(|e| match e.kind() {
476 std::io::ErrorKind::AlreadyExists => FsError::Conflict,
477 _ => io_err(e, &full),
478 })?;
479 Ok(())
480}
481
482/// Resolve a path that names an *entry*, not the file that entry may point at.
483///
484/// The last component is never followed and need not exist. The parent must,
485/// and is canonicalized and checked against the root.
486///
487/// This is the resolver for operations that act on the name: create, delete,
488/// rename, the source of a move, the destination of a move or copy. Following
489/// a symlink there would delete a file the request never mentioned, or rename
490/// one into a different directory. Reads and content writes use
491/// [`resolve_path`] instead and do follow, contained by `ensure_within`.
492pub(crate) fn resolve_entry(
493 server_root: &Path,
494 root_rel: &str,
495 req_rel: &str,
496) -> Result<PathBuf, FsError> {
497 let (root_abs, full) = join_under_root(server_root, root_rel, req_rel)?;
498 // The parent must exist and stay inside the root.
499 let parent = full
500 .parent()
501 .filter(|p| !p.as_os_str().is_empty())
502 .ok_or_else(|| FsError::Invalid("invalid path".to_string()))?;
503 let parent = parent.canonicalize().map_err(|e| io_err(e, parent))?;
504 ensure_within(&root_abs, &parent)?;
505 // Re-join onto the *canonical* parent. `full` may still spell a symlinked
506 // directory, and a caller comparing it against another resolved path (see
507 // [`move_to`]) would then compare two different spellings of one place.
508 let name = full
509 .file_name()
510 .ok_or_else(|| FsError::Invalid("invalid path".to_string()))?;
511 Ok(parent.join(name))
512}
513
514/// Rename (or move within the same directory) an item.
515/// Returns the path the item was renamed *away from*, so the caller can
516/// revoke anything (a share) that still names it.
517pub fn rename_item(
518 server_root: &Path,
519 root_rel: &str,
520 req_rel: &str,
521 new_name: &str,
522 overwrite: bool,
523) -> Result<PathBuf, FsError> {
524 validate_component(new_name)?;
525 let from = resolve_entry(server_root, root_rel, req_rel)?;
526 let parent = from
527 .parent()
528 .ok_or_else(|| FsError::Invalid("invalid path".to_string()))?;
529 let to = parent.join(new_name);
530 // Renaming onto itself is a no-op (the overwrite path below would
531 // delete the file before the rename). Nothing was vacated.
532 if to == from {
533 return Ok(to);
534 }
535 // `rename` replaces a file target atomically; no remove-then-rename gap.
536 check_move_conflict(&to, &from, overwrite)?;
537 std::fs::rename(&from, &to).map_err(|e| io_err(e, &to))?;
538 Ok(from)
539}
540
541/// Delete a file or a directory tree. Returns the path that is now gone (so
542/// the caller can revoke shares naming it).
543pub fn remove_item(server_root: &Path, root_rel: &str, req_rel: &str) -> Result<PathBuf, FsError> {
544 let full = resolve_entry(server_root, root_rel, req_rel)?;
545 remove_entry(&full)?;
546 Ok(full)
547}
548
549/// A symlink is unlinked, never followed: deleting it must not delete the
550/// file it names. A dangling link is deletable for the same reason.
551fn remove_entry(p: &Path) -> Result<(), FsError> {
552 if entry_is_dir(p) {
553 std::fs::remove_dir_all(p).map_err(|e| io_err(e, p))
554 } else {
555 std::fs::remove_file(p).map_err(|e| io_err(e, p))
556 }
557}
558
559/// Overwrite an existing file's contents (the editor's save path). `full`
560/// comes from [`resolve_path`], or [`resolve_file`] for a file share.
561///
562/// The file must already exist and be a regular file. If `expected_mtime`
563/// (whole unix seconds) is provided and differs from the file's current mtime,
564/// the file changed on disk since it was read → `Conflict` (409). Returns the
565/// file's new mtime (unix seconds) after a successful write.
566pub fn write_checked(
567 full: &Path,
568 content: &[u8],
569 expected_mtime: Option<i64>,
570) -> Result<i64, FsError> {
571 let meta = std::fs::metadata(full).map_err(|_| FsError::NotFound)?;
572 if meta.is_dir() {
573 return Err(FsError::NotADirectory);
574 }
575 if let Some(expected) = expected_mtime {
576 // No readable mtime means the check cannot pass: -1 never matches.
577 if mtime_secs(&meta).unwrap_or(-1) != expected {
578 return Err(FsError::Conflict);
579 }
580 }
581 std::fs::write(full, content).map_err(|e| io_err(e, full))?;
582 // Read the new mtime so the client can anchor the next conflict check.
583 let new_meta = std::fs::metadata(full).map_err(|_| FsError::NotFound)?;
584 Ok(mtime_secs(&new_meta).unwrap_or(0))
585}
586
587fn io_err(e: std::io::Error, p: &Path) -> FsError {
588 tracing::warn!(error = %e, path = %p.display(), "filesystem error");
589 match e.kind() {
590 std::io::ErrorKind::NotFound => FsError::NotFound,
591 _ => FsError::Forbidden,
592 }
593}
594
595/// True if `a` is `b` or a descendant of `b` (both canonical).
596pub(crate) fn is_within_or_eq(base: &Path, p: &Path) -> bool {
597 p.starts_with(base)
598}
599
600/// Move an item (possibly across roots). `dst_dir_rel` is the destination
601/// directory (relative to `dst_root_rel`); the item keeps its base name.
602///
603/// Returns the path the item was moved *away from*, like [`rename_item`].
604pub fn move_item(
605 server_root: &Path,
606 src_root_rel: &str,
607 src_rel: &str,
608 dst_root_rel: &str,
609 dst_dir_rel: &str,
610 overwrite: bool,
611) -> Result<PathBuf, FsError> {
612 let from = resolve_entry(server_root, src_root_rel, src_rel)?;
613 let dst_dir = resolve_dir(server_root, dst_root_rel, dst_dir_rel)?;
614 let name = from
615 .file_name()
616 .ok_or_else(|| FsError::Invalid("invalid path".to_string()))?
617 .to_owned();
618 let to = dst_dir.join(&name);
619
620 // A no-op (item already at the destination) — treat as success. Nothing
621 // was vacated.
622 if to == from {
623 return Ok(to);
624 }
625
626 // Refuse moving a directory into itself or a descendant.
627 if entry_is_dir(&from) && is_within_or_eq(&from, &dst_dir) {
628 return Err(FsError::Invalid(
629 "cannot move a folder into itself".to_string(),
630 ));
631 }
632 check_move_conflict(&to, &from, overwrite)?;
633 rename_or_copy(&from, &to)?;
634 Ok(from)
635}
636
637/// Copy an item (possibly across roots).
638pub fn copy_item(
639 server_root: &Path,
640 src_root_rel: &str,
641 src_rel: &str,
642 dst_root_rel: &str,
643 dst_dir_rel: &str,
644 overwrite: bool,
645) -> Result<(), FsError> {
646 // Two resolutions of one path, because a copy of a symlink wants both
647 // halves: the bytes of the file it names, under the name of the link
648 // itself. `cp` does the same.
649 let from = resolve_path(server_root, src_root_rel, src_rel)?;
650 let entry = resolve_entry(server_root, src_root_rel, src_rel)?;
651 let dst_dir = resolve_dir(server_root, dst_root_rel, dst_dir_rel)?;
652 let name = entry
653 .file_name()
654 .ok_or_else(|| FsError::Invalid("invalid path".to_string()))?
655 .to_owned();
656 let to = dst_dir.join(&name);
657
658 // A no-op (item already at the destination) — treat as success.
659 if to == from {
660 return Ok(());
661 }
662
663 if from.is_dir() && is_within_or_eq(&from, &dst_dir) {
664 return Err(FsError::Invalid(
665 "cannot copy a folder into itself".to_string(),
666 ));
667 }
668 check_move_conflict(&to, &from, overwrite)?;
669 copy_recursive(&from, &to)?;
670 Ok(())
671}
672
673/// Move an item to an explicit destination path (the WebDAV `MOVE` shape).
674///
675/// Unlike [`move_item`], the destination names the item itself, so this also
676/// renames. There is no overwrite check here: the WebDAV layer has already
677/// refused, or deleted, an existing destination by the time this runs.
678pub fn move_to(
679 server_root: &Path,
680 src_root_rel: &str,
681 src_rel: &str,
682 dst_root_rel: &str,
683 dst_rel: &str,
684) -> Result<(), FsError> {
685 // An entry, not its target: moving a symlink moves the link.
686 let from = resolve_entry(server_root, src_root_rel, src_rel)?;
687 let to = resolve_dest(server_root, &from, dst_root_rel, dst_rel)?;
688 if from == to {
689 return Ok(());
690 }
691 rename_or_copy(&from, &to)
692}
693
694/// `rename`, falling back to copy-then-remove when the two paths are on
695/// different filesystems.
696///
697/// A symlink is refused on the fallback path. `copy_recursive` stats with
698/// `metadata`, which follows, so it would replace the link with a copy of its
699/// target, and that target may be outside the root. Plain `rename` moves the
700/// link itself and needs no such guard.
701fn rename_or_copy(from: &Path, to: &Path) -> Result<(), FsError> {
702 match std::fs::rename(from, to) {
703 Ok(()) => Ok(()),
704 Err(e) if e.kind() == std::io::ErrorKind::CrossesDevices => {
705 if std::fs::symlink_metadata(from).is_ok_and(|m| m.file_type().is_symlink()) {
706 return Err(FsError::Forbidden);
707 }
708 copy_recursive(from, to)?;
709 remove_entry(from)
710 }
711 Err(e) => Err(io_err(e, to)),
712 }
713}
714
715/// Copy to an explicit destination path (the WebDAV `COPY` shape). The WebDAV
716/// layer only ever asks for a single file: it walks a tree itself, one
717/// `create_dir` and one `copy` per entry.
718pub fn copy_file_to(
719 server_root: &Path,
720 src_root_rel: &str,
721 src_rel: &str,
722 dst_root_rel: &str,
723 dst_rel: &str,
724) -> Result<(), FsError> {
725 // The source *is* followed: a copy wants the file's bytes, the way `cp`
726 // does. `resolve_path` still refuses a link that leaves the root.
727 let from = resolve_path(server_root, src_root_rel, src_rel)?;
728 let to = resolve_dest(server_root, &from, dst_root_rel, dst_rel)?;
729 if from == to {
730 return Ok(());
731 }
732 copy_recursive(&from, &to)
733}
734
735/// Copy one file, replacing a symlink at the destination instead of writing
736/// through it.
737///
738/// `std::fs::copy` follows a destination symlink, so a link out of the root
739/// makes the copy land outside it with every path check passing. Every caller
740/// here has already decided the destination may be overwritten, so unlinking
741/// first is also the semantics they wanted. `rename` needs no such guard; it
742/// replaces the link rather than following it.
743fn copy_file(src: &Path, dst: &Path) -> Result<(), FsError> {
744 if std::fs::symlink_metadata(dst).is_ok_and(|m| m.file_type().is_symlink()) {
745 std::fs::remove_file(dst).map_err(|e| io_err(e, dst))?;
746 }
747 std::fs::copy(src, dst).map_err(|e| io_err(e, dst))?;
748 Ok(())
749}
750
751/// Resolve the destination of a move or copy that names it in full.
752///
753/// A destination *inside* `from` is refused, which is what stops
754/// `MOVE /a /a/b` from eating itself. The source is the caller's to resolve: a
755/// move relocates the entry, a copy wants the bytes, so the two follow a
756/// symlink differently.
757fn resolve_dest(
758 server_root: &Path,
759 from: &Path,
760 dst_root_rel: &str,
761 dst_rel: &str,
762) -> Result<PathBuf, FsError> {
763 let to = resolve_entry(server_root, dst_root_rel, dst_rel)?;
764 if from != to && entry_is_dir(from) && is_within_or_eq(&to, from) {
765 return Err(FsError::Invalid(
766 "cannot move a folder into itself".to_string(),
767 ));
768 }
769 Ok(to)
770}
771
772/// Conflict rules shared by move and copy:
773/// - target is a directory → always conflict (no silent merge)
774/// - target is a file → conflict unless overwriting a file with a file
775fn check_move_conflict(to: &Path, from: &Path, overwrite: bool) -> Result<(), FsError> {
776 if entry_exists(to) {
777 let to_dir = entry_is_dir(to);
778 let from_dir = entry_is_dir(from);
779 if to_dir || from_dir || !overwrite {
780 return Err(FsError::Conflict);
781 }
782 }
783 Ok(())
784}
785
786/// Recursively copy a file or directory tree, preserving mtime.
787fn copy_recursive(src: &Path, dst: &Path) -> Result<(), FsError> {
788 let meta = std::fs::metadata(src).map_err(|e| io_err(e, src))?;
789 if meta.is_dir() {
790 std::fs::create_dir(dst).map_err(|e| io_err(e, dst))?;
791 for e in std::fs::read_dir(src)
792 .map_err(|e| io_err(e, src))?
793 .flatten()
794 {
795 copy_recursive(&e.path(), &dst.join(e.file_name()))?;
796 }
797 } else {
798 copy_file(src, dst)?;
799 }
800 set_mtime(dst, meta.modified().ok());
801 Ok(())
802}
803
804fn set_mtime(p: &Path, t: Option<std::time::SystemTime>) {
805 if let (Some(t), Ok(f)) = (t, std::fs::File::open(p)) {
806 let _ = f.set_modified(t);
807 }
808}
809
810// ---------------------------------------------------------------------------
811// Tests
812// ---------------------------------------------------------------------------
813
814#[cfg(test)]
815mod tests {
816 use super::*;
817
818 /// A temp dir used as the "server root" with a small fixture tree:
819 ///
820 /// ```text
821 /// root/
822 /// docs/
823 /// inner/
824 /// hello.txt
825 /// a.txt
826 /// src/
827 /// main.rs
828 /// file.txt
829 /// ```
830 struct T {
831 tmp: tempfile::TempDir,
832 root: PathBuf,
833 }
834
835 impl T {
836 fn new() -> Self {
837 let tmp = tempfile::tempdir().unwrap();
838 let root = tmp.path().to_path_buf();
839 std::fs::create_dir_all(root.join("docs/inner")).unwrap();
840 std::fs::create_dir_all(root.join("src")).unwrap();
841 std::fs::write(root.join("docs/inner/hello.txt"), "hello").unwrap();
842 std::fs::write(root.join("docs/a.txt"), "a").unwrap();
843 std::fs::write(root.join("src/main.rs"), "fn main() {}").unwrap();
844 std::fs::write(root.join("file.txt"), "top file").unwrap();
845 Self { tmp, root }
846 }
847
848 /// A directory that lives *next to* the root (outside of it), for
849 /// symlink/escape tests. The tempdir name is unique, so the sibling
850 /// name is unique too.
851 fn sibling(&self, name: &str) -> PathBuf {
852 let base = self
853 .tmp
854 .path()
855 .file_name()
856 .unwrap()
857 .to_string_lossy()
858 .into_owned();
859 let p = self.tmp.path().with_file_name(format!("{base}-{name}"));
860 std::fs::create_dir_all(&p).unwrap();
861 p
862 }
863 }
864
865 // ---------- validate_component ----------
866
867 #[test]
868 fn validate_name_accepts_simple_names() {
869 for ok in ["a", "file.txt", "my folder", "Ünïcödé", "with-dash_1.2.3"] {
870 assert!(validate_component(ok).is_ok(), "{ok:?} should be valid");
871 }
872 }
873
874 #[test]
875 fn validate_name_rejects_traversal_and_paths() {
876 for bad in [
877 "", ".", "..", "a/b", "a\\b", "a\0b", "/abs", "../x", "x/../y", "x/", "/x",
878 ] {
879 assert!(
880 validate_component(bad).is_err(),
881 "{bad:?} should be invalid"
882 );
883 }
884 }
885
886 // ---------- resolve_root ----------
887
888 #[test]
889 fn resolve_root_whole_root_and_subdir() {
890 let t = T::new();
891 let root = t.root.canonicalize().unwrap();
892 // "." means the whole root.
893 assert_eq!(resolve_root(&root, ".").unwrap(), root);
894 assert_eq!(resolve_root(&root, "docs").unwrap(), root.join("docs"));
895 assert_eq!(
896 resolve_root(&root, "docs/inner").unwrap(),
897 root.join("docs/inner")
898 );
899 }
900
901 #[test]
902 fn resolve_root_rejects_escape_and_missing() {
903 let t = T::new();
904 let root = t.root.canonicalize().unwrap();
905 let sib = t.sibling("escape");
906 let sib_rel = sib.file_name().unwrap().to_string_lossy().into_owned();
907 // Escapes that land on *existing* paths outside the root.
908 for esc in [
909 "..".to_string(),
910 "docs/../..".to_string(),
911 format!("../{sib_rel}"),
912 ] {
913 assert!(
914 matches!(resolve_root(&root, &esc), Err(FsError::Forbidden)),
915 "{esc:?} should be forbidden"
916 );
917 }
918 // Escapes to non-existing paths simply don't exist.
919 for esc in ["../no-such-dir", "a/b/../../..", "nope"] {
920 assert!(
921 matches!(resolve_root(&root, esc), Err(FsError::RootMissing)),
922 "{esc:?} should be missing"
923 );
924 }
925 // A file is not a valid root.
926 assert!(matches!(
927 resolve_root(&root, "file.txt"),
928 Err(FsError::RootMissing)
929 ));
930 }
931
932 #[cfg(unix)]
933 #[test]
934 fn resolve_root_rejects_symlink_escape() {
935 let t = T::new();
936 let root = t.root.canonicalize().unwrap();
937 let outside = t.sibling("outside");
938 std::os::unix::fs::symlink(&outside, root.join("link")).unwrap();
939 assert!(matches!(
940 resolve_root(&root, "link"),
941 Err(FsError::Forbidden)
942 ));
943 }
944
945 // ---------- resolve_path ----------
946
947 #[test]
948 fn resolve_path_traverses_inside_root() {
949 let t = T::new();
950 let root = t.root.canonicalize().unwrap();
951 // Empty relative path → the root itself.
952 assert_eq!(resolve_path(&root, ".", "").unwrap(), root);
953 assert_eq!(
954 resolve_path(&root, "docs", "inner/hello.txt").unwrap(),
955 root.join("docs/inner/hello.txt")
956 );
957 assert_eq!(
958 resolve_path(&root, ".", "file.txt").unwrap(),
959 root.join("file.txt")
960 );
961 }
962
963 #[test]
964 fn resolve_path_rejects_parent_traversal() {
965 let t = T::new();
966 let root = t.root.canonicalize().unwrap();
967 for p in ["..", "../file.txt", "docs/../../file.txt", "a/../../b"] {
968 assert!(
969 matches!(resolve_path(&root, ".", p), Err(FsError::Forbidden)),
970 "{p:?} should be forbidden"
971 );
972 }
973 }
974
975 #[test]
976 fn resolve_path_missing_is_not_found() {
977 let t = T::new();
978 let root = t.root.canonicalize().unwrap();
979 assert!(matches!(
980 resolve_path(&root, "docs", "nope.txt"),
981 Err(FsError::NotFound)
982 ));
983 assert!(matches!(
984 resolve_path(&root, "missing-root", ""),
985 Err(FsError::RootMissing)
986 ));
987 }
988
989 #[cfg(unix)]
990 #[test]
991 fn resolve_path_rejects_symlink_escape() {
992 let t = T::new();
993 let root = t.root.canonicalize().unwrap();
994 let outside = t.sibling("outside");
995 let secret = outside.join("secret.txt");
996 std::fs::write(&secret, "top secret").unwrap();
997 std::os::unix::fs::symlink(&secret, root.join("evil")).unwrap();
998 assert!(matches!(
999 resolve_path(&root, ".", "evil"),
1000 Err(FsError::Forbidden)
1001 ));
1002 // A symlink that stays inside the root is fine.
1003 std::os::unix::fs::symlink(root.join("file.txt"), root.join("alias")).unwrap();
1004 assert_eq!(
1005 resolve_path(&root, ".", "alias").unwrap(),
1006 root.join("file.txt")
1007 );
1008 }
1009
1010 // ---------- resolve_file / resolve_dir ----------
1011
1012 #[test]
1013 fn resolve_file_targets_files() {
1014 let t = T::new();
1015 let root = t.root.canonicalize().unwrap();
1016 assert_eq!(
1017 resolve_file(&root, "file.txt").unwrap(),
1018 root.join("file.txt")
1019 );
1020 assert!(matches!(
1021 resolve_file(&root, "nope.txt"),
1022 Err(FsError::NotFound)
1023 ));
1024 // Escape to an existing sibling file.
1025 let sib = t.sibling("escape");
1026 let sib_rel = sib.file_name().unwrap().to_string_lossy().into_owned();
1027 std::fs::write(sib.join("s.txt"), "x").unwrap();
1028 assert!(matches!(
1029 resolve_file(&root, &format!("../{sib_rel}/s.txt")),
1030 Err(FsError::Forbidden)
1031 ));
1032 }
1033
1034 #[test]
1035 fn resolve_dir_requires_existing_directory() {
1036 let t = T::new();
1037 let root = t.root.canonicalize().unwrap();
1038 assert_eq!(resolve_dir(&root, ".", "docs").unwrap(), root.join("docs"));
1039 assert!(matches!(
1040 resolve_dir(&root, ".", "file.txt"),
1041 Err(FsError::NotADirectory)
1042 ));
1043 assert!(matches!(
1044 resolve_dir(&root, ".", "nope"),
1045 Err(FsError::NotFound)
1046 ));
1047 }
1048
1049 // ---------- list_dir ----------
1050
1051 #[test]
1052 fn list_dir_sorts_folders_first_then_case_insensitive() {
1053 let t = T::new();
1054 let d = t.root.join("sortme");
1055 std::fs::create_dir_all(d.join("Zeta")).unwrap();
1056 std::fs::create_dir_all(d.join("alpha-dir")).unwrap();
1057 std::fs::write(d.join("b.txt"), "x").unwrap();
1058 std::fs::write(d.join("A.txt"), "x").unwrap();
1059 std::fs::write(d.join("C.md"), "x").unwrap();
1060 let entries = list_dir(&d, ListOpts::default()).unwrap().entries;
1061 let names: Vec<&str> = entries.iter().map(|e| e.name.as_str()).collect();
1062 // Folders first (alpha-dir, Zeta), then files case-insensitively.
1063 assert_eq!(names, vec!["alpha-dir", "Zeta", "A.txt", "b.txt", "C.md"]);
1064 let a = &entries[2];
1065 assert!(!a.is_dir);
1066 assert_eq!(a.size, 1);
1067 assert!(!a.mtime.is_empty());
1068 }
1069
1070 #[test]
1071 fn list_dir_error_cases() {
1072 let t = T::new();
1073 let root = t.root.canonicalize().unwrap();
1074 assert!(matches!(
1075 list_dir(&root.join("missing"), ListOpts::default()),
1076 Err(FsError::NotFound)
1077 ));
1078 assert!(matches!(
1079 list_dir(&root.join("file.txt"), ListOpts::default()),
1080 Err(FsError::NotADirectory)
1081 ));
1082 }
1083
1084 fn entry(name: &str, is_dir: bool, size: u64, mtime: &str) -> Entry {
1085 Entry {
1086 name: name.to_string(),
1087 is_dir,
1088 size,
1089 mtime: mtime.to_string(),
1090 kind: FileKind::Binary,
1091 }
1092 }
1093
1094 fn sorted(mut v: Vec<Entry>, key: SortKey, desc: bool) -> Vec<String> {
1095 v.sort_by(|a, b| cmp_entries(a, b, key, desc));
1096 v.into_iter().map(|e| e.name).collect()
1097 }
1098
1099 /// Paging slices this order, so a wrong order puts a file on the wrong
1100 /// page.
1101 #[test]
1102 fn sort_keeps_folders_first_in_every_order() {
1103 let v = vec![
1104 entry("b.txt", false, 10, "2026-01-02T00:00:00Z"),
1105 entry("Zdir", true, 0, "2026-01-01T00:00:00Z"),
1106 entry("a.txt", false, 30, "2026-01-03T00:00:00Z"),
1107 ];
1108 for key in [SortKey::Name, SortKey::Size, SortKey::Modified] {
1109 for desc in [false, true] {
1110 assert_eq!(
1111 sorted(v.clone(), key, desc)[0],
1112 "Zdir",
1113 "{key:?} desc={desc}"
1114 );
1115 }
1116 }
1117 }
1118
1119 #[test]
1120 fn sort_orders_by_the_chosen_key() {
1121 let v = vec![
1122 entry("b.txt", false, 10, "2026-01-02T00:00:00Z"),
1123 entry("a.txt", false, 30, "2026-01-03T00:00:00Z"),
1124 entry("c.txt", false, 20, "2026-01-01T00:00:00Z"),
1125 ];
1126 let s = |key, desc| sorted(v.clone(), key, desc);
1127 assert_eq!(s(SortKey::Name, false), ["a.txt", "b.txt", "c.txt"]);
1128 assert_eq!(s(SortKey::Name, true), ["c.txt", "b.txt", "a.txt"]);
1129 assert_eq!(s(SortKey::Size, false), ["b.txt", "c.txt", "a.txt"]);
1130 assert_eq!(s(SortKey::Modified, false), ["c.txt", "b.txt", "a.txt"]);
1131 }
1132
1133 #[test]
1134 fn list_dir_pages_the_sorted_listing() {
1135 let t = T::new();
1136 let d = t.root.join("paged");
1137 std::fs::create_dir_all(d.join("sub")).unwrap();
1138 for i in 0..7 {
1139 std::fs::write(d.join(format!("f{i}")), vec![b'x'; i]).unwrap();
1140 }
1141 let page = |offset, limit, dirs_only| {
1142 let opts = ListOpts {
1143 sort: SortKey::Size,
1144 desc: true,
1145 offset,
1146 limit: Some(limit),
1147 dirs_only,
1148 around: None,
1149 };
1150 let r = list_dir(&d, opts).unwrap();
1151 let names: Vec<String> = r.entries.into_iter().map(|e| e.name).collect();
1152 (names, r.total, r.offset)
1153 };
1154 assert_eq!(
1155 page(0, 3, false),
1156 (vec!["sub".into(), "f6".into(), "f5".into()], 8, 0)
1157 );
1158 assert_eq!(
1159 page(3, 3, false),
1160 (vec!["f4".into(), "f3".into(), "f2".into()], 8, 3)
1161 );
1162 // Past the end: the last page.
1163 assert_eq!(page(50, 3, false), (vec!["f1".into(), "f0".into()], 8, 6));
1164 // Exactly one past a full last page, as after deleting its entries.
1165 assert_eq!(page(8, 4, false).2, 4);
1166 assert_eq!(page(0, 3, true), (vec!["sub".into()], 1, 0));
1167 // A zero limit is one entry, not a division by zero.
1168 assert_eq!(page(2, 0, false), (vec!["f5".into()], 8, 2));
1169 assert_eq!(page(0, usize::MAX, false).0.len(), 8);
1170 let around = |name: &str| {
1171 let opts = ListOpts {
1172 sort: SortKey::Size,
1173 desc: true,
1174 offset: 3,
1175 limit: Some(3),
1176 around: Some(name.into()),
1177 ..ListOpts::default()
1178 };
1179 list_dir(&d, opts).unwrap().offset
1180 };
1181 assert_eq!(around("f1"), 6);
1182 assert_eq!(around("sub"), 0);
1183 assert_eq!(around("missing"), 3);
1184
1185 let empty = t.root.join("empty");
1186 std::fs::create_dir_all(&empty).unwrap();
1187 let opts = ListOpts {
1188 offset: 30,
1189 limit: Some(10),
1190 ..ListOpts::default()
1191 };
1192 let r = list_dir(&empty, opts).unwrap();
1193 assert_eq!((r.entries.len(), r.total, r.offset), (0, 0, 0));
1194 }
1195
1196 #[cfg(unix)]
1197 #[test]
1198 fn list_dir_reports_broken_symlink_as_empty_file() {
1199 let t = T::new();
1200 let d = t.root.join("withlink");
1201 std::fs::create_dir_all(&d).unwrap();
1202 std::os::unix::fs::symlink(d.join("does-not-exist"), d.join("broken")).unwrap();
1203 let entries = list_dir(&d, ListOpts::default()).unwrap().entries;
1204 assert_eq!(entries.len(), 1);
1205 assert_eq!(entries[0].name, "broken");
1206 assert!(!entries[0].is_dir);
1207 assert_eq!(entries[0].size, 0);
1208 }
1209
1210 // ---------- mkdir ----------
1211
1212 #[test]
1213 fn mkdir_creates_nested_dirs() {
1214 let t = T::new();
1215 let root = t.root.canonicalize().unwrap();
1216 // The parent must exist; "new" first, then "new/sub".
1217 mkdir(&root, ".", "new").unwrap();
1218 assert!(root.join("new").is_dir());
1219 mkdir(&root, ".", "new/sub").unwrap();
1220 assert!(root.join("new/sub").is_dir());
1221 }
1222
1223 #[test]
1224 fn mkdir_rejects_conflict_and_bad_names() {
1225 let t = T::new();
1226 let root = t.root.canonicalize().unwrap();
1227 assert!(matches!(mkdir(&root, ".", "docs"), Err(FsError::Conflict)));
1228 assert!(matches!(
1229 mkdir(&root, ".", "a/b/../../c"),
1230 Err(FsError::Forbidden)
1231 ));
1232 assert!(matches!(
1233 mkdir(&root, ".", "file.txt/x"),
1234 Err(FsError::Forbidden) // parent is a file → ENOTDIR
1235 ));
1236 }
1237
1238 // ---------- rename ----------
1239
1240 #[test]
1241 fn rename_moves_file_and_dir() {
1242 let t = T::new();
1243 let root = t.root.canonicalize().unwrap();
1244 let vacated = rename_item(&root, ".", "file.txt", "renamed.txt", false).unwrap();
1245 assert_eq!(vacated, root.join("file.txt"));
1246 assert!(!root.join("file.txt").exists());
1247 assert_eq!(
1248 std::fs::read_to_string(root.join("renamed.txt")).unwrap(),
1249 "top file"
1250 );
1251 rename_item(&root, ".", "docs", "docs2", false).unwrap();
1252 assert!(root.join("docs2/inner/hello.txt").exists());
1253 }
1254
1255 #[test]
1256 fn rename_conflicts_and_overwrite() {
1257 let t = T::new();
1258 let root = t.root.canonicalize().unwrap();
1259 std::fs::write(root.join("other.txt"), "other").unwrap();
1260 // Target file exists, no overwrite → conflict.
1261 assert!(matches!(
1262 rename_item(&root, ".", "file.txt", "other.txt", false),
1263 Err(FsError::Conflict)
1264 ));
1265 // Overwrite a file target → replaces it.
1266 rename_item(&root, ".", "file.txt", "other.txt", true).unwrap();
1267 assert_eq!(
1268 std::fs::read_to_string(root.join("other.txt")).unwrap(),
1269 "top file"
1270 );
1271 // A dir target is never overwritten, even with the flag.
1272 assert!(matches!(
1273 rename_item(&root, ".", "other.txt", "docs", true),
1274 Err(FsError::Conflict)
1275 ));
1276 // Renaming into a free slot works, then onto itself is a no-op.
1277 rename_item(&root, ".", "other.txt", "free.txt", false).unwrap();
1278 assert!(root.join("free.txt").exists());
1279 rename_item(&root, ".", "free.txt", "free.txt", false).unwrap();
1280 assert!(root.join("free.txt").exists());
1281 assert!(root.join("free.txt").is_file());
1282 }
1283
1284 #[test]
1285 fn rename_validates_new_name() {
1286 let t = T::new();
1287 let root = t.root.canonicalize().unwrap();
1288 for bad in ["a/b", "", ".", ".."] {
1289 assert!(matches!(
1290 rename_item(&root, ".", "file.txt", bad, false),
1291 Err(FsError::Invalid(_))
1292 ));
1293 }
1294 assert!(matches!(
1295 rename_item(&root, ".", "missing", "x", false),
1296 Err(FsError::NotFound)
1297 ));
1298 }
1299
1300 // ---------- remove ----------
1301
1302 #[test]
1303 fn remove_file_and_dir() {
1304 let t = T::new();
1305 let root = t.root.canonicalize().unwrap();
1306 let gone = remove_item(&root, ".", "file.txt").unwrap();
1307 assert_eq!(gone, root.join("file.txt"));
1308 assert!(!root.join("file.txt").exists());
1309 let gone = remove_item(&root, ".", "docs").unwrap();
1310 assert_eq!(gone, root.join("docs"));
1311 assert!(!root.join("docs").exists());
1312 assert!(matches!(
1313 remove_item(&root, ".", "file.txt"),
1314 Err(FsError::NotFound)
1315 ));
1316 }
1317
1318 // ---------- write_checked ----------
1319
1320 fn mtime_of(p: &Path) -> i64 {
1321 std::fs::metadata(p)
1322 .unwrap()
1323 .modified()
1324 .unwrap()
1325 .duration_since(std::time::UNIX_EPOCH)
1326 .unwrap()
1327 .as_secs() as i64
1328 }
1329
1330 #[test]
1331 fn write_checked_updates_content_and_returns_new_mtime() {
1332 let t = T::new();
1333 let root = t.root.canonicalize().unwrap();
1334 let file = root.join("file.txt");
1335 let before = mtime_of(&file);
1336 // Sleep so the mtime actually advances (filesystem granularity).
1337 std::thread::sleep(std::time::Duration::from_millis(1100));
1338 let new = write_checked(&file, b"brand new", Some(before)).unwrap();
1339 assert_eq!(std::fs::read(&file).unwrap(), b"brand new");
1340 assert!(new >= before);
1341 // A second save with the *returned* mtime succeeds.
1342 let new2 = write_checked(&file, b"again", Some(new)).unwrap();
1343 assert!(new2 >= new);
1344 // Without an expected mtime, always saves.
1345 let _ = write_checked(&file, b"force", None).unwrap();
1346 assert_eq!(std::fs::read(root.join("file.txt")).unwrap(), b"force");
1347 }
1348
1349 #[test]
1350 fn write_checked_conflict_on_stale_mtime() {
1351 let t = T::new();
1352 let root = t.root.canonicalize().unwrap();
1353 std::thread::sleep(std::time::Duration::from_millis(1100));
1354 // The mtime we pass is older than the file's real mtime → conflict.
1355 assert!(matches!(
1356 write_checked(&root.join("file.txt"), b"x", Some(1)),
1357 Err(FsError::Conflict)
1358 ));
1359 }
1360
1361 #[test]
1362 fn write_checked_error_cases() {
1363 let t = T::new();
1364 let root = t.root.canonicalize().unwrap();
1365 assert!(matches!(
1366 write_checked(&root.join("nope.txt"), b"x", None),
1367 Err(FsError::NotFound)
1368 ));
1369 assert!(matches!(
1370 write_checked(&root.join("docs"), b"x", None),
1371 Err(FsError::NotADirectory)
1372 ));
1373 }
1374
1375 // ---------- move / copy ----------
1376
1377 #[test]
1378 fn move_file_and_dir_across_dirs() {
1379 let t = T::new();
1380 let root = t.root.canonicalize().unwrap();
1381 let vacated = move_item(&root, ".", "file.txt", ".", "src", false).unwrap();
1382 assert_eq!(vacated, root.join("file.txt"));
1383 assert!(!root.join("file.txt").exists());
1384 assert!(root.join("src/file.txt").exists());
1385 move_item(&root, ".", "src", ".", "docs", false).unwrap();
1386 assert!(root.join("docs/src/main.rs").exists());
1387 assert!(!root.join("src").exists());
1388 }
1389
1390 #[test]
1391 fn move_refuses_into_self_and_conflicts() {
1392 let t = T::new();
1393 let root = t.root.canonicalize().unwrap();
1394 // A dir cannot be moved into itself or a descendant.
1395 assert!(matches!(
1396 move_item(&root, ".", "docs", ".", "docs", false),
1397 Err(FsError::Invalid(_))
1398 ));
1399 assert!(matches!(
1400 move_item(&root, ".", "docs", ".", "docs/inner", false),
1401 Err(FsError::Invalid(_))
1402 ));
1403 // A dir target always conflicts, even with overwrite: move the file
1404 // "x" into a folder that already contains a subfolder "x".
1405 std::fs::create_dir_all(root.join("mv/case/x")).unwrap();
1406 std::fs::create_dir_all(root.join("mv/out")).unwrap();
1407 std::fs::write(root.join("mv/out/x"), "a file named x").unwrap();
1408 assert!(matches!(
1409 move_item(&root, ".", "mv/out/x", ".", "mv/case", true),
1410 Err(FsError::Conflict)
1411 ));
1412 // File onto file: conflict without overwrite, replaced with.
1413 std::fs::write(root.join("tmp-x.txt"), "x").unwrap();
1414 std::fs::write(root.join("tmp-y.txt"), "y").unwrap();
1415 std::fs::rename(root.join("tmp-x.txt"), root.join("tmp-target.txt")).unwrap();
1416 std::fs::rename(root.join("tmp-y.txt"), root.join("tmp-target2.txt")).unwrap();
1417 // Two distinct files with the same name in one folder.
1418 std::fs::create_dir_all(root.join("mv/dst")).unwrap();
1419 std::fs::create_dir_all(root.join("mv/out2")).unwrap();
1420 std::fs::write(root.join("mv/dst/dup.txt"), "old").unwrap();
1421 std::fs::write(root.join("mv/out2/dup.txt"), "new").unwrap();
1422 assert!(matches!(
1423 move_item(&root, ".", "mv/out2/dup.txt", ".", "mv/dst", false),
1424 Err(FsError::Conflict)
1425 ));
1426 move_item(&root, ".", "mv/out2/dup.txt", ".", "mv/dst", true).unwrap();
1427 assert_eq!(
1428 std::fs::read_to_string(root.join("mv/dst/dup.txt")).unwrap(),
1429 "new"
1430 );
1431 // Moving onto itself is a no-op success.
1432 move_item(&root, ".", "tmp-target.txt", ".", ".", false).unwrap();
1433 assert!(root.join("tmp-target.txt").exists());
1434 // Missing destination dir.
1435 assert!(matches!(
1436 move_item(&root, ".", "file.txt", ".", "nope", false),
1437 Err(FsError::NotFound)
1438 ));
1439 }
1440
1441 #[test]
1442 fn copy_file_and_dir_preserves_mtime() {
1443 let t = T::new();
1444 let root = t.root.canonicalize().unwrap();
1445 let before = mtime_of(&root.join("file.txt"));
1446 copy_item(&root, ".", "file.txt", ".", "src", false).unwrap();
1447 let copy = root.join("src/file.txt");
1448 assert_eq!(std::fs::read(&copy).unwrap(), b"top file");
1449 assert_eq!(mtime_of(&copy), before);
1450 // Dir copy.
1451 copy_item(&root, ".", "docs", ".", "src", false).unwrap();
1452 assert_eq!(
1453 std::fs::read_to_string(root.join("src/docs/inner/hello.txt")).unwrap(),
1454 "hello"
1455 );
1456 // Originals still there.
1457 assert!(root.join("file.txt").exists());
1458 assert!(root.join("docs/a.txt").exists());
1459 }
1460
1461 #[test]
1462 fn copy_refuses_into_self_and_handles_conflict() {
1463 let t = T::new();
1464 let root = t.root.canonicalize().unwrap();
1465 assert!(matches!(
1466 copy_item(&root, ".", "docs", ".", "docs", false),
1467 Err(FsError::Invalid(_))
1468 ));
1469 assert!(matches!(
1470 copy_item(&root, ".", "docs", ".", "docs/inner", false),
1471 Err(FsError::Invalid(_))
1472 ));
1473 // First copy is fine, the second one conflicts, overwrite replaces.
1474 copy_item(&root, ".", "file.txt", ".", "src", false).unwrap();
1475 assert!(matches!(
1476 copy_item(&root, ".", "file.txt", ".", "src", false),
1477 Err(FsError::Conflict)
1478 ));
1479 std::fs::write(root.join("file.txt"), "v2").unwrap();
1480 copy_item(&root, ".", "file.txt", ".", "src", true).unwrap();
1481 assert_eq!(
1482 std::fs::read_to_string(root.join("src/file.txt")).unwrap(),
1483 "v2"
1484 );
1485 // Copying onto itself is a no-op success.
1486 copy_item(&root, ".", "src/file.txt", ".", "src", false).unwrap();
1487 assert_eq!(
1488 std::fs::read_to_string(root.join("src/file.txt")).unwrap(),
1489 "v2"
1490 );
1491 // Missing destination dir.
1492 assert!(matches!(
1493 copy_item(&root, ".", "file.txt", ".", "nope", false),
1494 Err(FsError::NotFound)
1495 ));
1496 }
1497
1498 #[test]
1499 fn copy_recursive_missing_source() {
1500 let t = T::new();
1501 let dst = t.tmp.path().join("dst");
1502 assert!(matches!(
1503 copy_recursive(&t.root.join("nope"), &dst),
1504 Err(FsError::NotFound)
1505 ));
1506 }
1507
1508 // ---------- is_within_or_eq ----------
1509
1510 #[test]
1511 fn is_within_or_eq_matrix() {
1512 let t = T::new();
1513 let root = t.root.canonicalize().unwrap();
1514 let docs = root.join("docs");
1515 assert!(is_within_or_eq(&docs, &docs));
1516 assert!(is_within_or_eq(&docs, &root.join("docs/inner")));
1517 assert!(!is_within_or_eq(&docs, &root));
1518 assert!(!is_within_or_eq(&docs, &root.join("src")));
1519 }
1520
1521 // ---------- content sniffing ----------
1522
1523 fn kind(bytes: &[u8], name: &str) -> FileKind {
1524 kind_from_bytes(bytes, Path::new(name))
1525 }
1526
1527 #[test]
1528 fn magic_numbers_classify_by_content() {
1529 let png = [0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A, 0, 0, 0, 0];
1530 // The name is a lie in every case: the bytes decide.
1531 assert_eq!(kind(&png, "notes.txt"), FileKind::Image);
1532 assert_eq!(kind(b"%PDF-1.7\n%aaa\n", "x.bin"), FileKind::Pdf);
1533 assert_eq!(
1534 kind(b"PK\x03\x04\x14\x00\x00\x00", "x.png"),
1535 FileKind::Archive
1536 );
1537 assert_eq!(
1538 kind(&[0x1F, 0x8B, 0x08, 0, 0, 0, 0, 0], "x"),
1539 FileKind::Archive
1540 );
1541 assert_eq!(
1542 kind(
1543 &[
1544 0, 0, 0, 0x20, b'f', b't', b'y', b'p', b'i', b's', b'o', b'm'
1545 ],
1546 "x"
1547 ),
1548 FileKind::Video
1549 );
1550 assert_eq!(
1551 kind(b"ID3\x04\x00\x00\x00\x00\x00\x00", "x"),
1552 FileKind::Audio
1553 );
1554 assert_eq!(kind(b"RIFF\x24\x00\x00\x00WAVEfmt ", "x"), FileKind::Audio);
1555 assert_eq!(kind(b"<!doctype html><p>hi", "x"), FileKind::Text);
1556 // Recognized but not viewable in a browser.
1557 assert_eq!(
1558 kind(&[0x00, 0x61, 0x73, 0x6d, 1, 0, 0, 0], "x.wasm"),
1559 FileKind::Binary
1560 );
1561 }
1562
1563 #[test]
1564 fn unsigned_files_fall_back_to_the_text_heuristic() {
1565 // No magic number: plain text, source, config.
1566 assert_eq!(kind(b"hello world\n", "notes"), FileKind::Text);
1567 assert_eq!(kind(b"fn main() {}\n", "main.rs"), FileKind::Text);
1568 assert_eq!(
1569 kind("# über\nkey: wert\n".as_bytes(), "c.yaml"),
1570 FileKind::Text
1571 );
1572 // Extensionless text files work, which the old extension list missed.
1573 assert_eq!(kind(b"all:\n\tcargo build\n", "Makefile"), FileKind::Text);
1574 // Empty file → editable.
1575 assert_eq!(kind(b"", "new.txt"), FileKind::Text);
1576 // A NUL byte means binary, whatever the name says. (ELF has no
1577 // `infer` signature, so this is the path that catches it.)
1578 assert_eq!(
1579 kind(&[0x7F, b'E', b'L', b'F', 2, 1, 1, 0, 0], "run.txt"),
1580 FileKind::Binary
1581 );
1582 assert_eq!(kind(&[0xC3, 0x28, 0xFF, 0xFE], "x.txt"), FileKind::Binary);
1583 }
1584
1585 #[test]
1586 fn svg_is_offered_as_an_image() {
1587 // SVG is XML text with no magic number, but browsers draw it, so the
1588 // extension is the only signal available.
1589 let svg = b"<svg xmlns=\"http://www.w3.org/2000/svg\"></svg>";
1590 assert_eq!(kind(svg, "logo.svg"), FileKind::Image);
1591 assert_eq!(kind(svg, "logo.txt"), FileKind::Text);
1592 }
1593
1594 #[test]
1595 fn truncated_utf8_at_the_read_boundary_is_still_text() {
1596 // 255 ASCII bytes plus the first byte of a 2-byte character: the read
1597 // cut a character in half, which must not read as binary.
1598 let mut b = vec![b'a'; SNIFF_BYTES - 1];
1599 b.push(0xC3);
1600 assert_eq!(kind(&b, "x.txt"), FileKind::Text);
1601 }
1602
1603 #[test]
1604 fn detect_kind_reads_from_disk() {
1605 let t = T::new();
1606 assert_eq!(detect_kind(&t.root.join("docs"), true), FileKind::Dir);
1607 assert_eq!(detect_kind(&t.root.join("file.txt"), false), FileKind::Text);
1608 // Unreadable / missing → Binary, never a failed listing.
1609 assert_eq!(detect_kind(&t.root.join("nope"), false), FileKind::Binary);
1610 }
1611
1612 /// `SNIFF_BUDGET` is process-wide, so the two tests that assert on it must
1613 /// not run at the same time as each other.
1614 static BUDGET_TESTS: std::sync::Mutex<()> = std::sync::Mutex::new(());
1615
1616 /// A directory big enough to take the fan-out path must produce exactly
1617 /// what the serial path would. Pass 2 fills `is_dir`, `size`, `mtime` and
1618 /// `kind`, all on worker threads, so a chunk-boundary mistake would show up
1619 /// as a row carrying another row's metadata or the untouched placeholders.
1620 #[test]
1621 fn parallel_rows_match_serial() {
1622 use std::sync::atomic::Ordering;
1623 let _guard = BUDGET_TESTS.lock().unwrap();
1624 let t = T::new();
1625 let big = t.root.join("big");
1626 std::fs::create_dir_all(&big).unwrap();
1627 // Well over SNIFF_CHUNK, so several chunks are handed out.
1628 let n = SNIFF_CHUNK * 3 + 7;
1629 for i in 0..n {
1630 let p = big.join(format!("f{i:05}"));
1631 // Every file gets a distinct length, so `size` pins the row identity.
1632 let pad = vec![b'A'; i];
1633 let mut body = match i % 3 {
1634 0 => vec![0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A],
1635 1 => b"plain text\n".to_vec(),
1636 _ => vec![0u8, 1, 2, 3],
1637 };
1638 body.extend_from_slice(&pad);
1639 std::fs::write(&p, &body).unwrap();
1640 }
1641 std::fs::create_dir(big.join("a-subdir")).unwrap();
1642
1643 let entries = list_dir(&big, ListOpts::default()).unwrap().entries;
1644 assert_eq!(entries.len(), n + 1);
1645
1646 // Every row must agree with what a single-threaded read of that same
1647 // file reports: kind, size and directory flag.
1648 for e in &entries {
1649 let p = big.join(&e.name);
1650 let m = std::fs::metadata(&p).unwrap();
1651 assert_eq!(e.is_dir, m.is_dir(), "{}", e.name);
1652 assert_eq!(e.size, m.len(), "size mismatch on {}", e.name);
1653 assert_eq!(e.mtime, mtime_str(&m), "mtime mismatch on {}", e.name);
1654 assert_eq!(
1655 e.kind,
1656 detect_kind(&p, e.is_dir),
1657 "kind mismatch on {}",
1658 e.name
1659 );
1660 }
1661
1662 // Sanity: several kinds are actually present, so the loop above is not
1663 // trivially true, and the directory sorts first.
1664 let kinds: Vec<FileKind> = entries.iter().map(|e| e.kind).collect();
1665 assert!(kinds.contains(&FileKind::Image));
1666 assert!(kinds.contains(&FileKind::Text));
1667 assert!(kinds.contains(&FileKind::Binary));
1668 assert_eq!(entries[0].name, "a-subdir");
1669 assert_eq!(entries[0].kind, FileKind::Dir);
1670
1671 // The thread budget is fully returned once the listing is done.
1672 assert_eq!(SNIFF_BUDGET.load(Ordering::Acquire), 0);
1673 }
1674
1675 #[test]
1676 fn sniff_permit_never_exceeds_the_budget() {
1677 use std::sync::atomic::Ordering;
1678 let _guard = BUDGET_TESTS.lock().unwrap();
1679 let total = sniff_budget_total();
1680 let a = SniffPermit::claim(total * 2);
1681 assert_eq!(a.0, total, "a single claim is capped at the total");
1682 // Nothing left: the next listing runs serially rather than queueing.
1683 let b = SniffPermit::claim(4);
1684 assert_eq!(b.0, 0);
1685 drop(a);
1686 drop(b);
1687 assert_eq!(SNIFF_BUDGET.load(Ordering::Acquire), 0);
1688 }
1689
1690 #[test]
1691 fn list_dir_reports_kinds() {
1692 let t = T::new();
1693 std::fs::write(
1694 t.root.join("docs/pic.dat"),
1695 [0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A],
1696 )
1697 .unwrap();
1698 let entries = list_dir(&t.root.join("docs"), ListOpts::default())
1699 .unwrap()
1700 .entries;
1701 let kind_of = |n: &str| entries.iter().find(|e| e.name == n).unwrap().kind;
1702 assert_eq!(kind_of("inner"), FileKind::Dir);
1703 assert_eq!(kind_of("a.txt"), FileKind::Text);
1704 assert_eq!(kind_of("pic.dat"), FileKind::Image);
1705 }
1706}
1707