Skip to main content

rvoip_sip/
errors.rs

1//! Error and `Result` types for the `rvoip-sip` session layer.
2//!
3//! [`SessionError`] is the crate-wide error enum returned by the public API
4//! surfaces (`Endpoint`, `StreamPeer`, `CallbackPeer`, `UnifiedCoordinator`,
5//! `SessionHandle`). [`Result`] is the `Result<T, SessionError>` alias used
6//! throughout the crate.
7
8use std::fmt;
9
10use thiserror::Error;
11
12use crate::api::headers::options::{HeaderNameDiagnostic, HeaderNamesDiagnostic, MethodDiagnostic};
13use rvoip_sip_core::types::sdp::CryptoSuite;
14
15/// SDP side on which an RFC 4568 SDES failure was observed.
16#[derive(Debug, Clone, Copy, PartialEq, Eq)]
17#[non_exhaustive]
18pub enum SdesNegotiationStage {
19    /// An inbound SDP offer was being validated.
20    RemoteOffer,
21    /// An inbound SDP answer was being validated.
22    RemoteAnswer,
23}
24
25impl fmt::Display for SdesNegotiationStage {
26    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
27        formatter.write_str(match self {
28            Self::RemoteOffer => "remote-offer",
29            Self::RemoteAnswer => "remote-answer",
30        })
31    }
32}
33
34/// Secret-safe class for an RFC 4568 SDES key-material failure.
35#[derive(Debug, Clone, Copy, PartialEq, Eq)]
36#[non_exhaustive]
37pub enum SdesNegotiationFailureClass {
38    /// The encoded key material was not acceptable Base64.
39    InvalidBase64,
40    /// Base64 decoded successfully but did not have the suite's exact length.
41    DecodedLength,
42}
43
44impl fmt::Display for SdesNegotiationFailureClass {
45    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
46        formatter.write_str(match self {
47            Self::InvalidBase64 => "invalid-base64",
48            Self::DecodedLength => "decoded-length",
49        })
50    }
51}
52
53/// Classification of the trailing Base64 padding in an SDES inline key.
54#[derive(Debug, Clone, Copy, PartialEq, Eq)]
55#[non_exhaustive]
56pub enum SdesBase64Padding {
57    /// Canonical Base64 ending in one or two `=` characters.
58    CanonicalPadded,
59    /// Canonical Base64 for a byte length that requires no `=` padding.
60    CanonicalUnpadded,
61    /// Otherwise-valid Base64 with only required trailing padding omitted.
62    OmittedTrailing,
63    /// Padding was misplaced, excessive, or structurally invalid.
64    Malformed,
65}
66
67impl fmt::Display for SdesBase64Padding {
68    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
69        formatter.write_str(match self {
70            Self::CanonicalPadded => "canonical-padded",
71            Self::CanonicalUnpadded => "canonical-unpadded",
72            Self::OmittedTrailing => "omitted-trailing",
73            Self::Malformed => "malformed",
74        })
75    }
76}
77
78/// Public, secret-safe diagnostics for one failed SDES key negotiation.
79///
80/// The encoded key, decoded key bytes, lifetime/MKI text, and parser source
81/// error are intentionally absent.
82#[derive(Debug, Clone, PartialEq, Eq)]
83#[non_exhaustive]
84pub struct SdesNegotiationDiagnostic {
85    /// Whether the peer's offer or answer was being processed.
86    pub stage: SdesNegotiationStage,
87    /// Stable failure class suitable for metrics and alerts.
88    pub failure_class: SdesNegotiationFailureClass,
89    /// RFC 4568 crypto attribute tag.
90    pub tag: u32,
91    /// Suite named by the peer's crypto attribute.
92    pub suite: CryptoSuite,
93    /// Length of the encoded key token, excluding lifetime/MKI suffixes.
94    pub encoded_bytes: usize,
95    /// Classification of the token's trailing Base64 padding.
96    pub padding: SdesBase64Padding,
97    /// Exact decoded key-plus-salt length required by the suite.
98    pub expected_decoded_bytes: usize,
99    /// Decoded byte length when decoding succeeded, otherwise `None`.
100    pub actual_decoded_bytes: Option<usize>,
101}
102
103impl fmt::Display for SdesNegotiationDiagnostic {
104    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
105        let actual = self
106            .actual_decoded_bytes
107            .map(|bytes| bytes.to_string())
108            .unwrap_or_else(|| "unknown".to_string());
109        write!(
110            formatter,
111            "SDES negotiation failed (stage={}, class={}, tag={}, suite={:?}, encoded_bytes={}, padding={}, expected_decoded_bytes={}, actual_decoded_bytes={actual})",
112            self.stage,
113            self.failure_class,
114            self.tag,
115            self.suite,
116            self.encoded_bytes,
117            self.padding,
118            self.expected_decoded_bytes
119        )
120    }
121}
122
123/// Convenience alias for `Result<T, SessionError>` used across the crate's API.
124pub type Result<T> = std::result::Result<T, SessionError>;
125
126struct TextDiagnostic<'a>(&'a str);
127
128impl fmt::Display for TextDiagnostic<'_> {
129    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
130        write!(formatter, "redacted(bytes={})", self.0.len())
131    }
132}
133
134impl fmt::Debug for TextDiagnostic<'_> {
135    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
136        fmt::Display::fmt(self, formatter)
137    }
138}
139
140/// Errors returned by the `rvoip-sip` session layer.
141#[derive(Error)]
142pub enum SessionError {
143    /// No session with the given identifier exists in the registry.
144    #[error("Session not found: {}", TextDiagnostic(.0))]
145    SessionNotFound(String),
146
147    /// A requested state-machine transition is not legal from the current state.
148    #[error("Invalid state transition: {}", TextDiagnostic(.0))]
149    InvalidTransition(String),
150
151    /// An error originating in the dialog layer (`rvoip-sip-dialog`).
152    #[error("Dialog error: {}", TextDiagnostic(.0))]
153    DialogError(String),
154
155    /// An error originating in the media layer (`rvoip-media-core`).
156    #[error("Media error: {}", TextDiagnostic(.0))]
157    MediaError(String),
158
159    /// SIP signalling succeeded but wiring media to the negotiated session failed.
160    #[error("Media integration error: {}", TextDiagnostic(.reason))]
161    MediaIntegration {
162        /// Human-readable description of the media-integration failure.
163        reason: String,
164    },
165
166    /// SDP offer/answer negotiation failed (no common codec, malformed SDP, etc.).
167    #[error("SDP negotiation failed: {}", TextDiagnostic(.0))]
168    SDPNegotiationFailed(String),
169
170    /// Invalid or inconsistent configuration supplied to a builder or coordinator.
171    #[error("Configuration error: {}", TextDiagnostic(.0))]
172    ConfigurationError(String),
173
174    /// Configuration error (legacy alias of [`SessionError::ConfigurationError`]).
175    #[error("Config error: {}", TextDiagnostic(.0))]
176    ConfigError(String),
177
178    /// An application-supplied argument was malformed or out of range.
179    #[error("Invalid input: {}", TextDiagnostic(.0))]
180    InvalidInput(String),
181
182    /// An operation did not complete within its allotted time.
183    #[error("Timeout: {}", TextDiagnostic(.0))]
184    Timeout(String),
185
186    /// A transport-level network error occurred.
187    #[error("Network error: {}", TextDiagnostic(.0))]
188    NetworkError(String),
189
190    /// A SIP protocol violation was detected.
191    #[error("Protocol error: {}", TextDiagnostic(.0))]
192    ProtocolError(String),
193
194    /// RFC 3262 — the remote peer did not advertise `Supported: 100rel` on the
195    /// INVITE, so we cannot send a reliable 183 Session Progress. Raised by
196    /// `send_early_media`. Today we fail fast; a future `send_progress(sdp)`
197    /// API could fall back to an unreliable 183.
198    #[error("peer did not advertise 100rel; cannot send reliable 183")]
199    UnreliableProvisionalsNotSupported,
200
201    /// RFC 3261 §22.2 — the server challenged our INVITE with 401/407 but the
202    /// session has no credentials on file. Set credentials via
203    /// `StreamPeerBuilder::with_credentials` (per-peer default) or
204    /// `control.invite(...).with_credentials(...)` (per-call).
205    #[error("server challenged INVITE but no credentials are on file")]
206    MissingCredentialsForInviteAuth,
207
208    /// RFC 3261 §22.2 — the server challenged an outbound request with 401/407
209    /// but the request flow has no credentials on file.
210    #[error(
211        "server challenged {} but no credentials are on file",
212        MethodDiagnostic(.method)
213    )]
214    MissingCredentialsForRequestAuth {
215        /// SIP method that was challenged.
216        method: rvoip_sip_core::Method,
217    },
218
219    /// RFC 3261 §22.2 — INVITE auth has already been retried once and the
220    /// server challenged again. Prevents loops against a broken server or
221    /// wrong credentials.
222    #[error("INVITE auth retry limit exceeded")]
223    InviteAuthRetryExhausted,
224
225    /// RFC 3261 §22.2 — outbound request auth has already been retried once and
226    /// the server challenged again.
227    #[error("{} auth retry limit exceeded", MethodDiagnostic(.method))]
228    RequestAuthRetryExhausted {
229        /// SIP method whose auth retry limit was exceeded.
230        method: rvoip_sip_core::Method,
231    },
232
233    /// An unexpected internal invariant was violated.
234    #[error("Internal error: {}", TextDiagnostic(.0))]
235    InternalError(String),
236
237    /// An underlying `std::io` error (transport sockets, file I/O).
238    #[error("I/O operation failed")]
239    IoError(#[from] std::io::Error),
240
241    /// The requested capability is recognized but not yet implemented.
242    #[error("Not implemented: {}", TextDiagnostic(.0))]
243    NotImplemented(String),
244
245    /// A call transfer (REFER flow) failed.
246    #[error("Transfer failed: {}", TextDiagnostic(.0))]
247    TransferFailed(String),
248
249    /// Authentication failed or could not be completed.
250    ///
251    /// The retained string remains matchable for compatibility, but only
252    /// library-owned fixed diagnostic classes are rendered by `Display` or
253    /// `Debug`. Arbitrary provider/application strings render as a fixed
254    /// redacted class.
255    #[error("Authentication error: {}", AuthErrorDiagnostic(.0))]
256    AuthError(String),
257
258    /// An outbound INVITE challenge could not be converted into a safe
259    /// Authorization response. Lower parser/provider diagnostics are omitted.
260    #[error("outbound INVITE authentication failed (class=challenge-response)")]
261    InviteAuthConstructionFailed,
262
263    /// A non-INVITE outbound challenge could not be converted into a safe
264    /// Authorization response. Lower parser/provider diagnostics are omitted.
265    #[error("outbound request authentication failed (class=challenge-response)")]
266    RequestAuthConstructionFailed,
267
268    /// An outbound REGISTER challenge could not be converted into a safe
269    /// Authorization response. Lower parser/provider diagnostics are omitted.
270    #[error("outbound REGISTER authentication failed (class=challenge-response)")]
271    RegisterAuthConstructionFailed,
272
273    /// A REGISTER flow failed after any supported retry path.
274    #[error("Registration failed: {}", TextDiagnostic(.0))]
275    RegistrationFailed(String),
276
277    /// A flattened/stringly error from a lower layer that has no dedicated variant.
278    #[error("Other error: {}", TextDiagnostic(.0))]
279    Other(String),
280
281    /// SIP_API_DESIGN_2 §8 — a builder setter or `with_header` call
282    /// staged a header that violates the per-method policy. The most
283    /// common case is staging a stack-managed name (Call-ID, CSeq,
284    /// Via, Max-Forwards) or a method-shaped name that has a
285    /// dedicated setter (e.g. Authorization -> `with_credentials` / `with_auth`).
286    #[error(
287        "header policy violation on {}: {} — {reason}",
288        MethodDiagnostic(.method),
289        HeaderNameDiagnostic(.header)
290    )]
291    HeaderPolicy {
292        /// SIP method whose per-method header policy was violated.
293        method: rvoip_sip_core::Method,
294        /// The offending header name.
295        header: rvoip_sip_core::types::headers::HeaderName,
296        /// Why the header was rejected.
297        reason: crate::api::headers::ViolationReason,
298    },
299
300    /// SIP_API_DESIGN_2 §8 — `HeaderPolicy::validate_outbound`
301    /// reported one or more required application-supplied headers
302    /// were missing for the chosen method.
303    #[error(
304        "required application header(s) missing for {}: {:?}",
305        MethodDiagnostic(.method),
306        HeaderNamesDiagnostic(.names)
307    )]
308    MissingRequiredHeader {
309        /// SIP method that requires the missing header(s).
310        method: rvoip_sip_core::Method,
311        /// The required header names that were not supplied.
312        names: Vec<rvoip_sip_core::types::headers::HeaderName>,
313    },
314
315    /// SIP_API_DESIGN_2 §7.3 invariant #5 — a second `.send()` was
316    /// attempted on the same session for a method whose
317    /// `pending_<method>_options` stash slot is still occupied by an
318    /// in-flight prior `.send()`. Wait for the first future to
319    /// complete (or drop cleanly) before starting another of the
320    /// same method.
321    #[error(
322        "another {} is already in flight on this session",
323        MethodDiagnostic(.method)
324    )]
325    Conflict {
326        /// SIP method whose in-flight `.send()` blocks a second concurrent send.
327        method: rvoip_sip_core::Method,
328    },
329}
330
331// Keep the established derived-Debug shape for every fixed/string variant,
332// while substituting diagnostic-only views for application-controlled SIP
333// method and header fields. The live error fields remain exact and matchable.
334impl fmt::Debug for SessionError {
335    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
336        match self {
337            Self::SessionNotFound(value) => formatter
338                .debug_tuple("SessionNotFound")
339                .field(&TextDiagnostic(value))
340                .finish(),
341            Self::InvalidTransition(value) => formatter
342                .debug_tuple("InvalidTransition")
343                .field(&TextDiagnostic(value))
344                .finish(),
345            Self::DialogError(value) => formatter
346                .debug_tuple("DialogError")
347                .field(&TextDiagnostic(value))
348                .finish(),
349            Self::MediaError(value) => formatter
350                .debug_tuple("MediaError")
351                .field(&TextDiagnostic(value))
352                .finish(),
353            Self::MediaIntegration { reason } => formatter
354                .debug_struct("MediaIntegration")
355                .field("reason", &TextDiagnostic(reason))
356                .finish(),
357            Self::SDPNegotiationFailed(value) => formatter
358                .debug_tuple("SDPNegotiationFailed")
359                .field(&TextDiagnostic(value))
360                .finish(),
361            Self::ConfigurationError(value) => formatter
362                .debug_tuple("ConfigurationError")
363                .field(&TextDiagnostic(value))
364                .finish(),
365            Self::ConfigError(value) => formatter
366                .debug_tuple("ConfigError")
367                .field(&TextDiagnostic(value))
368                .finish(),
369            Self::InvalidInput(value) => formatter
370                .debug_tuple("InvalidInput")
371                .field(&TextDiagnostic(value))
372                .finish(),
373            Self::Timeout(value) => formatter
374                .debug_tuple("Timeout")
375                .field(&TextDiagnostic(value))
376                .finish(),
377            Self::NetworkError(value) => formatter
378                .debug_tuple("NetworkError")
379                .field(&TextDiagnostic(value))
380                .finish(),
381            Self::ProtocolError(value) => formatter
382                .debug_tuple("ProtocolError")
383                .field(&TextDiagnostic(value))
384                .finish(),
385            Self::UnreliableProvisionalsNotSupported => {
386                formatter.write_str("UnreliableProvisionalsNotSupported")
387            }
388            Self::MissingCredentialsForInviteAuth => {
389                formatter.write_str("MissingCredentialsForInviteAuth")
390            }
391            Self::MissingCredentialsForRequestAuth { method } => formatter
392                .debug_struct("MissingCredentialsForRequestAuth")
393                .field("method", &MethodDiagnostic(method))
394                .finish(),
395            Self::InviteAuthRetryExhausted => formatter.write_str("InviteAuthRetryExhausted"),
396            Self::RequestAuthRetryExhausted { method } => formatter
397                .debug_struct("RequestAuthRetryExhausted")
398                .field("method", &MethodDiagnostic(method))
399                .finish(),
400            Self::InternalError(value) => formatter
401                .debug_tuple("InternalError")
402                .field(&TextDiagnostic(value))
403                .finish(),
404            Self::IoError(value) => formatter
405                .debug_struct("IoError")
406                .field("kind", &value.kind())
407                .finish(),
408            Self::NotImplemented(value) => formatter
409                .debug_tuple("NotImplemented")
410                .field(&TextDiagnostic(value))
411                .finish(),
412            Self::TransferFailed(value) => formatter
413                .debug_tuple("TransferFailed")
414                .field(&TextDiagnostic(value))
415                .finish(),
416            Self::AuthError(value) => formatter
417                .debug_tuple("AuthError")
418                .field(&AuthErrorDiagnostic(value))
419                .finish(),
420            Self::InviteAuthConstructionFailed => {
421                formatter.write_str("InviteAuthConstructionFailed")
422            }
423            Self::RequestAuthConstructionFailed => {
424                formatter.write_str("RequestAuthConstructionFailed")
425            }
426            Self::RegisterAuthConstructionFailed => {
427                formatter.write_str("RegisterAuthConstructionFailed")
428            }
429            Self::RegistrationFailed(value) => formatter
430                .debug_tuple("RegistrationFailed")
431                .field(&TextDiagnostic(value))
432                .finish(),
433            Self::Other(value) => formatter
434                .debug_tuple("Other")
435                .field(&TextDiagnostic(value))
436                .finish(),
437            Self::HeaderPolicy {
438                method,
439                header,
440                reason,
441            } => formatter
442                .debug_struct("HeaderPolicy")
443                .field("method", &MethodDiagnostic(method))
444                .field("header", &HeaderNameDiagnostic(header))
445                .field("reason", reason)
446                .finish(),
447            Self::MissingRequiredHeader { method, names } => formatter
448                .debug_struct("MissingRequiredHeader")
449                .field("method", &MethodDiagnostic(method))
450                .field("names", &HeaderNamesDiagnostic(names))
451                .finish(),
452            Self::Conflict { method } => formatter
453                .debug_struct("Conflict")
454                .field("method", &MethodDiagnostic(method))
455                .finish(),
456        }
457    }
458}
459
460#[derive(Clone, Copy, Debug, Eq, PartialEq)]
461pub(crate) enum OutboundAuthOperation {
462    Invite,
463    Request,
464    Register,
465}
466
467/// Fixed authentication dependency stage retained in outward diagnostics.
468#[derive(Clone, Copy, Debug, Eq, PartialEq)]
469pub(crate) enum AuthFailureStage {
470    CorePrimitive,
471    AkaClientProvider,
472    PrincipalProjection,
473    RateLimitCheck,
474    RateLimitRecord,
475    AuditSink,
476    BearerValidator,
477    BasicVerifier,
478    AkaVectorProvider,
479    DigestSecretProvider,
480    ReplayNonceRecord,
481    ReplayNonceStatus,
482    ReplayNonceCount,
483    ErasedCredentialProvider,
484}
485
486impl AuthFailureStage {
487    const ALL: [Self; 14] = [
488        Self::CorePrimitive,
489        Self::AkaClientProvider,
490        Self::PrincipalProjection,
491        Self::RateLimitCheck,
492        Self::RateLimitRecord,
493        Self::AuditSink,
494        Self::BearerValidator,
495        Self::BasicVerifier,
496        Self::AkaVectorProvider,
497        Self::DigestSecretProvider,
498        Self::ReplayNonceRecord,
499        Self::ReplayNonceStatus,
500        Self::ReplayNonceCount,
501        Self::ErasedCredentialProvider,
502    ];
503
504    pub(crate) const fn message(self) -> &'static str {
505        match self {
506            Self::CorePrimitive => "authentication failed (stage=core-primitive)",
507            Self::AkaClientProvider => "authentication failed (stage=aka-client-provider)",
508            Self::PrincipalProjection => "authentication failed (stage=principal-projection)",
509            Self::RateLimitCheck => "authentication failed (stage=rate-limit-check)",
510            Self::RateLimitRecord => "authentication failed (stage=rate-limit-record)",
511            Self::AuditSink => "authentication failed (stage=audit-sink)",
512            Self::BearerValidator => "authentication failed (stage=bearer-validator)",
513            Self::BasicVerifier => "authentication failed (stage=basic-verifier)",
514            Self::AkaVectorProvider => "authentication failed (stage=aka-vector-provider)",
515            Self::DigestSecretProvider => "authentication failed (stage=digest-secret-provider)",
516            Self::ReplayNonceRecord => "authentication failed (stage=replay-nonce-record)",
517            Self::ReplayNonceStatus => "authentication failed (stage=replay-nonce-status)",
518            Self::ReplayNonceCount => "authentication failed (stage=replay-nonce-count)",
519            Self::ErasedCredentialProvider => {
520                "authentication failed (stage=erased-credential-provider)"
521            }
522        }
523    }
524}
525
526/// Collapse a provider, validator, or shared-store failure without retaining
527/// its arbitrary diagnostic string in the session error.
528pub(crate) fn redacted_auth_failure<E>(stage: AuthFailureStage, _source: E) -> SessionError {
529    SessionError::AuthError(stage.message().to_string())
530}
531
532struct AuthErrorDiagnostic<'a>(&'a str);
533
534impl fmt::Display for AuthErrorDiagnostic<'_> {
535    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
536        formatter.write_str(safe_auth_error_message(self.0))
537    }
538}
539
540impl fmt::Debug for AuthErrorDiagnostic<'_> {
541    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
542        fmt::Debug::fmt(safe_auth_error_message(self.0), formatter)
543    }
544}
545
546fn safe_auth_error_message(value: &str) -> &str {
547    if AuthFailureStage::ALL
548        .iter()
549        .any(|stage| stage.message() == value)
550        || matches!(
551            value,
552            "Digest credentials cannot answer a non-Digest challenge"
553                | "Bearer token cannot answer a non-Bearer challenge"
554                | "Bearer authentication over cleartext SIP is disabled"
555                | "Bearer token cannot be empty"
556                | "Basic credentials cannot answer a non-Basic challenge"
557                | "Basic authentication over cleartext SIP is disabled"
558                | "AKA credentials cannot answer a non-AKA challenge"
559                | "generated SIP authorization header failed wire-safety validation"
560                | "no configured auth option can answer the challenge"
561                | "unsupported outbound SIP authorization header name"
562                | "outbound SIP authorization header failed wire-safety validation"
563        )
564    {
565        value
566    } else {
567        "authentication failed (stage=opaque-source)"
568    }
569}
570
571/// Collapse a lower challenge parser, Digest algorithm, or authentication
572/// provider failure into a value-free typed operation class.
573pub(crate) fn redacted_outbound_auth_error<E>(
574    operation: OutboundAuthOperation,
575    _source: E,
576) -> SessionError {
577    match operation {
578        OutboundAuthOperation::Invite => SessionError::InviteAuthConstructionFailed,
579        OutboundAuthOperation::Request => SessionError::RequestAuthConstructionFailed,
580        OutboundAuthOperation::Register => SessionError::RegisterAuthConstructionFailed,
581    }
582}
583
584impl From<crate::api::headers::HeaderPolicyViolation> for SessionError {
585    fn from(v: crate::api::headers::HeaderPolicyViolation) -> Self {
586        SessionError::HeaderPolicy {
587            method: v.method,
588            header: v.header,
589            reason: v.reason,
590        }
591    }
592}
593
594impl SessionError {
595    /// True if this error means "the session is already gone from the
596    /// registry" — covers both the typed `SessionNotFound` variant and the
597    /// legacy stringly-wrapped `Other("Session not found: …")` form.
598    ///
599    /// Useful for fire-and-forget teardown paths (e.g. `SessionHandle::hangup`)
600    /// that race against a natural call-ended cleanup: if the race is lost,
601    /// the goal is already achieved and the error should be silent.
602    pub fn is_session_gone(&self) -> bool {
603        matches!(self, SessionError::SessionNotFound(_))
604            || matches!(self, SessionError::Other(msg) if msg.starts_with("Session not found"))
605            || matches!(self, SessionError::Other(msg) if msg.starts_with("Session ") && msg.ends_with(" not found"))
606    }
607}
608
609impl From<Box<dyn std::error::Error>> for SessionError {
610    fn from(err: Box<dyn std::error::Error>) -> Self {
611        let err = match err.downcast::<SessionError>() {
612            Ok(error) => return *error,
613            Err(error) => error,
614        };
615        let err = match err.downcast::<rvoip_auth_core::AuthError>() {
616            Ok(error) => return SessionError::from(*error),
617            Err(error) => error,
618        };
619        if err
620            .downcast_ref::<rvoip_auth_core::CredentialAuthError>()
621            .is_some()
622        {
623            return redacted_auth_failure(AuthFailureStage::ErasedCredentialProvider, ());
624        }
625        SessionError::Other("lower-layer operation failed (class=opaque-erased)".to_string())
626    }
627}
628
629impl From<Box<dyn std::error::Error + Send + Sync>> for SessionError {
630    fn from(err: Box<dyn std::error::Error + Send + Sync>) -> Self {
631        let err = match err.downcast::<SessionError>() {
632            Ok(error) => return *error,
633            Err(error) => error,
634        };
635        let err = match err.downcast::<rvoip_auth_core::AuthError>() {
636            Ok(error) => return SessionError::from(*error),
637            Err(error) => error,
638        };
639        if err
640            .downcast_ref::<rvoip_auth_core::CredentialAuthError>()
641            .is_some()
642        {
643            return redacted_auth_failure(AuthFailureStage::ErasedCredentialProvider, ());
644        }
645        SessionError::Other("lower-layer operation failed (class=opaque-erased)".to_string())
646    }
647}
648
649impl From<rvoip_auth_core::AuthError> for SessionError {
650    fn from(err: rvoip_auth_core::AuthError) -> Self {
651        redacted_auth_failure(AuthFailureStage::CorePrimitive, err)
652    }
653}
654
655#[cfg(test)]
656mod method_diagnostic_tests {
657    use super::*;
658    use crate::api::headers::ViolationReason;
659    use rvoip_sip_core::types::headers::HeaderName;
660    use rvoip_sip_core::Method;
661
662    const METHOD_CANARY: &str = "CUSTOM\r\nX-Method-Canary: exposed";
663    const HEADER_CANARY: &str = "X-Header-Canary\r\nInjected";
664
665    fn assert_redacted(error: &SessionError) {
666        for rendered in [error.to_string(), format!("{error:?}")] {
667            assert!(
668                !rendered.contains(METHOD_CANARY),
669                "extension method leaked: {rendered}"
670            );
671            assert!(
672                !rendered.contains(HEADER_CANARY),
673                "custom header leaked: {rendered}"
674            );
675        }
676    }
677
678    #[test]
679    fn every_method_bearing_error_redacts_extension_spelling() {
680        let extension = || Method::Extension(METHOD_CANARY.to_string());
681        let errors = [
682            SessionError::MissingCredentialsForRequestAuth {
683                method: extension(),
684            },
685            SessionError::RequestAuthRetryExhausted {
686                method: extension(),
687            },
688            SessionError::HeaderPolicy {
689                method: extension(),
690                header: HeaderName::Other(HEADER_CANARY.to_string()),
691                reason: ViolationReason::StackManaged,
692            },
693            SessionError::MissingRequiredHeader {
694                method: extension(),
695                names: vec![HeaderName::Other(HEADER_CANARY.to_string())],
696            },
697            SessionError::Conflict {
698                method: extension(),
699            },
700        ];
701
702        for error in &errors {
703            assert_redacted(error);
704            let display = error.to_string();
705            let debug = format!("{error:?}");
706            assert!(display.contains(&format!("extension(len={})", METHOD_CANARY.len())));
707            assert!(debug.contains(&format!("value_len: {}", METHOD_CANARY.len())));
708        }
709
710        match &errors[2] {
711            SessionError::HeaderPolicy { method, header, .. } => {
712                assert_eq!(method, &Method::Extension(METHOD_CANARY.to_string()));
713                assert_eq!(header, &HeaderName::Other(HEADER_CANARY.to_string()));
714            }
715            other => panic!("unexpected error: {other:?}"),
716        }
717    }
718
719    #[test]
720    fn fixed_error_debug_shapes_remain_derived_compatible() {
721        assert_eq!(
722            format!("{:?}", SessionError::SessionNotFound("call-1".to_string())),
723            "SessionNotFound(redacted(bytes=6))"
724        );
725        assert_eq!(
726            format!(
727                "{:?}",
728                SessionError::MissingCredentialsForRequestAuth {
729                    method: Method::Invite,
730                }
731            ),
732            "MissingCredentialsForRequestAuth { method: Invite }"
733        );
734        assert_eq!(
735            SessionError::Conflict {
736                method: Method::Message,
737            }
738            .to_string(),
739            "another MESSAGE is already in flight on this session"
740        );
741    }
742}
743
744#[cfg(test)]
745mod auth_error_diagnostic_tests {
746    use super::*;
747
748    const SOURCE_CANARY: &str = "provider-secret\r\nX-Auth-Canary: exposed";
749
750    #[test]
751    fn arbitrary_public_auth_error_value_is_matchable_but_never_rendered() {
752        let error = SessionError::AuthError(SOURCE_CANARY.to_string());
753        assert_eq!(
754            error.to_string(),
755            "Authentication error: authentication failed (stage=opaque-source)"
756        );
757        assert_eq!(
758            format!("{error:?}"),
759            "AuthError(\"authentication failed (stage=opaque-source)\")"
760        );
761        assert!(!error.to_string().contains(SOURCE_CANARY));
762        assert!(!format!("{error:?}").contains(SOURCE_CANARY));
763        match error {
764            SessionError::AuthError(value) => assert_eq!(value, SOURCE_CANARY),
765            other => panic!("unexpected error: {other:?}"),
766        }
767    }
768
769    #[test]
770    fn auth_core_conversion_discards_lower_diagnostic_content() {
771        let error = SessionError::from(rvoip_auth_core::AuthError::ProviderError(
772            SOURCE_CANARY.to_string(),
773        ));
774        assert_eq!(
775            error.to_string(),
776            "Authentication error: authentication failed (stage=core-primitive)"
777        );
778        assert!(!error.to_string().contains(SOURCE_CANARY));
779        assert!(!format!("{error:?}").contains(SOURCE_CANARY));
780        assert!(matches!(
781            error,
782            SessionError::AuthError(value)
783                if value == AuthFailureStage::CorePrimitive.message()
784        ));
785    }
786
787    #[test]
788    fn fixed_library_auth_policy_messages_remain_actionable() {
789        let error = SessionError::AuthError(
790            "Basic authentication over cleartext SIP is disabled".to_string(),
791        );
792        assert!(error.to_string().contains("cleartext SIP"));
793        assert!(format!("{error:?}").contains("cleartext SIP"));
794    }
795}
796
797#[cfg(test)]
798mod outbound_auth_redaction_tests {
799    use super::*;
800    use crate::auth::{
801        AkaClientConfig, AkaClientProvider, SipClientAuth, SipTransportSecurityContext,
802    };
803    use std::sync::Arc;
804
805    const ALGORITHM_SECRET: &str = "MALICIOUS-ALGORITHM-CANARY";
806    const PROVIDER_SECRET: &str = "ARBITRARY-AKA-PROVIDER-CANARY";
807
808    struct FailingAkaProvider;
809
810    impl AkaClientProvider for FailingAkaProvider {
811        fn authorization(
812            &self,
813            _challenge_header: &str,
814            _method: &str,
815            _request_uri: &str,
816            _nonce_count: u32,
817        ) -> Result<String> {
818            Err(SessionError::AuthError(format!(
819                "AKA provider failure: {PROVIDER_SECRET}"
820            )))
821        }
822    }
823
824    fn assert_redacted(error: SessionError, secret: &str) {
825        let display = error.to_string();
826        let debug = format!("{error:?}");
827        for rendered in [&display, &debug] {
828            assert!(!rendered.contains(secret), "auth source leaked: {rendered}");
829        }
830        assert!(display.contains("challenge-response"));
831        assert!(matches!(error, SessionError::InviteAuthConstructionFailed));
832    }
833
834    #[test]
835    fn malicious_digest_algorithm_is_collapsed_before_retry_dispatch() {
836        let challenge = format!(
837            "Digest realm=\"pbx\", nonce=\"n1\", algorithm={ALGORITHM_SECRET}, qop=\"auth\""
838        );
839        let lower = SipClientAuth::digest("alice", "secret")
840            .authorization_for_challenge_with_transport_context(
841                &challenge,
842                "INVITE",
843                "sip:bob@example.test",
844                1,
845                None,
846                &SipTransportSecurityContext::from_transport_name("TLS"),
847            )
848            .expect_err("unsupported peer algorithm must fail");
849        assert!(!lower.to_string().contains(ALGORITHM_SECRET));
850        assert!(lower.to_string().contains("stage=core-primitive"));
851
852        assert_redacted(
853            redacted_outbound_auth_error(OutboundAuthOperation::Invite, lower),
854            ALGORITHM_SECRET,
855        );
856    }
857
858    #[test]
859    fn arbitrary_aka_provider_error_is_collapsed_before_retry_dispatch() {
860        let lower = SipClientAuth::aka(AkaClientConfig::new(Arc::new(FailingAkaProvider)))
861            .authorization_for_challenge_with_transport_context(
862                r#"Digest realm="ims", nonce="n1", algorithm=AKAv1-MD5"#,
863                "INVITE",
864                "sip:bob@example.test",
865                1,
866                None,
867                &SipTransportSecurityContext::from_transport_name("TLS"),
868            )
869            .expect_err("provider failure must fail auth construction");
870        assert!(!lower.to_string().contains(PROVIDER_SECRET));
871        assert!(lower.to_string().contains("stage=aka-client-provider"));
872
873        assert_redacted(
874            redacted_outbound_auth_error(OutboundAuthOperation::Invite, lower),
875            PROVIDER_SECRET,
876        );
877    }
878
879    #[test]
880    fn every_outbound_auth_operation_maps_to_a_fixed_typed_class() {
881        for (operation, expected) in [
882            (
883                OutboundAuthOperation::Invite,
884                SessionError::InviteAuthConstructionFailed,
885            ),
886            (
887                OutboundAuthOperation::Request,
888                SessionError::RequestAuthConstructionFailed,
889            ),
890            (
891                OutboundAuthOperation::Register,
892                SessionError::RegisterAuthConstructionFailed,
893            ),
894        ] {
895            let mapped = redacted_outbound_auth_error(
896                operation,
897                SessionError::AuthError(PROVIDER_SECRET.to_string()),
898            );
899            assert_eq!(mapped.to_string(), expected.to_string());
900            assert!(!mapped.to_string().contains(PROVIDER_SECRET));
901        }
902    }
903}
904
905#[cfg(test)]
906mod erased_error_boundary_tests {
907    use super::*;
908
909    const CANARY: &str = "erased-lower-error-canary\r\nAuthorization: exposed";
910
911    #[derive(Debug)]
912    struct ArbitraryLowerError;
913
914    impl fmt::Display for ArbitraryLowerError {
915        fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
916            formatter.write_str(CANARY)
917        }
918    }
919
920    impl std::error::Error for ArbitraryLowerError {}
921
922    fn assert_no_canary(error: &SessionError) {
923        for rendered in [error.to_string(), format!("{error:?}")] {
924            assert!(!rendered.contains(CANARY), "lower error leaked: {rendered}");
925        }
926    }
927
928    #[test]
929    fn arbitrary_erased_errors_collapse_to_fixed_class() {
930        let plain: SessionError =
931            (Box::new(ArbitraryLowerError) as Box<dyn std::error::Error>).into();
932        let send_sync: SessionError =
933            (Box::new(ArbitraryLowerError) as Box<dyn std::error::Error + Send + Sync>).into();
934        for error in [plain, send_sync] {
935            assert_no_canary(&error);
936            assert!(matches!(
937                error,
938                SessionError::Other(ref value)
939                    if value == "lower-layer operation failed (class=opaque-erased)"
940            ));
941        }
942    }
943
944    #[test]
945    fn erased_auth_errors_are_downcast_and_classified() {
946        let core: SessionError =
947            (Box::new(rvoip_auth_core::AuthError::ProviderError(CANARY.into()))
948                as Box<dyn std::error::Error>)
949                .into();
950        let provider = Box::new(rvoip_auth_core::CredentialAuthError::Unavailable(
951            CANARY.into(),
952        )) as Box<dyn std::error::Error + Send + Sync>;
953        let provider = SessionError::from(provider);
954
955        assert_no_canary(&core);
956        assert_no_canary(&provider);
957        assert!(matches!(
958            core,
959            SessionError::AuthError(ref value)
960                if value == "authentication failed (stage=core-primitive)"
961        ));
962        assert!(matches!(
963            provider,
964            SessionError::AuthError(ref value)
965                if value == "authentication failed (stage=erased-credential-provider)"
966        ));
967    }
968
969    #[test]
970    fn boxed_session_errors_keep_their_typed_variant() {
971        let error: SessionError = (Box::new(SessionError::SessionNotFound("call-1".into()))
972            as Box<dyn std::error::Error + Send + Sync>)
973            .into();
974        assert!(matches!(error, SessionError::SessionNotFound(ref id) if id == "call-1"));
975    }
976}