Skip to main content

coven_sync/
error.rs

1//! Failures of sequence publication, file uploads and sync steps (E5, E7).
2
3use crate::OperationRejection;
4use coven_crypto::{custody::KeyError, CryptoError, MaterialError};
5use coven_database::DbError;
6use coven_foundation::files::{FileError, ObservationError, StoreLockError};
7use coven_foundation::id_source::KeyId;
8use coven_merge::Audience;
9use coven_storage::{StorageError, StorageFailure};
10use std::sync::Arc;
11
12/// Why `confirm_device_cut_off` refused (§14.2, §20.9). Each is a reason the
13/// call can't change anything; the app tells the person which one.
14#[derive(Clone, Copy, Debug, PartialEq, Eq)]
15pub enum CutOffRefusal {
16    /// The applied history doesn't have the device gone for good: its
17    /// removal hasn't reached this device yet, or it was never removed.
18    /// After the next pass has read the removal the call can succeed.
19    DeviceNotGone,
20    /// The member whose device this is doesn't hold the store: another
21    /// member's account does, and only their devices change sharing. The
22    /// call can't succeed here.
23    NotStoreOwner,
24    /// The store is on S3, where no account is shared, so there is nothing to
25    /// take back. The call can't succeed.
26    NoAccountShared,
27}
28
29/// A synchronization request failed; its durable queue, if any, remains retryable.
30#[derive(Debug, thiserror::Error)]
31pub enum SyncError {
32    /// This call requires work that cannot yet advance.
33    #[error("pending: {0:?}")]
34    Pending(Box<crate::PendingRecord>),
35    /// A local invariant or resource failed, classified without erasing its
36    /// native cause. One failure delivered to several callers shares one cause.
37    #[error("{failure:?}: {source}")]
38    Failed {
39        /// Durable local category.
40        failure: crate::LocalFailure,
41        /// Original failure retained by the initiating call.
42        #[source]
43        source: Arc<dyn std::error::Error + Send + Sync>,
44    },
45    /// A later step, such as a rollback or closing another owner, failed
46    /// too; neither failure is hidden.
47    #[error("{operation}; then: {cleanup}")]
48    Cleanup {
49        /// The original failure.
50        operation: Box<SyncError>,
51        /// The later failure, such as restoring the original state.
52        #[source]
53        cleanup: Box<SyncError>,
54    },
55    /// This call requires connected storage; durable work remains queued.
56    #[error("storage is not connected")]
57    NoStorage,
58    /// The member whose account holds the store can't be removed, and their
59    /// access can't move to another account (§10.1): OneDrive and CloudKit
60    /// can't hand a store to another account, and coven doesn't use Dropbox's
61    /// ownership transfer (bae-fm/coven#237).
62    #[error("the store owner cannot be removed or leave the account that holds the store")]
63    StoreOwner,
64    /// The request or invitation is absent, expired, or no longer matches.
65    #[error("invitation or join request is no longer current")]
66    InvitationChanged,
67    /// The requested journal row is not failed app work available for retry or discard.
68    #[error("operation {0:?} is not pending")]
69    NotPending(crate::OperationId),
70    /// Administrative and snapshot publication wait for the retained reload (§11).
71    #[error("snapshot reload {0:?} must finish before publishing entries or snapshots")]
72    ReloadPending(crate::OperationId),
73    /// Deleting stored files waits for this unfinished snapshot, whose
74    /// journal still owns references no row holds (§17.10).
75    #[error("snapshot {0:?} must finish before stored files are deleted")]
76    SnapshotPending(crate::OperationId),
77    /// Decoding persisted operation data failed; its cause remains available.
78    #[error("invalid operation data: {0}")]
79    OperationData(#[from] serde_json::Error),
80    /// The sequence has no next slot: slot numbers are `u64`s, and the
81    /// slot it would follow is the last one.
82    #[error("the store sequence is exhausted")]
83    SequenceExhausted,
84    /// Storage refused or failed a request.
85    #[error(transparent)]
86    Storage(StorageError),
87    /// The local database failed.
88    #[error(transparent)]
89    Database(DbError),
90    /// Unlocking or keeping keys failed.
91    #[error(transparent)]
92    SecureStorage(KeyError),
93    /// Making encrypted or signed bytes failed.
94    #[error(transparent)]
95    Crypto(CryptoError),
96    /// The proposed entry violates its byte format.
97    #[error(transparent)]
98    Format(coven_format::Error),
99    /// A required key has not arrived or the local key material conflicts.
100    #[error(transparent)]
101    Key(#[from] MaterialError),
102    /// A required key's sealed copy has not arrived.
103    #[error("key {key} is not available yet")]
104    KeyUnavailable {
105        /// The audience the key belongs to.
106        audience: Audience,
107        /// The missing key.
108        key: KeyId,
109    },
110    /// The proposed change is already invalid in the author's applied view.
111    #[error("administrative change rejected: {0:?}")]
112    Rejected(OperationRejection),
113    /// `confirm_device_cut_off` changed nothing, for this reason.
114    #[error("the device cut-off was refused: {0:?}")]
115    CutOffRefused(CutOffRefusal),
116    /// A required object failed authentication or parsing.
117    #[error(transparent)]
118    Damaged(#[from] crate::DamagedObject),
119    /// This device or its member was removed; syncing stops for good (§14.1).
120    #[error("this device or its member was removed")]
121    Removed,
122    /// A joined store read to the end of the sequence with its pair bound
123    /// nowhere in its history: the admission that published it was refused,
124    /// and its device never became a member. Syncing stops for good (§13.2,
125    /// §14.1).
126    #[error("the admission of this device was refused")]
127    NotAdmitted,
128    /// A replacement of this member's S3 key by another key is still under
129    /// way: its id isn't recorded yet, or custody doesn't connect with it yet
130    /// (§14.3). Retrying with that key finishes it; once it ends, another key
131    /// can replace it.
132    #[error("the replacement by S3 key {access_key_id} is still under way")]
133    ReplacementPending {
134        /// The key the replacement under way records.
135        access_key_id: String,
136    },
137    /// Another copy uses this device's id, as this evidence shows. This copy
138    /// sends nothing more; nothing is recorded, and `reset_device` is the
139    /// app's option (§19.3).
140    #[error("another copy uses this device's id: {0:?}")]
141    DeviceIdInUse(crate::DeviceIdEvidence),
142    /// Held signing credentials are no longer current for this stable id, or
143    /// an object was fixed under credentials that are no longer current.
144    /// Sending stops; it never re-signs an old object with newer keys (§14.1).
145    #[error("member {member:?} has newer keys; held credentials cannot send")]
146    MemberKeysReplaced {
147        /// The stable member whose keys were replaced.
148        member: coven_foundation::id_source::MemberId,
149    },
150    /// This call needs a newer app schema or coven format (§18).
151    #[error("an update is required: {0:?}")]
152    UpdateRequired(crate::RequiredUpdate),
153    /// A code can't be used for this call, as one naming another store.
154    #[error(transparent)]
155    Code(#[from] crate::CodeError),
156    /// The call's runtime is shutting down and dropped its work (E15). A
157    /// database call the blocking pool dropped unstarted changed nothing; a
158    /// call that runs as its own task, as closing and replacing credentials
159    /// do, may have stopped partway.
160    #[error(transparent)]
161    Shutdown(#[from] coven_foundation::task::Shutdown),
162    /// The call's work panicked: a bug in coven, which the caller reports
163    /// (E15). Its category is `LocalFailure::Bug`.
164    #[error(transparent)]
165    Bug(#[from] coven_foundation::task::Bug),
166    /// The worker this call needs ended on a panic: the sync loop, a
167    /// connection attempt or the upload worker. The store's bug sink heard
168    /// the bug as it happened, so the app shows this and doesn't report it
169    /// again (E15). Its category is `LocalFailure::Bug`.
170    #[error("{0}; it has stopped")]
171    WorkerFailed(coven_foundation::task::Bug),
172}
173
174impl From<CutOffRefusal> for SyncError {
175    fn from(refusal: CutOffRefusal) -> Self {
176        Self::CutOffRefused(refusal)
177    }
178}
179
180impl coven_foundation::task::CarriesBug for SyncError {
181    fn bug(&self) -> Option<&coven_foundation::task::Bug> {
182        match self {
183            Self::Bug(bug) => Some(bug),
184            // An earlier bug, which the sink heard as it happened.
185            Self::WorkerFailed(_) => None,
186            Self::Database(error) => error.bug(),
187            Self::SecureStorage(error) => error.bug(),
188            Self::Storage(error) => error.bug(),
189            Self::Cleanup { operation, cleanup } => operation.bug().or_else(|| cleanup.bug()),
190            // The cause `into_shared` shares is the error itself.
191            Self::Failed { source, .. } => {
192                source.downcast_ref::<Self>().and_then(coven_foundation::task::CarriesBug::bug)
193            }
194            _ => None,
195        }
196    }
197}
198
199impl From<coven_foundation::task::TaskFailed> for SyncError {
200    fn from(failed: coven_foundation::task::TaskFailed) -> Self {
201        failed.into_error()
202    }
203}
204
205impl From<coven_database::SqliteError> for SyncError {
206    fn from(error: coven_database::SqliteError) -> Self {
207        DbError::from(error).into()
208    }
209}
210
211/// Sync's own writes keep their error: a database failure as itself, and
212/// any other with a failed rollback or byte removal as a later step that
213/// failed too (§4, Appendix C.3).
214impl coven_database::WriteFailure for SyncError {
215    fn with_rollback(self, rollback: coven_database::SqliteError) -> Self {
216        match self {
217            Self::Database(error) => Self::Database(error.with_rollback(rollback)),
218            error => Self::cleanup(error, rollback.into()),
219        }
220    }
221
222    fn with_cleanup(write: Self, failures: Vec<DbError>) -> Self {
223        match write {
224            Self::Database(error) => Self::Database(DbError::with_cleanup(error, failures)),
225            error => failures
226                .into_iter()
227                .fold(error, |error, failure| Self::cleanup(error, failure.into())),
228        }
229    }
230}
231
232impl From<DbError> for SyncError {
233    fn from(error: DbError) -> Self {
234        match error {
235            // The database refusing sync's own requests against changed state
236            // is not a database failure an app can act on.
237            error @ DbError::OperationChanged(_) => {
238                Self::Failed { failure: crate::LocalFailure::Database, source: Arc::new(error) }
239            }
240            // Coven broke its own rule: a bug, as a panic is.
241            error @ DbError::Invariant { .. } => {
242                Self::Failed { failure: crate::LocalFailure::Bug, source: Arc::new(error) }
243            }
244            // The process is ending the call; the database did not fail.
245            DbError::Shutdown(shutdown) => Self::Shutdown(shutdown),
246            DbError::Bug(bug) => Self::Bug(bug),
247            DbError::StagingCleanup { operation, cleanup } => {
248                Self::cleanup((*operation).into(), (*cleanup).into())
249            }
250            error => Self::Database(error),
251        }
252    }
253}
254
255impl SyncError {
256    /// `operation` failed, and a later step (a rollback, closing another
257    /// owner) failed too; neither failure is hidden.
258    pub fn cleanup(operation: SyncError, cleanup: SyncError) -> Self {
259        Self::Cleanup { operation: Box::new(operation), cleanup: Box::new(cleanup) }
260    }
261
262    /// The durable reason for a failed request, without losing its typed cause here.
263    /// Every variant is matched by name, so a new one is classified here.
264    #[deny(clippy::wildcard_enum_match_arm)]
265    pub fn pending_reason(&self, provider: coven_storage::CloudProvider) -> crate::PendingReason {
266        use crate::PendingReason;
267        let error = self;
268        match error {
269            SyncError::Pending(record) => record.reason.clone(),
270            SyncError::Storage(error) => PendingReason::Storage {
271                provider,
272                failure: error.failure(),
273                answered: error.answered(),
274            },
275            SyncError::Damaged(object) => PendingReason::Refused(object.failure.clone()),
276            SyncError::UpdateRequired(update) => PendingReason::UpdateRequired(*update),
277            SyncError::Removed => PendingReason::Removed,
278            SyncError::NotAdmitted => PendingReason::NotAdmitted,
279            SyncError::MemberKeysReplaced { member } => {
280                PendingReason::MemberKeysReplaced { member: *member }
281            }
282            SyncError::KeyUnavailable { audience, key } => {
283                PendingReason::KeyUnavailable { audience: audience.clone(), key: *key }
284            }
285            SyncError::Rejected(reason) => PendingReason::Rejected(reason.clone()),
286            SyncError::Cleanup { operation, cleanup } => PendingReason::Cleanup {
287                operation: Box::new(operation.pending_reason(provider)),
288                cleanup: Box::new(cleanup.pending_reason(provider)),
289            },
290            SyncError::Failed { .. }
291            | SyncError::NoStorage
292            | SyncError::StoreOwner
293            | SyncError::InvitationChanged
294            | SyncError::CutOffRefused(_)
295            | SyncError::ReplacementPending { .. }
296            | SyncError::NotPending(_)
297            | SyncError::ReloadPending(_)
298            | SyncError::SnapshotPending(_)
299            | SyncError::OperationData(_)
300            | SyncError::SequenceExhausted
301            | SyncError::Database(_)
302            | SyncError::SecureStorage(_)
303            | SyncError::Crypto(_)
304            | SyncError::Format(_)
305            | SyncError::Key(_)
306            | SyncError::DeviceIdInUse(_)
307            | SyncError::Code(_)
308            | SyncError::Shutdown(_)
309            | SyncError::Bug(_)
310            | SyncError::WorkerFailed(_) => PendingReason::Failed(error.local_failure()),
311        }
312    }
313
314    /// A local file, directory or store-lock failure, retaining its cause.
315    pub(crate) fn disk(error: impl std::error::Error + Send + Sync + 'static) -> Self {
316        Self::Failed { failure: crate::LocalFailure::of_disk(&error), source: Arc::new(error) }
317    }
318
319    /// The local category recorded for a failure that names no other condition.
320    #[deny(clippy::wildcard_enum_match_arm)]
321    pub(crate) fn local_failure(&self) -> crate::LocalFailure {
322        match self {
323            Self::Failed { failure, .. } => *failure,
324            Self::Database(_) => crate::LocalFailure::Database,
325            Self::SecureStorage(_) => crate::LocalFailure::KeyCustody,
326            Self::Crypto(_) | Self::Key(_) => crate::LocalFailure::Crypto,
327            Self::Cleanup { operation, .. } => operation.local_failure(),
328            Self::Pending(_)
329            | Self::NoStorage
330            | Self::StoreOwner
331            | Self::InvitationChanged
332            | Self::ReplacementPending { .. }
333            | Self::NotPending(_)
334            | Self::ReloadPending(_)
335            | Self::SnapshotPending(_)
336            | Self::OperationData(_)
337            | Self::SequenceExhausted
338            | Self::Storage(_)
339            | Self::Format(_)
340            | Self::KeyUnavailable { .. }
341            | Self::Rejected(_)
342            | Self::CutOffRefused(_)
343            | Self::Damaged(_)
344            | Self::Removed
345            | Self::NotAdmitted
346            | Self::DeviceIdInUse(_)
347            | Self::MemberKeysReplaced { .. }
348            | Self::UpdateRequired(_)
349            | Self::Code(_) => crate::LocalFailure::Operation,
350            Self::Shutdown(_) => crate::LocalFailure::Shutdown,
351            Self::Bug(_) | Self::WorkerFailed(_) => crate::LocalFailure::Bug,
352        }
353    }
354
355    /// A local database, disk or custody failure that can clear by itself,
356    /// such as a locked keychain or a failed disk write (E5), or the runtime
357    /// shutting down, which the next process clears (E15). It fails only
358    /// this attempt and says nothing about the slot being applied (§19.1).
359    pub(crate) fn local_failure_clears_by_itself(&self) -> bool {
360        matches!(
361            self,
362            Self::Database(_)
363                | Self::SecureStorage(_)
364                | Self::Failed { .. }
365                | Self::Shutdown(_)
366                | Self::Bug(_)
367                | Self::WorkerFailed(_)
368        ) && crate::PendingReason::Failed(self.local_failure()).clears()
369            == Some(crate::pending::Clears::ByItself)
370    }
371
372    /// The error a pending record keeps as its cause, the one `into_shared`
373    /// shares: a local failure's source, any other error itself.
374    pub(crate) fn cause(&self) -> &(dyn std::error::Error + 'static) {
375        match self {
376            Self::Failed { source, .. } => source.as_ref(),
377            error => error,
378        }
379    }
380
381    /// The local category and one shared cause, for a status and a waiting
382    /// caller failed by the same error.
383    pub fn into_shared(self) -> (crate::LocalFailure, Arc<dyn std::error::Error + Send + Sync>) {
384        match self {
385            Self::Failed { failure, source } => (failure, source),
386            error => (error.local_failure(), Arc::new(error)),
387        }
388    }
389}
390
391impl From<StorageError> for SyncError {
392    fn from(error: StorageError) -> Self {
393        match error {
394            StorageError::Bug(bug) => Self::Bug(bug),
395            error => Self::Storage(error),
396        }
397    }
398}
399
400impl From<KeyError> for SyncError {
401    fn from(error: KeyError) -> Self {
402        match error {
403            KeyError::Bug(bug) => Self::Bug(bug),
404            error => Self::SecureStorage(error),
405        }
406    }
407}
408
409impl From<FileError> for SyncError {
410    fn from(error: FileError) -> Self {
411        match error {
412            FileError::Bug(bug) => Self::Bug(bug),
413            error => Self::disk(error),
414        }
415    }
416}
417
418impl From<ObservationError> for SyncError {
419    fn from(error: ObservationError) -> Self {
420        Self::disk(error)
421    }
422}
423
424impl From<coven_foundation::files::WriterError> for SyncError {
425    fn from(error: coven_foundation::files::WriterError) -> Self {
426        match error {
427            coven_foundation::files::WriterError::Lock(error) => error.into(),
428            coven_foundation::files::WriterError::File(error) => error.into(),
429        }
430    }
431}
432
433impl From<StoreLockError> for SyncError {
434    fn from(error: StoreLockError) -> Self {
435        match error {
436            StoreLockError::Bug(bug) => Self::Bug(bug),
437            error => Self::disk(error),
438        }
439    }
440}
441
442/// A sync step stopped as a whole (E5).
443#[derive(Clone, Debug, thiserror::Error)]
444pub enum SyncFailure {
445    /// A schema or stored object format is newer than this implementation.
446    #[error("an update is required")]
447    UpdateRequired(crate::RequiredUpdate),
448    /// This device or its member has been removed.
449    #[error("this device or its member was removed")]
450    Removed,
451    /// The admission that published this joined store was refused (§13.2).
452    #[error("the admission of this device was refused")]
453    NotAdmitted,
454    /// Another copy uses this device's id; this copy sends nothing more (§19.3).
455    #[error("another copy uses this device's id: {0:?}")]
456    DeviceIdInUse(crate::DeviceIdEvidence),
457    /// The provider refused or failed a request.
458    #[error("storage failed: {0}")]
459    Storage(#[source] Arc<StorageError>),
460    /// The pass finished, but this work of coven's own failed its last
461    /// attempt; it retries under backoff (E5). The record carries the cause
462    /// it keeps, and the text names it.
463    #[error("coven's own work failed its last attempt: {0}")]
464    Operation(Box<crate::PendingRecord>),
465    /// Any other failure, retaining its typed cause.
466    #[error("sync failed: {0}")]
467    Other(#[source] Arc<dyn std::error::Error + Send + Sync>),
468}
469
470impl From<OperationRejection> for SyncError {
471    fn from(reason: OperationRejection) -> Self {
472        match reason {
473            OperationRejection::StoreOwner => Self::StoreOwner,
474            reason => Self::Rejected(reason),
475        }
476    }
477}
478
479impl From<StorageFailure> for SyncError {
480    fn from(failure: StorageFailure) -> Self {
481        Self::Storage(failure.into())
482    }
483}
484
485impl From<SyncError> for SyncFailure {
486    fn from(error: SyncError) -> Self {
487        match error {
488            SyncError::Removed => Self::Removed,
489            SyncError::NotAdmitted => Self::NotAdmitted,
490            SyncError::DeviceIdInUse(evidence) => Self::DeviceIdInUse(evidence),
491            SyncError::UpdateRequired(update) => Self::UpdateRequired(update),
492            SyncError::Storage(error) => Self::Storage(Arc::new(error)),
493            error => Self::Other(Arc::new(error)),
494        }
495    }
496}
497
498impl From<coven_format::Error> for SyncError {
499    fn from(error: coven_format::Error) -> Self {
500        match required_format(&error) {
501            Some(update) => Self::UpdateRequired(update),
502            None => Self::Format(error),
503        }
504    }
505}
506
507impl From<CryptoError> for SyncError {
508    fn from(error: CryptoError) -> Self {
509        Self::Crypto(error)
510    }
511}
512
513/// An object whose checks failed (§19.1).
514#[derive(Clone, Debug, thiserror::Error)]
515#[error("damaged object at {path}: {failure}")]
516pub struct DamagedObject {
517    /// The exact path read from storage.
518    pub path: String,
519    /// The failed check and its cause.
520    #[source]
521    pub failure: crate::Refusal,
522}
523
524pub(crate) fn required_format(
525    error: &(dyn std::error::Error + 'static),
526) -> Option<crate::RequiredUpdate> {
527    let version = match error.downcast_ref::<coven_format::Error>() {
528        Some(coven_format::Error::UnsupportedVersion(version)) => Some(*version),
529        _ => None,
530    }?;
531    (version > coven_format::FORMAT_VERSION)
532        .then_some(crate::RequiredUpdate::CovenFormat { version })
533}
534
535/// Older unsupported formats are damaged inputs; only newer ones require an update.
536pub(crate) fn newer_format(error: &(dyn std::error::Error + 'static)) -> bool {
537    required_format(error).is_some()
538}
539
540#[cfg(test)]
541#[path = "error_tests.rs"]
542mod tests;