videre_core/library.rs
1//! The explicit identity of one media library.
2//!
3//! Everything videre derives from a library (its database, config, locks,
4//! embeddings, thumbnails) lives under the library root itself, in the
5//! reserved `<root>/.videre` state directory, or in a cache keyed by that
6//! root, instead of one process-global home resolved from the environment.
7//! [`LibraryContext`] is that arrangement as a value: constructed from
8//! explicit inputs, reading no environment variable, and creating no
9//! directory anywhere. Choosing the root is a separate, earlier decision
10//! that happens at the CLI; this type only makes the chosen root precise.
11//!
12//! The root is pinned for the process lifetime. Construction opens a handle
13//! on the canonical root and records the directory's device/inode identity;
14//! [`LibraryContext::ensure_root_identity`] rechecks, bounded, and errors if
15//! the path now names a different directory, because a context that silently
16//! followed a replaced or unmounted root would write one library's state
17//! into another's. A symlink used to reach the library root is supported:
18//! canonicalization makes aliases of one root the same library, sharing one
19//! cache namespace.
20
21use crate::io_timeout;
22use crate::library_config::LibraryConfig;
23use anyhow::{bail, Context, Result};
24use std::fs::File;
25use std::os::unix::ffi::OsStrExt;
26use std::os::unix::fs::MetadataExt;
27use std::path::{Path, PathBuf};
28use std::sync::{Arc, Mutex};
29use std::time::Duration;
30
31/// The reserved state directory videre keeps inside every library root.
32/// Crate visible: the pinned-I/O layer refuses the same name as a sidecar
33/// target, so the literal is written once, here.
34pub(crate) const STATE_DIR: &str = ".videre";
35
36/// Where everything derived from one library root lives. All paths are
37/// absolute; the root is the canonical root, so every field is the same in
38/// every context opened on the same library no matter which alias reached it.
39#[derive(Debug, Clone, PartialEq, Eq)]
40pub struct LibraryPaths {
41 /// The canonical library root this context is pinned to.
42 pub root: PathBuf,
43 /// `<root>/.videre`, reserved for videre's own state inside the library.
44 pub state: PathBuf,
45 /// The library's SQLite database.
46 pub db: PathBuf,
47 /// The library's `config.toml`.
48 pub config: PathBuf,
49 /// Default JSONL export path (`scan --output` with no value).
50 pub jsonl: PathBuf,
51 /// Directory holding this library's per-model embedding databases.
52 pub embeddings: PathBuf,
53 /// Directory holding flock sidecar lock files for this library's database.
54 pub locks: PathBuf,
55}
56
57/// Cache locations for one library, namespaced under an explicit base.
58///
59/// The base is an input, not a discovery: it is used as given and need not
60/// exist until a writer creates it, so a context can be built for a library
61/// before any cache directory has been brought into being.
62#[derive(Debug, Clone, PartialEq, Eq)]
63pub struct CachePaths {
64 /// The cache root exactly as supplied; writers create it, readers of this
65 /// struct never do.
66 pub base: PathBuf,
67 /// This library's thumbnail cache, under a per-library key.
68 pub thumbnails: PathBuf,
69 /// The shared reverse-geocoding cache; not per-library.
70 pub geo: PathBuf,
71}
72
73/// Process-lifetime state shared by every clone of a context: the open root
74/// handle, the identity pinned from it, and the index-validation memo.
75#[derive(Debug)]
76struct Shared {
77 /// An open handle on the canonical root directory, held so later stages
78 /// can operate on the pinned directory rather than through a name that a
79 /// rename or unmount can silently repoint.
80 root_handle: File,
81 root_dev: u64,
82 root_ino: u64,
83 /// Memo slot recording a successful index validation, so the expensive
84 /// check happens once per context rather than per command. Success only,
85 /// by construction: there is deliberately no way to record a failure,
86 /// because a failed validation must be retried, not remembered.
87 index_validated: Mutex<bool>,
88}
89
90/// One media library as an explicit, immutable value.
91///
92/// Clones share the pinned identity and the memo (the interior state is
93/// behind an `Arc`); the path and settings structs are plain data computed
94/// once from the canonical root and identical in every clone. There is no
95/// connection here, no global, and no lookup against the environment: a
96/// context is fully described by what it was constructed with.
97#[derive(Clone, Debug)]
98pub struct LibraryContext {
99 /// Derived paths under the canonical root.
100 pub paths: LibraryPaths,
101 /// Cache locations under the explicit cache base.
102 pub cache: CachePaths,
103 /// Settings in force for this context, loaded once from the library's
104 /// `config.toml` at construction (built-in defaults when it is absent).
105 /// A snapshot: editing the file later does not mutate an existing
106 /// context, only a context built after the edit.
107 pub settings: LibraryConfig,
108 identity: Arc<Shared>,
109}
110
111fn paths_for(root: PathBuf) -> LibraryPaths {
112 let state = root.join(STATE_DIR);
113 LibraryPaths {
114 root,
115 db: state.join("hashes.db"),
116 config: state.join("config.toml"),
117 jsonl: state.join("hashes.jsonl"),
118 embeddings: state.join("embeddings"),
119 locks: state.join("locks"),
120 state,
121 }
122}
123
124/// The cache key for one library: the BLAKE3 digest of the canonical root.
125///
126/// Keying on the canonical root is what makes symlink aliases of one root
127/// share a cache. It must never be a face id or the database basename: every
128/// library's database is named `hashes.db`, so that key would point all
129/// libraries at one shared cache.
130fn cache_for(base: &Path, paths: &LibraryPaths) -> CachePaths {
131 let key = blake3::hash(paths.root.as_os_str().as_bytes()).to_hex();
132 CachePaths {
133 base: base.to_path_buf(),
134 thumbnails: base
135 .join("videre/libraries")
136 .join(key.as_str())
137 .join("thumbnails"),
138 geo: base.join("videre/geo"),
139 }
140}
141
142/// Run one path operation inside a budget, mapping both failure shapes to
143/// errors that name the path.
144///
145/// The budget is a parameter rather than a constant only so tests can prove
146/// the bound with a sleeping body, the way `io_timeout`'s own tests do;
147/// production callers pass [`io_timeout::STAT_TIMEOUT`], the right ceiling
148/// for metadata-sized work. On timeout the message is built from strings
149/// already in hand: re-reading the very path that failed to answer is the
150/// mistake `TimedOutAfter::describe` exists to prevent. Crate visible: the
151/// config layer runs its file operations through the same bound.
152pub(crate) fn bounded_op<T, F>(path: &Path, op: &str, budget: Duration, f: F) -> Result<T>
153where
154 T: Send + 'static,
155 F: FnOnce() -> std::io::Result<T> + Send + 'static,
156{
157 match io_timeout::run_with_timeout(budget, f) {
158 Ok(Ok(value)) => Ok(value),
159 Ok(Err(e)) => Err(anyhow::Error::new(e).context(format!("{} {}", op, path.display()))),
160 Err(io_timeout::TimedOut) => bail!(
161 "could not {} {} after {}s (the drive did not respond - is it connected?)",
162 op,
163 path.display(),
164 budget.as_secs()
165 ),
166 }
167}
168
169/// The device/inode pair that names a directory itself, independent of which
170/// of its possibly many path spellings was used to reach it.
171fn dir_identity(meta: &std::fs::Metadata) -> (u64, u64) {
172 (meta.dev(), meta.ino())
173}
174
175/// Whether an error chain bottoms out in `NotFound`, so a caller can phrase a
176/// missing thing as missing rather than as a generic failure. Crate visible
177/// for the same reader as `bounded_op`: the config layer.
178pub(crate) fn root_cause_is_not_found(e: &anyhow::Error) -> bool {
179 e.root_cause()
180 .downcast_ref::<std::io::Error>()
181 .is_some_and(|io| io.kind() == std::io::ErrorKind::NotFound)
182}
183
184/// Refuse a root that is itself the reserved state directory.
185///
186/// Selecting `<library>/.videre` would nest a state directory inside a state
187/// directory (`.videre/.videre`) and almost always means videre was pointed
188/// one level too deep. The error names the path and says what to select
189/// instead, so it is actionable without guessing at intent.
190fn reject_reserved(path: &Path) -> Result<()> {
191 if path.file_name() == Some(std::ffi::OsStr::new(STATE_DIR)) {
192 bail!(
193 "{} is videre's reserved state directory inside a library, not a library; select the directory that contains it",
194 path.display()
195 );
196 }
197 Ok(())
198}
199
200/// Resolve `root` to its canonical form, reporting the distinct ways a root
201/// can fail to exist rather than one generic error.
202///
203/// Returns the canonical path and whether the given path was itself a
204/// symlink; that fact is only used to phrase the dangling case, because
205/// `canonicalize` reports a dangling link and an absent path identically as
206/// `NotFound`.
207fn canonical_root(root: &Path) -> Result<(PathBuf, bool)> {
208 let owned = root.to_path_buf();
209 // lstat first, bounded: on a stale mount this is the call that would
210 // block forever, so it runs inside a budget before anything else, the
211 // same stat-first ordering run_with_timeout_for_path_detailed uses.
212 let meta = match bounded_op(root, "read", io_timeout::STAT_TIMEOUT, move || {
213 std::fs::symlink_metadata(&owned)
214 }) {
215 Ok(meta) => meta,
216 Err(e) if root_cause_is_not_found(&e) => {
217 bail!("library root {} does not exist", root.display())
218 }
219 Err(e) => return Err(e),
220 };
221 let is_symlink = meta.file_type().is_symlink();
222 let owned = root.to_path_buf();
223 match bounded_op(root, "resolve", io_timeout::STAT_TIMEOUT, move || {
224 std::fs::canonicalize(&owned)
225 }) {
226 Ok(canonical) => Ok((canonical, is_symlink)),
227 Err(e) if is_symlink && root_cause_is_not_found(&e) => bail!(
228 "library root {} is a dangling symlink: its target does not exist",
229 root.display()
230 ),
231 Err(e) => Err(e),
232 }
233}
234
235/// Open the canonical root and pin its identity from the open handle itself.
236///
237/// The identity comes from an fstat of the handle, not from a second name
238/// lookup, so it is the identity of the directory actually opened. One
239/// further bounded lstat of the name then confirms the handle corresponds to
240/// the canonical path; a mismatch means the directory was replaced in the
241/// window between resolution and open, and no context is built on that race.
242fn open_pinned(canonical: &Path) -> Result<(File, std::fs::Metadata)> {
243 let owned = canonical.to_path_buf();
244 let handle = bounded_op(canonical, "open", io_timeout::STAT_TIMEOUT, move || {
245 File::open(&owned)
246 })?;
247 // fstat through a duplicate of the fd: the bounded runner needs an owned
248 // 'static closure, and dup on an already-open fd is not a path operation,
249 // so it needs no budget of its own.
250 let dup = handle
251 .try_clone()
252 .with_context(|| format!("dupe handle on {}", canonical.display()))?;
253 let meta = bounded_op(canonical, "stat", io_timeout::STAT_TIMEOUT, move || {
254 dup.metadata()
255 })?;
256 if !meta.is_dir() {
257 bail!("library root {} is not a directory", canonical.display());
258 }
259 let pinned = dir_identity(&meta);
260 let owned = canonical.to_path_buf();
261 // lstat, deliberately: the output of canonicalize never has a symlink as
262 // its final component, so this stat not following symlinks is strictly
263 // stronger here, and it is the only stat that proves the name was not
264 // swapped for a link. A following stat would compare the link target's
265 // identity, which both the open above and the verification would then
266 // agree on, pinning the context to the wrong library without any
267 // disagreement to observe. ensure_root_identity, in contrast,
268 // intentionally follows: a renamed root reached through a symlink left
269 // behind still names the pinned directory and must keep validating.
270 let named = bounded_op(canonical, "read", io_timeout::STAT_TIMEOUT, move || {
271 std::fs::symlink_metadata(&owned)
272 })?;
273 if !named.is_dir() {
274 bail!(
275 "library root {} changed while it was being opened: it is no longer a directory",
276 canonical.display()
277 );
278 }
279 if dir_identity(&named) != pinned {
280 bail!(
281 "library root {} changed while it was being opened",
282 canonical.display()
283 );
284 }
285 Ok((handle, meta))
286}
287
288impl LibraryContext {
289 /// Build a context for `root`, keeping derived caches under `cache_base`.
290 ///
291 /// Creates nothing, anywhere: neither the state directory inside the root
292 /// nor any part of the cache base. Both are writers' job, so looking at a
293 /// library never litters it. The library's `config.toml` is read here
294 /// when present: settings are snapshotted into the context, an edit to
295 /// the file after construction does not mutate it, and an invalid or
296 /// corrupt file fails construction rather than silently defaulting.
297 pub fn new(root: &Path, cache_base: &Path) -> Result<Self> {
298 // Every path handed to lower layers is absolute. A relative input
299 // would have to be resolved against the process's cwd, which is
300 // exactly the ambient state this type exists to exclude, so it is
301 // refused rather than quietly absorbed.
302 if !root.is_absolute() {
303 bail!(
304 "library root must be an absolute path, got {}",
305 root.display()
306 );
307 }
308 if !cache_base.is_absolute() {
309 bail!(
310 "cache base must be an absolute path, got {}",
311 cache_base.display()
312 );
313 }
314 // The reserved name is refused before anything touches the
315 // filesystem: the mistake is in the argument, not in the world.
316 reject_reserved(root)?;
317 // The input is absolute, so canonicalization cannot consult the cwd;
318 // it exists to make aliases of one root converge on one library.
319 let (canonical, _) = canonical_root(root)?;
320 // The canonical spelling is rechecked, not just the given one:
321 // reaching a reserved directory through a symlink is still selecting
322 // it.
323 reject_reserved(&canonical)?;
324 let (handle, meta) = open_pinned(&canonical)?;
325 let paths = paths_for(canonical);
326 let cache = cache_for(cache_base, &paths);
327 // Refuse redirected state before loading through it. Database opens
328 // repeat these checks under their locks, but settings are already
329 // observable on the context and need the same protection here.
330 crate::library_locks::reject_dir_redirect(&paths.state, "the library state directory")?;
331 crate::library_locks::reject_redirect(&paths.config, "the library config")?;
332 // The config belongs to the library, so it is loaded through the
333 // pinned root's derived paths, after root validation and before the
334 // context exists: an invalid config must fail here, at the entrance,
335 // not surface as surprising behaviour mid-command.
336 let settings = crate::library_config::load(&paths)?;
337 Ok(Self {
338 paths,
339 cache,
340 settings,
341 identity: Arc::new(Shared {
342 root_handle: handle,
343 root_dev: meta.dev(),
344 root_ino: meta.ino(),
345 index_validated: Mutex::new(false),
346 }),
347 })
348 }
349
350 /// Recheck that the canonical root still names the directory pinned at
351 /// construction, and error if it does not.
352 ///
353 /// Bounded: one bounded stat; a check that could hang on a wedged mount
354 /// is not a check. The error is built from strings in hand; the failing
355 /// path is never touched again to phrase the message, the same rule
356 /// `TimedOutAfter::describe` exists to enforce.
357 pub fn ensure_root_identity(&self) -> Result<()> {
358 let canonical = self.paths.root.clone();
359 match bounded_op(
360 &self.paths.root,
361 "read",
362 io_timeout::STAT_TIMEOUT,
363 move || std::fs::metadata(&canonical),
364 ) {
365 Ok(meta) => {
366 if dir_identity(&meta) != (self.identity.root_dev, self.identity.root_ino) {
367 bail!(
368 "library root {} no longer names the directory this context was opened for; the folder was replaced, renamed, or its volume unmounted",
369 self.paths.root.display()
370 );
371 }
372 Ok(())
373 }
374 Err(e) if root_cause_is_not_found(&e) => bail!(
375 "library root {} no longer exists",
376 self.paths.root.display()
377 ),
378 Err(e) => Err(e),
379 }
380 }
381
382 /// The open handle on the canonical library root, held for the process
383 /// lifetime. Used by the pinned-I/O layers built on this context; crate
384 /// visible deliberately, since it is a building block, not an answer.
385 pub(crate) fn root_handle(&self) -> &File {
386 &self.identity.root_handle
387 }
388
389 /// Whether a successful index validation has already been memoized for
390 /// this context. Shared by every clone; failures are never memoized.
391 pub(crate) fn index_validated(&self) -> bool {
392 *self.identity.index_validated.lock().unwrap()
393 }
394
395 /// Record a successful index validation. Only success is recordable: a
396 /// failed validation must be retried rather than remembered.
397 pub(crate) fn mark_index_validated(&self) {
398 *self.identity.index_validated.lock().unwrap() = true;
399 }
400}
401
402#[cfg(test)]
403mod tests {
404 use super::*;
405 use crate::library_test_support::write_past_test_capture;
406 use std::os::unix::fs::PermissionsExt;
407
408 #[test]
409 fn paths_are_local_and_root_aliases_share_identity() {
410 let temp = tempfile::tempdir().unwrap();
411 let root = temp.path().join("photos");
412 let alias = temp.path().join("alias");
413 std::fs::create_dir(&root).unwrap();
414 std::os::unix::fs::symlink(&root, &alias).unwrap();
415 let cache = temp.path().join("cache");
416 let a = LibraryContext::new(&root, &cache).unwrap();
417 let b = LibraryContext::new(&alias, &cache).unwrap();
418 // macOS tempdirs live under /var or /tmp, both symlinks into
419 // /private, so the canonical root is not the spelling the test
420 // created; comparing against canonicalize(root) is the
421 // platform-independent statement of "state lives in the root itself".
422 let canonical = std::fs::canonicalize(&root).unwrap();
423 assert_eq!(a.paths.db, canonical.join(".videre/hashes.db"));
424 assert_eq!(a.cache.thumbnails, b.cache.thumbnails);
425 assert!(!a.paths.state.exists());
426 // The cache base is an explicit input that may not exist yet, and
427 // constructing a context must not bring it into existence.
428 assert!(!cache.exists());
429 assert!(a.ensure_root_identity().is_ok());
430 std::fs::rename(&root, temp.path().join("old")).unwrap();
431 std::fs::create_dir(&root).unwrap();
432 assert!(a.ensure_root_identity().is_err());
433 // The alias context pinned the same canonical directory, so it sees
434 // the same replacement.
435 assert!(b.ensure_root_identity().is_err());
436 }
437
438 #[test]
439 fn sibling_roots_get_distinct_cache_namespaces() {
440 let temp = tempfile::tempdir().unwrap();
441 let one = temp.path().join("2024");
442 let two = temp.path().join("2025");
443 std::fs::create_dir(&one).unwrap();
444 std::fs::create_dir(&two).unwrap();
445 let cache = temp.path().join("cache");
446 let a = LibraryContext::new(&one, &cache).unwrap();
447 let b = LibraryContext::new(&two, &cache).unwrap();
448 assert_ne!(a.cache.thumbnails, b.cache.thumbnails);
449 // The key material is pinned exactly: the digest of the canonical
450 // root, never the db basename (which every library shares) or a face
451 // id. A change here is a silent cache reset for every library.
452 let key = blake3::hash(a.paths.root.as_os_str().as_bytes()).to_hex();
453 assert_eq!(
454 a.cache.thumbnails,
455 cache
456 .join("videre/libraries")
457 .join(key.as_str())
458 .join("thumbnails")
459 );
460 // Geo is shared across libraries, not namespaced per root.
461 assert_eq!(a.cache.geo, cache.join("videre/geo"));
462 assert_eq!(b.cache.geo, a.cache.geo);
463 }
464
465 #[test]
466 fn context_rejects_redirected_state_before_loading_its_config() {
467 let temp = tempfile::tempdir().unwrap();
468 let root = temp.path().join("photos");
469 let outside = temp.path().join("outside-state");
470 std::fs::create_dir(&root).unwrap();
471 std::fs::create_dir(&outside).unwrap();
472 std::fs::write(outside.join("config.toml"), "min_read_rate_mb_s = 7\n").unwrap();
473 std::os::unix::fs::symlink(&outside, root.join(".videre")).unwrap();
474
475 let err = LibraryContext::new(&root, &temp.path().join("cache")).unwrap_err();
476
477 assert!(format!("{err:#}").contains("symlink"), "{err:#}");
478 }
479
480 #[test]
481 fn a_missing_root_is_rejected_with_its_path() {
482 let temp = tempfile::tempdir().unwrap();
483 let missing = temp.path().join("nowhere");
484 let err = LibraryContext::new(&missing, temp.path()).unwrap_err();
485 let msg = format!("{err:#}");
486 assert!(msg.contains("does not exist"), "{msg}");
487 assert!(msg.contains("nowhere"), "{msg}");
488 // And it is not misreported as a dead drive, which it is not.
489 assert!(!msg.contains("did not respond"), "{msg}");
490 }
491
492 #[test]
493 fn a_file_where_the_root_should_be_is_rejected() {
494 let temp = tempfile::tempdir().unwrap();
495 let file = temp.path().join("library.txt");
496 std::fs::write(&file, b"not a library").unwrap();
497 let err = LibraryContext::new(&file, temp.path()).unwrap_err();
498 let msg = format!("{err:#}");
499 assert!(msg.contains("not a directory"), "{msg}");
500 assert!(msg.contains("library.txt"), "{msg}");
501 }
502
503 #[test]
504 fn a_directory_named_videre_is_rejected() {
505 let temp = tempfile::tempdir().unwrap();
506 let state = temp.path().join("photos/.videre");
507 std::fs::create_dir_all(&state).unwrap();
508 let err = LibraryContext::new(&state, temp.path()).unwrap_err();
509 let msg = format!("{err:#}");
510 assert!(msg.contains(".videre"), "{msg}");
511 assert!(msg.contains("reserved"), "{msg}");
512 assert!(msg.contains("photos"), "{msg}");
513
514 // Reaching that directory through a symlink is the same selection:
515 // the canonical name is checked, not just the spelling first given.
516 let alias = temp.path().join("shortcut");
517 std::os::unix::fs::symlink(&state, &alias).unwrap();
518 let err = LibraryContext::new(&alias, temp.path()).unwrap_err();
519 assert!(format!("{err:#}").contains("reserved"));
520 }
521
522 #[test]
523 fn a_dangling_root_symlink_is_rejected() {
524 let temp = tempfile::tempdir().unwrap();
525 let link = temp.path().join("library");
526 std::os::unix::fs::symlink(temp.path().join("gone"), &link).unwrap();
527 let err = LibraryContext::new(&link, temp.path()).unwrap_err();
528 let msg = format!("{err:#}");
529 assert!(msg.contains("dangling"), "{msg}");
530 assert!(msg.contains("library"), "{msg}");
531 }
532
533 #[test]
534 fn a_root_swapped_for_a_symlink_during_open_is_rejected() {
535 // The race this guards lives between two adjacent syscalls inside
536 // open_pinned (canonicalize has already run, the name is swapped for
537 // a symlink before the verification stat), so it cannot be produced
538 // end to end deterministically. The post-race state can be: hand
539 // open_pinned a name that is now a symlink to another directory.
540 // A stat that follows the link compares the decoy directory's
541 // identity against the handle the open just got on that same decoy,
542 // and they agree, pinning the context to the wrong library; the
543 // lstat sees the link itself and rejects it.
544 let temp = tempfile::tempdir().unwrap();
545 let real = temp.path().join("photos");
546 let decoy = temp.path().join("other-library");
547 std::fs::create_dir(&real).unwrap();
548 std::fs::create_dir(&decoy).unwrap();
549 // Any name handed to open_pinned plays the canonical name; a symlink
550 // there is exactly the swapped state, since canonicalize never
551 // returns one.
552 let swapped = temp.path().join("swapped");
553 std::os::unix::fs::symlink(&decoy, &swapped).unwrap();
554 let err = open_pinned(&swapped).unwrap_err();
555 let msg = format!("{err:#}");
556 assert!(msg.contains("changed while it was being opened"), "{msg}");
557 assert!(msg.contains("swapped"), "{msg}");
558 }
559
560 #[test]
561 fn an_inaccessible_root_is_rejected_with_its_path() {
562 let temp = tempfile::tempdir().unwrap();
563 let root = temp.path().join("photos");
564 std::fs::create_dir(&root).unwrap();
565 // Root bypasses permission bits entirely (a stock Docker image runs
566 // as root), so the behaviour is probed rather than the uid checked,
567 // same probe the integration suite's permissions_are_enforced uses.
568 let probe = temp.path().join("probe");
569 std::fs::write(&probe, b"x").unwrap();
570 std::fs::set_permissions(&probe, std::fs::Permissions::from_mode(0o000)).unwrap();
571 if std::fs::read(&probe).is_ok() {
572 write_past_test_capture(
573 "SKIP: running as root, so chmod 000 does not block opening a directory\n",
574 );
575 return;
576 }
577 std::fs::set_permissions(&root, std::fs::Permissions::from_mode(0o000)).unwrap();
578 let err = LibraryContext::new(&root, temp.path()).unwrap_err();
579 let msg = format!("{err:#}");
580 assert!(msg.contains("photos"), "{msg}");
581 assert!(msg.contains("denied"), "{msg}");
582 let _ = std::fs::set_permissions(&root, std::fs::Permissions::from_mode(0o755));
583 }
584
585 #[test]
586 fn a_path_operation_past_its_budget_is_cut_off_and_names_the_path() {
587 // A real wedged mount cannot be produced portably, so the bound is
588 // proven the way io_timeout's own tests prove theirs: a body that
589 // sleeps past a tiny budget. The leaked sleeping thread is the
590 // documented tradeoff of run_with_timeout.
591 let start = std::time::Instant::now();
592 let err = bounded_op(
593 Path::new("/Volumes/wedged/library"),
594 "read",
595 Duration::from_millis(50),
596 || {
597 std::thread::sleep(Duration::from_secs(5));
598 Ok::<(), std::io::Error>(())
599 },
600 )
601 .unwrap_err();
602 let msg = format!("{err:#}");
603 assert!(msg.contains("/Volumes/wedged/library"), "{msg}");
604 assert!(msg.contains("did not respond"), "{msg}");
605 assert!(start.elapsed() < Duration::from_secs(2));
606 }
607
608 #[test]
609 fn selecting_home_is_allowed_and_a_child_touches_nothing_outside_itself() {
610 let temp = tempfile::tempdir().unwrap();
611 let home = temp.path().join("home");
612 let photos = home.join("Photos");
613 std::fs::create_dir_all(&photos).unwrap();
614 let cache = temp.path().join("cache");
615 // A HOME is just another directory as far as the context is
616 // concerned: selecting it puts the state in HOME/.videre.
617 let ctx = LibraryContext::new(&home, &cache).unwrap();
618 let home_canonical = std::fs::canonicalize(&home).unwrap();
619 assert_eq!(ctx.paths.db, home_canonical.join(".videre/hashes.db"));
620 // Selecting its Photos child must not reach back out and create the
621 // old global HOME/.videre, which home-based resolution would have.
622 let _child = LibraryContext::new(&photos, &cache).unwrap();
623 assert!(!home.join(".videre").exists());
624 }
625
626 #[test]
627 fn relative_inputs_are_rejected_rather_than_resolved_against_cwd() {
628 // Resolving a relative root would consult the process's cwd, which is
629 // exactly the ambient state this type exists to exclude.
630 let err = LibraryContext::new(Path::new("photos"), Path::new("/cache")).unwrap_err();
631 assert!(format!("{err:#}").contains("absolute"), "{err:#}");
632 let err = LibraryContext::new(Path::new("/tmp/photos"), Path::new("cache")).unwrap_err();
633 assert!(format!("{err:#}").contains("absolute"), "{err:#}");
634 }
635
636 #[test]
637 fn a_new_context_carries_the_built_in_settings() {
638 let temp = tempfile::tempdir().unwrap();
639 let root = temp.path().join("photos");
640 std::fs::create_dir(&root).unwrap();
641 let ctx = LibraryContext::new(&root, &temp.path().join("cache")).unwrap();
642 assert_eq!(
643 ctx.settings.default_model,
644 crate::embeddings::DEFAULT_MODEL_ID
645 );
646 assert_eq!(
647 ctx.settings.xmp_precedence,
648 crate::marks::XmpPrecedence::default()
649 );
650 assert!(!ctx.settings.export_xmp_on_watch);
651 assert_eq!(ctx.settings.min_read_rate_mb_s, None);
652 }
653
654 #[test]
655 fn clones_share_the_pinned_identity_and_the_validation_memo() {
656 let temp = tempfile::tempdir().unwrap();
657 let root = temp.path().join("photos");
658 std::fs::create_dir(&root).unwrap();
659 let ctx = LibraryContext::new(&root, &temp.path().join("cache")).unwrap();
660 let clone = ctx.clone();
661 // The accessor hands out a handle on the pinned directory, so later
662 // stages can read through it rather than through a re-lookup.
663 assert!(ctx.root_handle().metadata().unwrap().is_dir());
664 assert!(!clone.index_validated());
665 ctx.mark_index_validated();
666 assert!(
667 clone.index_validated(),
668 "clones must share the memo, not copy it"
669 );
670 assert!(ctx.ensure_root_identity().is_ok());
671 assert!(clone.ensure_root_identity().is_ok());
672 }
673}