Files
alksocks/src/error.rs
T
glm-5.3-flash 1ccd5bbe0c params/error: the wire-stable open-op surface + the typed open-error vocabulary
- params: the ADR-001/ADR-006 constants (`channels/socks5/sub`,
  `alk/socks5`, `socks5:open`, `socks5:bind`), `socks5_open_spec()`
  (Sub/External/channel-open marker; trivial empty-object input schema
  with loud `additionalProperties: false`; reply
  `{channel_id: integer, minimum 1}` required; `socks5:open` scope),
  `Socks5Params {}` (deny_unknown_fields — empty stays valid forever),
  the `establishment_reason` helper re-export.
- error: `SocksOpenError` (the ADR-049 §4 typed surface, shaped on
  alktunnels' `TunnelOpenError`), the typed
  `SocksEstablishmentReason` vocabulary (the five reason codes,
  parse/as_str; unknown codes never aliased), `SocksOpenError::reason()`
  as the typed view, and `SocksSessionError` (open vs in-stream `Rfc`
  vs `Io` — a refused target is an RFC reply code through the session
  type, consumer.md §Error surface). `SocksError`/`SocksServerError`/
  `ReplyError` re-exported from the forked core.
- lib.rs re-exports the new public surface (convention 15).
- 9 new tests: spec shape, constants, serde round-trip +
  unknown-field rejection, the reason mapping table, the
  non-establishment classes (`FORBIDDEN`/`INVALID_INPUT`/
  `too_many_channels`), future-reason non-aliasing, the three-plane
  session surface.
- task `implementation/core-params-errors`: status → completed;
  Notes + Summary filled.

Verification: cargo test (104 green), clippy --all-targets -D
warnings clean, fmt --check clean, doc --no-deps zero warnings,
both wasm checks (check + clippy on wasm32-unknown-unknown) clean.
2026-09-23 15:17:20 +00:00

231 lines
9.4 KiB
Rust

//! 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 (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(_)));
}
}