Skip to main content

coven/
bootstrap_restore.rs

1//! A person's new device restoring from their restore code (§13.1): from a
2//! code or a saved code; the record it keeps on the device from just before
3//! it registers the device until it ends; the listing of unfinished
4//! restores, resuming one from its record, and how a call that ends without
5//! the store settles it (§11.6).
6
7use super::*;
8use coven_sync::DeviceCredentials;
9
10/// Restore this person's store on a new device, returning its open handle.
11/// S3 requires a key: the one the code carries, or one entered separately,
12/// never both. Other providers require None and use the builder's own
13/// sign-in. Invalid key inputs fail before bootstrap begins. A store whose
14/// unfinished installation holds a join's saved code is refused with
15/// `StoreLockError::BootstrapPending` before anything changes (§13.4). Until
16/// the restore records itself, a cancel or a failure removes the
17/// installation it loaded into (§13.1).
18pub async fn restore_from_code(
19    builder: CovenBuilder,
20    code: &str,
21    s3_credentials: Option<S3Credentials>,
22    device_name: &str,
23    on_status: impl Fn(BootstrapStatus),
24    cancel: &watch::Receiver<bool>,
25) -> Result<CovenHandle, BootstrapError> {
26    check_cancel(cancel)?;
27    let code = coven_sync::read_restore_code(code)?;
28    let storage = RestoreStorage::decode(code.storage.as_bytes()).map_err(SyncError::from)?;
29    let (data, sign_in) = restore_connection(&builder, storage, s3_credentials)?;
30    let sign_in = sign_in.map_or(SignInCustody::None, SignInCustody::Session);
31    Box::pin(bootstrap_device(
32        builder,
33        BootstrapRequest::Restore { code, data, sign_in, name: device_name.into() },
34        on_status,
35        cancel,
36    ))
37    .await?
38    .ok_or_else(|| SyncError::from(StorageFailure::MemberKeysMissing).into())
39}
40
41/// Restore a readable entry selected from `saved_restore_codes`. Reads the current
42/// code by store id, including a replacement saved since listing. Returns None
43/// if that store's code is no longer saved.
44/// S3 uses the explicit key when supplied, otherwise its key saved beside the code.
45/// Missing keys, wrong-provider keys and keychain failures precede bootstrap.
46pub async fn restore_from_saved_code(
47    builder: CovenBuilder,
48    saved: &SavedRestoreCode,
49    s3_credentials: Option<S3Credentials>,
50    device_name: &str,
51    on_status: impl Fn(BootstrapStatus),
52    cancel: &watch::Receiver<bool>,
53) -> Result<Option<CovenHandle>, BootstrapError> {
54    check_cancel(cancel)?;
55    let person = PersonEntries::new(builder.keychain()?, saved.store_id);
56    let Some((code, location)) = keychain_code(&person, saved.store_id)? else {
57        return Ok(None);
58    };
59    let s3_credentials = if location.provider() == CloudProvider::S3 && s3_credentials.is_none() {
60        person
61            .s3_credentials()?
62            .map(|bytes| S3Credentials::decode(bytes.as_bytes()).map_err(SyncError::from))
63            .transpose()?
64    } else {
65        s3_credentials
66    };
67    // The saved code carries no key; the person entries hold it beside it.
68    let storage = RestoreStorage::location_only(location);
69    let (data, sign_in) = restore_connection(&builder, storage, s3_credentials)?;
70    let sign_in = sign_in.map_or(SignInCustody::None, SignInCustody::Session);
71    Box::pin(bootstrap_device(
72        builder,
73        BootstrapRequest::Restore { code, data, sign_in, name: device_name.into() },
74        on_status,
75        cancel,
76    ))
77    .await
78}
79
80#[expect(
81    clippy::map_err_ignore,
82    reason = "read_saved_code logs why a saved code is unreadable; restoring sees Invalid"
83)]
84pub(super) fn keychain_code(
85    keychain: &PersonEntries,
86    store: StoreId,
87) -> Result<Option<(RestoreCode, StorageConfig)>, BootstrapError> {
88    keychain
89        .restore_code()?
90        .map(|bytes| {
91            coven_sync::read_saved_code(store, bytes.as_bytes())
92                .map(|(saved, location)| (saved.code, location))
93                .map_err(|_| CodeError::Invalid.into())
94        })
95        .transpose()
96}
97
98/// What a restore keeps on the device before its registration, its first
99/// write to storage: from then on it only goes forward, and finishes from
100/// what it kept. A new device's restore keeps its record (§13.1); a
101/// replacement of this device's installation keeps its own entry beside the
102/// replacement's record (§19.3).
103pub(super) struct Recording {
104    entries: Arc<StoreKeychain>,
105    credentials: Arc<DeviceCredentials>,
106    name: String,
107    replacement: bool,
108}
109
110impl Recording {
111    /// What a new device's restore keeps.
112    pub(super) fn new(
113        builder: &CovenBuilder,
114        store: StoreId,
115        name: &str,
116    ) -> Result<Self, BootstrapError> {
117        Self::kept(builder, store, name, false)
118    }
119
120    /// What a replacement of this device's installation keeps.
121    pub(super) fn replacement(
122        builder: &CovenBuilder,
123        store: StoreId,
124        name: &str,
125    ) -> Result<Self, BootstrapError> {
126        Self::kept(builder, store, name, true)
127    }
128
129    fn kept(
130        builder: &CovenBuilder,
131        store: StoreId,
132        name: &str,
133        replacement: bool,
134    ) -> Result<Self, BootstrapError> {
135        let keychain = builder.keychain()?;
136        Ok(Self {
137            entries: Arc::new(StoreKeychain::new(keychain.clone(), store)),
138            credentials: crate::builder::device_credentials(
139                keychain,
140                store,
141                builder.directories.store_dir(&store),
142                builder.oauth_exchange(),
143            ),
144            name: name.to_owned(),
145            replacement,
146        })
147    }
148
149    /// Record the restore, then keep its sign-in (`keep_sign_in`).
150    pub(super) fn record(
151        &self,
152        member: &MemberIdentity,
153        store: StoreId,
154        data: &ConnectionCredentials,
155        sign_in: SignInCustody,
156    ) -> Result<SignInCustody, BootstrapError> {
157        self.keep(member, store, data)?;
158        self.keep_sign_in(sign_in)
159    }
160
161    /// Keep the code, holding the S3 key on S3, and the device's name.
162    pub(super) fn keep(
163        &self,
164        member: &MemberIdentity,
165        store: StoreId,
166        data: &ConnectionCredentials,
167    ) -> Result<(), BootstrapError> {
168        let s3_key = match &data.credentials {
169            StorageCredentials::S3(key) => Some(key.clone()),
170            _ => None,
171        };
172        let code = RestoreCode {
173            store,
174            member: member.member(),
175            member_keys: member.keys().clone(),
176            storage: RestoreStorage { location: data.location.clone(), s3_key }
177                .encode()
178                .map_err(SyncError::from)?,
179        };
180        let text = SecretText::new(code.to_text().map_err(SyncError::from)?.to_string());
181        match self.replacement {
182            true => self.entries.set_replacement_record(&text, &self.name)?,
183            false => self.entries.set_restore_record(&text, &self.name)?,
184        }
185        Ok(())
186    }
187
188    /// Delete what `keep` kept, when the call ends before it needs it.
189    pub(super) fn forget(&self) -> Result<(), BootstrapError> {
190        match self.replacement {
191            true => self.entries.delete_replacement_record()?,
192            false => self.entries.delete_restore_record()?,
193        }
194        Ok(())
195    }
196
197    /// On Dropbox and OneDrive keep the sign-in, which from now on refreshes
198    /// in the store's device credentials. A record without its sign-in lists
199    /// a restore that resumes once the person signs in; a sign-in is never
200    /// kept without its record. Returns the custody the sign-in refreshes in.
201    pub(super) fn keep_sign_in(
202        &self,
203        sign_in: SignInCustody,
204    ) -> Result<SignInCustody, BootstrapError> {
205        Ok(match sign_in {
206            SignInCustody::Session(session) => {
207                session.publish(&self.credentials, |keychains, tokens| {
208                    coven_sync::commit_credentials(
209                        keychains,
210                        &StorageCredentials::OAuth(tokens.clone()),
211                        None,
212                    )
213                    .map(drop)
214                })?;
215                SignInCustody::Device { credentials: self.credentials.clone(), kept: false }
216            }
217            custody => custody,
218        })
219    }
220}
221
222/// The restores under way on this device (`CovenBuilder::unfinished_restores`).
223pub(crate) async fn unfinished_restores(
224    builder: &CovenBuilder,
225) -> Result<Vec<CodeMetadata>, BootstrapError> {
226    let keychain = builder.keychain()?;
227    let mut restores = Vec::new();
228    for store in builder
229        .directories
230        .unfinished_first_installations()
231        .await
232        .map_err(BootstrapDirectoryError::from)?
233    {
234        // A join keeps no record; an installation that keeps nothing is
235        // reclaimed.
236        let entries = StoreKeychain::new(keychain.clone(), store);
237        let Kept::Restore(code) = kept(builder, &entries, store).await? else {
238            continue;
239        };
240        let metadata = match coven_sync::decode_code_info(code.as_str())? {
241            CodeInfo::Restore(metadata) => metadata,
242            CodeInfo::Invite(_) => {
243                return Err(CodeError::WrongKind {
244                    expected: CodeKind::Restore,
245                    actual: CodeKind::Invite,
246                }
247                .into())
248            }
249        };
250        CodeError::require_store(store, metadata.store_id)?;
251        restores.push(metadata);
252    }
253    Ok(restores)
254}
255
256/// Continue an unfinished restore from its record (`CovenBuilder::resume_restore`).
257pub(crate) async fn resume_restore(
258    builder: CovenBuilder,
259    store: StoreId,
260    on_status: impl Fn(BootstrapStatus),
261    cancel: &watch::Receiver<bool>,
262) -> Result<CovenHandle, BootstrapError> {
263    check_cancel(cancel)?;
264    let (text, name) = StoreKeychain::new(builder.keychain()?, store)
265        .restore_record()?
266        .ok_or(BootstrapError::NoUnfinishedRestore(store))?;
267    let code = coven_sync::read_restore_code(text.as_str())?;
268    CodeError::require_store(store, code.store)?;
269    let storage = RestoreStorage::decode(code.storage.as_bytes()).map_err(SyncError::from)?;
270    let (data, sign_in) = match storage.location.provider() {
271        // The sign-in the restore kept, or a new one the builder holds.
272        provider if provider.signs_in_with_oauth() => {
273            let (custody, tokens) = join_sign_in(&builder, store, &storage.location)?;
274            let data = ConnectionCredentials {
275                location: storage.location,
276                credentials: StorageCredentials::OAuth(tokens),
277            };
278            data.validate().map_err(SyncError::from)?;
279            (data, custody)
280        }
281        // The code holds the S3 key; CloudKit uses the Apple account.
282        _ => {
283            let (data, _) = restore_connection(&builder, storage, None)?;
284            (data, SignInCustody::None)
285        }
286    };
287    Box::pin(bootstrap_device(
288        builder,
289        BootstrapRequest::Restore { code, data, sign_in, name },
290        on_status,
291        cancel,
292    ))
293    .await?
294    .ok_or_else(|| SyncError::from(StorageFailure::MemberKeysMissing).into())
295}
296
297/// End a restore whose call failed or was cancelled before it published the
298/// store. Before the record the restore has only read storage, and a cancel
299/// or any failure removes its installation (§11.6). Once the restore is
300/// recorded it only goes forward, and stays for the next call, unless the
301/// facts end it: a cancel, a registration refused, a member removed or its
302/// keys replaced, while no registration is journaled and the history the
303/// installation applied doesn't name the device as active, or names it as
304/// gone for good. Then nothing in storage
305/// stands for the device, and the restore removes everything it kept. A
306/// cancel the facts don't end is refused with `RestoreRegistered` (§13.1,
307/// §11.6).
308pub(super) async fn ended(
309    builder: &CovenBuilder,
310    pending: &BootstrapStore,
311    entries: &StoreKeychain,
312    error: BootstrapError,
313) -> Result<Option<CovenHandle>, BootstrapError> {
314    let recorded = match entries.restore_record() {
315        Ok(record) => record.is_some(),
316        Err(read) => return combine(Err(error), Err(read.into())),
317    };
318    if !recorded {
319        return remove_unfinished(entries, pending, Err(error));
320    }
321    let cancelled = matches!(error, BootstrapError::Cancelled);
322    let ends = cancelled
323        || matches!(
324            error,
325            BootstrapError::Sync(
326                SyncError::Removed | SyncError::MemberKeysReplaced { .. } | SyncError::Rejected(_)
327            )
328        );
329    if !ends {
330        return Err(error);
331    }
332    match registered(builder, pending.directory()).await {
333        Ok(false) => remove_unfinished(entries, pending, Err(error)),
334        Ok(true) if cancelled => Err(BootstrapError::RestoreRegistered),
335        Ok(true) => Err(error),
336        Err(read) => combine(Err(error), Err(read)),
337    }
338}
339
340/// Whether storage may name this installation's device as active. The
341/// history the installation applied decides once it names the device: its
342/// registration landed, and the device is active, or gone for good since,
343/// removed or its member's keys replaced with a pair it doesn't hold (§14.1).
344/// A replaced pair leaves the device's entry active, so the entry alone
345/// doesn't decide. Until history names the device, a registration still
346/// journaled may land. Read from the installation's own database, with
347/// nothing of it running: a new device's, or a replacement's (§19.3).
348pub(super) async fn registered(
349    builder: &CovenBuilder,
350    directory: StoreDir,
351) -> Result<bool, BootstrapError> {
352    let device = directory.settings().map_err(CovenError::from)?.device_id;
353    let database = open_installation(builder.database(directory)?, &|_| ()).await?;
354    let facts = async {
355        if database.device_stop().await.map_err(CovenError::from)?.is_some() {
356            return Ok(false);
357        }
358        match database.store_state().await?.devices.get(&device) {
359            Some(_) => Ok(true),
360            None => Ok::<_, BootstrapError>(coven_sync::registration_journaled(&database).await?),
361        }
362    }
363    .await;
364    combine(facts, close_database(&database).await)
365}