Skip to main content

coven/
coven.rs

1//! This app's stores on this device: where they live, the keychain their
2//! entries are kept in, and the device's network; and the calls that create,
3//! list, locate, open and remove them (E1).
4
5use crate::{
6    BootstrapError, CovenBuilder, CovenError, DbError, FileError, SavedRestoreEntry,
7    StoreDeletionError, StoreDir, StoreId, StoreLockError,
8};
9use coven_crypto::custody::{KeyError, Keychain, StoreKeychain};
10use coven_foundation::files::{ReplacedBy, StoreDirAccess, StoreDirectories};
11use coven_foundation::network_watch::NetworkSource;
12use std::path::PathBuf;
13use std::sync::Arc;
14
15/// This app's stores on this device (E1): the app directory each has its own
16/// directory under, the OS keychain service their keys and secrets are kept
17/// under, and the device's network, which each open store watches. The app
18/// builds one at startup and holds it.
19#[derive(Clone)]
20pub struct Coven {
21    directories: StoreDirectories,
22    keychain: Arc<Keychain>,
23    network: NetworkSource,
24}
25
26/// A store on this device (E1).
27#[derive(Clone, Debug, PartialEq, Eq)]
28pub struct StoreInfo {
29    /// The store's identity.
30    pub id: StoreId,
31    /// The current name from this device's committed database state. While
32    /// a replacement is unfinished, the name its new installation loaded.
33    pub name: String,
34    /// The work that must finish before the store opens, if any.
35    pub unfinished: Option<Unfinished>,
36}
37
38/// A closed store's sync status, as `Coven::closed_sync_status` reads it
39/// (E1): the state opening shows before sync starts, and what deleting the
40/// store would lose. A queued write or file waits to sync with `NotSyncing`,
41/// as sync isn't running; `unsent` counts them.
42#[derive(Clone, Debug, PartialEq)]
43pub struct ClosedSyncStatus {
44    /// The sync state and the last synced time.
45    pub sync: crate::SyncStatus,
46    /// The writes and files this device committed and has not uploaded.
47    pub unsent: crate::UnsentWork,
48}
49
50/// Work on a store that must finish before it opens (E1).
51#[derive(Clone, Copy, Debug, PartialEq, Eq)]
52pub enum Unfinished {
53    /// A replacement of this device's installation, which the call it names
54    /// finishes, or `CovenBuilder::resume_replacement` (§19.3).
55    Replacement(ReplacedBy),
56    /// This device is deleting the store and leaving it, its removal
57    /// journaled or about to be: `CovenBuilder::resume_deletion` finishes it
58    /// (§11.6).
59    Deletion,
60}
61
62/// A store on this device as the listing found it (E1).
63#[derive(Debug)]
64pub enum ListedStore {
65    /// Its name and state read.
66    Readable(StoreInfo),
67    /// Its replacement record or name can't be read, for `reason`. The app
68    /// shows it as damaged and can offer `discard_store` for it.
69    Unreadable {
70        /// The store whose record or name can't be read.
71        id: StoreId,
72        /// Why it can't be read.
73        reason: CovenError,
74    },
75}
76
77impl ListedStore {
78    /// The store this entry lists.
79    pub fn id(&self) -> StoreId {
80        match self {
81            Self::Readable(info) => info.id,
82            Self::Unreadable { id, .. } => *id,
83        }
84    }
85}
86
87/// A failure listing the stores. A store that can't be read is not one: it
88/// is listed as `ListedStore::Unreadable`.
89#[derive(Debug, thiserror::Error)]
90pub enum StoreListError {
91    /// The directory listing or an identity settings read failed.
92    #[error(transparent)]
93    File(FileError),
94    /// The runtime is shutting down and dropped the listing's work (E15).
95    #[error(transparent)]
96    Shutdown(#[from] crate::task::Shutdown),
97    /// Listing the stores panicked on the blocking pool: a bug, which the
98    /// caller reports (E15).
99    #[error(transparent)]
100    Bug(#[from] crate::task::Bug),
101    /// Finishing the recorded deletion of `store` failed (E1). The store is
102    /// not listed, and the next listing finishes the deletion.
103    #[error("finishing the deletion of store {store} failed: {source}")]
104    Deletion {
105        /// The store being deleted.
106        store: StoreId,
107        /// Why its deletion stopped.
108        #[source]
109        source: StoreDeletionError,
110    },
111}
112
113impl From<FileError> for StoreListError {
114    fn from(error: FileError) -> Self {
115        match error {
116            FileError::Bug(bug) => Self::Bug(bug),
117            FileError::Shutdown(shutdown) => Self::Shutdown(shutdown),
118            error => Self::File(error),
119        }
120    }
121}
122
123impl crate::task::CarriesBug for StoreListError {
124    fn bug(&self) -> Option<&crate::task::Bug> {
125        match self {
126            Self::Bug(bug) => Some(bug),
127            Self::File(error) => error.bug(),
128            Self::Deletion { source, .. } => source.bug(),
129            Self::Shutdown(_) => None,
130        }
131    }
132}
133
134impl Coven {
135    /// The stores under `app_dir`, their keys and secrets in the OS keychain
136    /// under the service `keyring_service`, watching the platform's network.
137    /// The app creates `app_dir`; coven creates directories only inside it
138    /// and never opens a directory above it, which Android refuses. No key
139    /// is read here. A bug in work nobody waits for, in any store, goes to
140    /// `bugs`, the app's crash reporter (E15). An empty service name, or one
141    /// containing NUL, fails with `KeyError::InvalidServiceName`; an OS
142    /// keychain coven can't reach fails with its `KeyError`.
143    pub fn new(
144        app_dir: PathBuf,
145        keyring_service: &str,
146        bugs: crate::task::BugSinkRef,
147    ) -> Result<Self, KeyError> {
148        Ok(Self {
149            directories: StoreDirectories::new(app_dir, bugs),
150            keychain: Keychain::native(keyring_service)?,
151            network: NetworkSource::Platform,
152        })
153    }
154
155    /// Read the saved storage location without opening the store or its custody.
156    /// None means storage has never been set up; once set up, the location stays.
157    pub async fn storage_config(
158        store_dir: &StoreDir,
159    ) -> Result<Option<crate::StorageConfig>, crate::SyncError> {
160        let store_dir = store_dir.clone();
161        let bugs = store_dir.bugs().clone();
162        crate::task::blocking_completing("read the storage settings", bugs, move || {
163            coven_storage::recorded_config(&store_dir)
164        })
165        .await?
166        .map_err(crate::SyncError::from)
167    }
168
169    /// A closed store's sync state, last synced time and unsent work, read
170    /// without opening it (E1, E5): the state opening shows before sync
171    /// starts. It holds the store's writer lock while it reads, since only
172    /// that lock shows that no handle runs the store's sync: an open handle,
173    /// here or in another process, refuses it with
174    /// `StoreLockError::AlreadyOpen` at once, and the app reads that handle.
175    /// An instance closing in this process is waited for first, and an open
176    /// of the store in this process meanwhile waits for this read. Whether
177    /// this device holds the location's credentials is read from the
178    /// keychain, as opening reads it. A store whose replacement or deletion
179    /// is unfinished is refused as opening refuses it, and one whose device
180    /// entry the keychain doesn't hold fails with `DeviceKeyMissing`.
181    pub async fn closed_sync_status(
182        &self,
183        store_dir: &StoreDir,
184    ) -> Result<ClosedSyncStatus, CovenError> {
185        let lock = store_dir.lock_closed().await?;
186        let location = {
187            let directory = store_dir.clone();
188            let keys = StoreKeychain::new(self.keychain.clone(), store_dir.id());
189            let bugs = store_dir.bugs().clone();
190            crate::task::blocking_completing("read the closed store's location", bugs, move || {
191                let store = directory.id();
192                crate::builder::refuse_unfinished_deletion(&directory, store)?;
193                // A copy whose keychain no longer names its device doesn't
194                // open, so it has no state to show (§19.3).
195                if keys.device_id()? != Some(directory.settings()?.device_id) {
196                    return Err(CovenError::DeviceKeyMissing { store });
197                }
198                coven_sync::read_location(&directory, &keys).map_err(CovenError::from)
199            })
200            .await??
201        };
202        let (facts, unsent) = coven_database::closed_sync_facts(store_dir.clone(), lock).await?;
203        let sync = coven_sync::SyncStatus::closed(facts, location)?;
204        Ok(ClosedSyncStatus { sync, unsent })
205    }
206
207    /// Keep SQLite's temporary tables in `directory`, such as the app's cache
208    /// folder, for every store this process opens. An Android app calls this
209    /// before opening its first store: SQLite finds no folder there by itself,
210    /// and temporary tables that outgrow a connection's cache fail without
211    /// one. Elsewhere SQLite uses the system's.
212    pub fn set_temporary_directory(
213        directory: &std::path::Path,
214    ) -> Result<(), crate::TemporaryDirectoryError> {
215        coven_database::set_temporary_directory(directory)
216    }
217
218    /// Stores on this device, ordered by id, with their committed names.
219    /// A store whose replacement record or name can't be read is listed as
220    /// `Unreadable` with why; a listing never substitutes an older name.
221    /// A store whose replacement or deletion is unfinished lists with the
222    /// work that must finish, so the app can offer to finish it; an
223    /// unfinished first installation is not listed. First it finishes every
224    /// deletion from the device that a cut call left under the directory's
225    /// deletion name, which no call holds (§19.3, E1); a failure there fails
226    /// the listing with `StoreListError::Deletion`.
227    pub async fn stores(&self) -> Result<Vec<ListedStore>, StoreListError> {
228        for store in self.directories.recorded_deletions().await? {
229            let directory = self.store_dir(&store);
230            let keys = StoreKeychain::new(self.keychain.clone(), store);
231            match finish_deletion(&directory, keys).await {
232                Ok(()) => tracing::info!(%store, "finished a recorded deletion"),
233                // A call is deleting it now, and finishes it.
234                Err(StoreDeletionError::Lock(StoreLockError::AlreadyOpen(_))) => {
235                    tracing::debug!(%store, "a call holds a recorded deletion")
236                }
237                Err(StoreDeletionError::Bug(bug)) => return Err(StoreListError::Bug(bug)),
238                Err(StoreDeletionError::Shutdown(shutdown)) => {
239                    return Err(StoreListError::Shutdown(shutdown))
240                }
241                Err(source) => return Err(StoreListError::Deletion { store, source }),
242            }
243        }
244        let mut stores = Vec::new();
245        for id in self.directories.store_ids().await? {
246            stores.push(match self.read_listed(id).await {
247                Ok(info) => ListedStore::Readable(info),
248                Err(error) => {
249                    if let Some(bug) = crate::task::CarriesBug::bug(&error) {
250                        return Err(StoreListError::Bug(bug.clone()));
251                    }
252                    match error {
253                        CovenError::Shutdown(shutdown)
254                        | CovenError::Database(DbError::Shutdown(shutdown)) => {
255                            return Err(StoreListError::Shutdown(shutdown));
256                        }
257                        reason => ListedStore::Unreadable { id, reason },
258                    }
259                }
260            });
261        }
262        Ok(stores)
263    }
264
265    async fn read_listed(&self, id: StoreId) -> Result<StoreInfo, CovenError> {
266        let directory = self.store_dir(&id);
267        let (replacement, deletion) = {
268            let directory = directory.clone();
269            let bugs = directory.bugs().clone();
270            crate::task::blocking_completing("read the store's records", bugs, move || {
271                Ok::<_, CovenError>((
272                    directory.unfinished_replacement()?,
273                    directory.unfinished_deletion().map_err(DbError::from)?,
274                ))
275            })
276            .await??
277        };
278        Ok(match replacement {
279            Some(record) => StoreInfo {
280                id,
281                name: record.name,
282                unfinished: Some(Unfinished::Replacement(record.by)),
283            },
284            None => StoreInfo {
285                id,
286                name: coven_database::read_store_name(directory).await?,
287                unfinished: deletion.map(|_| Unfinished::Deletion),
288            },
289        })
290    }
291
292    /// Address a store without reading settings or acquiring the writer lock.
293    pub fn store_dir(&self, id: &StoreId) -> StoreDir {
294        self.directories.store_dir(id)
295    }
296
297    /// Configure creating, opening, restoring or joining a store.
298    pub fn builder(&self) -> CovenBuilder {
299        CovenBuilder::new(self.directories.clone(), self.keychain.clone(), self.network.clone())
300    }
301
302    /// Deletes a closed store from this device without opening it: this
303    /// device's own keychain entries for it, including every saved host
304    /// secret, then its directory. The person's saved restore code and S3 key
305    /// stay for their next devices; they go only when the
306    /// person leaves the store (§13.1). Unsent writes and files are lost, and
307    /// this device stays registered with the store until it is removed; prefer
308    /// `CovenHandle::delete_store`. Use this for a store that cannot open, or
309    /// to finish a handle's deletion that failed after closing. A store whose
310    /// `reset_device` or `restore_device` stopped past its record is deleted
311    /// whole: both installations, the replacement's directory and record,
312    /// and this device's entries, which by then may name either device.
313    /// Refused while a writer, read-only handle, file stream or a running
314    /// replacement remains; storage is untouched. A handle of it in this
315    /// process that is closing, or whose last clone was dropped, is waited
316    /// for. Its first step renames the store's directory, which hides the
317    /// store and records the deletion: one that stops part way, a crash
318    /// included, is finished by retrying, by the next `stores()`, or by a
319    /// restore or join of the store (§19.3).
320    pub async fn discard_store(&self, store_dir: &StoreDir) -> Result<(), StoreDeletionError> {
321        let keys = StoreKeychain::new(self.keychain.clone(), store_dir.id());
322        finish_deletion(store_dir, keys).await
323    }
324
325    /// The saved entries of this app's keychain service, in iCloud Keychain on
326    /// Apple and in this phone's Block Store on Android, one per store,
327    /// ordered by store id; empty when none are saved. An entry coven can't
328    /// restore from is listed as `Unreadable`, never omitted, so the person
329    /// can forget it. A keychain read failure (the search, an entry's bytes
330    /// or an S3 key's presence) fails the whole call, as does a restore-code
331    /// account with no store id, `KeyError::RestoreCodeStoreId`. A keychain
332    /// with neither iCloud Keychain nor Block Store fails with
333    /// `BootstrapError::SecureStorage(KeyError::Unsupported)`. Pass a readable
334    /// entry to `restore_from_saved_code`, which reads its current code and key.
335    pub async fn saved_restore_codes(&self) -> Result<Vec<SavedRestoreEntry>, BootstrapError> {
336        let keychain = self.keychain.clone();
337        let bugs = self.directories.bugs().clone();
338        crate::task::blocking_completing("list saved restore codes", bugs, move || {
339            crate::saved_restore::list(keychain)
340        })
341        .await?
342    }
343
344    /// Delete the store's saved S3 key, then its restore code: from iCloud
345    /// Keychain, and so from every one of the person's Apple devices, or from
346    /// this phone's Block Store, leaving other phones' own (§13.1). Readable
347    /// and unreadable entries alike; every keychain group's copy goes. The
348    /// store, its storage and every device's copy stay; no store need be open
349    /// and no provider connected. Absent entries succeed; after a failure
350    /// part way the store is still listed, and calling again finishes. A
351    /// later join, restore, reset or member-key replacement saves the entry
352    /// again. A keychain with neither iCloud Keychain nor Block Store fails
353    /// with `BootstrapError::SecureStorage(KeyError::Unsupported)`.
354    pub async fn forget_saved_restore_code(&self, store: StoreId) -> Result<(), BootstrapError> {
355        let keychain = self.keychain.clone();
356        let bugs = self.directories.bugs().clone();
357        crate::task::blocking_completing("forget the saved restore code", bugs, move || {
358            crate::saved_restore::forget(keychain, store)
359        })
360        .await?
361    }
362}
363
364/// Delete the store at `directory` from this device, or finish a deletion a
365/// cut call left recorded, once no instance of it in this process is
366/// closing.
367pub(crate) async fn finish_deletion<E>(directory: &StoreDir, keys: StoreKeychain) -> Result<(), E>
368where
369    E: From<StoreLockError>
370        + From<FileError>
371        + From<KeyError>
372        + From<crate::task::TaskFailed>
373        + crate::task::CarriesBug
374        + Send
375        + 'static,
376{
377    directory.closed().await?;
378    let directory = directory.clone();
379    let bugs = directory.bugs().clone();
380    crate::task::blocking_completing("delete the store", bugs, move || delete(&directory, &keys))
381        .await?
382}
383
384/// Delete the store at `directory` from this device: rename its directory to
385/// its deletion name, which hides the store and records the deletion, then
386/// delete this device's entries for it, then the directory and its lock
387/// files (§19.3, E1). Each step finds the last one's work done, so a
388/// deletion stopped anywhere is finished by deleting again.
389pub(crate) fn delete<E>(directory: &StoreDir, keys: &StoreKeychain) -> Result<(), E>
390where
391    E: From<StoreLockError> + From<FileError> + From<KeyError>,
392{
393    let mut lock = directory.lock_for_deletion()?;
394    if let Some(lock) = &mut lock {
395        lock.unpublish()?;
396    }
397    keys.delete_device_entries()?;
398    if let Some(lock) = lock {
399        lock.remove_directory()?;
400    }
401    Ok(())
402}
403
404#[cfg(any(test, feature = "test-utils"))]
405#[path = "test_utils.rs"]
406mod test_utils;
407
408#[cfg(test)]
409#[path = "coven_tests.rs"]
410mod tests;