mbr-core 0.7.0

Render core of mbr, the markdown browser: markdown, templates, config, repository scan, search and indexes. Internal crate of mbr-markdown-browser, versioned in lockstep with it; no API stability guarantees
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
//! The storage seam: every repository-shaped read and write in the core goes
//! through a [`Vault`].
//!
//! mbr used to assume `std::fs` everywhere. That is right for the desktop and
//! wrong for everything else mbr is meant to run on: an iOS file provider can
//! hand back *dataless* files that block on `open()` while they download and
//! wants coordinated writes; Android's storage access framework has no paths at
//! all (`content://` tree URIs); an encrypted sync library is a linked API, not
//! a directory; and tests want a repository that lives in memory. The trait is
//! the one place those differences are allowed to live.
//!
//! # Rules
//!
//! - **No platform `cfg` in core.** No `#[cfg(target_os = "ios"/"android")]`
//!   anywhere in this crate. Platform behaviour is *injected* by implementing
//!   [`Vault`] in the platform crate (`mbr-ffi`), never by branching here.
//! - **Bulk-first.** Listing is [`Vault::list_dir`] — one call per directory,
//!   returning full [`Entry`]s (kind, size, times, availability) — so a
//!   foreign implementation is crossed once per directory, never once per
//!   file, and the desktop pays one `stat` per entry (fewer than the scanner
//!   paid before).
//! - **`local_path` fast path.** Where a real path exists, [`Vault::local_path`]
//!   returns it and hot consumers use it directly: grep's `search_path`,
//!   `ServeFile`, ffmpeg/pdfium probes, and the task scanner's buffer-reusing
//!   read. Every such consumer has a byte-reading fallback for when it is
//!   `None`, which is what the [`MemVault`]-backed tests exercise.
//! - **Containment at the boundary.** A [`VaultPath`] cannot name anything
//!   outside the vault lexically. Symlinks are resolved where untrusted input
//!   enters — [`Vault::canonicalize`] and [`resolve_under`] — exactly where the
//!   desktop always canonicalized, not on every read.
//!
//! # Keys
//!
//! The repository index ([`crate::repo::Repo`]) is still keyed by absolute
//! `PathBuf`s, as it was before this seam existed; for a vault those keys are
//! [`Vault::key`] — the vault path under [`Vault::root`]. For [`LocalVault`]
//! that is the canonical on-disk path, identical to the old keys; for any other
//! vault it is an opaque identifier that must only be turned back into a
//! [`VaultPath`] ([`Vault::vault_path`]), never opened.

mod local;
mod mem;
mod mount;
mod path;

use std::fmt;
use std::io;
use std::path::{Path, PathBuf};
use std::time::SystemTime;

pub use local::{
    AvailabilityProbe, LocalVault, atomic_write, create_unique_temp_file, read_prefix_native,
    read_range_native,
};
pub use mem::MemVault;
pub use mount::{Mount, MountPolicy};
pub use path::{VaultPath, VaultPathError};

/// What an [`Entry`] is. Symlinks are followed, as the desktop scanner always
/// followed them, so this is the kind of the link's *target*.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub enum EntryKind {
    File,
    Dir,
}

/// Whether a file's bytes are on this device.
///
/// A file provider can list a file whose content is still in the cloud: iOS
/// "dataless" files report their real size from `stat`, but reading them
/// blocks (or, with the scanner's I/O policy, fails fast) until they download.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
pub enum Availability {
    /// The bytes are local; reading will not wait on the network.
    #[default]
    Local,
    /// Listed, but the content has to be downloaded first. Reads return
    /// [`VaultError::NotDownloaded`]; [`Vault::request_download`] asks for it.
    NotDownloaded,
    /// The vault cannot tell cheaply.
    Unknown,
}

/// One file or directory, as listed or stat'ed.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Entry {
    pub path: VaultPath,
    pub kind: EntryKind,
    /// Size in bytes (0 for directories on most backends).
    pub size: u64,
    /// Last modification time. [`SystemTime::UNIX_EPOCH`] when the backend
    /// cannot report one.
    pub modified: SystemTime,
    /// Creation (birth) time, where the backend has one. Many Linux
    /// filesystems and network mounts do not.
    pub created: Option<SystemTime>,
    pub availability: Availability,
}

