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.
This commit is contained in:
1 parent
d530eae5c4
commit
1ccd5bbe0c
4 files changed
+522
-8
No files matched your search
+229
-2
@@ -1,3 +1,230 @@
|
||||
//! `SocksError` + the typed open-error surface (alkcall ADR-049 §4).
|
||||
//! 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`]).
|
||||
//!
|
||||
//! Stub: filled by the `implementation/core-params-errors` task.
|
||||
//! 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(_)));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user