alksocks 0.3.2

SOCKS5 (RFC 1928) proxy protocol over alkcall channels: targeted-forked fast-socks5 typestate core plus producer/consumer sessions. Producer/consumer protocol crate.
Documentation
//! 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> {
        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<Self> {
        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 (the refused-target
/// 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(_)));
    }

    #[test]
    fn establishment_reason_display_renders_the_wire_code() {
        // The Display impl body (review 002 N-8): the typed reason
        // renders as its canonical wire code (the `as_str` inverse).
        for (typed, wire) in [
            (SocksEstablishmentReason::DialFailed, "dial_failed"),
            (
                SocksEstablishmentReason::UnknownResource,
                "unknown_resource",
            ),
            (
                SocksEstablishmentReason::ResourceShortage,
                "resource_shortage",
            ),
            (SocksEstablishmentReason::HandlerError, "handler_error"),
            (SocksEstablishmentReason::Timeout, "timeout"),
        ] {
            assert_eq!(typed.to_string(), wire);
        }
    }
}