impl Entry {
    pub fn is_dir(&self) -> bool {
        self.kind == EntryKind::Dir
    }

    pub fn is_file(&self) -> bool {
        self.kind == EntryKind::File
    }

    /// The entry's own name (last path segment); empty for the root.
    pub fn name(&self) -> &str {
        self.path.file_name().unwrap_or("")
    }

    /// `(created, modified)` in whole seconds since the Unix epoch, the shape
    /// the repository index stores.
    ///
    /// `None` when the modification time predates the epoch, which the scanner
    /// has always treated as "cannot process this file". A missing creation
    /// time falls back to the modification time, so a file is never dropped
    /// from listings just because its filesystem has no birth time.
    pub fn epoch_secs(&self) -> Option<(u64, u64)> {
        let secs = |t: SystemTime| {
            t.duration_since(SystemTime::UNIX_EPOCH)
                .ok()
                .map(|d| d.as_secs())
        };
        let modified = secs(self.modified)?;
        let created = self.created.and_then(secs).unwrap_or(modified);
        Some((created, modified))
    }
}

/// Storage failures, classified so callers can map them without inspecting
/// `io::ErrorKind`s themselves.
#[derive(Debug, thiserror::Error)]
pub enum VaultError {
    #[error("not found: {path}")]
    NotFound { path: String },

    /// The file is listed but its content is not on this device (see
    /// [`Availability::NotDownloaded`]).
    #[error("not downloaded: {path}")]
    NotDownloaded { path: String },

    #[error("permission denied: {path}")]
    PermissionDenied { path: String },

    /// The path resolves (through a symlink) to somewhere outside the vault.
    /// `path` names the resolved target.
    #[error("outside the vault root: {path}")]
    OutsideRoot { path: String },

    /// The path is inside a read-only part of the vault — a symlink mount
    /// ([`Vault::is_read_only`]) — and the operation would write there.
    #[error("read-only (an external folder mounted through a symlink): {path}")]
    ReadOnly { path: String },

    #[error(transparent)]
    InvalidPath(#[from] VaultPathError),

    #[error("{path}: {source}")]
    Io {
        path: String,
        #[source]
        source: io::Error,
    },
}

impl VaultError {
    /// Classifies an `io::Error` raised for `path`.
    ///
    /// `ErrorKind::Deadlock` is `EDEADLK`, which is what a read of a dataless
    /// file returns on Apple platforms when the reading thread has opted out of
    /// materializing it — so it means "not downloaded" wherever it occurs. The
    /// mapping needs no platform `cfg`: no other read returns it.
    pub fn from_io(path: impl fmt::Display, source: io::Error) -> Self {
        let path = path.to_string();
        match source.kind() {
            io::ErrorKind::NotFound => Self::NotFound { path },
            io::ErrorKind::PermissionDenied => Self::PermissionDenied { path },
            io::ErrorKind::Deadlock => Self::NotDownloaded { path },
            _ => Self::Io { path, source },
        }
    }

    pub fn is_not_found(&self) -> bool {
        matches!(self, Self::NotFound { .. })
    }

    pub fn not_found(path: impl fmt::Display) -> Self {
        Self::NotFound {
            path: path.to_string(),
        }
    }
}

/// Lossy but kind-preserving: handlers that answer with an HTTP status derived
/// from `io::ErrorKind` (404 for `NotFound`, 403 for `PermissionDenied`) keep
/// answering the same status.
impl From<VaultError> for io::Error {
    fn from(error: VaultError) -> Self {
        let kind = match &error {
            VaultError::NotFound { .. } => io::ErrorKind::NotFound,
            VaultError::PermissionDenied { .. }
            | VaultError::OutsideRoot { .. }
            | VaultError::ReadOnly { .. } => io::ErrorKind::PermissionDenied,
            VaultError::NotDownloaded { .. } => io::ErrorKind::WouldBlock,
            VaultError::InvalidPath(_) => io::ErrorKind::InvalidInput,
            VaultError::Io { .. } => {
                let VaultError::Io { source, .. } = error else {
                    unreachable!()
                };
                return source;
            }
        };
        io::Error::new(kind, error)
    }
}

/// A repository's storage.
///
/// See the [module docs](self) for the rules every implementation and caller
/// follows. Methods are synchronous: the desktop implementation is blocking
/// `std::fs`, and async callers already move repository I/O to
/// `spawn_blocking` (or run it on rayon), which is where a foreign
/// implementation's blocking calls belong too.
pub trait Vault: Send + Sync + fmt::Debug {
    /// The namespace [`Vault::key`]s live under. For [`LocalVault`], the
    /// canonical repository root; for any other vault, an identifier that is
    /// never opened.
    fn root(&self) -> &Path;

