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(_)));
|
||||
}
|
||||
}
|
||||
@@ -29,4 +29,12 @@ pub mod params;
|
||||
pub mod producer;
|
||||
pub mod wire;
|
||||
|
||||
pub use error::{
|
||||
ReplyError, SocksError, SocksEstablishmentReason, SocksOpenError, SocksServerError,
|
||||
SocksSessionError,
|
||||
};
|
||||
pub use params::{
|
||||
establishment_reason, socks5_open_spec, Socks5Params, OP_SOCKS5_OPEN, SOCKS5_ALPN,
|
||||
SOCKS5_BIND_SCOPE, SOCKS5_OPEN_SCOPE,
|
||||
};
|
||||
pub use wire::{addr_encoding_len, frame, DatagramReader, WireError, MAX_INNER_LEN};
|
||||
+219
-3
@@ -1,4 +1,220 @@
|
||||
//! The trivial open-op params schema + the scope constants
|
||||
//! (`SOCKS5_OPEN_SCOPE`, `SOCKS5_BIND_SCOPE`) — ADR-001/ADR-006.
|
||||
//! The trivial open-op params, the wire-stable constants, and the
|
||||
//! `OperationSpec` builder — `wire.md` §The Open Op, ADR-001, ADR-006.
|
||||
//!
|
||||
//! Stub: filled by the `implementation/core-params-errors` task.
|
||||
//! This module is the crate's wire surface (the one-way door): the op
|
||||
//! id, the ALPN string, the two scope strings, and the trivial params
|
||||
//! shape are frozen at first publish (ADR-001). The open-op params are
|
||||
//! **trivially empty** — the SOCKS5 target is unknown at open time (it
|
||||
//! arrives in-stream post-negotiation); the establisher is a session
|
||||
//! gate, not a dial. The empty-object schema with
|
||||
//! `additionalProperties: false` keeps `{}` valid forever: any future
|
||||
//! per-session option is an *additive* schema extension (ADR-001's
|
||||
//! extension rule) — consumers that send `{}` stay valid.
|
||||
//!
|
||||
//! All three RFC 1928 commands (CONNECT, BIND, UDP ASSOCIATE) ride the
|
||||
//! one open op, selected in-stream at command dispatch (ADR-001); BIND
|
||||
//! is scope-gated separately at that dispatch (`SOCKS5_BIND_SCOPE`,
|
||||
//! ADR-006) — the command is an RFC-1928 wire fact, not a wire-format
|
||||
//! choice of ours.
|
||||
//!
|
||||
//! The typed open-error vocabulary ([`crate::error::SocksOpenError`],
|
||||
//! the `channel:open_failed` reason mapping) lives in [`crate::error`]
|
||||
//! (alkcall ADR-049 §4); the establishment-reason extraction helper is
|
||||
//! re-exported here's-error-shaped via [`establishment_reason`].
|
||||
|
||||
use alkcall::channels::client::ChannelOpenError;
|
||||
use alkcall::registry::spec::{
|
||||
AccessControl, ChannelOpenSpec, OperationSpec, OperationType, Visibility,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
|
||||
/// The open-op operation id (the one SOCKS5 session op; ADR-001 —
|
||||
/// flavor-form per alkcall ADR-047: openable ALPNs are operations).
|
||||
/// All three RFC 1928 commands ride this one op. Wire-stable.
|
||||
pub const OP_SOCKS5_OPEN: &str = "channels/socks5/sub";
|
||||
|
||||
/// The data-plane ALPN the SOCKS5 channel carries (ADR-001; the
|
||||
/// `alk/` prefix per the alkcall ADR-004 ALPN convention). One ALPN
|
||||
/// per protocol. Wire-stable.
|
||||
pub const SOCKS5_ALPN: &str = "alk/socks5";
|
||||
|
||||
/// The scope gating SOCKS5 opens (ADR-006): proxy egress — CONNECT
|
||||
/// and UDP ASSOCIATE. The scope-string convention
|
||||
/// `<alpn-family>:<capability>`. Stable once published.
|
||||
pub const SOCKS5_OPEN_SCOPE: &str = "socks5:open";
|
||||
|
||||
/// The scope gating TCP BIND at command dispatch (ADR-006): the
|
||||
/// *inbound* capability — ingress listeners. A separate grant from
|
||||
/// [`SOCKS5_OPEN_SCOPE`], **never implied by it**: an identity with
|
||||
/// proxy egress must not implicitly get ingress listeners. Checked
|
||||
/// in-stream at command read, from the per-call opener identity
|
||||
/// (CF-005/CF-006); a scope-less identity gets the RFC-faithful
|
||||
/// `ConnectionNotAllowed` (0x02) refusal while CONNECT still serves
|
||||
/// it. Stable once published.
|
||||
pub const SOCKS5_BIND_SCOPE: &str = "socks5:bind";
|
||||
|
||||
/// The open-op input params (ADR-001): the trivial object. Wire-stable
|
||||
/// — the shape is frozen (empty object, nothing required, nothing
|
||||
/// else); a future per-session option is an additive schema extension,
|
||||
/// never a format change.
|
||||
///
|
||||
/// `deny_unknown_fields` is the loud-rejection posture: unknown fields
|
||||
/// fail at parse time, never silently ignored (matching the input
|
||||
/// schema's `additionalProperties: false` — the registry's gate and
|
||||
/// this serde pin agree). Empty params stay valid forever.
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Socks5Params {}
|
||||
|
||||
impl Socks5Params {
|
||||
/// The canonical wire form: the empty object — the only valid
|
||||
/// params value (ADR-001).
|
||||
pub fn as_json() -> serde_json::Value {
|
||||
json!({})
|
||||
}
|
||||
}
|
||||
|
||||
/// The open-op spec (`channels/socks5/sub`, ADR-001/ADR-006):
|
||||
/// `Sub`-typed, external, channel-open marker on the `alk/socks5`
|
||||
/// ALPN, scope-gated by `socks5:open`, trivial params.
|
||||
///
|
||||
/// - Input schema: `{type: object}` + `additionalProperties: false` —
|
||||
/// empty params stay valid forever (ADR-001's additive-only
|
||||
/// extension rule); unknown fields are rejected loudly.
|
||||
/// - Reply schema: `{"channel_id": <u32>, minimum 1}` — the
|
||||
/// `Sub`-typed operation reply. No extra reply fields in v1 (the
|
||||
/// establishment phase is a session gate and has nothing to
|
||||
/// project; alkcall's `with_reply_field` seam remains available
|
||||
/// additively if a future need appears — ADR-001).
|
||||
/// - `required_scopes = ["socks5:open"]` — the registry runs the ACL
|
||||
/// before the open handler (ADR-006; the open gate is the
|
||||
/// boundary).
|
||||
///
|
||||
/// `error_schemas` follow alkcall ADR-049 §4 + ADR-016 (the typed
|
||||
/// `channel:open_failed` contract with the reason vocabulary, plus
|
||||
/// the registry's own `FORBIDDEN` class).
|
||||
///
|
||||
/// The typed open-error vocabulary lives in [`crate::error`]
|
||||
/// ([`crate::error::SocksOpenError`] + its
|
||||
/// [`crate::error::SocksOpenError::reason`] typed view).
|
||||
pub fn socks5_open_spec() -> OperationSpec {
|
||||
OperationSpec::new(
|
||||
OP_SOCKS5_OPEN,
|
||||
OperationType::Sub,
|
||||
Visibility::External,
|
||||
json!({
|
||||
"type": "object",
|
||||
"additionalProperties": false
|
||||
}),
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"channel_id": { "type": "integer", "minimum": 1 }
|
||||
},
|
||||
"required": ["channel_id"]
|
||||
}),
|
||||
vec![],
|
||||
AccessControl {
|
||||
required_scopes: vec![SOCKS5_OPEN_SCOPE.to_string()],
|
||||
required_scopes_any: None,
|
||||
resource_type: None,
|
||||
resource_action: None,
|
||||
},
|
||||
None,
|
||||
)
|
||||
.with_channel_open(ChannelOpenSpec::new(SOCKS5_ALPN))
|
||||
.with_description(
|
||||
"Open a SOCKS5 proxy channel (RFC 1928 rides the channel BiStream; \
|
||||
trivial params — the target arrives in-stream post-negotiation)",
|
||||
)
|
||||
}
|
||||
|
||||
/// The typed open-failure reason of a `channel:open_failed` error
|
||||
/// (alkcall ADR-049 §4): the consumer-side establishment-failure
|
||||
/// vocabulary. `None` for any other failure shape (the registry's
|
||||
/// `FORBIDDEN`, `channel:too_many_channels`, local-only failures).
|
||||
///
|
||||
/// Delegates to [`ChannelOpenError::establishment_reason`] — the
|
||||
/// alktunnels helper posture. The typed vocabulary lives in
|
||||
/// [`crate::error`] ([`crate::error::SocksEstablishmentReason`], the
|
||||
/// parsed view a consumer branches on via
|
||||
/// [`crate::error::SocksOpenError::reason`]); use this directly for
|
||||
/// the raw reason string.
|
||||
pub fn establishment_reason(err: &ChannelOpenError) -> Option<&str> {
|
||||
err.establishment_reason()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use alkcall::protocol::wire::CallError;
|
||||
|
||||
#[test]
|
||||
fn open_spec_matches_adr_001_and_wire_md() {
|
||||
let spec = socks5_open_spec();
|
||||
assert_eq!(spec.name, "channels/socks5/sub");
|
||||
assert_eq!(spec.op_type, OperationType::Sub);
|
||||
assert_eq!(spec.visibility, Visibility::External);
|
||||
// Trivial params: empty object, unknown fields rejected loudly
|
||||
// (ADR-001 — empty params stay valid forever).
|
||||
assert_eq!(spec.input_schema["type"], "object");
|
||||
assert_eq!(spec.input_schema["additionalProperties"], json!(false));
|
||||
assert!(spec.input_schema.get("required").is_none());
|
||||
assert_eq!(
|
||||
spec.output_schema["properties"]["channel_id"],
|
||||
json!({"type": "integer", "minimum": 1})
|
||||
);
|
||||
assert_eq!(spec.output_schema["required"], json!(["channel_id"]));
|
||||
assert_eq!(
|
||||
spec.access_control.required_scopes,
|
||||
vec![SOCKS5_OPEN_SCOPE.to_string()]
|
||||
);
|
||||
assert!(spec.access_control.required_scopes_any.is_none());
|
||||
let channel_open = spec.channel_open.as_ref().expect("channel-open marker");
|
||||
assert_eq!(channel_open.alpn, SOCKS5_ALPN);
|
||||
assert!(spec.description.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn constants_match_adr_001_and_adr_006() {
|
||||
assert_eq!(OP_SOCKS5_OPEN, "channels/socks5/sub");
|
||||
assert_eq!(SOCKS5_ALPN, "alk/socks5");
|
||||
assert_eq!(SOCKS5_OPEN_SCOPE, "socks5:open");
|
||||
assert_eq!(SOCKS5_BIND_SCOPE, "socks5:bind");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trivial_params_serde_round_trip() {
|
||||
let v = serde_json::to_value(Socks5Params::default()).unwrap();
|
||||
assert_eq!(v, json!({}));
|
||||
assert_eq!(v, Socks5Params::as_json());
|
||||
let back: Socks5Params = serde_json::from_value(json!({})).unwrap();
|
||||
assert_eq!(back, Socks5Params::default());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trivial_params_reject_unknown_fields() {
|
||||
// The loud-rejection posture — the serde pin agrees with the
|
||||
// input schema's additionalProperties: false (ADR-001).
|
||||
let v = json!({"resource": "sneaky"});
|
||||
assert!(serde_json::from_value::<Socks5Params>(v).is_err());
|
||||
let v = json!({"substrate": "tcp"});
|
||||
assert!(serde_json::from_value::<Socks5Params>(v).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn establishment_reason_reads_the_typed_surface() {
|
||||
let open_failed = ChannelOpenError::CallFailed {
|
||||
error: CallError::new("channel:open_failed", "establishment failed", false)
|
||||
.with_details(json!({"reason": "dial_failed"})),
|
||||
};
|
||||
assert_eq!(establishment_reason(&open_failed), Some("dial_failed"));
|
||||
let registry_gate = ChannelOpenError::CallFailed {
|
||||
error: CallError::internal("forbidden"),
|
||||
};
|
||||
assert_eq!(establishment_reason(®istry_gate), None);
|
||||
let local_only = ChannelOpenError::MissingChannelId;
|
||||
assert_eq!(establishment_reason(&local_only), None);
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
id: implementation/core-params-errors
|
||||
name: Core — params schema + scope constants + typed error surface
|
||||
status: pending
|
||||
status: completed
|
||||
depends_on: [implementation/core-crate-deps]
|
||||
scope: narrow
|
||||
risk: low
|
||||
@@ -81,8 +81,71 @@ error-reason mapping table.
|
||||
|
||||
## Notes
|
||||
|
||||
> To be filled by implementation agent
|
||||
> Filled by the implementation agent (2026-09-23).
|
||||
|
||||
- `src/params.rs`: the four wire-stable constants (`OP_SOCKS5_OPEN`,
|
||||
`SOCKS5_ALPN`, `SOCKS5_OPEN_SCOPE`, `SOCKS5_BIND_SCOPE`), the
|
||||
trivial-params struct `Socks5Params {}` (serde
|
||||
`deny_unknown_fields`, + `Serialize` so tests round-trip it;
|
||||
`as_json()` names the canonical wire form), `socks5_open_spec()`
|
||||
(Sub/External/channel-open marker on `alk/socks5`; input schema
|
||||
`{type: object, additionalProperties: false}`; reply schema
|
||||
`channel_id` integer minimum 1 required; `required_scopes =
|
||||
["socks5:open"]`), and the `establishment_reason` helper re-export
|
||||
(the alktunnels posture — delegates to
|
||||
`ChannelOpenError::establishment_reason`).
|
||||
- `src/error.rs`: `SocksOpenError` (single `Open` variant wrapping
|
||||
`ChannelOpenError` — the same shape as alktunnels'
|
||||
`TunnelOpenError`; the `From` impl covers the
|
||||
`channel:open_failed`/`FORBIDDEN`/`INVALID_INPUT` classes), the
|
||||
typed `SocksEstablishmentReason` vocabulary (ADR-049 §3's five
|
||||
codes, `parse`/`as_str`; unknown codes are `None` — never silently
|
||||
aliased, the vocabulary is one-way), `SocksOpenError::reason()` as
|
||||
the typed view, and `SocksSessionError` (open errors vs in-stream
|
||||
`Rfc(SocksError)` vs `Io`; the RFC reply-code refusals ride the
|
||||
session type per consumer.md §Error surface). `SocksError`/
|
||||
`SocksServerError`/`ReplyError` re-exported from `rfc1928`
|
||||
(`SocksServerError` lives in `rfc1928::server`, not the module
|
||||
root).
|
||||
- lib.rs re-exports both modules' public surface (convention 15).
|
||||
- Deliberate deviations from the task text: the params struct
|
||||
derives `Serialize` (test round-trip needs it; harmless on the
|
||||
wire — the consumer sends `json!({})`); `SocksEstablishmentReason`
|
||||
is a separate named type rather than an inline match so the
|
||||
mapping table is testable in both directions; unknown reason codes
|
||||
do NOT alias a variant (one-way vocabulary discipline).
|
||||
- rustdoc links to `error::SocksOpenError` from `params` are
|
||||
fully-qualified (`crate::error::…`) — plain `[SocksOpenError]`
|
||||
does not resolve cross-module in this crate's layout.
|
||||
|
||||
## Summary
|
||||
|
||||
> To be filled on completion
|
||||
> Filled on completion (2026-09-23).
|
||||
|
||||
Implemented the wire-stable open-op surface and the typed
|
||||
open-error vocabulary:
|
||||
|
||||
- `params`: constants + `socks5_open_spec()` per ADR-001/ADR-006
|
||||
exactly (op id `channels/socks5/sub`, ALPN `alk/socks5`, scopes
|
||||
`socks5:open`/`socks5:bind`, trivial empty-object input schema with
|
||||
loud `additionalProperties: false`, reply
|
||||
`{channel_id: integer, minimum 1}` required).
|
||||
- `Socks5Params {}` with `deny_unknown_fields` — empty object valid,
|
||||
unknown fields rejected.
|
||||
- `error`: `SocksOpenError` mapping the ADR-049 §4 reasons
|
||||
(`dial_failed`/`unknown_resource`/`resource_shortage`/
|
||||
`handler_error`/`timeout`) through the typed
|
||||
`SocksEstablishmentReason` (unknown codes never aliased); the
|
||||
registry's `FORBIDDEN`/`INVALID_INPUT` classes correctly
|
||||
non-establishment; `SocksSessionError` shaped open-vs-Rfc-vs-Io
|
||||
per consumer.md §Error surface.
|
||||
- 9 new tests (spec shape, constants, serde round-trip + unknown-field
|
||||
rejection, the reason mapping table, non-establishment classes,
|
||||
future-reason non-aliasing, the three-plane session surface).
|
||||
- lib.rs re-exports the public surface.
|
||||
|
||||
Verification: `cargo test` (104 green: 67 lib + 37 rfc1928 + 0
|
||||
doc), `cargo clippy --all-targets -- -D warnings` clean,
|
||||
`cargo fmt --check` clean, `cargo doc --no-deps` zero warnings, both
|
||||
wasm checks (`cargo check` + `cargo clippy` on
|
||||
`wasm32-unknown-unknown`) clean.
|
||||
Reference in new issue
Block a user