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/// Carries a sealed-layer failure in as the MLS error it has always been.
297///
298/// The envelope codec and the group-id constructor used to raise
299/// `MlsError` directly; they now raise
300/// [`SealedError`](offline_protocol_sealed::SealedError), which the MLS crate
301/// maps back variant for variant. Routing through that mapping rather than
302/// inventing a new variant here is what keeps `Error::Mls` matching, the
303/// `SessionStateError` classification above, and the rendered text identical
304/// to what callers saw before the move.
305impl From<offline_protocol_sealed::SealedError> for Error {
306 fn from(err: offline_protocol_sealed::SealedError) -> Self {
307 Self::Mls(offline_protocol_mls::MlsError::from(err))
308 }
309}
310
311#[cfg(test)]
312mod tests {
313 use super::{Error, SessionStateError};
314 use offline_protocol_mls::MlsError;
315 use offline_protocol_transport::Error as TransportError;
316
317 #[test]
318 fn session_state_code_values_are_stable() {
319 assert_eq!(
320 SessionStateError::SessionNotReady.code(),
321 "SESSION_NOT_READY"
322 );
323 assert_eq!(SessionStateError::GroupNotFound.code(), "GROUP_NOT_FOUND");
324 assert_eq!(SessionStateError::NotInitialized.code(), "NOT_INITIALIZED");
325 assert_eq!(
326 SessionStateError::TransportFailure.code(),
327 "TRANSPORT_FAILURE"
328 );
329 assert_eq!(SessionStateError::CryptoFailure.code(), "CRYPTO_FAILURE");
330 assert_eq!(SessionStateError::SessionDesync.code(), "SESSION_DESYNC");
331 assert_eq!(SessionStateError::Unknown.code(), "UNKNOWN");
332 }
333
334 #[test]
335 fn classify_maps_mls_group_not_found() {
336 let classified = SessionStateError::from(&MlsError::GroupNotFound("g1".to_string()));
337 assert_eq!(classified, SessionStateError::GroupNotFound);
338 }
339
340 #[test]
341 fn classify_maps_protocol_group_not_found() {
342 // The protocol-level variant must land in the same class as the
343 // MLS-layer one — one condition, one category.
344 let classified = SessionStateError::classify(&Error::GroupNotFound("g1".to_string()));
345 assert_eq!(classified, SessionStateError::GroupNotFound);
346 }
347
348 #[test]
349 fn classify_maps_not_initialized() {
350 let classified = SessionStateError::from(&MlsError::NotInitialized);
351 assert_eq!(classified, SessionStateError::NotInitialized);
352 }
353
354 #[test]
355 fn classify_maps_crypto_failures() {
356 let classified = SessionStateError::from(&MlsError::Decryption("failed".to_string()));
357 assert_eq!(classified, SessionStateError::CryptoFailure);
358 }
359
360 #[test]
361 fn classify_maps_session_desync_distinct_from_crypto_failure() {
362 // An epoch desync is recoverable and must land in its own class, NOT
363 // CryptoFailure — otherwise the receive path would drop-and-ACK it as a
364 // permanent failure instead of re-keying.
365 let desync = SessionStateError::from(&MlsError::SessionDesync("wrong epoch".to_string()));
366 assert_eq!(desync, SessionStateError::SessionDesync);
367 // The genuine-failure sibling stays CryptoFailure.
368 let crypto = SessionStateError::from(&MlsError::Decryption("aead".to_string()));
369 assert_eq!(crypto, SessionStateError::CryptoFailure);
370 }
371
372 #[test]
373 fn classify_unknown_fallback_is_safe() {
374 let classified = SessionStateError::classify(&Error::Other("opaque".to_string()));
375 assert_eq!(classified, SessionStateError::Unknown);
376 }
377
378 #[test]
379 fn welcome_reason_mapping_uses_typed_transport_failure() {
380 let classified = SessionStateError::classify(&Error::Transport(
381 TransportError::SendFailed("send failed".to_string()),
382 ));
383 assert_eq!(
384 classified.to_welcome_reason_code(),
385 crate::events::WelcomeReasonCode::TransportUnavailable
386 );
387 }
388
389 #[test]
390 fn welcome_reason_mapping_unknown_defaults_internal_error() {
391 let classified = SessionStateError::classify(&Error::Other("opaque".to_string()));
392 assert_eq!(
393 classified.to_welcome_reason_code(),
394 crate::events::WelcomeReasonCode::InternalError
395 );
396 }
397}