use std::collections::HashMap; use std::io; use std::path::PathBuf; use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::{Arc, Mutex, MutexGuard}; use avril_caps::{Capabilities, PathTarget}; use avril_kv::{Access, Store}; fn denied(label: &str, write: bool) -> io::Error { io::Error::new( io::ErrorKind::PermissionDenied, format!("{} {label}", if write { "write" } else { "read" }), ) } fn authorize(caps: &Capabilities, target: &PathTarget, label: &str, write: bool) -> io::Result<()> { if write { caps.check_write(target) } else { caps.check_read(target) } .map_err(|_| denied(label, write)) } fn absolute(caps: &Capabilities, label: &str) -> io::Result<(PathBuf, PathTarget)> { if label.is_empty() { return Err(io::Error::new( io::ErrorKind::NotFound, "Empty kv database path", )); } let path = caps.cwd().join(label); // Resolve the existing prefix like ext:file does; a missing tail is // expected when creating, so refusal to resolve only denies when the // grant could not have covered the path anyway. let target = match PathTarget::for_creation(&path) { Ok(target) => target, Err(_) if !caps.unrestricted_read() && !caps.unrestricted_write() => { return Err(denied(label, false)); } Err(error) => return Err(error), }; Ok((path, target)) } struct Auth { caps: Arc, target: PathTarget, label: String, } pub struct Entry { state: Mutex, } struct EntryState { store: Option, auth: Auth, } pub struct Registry { next: AtomicU64, entries: Mutex>>, } impl Default for Registry { fn default() -> Self { Self::new() } } fn closed_handle() -> io::Error { io::Error::new(io::ErrorKind::NotConnected, "kv database handle is closed") } impl Registry { pub fn new() -> Self { Self { next: AtomicU64::new(1), entries: Mutex::new(HashMap::new()), } } fn lock_entries(&self) -> MutexGuard<'_, HashMap>> { self.entries .lock() .unwrap_or_else(|error| error.into_inner()) } fn entry(&self, id: u64) -> io::Result> { self.lock_entries().get(&id).cloned().ok_or_else(|| { io::Error::new( io::ErrorKind::NotConnected, "kv database handle is closed or unknown", ) }) } pub fn with_entry( &self, id: u64, write: bool, work: impl FnOnce(&Store) -> io::Result, ) -> io::Result { let entry = self.entry(id)?; let state = entry .state .lock() .unwrap_or_else(|error| error.into_inner()); let Some(store) = state.store.as_ref() else { return Err(closed_handle()); }; // Re-authorize against the live Capabilities on every operation so a // pledge() narrowing applies to already-open handles. Writes need // read access too: a write transaction reads internally. let auth = &state.auth; authorize(&auth.caps, &auth.target, &auth.label, false)?; if write { authorize(&auth.caps, &auth.target, &auth.label, true)?; } // The entry mutex stays held for the whole operation: close() takes // the store under the same mutex, so nothing is in flight when the // last Arc drops and the database file is physically released. work(store) } pub fn open( &self, caps: &Arc, label: &str, access: &str, create: bool, ) -> io::Result { let access = Access::parse(access)?; let (path, target) = absolute(caps, label)?; authorize(caps, &target, label, false)?; if access.writes() { authorize(caps, &target, label, true)?; } let store = Store::open(&path, access, create)?; let id = self.next.fetch_add(1, Ordering::Relaxed); self.lock_entries().insert( id, Arc::new(Entry { state: Mutex::new(EntryState { store: Some(store), auth: Auth { caps: caps.clone(), target, label: label.to_owned(), }, }), }), ); Ok(id) } // Idempotent: closing an unknown or already-closed id succeeds. Taking // the store under the entry mutex physically releases the database: // with_entry holds that mutex for its whole operation, so nothing is in // flight when close returns. pub fn close(&self, id: u64) -> io::Result<()> { let entry = self.lock_entries().remove(&id); if let Some(entry) = entry { let mut state = entry .state .lock() .unwrap_or_else(|error| error.into_inner()); state.store.take(); drop(state); } Ok(()) } }