Skip to main content

coven/
read_handle.rs

1//! The read-only handle: a same-store secondary reader.
2//!
3//! Where [`CovenHandle`](crate::CovenHandle) is the one full handle a host opens to
4//! drive rows, blobs, and sync, [`CovenReadHandle`] is the deliberately narrow
5//! counterpart for a second reader of the *same* store — a separate process (the
6//! macOS File Provider extension) or a second in-process handle — that must read
7//! while another handle holds the writer open.
8//!
9//! It is opened with [`Coven::builder(cfg).open_read_only()`](crate::CovenBuilder::open_read_only)
10//! and exposes reads only: SQL queries against the connection coven owns, and blob
11//! reads (local store, pinned/evictable cache, or a cloud fetch into the cache).
12//! There is no write, sync-manager, migration, or stamp API on it — those are absent
13//! by construction, so a reader cannot mutate the synced state a concurrent writer
14//! owns. Its connection is `SQLITE_OPEN_READONLY`, so even the raw
15//! [`sql_read`](CovenReadHandle::sql_read) closure's `&Connection` refuses DML at
16//! the SQLite layer.
17//!
18//! It takes no store lock: it coexists with the writer that holds the exclusive
19//! `.coven-lock`, and with any number of other read-only opens (a read-only
20//! connection cannot write, so the single-writer lock does not apply to it).
21//! Cross-process reads are safe because the writer opens the db in WAL mode.
22
23use std::sync::Arc;
24
25use crate::blob::cache::BlobCacheError;
26use crate::blob::RowBlobRef;
27use crate::clock::ClockRef;
28use crate::config::Config;
29use crate::coven::{CovenError, CovenResult};
30use crate::database::Database;
31use crate::encryption::SealError;
32use crate::keys::{DeviceIdentityCustody, MasterKeyCustody, StoreKeys};
33use crate::store_dir::StoreDir;
34use crate::sync::storage::SyncStorage;
35use crate::sync::store::StoreDatabase;
36use crate::sync::sync_manager::ConfigProvider;
37
38/// A read-only handle over one coven store, for a same-store secondary reader.
39///
40/// Open it with
41/// [`Coven::builder(cfg).open_read_only()`](crate::CovenBuilder::open_read_only).
42/// Cheap to [`clone`](Clone) — every field is shared (an `Arc` or a `Clone` handle),
43/// so a clone reads the same database and storage as the original.
44///
45/// # What it can do
46///
47/// - **Rows** — read via [`sql_read`](Self::sql_read). The closure receives the
48///   `&Connection` coven owns; because the connection is read-only, any write
49///   statement fails at the SQLite layer.
50/// - **Blobs** — [`read_blob`](Self::read_blob) and
51///   [`open_blob_stream`](Self::open_blob_stream) resolve a blob's locality and serve
52///   it from the local store, the cache, or a cloud fetch into the per-device cache.
53///   [`is_pinned`](Self::is_pinned) reports whether a set is kept offline.
54///
55/// It builds read storage from the current [`Config`] on a cloud miss, exactly as a
56/// home-less full handle does — there is no sync loop to reuse.
57#[derive(Clone)]
58pub struct CovenReadHandle {
59    database: StoreDatabase,
60    store_dir: StoreDir,
61    config_provider: ConfigProvider,
62    key_service: StoreKeys,
63    key_custody: Arc<dyn MasterKeyCustody>,
64    identity_custody: Arc<dyn DeviceIdentityCustody>,
65    clock: ClockRef,
66    cloudkit_ops: Option<Arc<dyn crate::storage::cloud::cloudkit::CloudKitOps>>,
67}
68
69impl CovenReadHandle {
70    #[allow(clippy::too_many_arguments)]
71    pub(crate) fn new(
72        db: Database,
73        store_dir: StoreDir,
74        config_provider: ConfigProvider,
75        key_service: StoreKeys,
76        key_custody: Arc<dyn MasterKeyCustody>,
77        identity_custody: Arc<dyn DeviceIdentityCustody>,
78        clock: ClockRef,
79        cloudkit_ops: Option<Arc<dyn crate::storage::cloud::cloudkit::CloudKitOps>>,
80    ) -> Self {
81        Self {
82            database: StoreDatabase::from_database(db),
83            store_dir,
84            config_provider,
85            key_service,
86            key_custody,
87            identity_custody,
88            clock,
89            cloudkit_ops,
90        }
91    }
92
93    fn config(&self) -> Config {
94        (self.config_provider)()
95    }
96
97    /// Run a pure read against the connection coven owns and await the result.
98    ///
99    /// This is the read handle's form of
100    /// [`CovenHandle::sql_read`](crate::CovenHandle::sql_read): the closure receives
101    /// the `&Connection` directly (coven serializes access on its connection
102    /// thread, so this never races the writer's process), and a host closure
103    /// written against `CovenHandle::sql_read` ports unchanged here. The connection
104    /// is `SQLITE_OPEN_READONLY`: a `SELECT`/`PRAGMA` reads normally, and any
105    /// `INSERT`/`UPDATE`/`DELETE`/DDL is refused by SQLite — the read-only
106    /// guarantee is enforced at the connection, not left to the caller.
107    pub async fn sql_read<F, R>(&self, f: F) -> CovenResult<R>
108    where
109        F: FnOnce(&rusqlite::Connection) -> CovenResult<R> + Send + 'static,
110        R: Send + 'static,
111    {
112        let outcome = self
113            .database
114            .sqlite()
115            .call(move |conn| Ok(f(conn)))
116            .await
117            .map_err(CovenError::from)?;
118        outcome
119    }
120
121    /// The read [`SyncStorage`] for a cloud miss, or `None` for a home-less store.
122    ///
123    /// A read-only handle holds no sync manager, so — unlike the full handle — it
124    /// always builds storage from the current [`Config`]: `None` when no provider is
125    /// configured (a home-less store holds only Local blobs, which never reach the
126    /// cloud-miss path), `Some` built from config otherwise. A provider configured
127    /// but unbuildable (missing credentials) surfaces its setup error.
128    async fn blob_storage(
129        &self,
130    ) -> Result<Option<Arc<dyn SyncStorage>>, crate::storage::cloud::setup::StorageSetupError> {
131        let config = self.config();
132        if config.cloud_home.provider.is_none() {
133            return Ok(None);
134        }
135        let storage = crate::storage::cloud::setup::create_sync_storage_with_cloudkit(
136            &config,
137            &self.key_service,
138            self.key_custody.as_ref(),
139            self.identity_custody.as_ref(),
140            None,
141            self.clock.clone(),
142            self.cloudkit_ops.clone(),
143        )
144        .await?;
145        Ok(Some(Arc::new(storage)))
146    }
147
148    /// Capture the exact current blob-bearing row version from this reader's
149    /// database snapshot.
150    pub async fn row_blob_ref(
151        &self,
152        table: &str,
153        row_id: &str,
154    ) -> Result<RowBlobRef, crate::database::DbError> {
155        self.database.sqlite().row_blob_ref(table, row_id).await
156    }
157
158    /// Read a blob's whole plaintext through coven's locality-aware read: served from
159    /// the user's file (Local user-provided), coven's local store (Local
160    /// host-provided), the pinned/evictable cache on a Remote hit, or fetched from
161    /// the cloud into the cache on a Remote miss. The read counterpart of
162    /// [`CovenHandle::read_blob`](crate::CovenHandle::read_blob).
163    ///
164    /// A cloud fetch writes the fetched bytes into the per-device cache
165    /// (`storage/cache/`) with an atomic temp-then-rename — device scratch, no synced
166    /// state touched — so a File Provider materializing remote content works through a
167    /// read-only handle. The supplied [`RowBlobRef`] already carries the exact stored
168    /// object and authority, so the read performs no database write or cloud listing.
169    pub async fn read_blob(&self, blob: &RowBlobRef) -> Result<Vec<u8>, BlobCacheError> {
170        let storage = self.blob_storage().await?;
171        crate::sync::store::blob::read_blob(
172            &self.database,
173            &self.store_dir,
174            storage.as_deref(),
175            blob,
176        )
177        .await
178    }
179
180    /// Serve `len` plaintext bytes of an exact row blob starting at `offset`, for
181    /// streaming or seeking without loading the whole file. The [`RowBlobRef`]
182    /// carries the plaintext length used to bound the range. The ranged sibling of
183    /// [`read_blob`](Self::read_blob); a Remote-miss range read fetches from the cloud
184    /// but writes no cache file (only a whole-file read populates).
185    pub async fn open_blob_stream(
186        &self,
187        blob: &RowBlobRef,
188        offset: u64,
189        len: u64,
190    ) -> Result<Vec<u8>, BlobCacheError> {
191        let storage = self.blob_storage().await?;
192        crate::sync::store::blob::open_blob_stream(
193            &self.database,
194            &self.store_dir,
195            storage.as_deref(),
196            blob,
197            offset,
198            len,
199        )
200        .await
201    }
202
203    /// Open a payload
204    /// [`CovenHandle::seal_app_data`](crate::CovenHandle::seal_app_data) produced,
205    /// resolving the store's master keyring through this handle's custody. The read
206    /// side of app-data sealing: a secondary reader opens what the writer sealed,
207    /// under whichever generation the payload names.
208    ///
209    /// There is no seal counterpart here — sealing writes new ciphertext, which is
210    /// the writer's job; this handle only reads.
211    ///
212    /// [`SealError::Locked`] if the store is locked; a wrong `aad`, a tampered
213    /// payload, an unreadable version, or a generation this store's keyring lacks
214    /// each surface their own typed error.
215    pub fn open_app_data(&self, sealed: &[u8], aad: &[u8]) -> Result<Vec<u8>, SealError> {
216        crate::handle::app_data_cipher(self.key_custody.as_ref())?.open_app_data(sealed, aad)
217    }
218
219    /// Whether every blob in `blobs` is pinned for offline — present in coven's kept
220    /// cache folder (`storage/pinned/`). An empty set is vacuously pinned. A read; it
221    /// stats the folder, never writes.
222    pub async fn is_pinned(&self, blobs: &[RowBlobRef]) -> Result<bool, BlobCacheError> {
223        for blob in blobs {
224            if !crate::blob::cache::is_pinned(self.database.sqlite(), &self.store_dir, blob).await?
225            {
226                return Ok(false);
227            }
228        }
229        Ok(true)
230    }
231}