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)) => {
160 Err(crate::error_kind::from_io(e).context(format!("{} {}", op, path.display())))
161 }
162 Err(io_timeout::TimedOut) => Err(anyhow::anyhow!(
163 "could not {} {} after {}s (the drive did not respond - is it connected?)",
164 op,
165 path.display(),
166 budget.as_secs()
167 )
168 .context(crate::error_kind::ErrorKind::SourceUnavailable)),
169 }
170}
171
172/// The device/inode pair that names a directory itself, independent of which
173/// of its possibly many path spellings was used to reach it.
174fn dir_identity(meta: &std::fs::Metadata) -> (u64, u64) {
175 (meta.dev(), meta.ino())
176}
177
178/// Whether an error chain bottoms out in `NotFound`, so a caller can phrase a
179/// missing thing as missing rather than as a generic failure. Crate visible
180/// for the same reader as `bounded_op`: the config layer.
181pub(crate) fn root_cause_is_not_found(e: &anyhow::Error) -> bool {
182 e.root_cause()
183 .downcast_ref::<std::io::Error>()
184 .is_some_and(|io| io.kind() == std::io::ErrorKind::NotFound)
185}
186
187/// Refuse a root that is itself the reserved state directory.
188///
189/// Selecting `<library>/.videre` would nest a state directory inside a state
190/// directory (`.videre/.videre`) and almost always means videre was pointed
191/// one level too deep. The error names the path and says what to select
192/// instead, so it is actionable without guessing at intent.
193fn reject_reserved(path: &Path) -> Result<()> {
194 if path.file_name() == Some(std::ffi::OsStr::new(STATE_DIR)) {
195 bail!(
196 "{} is videre's reserved state directory inside a library, not a library; select the directory that contains it",
197 path.display()
198 );
199 }
200 Ok(())
201}
202
203/// Resolve `root` to its canonical form, reporting the distinct ways a root
204/// can fail to exist rather than one generic error.
205///
206/// Returns the canonical path and whether the given path was itself a
207/// symlink; that fact is only used to phrase the dangling case, because
208/// `canonicalize` reports a dangling link and an absent path identically as
209/// `NotFound`.
210fn canonical_root(root: &Path) -> Result<(PathBuf, bool)> {
211 let owned = root.to_path_buf();
212 // lstat first, bounded: on a stale mount this is the call that would
213 // block forever, so it runs inside a budget before anything else, the
214 // same stat-first ordering run_with_timeout_for_path_detailed uses.
215 let meta = match bounded_op(root, "read", io_timeout::STAT_TIMEOUT, move || {
216 std::fs::symlink_metadata(&owned)
217 }) {
218 Ok(meta) => meta,
219 Err(e) if root_cause_is_not_found(&e) => {
220 bail!("library root {} does not exist", root.display())
221 }
222 Err(e) => return Err(e),
223 };
224 let is_symlink = meta.file_type().is_symlink();
225 let owned = root.to_path_buf();
226 match bounded_op(root, "resolve", io_timeout::STAT_TIMEOUT, move || {
227 std::fs::canonicalize(&owned)
228 }) {
229 Ok(canonical) => Ok((canonical, is_symlink)),
230 Err(e) if is_symlink && root_cause_is_not_found(&e) => bail!(
231 "library root {} is a dangling symlink: its target does not exist",
232 root.display()
233 ),
234 Err(e) => Err(e),
235 }
236}
237
238/// Open the canonical root and pin its identity from the open handle itself.
239///
240/// The identity comes from an fstat of the handle, not from a second name
241/// lookup, so it is the identity of the directory actually opened. One
242/// further bounded lstat of the name then confirms the handle corresponds to
243/// the canonical path; a mismatch means the directory was replaced in the
244/// window between resolution and open, and no context is built on that race.
245fn open_pinned(canonical: &Path) -> Result<(File, std::fs::Metadata)> {
246 let owned = canonical.to_path_buf();
247 let handle = bounded_op(canonical, "open", io_timeout::STAT_TIMEOUT, move || {
248 File::open(&owned)
249 })?;
250 // fstat through a duplicate of the fd: the bounded runner needs an owned
251 // 'static closure, and dup on an already-open fd is not a path operation,
252 // so it needs no budget of its own.
253 let dup = handle
254 .try_clone()
255 .with_context(|| format!("dupe handle on {}", canonical.display()))?;
256 let meta = bounded_op(canonical, "stat", io_timeout::STAT_TIMEOUT, move || {
257 dup.metadata()
258 })?;
259 if !meta.is_dir() {
260 bail!("library root {} is not a directory", canonical.display());
261 }
262 let pinned = dir_identity(&meta);
263 let owned = canonical.to_path_buf();
264 // lstat, deliberately: the output of canonicalize never has a symlink as
265 // its final component, so this stat not following symlinks is strictly
266 // stronger here, and it is the only stat that proves the name was not
267 // swapped for a link. A following stat would compare the link target's
268 // identity, which both the open above and the verification would then
269 // agree on, pinning the context to the wrong library without any
270 // disagreement to observe. ensure_root_identity, in contrast,
271 // intentionally follows: a renamed root reached through a symlink left
272 // behind still names the pinned directory and must keep validating.
273 let named = bounded_op(canonical, "read", io_timeout::STAT_TIMEOUT, move || {
274 std::fs::symlink_metadata(&owned)
275 })?;
276 if !named.is_dir() {
277 bail!(
278 "library root {} changed while it was being opened: it is no longer a directory",
279 canonical.display()
280 );
281 }
282 if dir_identity(&named) != pinned {
283 bail!(
284 "library root {} changed while it was being opened",
285 canonical.display()
286 );
287 }
288 Ok((handle, meta))
289}
290
291impl LibraryContext {
292 /// Build a context for `root`, keeping derived caches under `cache_base`.
293 ///
294 /// Creates nothing, anywhere: neither the state directory inside the root
295 /// nor any part of the cache base. Both are writers' job, so looking at a
296 /// library never litters it. The library's `config.toml` is read here
297 /// when present: settings are snapshotted into the context, an edit to
298 /// the file after construction does not mutate it, and an invalid or
299 /// corrupt file fails construction rather than silently defaulting.
300 pub fn new(root: &Path, cache_base: &Path) -> Result<Self> {
301 // Every path handed to lower layers is absolute. A relative input
302 // would have to be resolved against the process's cwd, which is
303 // exactly the ambient state this type exists to exclude, so it is
304 // refused rather than quietly absorbed.
305 if !root.is_absolute() {
306 bail!(
307 "library root must be an absolute path, got {}",
308 root.display()
309 );
310 }
311 if !cache_base.is_absolute() {
312 bail!(
313 "cache base must be an absolute path, got {}",
314 cache_base.display()
315 );
316 }
317 // The reserved name is refused before anything touches the
318 // filesystem: the mistake is in the argument, not in the world.
319 reject_reserved(root)?;
320 // The input is absolute, so canonicalization cannot consult the cwd;
321 // it exists to make aliases of one root converge on one library.
322 let (canonical, _) = canonical_root(root)?;
323 // The canonical spelling is rechecked, not just the given one:
324 // reaching a reserved directory through a symlink is still selecting
325 // it.
326 reject_reserved(&canonical)?;
327 let (handle, meta) = open_pinned(&canonical)?;
328 let paths = paths_for(canonical);
329 let cache = cache_for(cache_base, &paths);
330 // Refuse redirected state before loading through it. Database opens
331 // repeat these checks under their locks, but settings are already
332 // observable on the context and need the same protection here.
333 crate::library_locks::reject_dir_redirect(&paths.state, "the library state directory")?;
334 crate::library_locks::reject_redirect(&paths.config, "the library config")?;
335 // The config belongs to the library, so it is loaded through the
336 // pinned root's derived paths, after root validation and before the
337 // context exists: an invalid config must fail here, at the entrance,
338 // not surface as surprising behaviour mid-command.
339 let settings = crate::library_config::load(&paths)?;
340 Ok(Self {
341 paths,
342 cache,
343 settings,
344 identity: Arc::new(Shared {
345 root_handle: handle,
346 root_dev: meta.dev(),
347 root_ino: meta.ino(),
348 index_validated: Mutex::new(false),
349 }),
350 })
351 }
352
353 /// Recheck that the canonical root still names the directory pinned at
354 /// construction, and error if it does not.
355 ///
356 /// Bounded: one bounded stat; a check that could hang on a wedged mount
357 /// is not a check. The error is built from strings in hand; the failing
358 /// path is never touched again to phrase the message, the same rule
359 /// `TimedOutAfter::describe` exists to enforce.
360 pub fn ensure_root_identity(&self) -> Result<()> {
361 let canonical = self.paths.root.clone();
362 match bounded_op(
363 &self.paths.root,
364 "read",
365 io_timeout::STAT_TIMEOUT,
366 move || std::fs::metadata(&canonical),
367 ) {
368 Ok(meta) => {
369 if dir_identity(&meta) != (self.identity.root_dev, self.identity.root_ino) {
370 bail!(
371 "library root {} no longer names the directory this context was opened for; the folder was replaced, renamed, or its volume unmounted",
372 self.paths.root.display()
373 );
374 }
375 Ok(())
376 }
377 Err(e) if root_cause_is_not_found(&e) => bail!(
378 "library root {} no longer exists",
379 self.paths.root.display()
380 ),
381 Err(e) => Err(e),
382 }
383 }
384
385 /// The open handle on the canonical library root, held for the process
386 /// lifetime. Used by the pinned-I/O layers built on this context; crate
387 /// visible deliberately, since it is a building block, not an answer.
388 pub(crate) fn root_handle(&self) -> &File {
389 &self.identity.root_handle
390 }
391
392 /// Whether a successful index validation has already been memoized for
393 /// this context. Shared by every clone; failures are never memoized.
394 pub(crate) fn index_validated(&self) -> bool {
395 *self.identity.index_validated.lock().unwrap()
396 }
397
398 /// Record a successful index validation. Only success is recordable: a
399 /// failed validation must be retried rather than remembered.
400 pub(crate) fn mark_index_validated(&self) {
401 *self.identity.index_validated.lock().unwrap() = true;
402 }
403}
404
405#[cfg(test)]
406mod tests {
407 use super::*;
408 use crate::library_test_support::write_past_test_capture;
409 use std::os::unix::fs::PermissionsExt;
410
411 #[test]
412 fn paths_are_local_and_root_aliases_share_identity() {
413 let temp = tempfile::tempdir().unwrap();
414 let root = temp.path().join("photos");
415 let alias = temp.path().join("alias");
416 std::fs::create_dir(&root).unwrap();
417 std::os::unix::fs::symlink(&root, &alias).unwrap();
418 let cache = temp.path().join("cache");
419 let a = LibraryContext::new(&root, &cache).unwrap();
420 let b = LibraryContext::new(&alias, &cache).unwrap();
421 // macOS tempdirs live under /var or /tmp, both symlinks into
422 // /private, so the canonical root is not the spelling the test
423 // created; comparing against canonicalize(root) is the
424 // platform-independent statement of "state lives in the root itself".
425 let canonical = std::fs::canonicalize(&root).unwrap();
426 assert_eq!(a.paths.db, canonical.join(".videre/hashes.db"));
427 assert_eq!(a.cache.thumbnails, b.cache.thumbnails);
428 assert!(!a.paths.state.exists());
429 // The cache base is an explicit input that may not exist yet, and
430 // constructing a context must not bring it into existence.
431 assert!(!cache.exists());
432 assert!(a.ensure_root_identity().is_ok());
433 std::fs::rename(&root, temp.path().join("old")).unwrap();
434 std::fs::create_dir(&root).unwrap();
435 assert!(a.ensure_root_identity().is_err());
436 // The alias context pinned the same canonical directory, so it sees
437 // the same replacement.
438 assert!(b.ensure_root_identity().is_err());
439 }
440
441 #[test]
442 fn sibling_roots_get_distinct_cache_namespaces() {
443 let temp = tempfile::tempdir().unwrap();
444 let one = temp.path().join("2024");
445 let two = temp.path().join("2025");
446 std::fs::create_dir(&one).unwrap();
447 std::fs::create_dir(&two).unwrap();
448 let cache = temp.path().join("cache");
449 let a = LibraryContext::new(&one, &cache).unwrap();
450 let b = LibraryContext::new(&two, &cache).unwrap();
451 assert_ne!(a.cache.thumbnails, b.cache.thumbnails);
452 // The key material is pinned exactly: the digest of the canonical
453 // root, never the db basename (which every library shares) or a face
454 // id. A change here is a silent cache reset for every library.
455 let key = blake3::hash(a.paths.root.as_os_str().as_bytes()).to_hex();
456 assert_eq!(
457 a.cache.thumbnails,
458 cache
459 .join("videre/libraries")
460 .join(key.as_str())
461 .join("thumbnails")
462 );
463 // Geo is shared across libraries, not namespaced per root.
464 assert_eq!(a.cache.geo, cache.join("videre/geo"));
465 assert_eq!(b.cache.geo, a.cache.geo);
466 }
467
468 #[test]
469 fn context_rejects_redirected_state_before_loading_its_config() {
470 let temp = tempfile::tempdir().unwrap();
471 let root = temp.path().join("photos");
472 let outside = temp.path().join("outside-state");
473 std::fs::create_dir(&root).unwrap();
474 std::fs::create_dir(&outside).unwrap();
475 std::fs::write(outside.join("config.toml"), "min_read_rate_mb_s = 7\n").unwrap();
476 std::os::unix::fs::symlink(&outside, root.join(".videre")).unwrap();
477
478 let err = LibraryContext::new(&root, &temp.path().join("cache")).unwrap_err();
479
480 assert!(format!("{err:#}").contains("symlink"), "{err:#}");
481 }
482
483 #[test]
484 fn a_missing_root_is_rejected_with_its_path() {
485 let temp = tempfile::tempdir().unwrap();
486 let missing = temp.path().join("nowhere");
487 let err = LibraryContext::new(&missing, temp.path()).unwrap_err();
488 let msg = format!("{err:#}");
489 assert!(msg.contains("does not exist"), "{msg}");
490 assert!(msg.contains("nowhere"), "{msg}");
491 // And it is not misreported as a dead drive, which it is not.
492 assert!(!msg.contains("did not respond"), "{msg}");
493 }
494
495 #[test]
496 fn a_file_where_the_root_should_be_is_rejected() {
497 let temp = tempfile::tempdir().unwrap();
498 let file = temp.path().join("library.txt");
499 std::fs::write(&file, b"not a library").unwrap();
500 let err = LibraryContext::new(&file, temp.path()).unwrap_err();
501 let msg = format!("{err:#}");
502 assert!(msg.contains("not a directory"), "{msg}");
503 assert!(msg.contains("library.txt"), "{msg}");
504 }
505
506 #[test]
507 fn a_directory_named_videre_is_rejected() {
508 let temp = tempfile::tempdir().unwrap();
509 let state = temp.path().join("photos/.videre");
510 std::fs::create_dir_all(&state).unwrap();
511 let err = LibraryContext::new(&state, temp.path()).unwrap_err();
512 let msg = format!("{err:#}");
513 assert!(msg.contains(".videre"), "{msg}");
514 assert!(msg.contains("reserved"), "{msg}");
515 assert!(msg.contains("photos"), "{msg}");
516
517 // Reaching that directory through a symlink is the same selection:
518 // the canonical name is checked, not just the spelling first given.
519 let alias = temp.path().join("shortcut");
520 std::os::unix::fs::symlink(&state, &alias).unwrap();
521 let err = LibraryContext::new(&alias, temp.path()).unwrap_err();
522 assert!(format!("{err:#}").contains("reserved"));
523 }
524
525 #[test]
526 fn a_dangling_root_symlink_is_rejected() {
527 let temp = tempfile::tempdir().unwrap();
528 let link = temp.path().join("library");
529 std::os::unix::fs::symlink(temp.path().join("gone"), &link).unwrap();
530 let err = LibraryContext::new(&link, temp.path()).unwrap_err();
531 let msg = format!("{err:#}");
532 assert!(msg.contains("dangling"), "{msg}");
533 assert!(msg.contains("library"), "{msg}");
534 }
535
536 #[test]
537 fn a_root_swapped_for_a_symlink_during_open_is_rejected() {
538 // The race this guards lives between two adjacent syscalls inside
539 // open_pinned (canonicalize has already run, the name is swapped for
540 // a symlink before the verification stat), so it cannot be produced
541 // end to end deterministically. The post-race state can be: hand
542 // open_pinned a name that is now a symlink to another directory.
543 // A stat that follows the link compares the decoy directory's
544 // identity against the handle the open just got on that same decoy,
545 // and they agree, pinning the context to the wrong library; the
546 // lstat sees the link itself and rejects it.
547 let temp = tempfile::tempdir().unwrap();
548 let real = temp.path().join("photos");
549 let decoy = temp.path().join("other-library");
550 std::fs::create_dir(&real).unwrap();
551 std::fs::create_dir(&decoy).unwrap();
552 // Any name handed to open_pinned plays the canonical name; a symlink
553 // there is exactly the swapped state, since canonicalize never
554 // returns one.
555 let swapped = temp.path().join("swapped");
556 std::os::unix::fs::symlink(&decoy, &swapped).unwrap();
557 let err = open_pinned(&swapped).unwrap_err();
558 let msg = format!("{err:#}");
559 assert!(msg.contains("changed while it was being opened"), "{msg}");
560 assert!(msg.contains("swapped"), "{msg}");
561 }
562
563 #[test]
564 fn an_inaccessible_root_is_rejected_with_its_path() {
565 let temp = tempfile::tempdir().unwrap();
566 let root = temp.path().join("photos");
567 std::fs::create_dir(&root).unwrap();
568 // Root bypasses permission bits entirely (a stock Docker image runs
569 // as root), so the behaviour is probed rather than the uid checked,
570 // same probe the integration suite's permissions_are_enforced uses.
571 let probe = temp.path().join("probe");
572 std::fs::write(&probe, b"x").unwrap();
573 std::fs::set_permissions(&probe, std::fs::Permissions::from_mode(0o000)).unwrap();
574 if std::fs::read(&probe).is_ok() {
575 write_past_test_capture(
576 "SKIP: running as root, so chmod 000 does not block opening a directory\n",
577 );
578 return;
579 }
580 std::fs::set_permissions(&root, std::fs::Permissions::from_mode(0o000)).unwrap();
581 let err = LibraryContext::new(&root, temp.path()).unwrap_err();
582 let msg = format!("{err:#}");
583 assert!(msg.contains("photos"), "{msg}");
584 assert!(msg.contains("denied"), "{msg}");
585 let _ = std::fs::set_permissions(&root, std::fs::Permissions::from_mode(0o755));
586 }
587
588 #[test]
589 fn a_path_operation_past_its_budget_is_cut_off_and_names_the_path() {
590 // A real wedged mount cannot be produced portably, so the bound is
591 // proven the way io_timeout's own tests prove theirs: a body that
592 // sleeps past a tiny budget. The leaked sleeping thread is the
593 // documented tradeoff of run_with_timeout.
594 let start = std::time::Instant::now();
595 let err = bounded_op(
596 Path::new("/Volumes/wedged/library"),
597 "read",
598 Duration::from_millis(50),
599 || {
600 std::thread::sleep(Duration::from_secs(5));
601 Ok::<(), std::io::Error>(())
602 },
603 )
604 .unwrap_err();
605 let msg = format!("{err:#}");
606 assert!(msg.contains("/Volumes/wedged/library"), "{msg}");
607 assert!(msg.contains("did not respond"), "{msg}");
608 assert!(start.elapsed() < Duration::from_secs(2));
609 }
610
611 #[test]
612 fn selecting_home_is_allowed_and_a_child_touches_nothing_outside_itself() {
613 let temp = tempfile::tempdir().unwrap();
614 let home = temp.path().join("home");
615 let photos = home.join("Photos");
616 std::fs::create_dir_all(&photos).unwrap();
617 let cache = temp.path().join("cache");
618 // A HOME is just another directory as far as the context is
619 // concerned: selecting it puts the state in HOME/.videre.
620 let ctx = LibraryContext::new(&home, &cache).unwrap();
621 let home_canonical = std::fs::canonicalize(&home).unwrap();
622 assert_eq!(ctx.paths.db, home_canonical.join(".videre/hashes.db"));
623 // Selecting its Photos child must not reach back out and create the
624 // old global HOME/.videre, which home-based resolution would have.
625 let _child = LibraryContext::new(&photos, &cache).unwrap();
626 assert!(!home.join(".videre").exists());
627 }
628
629 #[test]
630 fn relative_inputs_are_rejected_rather_than_resolved_against_cwd() {
631 // Resolving a relative root would consult the process's cwd, which is
632 // exactly the ambient state this type exists to exclude.
633 let err = LibraryContext::new(Path::new("photos"), Path::new("/cache")).unwrap_err();
634 assert!(format!("{err:#}").contains("absolute"), "{err:#}");
635 let err = LibraryContext::new(Path::new("/tmp/photos"), Path::new("cache")).unwrap_err();
636 assert!(format!("{err:#}").contains("absolute"), "{err:#}");
637 }
638
639 #[test]
640 fn a_new_context_carries_the_built_in_settings() {
641 let temp = tempfile::tempdir().unwrap();
642 let root = temp.path().join("photos");
643 std::fs::create_dir(&root).unwrap();
644 let ctx = LibraryContext::new(&root, &temp.path().join("cache")).unwrap();
645 assert_eq!(
646 ctx.settings.default_model,
647 crate::embeddings::DEFAULT_MODEL_ID
648 );
649 assert_eq!(
650 ctx.settings.xmp_precedence,
651 crate::marks::XmpPrecedence::default()
652 );
653 assert!(!ctx.settings.export_xmp_on_watch);
654 assert_eq!(ctx.settings.min_read_rate_mb_s, None);
655 }
656
657 #[test]
658 fn clones_share_the_pinned_identity_and_the_validation_memo() {
659 let temp = tempfile::tempdir().unwrap();
660 let root = temp.path().join("photos");
661 std::fs::create_dir(&root).unwrap();
662 let ctx = LibraryContext::new(&root, &temp.path().join("cache")).unwrap();
663 let clone = ctx.clone();
664 // The accessor hands out a handle on the pinned directory, so later
665 // stages can read through it rather than through a re-lookup.
666 assert!(ctx.root_handle().metadata().unwrap().is_dir());
667 assert!(!clone.index_validated());
668 ctx.mark_index_validated();
669 assert!(
670 clone.index_validated(),
671 "clones must share the memo, not copy it"
672 );
673 assert!(ctx.ensure_root_identity().is_ok());
674 assert!(clone.ensure_root_identity().is_ok());
675 }
676}