Skip to content
14 changes: 10 additions & 4 deletions src/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2506,10 +2506,16 @@ impl Client {
}
continue;
}
let is_db_locked = e.downcast_ref::<wacore::store::error::StoreError>()
.is_some_and(|se| matches!(se, wacore::store::error::StoreError::Database(msg) if msg.contains("locked") || msg.contains("busy")))
let is_db_locked = e
.downcast_ref::<wacore::store::error::StoreError>()
.is_some_and(|se| se.is_database_busy_or_locked())
|| e.downcast_ref::<crate::appstate_sync::AppStateSyncError>()
.is_some_and(|ase| matches!(ase, crate::appstate_sync::AppStateSyncError::Store(wacore::store::error::StoreError::Database(msg)) if msg.contains("locked") || msg.contains("busy")));
.is_some_and(|ase| match ase {
crate::appstate_sync::AppStateSyncError::Store(se) => {
se.is_database_busy_or_locked()
}
_ => false,
});
if is_db_locked && attempt < APP_STATE_RETRY_MAX_ATTEMPTS {
let backoff = Duration::from_millis(200 * attempt as u64 + 150);
warn!(target: "Client/AppState", "Attempt {} for {:?} failed due to locked DB; backing off {:?} and retrying", attempt, name, backoff);
Expand Down Expand Up @@ -3608,7 +3614,7 @@ impl Client {

let plaintext_buf = wacore_binary::marshal::marshal_auto(&node).map_err(|e| {
error!("Failed to marshal node: {e:?}");
SocketError::Crypto("Marshal error".to_string())
SocketError::Marshal(e)
})?;

self.send_raw_bytes(plaintext_buf).await
Expand Down
34 changes: 32 additions & 2 deletions src/features/mex.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,23 +7,29 @@ use crate::request::IqError;
use serde_json::Value;
use thiserror::Error;
use wacore::iq::mex::MexQuerySpec;
use wacore_binary::jid::JidError;

// Re-export types from wacore
pub use wacore::iq::mex::{MexDoc, MexErrorExtensions, MexGraphQLError, MexResponse};

/// Error types for MEX operations.
#[derive(Debug, Error)]
pub enum MexError {
/// Payload missing or otherwise malformed in a way that has no underlying
/// typed source (descriptive message only — e.g. "missing data").
#[error("MEX payload parsing error: {0}")]
PayloadParsing(String),

#[error("MEX payload contained an invalid JID")]
InvalidJid(#[from] JidError),

#[error("MEX extension error: code={code}, message='{message}'")]
ExtensionError { code: i32, message: String },

#[error("IQ request failed: {0}")]
#[error("IQ request failed")]
Request(#[from] IqError),

#[error("JSON error: {0}")]
#[error("JSON error")]
Json(#[from] serde_json::Error),
}

Expand Down Expand Up @@ -237,4 +243,28 @@ mod tests {
assert!(ext.is_retryable.is_none());
assert!(ext.severity.is_none());
}

#[test]
fn invalid_jid_preserves_jid_error_source() {
let raw: Result<wacore_binary::Jid, JidError> = "not-a-valid-jid".parse();
let jid_err = raw.unwrap_err();
let me: MexError = jid_err.into();
let src = std::error::Error::source(&me).expect("source preserved");
let inner = src
.downcast_ref::<JidError>()
.expect("downcasts to JidError");
assert!(matches!(inner, JidError::InvalidFormat(_)));
}

#[test]
fn request_preserves_iq_error_source() {
let iq = IqError::ServerError {
code: 404,
text: "not-found".into(),
};
let me: MexError = iq.into();
let src = std::error::Error::source(&me).expect("source preserved");
let inner = src.downcast_ref::<IqError>().expect("downcasts to IqError");
assert!(matches!(inner, IqError::ServerError { code: 404, .. }));
}
}
4 changes: 1 addition & 3 deletions src/features/newsletter.rs
Original file line number Diff line number Diff line change
Expand Up @@ -420,9 +420,7 @@ fn parse_newsletter_metadata(value: &serde_json::Value) -> Result<NewsletterMeta
let jid_str = value["id"]
.as_str()
.ok_or_else(|| MexError::PayloadParsing("missing newsletter id".into()))?;
let jid: Jid = jid_str
.parse()
.map_err(|e| MexError::PayloadParsing(format!("invalid newsletter JID: {e}")))?;
let jid: Jid = jid_str.parse()?;

let thread = &value["thread_metadata"];

Expand Down
4 changes: 3 additions & 1 deletion src/keepalive.rs
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ enum KeepaliveResult {
fn classify_keepalive_error(e: &IqError) -> KeepaliveResult {
match e {
IqError::Socket(_)
| IqError::EncryptSend(_)
| IqError::ClientState(_)
| IqError::Disconnected(_)
| IqError::NotConnected
| IqError::InternalChannelClosed
Expand Down Expand Up @@ -229,7 +231,7 @@ mod tests {
#[test]
fn test_classify_socket_error_is_fatal() {
assert_eq!(
classify_keepalive_error(&IqError::Socket(SocketError::Crypto("test".to_string()))),
classify_keepalive_error(&IqError::Socket(SocketError::SocketClosed)),
KeepaliveResult::FatalFailure,
);
}
Expand Down
67 changes: 54 additions & 13 deletions src/pair_code.rs
Original file line number Diff line number Diff line change
Expand Up @@ -52,13 +52,25 @@ use log::{error, info, warn};

use std::sync::Arc;
use wacore::libsignal::protocol::KeyPair;
use wacore::pair_code::{PairCodeError, PairCodeState, PairCodeUtils, resolve_companion_platform};
use wacore::pair_code::{PairCodeState, PairCodeUtils, resolve_companion_platform};
use wacore_binary::Jid;
use wacore_binary::{NodeContent, NodeContentRef, NodeRef};

// Re-export types for user convenience
pub use wacore::companion_reg::CompanionWebClientType;
pub use wacore::pair_code::PairCodeOptions;
pub use wacore::pair_code::{PairCodeError, PairCodeOptions};

/// Errors raised by the high-level pair-code flow.
///
/// Wraps `wacore::pair_code::PairCodeError` (validation, key derivation, bundle
/// building) and adds the IQ transport layer via `RequestFailed`.
#[derive(Debug, thiserror::Error)]
pub enum PairError {
#[error(transparent)]
PairCode(#[from] PairCodeError),

#[error("pair-code IQ request failed")]
RequestFailed(#[from] IqError),
}

impl Client {
/// Initiates pair code authentication as an alternative to QR code pairing.
Expand Down Expand Up @@ -99,7 +111,7 @@ impl Client {
pub async fn pair_with_code(
self: &Arc<Self>,
options: PairCodeOptions,
) -> Result<String, PairCodeError> {
) -> Result<String, PairError> {
// Strip non-digit characters from phone number (allows "+1-555-123-4567" format)
let phone_number: String = options
.phone_number
Expand All @@ -109,20 +121,20 @@ impl Client {

// Validate phone number
if phone_number.is_empty() {
return Err(PairCodeError::PhoneNumberRequired);
return Err(PairCodeError::PhoneNumberRequired.into());
}
if phone_number.len() < 7 {
return Err(PairCodeError::PhoneNumberTooShort);
return Err(PairCodeError::PhoneNumberTooShort.into());
}
if phone_number.starts_with('0') {
return Err(PairCodeError::PhoneNumberNotInternational);
return Err(PairCodeError::PhoneNumberNotInternational.into());
}

// Generate or validate code
let code = match &options.custom_code {
Some(custom) => {
if !PairCodeUtils::validate_code(custom) {
return Err(PairCodeError::InvalidCustomCode);
return Err(PairCodeError::InvalidCustomCode.into());
}
custom.to_uppercase()
}
Expand Down Expand Up @@ -192,12 +204,8 @@ impl Client {
timeout: Some(std::time::Duration::from_secs(30)),
};

let response = self
.send_iq(query)
.await
.map_err(|e: IqError| PairCodeError::RequestFailed(e.to_string()))?;
let response = self.send_iq(query).await?;

// Extract pairing ref from response
let pairing_ref = PairCodeUtils::parse_companion_hello_response(response.get())
.ok_or(PairCodeError::MissingPairingRef)?;

Expand Down Expand Up @@ -373,3 +381,36 @@ pub(crate) async fn handle_pair_code_notification(

true
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn pair_error_request_failed_preserves_iq_source() {
let iq = IqError::ServerError {
code: 400,
text: "bad-request".into(),
};
let pe: PairError = iq.into();
let src = std::error::Error::source(&pe).expect("source preserved");
let downcast = src.downcast_ref::<IqError>().expect("downcasts to IqError");
assert!(matches!(downcast, IqError::ServerError { code: 400, .. }));
}

#[test]
fn pair_error_paircode_transparent_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");
let src = std::error::Error::source(&pe).expect("source preserved");
let curve = src
.downcast_ref::<CurveError>()
.expect("downcasts to CurveError through transparent wrapper");
assert!(matches!(curve, CurveError::NoKeyTypeIdentifier));
}
}
33 changes: 20 additions & 13 deletions src/request.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
use crate::client::Client;
use crate::socket::error::SocketError;
use crate::client::ClientError;
use crate::socket::error::{EncryptSendError, SocketError};
use futures::FutureExt;
use std::sync::Arc;
use std::sync::atomic::Ordering;
Expand All @@ -14,19 +15,23 @@ pub use wacore::request::{InfoQuery, InfoQueryType, RequestUtils};
pub enum IqError {
#[error("IQ request timed out")]
Timeout,
#[error("Client is not connected")]
#[error("client is not connected")]
NotConnected,
#[error("Socket error: {0}")]
#[error("socket error")]
Socket(#[from] SocketError),
#[error("Received disconnect node during IQ wait: {0:?}")]
#[error("encrypted send pipeline failed")]
EncryptSend(#[from] EncryptSendError),
#[error("client state prevented send")]
ClientState(#[source] ClientError),
#[error("received disconnect node during IQ wait: {0:?}")]
Disconnected(Node),
#[error("Received a server error response: code={code}, text='{text}'")]
#[error("received a server error response: code={code}, text='{text}'")]
ServerError { code: u16, text: String },
#[error("Internal channel closed unexpectedly")]
#[error("internal channel closed unexpectedly")]
InternalChannelClosed,
#[error("Failed to encode IQ request: {0}")]
EncodeError(anyhow::Error),
#[error("Failed to parse IQ response: {0}")]
#[error("failed to encode IQ request")]
EncodeError(#[source] anyhow::Error),
#[error("failed to parse IQ response")]
ParseError(#[from] anyhow::Error),
}

Expand All @@ -40,7 +45,6 @@ impl From<wacore::request::IqError> for IqError {
Self::ServerError { code, text }
}
wacore::request::IqError::InternalChannelClosed => Self::InternalChannelClosed,
wacore::request::IqError::Network(msg) => Self::Socket(SocketError::Crypto(msg)),
}
}
}
Expand Down Expand Up @@ -225,9 +229,12 @@ impl Client {
if let Err(e) = send_fn.await {
self.response_waiters.lock().await.remove(&req_id);
return match e {
crate::client::ClientError::Socket(s_err) => Err(IqError::Socket(s_err)),
crate::client::ClientError::NotConnected => Err(IqError::NotConnected),
_ => Err(IqError::Socket(SocketError::Crypto(e.to_string()))),
ClientError::Socket(s_err) => Err(IqError::Socket(s_err)),
ClientError::EncryptSend(es_err) => Err(IqError::EncryptSend(es_err)),
ClientError::NotConnected => Err(IqError::NotConnected),
other @ (ClientError::AlreadyConnected | ClientError::NotLoggedIn) => {
Err(IqError::ClientState(other))
}
};
}

Expand Down
49 changes: 43 additions & 6 deletions src/socket/error.rs
Original file line number Diff line number Diff line change
@@ -1,15 +1,17 @@
use thiserror::Error;
use wacore::handshake::NoiseError;
use wacore_binary::error::BinaryError;

#[derive(Debug, Error)]
pub enum SocketError {
#[error("Socket is closed")]
#[error("socket is closed")]
SocketClosed,
#[error("Noise handshake failed: {0}")]
NoiseHandshake(String),
#[error("I/O error: {0}")]
#[error("I/O error")]
Io(#[from] std::io::Error),
#[error("Crypto error: {0}")]
Crypto(String),
#[error("noise cipher operation failed")]
Cipher(#[from] NoiseError),
#[error("binary protocol marshalling failed")]
Marshal(#[source] BinaryError),
}

pub type Result<T> = std::result::Result<T, SocketError>;
Expand Down Expand Up @@ -80,3 +82,38 @@ impl EncryptSendError {
)
}
}

#[cfg(test)]
mod tests {
use super::*;
use wacore::libsignal::crypto::CryptoProviderError;

#[test]
fn cipher_preserves_noise_source_through_socket_error() {
let noise = NoiseError::Decrypt(CryptoProviderError::AuthFailed);
let se: SocketError = noise.into();
// First hop: SocketError → NoiseError
let src = std::error::Error::source(&se).expect("source preserved");
let ne = src
.downcast_ref::<NoiseError>()
.expect("downcasts to NoiseError");
assert!(matches!(ne, NoiseError::Decrypt(_)));
// Second hop: NoiseError → CryptoProviderError
let inner = std::error::Error::source(ne).expect("inner source preserved");
let cpe = inner
.downcast_ref::<CryptoProviderError>()
.expect("downcasts to CryptoProviderError");
assert!(matches!(cpe, CryptoProviderError::AuthFailed));
}

#[test]
fn marshal_preserves_binary_error_source() {
let be = BinaryError::InvalidNode;
let se = SocketError::Marshal(be);
let src = std::error::Error::source(&se).expect("source preserved");
let inner = src
.downcast_ref::<BinaryError>()
.expect("downcasts to BinaryError");
assert!(matches!(inner, BinaryError::InvalidNode));
}
}
2 changes: 1 addition & 1 deletion src/socket/noise_socket.rs
Original file line number Diff line number Diff line change
Expand Up @@ -180,7 +180,7 @@ impl NoiseSocket {
let counter = self.read_counter.fetch_add(1, Ordering::SeqCst);
self.read_key
.decrypt_in_place_with_counter(counter, &mut ciphertext)
.map_err(|e| SocketError::Crypto(e.to_string()))?;
.map_err(SocketError::Cipher)?;
Ok(ciphertext)
}
}
Expand Down
2 changes: 1 addition & 1 deletion src/store/error.rs
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
// Re-export error types from wacore for compatibility
pub use wacore::store::error::{Result, StoreError, db_err};
pub use wacore::store::error::{Result, StoreError};
Loading
Loading