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)) => 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}