    /// The entry at `path` (following symlinks), or `None` when nothing is
    /// there.
    fn stat(&self, path: &VaultPath) -> Result<Option<Entry>, VaultError>;

    /// The immediate children of directory `dir`, in no particular order.
    ///
    /// Children that cannot be described are skipped rather than failing the
    /// listing: a broken symlink, a name that is not valid UTF-8 or not a valid
    /// [`VaultPath`] segment. That is what the scanner's old `WalkDir` loop did
    /// (`filter_map(Result::ok)`).
    fn list_dir(&self, dir: &VaultPath) -> Result<Vec<Entry>, VaultError>;

    /// Resolves symlinks in `path` and returns where it really is inside the
    /// vault.
    ///
    /// [`VaultError::NotFound`] when it does not exist;
    /// [`VaultError::OutsideRoot`] when it resolves outside the vault. A vault
    /// without links returns `path` itself for anything that exists.
    fn canonicalize(&self, path: &VaultPath) -> Result<VaultPath, VaultError>;

    /// The whole file.
    fn read(&self, path: &VaultPath) -> Result<Vec<u8>, VaultError>;

    /// At most the first `max_len` bytes (frontmatter extraction reads 8 KB).
    fn read_prefix(&self, path: &VaultPath, max_len: usize) -> Result<Vec<u8>, VaultError>;

    /// At most `len` bytes starting at byte `offset`: fewer at the end of the
    /// file, none past it. The streaming and `Range` path for files a vault
    /// has no [`Vault::local_path`] for (`mbr_server` serves those in chunks).
    ///
    /// The default reads the whole file and slices it, which is right for an
    /// in-memory vault and wrong for anything large; a vault with real
    /// random access overrides it.
    fn read_range(&self, path: &VaultPath, offset: u64, len: usize) -> Result<Vec<u8>, VaultError> {
        let bytes = self.read(path)?;
        let start = usize::try_from(offset)
            .unwrap_or(usize::MAX)
            .min(bytes.len());
        let end = start.saturating_add(len).min(bytes.len());
        Ok(bytes[start..end].to_vec())
    }

    /// Whether the file at `path` is on this device, decided without reading
    /// it — the check a server makes before handing a file to a reader that
    /// would block on (or fail half-way through) a not-downloaded one.
    ///
    /// The default answers [`Availability::Local`] and does no I/O, which is
    /// the truth for any vault whose files are always local (the desktop's
    /// [`LocalVault`]). [`Availability::Unknown`] means the vault could not
    /// tell (the file vanished, say); callers treat it as "try the read".
    fn availability(&self, _path: &VaultPath) -> Availability {
        Availability::Local
    }

    /// Replaces (or creates) `path` with `bytes` atomically: readers see the
    /// old content or the new, never a mix, and a failure leaves the old file.
    /// The parent directory must exist. An existing file's permissions are
    /// kept.
    ///
    /// Does not serialize writers; callers that read before writing hold a
    /// per-file lock across both (`mbr_server::file_write::FileWriteLocks`).
    fn write_atomic(&self, path: &VaultPath, bytes: &[u8]) -> Result<(), VaultError>;

    /// Creates `path` and any missing ancestors as directories.
    fn create_dir_all(&self, path: &VaultPath) -> Result<(), VaultError>;

    /// Removes the file at `path`.
    fn remove_file(&self, path: &VaultPath) -> Result<(), VaultError>;

    /// Renames `from` to `to`, replacing a file at `to`.
    fn rename(&self, from: &VaultPath, to: &VaultPath) -> Result<(), VaultError>;

