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}