Skip to main content

offline_protocol/
error.rs

1//! Protocol errors.
2
3use thiserror::Error;
4
5/// Result type alias.
6pub type Result<T> = std::result::Result<T, Error>;
7
8/// Per-peer establishment state: where we are in getting to "can send" with a peer.
9///
10/// Used both as the value returned from `get_establishment_state` and as the payload
11/// of `Error::SessionNotReady`, so callers can show "Establishing…" and retry.
12#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
13pub enum EstablishmentState {
14    /// No key package for this peer (memory nor storage).
15    NoKeyPackage,
16    /// Key package stored; no MLS session yet.
17    HaveKeyPackage,
18    /// Session created, welcome sent/queued; not confirmed.
19    SessionPending,
20    /// Can send/receive encrypted messages.
21    SessionConfirmed,
22}
23
24impl EstablishmentState {
25    /// Stable string for logging and FFI.
26    pub fn as_str(&self) -> &'static str {
27        match self {
28            Self::NoKeyPackage => "NoKeyPackage",
29            Self::HaveKeyPackage => "HaveKeyPackage",
30            Self::SessionPending => "SessionPending",
31            Self::SessionConfirmed => "SessionConfirmed",
32        }
33    }
34}
35
36/// Stable, machine-readable error classes used for session readiness decisions.
37///
38/// This enum is the single SDK boundary that maps heterogeneous upstream error
39/// types into deterministic categories for protocol control flow.
40// Adding a variant to a public error enum is a breaking change without
41// this attribute; downstream crates must carry a wildcard arm.
42#[non_exhaustive]
43#[derive(Debug, Clone, Copy, PartialEq, Eq)]
44pub enum SessionStateError {
45    /// Session exists but cannot process application traffic yet.
46    SessionNotReady,
47    /// Session/group state does not exist locally.
48    GroupNotFound,
49    /// MLS stack is not initialized.
50    NotInitialized,
51    /// Underlying transport failure.
52    TransportFailure,
53    /// Cryptographic operation failure.
54    CryptoFailure,
55    /// Session exists but is out of sync with the sender's epoch (the two sides
56    /// disagree on the MLS epoch).
57    ///
58    /// What separates this from [`Self::CryptoFailure`] is the **re-key**, not
59    /// the ACK: under `crypto_recovery_enabled` both classes withhold the
60    /// delivery ACK so the sender's resend can still deliver, but only this one
61    /// additionally re-establishes the 1:1 session. The split is deliberate —
62    /// re-keying on an AEAD/authentication failure, which any injected frame can
63    /// produce, would be a re-key-storm vector.
64    SessionDesync,
65    /// Error that does not match a known class.
66    Unknown,
67}
68
69impl SessionStateError {
70    /// Returns a stable, non-localized machine-readable error code.
71    pub fn code(&self) -> &'static str {
72        match self {
73            Self::SessionNotReady => "SESSION_NOT_READY",
74            Self::GroupNotFound => "GROUP_NOT_FOUND",
75            Self::NotInitialized => "NOT_INITIALIZED",
76            Self::TransportFailure => "TRANSPORT_FAILURE",
77            Self::CryptoFailure => "CRYPTO_FAILURE",
78            Self::SessionDesync => "SESSION_DESYNC",
79            Self::Unknown => "UNKNOWN",
80        }
81    }
82
83    /// Classifies protocol-level errors for session state control flow.
84    pub fn classify(error: &Error) -> Self {
85        match error {
86            Error::SessionNotReady(_) => Self::SessionNotReady,
87            Error::MlsNotInitialized => Self::NotInitialized,
88            Error::Transport(_) => Self::TransportFailure,
89            Error::EncryptFailed(_) => Self::CryptoFailure,
90            Error::GroupNotFound(_) => Self::GroupNotFound,
91            Error::Mls(inner) => Self::from(inner),
92            _ => Self::Unknown,
93        }
94    }
95
96    /// Maps session state class into welcome lifecycle reason taxonomy.
97    pub fn to_welcome_reason_code(&self) -> crate::events::WelcomeReasonCode {
98        match self {
99            Self::TransportFailure => crate::events::WelcomeReasonCode::TransportUnavailable,
100            Self::SessionNotReady
101            | Self::GroupNotFound
102            | Self::NotInitialized
103            | Self::CryptoFailure
104            | Self::SessionDesync
105            | Self::Unknown => crate::events::WelcomeReasonCode::InternalError,
106        }
107    }
108}
109
110impl From<&offline_protocol_mls::MlsError> for SessionStateError {
111    fn from(error: &offline_protocol_mls::MlsError) -> Self {
112        match error {
113            offline_protocol_mls::MlsError::GroupNotFound(_)
114            | offline_protocol_mls::MlsError::SessionNotFound(_) => Self::GroupNotFound,
115            offline_protocol_mls::MlsError::NotInitialized => Self::NotInitialized,
116            offline_protocol_mls::MlsError::SessionDesync(_) => Self::SessionDesync,
117            offline_protocol_mls::MlsError::Storage(_)
118            | offline_protocol_mls::MlsError::OpenMls(_)
119            | offline_protocol_mls::MlsError::Deserialization(_)
120            | offline_protocol_mls::MlsError::Serialization(_)
121            | offline_protocol_mls::MlsError::InvalidMessage(_)
122            | offline_protocol_mls::MlsError::Encryption(_)
123            | offline_protocol_mls::MlsError::Decryption(_)
124            | offline_protocol_mls::MlsError::CryptoGeneration(_)
125            | offline_protocol_mls::MlsError::Signing(_)
126            | offline_protocol_mls::MlsError::VerificationFailed(_)
127            | offline_protocol_mls::MlsError::InvalidPublicKey(_) => Self::CryptoFailure,
128            // Not a session-state condition: commit enforcement is a group
129            // policy, and 1:1 sessions are exempt from it. It can only reach
130            // this classifier when a *group* commit ciphertext arrives wrapped
131            // in an `__MLS_ENC__` envelope, where `Unknown` is the disposition
132            // we want — drop and ACK, never queue (the refusal is permanent,
133            // so the same frame can never become decryptable) and never re-key
134            // (the session is healthy).
135            offline_protocol_mls::MlsError::CommitNotAuthorized { .. } => Self::Unknown,
136            // Not a session-state condition either: the envelope named a slot
137            // that is not the claimed sender's, which is a security rejection,
138            // not a readiness problem. `Unknown` is the disposition we want on
139            // any path that reaches this classifier — never queue (the frame
140            // can never become legitimate) and never re-key (there is no
141            // evidence our session with the claimed sender is unhealthy). The
142            // 1:1 text path intercepts this variant before classification so it
143            // can withhold the ACK; see `handle_encrypted_message`.
144            offline_protocol_mls::MlsError::SessionIdentityMismatch { .. } => Self::Unknown,
145            // Not a session-state condition either, and `Unknown` is the
146            // disposition both need on any path that reaches this classifier
147            // (a group commit or message wrapped in an `__MLS_ENC__`
148            // envelope): never queue — a leaf whose key does not hash to its
149            // own credential can never start doing so, and an unsupported
150            // sender role never becomes supported — and never re-key, since
151            // neither says anything about the health of our session with the
152            // claimed sender. Both intercept these before classification so
153            // they can withhold the ACK — the group path in
154            // `decrypt_group_application`, and the 1:1 path inline in
155            // `handle_encrypted_message` plus `is_media_security_rejection`,
156            // which is not redundant: an `__MLS_ENC__` envelope may name a
157            // `group:` id and so reach group decrypt without going through the
158            // group handler.
159            offline_protocol_mls::MlsError::LeafAddressMismatch { .. }
160            | offline_protocol_mls::MlsError::UnsupportedSender { .. } => Self::Unknown,
161            _ => Self::Unknown,
162        }
163    }
164}
165
166/// Protocol errors.
167// Adding a variant to a public error enum is a breaking change without
168// this attribute; downstream crates must carry a wildcard arm.
169#[non_exhaustive]
170#[derive(Debug, Error)]
171pub enum Error {
172    /// Protocol not started.
173    #[error("Protocol not started")]
174    NotStarted,
175
176    /// Protocol already started.
177    #[error("Protocol already started")]
178    AlreadyStarted,
179
180    /// Invalid configuration.
181    #[error("Invalid configuration: {0}")]
182    InvalidConfiguration(String),
183
184    /// Core error.
185    #[error("Core error: {0}")]
186    Core(#[from] offline_protocol_core::Error),
187
188    /// Transport error.
189    #[error("Transport error: {0}")]
190    Transport(#[from] offline_protocol_transport::Error),
191
192    /// Router error.
193    #[error("Router error: {0}")]
194    Router(#[from] offline_protocol_router::Error),
195
196    /// Reliability error.
197    #[error("Reliability error: {0}")]
198    Reliability(#[from] offline_protocol_reliability::Error),
199
200    /// MLS error.
201    #[error("MLS error: {0}")]
202    Mls(#[from] offline_protocol_mls::MlsError),
203
204    /// MLS not initialized.
205    #[error("MLS encryption not initialized")]
206    MlsNotInitialized,
207
208    /// No key package available for recipient.
209    #[error("No key package available for recipient: {0}")]
210    NoKeyPackage(String),
211
212    /// Session not ready; establishment in progress. Caller can retry or show "Establishing…".
213    #[error("Session not ready: {0:?}")]
214    SessionNotReady(EstablishmentState),
215
216    /// Outbound message encryption failed.
217    #[error("Failed to encrypt message: {0}")]
218    EncryptFailed(String),
219
220    /// Service error.
221    #[error("Service error: {0}")]
222    Service(#[from] offline_protocol_services::ServiceError),
223
224    /// Serialization error.
225    #[error("Serialization error: {0}")]
226    Serialization(String),
227
228    /// Operation rejected because the target user is blocked.
229    #[error("User is blocked: {0}")]
230    UserBlocked(String),
231
232    /// Too many concurrent media transfers to the same recipient. Encrypted
233    /// transfers share the recipient's session ratchet, whose out-of-order
234    /// tolerance bounds how many chunks may be in flight at once.
235    #[error(
236        "Too many concurrent media transfers to {0}; retry after an active transfer completes"
237    )]
238    MediaTransferLimit(String),
239
240    /// Group does not exist locally.
241    #[error("Group not found: {0}")]
242    GroupNotFound(String),
243
244    /// Operation requires a group role or permission the caller does not hold.
245    #[error("{0}")]
246    PermissionDenied(String),
247
248    /// Operation rejected by the current group or protocol state (e.g.
249    /// last-admin constraints, member limits, expired key packages).
250    #[error("{0}")]
251    InvalidState(String),
252
253    /// A caller-supplied argument failed validation.
254    #[error("{0}")]
255    InvalidArgument(String),
256
257    /// The data layer is switched off in configuration.
258    #[error("Data layer is disabled")]
259    DataDisabled,
260
261    /// The data layer has no storage backend to work with.
262    ///
263    /// Raised when the engine has no protocol-state store yet, which means
264    /// `initialize_mls` has not run: documents are sealed at rest, and the
265    /// record key that seals them is minted during that call.
266    #[error("Data layer has no storage; initialize_mls must run first")]
267    DataStorageUnavailable,
268
269    /// A document has grown past the per-document cap.
270    ///
271    /// The change that breached it is already durable, so nothing is lost.
272    /// What is refused is further growth: deletions still apply, so a
273    /// document can be brought back under the cap and resume accepting
274    /// edits.
275    #[error("Document is {actual} bytes compacted, over the {limit} byte cap")]
276    DocTooLarge {
277        /// The compacted size that breached the cap.
278        actual: usize,
279        /// The cap in force.
280        limit: usize,
281    },
282
283    /// Document data could not be decoded.
284    ///
285    /// Documents are read back out of sealed records, so their bytes have
286    /// already been authenticated. This therefore reports a permanent
287    /// verdict, not a transient read failure worth retrying.
288    #[error("Document data is corrupt: {0}")]
289    DataCorrupted(String),
290
291    /// Generic error.
292    #[error("{0}")]
293    Other(String),
294}
295
296#[cfg(test)]
297mod tests {
298    use super::{Error, SessionStateError};
299    use offline_protocol_mls::MlsError;
300    use offline_protocol_transport::Error as TransportError;
301
302    #[test]
303    fn session_state_code_values_are_stable() {
304        assert_eq!(
305            SessionStateError::SessionNotReady.code(),
306            "SESSION_NOT_READY"
307        );
308        assert_eq!(SessionStateError::GroupNotFound.code(), "GROUP_NOT_FOUND");
309        assert_eq!(SessionStateError::NotInitialized.code(), "NOT_INITIALIZED");
310        assert_eq!(
311            SessionStateError::TransportFailure.code(),
312            "TRANSPORT_FAILURE"
313        );
314        assert_eq!(SessionStateError::CryptoFailure.code(), "CRYPTO_FAILURE");
315        assert_eq!(SessionStateError::SessionDesync.code(), "SESSION_DESYNC");
316        assert_eq!(SessionStateError::Unknown.code(), "UNKNOWN");
317    }
318
319    #[test]
320    fn classify_maps_mls_group_not_found() {
321        let classified = SessionStateError::from(&MlsError::GroupNotFound("g1".to_string()));
322        assert_eq!(classified, SessionStateError::GroupNotFound);
323    }
324
325    #[test]
326    fn classify_maps_protocol_group_not_found() {
327        // The protocol-level variant must land in the same class as the
328        // MLS-layer one — one condition, one category.
329        let classified = SessionStateError::classify(&Error::GroupNotFound("g1".to_string()));
330        assert_eq!(classified, SessionStateError::GroupNotFound);
331    }
332
333    #[test]
334    fn classify_maps_not_initialized() {
335        let classified = SessionStateError::from(&MlsError::NotInitialized);
336        assert_eq!(classified, SessionStateError::NotInitialized);
337    }
338
339    #[test]
340    fn classify_maps_crypto_failures() {
341        let classified = SessionStateError::from(&MlsError::Decryption("failed".to_string()));
342        assert_eq!(classified, SessionStateError::CryptoFailure);
343    }
344
345    #[test]
346    fn classify_maps_session_desync_distinct_from_crypto_failure() {
347        // An epoch desync is recoverable and must land in its own class, NOT
348        // CryptoFailure — otherwise the receive path would drop-and-ACK it as a
349        // permanent failure instead of re-keying.
350        let desync = SessionStateError::from(&MlsError::SessionDesync("wrong epoch".to_string()));
351        assert_eq!(desync, SessionStateError::SessionDesync);
352        // The genuine-failure sibling stays CryptoFailure.
353        let crypto = SessionStateError::from(&MlsError::Decryption("aead".to_string()));
354        assert_eq!(crypto, SessionStateError::CryptoFailure);
355    }
356
357    #[test]
358    fn classify_unknown_fallback_is_safe() {
359        let classified = SessionStateError::classify(&Error::Other("opaque".to_string()));
360        assert_eq!(classified, SessionStateError::Unknown);
361    }
362
363    #[test]
364    fn welcome_reason_mapping_uses_typed_transport_failure() {
365        let classified = SessionStateError::classify(&Error::Transport(
366            TransportError::SendFailed("send failed".to_string()),
367        ));
368        assert_eq!(
369            classified.to_welcome_reason_code(),
370            crate::events::WelcomeReasonCode::TransportUnavailable
371        );
372    }
373
374    #[test]
375    fn welcome_reason_mapping_unknown_defaults_internal_error() {
376        let classified = SessionStateError::classify(&Error::Other("opaque".to_string()));
377        assert_eq!(
378            classified.to_welcome_reason_code(),
379            crate::events::WelcomeReasonCode::InternalError
380        );
381    }
382}