Skip to main content

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}