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