//! The crate's error surfaces: the typed open-error vocabulary //! ([`SocksOpenError`], alkcall ADR-049 §4), the session-level error //! ([`SocksSessionError`]) for the consumer sessions, and the //! re-exported RFC data-plane vocabulary ([`SocksError`], //! [`SocksServerError`], [`ReplyError`]). //! //! Open errors are channels-level: a failed open never yields a //! phantom channel (the no-phantom-channel property, ADR-049 — the //! session never exists opener-side). Target-level refusal happens //! later, in-stream, as an RFC reply code surfaced through //! [`SocksSessionError::Rfc`] (consumer.md §Error surface) — never as //! an open failure. use alkcall::channels::client::ChannelOpenError; pub use crate::rfc1928::server::SocksServerError; pub use crate::rfc1928::{ReplyError, SocksError}; /// The typed open-failure vocabulary (alkcall ADR-049 §4): a failed /// SOCKS5 channel open. A failure never yields a session — no phantom /// channel (the channel is torn down accept-side and no `channel_id` /// was ever returned). /// /// The establisher-driven reasons (`dial_failed` / `unknown_resource` /// / `resource_shortage` / `handler_error` / `timeout`) arrive inside /// the [`SocksOpenError::Open`] variant, keyed by the wire reason /// code; the registry's own gate runs *before* the establisher and /// keeps its RFC-unrelated codes (`FORBIDDEN` for a scope-less /// identity, `INVALID_INPUT` for a schema-mismatched params payload, /// `channel:too_many_channels` for the per-connection cap). Branch on /// [`SocksOpenError::reason`] for the typed establishment view, or /// [`SocksOpenError::open_ref`] for the full raw /// [`ChannelOpenError`]. #[derive(Debug, thiserror::Error)] pub enum SocksOpenError { /// The open op failed — the typed [`ChannelOpenError`] carries the /// wire [`alkcall::protocol::wire::CallError`] verbatim /// (`channel:open_failed` + `details.reason` per ADR-049 §3 when /// the establisher refused; `FORBIDDEN`, `INVALID_INPUT`, /// `channel:too_many_channels`, … for the registry-gate classes). #[error("socks5 open failed: {0}")] Open(#[from] ChannelOpenError), } impl SocksOpenError { /// The underlying [`ChannelOpenError`] (the ADR-049 typed surface). pub fn open_ref(&self) -> &ChannelOpenError { match self { SocksOpenError::Open(e) => e, } } /// The typed establishment-failure reason of a /// `channel:open_failed` error (`dial_failed` / `unknown_resource` /// / `resource_shortage` / `handler_error` / `timeout` — alkcall /// ADR-049 §3's vocabulary, parsed). `None` for any other failure /// shape: the registry's `FORBIDDEN`/`INVALID_INPUT` classes, /// `channel:too_many_channels`, or local-only failures /// ([`ChannelOpenError::MissingChannelId`] / /// [`ChannelOpenError::AdoptFailed`]). pub fn reason(&self) -> Option { SocksEstablishmentReason::parse(self.open_ref().establishment_reason()?) } } /// The typed establishment-reason vocabulary (alkcall ADR-049 §3's /// reason codes, parsed from the wire string). One-way once consumers /// branch on it (ADR-049's door-type note) — the string codes are the /// wire truth; this enum is the typed view. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum SocksEstablishmentReason { /// The backend/target could not be reached or refused. DialFailed, /// The requested resource does not exist. UnknownResource, /// The backend is out of capacity (ports, fds, slots). ResourceShortage, /// Establisher-internal failure not covered above. HandlerError, /// Establishment exceeded the deadline. Timeout, } impl SocksEstablishmentReason { /// Parse the wire `details.reason` string (ADR-049 §3's /// vocabulary). Unknown strings are `None` — a *new* reason code /// from a newer producer must not silently alias an old variant /// (the vocabulary is one-way; additive codes need a migration). pub fn parse(reason: &str) -> Option { match reason { "dial_failed" => Some(Self::DialFailed), "unknown_resource" => Some(Self::UnknownResource), "resource_shortage" => Some(Self::ResourceShortage), "handler_error" => Some(Self::HandlerError), "timeout" => Some(Self::Timeout), _ => None, } } /// The canonical wire reason code (the inverse of [`Self::parse`]). pub fn as_str(self) -> &'static str { match self { Self::DialFailed => "dial_failed", Self::UnknownResource => "unknown_resource", Self::ResourceShortage => "resource_shortage", Self::HandlerError => "handler_error", Self::Timeout => "timeout", } } } impl std::fmt::Display for SocksEstablishmentReason { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.write_str(self.as_str()) } } /// The consumer session's error surface (consumer.md §Error surface): /// open failures are channels-level and typed /// ([`SocksOpenError`]); in-stream RFC failures (a refused target — /// the reply code) are [`SocksSessionError::Rfc`]; the data-plane /// codec failures are [`SocksSessionError::Rfc`]'s inner /// [`SocksError`] and [`SocksSessionError::Io`] is the plain I/O /// failure. /// /// A refused session never yields a phantom channel (an open failure /// is `Err` before any session exists); a refused *target* is an RFC /// reply code surfaced through the session type. #[derive(Debug, thiserror::Error)] pub enum SocksSessionError { /// The channel open failed (the session never came to exist). #[error("socks5 open failed: {0}")] Open(#[from] SocksOpenError), /// The in-stream RFC 1928 conversation failed — a refused target /// (the reply code, e.g. `ConnectionNotAllowed` 0x02 for a BIND /// without `socks5:bind`), an auth rejection, a malformed /// negotiation, or the UDP-frame codec. The RFC-faithful surface: /// never coerced to a channels-level shape. #[error("socks5: {0}")] Rfc(#[from] SocksError), /// An I/O failure on the session's data plane (the channel /// `BiStream` itself). #[error("io: {0}")] Io(#[from] std::io::Error), } #[cfg(test)] mod tests { use super::*; use alkcall::protocol::wire::CallError; fn open_failed_with(reason: &str) -> SocksOpenError { SocksOpenError::Open(ChannelOpenError::CallFailed { error: CallError::new("channel:open_failed", "establishment failed", false) .with_details(serde_json::json!({ "reason": reason })), }) } #[test] fn open_error_maps_the_adr_049_reason_vocabulary() { // The reason-mapping table (alkcall ADR-049 §3's five codes). for (wire, typed) in [ ("dial_failed", SocksEstablishmentReason::DialFailed), ( "unknown_resource", SocksEstablishmentReason::UnknownResource, ), ( "resource_shortage", SocksEstablishmentReason::ResourceShortage, ), ("handler_error", SocksEstablishmentReason::HandlerError), ("timeout", SocksEstablishmentReason::Timeout), ] { let err = open_failed_with(wire); assert_eq!(err.reason(), Some(typed)); assert_eq!(typed.as_str(), wire); } } #[test] fn open_error_non_establishment_shapes_are_none() { // The registry's own gate classes run before the establisher — // they are NOT establishment reasons (ADR-049 §3). for code in ["FORBIDDEN", "INVALID_INPUT", "channel:too_many_channels"] { let err = SocksOpenError::Open(ChannelOpenError::CallFailed { error: CallError::new(code, "gate", false), }); assert!( err.reason().is_none(), "{code} is not an establishment reason" ); } // Local-only failures (no wire error at all). assert!(SocksOpenError::Open(ChannelOpenError::MissingChannelId) .reason() .is_none()); } #[test] fn unknown_reason_code_does_not_alias_a_variant() { // A newer producer's additive reason code must not silently // alias an old variant (the vocabulary is one-way, ADR-049). let err = open_failed_with("some_future_reason"); assert!(err.reason().is_none()); // The raw string stays reachable for consumers that want it. assert_eq!( crate::params::establishment_reason(err.open_ref()), Some("some_future_reason") ); } #[test] fn session_error_surfaces_the_three_planes() { // Open plane: a refused session never yields a phantom channel. let open = SocksSessionError::from(open_failed_with("dial_failed")); assert!(matches!(open, SocksSessionError::Open(_))); // In-stream RFC refusal — the reply code rides the session type // (consumer.md §Error surface), never an open failure. let refused = SocksSessionError::from(SocksError::ReplyError(ReplyError::ConnectionNotAllowed)); assert!(matches!(refused, SocksSessionError::Rfc(_))); // Plain I/O on the data plane. let io = SocksSessionError::from(std::io::Error::new( std::io::ErrorKind::UnexpectedEof, "channel cut", )); assert!(matches!(io, SocksSessionError::Io(_))); } }