1use std::fs::{self, File};
9use std::io::{self, Read, Seek, SeekFrom, Write};
10use std::path::{Path, PathBuf};
11use std::sync::Arc;
12use std::sync::atomic::{AtomicU64, Ordering};
13use std::time::SystemTime;
14
15use super::mount::{Mount, MountPolicy, MountTable};
16use super::{Availability, Entry, EntryKind, Vault, VaultError, VaultPath};
17
18pub type AvailabilityProbe = fn(&fs::Metadata) -> Availability;
26
27#[derive(Debug, Clone)]
29pub struct LocalVault {
30 root: PathBuf,
32 mounts: Option<Arc<MountTable>>,
36 availability_probe: Option<AvailabilityProbe>,
40}
41
42impl LocalVault {
43 pub fn new(root: impl Into<PathBuf>) -> Self {
53 let root = root.into();
54 Self {
55 root: root.canonicalize().unwrap_or(root),
56 mounts: None,
57 availability_probe: None,
58 }
59 }
60
61 #[must_use]
64 pub fn with_availability_probe(mut self, probe: AvailabilityProbe) -> Self {
65 self.availability_probe = Some(probe);
66 self
67 }
68
69 pub fn with_mounts(root: impl Into<PathBuf>, policy: MountPolicy) -> Self {
74 Self {
75 mounts: Some(Arc::new(MountTable::new(policy))),
76 ..Self::new(root)
77 }
78 }
79
80 fn check_writable(&self, path: &VaultPath) -> Result<(), VaultError> {
91 let existing = path
92 .ancestors()
93 .find(|ancestor| fs::symlink_metadata(self.native(ancestor)).is_ok())
94 .unwrap_or_default();
95 let canonical = match self.canonicalize(&existing) {
96 Ok(canonical) => canonical,
97 Err(VaultError::NotFound { .. }) => return Ok(()),
100 Err(e) => return Err(e),
101 };
102 if self.is_read_only(path) || self.is_read_only(&canonical) {
103 return Err(VaultError::ReadOnly {
104 path: path.to_string(),
105 });
106 }
107 Ok(())
108 }
109
110 pub fn native(&self, path: &VaultPath) -> PathBuf {
112 path.to_native(&self.root)
113 }
114
115 fn entry(&self, path: VaultPath, metadata: &fs::Metadata) -> Entry {
116 let availability = match self.availability_probe {
117 Some(probe) if !metadata.is_dir() => probe(metadata),
118 _ => Availability::Local,
119 };
120 Entry {
121 path,
122 kind: if metadata.is_dir() {
123 EntryKind::Dir
124 } else {
125 EntryKind::File
126 },
127 size: metadata.len(),
128 modified: metadata.modified().unwrap_or(SystemTime::UNIX_EPOCH),
129 created: metadata.created().ok(),
130 availability,
131 }
132 }
133}
134
135impl Vault for LocalVault {
136 fn root(&self) -> &Path {
137 &self.root
138 }
139
140 fn stat(&self, path: &VaultPath) -> Result<Option<Entry>, VaultError> {
141 match fs::metadata(self.native(path)) {
142 Ok(metadata) => Ok(Some(self.entry(path.clone(), &metadata))),
143 Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(None),
144 Err(e) => Err(VaultError::from_io(path, e)),
145 }
146 }
147
148 fn list_dir(&self, dir: &VaultPath) -> Result<Vec<Entry>, VaultError> {
149 let native = self.native(dir);
150 let entries = fs::read_dir(&native).map_err(|e| VaultError::from_io(dir, e))?;
151 Ok(entries
152 .filter_map(|entry| {
153 let entry = entry.ok()?;
154 let name = entry.file_name();
155 let Some(name) = name.to_str() else {
156 tracing::debug!("Skipping non-UTF-8 name in {}", native.display());
157 return None;
158 };
159 let path = match dir.child(name) {
160 Ok(path) => path,
161 Err(e) => {
162 tracing::debug!("Skipping {:?} in {}: {e}", name, native.display());
163 return None;
164 }
165 };
166 let metadata = fs::metadata(entry.path()).ok()?;
170 Some(self.entry(path, &metadata))
171 })
172 .collect())
173 }
174
175 fn canonicalize(&self, path: &VaultPath) -> Result<VaultPath, VaultError> {
176 let canonical = self
177 .native(path)
178 .canonicalize()
179 .map_err(|e| VaultError::from_io(path, e))?;
180 if canonical.starts_with(&self.root) {
183 return VaultPath::from_absolute(&self.root, &canonical).map_err(|_| {
184 VaultError::OutsideRoot {
185 path: canonical.display().to_string(),
186 }
187 });
188 }
189 match &self.mounts {
190 Some(table) => table.resolve(&self.root, |p| self.native(p), path, canonical),
191 None => Err(VaultError::OutsideRoot {
192 path: canonical.display().to_string(),
193 }),
194 }
195 }
196
197 fn read(&self, path: &VaultPath) -> Result<Vec<u8>, VaultError> {
198 fs::read(self.native(path)).map_err(|e| VaultError::from_io(path, e))
199 }
200
201 fn read_prefix(&self, path: &VaultPath, max_len: usize) -> Result<Vec<u8>, VaultError> {
202 read_prefix_native(&self.native(path), max_len).map_err(|e| VaultError::from_io(path, e))
203 }
204
205 fn read_range(&self, path: &VaultPath, offset: u64, len: usize) -> Result<Vec<u8>, VaultError> {
206 read_range_native(&self.native(path), offset, len).map_err(|e| VaultError::from_io(path, e))
207 }
208
209 fn availability(&self, path: &VaultPath) -> Availability {
210 match self.availability_probe {
211 None => Availability::Local,
212 Some(probe) => match fs::metadata(self.native(path)) {
213 Ok(metadata) if metadata.is_dir() => Availability::Local,
214 Ok(metadata) => probe(&metadata),
215 Err(_) => Availability::Unknown,
216 },
217 }
218 }
219
220 fn write_atomic(&self, path: &VaultPath, bytes: &[u8]) -> Result<(), VaultError> {
221 self.check_writable(path)?;
222 atomic_write(&self.native(path), bytes).map_err(|e| VaultError::from_io(path, e))
223 }
224
225 fn create_dir_all(&self, path: &VaultPath) -> Result<(), VaultError> {
226 self.check_writable(path)?;
227 fs::create_dir_all(self.native(path)).map_err(|e| VaultError::from_io(path, e))
228 }
229
230 fn remove_file(&self, path: &VaultPath) -> Result<(), VaultError> {
231 self.check_writable(path)?;
232 fs::remove_file(self.native(path)).map_err(|e| VaultError::from_io(path, e))
233 }
234
235 fn rename(&self, from: &VaultPath, to: &VaultPath) -> Result<(), VaultError> {
236 self.check_writable(from)?;
237 self.check_writable(to)?;
238 fs::rename(self.native(from), self.native(to)).map_err(|e| VaultError::from_io(from, e))
239 }
240
241 fn local_path(&self, path: &VaultPath) -> Option<PathBuf> {
242 Some(self.native(path))
243 }
244
245 fn is_read_only(&self, path: &VaultPath) -> bool {
246 self.mounts
247 .as_ref()
248 .is_some_and(|table| table.contains(path))
249 }
250
251 fn mounts(&self) -> Vec<Mount> {
252 self.mounts
253 .as_ref()
254 .map(|table| table.snapshot())
255 .unwrap_or_default()
256 }
257}
258
259pub fn read_prefix_native(path: &Path, max_len: usize) -> io::Result<Vec<u8>> {
265 let mut file = File::open(path)?;
266 let len = file.metadata().map(|m| m.len() as usize).unwrap_or(0);
267 let mut buffer = vec![0u8; len.min(max_len)];
268 file.read_exact(&mut buffer)?;
269 Ok(buffer)
270}
271
272pub fn read_range_native(path: &Path, offset: u64, len: usize) -> io::Result<Vec<u8>> {
275 let mut file = File::open(path)?;
276 file.seek(SeekFrom::Start(offset))?;
277 let mut buffer = Vec::with_capacity(len.min(1 << 20));
278 file.take(len as u64).read_to_end(&mut buffer)?;
279 Ok(buffer)
280}
281
282pub fn create_unique_temp_file(dir: &Path, file_name: &str) -> io::Result<(PathBuf, File)> {
290 static COUNTER: AtomicU64 = AtomicU64::new(0);
291 const ATTEMPTS: u32 = 16;
294 let pid = std::process::id();
295 let mut last_error = None;
296 for _ in 0..ATTEMPTS {
297 let n = COUNTER.fetch_add(1, Ordering::Relaxed);
298 let path = dir.join(format!(".{file_name}.{pid}-{n}.mbr-tmp"));
299 match fs::OpenOptions::new()
300 .write(true)
301 .create_new(true)
302 .open(&path)
303 {
304 Ok(file) => return Ok((path, file)),
305 Err(e) if e.kind() == io::ErrorKind::AlreadyExists => last_error = Some(e),
306 Err(e) => return Err(e),
307 }
308 }
309 Err(last_error.unwrap_or_else(|| io::Error::other("no free temp file name")))
310}
311
312pub fn atomic_write(path: &Path, bytes: &[u8]) -> io::Result<()> {
322 let parent = path.parent().unwrap_or_else(|| Path::new("."));
323 let file_name = path
324 .file_name()
325 .and_then(|n| n.to_str())
326 .unwrap_or("file.md");
327 let (tmp, mut file) = create_unique_temp_file(parent, file_name)?;
328 let written = file
329 .write_all(bytes)
330 .and_then(|()| match fs::metadata(path) {
331 Ok(existing) => file.set_permissions(existing.permissions()),
332 Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(()),
333 Err(e) => Err(e),
334 })
335 .and_then(|()| {
336 drop(file);
337 fs::rename(&tmp, path)
338 });
339 if written.is_err() {
340 let _ = fs::remove_file(&tmp);
341 }
342 written
343}
344
345#[cfg(test)]
346mod tests {
347 use super::*;
348
349 fn vp(s: &str) -> VaultPath {
350 VaultPath::new(s).unwrap()
351 }
352
353 fn fixture() -> (tempfile::TempDir, LocalVault) {
354 let dir = tempfile::tempdir().expect("temp dir");
355 fs::create_dir_all(dir.path().join("docs/sub")).unwrap();
356 fs::write(dir.path().join("docs/a.md"), "# A\n").unwrap();
357 fs::write(dir.path().join("docs/sub/b.md"), "# B\n").unwrap();
358 fs::write(dir.path().join("top.txt"), "top").unwrap();
359 let vault = LocalVault::new(dir.path());
360 (dir, vault)
361 }
362
363 #[test]
364 fn root_is_canonical() {
365 let (dir, vault) = fixture();
366 assert_eq!(vault.root(), dir.path().canonicalize().unwrap());
367 }
368
369 #[test]
370 fn stat_reports_kind_size_and_absence() {
371 let (_dir, vault) = fixture();
372 let a = vault.stat(&vp("docs/a.md")).unwrap().unwrap();
373 assert!(a.is_file());
374 assert_eq!(a.size, 4);
375 assert_eq!(a.availability, Availability::Local);
376 assert!(vault.stat(&vp("docs")).unwrap().unwrap().is_dir());
377 assert!(vault.stat(&VaultPath::root()).unwrap().unwrap().is_dir());
378 assert_eq!(vault.stat(&vp("missing.md")).unwrap(), None);
379 assert!(vault.is_file(&vp("top.txt")));
380 assert!(vault.is_dir(&vp("docs/sub")));
381 assert!(!vault.is_dir(&vp("top.txt")));
382 }
383
384 #[test]
385 fn list_dir_returns_children_with_their_kinds() {
386 let (_dir, vault) = fixture();
387 let mut names: Vec<(String, EntryKind)> = vault
388 .list_dir(&vp("docs"))
389 .unwrap()
390 .into_iter()
391 .map(|e| (e.path.to_string(), e.kind))
392 .collect();
393 names.sort();
394 assert_eq!(
395 names,
396 vec![
397 ("docs/a.md".to_string(), EntryKind::File),
398 ("docs/sub".to_string(), EntryKind::Dir),
399 ]
400 );
401 assert!(vault.list_dir(&vp("missing")).unwrap_err().is_not_found());
402 }
403
404 #[test]
405 fn reads_whole_files_and_prefixes() {
406 let (_dir, vault) = fixture();
407 assert_eq!(vault.read(&vp("docs/a.md")).unwrap(), b"# A\n");
408 assert_eq!(vault.read_to_string(&vp("docs/a.md")).unwrap(), "# A\n");
409 assert_eq!(vault.read_prefix(&vp("docs/a.md"), 2).unwrap(), b"# ");
410 assert_eq!(vault.read_prefix(&vp("docs/a.md"), 100).unwrap(), b"# A\n");
411 assert!(vault.read(&vp("missing")).unwrap_err().is_not_found());
412 }
413
414 #[test]
415 fn read_range_reads_a_window_and_stops_at_the_end() {
416 let (_dir, vault) = fixture();
417 let path = vp("top.txt");
418 assert_eq!(vault.read_range(&path, 1, 2).unwrap(), b"op");
419 assert_eq!(vault.read_range(&path, 2, 100).unwrap(), b"p");
420 assert!(vault.read_range(&path, 10, 5).unwrap().is_empty());
421 assert!(
422 vault
423 .read_range(&vp("nope"), 0, 1)
424 .unwrap_err()
425 .is_not_found()
426 );
427 }
428
429 #[test]
430 fn availability_is_local_without_a_probe_and_probed_with_one() {
431 let (dir, vault) = fixture();
432 assert_eq!(vault.availability(&vp("top.txt")), Availability::Local);
433 let probed = LocalVault::new(dir.path()).with_availability_probe(|metadata| {
434 if metadata.len() == 3 {
435 Availability::NotDownloaded
436 } else {
437 Availability::Local
438 }
439 });
440 assert_eq!(
441 probed.availability(&vp("top.txt")),
442 Availability::NotDownloaded
443 );
444 assert_eq!(probed.availability(&vp("docs/a.md")), Availability::Local);
445 assert_eq!(probed.availability(&vp("docs")), Availability::Local);
446 assert_eq!(probed.availability(&vp("missing")), Availability::Unknown);
447 let listed = probed.list_dir(&VaultPath::root()).unwrap();
448 let top = listed.iter().find(|e| e.name() == "top.txt").unwrap();
449 assert_eq!(top.availability, Availability::NotDownloaded);
450 assert_eq!(
451 probed.stat(&vp("top.txt")).unwrap().unwrap().availability,
452 Availability::NotDownloaded
453 );
454 }
455
456 #[test]
457 fn invalid_utf8_is_invalid_data() {
458 let (dir, vault) = fixture();
459 fs::write(dir.path().join("bin.md"), [0xff, 0xfe, 0x00]).unwrap();
460 let err = vault.read_to_string(&vp("bin.md")).unwrap_err();
461 assert_eq!(io::Error::from(err).kind(), io::ErrorKind::InvalidData);
462 }
463
464 #[test]
465 fn local_path_and_keys_are_the_native_paths() {
466 let (_dir, vault) = fixture();
467 let native = vault.root().join("docs").join("a.md");
468 assert_eq!(vault.local_path(&vp("docs/a.md")), Some(native.clone()));
469 assert_eq!(vault.key(&vp("docs/a.md")), native);
470 assert_eq!(vault.vault_path(&native).unwrap(), vp("docs/a.md"));
471 let outside = std::env::temp_dir().join("elsewhere.md");
472 assert!(matches!(
473 vault.vault_path(&outside),
474 Err(VaultError::OutsideRoot { .. })
475 ));
476 }
477
478 #[test]
479 fn write_atomic_replaces_creates_and_leaves_no_temp_files() {
480 let (dir, vault) = fixture();
481 vault.write_atomic(&vp("docs/a.md"), b"# A2\n").unwrap();
482 assert_eq!(
483 fs::read_to_string(dir.path().join("docs/a.md")).unwrap(),
484 "# A2\n"
485 );
486 vault.write_atomic(&vp("docs/new.md"), b"new").unwrap();
487 assert_eq!(fs::read(dir.path().join("docs/new.md")).unwrap(), b"new");
488 let leftovers: Vec<_> = fs::read_dir(dir.path().join("docs"))
489 .unwrap()
490 .filter_map(|e| e.ok()?.file_name().into_string().ok())
491 .filter(|n| n.ends_with(".mbr-tmp"))
492 .collect();
493 assert!(leftovers.is_empty(), "{leftovers:?}");
494 }
495
496 #[test]
497 fn write_atomic_needs_an_existing_parent() {
498 let (dir, vault) = fixture();
499 assert!(vault.write_atomic(&vp("nope/x.md"), b"x").is_err());
500 assert!(!dir.path().join("nope").exists());
501 vault.create_dir_all(&vp("nope/deeper")).unwrap();
502 vault.write_atomic(&vp("nope/deeper/x.md"), b"x").unwrap();
503 assert!(vault.is_file(&vp("nope/deeper/x.md")));
504 }
505
506 #[cfg(unix)]
507 #[test]
508 fn write_atomic_keeps_the_file_permissions() {
509 use std::os::unix::fs::PermissionsExt;
510 let (dir, vault) = fixture();
511 let path = dir.path().join("docs/a.md");
512 fs::set_permissions(&path, fs::Permissions::from_mode(0o600)).unwrap();
513 vault.write_atomic(&vp("docs/a.md"), b"secret").unwrap();
514 let mode = fs::metadata(&path).unwrap().permissions().mode();
515 assert_eq!(mode & 0o777, 0o600);
516 }
517
518 #[test]
519 fn rename_and_remove() {
520 let (_dir, vault) = fixture();
521 vault.rename(&vp("top.txt"), &vp("docs/top.txt")).unwrap();
522 assert!(!vault.is_file(&vp("top.txt")));
523 assert!(vault.is_file(&vp("docs/top.txt")));
524 vault.remove_file(&vp("docs/top.txt")).unwrap();
525 assert_eq!(vault.stat(&vp("docs/top.txt")).unwrap(), None);
526 }
527
528 #[test]
529 fn canonicalize_existing_and_missing() {
530 let (_dir, vault) = fixture();
531 assert_eq!(
532 vault.canonicalize(&vp("docs/./a.md")).unwrap(),
533 vp("docs/a.md")
534 );
535 assert_eq!(
536 vault.canonicalize(&VaultPath::root()).unwrap(),
537 VaultPath::root()
538 );
539 assert!(
540 vault
541 .canonicalize(&vp("missing"))
542 .unwrap_err()
543 .is_not_found()
544 );
545 }
546
547 #[cfg(unix)]
550 mod symlinks {
551 use super::*;
552 use crate::vault::resolve_under;
553 use std::os::unix::fs::symlink;
554
555 fn with_links() -> (tempfile::TempDir, tempfile::TempDir, LocalVault) {
556 let (dir, vault) = fixture();
557 let outside = tempfile::tempdir().unwrap();
558 fs::write(outside.path().join("secret.md"), "# Secret").unwrap();
559 fs::create_dir(outside.path().join("dir")).unwrap();
560 fs::write(outside.path().join("dir/inner.md"), "# Inner").unwrap();
561 symlink(outside.path().join("secret.md"), dir.path().join("leak.md")).unwrap();
563 symlink(outside.path().join("dir"), dir.path().join("leakdir")).unwrap();
564 symlink(dir.path().join("docs/a.md"), dir.path().join("alias.md")).unwrap();
566 symlink(dir.path().join("docs/sub"), dir.path().join("subalias")).unwrap();
567 symlink(
569 dir.path().join("nowhere.md"),
570 dir.path().join("dangling.md"),
571 )
572 .unwrap();
573 (dir, outside, vault)
574 }
575
576 #[test]
577 fn canonicalize_refuses_links_out_of_the_vault() {
578 let (_dir, _outside, vault) = with_links();
579 assert!(matches!(
580 vault.canonicalize(&vp("leak.md")),
581 Err(VaultError::OutsideRoot { .. })
582 ));
583 assert!(matches!(
584 vault.canonicalize(&vp("leakdir")),
585 Err(VaultError::OutsideRoot { .. })
586 ));
587 assert!(matches!(
588 vault.canonicalize(&vp("leakdir/inner.md")),
589 Err(VaultError::OutsideRoot { .. })
590 ));
591 }
592
593 #[test]
594 fn canonicalize_resolves_links_that_stay_inside() {
595 let (_dir, _outside, vault) = with_links();
596 assert_eq!(
597 vault.canonicalize(&vp("alias.md")).unwrap(),
598 vp("docs/a.md")
599 );
600 assert_eq!(
601 vault.canonicalize(&vp("subalias/b.md")).unwrap(),
602 vp("docs/sub/b.md")
603 );
604 }
605
606 #[test]
610 fn resolve_under_refuses_escaping_links_and_follows_internal_ones() {
611 let (_dir, _outside, vault) = with_links();
612 let root = VaultPath::root();
613 assert_eq!(resolve_under(&vault, &root, &vp("leak.md")), None);
614 assert_eq!(resolve_under(&vault, &root, &vp("leakdir/inner.md")), None);
615 assert_eq!(resolve_under(&vault, &root, &vp("leakdir/new.md")), None);
618 assert_eq!(
619 resolve_under(&vault, &root, &vp("alias.md")),
620 Some(vp("docs/a.md"))
621 );
622 symlink(
625 vault.root().join("docs/a.md"),
626 vault.root().join("docs/sub/up.md"),
627 )
628 .unwrap();
629 assert_eq!(resolve_under(&vault, &vp("docs/sub"), &vp("up.md")), None);
630 assert_eq!(
631 resolve_under(&vault, &vp("docs/sub"), &vp("b.md")),
632 Some(vp("docs/sub/b.md"))
633 );
634 }
635
636 #[test]
639 fn dangling_links_resolve_like_missing_files() {
640 let (_dir, _outside, vault) = with_links();
641 assert_eq!(
642 resolve_under(&vault, &VaultPath::root(), &vp("dangling.md")),
643 Some(vp("dangling.md"))
644 );
645 assert_eq!(vault.stat(&vp("dangling.md")).unwrap(), None);
646 }
647
648 #[test]
652 fn list_dir_follows_links_and_skips_dangling_ones() {
653 let (_dir, _outside, vault) = with_links();
654 let entries = vault.list_dir(&VaultPath::root()).unwrap();
655 let kind = |name: &str| entries.iter().find(|e| e.name() == name).map(|e| e.kind);
656 assert_eq!(kind("leak.md"), Some(EntryKind::File));
657 assert_eq!(kind("leakdir"), Some(EntryKind::Dir));
658 assert_eq!(kind("subalias"), Some(EntryKind::Dir));
659 assert_eq!(kind("dangling.md"), None);
660 }
661
662 #[test]
665 fn a_symlinked_root_still_contains_its_children() {
666 let (dir, _vault) = fixture();
667 let holder = tempfile::tempdir().unwrap();
668 let link = holder.path().join("vault-link");
669 symlink(dir.path(), &link).unwrap();
670 let vault = LocalVault::new(&link);
671 assert_eq!(
672 vault.canonicalize(&vp("docs/a.md")).unwrap(),
673 vp("docs/a.md")
674 );
675 }
676 }
677}