diff --git a/Cargo.lock b/Cargo.lock index 5363d2fda..ab72306bd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4347,6 +4347,7 @@ dependencies = [ "serde", "serde_json", "sha2 0.11.0", + "syn 3.0.3", "thiserror 2.0.19", "tokio", "tracing", diff --git a/Cargo.toml b/Cargo.toml index 320f4777a..a7e147ed8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -260,6 +260,9 @@ hmac = { workspace = true } libc = "0.2" metrics-exporter-prometheus = "0.18" sha2 = { workspace = true } +# Parses the crate sources in tests/error_surface.rs. Text scanning silently +# missed error enums whose doc comments carried unbalanced braces. +syn = { version = "3.0", features = ["full", "parsing"] } tokio = { workspace = true, features = ["full", "test-util"] } tracing-subscriber = { version = "0.3", features = ["env-filter"] } uuid = { workspace = true, features = ["v4"] } diff --git a/src/bot.rs b/src/bot.rs index d38927db4..4398406ac 100644 --- a/src/bot.rs +++ b/src/bot.rs @@ -89,7 +89,7 @@ pub enum BotBuilderError { /// Initializing the device row in the storage backend failed. #[error("failed to initialize the device store: {0}")] Store(#[from] StoreError), - #[error(transparent)] + #[error("{0}")] Client(#[from] ClientBuilderError), } diff --git a/src/client.rs b/src/client.rs index 2b5121879..6f5f0a5ef 100644 --- a/src/client.rs +++ b/src/client.rs @@ -569,9 +569,9 @@ pub enum ClientError { #[error("IQ request failed: {0}")] Iq(#[from] crate::request::IqError), /// Last-resort catch-all for internal failures threaded through `?` that do - /// not (yet) have a dedicated variant. Transparent so the underlying - /// error's `Display`/source chain is preserved. - #[error(transparent)] + /// not (yet) have a dedicated variant. `Display` forwards to the inner + /// error while `source()` still exposes it for downcast. + #[error("{0}")] Internal(#[from] anyhow::Error), } @@ -625,7 +625,7 @@ pub enum ConnectError { #[error("failed to open transport")] Transport(#[source] anyhow::Error), /// The noise handshake failed after the transport was up. - #[error(transparent)] + #[error("{0}")] Handshake(#[from] handshake::HandshakeError), } @@ -650,7 +650,7 @@ pub enum SignalMaintenanceError { #[error("IQ request failed: {0}")] Iq(#[from] crate::request::IqError), /// A Signal primitive failed (e.g. signing the new signed pre-key). - #[error(transparent)] + #[error("{0}")] Signal(#[from] wacore::libsignal::protocol::SignalProtocolError), /// The inbound drain batch could not be committed, so the Signal cache was /// left unflushed on purpose and the server redelivers those messages. @@ -664,6 +664,23 @@ pub enum SignalMaintenanceError { DrainShuttingDown, } +impl ConnectError { + /// A step of the connect flow ran out of time. + /// + /// Matched exhaustively so a new variant has to be classified here rather + /// than defaulting to "not a timeout" unnoticed. + pub fn is_timeout(&self) -> bool { + match self { + ConnectError::Timeout { .. } => true, + ConnectError::Handshake(handshake) => handshake.is_timeout(), + ConnectError::AlreadyConnected + | ConnectError::NotActivated + | ConnectError::Version(_) + | ConnectError::Transport(_) => false, + } + } +} + impl ClientError { pub fn is_transport_unavailable(&self) -> bool { match self { diff --git a/src/client/voip.rs b/src/client/voip.rs index 62eff946c..f6429ddac 100644 --- a/src/client/voip.rs +++ b/src/client/voip.rs @@ -36,7 +36,7 @@ impl Client { #[derive(Debug, thiserror::Error)] #[non_exhaustive] pub enum CallError { - #[error(transparent)] + #[error("{0}")] Send(#[from] ClientError), #[error("call_id cannot be empty")] EmptyCallId, diff --git a/src/error.rs b/src/error.rs new file mode 100644 index 000000000..ce265cfd8 --- /dev/null +++ b/src/error.rs @@ -0,0 +1,225 @@ +//! Typed recovery over the error chain. +//! +//! [`ErrorChainExt`] answers a few questions about any error without the caller +//! knowing its concrete type, so it need not walk [`std::error::Error::source`] +//! itself nor learn that three different types can carry a server rejection. It +//! is a read-only view with a blanket impl: a domain error added later answers +//! the same questions without implementing anything, and no new error type or +//! parallel hierarchy exists. +//! +//! ```no_run +//! use whatsapp_rust::ErrorChainExt; +//! +//! # fn demo(err: whatsapp_rust::features::GroupError) { +//! if let Some(rejection) = err.server_rejection() { +//! eprintln!("server said {}: {}", rejection.code, rejection.text); +//! } else if err.is_transport_unavailable() { +//! eprintln!("offline, will retry"); +//! } +//! # } +//! ``` +//! +//! From an `anyhow::Error`, annotate the cast: it carries two +//! `AsRef` impls and both are covered here. +//! +//! ```no_run +//! # use whatsapp_rust::ErrorChainExt; +//! # fn demo(err: whatsapp_rust::anyhow::Error) { +//! let cause: &(dyn std::error::Error + 'static) = err.as_ref(); +//! let _ = cause.server_rejection(); +//! # } +//! ``` +//! +//! # Scope +//! +//! Only questions the crate already answers internally are exposed. There is +//! deliberately no "invalid input", "protocol violation" or "internal" query: +//! each domain spells those as its own `InvalidRequest(String)`-style variant +//! with no shared representation, so any such split would be invented here +//! rather than recovered. [`crate::features::MexError::ExtensionError`] is +//! likewise not reported as a server rejection: its `code` is a GraphQL +//! extension code, a different space from the IQ `code` attribute, and merging +//! the two would make the number meaningless. +//! +//! # Rendering +//! +//! A wrapping variant renders exactly what it wraps, so each error's own +//! `Display` is unchanged. A caller that concatenates the whole chain will see +//! consecutive nodes repeat the same sentence, which is the price of keeping +//! the wrapped error downcastable. Print the innermost cause, or collapse equal +//! neighbours, rather than joining every node. + +use std::error::Error as StdError; + +use crate::request::IqError as ClientIqError; +use wacore::request::{IqError as CoreIqError, ServerErrorCode}; +use wacore::store::error::StoreError; + +/// A rejection the server sent in response to a request. +/// +/// Borrowed from whichever error in the chain carried it, so recovering one +/// costs no allocation. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[non_exhaustive] +pub struct ServerRejection<'a> { + /// The `code` attribute of the `` node. + pub code: u16, + /// The `text` attribute; empty when the server sent none. + pub text: &'a str, + /// XMPP error class from the `type` attribute (e.g. `"wait"` vs + /// `"cancel"`); `None` if absent. + pub error_type: Option<&'a str>, + /// Server-directed retry delay in seconds from the `backoff` attribute; + /// `None` if absent. + pub backoff: Option, +} + +/// Iterator over an error and everything reachable from its +/// [`source`](StdError::source). +#[derive(Clone)] +pub struct Sources<'a> { + next: Option<&'a (dyn StdError + 'static)>, +} + +impl<'a> Iterator for Sources<'a> { + type Item = &'a (dyn StdError + 'static); + + fn next(&mut self) -> Option { + let current = self.next?; + self.next = current.source(); + Some(current) + } +} + +/// Answers a few questions about any error without knowing its concrete type. +/// +/// Implemented for every [`std::error::Error`]; see the [module +/// docs](self) for what is deliberately left out. +pub trait ErrorChainExt { + /// The receiver as a trait object, so the provided methods can walk it. + #[doc(hidden)] + fn as_dyn_error(&self) -> &(dyn StdError + 'static); + + /// This error and every error reachable from it, nearest first. + /// + /// Use this to recover a domain type this trait does not model. + fn sources(&self) -> Sources<'_> { + Sources { + next: Some(self.as_dyn_error()), + } + } + + /// The server rejection behind this error, if any. + /// + /// Reports IQ-level rejections only. See the [module docs](self) for why + /// MEX extension errors are excluded. + fn server_rejection(&self) -> Option> { + self.sources().find_map(server_rejection_of) + } + + /// Whether the operation ran out of time waiting for the server. + /// + /// Covers a request that got no answer and a connect or handshake step + /// that never completed. + fn is_timeout(&self) -> bool { + self.sources().any(|cause| { + if let Some(iq) = cause.downcast_ref::() { + return iq.is_timeout(); + } + if let Some(iq) = cause.downcast_ref::() { + return iq.is_timeout(); + } + if let Some(connect) = cause.downcast_ref::() { + return connect.is_timeout(); + } + cause + .downcast_ref::() + .is_some_and(crate::handshake::HandshakeError::is_timeout) + }) + } + + /// Whether the failure was the transport being gone rather than the + /// operation being refused. + /// + /// Mirrors the judgement the send and receive paths already make when + /// deciding whether a failure is worth retrying. + fn is_transport_unavailable(&self) -> bool { + self.sources().any(|cause| { + if let Some(client) = cause.downcast_ref::() { + return client.is_transport_unavailable(); + } + if let Some(iq) = cause.downcast_ref::() { + return iq.is_transport_unavailable(); + } + if let Some(encrypt) = cause.downcast_ref::() { + return encrypt.is_transport_unavailable(); + } + cause + .downcast_ref::() + .is_some_and(CoreIqError::is_transport_unavailable) + }) + } + + /// The persistence failure behind this error, if any. + fn store_failure(&self) -> Option<&StoreError> { + self.sources().find_map(|cause| cause.downcast_ref()) + } +} + +fn server_rejection_of<'a>(cause: &'a (dyn StdError + 'static)) -> Option> { + if let Some(CoreIqError::ServerError { + code, + text, + error_type, + backoff, + }) = cause.downcast_ref::() + { + return Some(ServerRejection { + code: *code, + text, + error_type: error_type.as_deref(), + backoff: *backoff, + }); + } + if let Some(ClientIqError::ServerError { + code, + text, + error_type, + backoff, + }) = cause.downcast_ref::() + { + return Some(ServerRejection { + code: *code, + text, + error_type: error_type.as_deref(), + backoff: *backoff, + }); + } + let shared = cause.downcast_ref::()?; + Some(ServerRejection { + code: shared.code, + text: &shared.text, + error_type: shared.error_type.as_deref(), + backoff: shared.backoff, + }) +} + +impl ErrorChainExt for E { + fn as_dyn_error(&self) -> &(dyn StdError + 'static) { + self + } +} + +impl ErrorChainExt for dyn StdError + 'static { + fn as_dyn_error(&self) -> &(dyn StdError + 'static) { + self + } +} + +// `anyhow::Error` derefs to this shape, so a caller holding one can reach the +// same answers via `err.as_ref()` without this crate naming `anyhow` in the API. +impl ErrorChainExt for dyn StdError + Send + Sync + 'static { + fn as_dyn_error(&self) -> &(dyn StdError + 'static) { + self + } +} diff --git a/src/features/blocking.rs b/src/features/blocking.rs index 7a13f8519..7204afef8 100644 --- a/src/features/blocking.rs +++ b/src/features/blocking.rs @@ -16,14 +16,14 @@ use wacore_binary::Jid; #[non_exhaustive] pub enum BlockingError { /// The IQ to the server failed (transport, timeout, server rejection). - #[error(transparent)] + #[error("{0}")] Iq(#[from] IqError), /// The target JID is not a user JID, or has no resolvable LID↔PN mapping /// (modern WA requires both sides for a block). #[error("invalid blocklist target: {0}")] InvalidJid(String), /// Catch-all for internal failures (e.g. LID/PN store lookup). - #[error(transparent)] + #[error("{0}")] Internal(#[from] anyhow::Error), } diff --git a/src/features/chat_actions.rs b/src/features/chat_actions.rs index b919ab0fc..eee36ea08 100644 --- a/src/features/chat_actions.rs +++ b/src/features/chat_actions.rs @@ -29,7 +29,7 @@ pub enum AppStateError { #[error("invalid app-state request: {0}")] InvalidRequest(String), /// Encoding, key lookup, or sending the app-state patch failed. - #[error(transparent)] + #[error("{0}")] Internal(#[from] anyhow::Error), } diff --git a/src/features/chatstate.rs b/src/features/chatstate.rs index 2e2479f71..8b18f0fe0 100644 --- a/src/features/chatstate.rs +++ b/src/features/chatstate.rs @@ -12,7 +12,7 @@ use wacore_binary::builder::NodeBuilder; #[non_exhaustive] pub enum ChatStateError { /// Connection/transport failure sending the `` stanza. - #[error(transparent)] + #[error("{0}")] Client(#[from] ClientError), } diff --git a/src/features/community.rs b/src/features/community.rs index c78704c94..aed7b5e25 100644 --- a/src/features/community.rs +++ b/src/features/community.rs @@ -26,13 +26,13 @@ use wacore_binary::Jid; #[non_exhaustive] pub enum CommunityError { /// A `w:g2` IQ to the server failed. - #[error(transparent)] + #[error("{0}")] Iq(#[from] IqError), /// A MEX (GraphQL) metadata query/mutation failed or returned bad data. - #[error(transparent)] + #[error("{0}")] Mex(#[from] MexError), /// A delegated group operation failed (e.g. setting the community description). - #[error(transparent)] + #[error("{0}")] Group(#[from] GroupError), /// The request was malformed or the server response was missing required data. #[error("invalid community request: {0}")] diff --git a/src/features/contacts.rs b/src/features/contacts.rs index e8e154b98..617e84712 100644 --- a/src/features/contacts.rs +++ b/src/features/contacts.rs @@ -24,7 +24,7 @@ pub use wacore::stanza::business::VerifiedName; #[non_exhaustive] pub enum ContactError { /// The usync/profile IQ to the server failed. - #[error(transparent)] + #[error("{0}")] Iq(#[from] IqError), /// An input JID is not supported for this query (only PN and LID are). #[error("unsupported contact JID: {0}")] diff --git a/src/features/groups.rs b/src/features/groups.rs index 21ead5bfb..51a53a480 100644 --- a/src/features/groups.rs +++ b/src/features/groups.rs @@ -40,10 +40,10 @@ pub use wacore::iq::groups::{ #[non_exhaustive] pub enum GroupError { /// A `w:g2` IQ to the server failed (transport, timeout, server rejection). - #[error(transparent)] + #[error("{0}")] Iq(#[from] IqError), /// A MEX (GraphQL) group-property mutation failed. - #[error(transparent)] + #[error("{0}")] Mex(#[from] MexError), /// The request was malformed (e.g. empty invite code, batch over the limit, /// expired V4 invite, non-group JID where one is required). @@ -57,7 +57,7 @@ pub enum GroupError { DescriptionConflict, /// Catch-all for internal failures (LID/PN resolution, the protocol-message /// send path behind `update_member_label`, cache plumbing). - #[error(transparent)] + #[error("{0}")] Internal(#[from] anyhow::Error), } diff --git a/src/features/media_reupload.rs b/src/features/media_reupload.rs index 1700e31ad..663e27b39 100644 --- a/src/features/media_reupload.rs +++ b/src/features/media_reupload.rs @@ -31,7 +31,7 @@ const MEDIA_REUPLOAD_CONCURRENCY: usize = 32; #[non_exhaustive] pub enum MediaReuploadError { /// Connection/transport failure sending the server-error receipt. - #[error(transparent)] + #[error("{0}")] Client(#[from] ClientError), /// The client is not logged in. #[error("client is not logged in")] @@ -44,7 +44,7 @@ pub enum MediaReuploadError { #[error("media retry notification timed out")] Timeout, /// Catch-all for internal failures (receipt encryption, response parsing). - #[error(transparent)] + #[error("{0}")] Internal(#[from] anyhow::Error), } diff --git a/src/features/newsletter.rs b/src/features/newsletter.rs index 7774b140b..0b44a3248 100644 --- a/src/features/newsletter.rs +++ b/src/features/newsletter.rs @@ -27,20 +27,20 @@ use waproto::whatsapp as wa; #[non_exhaustive] pub enum NewsletterError { /// A MEX (GraphQL) query/mutation failed or returned malformed data. - #[error(transparent)] + #[error("{0}")] Mex(#[from] MexError), /// An IQ (message history, live updates) failed. - #[error(transparent)] + #[error("{0}")] Iq(#[from] IqError), /// Connection/transport failure sending a plaintext stanza (edit/revoke). - #[error(transparent)] + #[error("{0}")] Client(#[from] ClientError), /// The request was malformed (e.g. a non-newsletter JID, an empty target /// message id, or a missing element in the server response). #[error("invalid newsletter request: {0}")] InvalidRequest(String), /// Catch-all for internal failures with no dedicated variant. - #[error(transparent)] + #[error("{0}")] Internal(#[from] anyhow::Error), } diff --git a/src/features/polls.rs b/src/features/polls.rs index c81bc8701..8d580fe05 100644 --- a/src/features/polls.rs +++ b/src/features/polls.rs @@ -17,7 +17,7 @@ pub use wacore::poll::PollVoteCiphertext; #[non_exhaustive] pub enum PollError { /// Sending the poll/vote stanza failed (embeds the send path error). - #[error(transparent)] + #[error("{0}")] Send(#[from] SendError), /// The poll definition is invalid (option count, duplicate names, bad /// quiz index, selectable count out of range). diff --git a/src/features/presence.rs b/src/features/presence.rs index e14464325..a65a981a4 100644 --- a/src/features/presence.rs +++ b/src/features/presence.rs @@ -13,10 +13,10 @@ pub enum PresenceError { #[error("cannot send presence without a push name set")] PushNameEmpty, /// Connection/transport failure sending the `` stanza. - #[error(transparent)] + #[error("{0}")] Client(#[from] ClientError), /// Catch-all for internal failures with no dedicated variant. - #[error(transparent)] + #[error("{0}")] Other(#[from] anyhow::Error), } diff --git a/src/features/profile.rs b/src/features/profile.rs index c895ab9b0..cecc7ecff 100644 --- a/src/features/profile.rs +++ b/src/features/profile.rs @@ -19,16 +19,16 @@ pub use wacore::iq::contacts::SetProfilePictureResponse; #[non_exhaustive] pub enum ProfileError { /// An IQ to the server failed (status text / profile picture). - #[error(transparent)] + #[error("{0}")] Iq(#[from] IqError), /// Connection/transport failure sending a stanza (push-name presence). - #[error(transparent)] + #[error("{0}")] Client(#[from] ClientError), /// A provided argument is invalid (e.g. an empty push name). #[error("invalid argument: {0}")] InvalidArgument(String), /// Catch-all for internal failures with no dedicated variant. - #[error(transparent)] + #[error("{0}")] Internal(#[from] anyhow::Error), } diff --git a/src/features/signal.rs b/src/features/signal.rs index 5607291fb..157cf83e7 100644 --- a/src/features/signal.rs +++ b/src/features/signal.rs @@ -22,7 +22,7 @@ use crate::client::Client; #[non_exhaustive] pub enum SignalError { /// A Signal protocol primitive (encrypt/decrypt/session) failed. - #[error(transparent)] + #[error("{0}")] Protocol(#[from] SignalProtocolError), /// The requested operation is not valid for this input (e.g. a sender-key /// or message-secret envelope passed to the pairwise decrypt path). @@ -32,7 +32,7 @@ pub enum SignalError { #[error("invalid signal input: {0}")] InvalidInput(String), /// Catch-all for internal failures (device resolution, cache flush). - #[error(transparent)] + #[error("{0}")] Internal(#[from] anyhow::Error), } diff --git a/src/features/stanza.rs b/src/features/stanza.rs index 2a0385eb0..0b4d1507d 100644 --- a/src/features/stanza.rs +++ b/src/features/stanza.rs @@ -73,7 +73,7 @@ pub enum StanzaResponseError { UnsupportedStanzaClass, #[error("failed to encode stanza response")] Encoding(#[from] wacore_binary::error::BinaryError), - #[error(transparent)] + #[error("{0}")] Client(#[from] ClientError), } @@ -155,7 +155,7 @@ pub enum RetryRequestError { MissingLocalIdentity, #[error("invalid message stanza")] InvalidStanza(#[source] anyhow::Error), - #[error(transparent)] + #[error("{0}")] Client(#[from] ClientError), #[error("failed to prepare retry request")] Internal(#[from] anyhow::Error), diff --git a/src/features/tctoken.rs b/src/features/tctoken.rs index ef6d16021..380c77ed4 100644 --- a/src/features/tctoken.rs +++ b/src/features/tctoken.rs @@ -32,10 +32,10 @@ use wacore_binary::Jid; #[non_exhaustive] pub enum TcTokenError { /// The IQ requesting tokens from the server failed. - #[error(transparent)] + #[error("{0}")] Iq(#[from] IqError), /// A token store (persistence) operation failed. - #[error(transparent)] + #[error("{0}")] Store(#[from] StoreError), } diff --git a/src/handshake.rs b/src/handshake.rs index 3e9f9290d..c36d810ad 100644 --- a/src/handshake.rs +++ b/src/handshake.rs @@ -42,6 +42,23 @@ pub enum HandshakeError { UnexpectedEvent(String), } +impl HandshakeError { + /// The handshake ran out of time, as opposed to being torn down. + /// + /// Matched exhaustively so a new variant has to be classified here rather + /// than defaulting to "not a timeout" unnoticed. + pub fn is_timeout(&self) -> bool { + match self { + HandshakeError::Timeout => true, + HandshakeError::Transport(_) + | HandshakeError::Core(_) + | HandshakeError::StreamClosed + | HandshakeError::Disconnected + | HandshakeError::UnexpectedEvent(_) => false, + } + } +} + impl HandshakeError { /// Transient errors that are expected during reconnect and will resolve /// on retry. These never invalidate the cached server static. diff --git a/src/lib.rs b/src/lib.rs index 79ee0cab9..b351ed6a0 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -133,6 +133,8 @@ pub use client::{ConnectError, ConnectStage, SignalMaintenanceError}; pub use types::durability_hook::InboundDurabilityHook; pub use types::retry_admission::RetryAdmission; pub mod download; +pub mod error; +pub use error::{ErrorChainExt, ServerRejection, Sources}; pub mod handlers; pub use handlers::chatstate::ChatStateEvent; pub mod handshake; diff --git a/src/message/special.rs b/src/message/special.rs index 9241af5e4..3cefed74b 100644 --- a/src/message/special.rs +++ b/src/message/special.rs @@ -6,17 +6,9 @@ const APP_STATE_KEY_SHARE_SEND_ATTEMPTS: u8 = 3; const APP_STATE_KEY_SHARE_SEND_RETRY: std::time::Duration = std::time::Duration::from_secs(1); fn app_state_key_share_requires_reconnect(error: &anyhow::Error) -> bool { - error.chain().any(|cause| { - cause - .downcast_ref::() - .is_some_and(crate::client::ClientError::is_transport_unavailable) - || cause - .downcast_ref::() - .is_some_and(crate::request::IqError::is_transport_unavailable) - || cause - .downcast_ref::() - .is_some_and(crate::socket::error::EncryptSendError::is_transport_unavailable) - }) + // Annotated because `anyhow::Error` has two `AsRef` impls. + let cause: &(dyn std::error::Error + 'static) = error.as_ref(); + crate::ErrorChainExt::is_transport_unavailable(cause) } impl Client { diff --git a/src/pair_code.rs b/src/pair_code.rs index b2c502e5f..5f37d36c4 100644 --- a/src/pair_code.rs +++ b/src/pair_code.rs @@ -66,7 +66,7 @@ pub use wacore::pair_code::{PairCodeError, PairCodeOptions}; #[derive(Debug, thiserror::Error)] #[non_exhaustive] pub enum PairError { - #[error(transparent)] + #[error("{0}")] PairCode(#[from] PairCodeError), /// The pair-code IQ was rejected by the server. @@ -590,18 +590,22 @@ mod tests { } #[test] - fn pair_error_paircode_transparent_walks_to_curve_error() { + fn pair_error_paircode_walks_to_curve_error() { use wacore::libsignal::protocol::CurveError; // Wrap a wacore PairCodeError that itself carries a CurveError source. - // Because PairError::PairCode is `transparent`, walking source() once - // skips the transparent layer and lands directly on the CurveError. let pe: PairError = PairCodeError::EphemeralKeyAgreement(CurveError::NoKeyTypeIdentifier).into(); assert_eq!(pe.to_string(), "ephemeral key agreement failed"); + // Hop 1 is the PairCodeError itself: the wrapper no longer erases it. let src = std::error::Error::source(&pe).expect("source preserved"); - let curve = src + let pce = src + .downcast_ref::() + .expect("downcasts to PairCodeError"); + assert!(matches!(pce, PairCodeError::EphemeralKeyAgreement(_))); + let curve = std::error::Error::source(pce) + .expect("inner source preserved") .downcast_ref::() - .expect("downcasts to CurveError through transparent wrapper"); + .expect("downcasts to CurveError"); assert!(matches!(curve, CurveError::NoKeyTypeIdentifier)); } diff --git a/src/plugins/events.rs b/src/plugins/events.rs index f70408dad..8e52e47b0 100644 --- a/src/plugins/events.rs +++ b/src/plugins/events.rs @@ -164,7 +164,7 @@ pub enum PluginEventRouteError { #[derive(Debug, Error, Clone, PartialEq, Eq)] #[non_exhaustive] pub enum PluginEventSubscribeError { - #[error(transparent)] + #[error("{0}")] Resource(#[from] PluginResourceError), #[error("at least one plugin event selector is required")] EmptySelectors, @@ -184,7 +184,7 @@ pub enum PluginEventSubscribeError { #[derive(Debug, Error, Clone, Copy, PartialEq, Eq)] #[non_exhaustive] pub enum PluginEventPublishError { - #[error(transparent)] + #[error("{0}")] Resource(#[from] PluginResourceError), #[error("plugin event schema version must be greater than zero")] InvalidSchemaVersion, diff --git a/src/plugins/mod.rs b/src/plugins/mod.rs index dbfb7a42d..130a06322 100644 --- a/src/plugins/mod.rs +++ b/src/plugins/mod.rs @@ -290,18 +290,18 @@ pub enum PluginResourceError { #[derive(Debug, Error)] #[non_exhaustive] pub enum PluginMessagingError { - #[error(transparent)] + #[error("{0}")] Resource(#[from] PluginResourceError), - #[error(transparent)] + #[error("{0}")] Send(#[from] SendError), } #[derive(Debug, Error)] #[non_exhaustive] pub enum PluginIqError { - #[error(transparent)] + #[error("{0}")] Resource(#[from] PluginResourceError), - #[error(transparent)] + #[error("{0}")] Iq(#[from] IqError), } @@ -2585,7 +2585,7 @@ enum PluginCallbackError { "callback timed out after {timeout_seconds:.3} seconds and panicked while being cancelled" )] TimeoutCancellationPanic { timeout_seconds: f64 }, - #[error(transparent)] + #[error("{0}")] Callback(#[from] anyhow::Error), } diff --git a/src/request.rs b/src/request.rs index 58bab0c1e..927558928 100644 --- a/src/request.rs +++ b/src/request.rs @@ -111,6 +111,27 @@ impl IqError { _ => false, } } + + /// The request went out and no answer came back in time. + /// + /// Matched exhaustively so a new variant has to be classified here rather + /// than defaulting to "not a timeout" unnoticed. + pub(crate) fn is_timeout(&self) -> bool { + match self { + IqError::Timeout => true, + IqError::NotConnected + | IqError::Socket(_) + | IqError::EncryptSend(_) + | IqError::ClientState(_) + | IqError::Disconnected(_) + | IqError::ServerError { .. } + | IqError::UnexpectedResponseType { .. } + | IqError::InternalChannelClosed + | IqError::DuplicateRequestId(_) + | IqError::EncodeError(_) + | IqError::ParseError(_) => false, + } + } } impl From for IqError { diff --git a/src/send/mod.rs b/src/send/mod.rs index 6be033298..6350dbb53 100644 --- a/src/send/mod.rs +++ b/src/send/mod.rs @@ -35,8 +35,8 @@ pub enum SendError { /// Connection/transport/IQ failure (embeds the shared base error). // No `#[from]`: the manual `From` impl flattens a bare `?` so // `NotLoggedIn`/`Iq` stay matchable instead of nesting under `Client(..)`. - #[error(transparent)] - Client(ClientError), + #[error("{0}")] + Client(#[source] ClientError), /// The client has no PN/LID identity yet (not paired / mid LID migration). #[error("client is not logged in")] NotLoggedIn, @@ -48,9 +48,9 @@ pub enum SendError { #[error("invalid send request: {0}")] InvalidRequest(String), /// Catch-all for internal send failures (Signal encrypt, protobuf, group - /// resolution) that have no dedicated variant yet. Transparent so the - /// underlying error's `Display`/source chain is preserved. - #[error(transparent)] + /// resolution) that have no dedicated variant yet. `Display` forwards to + /// the inner error while `source()` still exposes it for downcast. + #[error("{0}")] Internal(#[from] anyhow::Error), } diff --git a/tests/error_surface.rs b/tests/error_surface.rs new file mode 100644 index 000000000..ac532b8d6 --- /dev/null +++ b/tests/error_surface.rs @@ -0,0 +1,491 @@ +//! The contract for the public error surface. +//! +//! Two kinds of test live here. The `surface_*` ones read the crate sources and +//! fail on any error type that departs from the pattern, including one added in +//! a future PR that never touches this file. The rest pin the behaviour that +//! makes a failure recoverable by type. + +use std::error::Error as StdError; +use std::path::{Path, PathBuf}; +use std::time::Duration; + +use whatsapp_rust::client::{ConnectError, ConnectStage}; +use whatsapp_rust::handshake::HandshakeError; + +use wacore::request::{IqError as CoreIqError, ServerErrorCode}; +use wacore::store::error::StoreError; +use whatsapp_rust::features::{ + BlockingError, ChatStateError, CommunityError, ContactError, GroupError, MediaReuploadError, + MexError, NewsletterError, PollError, PresenceError, ProfileError, StanzaResponseError, + TcTokenError, +}; +use whatsapp_rust::{ClientError, ErrorChainExt, IqError, SendError, ServerRejection}; + +// ── Source scan ───────────────────────────────────────────────────────────── + +fn rust_sources() -> Vec { + fn walk(dir: &Path, out: &mut Vec) { + let entries = std::fs::read_dir(dir).unwrap_or_else(|e| panic!("read {dir:?}: {e}")); + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + walk(&path, out); + } else if path.extension().is_some_and(|e| e == "rs") { + out.push(path); + } + } + } + let root = Path::new(env!("CARGO_MANIFEST_DIR")); + let mut out = Vec::new(); + walk(&root.join("src"), &mut out); + walk(&root.join("wacore/src"), &mut out); + out.sort(); + out +} + +/// Every `enum` in `text`, including those nested in inline modules. +fn enums_of(text: &str) -> Vec { + fn collect(items: &[syn::Item], out: &mut Vec) { + for item in items { + match item { + syn::Item::Enum(item) => out.push(item.clone()), + syn::Item::Mod(module) => { + if let Some((_, items)) = &module.content { + collect(items, out); + } + } + _ => {} + } + } + } + // A parse failure must be loud: silently skipping a file would let an + // unchecked error enum through, which is the whole thing this guards. + let file = syn::parse_file(text).expect("parse Rust source"); + let mut out = Vec::new(); + collect(&file.items, &mut out); + out +} + +fn has_attribute(attrs: &[syn::Attribute], name: &str) -> bool { + attrs.iter().any(|attr| attr.path().is_ident(name)) +} + +/// A `thiserror` enum, identified by its variants carrying `#[error(..)]`. +fn is_error_enum(item: &syn::ItemEnum) -> bool { + item.variants + .iter() + .any(|variant| has_attribute(&variant.attrs, "error")) +} + +/// Names of error enums in `text` that are `pub` and lack `#[non_exhaustive]`. +fn error_enums_missing_non_exhaustive(text: &str) -> Vec { + enums_of(text) + .iter() + .filter(|item| matches!(item.vis, syn::Visibility::Public(_))) + .filter(|item| is_error_enum(item)) + .filter(|item| !has_attribute(&item.attrs, "non_exhaustive")) + .map(|item| item.ident.to_string()) + .collect() +} + +/// Names of variants in `text` annotated `#[error(transparent)]`. +fn transparent_variants(text: &str) -> Vec { + let mut out = Vec::new(); + for item in enums_of(text) { + for variant in &item.variants { + for attr in &variant.attrs { + if attr.path().is_ident("error") + && attr + .parse_args::() + .is_ok_and(|arg| arg == "transparent") + { + out.push(format!("{}::{}", item.ident, variant.ident)); + } + } + } + } + out +} + +/// `#[error(transparent)]` delegates `source()` to the *wrapped error\'s own* +/// source, so a wrapped leaf disappears from the chain entirely and can never +/// be downcast. `#[error("{0}")]` renders identically and keeps it reachable. +/// +/// This is a blanket policy, deliberately wider than the reported symptom: it +/// covers private enums too, because today\'s private error is tomorrow\'s public +/// one and the attribute is invisible at the point where it hurts. Waiving it +/// for a case where erasure is genuinely wanted is a decision to argue for in +/// review, not to make silently. +#[test] +fn surface_has_no_transparent_error_attribute() { + let mut offenders = Vec::new(); + for file in rust_sources() { + let text = std::fs::read_to_string(&file).expect("read source"); + for variant in transparent_variants(&text) { + offenders.push(format!("{} {}", file.display(), variant)); + } + } + assert!( + offenders.is_empty(), + "`#[error(transparent)]` erases the wrapped error from the source chain. \ + Use `#[error(\"{{0}}\")]`, which renders the same text and keeps it \ + downcastable. Found at:\n {}", + offenders.join("\n ") + ); +} + +/// A new variant is a breaking change for anyone matching exhaustively unless +/// the enum is sealed against it. +#[test] +fn surface_error_enums_are_non_exhaustive() { + let mut offenders = Vec::new(); + for file in rust_sources() { + let text = std::fs::read_to_string(&file).expect("read source"); + for name in error_enums_missing_non_exhaustive(&text) { + offenders.push(format!("{} {}", file.display(), name)); + } + } + assert!( + offenders.is_empty(), + "public error enums must be `#[non_exhaustive]` so a new variant is not \ + a breaking change. Found without it:\n {}", + offenders.join("\n ") + ); +} + +// ── The scanner guards itself ─────────────────────────────────────────────── +// +// Text scanning got both of these wrong: a blank line between the attribute and +// the declaration hid `#[non_exhaustive]`, and an unbalanced brace in a doc +// comment truncated the enum so it stopped looking like an error enum at all. +// The second was fail-open, which is why this is parsed rather than scanned. + +#[test] +fn scanner_sees_attributes_separated_by_a_blank_line() { + let source = "#[derive(Debug, thiserror::Error)]\n#[non_exhaustive]\n\npub enum E {\n #[error(\"x\")]\n V,\n}"; + assert!(error_enums_missing_non_exhaustive(source).is_empty()); +} + +#[test] +fn scanner_sees_through_unbalanced_braces_in_doc_comments() { + let source = "#[derive(Debug, thiserror::Error)]\npub enum E {\n /// shape: { \"k\": v }}\n #[error(\"x\")]\n V,\n}"; + assert_eq!(error_enums_missing_non_exhaustive(source), vec!["E"]); +} + +#[test] +fn scanner_sees_transparent_followed_by_other_text() { + let source = + "pub enum E {\n #[error(transparent)] // legacy\n V(#[from] std::fmt::Error),\n}"; + assert_eq!(transparent_variants(source), vec!["E::V"]); +} + +#[test] +fn scanner_reads_a_declaration_whose_header_wraps() { + let source = + "#[derive(Debug, thiserror::Error)]\npub enum\n E\n{\n #[error(\"x\")]\n V,\n}"; + assert_eq!(error_enums_missing_non_exhaustive(source), vec!["E"]); +} + +#[test] +fn scanner_ignores_enums_that_are_not_errors() { + // Prose mentioning Error, and a variant named Error, must not be enough. + let source = "/// Errors are boxed here.\n#[derive(Debug)]\npub enum Outcome {\n Value(u8),\n Error(Box),\n}"; + assert!(error_enums_missing_non_exhaustive(source).is_empty()); +} + +#[test] +fn scanner_ignores_private_and_empty_enums_for_non_exhaustive() { + let private = "#[derive(Debug, thiserror::Error)]\nenum E {\n #[error(\"x\")]\n V,\n}"; + assert!(error_enums_missing_non_exhaustive(private).is_empty()); + let empty = "#[derive(Debug)]\npub enum E {}"; + assert!(error_enums_missing_non_exhaustive(empty).is_empty()); +} + +#[test] +fn scanner_finds_enums_nested_in_modules() { + let source = "mod inner {\n #[derive(Debug, thiserror::Error)]\n pub enum E {\n #[error(\"x\")]\n V,\n }\n}"; + assert_eq!(error_enums_missing_non_exhaustive(source), vec!["E"]); +} + +// ── Helpers ───────────────────────────────────────────────────────────────── + +fn rejected(code: u16) -> IqError { + IqError::ServerError { + code, + text: "forbidden".to_string(), + error_type: Some("cancel".to_string()), + backoff: None, + } +} + +#[track_caller] +fn assert_source_is(err: &(dyn StdError + 'static), what: &str) { + let source = err + .source() + .unwrap_or_else(|| panic!("{what}: source() is None, the wrapped error was erased")); + assert!( + source.downcast_ref::().is_some(), + "{what}: source() is not a {}", + std::any::type_name::() + ); +} + +// ── Every wrapping variant keeps its typed source ─────────────────────────── + +/// One sample per public variant that wraps a typed error. Complements +/// [`surface_has_no_transparent_error_attribute`]: the scan proves the +/// attribute is right, this proves the resulting chain actually is. +#[test] +fn wrapping_variants_preserve_their_typed_source() { + assert_source_is::(&BlockingError::Iq(rejected(403)), "BlockingError::Iq"); + assert_source_is::(&ContactError::Iq(rejected(403)), "ContactError::Iq"); + assert_source_is::(&GroupError::Iq(rejected(403)), "GroupError::Iq"); + assert_source_is::(&NewsletterError::Iq(rejected(403)), "NewsletterError::Iq"); + assert_source_is::(&ProfileError::Iq(rejected(403)), "ProfileError::Iq"); + assert_source_is::(&CommunityError::Iq(rejected(403)), "CommunityError::Iq"); + assert_source_is::(&TcTokenError::Iq(rejected(403)), "TcTokenError::Iq"); + + let mex = || MexError::ExtensionError { + code: 1, + message: "denied".to_string(), + }; + assert_source_is::(&GroupError::Mex(mex()), "GroupError::Mex"); + assert_source_is::(&CommunityError::Mex(mex()), "CommunityError::Mex"); + assert_source_is::(&NewsletterError::Mex(mex()), "NewsletterError::Mex"); + + let client = || ClientError::NotConnected; + assert_source_is::(&PresenceError::Client(client()), "PresenceError::Client"); + assert_source_is::(&ChatStateError::Client(client()), "ChatStateError::Client"); + assert_source_is::(&ProfileError::Client(client()), "ProfileError::Client"); + assert_source_is::( + &NewsletterError::Client(client()), + "NewsletterError::Client", + ); + assert_source_is::( + &MediaReuploadError::Client(client()), + "MediaReuploadError::Client", + ); + assert_source_is::( + &StanzaResponseError::Client(client()), + "StanzaResponseError::Client", + ); + assert_source_is::(&SendError::Client(client()), "SendError::Client"); + + assert_source_is::( + &TcTokenError::Store(StoreError::DeviceNotFound(1)), + "TcTokenError::Store", + ); + assert_source_is::(&PollError::Send(SendError::NotLoggedIn), "PollError::Send"); + assert_source_is::( + &CommunityError::Group(GroupError::DescriptionConflict), + "CommunityError::Group", + ); +} + +// ── The reported symptom ──────────────────────────────────────────────────── + +/// Regression for the original report: a `403` from a group operation was +/// unrecoverable because `GroupError::Iq` erased the `IqError`. +#[test] +fn group_server_rejection_exposes_code_and_text() { + let err = GroupError::Iq(rejected(403)); + + let rejection = err + .server_rejection() + .expect("a server rejection is recoverable from a group error"); + assert_eq!(rejection.code, 403); + assert_eq!(rejection.text, "forbidden"); + assert_eq!(rejection.error_type, Some("cancel")); + + // The same fact is reachable by hand, for a consumer that walks the chain + // itself rather than using the trait. + let source = StdError::source(&err).expect("source preserved"); + assert!(matches!( + source.downcast_ref::(), + Some(IqError::ServerError { code: 403, .. }) + )); +} + +/// The other half of the report: the `409` a group description update gets when +/// the `prev` token is stale. +#[test] +fn group_description_conflict_409_exposes_its_code() { + let err = GroupError::Iq(IqError::ServerError { + code: 409, + text: "conflict".to_string(), + error_type: None, + backoff: None, + }); + let rejection = err.server_rejection().expect("409 is recoverable"); + assert_eq!(rejection.code, 409); + assert_eq!(rejection.error_type, None); +} + +/// `Display` is unchanged by the switch away from `transparent`: it still +/// renders the wrapped error verbatim. +#[test] +fn wrapping_variants_render_the_wrapped_error_verbatim() { + let inner = rejected(403); + let rendered = inner.to_string(); + assert_eq!(GroupError::Iq(rejected(403)).to_string(), rendered); + assert_eq!(ProfileError::Iq(rejected(403)).to_string(), rendered); + assert_eq!( + PresenceError::Client(ClientError::NotConnected).to_string(), + ClientError::NotConnected.to_string() + ); +} + +// ── Domain-agnostic recovery ──────────────────────────────────────────────── + +/// Goal: code written against one domain works for every other. The helper +/// below names no concrete error type. +fn code_of(err: &(dyn StdError + 'static)) -> Option { + err.server_rejection().map(|r: ServerRejection<'_>| r.code) +} + +#[test] +fn server_rejection_is_recovered_the_same_way_in_every_domain() { + let domains: Vec> = vec![ + Box::new(GroupError::Iq(rejected(403))), + Box::new(NewsletterError::Iq(rejected(403))), + Box::new(ProfileError::Iq(rejected(403))), + Box::new(BlockingError::Iq(rejected(403))), + Box::new(CommunityError::Iq(rejected(403))), + Box::new(ContactError::Iq(rejected(403))), + Box::new(TcTokenError::Iq(rejected(403))), + // Nested two domains deep. + Box::new(CommunityError::Group(GroupError::Iq(rejected(403)))), + // Reached through the shared cross-crate carrier instead of an IqError. + Box::new(GroupError::Internal(anyhow::Error::new(ServerErrorCode { + code: 403, + text: "forbidden".to_string(), + error_type: None, + backoff: None, + }))), + ]; + for err in &domains { + assert_eq!(code_of(err.as_ref()), Some(403), "failed for {err:?}"); + } +} + +#[test] +fn timeout_is_recovered_across_domains() { + assert!(GroupError::Iq(IqError::Timeout).is_timeout()); + assert!(ProfileError::Iq(IqError::Timeout).is_timeout()); + assert!(CommunityError::Group(GroupError::Iq(IqError::Timeout)).is_timeout()); + assert!(!GroupError::Iq(rejected(403)).is_timeout()); +} + +/// Connect and handshake run out of time without any `IqError` involved, and +/// the crate already tells those apart from every other connect failure. +#[test] +fn connect_and_handshake_timeouts_are_recovered_too() { + let connect = ConnectError::Timeout { + stage: ConnectStage::Socket, + timeout: Duration::from_secs(10), + }; + assert!(connect.is_timeout()); + assert!(HandshakeError::Timeout.is_timeout()); + // Reached through the wrapper as well. + assert!(ConnectError::Handshake(HandshakeError::Timeout).is_timeout()); + + // Neighbouring failures of the same flow are not timeouts. + assert!(!ConnectError::AlreadyConnected.is_timeout()); + assert!(!HandshakeError::StreamClosed.is_timeout()); + assert!(!ConnectError::Handshake(HandshakeError::Disconnected).is_timeout()); +} + +#[test] +fn transport_loss_is_recovered_across_domains() { + assert!(PresenceError::Client(ClientError::NotConnected).is_transport_unavailable()); + assert!(NewsletterError::Client(ClientError::NotConnected).is_transport_unavailable()); + assert!(GroupError::Iq(IqError::NotConnected).is_transport_unavailable()); + assert!(SendError::Client(ClientError::NotConnected).is_transport_unavailable()); + // A refusal is not a disconnection. + assert!(!GroupError::Iq(rejected(403)).is_transport_unavailable()); +} + +#[test] +fn store_failure_is_recovered_across_domains() { + let err = TcTokenError::Store(StoreError::DeviceNotFound(7)); + assert!(matches!( + err.store_failure(), + Some(StoreError::DeviceNotFound(7)) + )); + assert!(GroupError::Iq(rejected(403)).store_failure().is_none()); +} + +/// A `wacore` error reaches the same answers, so the two `IqError` types are +/// not a distinction the consumer has to know about. +#[test] +fn the_wacore_iq_error_answers_identically() { + let core = CoreIqError::ServerError { + code: 401, + text: "unauthorized".to_string(), + error_type: None, + backoff: Some(30), + }; + let rejection = core.server_rejection().expect("recoverable"); + assert_eq!(rejection.code, 401); + assert_eq!(rejection.backoff, Some(30)); + assert!(CoreIqError::Timeout.is_timeout()); + assert!(CoreIqError::NotConnected.is_transport_unavailable()); +} + +/// `Internal(anyhow)` is not a dead end: the head of the `anyhow` chain is +/// exposed as `source()` and stays downcastable. +#[test] +fn internal_anyhow_still_exposes_its_head() { + let err = GroupError::Internal(anyhow::Error::new(rejected(403))); + let source = StdError::source(&err).expect("anyhow head is exposed"); + assert!(source.downcast_ref::().is_some()); + assert_eq!(code_of(&err), Some(403)); +} + +// ── Categories are recovered, never invented ──────────────────────────────── + +/// A MEX extension `code` is a GraphQL extension code, a different space from +/// the IQ `code` attribute. Reporting it as a server rejection would make the +/// number mean two things. +#[test] +fn mex_extension_error_is_not_reported_as_a_server_rejection() { + let err = GroupError::Mex(MexError::ExtensionError { + code: 403, + message: "denied".to_string(), + }); + assert_eq!(err.server_rejection(), None); + // It is still recoverable by type, just not under a category it does not + // belong to. + let source = StdError::source(&err).expect("source preserved"); + assert!(matches!( + source.downcast_ref::(), + Some(MexError::ExtensionError { code: 403, .. }) + )); +} + +/// Errors that carry none of the modelled facts answer `None`/`false` rather +/// than being forced into some bucket. +#[test] +fn errors_without_a_modelled_category_report_nothing() { + let err = GroupError::InvalidRequest("empty invite code".to_string()); + assert_eq!(err.server_rejection(), None); + assert!(!err.is_timeout()); + assert!(!err.is_transport_unavailable()); + assert!(err.store_failure().is_none()); + + let conflict = GroupError::DescriptionConflict; + assert_eq!(conflict.server_rejection(), None); + assert!(!conflict.is_transport_unavailable()); +} + +// ── Chain access for facts the trait does not model ───────────────────────── + +#[test] +fn sources_walks_the_whole_chain_nearest_first() { + let err = CommunityError::Group(GroupError::Iq(rejected(403))); + let chain: Vec<&(dyn StdError + 'static)> = err.sources().collect(); + assert_eq!(chain.len(), 3, "community -> group -> iq"); + assert!(chain[0].downcast_ref::().is_some()); + assert!(chain[1].downcast_ref::().is_some()); + assert!(chain[2].downcast_ref::().is_some()); +} diff --git a/wacore/src/appstate_sync.rs b/wacore/src/appstate_sync.rs index 9f0dcd4a1..e55370363 100644 --- a/wacore/src/appstate_sync.rs +++ b/wacore/src/appstate_sync.rs @@ -133,7 +133,7 @@ pub enum AppStateSyncError { KeyNotFound(String), #[error("store error")] Store(#[from] crate::store::error::StoreError), - #[error(transparent)] + #[error("{0}")] Other(#[from] anyhow::Error), } diff --git a/wacore/src/download.rs b/wacore/src/download.rs index f69fd494b..f7a24e1d4 100644 --- a/wacore/src/download.rs +++ b/wacore/src/download.rs @@ -28,7 +28,7 @@ pub enum MediaDecryptionError { Decryption(#[source] AesCbcDecryptionError), #[error("HMAC initialization failed")] Mac(#[source] CryptoError), - #[error(transparent)] + #[error("{0}")] Other(#[from] anyhow::Error), } diff --git a/wacore/src/iq/chatstate.rs b/wacore/src/iq/chatstate.rs index 820905a9f..dba87caf1 100644 --- a/wacore/src/iq/chatstate.rs +++ b/wacore/src/iq/chatstate.rs @@ -29,6 +29,7 @@ use wacore_binary::NodeRef; /// Error type for chatstate parsing failures. #[derive(Debug, Error)] +#[non_exhaustive] pub enum ChatstateParseError { /// Stanza has wrong tag (not ``) #[error("expected , got <{0}>")] diff --git a/wacore/src/iq/dirty.rs b/wacore/src/iq/dirty.rs index 64c467071..040e84ae1 100644 --- a/wacore/src/iq/dirty.rs +++ b/wacore/src/iq/dirty.rs @@ -22,6 +22,7 @@ pub enum DirtyType { } #[derive(Debug, thiserror::Error)] +#[non_exhaustive] pub enum DirtyBitParseError { #[error("invalid timestamp '{value}': {source}")] InvalidTimestamp { diff --git a/wacore/src/pair_code.rs b/wacore/src/pair_code.rs index 119a58747..65b812cda 100644 --- a/wacore/src/pair_code.rs +++ b/wacore/src/pair_code.rs @@ -517,6 +517,7 @@ impl PairCodeUtils { /// `whatsapp_rust::pair_code::PairError` and adds an IQ-failure variant for the /// transport layer. #[derive(Debug, thiserror::Error)] +#[non_exhaustive] pub enum PairCodeError { #[error("phone number is required")] PhoneNumberRequired, diff --git a/wacore/src/request.rs b/wacore/src/request.rs index 6a768d18f..9b8e9e027 100644 --- a/wacore/src/request.rs +++ b/wacore/src/request.rs @@ -103,6 +103,34 @@ pub enum IqError { InternalChannelClosed, } +impl IqError { + /// The transport is gone rather than the request having been refused. + /// + /// Sole owner of that judgement for this type: callers that classify an + /// error chain read it here instead of restating the variant list. + pub fn is_transport_unavailable(&self) -> bool { + matches!( + self, + IqError::NotConnected | IqError::Disconnected(_) | IqError::InternalChannelClosed + ) + } + + /// The request went out and no answer came back in time. + /// + /// Matched exhaustively so a new variant has to be classified here rather + /// than defaulting to "not a timeout" unnoticed. + pub fn is_timeout(&self) -> bool { + match self { + IqError::Timeout => true, + IqError::NotConnected + | IqError::Disconnected(_) + | IqError::ServerError { .. } + | IqError::UnexpectedResponseType { .. } + | IqError::InternalChannelClosed => false, + } + } +} + /// Lightweight server error that can be embedded in `anyhow::Error` and /// downcast from any crate. Used as a shared type across crate boundaries /// when `wacore::request::IqError` isn't directly available (e.g., errors diff --git a/wacore/src/shortcake.rs b/wacore/src/shortcake.rs index 0e795f64b..6e120a399 100644 --- a/wacore/src/shortcake.rs +++ b/wacore/src/shortcake.rs @@ -47,6 +47,7 @@ const ENC_KEY_INFO: &[u8] = b"Pairing Information Encryption Key"; const VERIFICATION_CODE_BYTES: usize = 5; #[derive(Debug, thiserror::Error)] +#[non_exhaustive] pub enum ShortcakeError { #[error("invalid primary public key: {0}")] InvalidPrimaryKey(CurveError),