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