- 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.
231 lines
9.4 KiB
Rust
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(_)));
|
|
}
|
|
}
|