use crate::io_timeout;
use crate::library_config::LibraryConfig;
use anyhow::{bail, Context, Result};
use std::fs::File;
use std::os::unix::ffi::OsStrExt;
use std::os::unix::fs::MetadataExt;
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use std::time::Duration;
pub(crate) const STATE_DIR: &str = ".videre";
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LibraryPaths {
pub root: PathBuf,
pub state: PathBuf,
pub db: PathBuf,
pub config: PathBuf,
pub jsonl: PathBuf,
pub embeddings: PathBuf,
pub locks: PathBuf,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CachePaths {
pub base: PathBuf,
pub thumbnails: PathBuf,
pub geo: PathBuf,
}
#[derive(Debug)]
struct Shared {
root_handle: File,
root_dev: u64,
root_ino: u64,
index_validated: Mutex<bool>,
}
#[derive(Clone, Debug)]
pub struct LibraryContext {
pub paths: LibraryPaths,
pub cache: CachePaths,
pub settings: LibraryConfig,
identity: Arc<Shared>,
}
fn paths_for(root: PathBuf) -> LibraryPaths {
let state = root.join(STATE_DIR);
LibraryPaths {
root,
db: state.join("hashes.db"),
config: state.join("config.toml"),
jsonl: state.join("hashes.jsonl"),
embeddings: state.join("embeddings"),
locks: state.join("locks"),
state,
}
}
fn cache_for(base: &Path, paths: &LibraryPaths) -> CachePaths {
let key = blake3::hash(paths.root.as_os_str().as_bytes()).to_hex();
CachePaths {
base: base.to_path_buf(),
thumbnails: base
.join("videre/libraries")
.join(key.as_str())
.join("thumbnails"),
geo: base.join("videre/geo"),
}
}
pub(crate) fn bounded_op<T, F>(path: &Path, op: &str, budget: Duration, f: F) -> Result<T>
where
T: Send + 'static,
F: FnOnce() -> std::io::Result<T> + Send + 'static,
{
match io_timeout::run_with_timeout(budget, f) {
Ok(Ok(value)) => Ok(value),
Ok(Err(e)) => Err(anyhow::Error::new(e).context(format!("{} {}", op, path.display()))),
Err(io_timeout::TimedOut) => bail!(
"could not {} {} after {}s (the drive did not respond - is it connected?)",
op,
path.display(),
budget.as_secs()
),
}
}
fn dir_identity(meta: &std::fs::Metadata) -> (u64, u64) {
(meta.dev(), meta.ino())
}
pub(crate) fn root_cause_is_not_found(e: &anyhow::Error) -> bool {
e.root_cause()
.downcast_ref::<std::io::Error>()
.is_some_and(|io| io.kind() == std::io::ErrorKind::NotFound)
}
fn reject_reserved(path: &Path) -> Result<()> {
if path.file_name() == Some(std::ffi::OsStr::new(STATE_DIR)) {
bail!(
"{} is videre's reserved state directory inside a library, not a library; select the directory that contains it",
path.display()
);
}
Ok(())
}
fn canonical_root(root: &Path) -> Result<(PathBuf, bool)> {
let owned = root.to_path_buf();
let meta = match bounded_op(root, "read", io_timeout::STAT_TIMEOUT, move || {
std::fs::symlink_metadata(&owned)
}) {
Ok(meta) => meta,
Err(e) if root_cause_is_not_found(&e) => {
bail!("library root {} does not exist", root.display())
}
Err(e) => return Err(e),
};
let is_symlink = meta.file_type().is_symlink();
let owned = root.to_path_buf();
match bounded_op(root, "resolve", io_timeout::STAT_TIMEOUT, move || {
std::fs::canonicalize(&owned)
}) {
Ok(canonical) => Ok((canonical, is_symlink)),
Err(e) if is_symlink && root_cause_is_not_found(&e) => bail!(
"library root {} is a dangling symlink: its target does not exist",
root.display()
),
Err(e) => Err(e),
}
}
fn open_pinned(canonical: &Path) -> Result<(File, std::fs::Metadata)> {
let owned = canonical.to_path_buf();
let handle = bounded_op(canonical, "open", io_timeout::STAT_TIMEOUT, move || {
File::open(&owned)
})?;
let dup = handle
.try_clone()
.with_context(|| format!("dupe handle on {}", canonical.display()))?;
let meta = bounded_op(canonical, "stat", io_timeout::STAT_TIMEOUT, move || {
dup.metadata()
})?;
if !meta.is_dir() {
bail!("library root {} is not a directory", canonical.display());
}
let pinned = dir_identity(&meta);
let owned = canonical.to_path_buf();
let named = bounded_op(canonical, "read", io_timeout::STAT_TIMEOUT, move || {
std::fs::symlink_metadata(&owned)
})?;
if !named.is_dir() {
bail!(
"library root {} changed while it was being opened: it is no longer a directory",
canonical.display()
);
}
if dir_identity(&named) != pinned {
bail!(
"library root {} changed while it was being opened",
canonical.display()
);
}
Ok((handle, meta))
}
impl LibraryContext {
pub fn new(root: &Path, cache_base: &Path) -> Result<Self> {
if !root.is_absolute() {
bail!(
"library root must be an absolute path, got {}",
root.display()
);
}
if !cache_base.is_absolute() {
bail!(
"cache base must be an absolute path, got {}",
cache_base.display()
);
}
reject_reserved(root)?;
let (canonical, _) = canonical_root(root)?;
reject_reserved(&canonical)?;
let (handle, meta) = open_pinned(&canonical)?;
let paths = paths_for(canonical);
let cache = cache_for(cache_base, &paths);
crate::library_locks::reject_dir_redirect(&paths.state, "the library state directory")?;
crate::library_locks::reject_redirect(&paths.config, "the library config")?;
let settings = crate::library_config::load(&paths)?;
Ok(Self {
paths,
cache,
settings,
identity: Arc::new(Shared {
root_handle: handle,
root_dev: meta.dev(),
root_ino: meta.ino(),
index_validated: Mutex::new(false),
}),
})
}
pub fn ensure_root_identity(&self) -> Result<()> {
let canonical = self.paths.root.clone();
match bounded_op(
&self.paths.root,
"read",
io_timeout::STAT_TIMEOUT,
move || std::fs::metadata(&canonical),
) {
Ok(meta) => {
if dir_identity(&meta) != (self.identity.root_dev, self.identity.root_ino) {
bail!(
"library root {} no longer names the directory this context was opened for; the folder was replaced, renamed, or its volume unmounted",
self.paths.root.display()
);
}
Ok(())
}
Err(e) if root_cause_is_not_found(&e) => bail!(
"library root {} no longer exists",
self.paths.root.display()
),
Err(e) => Err(e),
}
}
pub(crate) fn root_handle(&self) -> &File {
&self.identity.root_handle
}
pub(crate) fn index_validated(&self) -> bool {
*self.identity.index_validated.lock().unwrap()
}
pub(crate) fn mark_index_validated(&self) {
*self.identity.index_validated.lock().unwrap() = true;
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::library_test_support::write_past_test_capture;
use std::os::unix::fs::PermissionsExt;
#[test]
fn paths_are_local_and_root_aliases_share_identity() {
let temp = tempfile::tempdir().unwrap();
let root = temp.path().join("photos");
let alias = temp.path().join("alias");
std::fs::create_dir(&root).unwrap();
std::os::unix::fs::symlink(&root, &alias).unwrap();
let cache = temp.path().join("cache");
let a = LibraryContext::new(&root, &cache).unwrap();
let b = LibraryContext::new(&alias, &cache).unwrap();
let canonical = std::fs::canonicalize(&root).unwrap();
assert_eq!(a.paths.db, canonical.join(".videre/hashes.db"));
assert_eq!(a.cache.thumbnails, b.cache.thumbnails);
assert!(!a.paths.state.exists());
assert!(!cache.exists());
assert!(a.ensure_root_identity().is_ok());
std::fs::rename(&root, temp.path().join("old")).unwrap();
std::fs::create_dir(&root).unwrap();
assert!(a.ensure_root_identity().is_err());
assert!(b.ensure_root_identity().is_err());
}
#[test]
fn sibling_roots_get_distinct_cache_namespaces() {
let temp = tempfile::tempdir().unwrap();
let one = temp.path().join("2024");
let two = temp.path().join("2025");
std::fs::create_dir(&one).unwrap();
std::fs::create_dir(&two).unwrap();
let cache = temp.path().join("cache");
let a = LibraryContext::new(&one, &cache).unwrap();
let b = LibraryContext::new(&two, &cache).unwrap();
assert_ne!(a.cache.thumbnails, b.cache.thumbnails);
let key = blake3::hash(a.paths.root.as_os_str().as_bytes()).to_hex();
assert_eq!(
a.cache.thumbnails,
cache
.join("videre/libraries")
.join(key.as_str())
.join("thumbnails")
);
assert_eq!(a.cache.geo, cache.join("videre/geo"));
assert_eq!(b.cache.geo, a.cache.geo);
}
#[test]
fn context_rejects_redirected_state_before_loading_its_config() {
let temp = tempfile::tempdir().unwrap();
let root = temp.path().join("photos");
let outside = temp.path().join("outside-state");
std::fs::create_dir(&root).unwrap();
std::fs::create_dir(&outside).unwrap();
std::fs::write(outside.join("config.toml"), "min_read_rate_mb_s = 7\n").unwrap();
std::os::unix::fs::symlink(&outside, root.join(".videre")).unwrap();
let err = LibraryContext::new(&root, &temp.path().join("cache")).unwrap_err();
assert!(format!("{err:#}").contains("symlink"), "{err:#}");
}
#[test]
fn a_missing_root_is_rejected_with_its_path() {
let temp = tempfile::tempdir().unwrap();
let missing = temp.path().join("nowhere");
let err = LibraryContext::new(&missing, temp.path()).unwrap_err();
let msg = format!("{err:#}");
assert!(msg.contains("does not exist"), "{msg}");
assert!(msg.contains("nowhere"), "{msg}");
assert!(!msg.contains("did not respond"), "{msg}");
}
#[test]
fn a_file_where_the_root_should_be_is_rejected() {
let temp = tempfile::tempdir().unwrap();
let file = temp.path().join("library.txt");
std::fs::write(&file, b"not a library").unwrap();
let err = LibraryContext::new(&file, temp.path()).unwrap_err();
let msg = format!("{err:#}");
assert!(msg.contains("not a directory"), "{msg}");
assert!(msg.contains("library.txt"), "{msg}");
}
#[test]
fn a_directory_named_videre_is_rejected() {
let temp = tempfile::tempdir().unwrap();
let state = temp.path().join("photos/.videre");
std::fs::create_dir_all(&state).unwrap();
let err = LibraryContext::new(&state, temp.path()).unwrap_err();
let msg = format!("{err:#}");
assert!(msg.contains(".videre"), "{msg}");
assert!(msg.contains("reserved"), "{msg}");
assert!(msg.contains("photos"), "{msg}");
let alias = temp.path().join("shortcut");
std::os::unix::fs::symlink(&state, &alias).unwrap();
let err = LibraryContext::new(&alias, temp.path()).unwrap_err();
assert!(format!("{err:#}").contains("reserved"));
}
#[test]
fn a_dangling_root_symlink_is_rejected() {
let temp = tempfile::tempdir().unwrap();
let link = temp.path().join("library");
std::os::unix::fs::symlink(temp.path().join("gone"), &link).unwrap();
let err = LibraryContext::new(&link, temp.path()).unwrap_err();
let msg = format!("{err:#}");
assert!(msg.contains("dangling"), "{msg}");
assert!(msg.contains("library"), "{msg}");
}
#[test]
fn a_root_swapped_for_a_symlink_during_open_is_rejected() {
let temp = tempfile::tempdir().unwrap();
let real = temp.path().join("photos");
let decoy = temp.path().join("other-library");
std::fs::create_dir(&real).unwrap();
std::fs::create_dir(&decoy).unwrap();
let swapped = temp.path().join("swapped");
std::os::unix::fs::symlink(&decoy, &swapped).unwrap();
let err = open_pinned(&swapped).unwrap_err();
let msg = format!("{err:#}");
assert!(msg.contains("changed while it was being opened"), "{msg}");
assert!(msg.contains("swapped"), "{msg}");
}
#[test]
fn an_inaccessible_root_is_rejected_with_its_path() {
let temp = tempfile::tempdir().unwrap();
let root = temp.path().join("photos");
std::fs::create_dir(&root).unwrap();
let probe = temp.path().join("probe");
std::fs::write(&probe, b"x").unwrap();
std::fs::set_permissions(&probe, std::fs::Permissions::from_mode(0o000)).unwrap();
if std::fs::read(&probe).is_ok() {
write_past_test_capture(
"SKIP: running as root, so chmod 000 does not block opening a directory\n",
);
return;
}
std::fs::set_permissions(&root, std::fs::Permissions::from_mode(0o000)).unwrap();
let err = LibraryContext::new(&root, temp.path()).unwrap_err();
let msg = format!("{err:#}");
assert!(msg.contains("photos"), "{msg}");
assert!(msg.contains("denied"), "{msg}");
let _ = std::fs::set_permissions(&root, std::fs::Permissions::from_mode(0o755));
}
#[test]
fn a_path_operation_past_its_budget_is_cut_off_and_names_the_path() {
let start = std::time::Instant::now();
let err = bounded_op(
Path::new("/Volumes/wedged/library"),
"read",
Duration::from_millis(50),
|| {
std::thread::sleep(Duration::from_secs(5));
Ok::<(), std::io::Error>(())
},
)
.unwrap_err();
let msg = format!("{err:#}");
assert!(msg.contains("/Volumes/wedged/library"), "{msg}");
assert!(msg.contains("did not respond"), "{msg}");
assert!(start.elapsed() < Duration::from_secs(2));
}
#[test]
fn selecting_home_is_allowed_and_a_child_touches_nothing_outside_itself() {
let temp = tempfile::tempdir().unwrap();
let home = temp.path().join("home");
let photos = home.join("Photos");
std::fs::create_dir_all(&photos).unwrap();
let cache = temp.path().join("cache");
let ctx = LibraryContext::new(&home, &cache).unwrap();
let home_canonical = std::fs::canonicalize(&home).unwrap();
assert_eq!(ctx.paths.db, home_canonical.join(".videre/hashes.db"));
let _child = LibraryContext::new(&photos, &cache).unwrap();
assert!(!home.join(".videre").exists());
}
#[test]
fn relative_inputs_are_rejected_rather_than_resolved_against_cwd() {
let err = LibraryContext::new(Path::new("photos"), Path::new("/cache")).unwrap_err();
assert!(format!("{err:#}").contains("absolute"), "{err:#}");
let err = LibraryContext::new(Path::new("/tmp/photos"), Path::new("cache")).unwrap_err();
assert!(format!("{err:#}").contains("absolute"), "{err:#}");
}
#[test]
fn a_new_context_carries_the_built_in_settings() {
let temp = tempfile::tempdir().unwrap();
let root = temp.path().join("photos");
std::fs::create_dir(&root).unwrap();
let ctx = LibraryContext::new(&root, &temp.path().join("cache")).unwrap();
assert_eq!(
ctx.settings.default_model,
crate::embeddings::DEFAULT_MODEL_ID
);
assert_eq!(
ctx.settings.xmp_precedence,
crate::marks::XmpPrecedence::default()
);
assert!(!ctx.settings.export_xmp_on_watch);
assert_eq!(ctx.settings.min_read_rate_mb_s, None);
}
#[test]
fn clones_share_the_pinned_identity_and_the_validation_memo() {
let temp = tempfile::tempdir().unwrap();
let root = temp.path().join("photos");
std::fs::create_dir(&root).unwrap();
let ctx = LibraryContext::new(&root, &temp.path().join("cache")).unwrap();
let clone = ctx.clone();
assert!(ctx.root_handle().metadata().unwrap().is_dir());
assert!(!clone.index_validated());
ctx.mark_index_validated();
assert!(
clone.index_validated(),
"clones must share the memo, not copy it"
);
assert!(ctx.ensure_root_identity().is_ok());
assert!(clone.ensure_root_identity().is_ok());
}
}