mbr_core/vault/mod.rs
1//! The storage seam: every repository-shaped read and write in the core goes
2//! through a [`Vault`].
3//!
4//! mbr used to assume `std::fs` everywhere. That is right for the desktop and
5//! wrong for everything else mbr is meant to run on: an iOS file provider can
6//! hand back *dataless* files that block on `open()` while they download and
7//! wants coordinated writes; Android's storage access framework has no paths at
8//! all (`content://` tree URIs); an encrypted sync library is a linked API, not
9//! a directory; and tests want a repository that lives in memory. The trait is
10//! the one place those differences are allowed to live.
11//!
12//! # Rules
13//!
14//! - **No platform `cfg` in core.** No `#[cfg(target_os = "ios"/"android")]`
15//! anywhere in this crate. Platform behaviour is *injected* by implementing
16//! [`Vault`] in the platform crate (`mbr-ffi`), never by branching here.
17//! - **Bulk-first.** Listing is [`Vault::list_dir`] — one call per directory,
18//! returning full [`Entry`]s (kind, size, times, availability) — so a
19//! foreign implementation is crossed once per directory, never once per
20//! file, and the desktop pays one `stat` per entry (fewer than the scanner
21//! paid before).
22//! - **`local_path` fast path.** Where a real path exists, [`Vault::local_path`]
23//! returns it and hot consumers use it directly: grep's `search_path`,
24//! `ServeFile`, ffmpeg/pdfium probes, and the task scanner's buffer-reusing
25//! read. Every such consumer has a byte-reading fallback for when it is
26//! `None`, which is what the [`MemVault`]-backed tests exercise.
27//! - **Containment at the boundary.** A [`VaultPath`] cannot name anything
28//! outside the vault lexically. Symlinks are resolved where untrusted input
29//! enters — [`Vault::canonicalize`] and [`resolve_under`] — exactly where the
30//! desktop always canonicalized, not on every read.
31//!
32//! # Keys
33//!
34//! The repository index ([`crate::repo::Repo`]) is still keyed by absolute
35//! `PathBuf`s, as it was before this seam existed; for a vault those keys are
36//! [`Vault::key`] — the vault path under [`Vault::root`]. For [`LocalVault`]
37//! that is the canonical on-disk path, identical to the old keys; for any other
38//! vault it is an opaque identifier that must only be turned back into a
39//! [`VaultPath`] ([`Vault::vault_path`]), never opened.
40
41mod local;
42mod mem;
43mod mount;
44mod path;
45
46use std::fmt;
47use std::io;
48use std::path::{Path, PathBuf};
49use std::time::SystemTime;
50
51pub use local::{
52 AvailabilityProbe, LocalVault, atomic_write, create_unique_temp_file, read_prefix_native,
53 read_range_native,
54};
55pub use mem::MemVault;
56pub use mount::{Mount, MountPolicy};
57pub use path::{VaultPath, VaultPathError};
58
59/// What an [`Entry`] is. Symlinks are followed, as the desktop scanner always
60/// followed them, so this is the kind of the link's *target*.
61#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
62pub enum EntryKind {
63 File,
64 Dir,
65}
66
67/// Whether a file's bytes are on this device.
68///
69/// A file provider can list a file whose content is still in the cloud: iOS
70/// "dataless" files report their real size from `stat`, but reading them
71/// blocks (or, with the scanner's I/O policy, fails fast) until they download.
72#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
73pub enum Availability {
74 /// The bytes are local; reading will not wait on the network.
75 #[default]
76 Local,
77 /// Listed, but the content has to be downloaded first. Reads return
78 /// [`VaultError::NotDownloaded`]; [`Vault::request_download`] asks for it.
79 NotDownloaded,
80 /// The vault cannot tell cheaply.
81 Unknown,
82}
83
84/// One file or directory, as listed or stat'ed.
85#[derive(Debug, Clone, PartialEq, Eq)]
86pub struct Entry {
87 pub path: VaultPath,
88 pub kind: EntryKind,
89 /// Size in bytes (0 for directories on most backends).
90 pub size: u64,
91 /// Last modification time. [`SystemTime::UNIX_EPOCH`] when the backend
92 /// cannot report one.
93 pub modified: SystemTime,
94 /// Creation (birth) time, where the backend has one. Many Linux
95 /// filesystems and network mounts do not.
96 pub created: Option<SystemTime>,
97 pub availability: Availability,
98}
99
100impl Entry {
101 pub fn is_dir(&self) -> bool {
102 self.kind == EntryKind::Dir
103 }
104
105 pub fn is_file(&self) -> bool {
106 self.kind == EntryKind::File
107 }
108
109 /// The entry's own name (last path segment); empty for the root.
110 pub fn name(&self) -> &str {
111 self.path.file_name().unwrap_or("")
112 }
113
114 /// `(created, modified)` in whole seconds since the Unix epoch, the shape
115 /// the repository index stores.
116 ///
117 /// `None` when the modification time predates the epoch, which the scanner
118 /// has always treated as "cannot process this file". A missing creation
119 /// time falls back to the modification time, so a file is never dropped
120 /// from listings just because its filesystem has no birth time.
121 pub fn epoch_secs(&self) -> Option<(u64, u64)> {
122 let secs = |t: SystemTime| {
123 t.duration_since(SystemTime::UNIX_EPOCH)
124 .ok()
125 .map(|d| d.as_secs())
126 };
127 let modified = secs(self.modified)?;
128 let created = self.created.and_then(secs).unwrap_or(modified);
129 Some((created, modified))
130 }
131}
132
133/// Storage failures, classified so callers can map them without inspecting
134/// `io::ErrorKind`s themselves.
135#[derive(Debug, thiserror::Error)]
136pub enum VaultError {
137 #[error("not found: {path}")]
138 NotFound { path: String },
139
140 /// The file is listed but its content is not on this device (see
141 /// [`Availability::NotDownloaded`]).
142 #[error("not downloaded: {path}")]
143 NotDownloaded { path: String },
144
145 #[error("permission denied: {path}")]
146 PermissionDenied { path: String },
147
148 /// The path resolves (through a symlink) to somewhere outside the vault.
149 /// `path` names the resolved target.
150 #[error("outside the vault root: {path}")]
151 OutsideRoot { path: String },
152
153 /// The path is inside a read-only part of the vault — a symlink mount
154 /// ([`Vault::is_read_only`]) — and the operation would write there.
155 #[error("read-only (an external folder mounted through a symlink): {path}")]
156 ReadOnly { path: String },
157
158 #[error(transparent)]
159 InvalidPath(#[from] VaultPathError),
160
161 #[error("{path}: {source}")]
162 Io {
163 path: String,
164 #[source]
165 source: io::Error,
166 },
167}
168
169impl VaultError {
170 /// Classifies an `io::Error` raised for `path`.
171 ///
172 /// `ErrorKind::Deadlock` is `EDEADLK`, which is what a read of a dataless
173 /// file returns on Apple platforms when the reading thread has opted out of
174 /// materializing it — so it means "not downloaded" wherever it occurs. The
175 /// mapping needs no platform `cfg`: no other read returns it.
176 pub fn from_io(path: impl fmt::Display, source: io::Error) -> Self {
177 let path = path.to_string();
178 match source.kind() {
179 io::ErrorKind::NotFound => Self::NotFound { path },
180 io::ErrorKind::PermissionDenied => Self::PermissionDenied { path },
181 io::ErrorKind::Deadlock => Self::NotDownloaded { path },
182 _ => Self::Io { path, source },
183 }
184 }
185
186 pub fn is_not_found(&self) -> bool {
187 matches!(self, Self::NotFound { .. })
188 }
189
190 pub fn not_found(path: impl fmt::Display) -> Self {
191 Self::NotFound {
192 path: path.to_string(),
193 }
194 }
195}
196
197/// Lossy but kind-preserving: handlers that answer with an HTTP status derived
198/// from `io::ErrorKind` (404 for `NotFound`, 403 for `PermissionDenied`) keep
199/// answering the same status.
200impl From<VaultError> for io::Error {
201 fn from(error: VaultError) -> Self {
202 let kind = match &error {
203 VaultError::NotFound { .. } => io::ErrorKind::NotFound,
204 VaultError::PermissionDenied { .. }
205 | VaultError::OutsideRoot { .. }
206 | VaultError::ReadOnly { .. } => io::ErrorKind::PermissionDenied,
207 VaultError::NotDownloaded { .. } => io::ErrorKind::WouldBlock,
208 VaultError::InvalidPath(_) => io::ErrorKind::InvalidInput,
209 VaultError::Io { .. } => {
210 let VaultError::Io { source, .. } = error else {
211 unreachable!()
212 };
213 return source;
214 }
215 };
216 io::Error::new(kind, error)
217 }
218}
219
220/// A repository's storage.
221///
222/// See the [module docs](self) for the rules every implementation and caller
223/// follows. Methods are synchronous: the desktop implementation is blocking
224/// `std::fs`, and async callers already move repository I/O to
225/// `spawn_blocking` (or run it on rayon), which is where a foreign
226/// implementation's blocking calls belong too.
227pub trait Vault: Send + Sync + fmt::Debug {
228 /// The namespace [`Vault::key`]s live under. For [`LocalVault`], the
229 /// canonical repository root; for any other vault, an identifier that is
230 /// never opened.
231 fn root(&self) -> &Path;
232
233 /// The entry at `path` (following symlinks), or `None` when nothing is
234 /// there.
235 fn stat(&self, path: &VaultPath) -> Result<Option<Entry>, VaultError>;
236
237 /// The immediate children of directory `dir`, in no particular order.
238 ///
239 /// Children that cannot be described are skipped rather than failing the
240 /// listing: a broken symlink, a name that is not valid UTF-8 or not a valid
241 /// [`VaultPath`] segment. That is what the scanner's old `WalkDir` loop did
242 /// (`filter_map(Result::ok)`).
243 fn list_dir(&self, dir: &VaultPath) -> Result<Vec<Entry>, VaultError>;
244
245 /// Resolves symlinks in `path` and returns where it really is inside the
246 /// vault.
247 ///
248 /// [`VaultError::NotFound`] when it does not exist;
249 /// [`VaultError::OutsideRoot`] when it resolves outside the vault. A vault
250 /// without links returns `path` itself for anything that exists.
251 fn canonicalize(&self, path: &VaultPath) -> Result<VaultPath, VaultError>;
252
253 /// The whole file.
254 fn read(&self, path: &VaultPath) -> Result<Vec<u8>, VaultError>;
255
256 /// At most the first `max_len` bytes (frontmatter extraction reads 8 KB).
257 fn read_prefix(&self, path: &VaultPath, max_len: usize) -> Result<Vec<u8>, VaultError>;
258
259 /// At most `len` bytes starting at byte `offset`: fewer at the end of the
260 /// file, none past it. The streaming and `Range` path for files a vault
261 /// has no [`Vault::local_path`] for (`mbr_server` serves those in chunks).
262 ///
263 /// The default reads the whole file and slices it, which is right for an
264 /// in-memory vault and wrong for anything large; a vault with real
265 /// random access overrides it.
266 fn read_range(&self, path: &VaultPath, offset: u64, len: usize) -> Result<Vec<u8>, VaultError> {
267 let bytes = self.read(path)?;
268 let start = usize::try_from(offset)
269 .unwrap_or(usize::MAX)
270 .min(bytes.len());
271 let end = start.saturating_add(len).min(bytes.len());
272 Ok(bytes[start..end].to_vec())
273 }
274
275 /// Whether the file at `path` is on this device, decided without reading
276 /// it — the check a server makes before handing a file to a reader that
277 /// would block on (or fail half-way through) a not-downloaded one.
278 ///
279 /// The default answers [`Availability::Local`] and does no I/O, which is
280 /// the truth for any vault whose files are always local (the desktop's
281 /// [`LocalVault`]). [`Availability::Unknown`] means the vault could not
282 /// tell (the file vanished, say); callers treat it as "try the read".
283 fn availability(&self, _path: &VaultPath) -> Availability {
284 Availability::Local
285 }
286
287 /// Replaces (or creates) `path` with `bytes` atomically: readers see the
288 /// old content or the new, never a mix, and a failure leaves the old file.
289 /// The parent directory must exist. An existing file's permissions are
290 /// kept.
291 ///
292 /// Does not serialize writers; callers that read before writing hold a
293 /// per-file lock across both (`mbr_server::file_write::FileWriteLocks`).
294 fn write_atomic(&self, path: &VaultPath, bytes: &[u8]) -> Result<(), VaultError>;
295
296 /// Creates `path` and any missing ancestors as directories.
297 fn create_dir_all(&self, path: &VaultPath) -> Result<(), VaultError>;
298
299 /// Removes the file at `path`.
300 fn remove_file(&self, path: &VaultPath) -> Result<(), VaultError>;
301
302 /// Renames `from` to `to`, replacing a file at `to`.
303 fn rename(&self, from: &VaultPath, to: &VaultPath) -> Result<(), VaultError>;
304
305 /// Asks for not-downloaded files to be fetched. Returns immediately;
306 /// completion shows up as the files becoming readable. A no-op for vaults
307 /// whose files are always local.
308 fn request_download(&self, _paths: &[VaultPath]) {}
309
310 /// A real filesystem path for `path`, when one exists — the fast path for
311 /// consumers that need one (`ServeFile`, grep's `search_path`, ffmpeg,
312 /// pdfium, mmap). `None` means "read it through the vault".
313 fn local_path(&self, _path: &VaultPath) -> Option<PathBuf> {
314 None
315 }
316
317 /// The whole file as UTF-8. Invalid UTF-8 is an [`VaultError::Io`] with
318 /// `ErrorKind::InvalidData`, as `std::fs::read_to_string` reports it.
319 fn read_to_string(&self, path: &VaultPath) -> Result<String, VaultError> {
320 let bytes = self.read(path)?;
321 String::from_utf8(bytes).map_err(|e| VaultError::Io {
322 path: path.to_string(),
323 source: io::Error::new(io::ErrorKind::InvalidData, e),
324 })
325 }
326
327 /// The index key for `path`: `path` under [`Vault::root`].
328 fn key(&self, path: &VaultPath) -> PathBuf {
329 path.to_native(self.root())
330 }
331
332 /// The vault path for an index key — the inverse of [`Vault::key`].
333 /// Lexical: it does not resolve symlinks.
334 fn vault_path(&self, key: &Path) -> Result<VaultPath, VaultError> {
335 VaultPath::from_absolute(self.root(), key).map_err(|e| match e {
336 VaultPathError::NotUnderRoot { path, .. } => VaultError::OutsideRoot {
337 path: path.display().to_string(),
338 },
339 other => VaultError::InvalidPath(other),
340 })
341 }
342
343 /// Whether `path` is an existing file. Errors read as `false`, as
344 /// `Path::is_file` does.
345 fn is_file(&self, path: &VaultPath) -> bool {
346 matches!(self.stat(path), Ok(Some(entry)) if entry.is_file())
347 }
348
349 /// Whether `path` is an existing directory. Errors read as `false`.
350 fn is_dir(&self, path: &VaultPath) -> bool {
351 matches!(self.stat(path), Ok(Some(entry)) if entry.is_dir())
352 }
353
354 /// Whether `path` lies in a read-only part of the vault — a symlink
355 /// mount's location or anything under it. Lexical: callers pass resolved
356 /// (canonical) paths, and the vault's own write methods re-check the
357 /// canonical form. `false` for vaults without mounts.
358 fn is_read_only(&self, _path: &VaultPath) -> bool {
359 false
360 }
361
362 /// The symlink mounts accepted so far (see [`Mount`]). They are discovered
363 /// as paths through them are first resolved, so the list grows during the
364 /// first scan. Empty for vaults without mounts.
365 fn mounts(&self) -> Vec<Mount> {
366 Vec::new()
367 }
368}
369
370/// Whether a path segment is **hidden** for serving: it starts with a dot.
371///
372/// The one exception is `.well-known` (RFC 8615): the registered prefix for
373/// site metadata — `security.txt`, ACME challenges, app-site associations —
374/// which a static site has to be able to publish. It holds no secrets by
375/// definition, unlike `.git`, `.env` or `.ssh`.
376pub fn is_hidden_segment(segment: &str) -> bool {
377 segment.starts_with('.') && segment != ".well-known"
378}
379
380/// Whether `path` has a hidden segment ([`is_hidden_segment`]) that is not
381/// covered by `exempt` — the hidden directories the user named on the command
382/// line (`Config::explicit_hidden_dirs`, as vault paths). A hidden segment is
383/// exempt when the path up to and including it is one of them, so naming
384/// `.scratch` admits `.scratch/a.md` but not `.scratch/.git/config`.
385///
386/// Hidden files are never served: they are where repositories keep
387/// credentials and tooling state (`.env`, `.git/config`, `.mbr/config.toml`
388/// with its token hash). Allocation-free.
389pub fn has_hidden_segment(path: &VaultPath, exempt: &[VaultPath]) -> bool {
390 let full = path.as_str();
391 let mut end = 0;
392 path.segments().any(|segment| {
393 end += segment.len() + usize::from(end != 0);
394 is_hidden_segment(segment) && !exempt.iter().any(|dir| dir.as_str() == &full[..end])
395 })
396}
397
398/// Places a configured folder (`static_folder`) inside `vault`, or `None`
399/// when it lands nowhere in it.
400///
401/// The usual value is a plain relative path (`static`, `./assets/static`),
402/// which is parsed lexically and needs no storage access. An absolute or
403/// `..`-climbing spelling that still lands inside the root (`/abs/root/static`,
404/// `../root/static`) can only be placed by resolving it on disk, so that half
405/// needs a [`Vault::local_path`] — exactly as the old
406/// `canonical_root.join(static_folder)` did. A value resolving *outside* the
407/// root is an external overlay, which is a vault of its own and not this
408/// function's business.
409pub fn configured_folder(vault: &dyn Vault, configured: &str) -> Option<VaultPath> {
410 VaultPath::new(configured).ok().or_else(|| {
411 let local_root = vault.local_path(&VaultPath::root())?;
412 let resolved = crate::config::resolve_existing_or_lexical(&local_root.join(configured));
413 vault.vault_path(&resolved).ok()
414 })
415}
416
417/// Resolves `relative` beneath `base`, refusing anything that ends up outside
418/// `base` once symlinks are followed.
419///
420/// This is the vault form of the resolver's old `safe_join`, with the same
421/// answers:
422///
423/// - `base` is canonicalized first; a `base` that does not exist resolves
424/// nothing. The vault root is taken as canonical without asking.
425/// - When `base/relative` exists, the answer is its **canonical** path, and
426/// only if that is still under the canonical `base`. A final component that
427/// is a symlink out of `base` is refused here rather than falling through to
428/// the "does not exist yet" branch — that fall-through would validate only
429/// the parent and hand back the unresolved link.
430/// - When it does not exist (probing `foo.md` for `/foo/`), its parent must
431/// canonicalize under `base`, and the answer is the canonical parent joined
432/// with the requested name.
433///
434/// `relative` is already lexically normalized (it is a [`VaultPath`]), which is
435/// the one difference from `safe_join`: a request's `..` is resolved by
436/// position, as a browser resolves it, before any symlink is followed.
437pub fn resolve_under(
438 vault: &dyn Vault,
439 base: &VaultPath,
440 relative: &VaultPath,
441) -> Option<VaultPath> {
442 // The root is canonical by construction (nothing a `VaultPath` can name is
443 // above it), so the common case costs no `canonicalize` for the base —
444 // the resolver used to cache the canonical root for the same reason.
445 let canonical_base = if base.is_root() {
446 VaultPath::root()
447 } else {
448 vault.canonicalize(base).ok()?
449 };
450 let candidate = canonical_base.join_path(relative);
451 match vault.canonicalize(&candidate) {
452 Ok(canonical) => canonical.starts_with(&canonical_base).then_some(canonical),
453 Err(VaultError::OutsideRoot { .. }) => None,
454 Err(_) => {
455 let name = candidate.file_name()?;
456 let canonical_parent = vault.canonicalize(&candidate.parent()?).ok()?;
457 if !canonical_parent.starts_with(&canonical_base) {
458 return None;
459 }
460 canonical_parent.child(name).ok()
461 }
462 }
463}
464
465/// Every file in the vault, recursively, for a repository-wide pass that is
466/// not the scanner (backlink grep, link rewriting on a move).
467///
468/// Follows symlinks the way the scanner does: each directory is listed once,
469/// under its **canonical** path, so one reached twice (a link to a folder that
470/// is also walked directly) contributes its files once, and a link cycle ends;
471/// a directory that resolves outside the vault is not entered at all — so a
472/// repository-wide *write* can never land outside the repository through a
473/// link. `enter` prunes directories (it sees each directory's entry before it
474/// is listed); every file below an entered directory is returned, with its
475/// path under the canonical directory.
476///
477/// Sequential and one [`Vault::list_dir`] per directory. Directories that
478/// cannot be listed are skipped, as `WalkDir`'s error entries were.
479pub fn walk_files(vault: &dyn Vault, enter: impl Fn(&Entry) -> bool) -> Vec<Entry> {
480 walk_files_under(vault, &VaultPath::root(), enter)
481}
482
483/// [`walk_files`] from `base` rather than the root: every file below it, each
484/// directory once by canonical path, nothing outside the vault. A directory
485/// below `base` that canonicalizes elsewhere in the vault is walked under its
486/// canonical path, as [`walk_files`] walks it.
487pub fn walk_files_under(
488 vault: &dyn Vault,
489 base: &VaultPath,
490 enter: impl Fn(&Entry) -> bool,
491) -> Vec<Entry> {
492 walk_vault_under(vault, base, enter).files
493}
494
495/// What [`walk_vault_under`] found.
496#[derive(Debug, Default)]
497pub struct VaultWalk {
498 /// Every file below the base, as [`walk_files_under`] returns them.
499 pub files: Vec<Entry>,
500 /// Directories whose listing failed with [`VaultError::NotDownloaded`],
501 /// by canonical vault path: a file provider knows they exist but has not
502 /// fetched what is in them (an iCloud Drive folder on iOS stays
503 /// *dataless* until something lists it on a thread allowed to wait). Their
504 /// files are missing from `files`, not deleted.
505 pub not_downloaded_dirs: Vec<VaultPath>,
506 /// How many directories were listed, for diagnostics.
507 pub dirs_listed: usize,
508}
509
510/// [`walk_files_under`], also reporting the directories that could not be
511/// listed because their listing is not on the device yet.
512pub fn walk_vault_under(
513 vault: &dyn Vault,
514 base: &VaultPath,
515 enter: impl Fn(&Entry) -> bool,
516) -> VaultWalk {
517 let mut walk = VaultWalk::default();
518 let mut visited = std::collections::HashSet::new();
519 let mut pending = vec![base.clone()];
520 while let Some(dir) = pending.pop() {
521 let Ok(canonical) = vault.canonicalize(&dir) else {
522 continue;
523 };
524 if !visited.insert(canonical.clone()) {
525 continue;
526 }
527 let entries = match vault.list_dir(&canonical) {
528 Ok(entries) => {
529 walk.dirs_listed += 1;
530 entries
531 }
532 Err(VaultError::NotDownloaded { .. }) => {
533 walk.not_downloaded_dirs.push(canonical);
534 continue;
535 }
536 Err(_) => continue,
537 };
538 for entry in entries {
539 if entry.is_dir() {
540 if enter(&entry) {
541 pending.push(entry.path);
542 }
543 } else {
544 walk.files.push(entry);
545 }
546 }
547 }
548 walk
549}
550
551#[cfg(test)]
552mod tests {
553 use super::*;
554
555 #[test]
556 fn io_errors_are_classified() {
557 let nf = VaultError::from_io("a.md", io::Error::from(io::ErrorKind::NotFound));
558 assert!(nf.is_not_found());
559 assert!(matches!(
560 VaultError::from_io("a.md", io::Error::from(io::ErrorKind::PermissionDenied)),
561 VaultError::PermissionDenied { .. }
562 ));
563 assert!(matches!(
564 VaultError::from_io("a.md", io::Error::from(io::ErrorKind::Deadlock)),
565 VaultError::NotDownloaded { .. }
566 ));
567 assert!(matches!(
568 VaultError::from_io("a.md", io::Error::other("boom")),
569 VaultError::Io { .. }
570 ));
571 }
572
573 #[test]
574 fn vault_errors_keep_their_io_kind() {
575 let kind = |e: VaultError| io::Error::from(e).kind();
576 assert_eq!(kind(VaultError::not_found("x")), io::ErrorKind::NotFound);
577 assert_eq!(
578 kind(VaultError::PermissionDenied { path: "x".into() }),
579 io::ErrorKind::PermissionDenied
580 );
581 assert_eq!(
582 kind(VaultError::OutsideRoot { path: "x".into() }),
583 io::ErrorKind::PermissionDenied
584 );
585 let original = io::Error::new(io::ErrorKind::StorageFull, "disk full");
586 assert_eq!(
587 kind(VaultError::from_io("x", original)),
588 io::ErrorKind::StorageFull
589 );
590 }
591
592 #[test]
593 fn epoch_secs_falls_back_to_modified_and_refuses_pre_epoch() {
594 let at = |secs| SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(secs);
595 let entry = Entry {
596 path: VaultPath::new("a.md").unwrap(),
597 kind: EntryKind::File,
598 size: 1,
599 modified: at(200),
600 created: None,
601 availability: Availability::Local,
602 };
603 assert_eq!(entry.epoch_secs(), Some((200, 200)));
604 let with_created = Entry {
605 created: Some(at(100)),
606 ..entry.clone()
607 };
608 assert_eq!(with_created.epoch_secs(), Some((100, 200)));
609 let pre_epoch = Entry {
610 modified: SystemTime::UNIX_EPOCH - std::time::Duration::from_secs(1),
611 ..entry
612 };
613 assert_eq!(pre_epoch.epoch_secs(), None);
614 }
615
616 #[test]
617 fn walk_files_lists_every_file_and_prunes_directories() {
618 let vault = MemVault::new()
619 .with_file("a.md", "a")
620 .with_file("docs/b.md", "b")
621 .with_file("docs/deep/c.md", "c")
622 .with_file("node_modules/x.md", "x")
623 .with_dir("empty");
624 let mut found: Vec<String> = walk_files(&vault, |dir| dir.name() != "node_modules")
625 .into_iter()
626 .map(|e| e.path.to_string())
627 .collect();
628 found.sort();
629 assert_eq!(found, vec!["a.md", "docs/b.md", "docs/deep/c.md"]);
630 }
631
632 #[cfg(unix)]
633 #[test]
634 fn walk_files_follows_links_inside_once_and_never_leaves_the_vault() {
635 use std::os::unix::fs::symlink;
636 let dir = tempfile::tempdir().unwrap();
637 let outside = tempfile::tempdir().unwrap();
638 std::fs::create_dir(dir.path().join("real")).unwrap();
639 std::fs::write(dir.path().join("real/note.md"), "n").unwrap();
640 std::fs::write(outside.path().join("secret.md"), "s").unwrap();
641 // A second route to `real`, a cycle, and a link out of the vault.
642 symlink(dir.path().join("real"), dir.path().join("alias")).unwrap();
643 symlink(dir.path(), dir.path().join("real/loop")).unwrap();
644 symlink(outside.path(), dir.path().join("leak")).unwrap();
645 let vault = LocalVault::new(dir.path());
646 let found: Vec<String> = walk_files(&vault, |_| true)
647 .into_iter()
648 .map(|e| e.path.to_string())
649 .collect();
650 assert_eq!(
651 found,
652 vec!["real/note.md"],
653 "listed once, canonically, nothing outside"
654 );
655 }
656
657 #[test]
658 fn hidden_segments_and_their_exemptions() {
659 let p = |s: &str| VaultPath::new(s).unwrap();
660 assert!(!has_hidden_segment(&VaultPath::root(), &[]));
661 assert!(!has_hidden_segment(&p("docs/a.md"), &[]));
662 assert!(!has_hidden_segment(&p("docs/a.b.md"), &[]));
663 assert!(has_hidden_segment(&p(".env"), &[]));
664 assert!(has_hidden_segment(&p(".git/config"), &[]));
665 assert!(has_hidden_segment(&p("docs/.private/a.md"), &[]));
666 assert!(has_hidden_segment(&p("docs/.hidden.png"), &[]));
667 assert!(!has_hidden_segment(&p(".well-known/security.txt"), &[]));
668 assert!(has_hidden_segment(&p(".well-known/.secret"), &[]));
669 // Exempt: exactly the named chain.
670 let exempt = [p(".scratch"), p(".scratch/b/.deep")];
671 assert!(!has_hidden_segment(&p(".scratch/a.md"), &exempt));
672 assert!(!has_hidden_segment(&p(".scratch/b/.deep/c.md"), &exempt));
673 assert!(has_hidden_segment(&p(".scratch/.git/config"), &exempt));
674 assert!(has_hidden_segment(&p("x/.scratch/a.md"), &exempt));
675 assert!(has_hidden_segment(&p(".scratchy/a.md"), &exempt));
676 }
677
678 #[test]
679 fn resolve_under_on_a_mem_vault() {
680 let vault = MemVault::new()
681 .with_file("docs/a.md", "# A")
682 .with_file("static/img.png", "png");
683 let root = VaultPath::root();
684 let p = |s: &str| VaultPath::new(s).unwrap();
685 assert_eq!(
686 resolve_under(&vault, &root, &p("docs/a.md")),
687 Some(p("docs/a.md"))
688 );
689 // Missing file under an existing parent: the probe answer.
690 assert_eq!(
691 resolve_under(&vault, &root, &p("docs/b.md")),
692 Some(p("docs/b.md"))
693 );
694 // Missing parent: nothing.
695 assert_eq!(resolve_under(&vault, &root, &p("nope/b.md")), None);
696 // Relative to a sub-base.
697 assert_eq!(
698 resolve_under(&vault, &p("static"), &p("img.png")),
699 Some(p("static/img.png"))
700 );
701 // A base that does not exist resolves nothing.
702 assert_eq!(resolve_under(&vault, &p("missing"), &p("img.png")), None);
703 }
704}