    /// Asks for not-downloaded files to be fetched. Returns immediately;
    /// completion shows up as the files becoming readable. A no-op for vaults
    /// whose files are always local.
    fn request_download(&self, _paths: &[VaultPath]) {}

    /// A real filesystem path for `path`, when one exists — the fast path for
    /// consumers that need one (`ServeFile`, grep's `search_path`, ffmpeg,
    /// pdfium, mmap). `None` means "read it through the vault".
    fn local_path(&self, _path: &VaultPath) -> Option<PathBuf> {
        None
    }

    /// The whole file as UTF-8. Invalid UTF-8 is an [`VaultError::Io`] with
    /// `ErrorKind::InvalidData`, as `std::fs::read_to_string` reports it.
    fn read_to_string(&self, path: &VaultPath) -> Result<String, VaultError> {
        let bytes = self.read(path)?;
        String::from_utf8(bytes).map_err(|e| VaultError::Io {
            path: path.to_string(),
            source: io::Error::new(io::ErrorKind::InvalidData, e),
        })
    }

    /// The index key for `path`: `path` under [`Vault::root`].
    fn key(&self, path: &VaultPath) -> PathBuf {
        path.to_native(self.root())
    }

    /// The vault path for an index key — the inverse of [`Vault::key`].
    /// Lexical: it does not resolve symlinks.
    fn vault_path(&self, key: &Path) -> Result<VaultPath, VaultError> {
        VaultPath::from_absolute(self.root(), key).map_err(|e| match e {
            VaultPathError::NotUnderRoot { path, .. } => VaultError::OutsideRoot {
                path: path.display().to_string(),
            },
            other => VaultError::InvalidPath(other),
        })
    }

    /// Whether `path` is an existing file. Errors read as `false`, as
    /// `Path::is_file` does.
    fn is_file(&self, path: &VaultPath) -> bool {
        matches!(self.stat(path), Ok(Some(entry)) if entry.is_file())
    }

    /// Whether `path` is an existing directory. Errors read as `false`.
    fn is_dir(&self, path: &VaultPath) -> bool {
        matches!(self.stat(path), Ok(Some(entry)) if entry.is_dir())
    }

    /// Whether `path` lies in a read-only part of the vault — a symlink
    /// mount's location or anything under it. Lexical: callers pass resolved
    /// (canonical) paths, and the vault's own write methods re-check the
    /// canonical form. `false` for vaults without mounts.
    fn is_read_only(&self, _path: &VaultPath) -> bool {
        false
    }

    /// The symlink mounts accepted so far (see [`Mount`]). They are discovered
    /// as paths through them are first resolved, so the list grows during the
    /// first scan. Empty for vaults without mounts.
    fn mounts(&self) -> Vec<Mount> {
        Vec::new()
    }
}

/// Whether a path segment is **hidden** for serving: it starts with a dot.
///
/// The one exception is `.well-known` (RFC 8615): the registered prefix for
/// site metadata — `security.txt`, ACME challenges, app-site associations —
/// which a static site has to be able to publish. It holds no secrets by
/// definition, unlike `.git`, `.env` or `.ssh`.
pub fn is_hidden_segment(segment: &str) -> bool {
    segment.starts_with('.') && segment != ".well-known"
}

/// Whether `path` has a hidden segment ([`is_hidden_segment`]) that is not
/// covered by `exempt` — the hidden directories the user named on the command
/// line (`Config::explicit_hidden_dirs`, as vault paths). A hidden segment is
/// exempt when the path up to and including it is one of them, so naming
/// `.scratch` admits `.scratch/a.md` but not `.scratch/.git/config`.
///
/// Hidden files are never served: they are where repositories keep
/// credentials and tooling state (`.env`, `.git/config`, `.mbr/config.toml`
/// with its token hash). Allocation-free.
pub fn has_hidden_segment(path: &VaultPath, exempt: &[VaultPath]) -> bool {
    let full = path.as_str();
    let mut end = 0;
    path.segments().any(|segment| {
        end += segment.len() + usize::from(end != 0);
        is_hidden_segment(segment) && !exempt.iter().any(|dir| dir.as_str() == &full[..end])
    })
}

/// Places a configured folder (`static_folder`) inside `vault`, or `None`
/// when it lands nowhere in it.
///
/// The usual value is a plain relative path (`static`, `./assets/static`),
/// which is parsed lexically and needs no storage access. An absolute or
/// `..`-climbing spelling that still lands inside the root (`/abs/root/static`,
/// `../root/static`) can only be placed by resolving it on disk, so that half
/// needs a [`Vault::local_path`] — exactly as the old
/// `canonical_root.join(static_folder)` did. A value resolving *outside* the
/// root is an external overlay, which is a vault of its own and not this
/// function's business.
pub fn configured_folder(vault: &dyn Vault, configured: &str) -> Option<VaultPath> {
    VaultPath::new(configured).ok().or_else(|| {
        let local_root = vault.local_path(&VaultPath::root())?;
        let resolved = crate::config::resolve_existing_or_lexical(&local_root.join(configured));
        vault.vault_path(&resolved).ok()
    })
}

/// Resolves `relative` beneath `base`, refusing anything that ends up outside
/// `base` once symlinks are followed.
///
/// This is the vault form of the resolver's old `safe_join`, with the same
/// answers:
///
/// - `base` is canonicalized first; a `base` that does not exist resolves
///   nothing. The vault root is taken as canonical without asking.
/// - When `base/relative` exists, the answer is its **canonical** path, and
///   only if that is still under the canonical `base`. A final component that
///   is a symlink out of `base` is refused here rather than falling through to
///   the "does not exist yet" branch — that fall-through would validate only
///   the parent and hand back the unresolved link.
/// - When it does not exist (probing `foo.md` for `/foo/`), its parent must
///   canonicalize under `base`, and the answer is the canonical parent joined
///   with the requested name.
///
/// `relative` is already lexically normalized (it is a [`VaultPath`]), which is
/// the one difference from `safe_join`: a request's `..` is resolved by
/// position, as a browser resolves it, before any symlink is followed.
pub fn resolve_under(
    vault: &dyn Vault,
    base: &VaultPath,
    relative: &VaultPath,
) -> Option<VaultPath> {
    // The root is canonical by construction (nothing a `VaultPath` can name is
    // above it), so the common case costs no `canonicalize` for the base —
    // the resolver used to cache the canonical root for the same reason.
    let canonical_base = if base.is_root() {
        VaultPath::root()
    } else {
        vault.canonicalize(base).ok()?
    };
    let candidate = canonical_base.join_path(relative);
    match vault.canonicalize(&candidate) {
        Ok(canonical) => canonical.starts_with(&canonical_base).then_some(canonical),
        Err(VaultError::OutsideRoot { .. }) => None,
        Err(_) => {
            let name = candidate.file_name()?;
            let canonical_parent = vault.canonicalize(&candidate.parent()?).ok()?;
            if !canonical_parent.starts_with(&canonical_base) {
                return None;
            }
            canonical_parent.child(name).ok()
        }
    }
}

/// Every file in the vault, recursively, for a repository-wide pass that is
/// not the scanner (backlink grep, link rewriting on a move).
///
/// Follows symlinks the way the scanner does: each directory is listed once,
/// under its **canonical** path, so one reached twice (a link to a folder that
/// is also walked directly) contributes its files once, and a link cycle ends;
/// a directory that resolves outside the vault is not entered at all — so a
/// repository-wide *write* can never land outside the repository through a
/// link. `enter` prunes directories (it sees each directory's entry before it
/// is listed); every file below an entered directory is returned, with its
/// path under the canonical directory.
///
/// Sequential and one [`Vault::list_dir`] per directory. Directories that
/// cannot be listed are skipped, as `WalkDir`'s error entries were.
pub fn walk_files(vault: &dyn Vault, enter: impl Fn(&Entry) -> bool) -> Vec<Entry> {
    walk_files_under(vault, &VaultPath::root(), enter)
}

/// [`walk_files`] from `base` rather than the root: every file below it, each
/// directory once by canonical path, nothing outside the vault. A directory
/// below `base` that canonicalizes elsewhere in the vault is walked under its
/// canonical path, as [`walk_files`] walks it.
pub fn walk_files_under(
    vault: &dyn Vault,
    base: &VaultPath,
    enter: impl Fn(&Entry) -> bool,
) -> Vec<Entry> {
    walk_vault_under(vault, base, enter).files
}

/// What [`walk_vault_under`] found.
#[derive(Debug, Default)]
pub struct VaultWalk {
    /// Every file below the base, as [`walk_files_under`] returns them.
    pub files: Vec<Entry>,
    /// Directories whose listing failed with [`VaultError::NotDownloaded`],
    /// by canonical vault path: a file provider knows they exist but has not
    /// fetched what is in them (an iCloud Drive folder on iOS stays
    /// *dataless* until something lists it on a thread allowed to wait). Their
    /// files are missing from `files`, not deleted.
    pub not_downloaded_dirs: Vec<VaultPath>,
    /// How many directories were listed, for diagnostics.
    pub dirs_listed: usize,
}

/// [`walk_files_under`], also reporting the directories that could not be
/// listed because their listing is not on the device yet.
pub fn walk_vault_under(
    vault: &dyn Vault,
    base: &VaultPath,
    enter: impl Fn(&Entry) -> bool,
) -> VaultWalk {
    let mut walk = VaultWalk::default();
    let mut visited = std::collections::HashSet::new();
    let mut pending = vec![base.clone()];
    while let Some(dir) = pending.pop() {
        let Ok(canonical) = vault.canonicalize(&dir) else {
            continue;
        };
        if !visited.insert(canonical.clone()) {
            continue;
        }
        let entries = match vault.list_dir(&canonical) {
            Ok(entries) => {
                walk.dirs_listed += 1;
                entries
            }
            Err(VaultError::NotDownloaded { .. }) => {
                walk.not_downloaded_dirs.push(canonical);
                continue;
            }
            Err(_) => continue,
        };
        for entry in entries {
            if entry.is_dir() {
                if enter(&entry) {
                    pending.push(entry.path);
                }
            } else {
                walk.files.push(entry);
            }
        }
    }
    walk
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn io_errors_are_classified() {
        let nf = VaultError::from_io("a.md", io::Error::from(io::ErrorKind::NotFound));
        assert!(nf.is_not_found());
        assert!(matches!(
            VaultError::from_io("a.md", io::Error::from(io::ErrorKind::PermissionDenied)),
            VaultError::PermissionDenied { .. }
        ));
        assert!(matches!(
            VaultError::from_io("a.md", io::Error::from(io::ErrorKind::Deadlock)),
            VaultError::NotDownloaded { .. }
        ));
        assert!(matches!(
            VaultError::from_io("a.md", io::Error::other("boom")),
            VaultError::Io { .. }
        ));
    }

    #[test]
    fn vault_errors_keep_their_io_kind() {
        let kind = |e: VaultError| io::Error::from(e).kind();
        assert_eq!(kind(VaultError::not_found("x")), io::ErrorKind::NotFound);
        assert_eq!(
            kind(VaultError::PermissionDenied { path: "x".into() }),
            io::ErrorKind::PermissionDenied
        );
        assert_eq!(
            kind(VaultError::OutsideRoot { path: "x".into() }),
            io::ErrorKind::PermissionDenied
        );
        let original = io::Error::new(io::ErrorKind::StorageFull, "disk full");
        assert_eq!(
            kind(VaultError::from_io("x", original)),
            io::ErrorKind::StorageFull
        );
    }

    #[test]
    fn epoch_secs_falls_back_to_modified_and_refuses_pre_epoch() {
        let at = |secs| SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(secs);
        let entry = Entry {
            path: VaultPath::new("a.md").unwrap(),
            kind: EntryKind::File,
            size: 1,
            modified: at(200),
            created: None,
            availability: Availability::Local,
        };
        assert_eq!(entry.epoch_secs(), Some((200, 200)));
        let with_created = Entry {
            created: Some(at(100)),
            ..entry.clone()
        };
        assert_eq!(with_created.epoch_secs(), Some((100, 200)));
        let pre_epoch = Entry {
            modified: SystemTime::UNIX_EPOCH - std::time::Duration::from_secs(1),
            ..entry
        };
        assert_eq!(pre_epoch.epoch_secs(), None);
    }

    #[test]
    fn walk_files_lists_every_file_and_prunes_directories() {
        let vault = MemVault::new()
            .with_file("a.md", "a")
            .with_file("docs/b.md", "b")
            .with_file("docs/deep/c.md", "c")
            .with_file("node_modules/x.md", "x")
            .with_dir("empty");
        let mut found: Vec<String> = walk_files(&vault, |dir| dir.name() != "node_modules")
            .into_iter()
            .map(|e| e.path.to_string())
            .collect();
        found.sort();
        assert_eq!(found, vec!["a.md", "docs/b.md", "docs/deep/c.md"]);
    }

    #[cfg(unix)]
    #[test]
    fn walk_files_follows_links_inside_once_and_never_leaves_the_vault() {
        use std::os::unix::fs::symlink;
        let dir = tempfile::tempdir().unwrap();
        let outside = tempfile::tempdir().unwrap();
        std::fs::create_dir(dir.path().join("real")).unwrap();
        std::fs::write(dir.path().join("real/note.md"), "n").unwrap();
        std::fs::write(outside.path().join("secret.md"), "s").unwrap();
        // A second route to `real`, a cycle, and a link out of the vault.
        symlink(dir.path().join("real"), dir.path().join("alias")).unwrap();
        symlink(dir.path(), dir.path().join("real/loop")).unwrap();
        symlink(outside.path(), dir.path().join("leak")).unwrap();
        let vault = LocalVault::new(dir.path());
        let found: Vec<String> = walk_files(&vault, |_| true)
            .into_iter()
            .map(|e| e.path.to_string())
            .collect();
        assert_eq!(
            found,
            vec!["real/note.md"],
            "listed once, canonically, nothing outside"
        );
    }

    #[test]
    fn hidden_segments_and_their_exemptions() {
        let p = |s: &str| VaultPath::new(s).unwrap();
        assert!(!has_hidden_segment(&VaultPath::root(), &[]));
        assert!(!has_hidden_segment(&p("docs/a.md"), &[]));
        assert!(!has_hidden_segment(&p("docs/a.b.md"), &[]));
        assert!(has_hidden_segment(&p(".env"), &[]));
        assert!(has_hidden_segment(&p(".git/config"), &[]));
        assert!(has_hidden_segment(&p("docs/.private/a.md"), &[]));
        assert!(has_hidden_segment(&p("docs/.hidden.png"), &[]));
        assert!(!has_hidden_segment(&p(".well-known/security.txt"), &[]));
        assert!(has_hidden_segment(&p(".well-known/.secret"), &[]));
        // Exempt: exactly the named chain.
        let exempt = [p(".scratch"), p(".scratch/b/.deep")];
        assert!(!has_hidden_segment(&p(".scratch/a.md"), &exempt));
        assert!(!has_hidden_segment(&p(".scratch/b/.deep/c.md"), &exempt));
        assert!(has_hidden_segment(&p(".scratch/.git/config"), &exempt));
        assert!(has_hidden_segment(&p("x/.scratch/a.md"), &exempt));
        assert!(has_hidden_segment(&p(".scratchy/a.md"), &exempt));
    }

    #[test]
    fn resolve_under_on_a_mem_vault() {
        let vault = MemVault::new()
            .with_file("docs/a.md", "# A")
            .with_file("static/img.png", "png");
        let root = VaultPath::root();
        let p = |s: &str| VaultPath::new(s).unwrap();
        assert_eq!(
            resolve_under(&vault, &root, &p("docs/a.md")),
            Some(p("docs/a.md"))
        );
        // Missing file under an existing parent: the probe answer.
        assert_eq!(
            resolve_under(&vault, &root, &p("docs/b.md")),
            Some(p("docs/b.md"))
        );
        // Missing parent: nothing.
        assert_eq!(resolve_under(&vault, &root, &p("nope/b.md")), None);
        // Relative to a sub-base.
        assert_eq!(
            resolve_under(&vault, &p("static"), &p("img.png")),
            Some(p("static/img.png"))
        );
        // A base that does not exist resolves nothing.
        assert_eq!(resolve_under(&vault, &p("missing"), &p("img.png")), None);
    }
}