- D-3: socks5_open_spec declares the channel:open_failed ErrorDefinition (alktty-ported schema, adapted) — reason enum is the full ADR-049 §3 five-code vocabulary matching SocksEstablishmentReason's parse table; description carries this crate's gate classes (FORBIDDEN / INVALID_INPUT / channel:too_many_channels) + the no-phantom-channel property. Pinned by open_spec_declares_the_open_failed_error_definition, which round-trips every enum value through the typed vocabulary — schema and typed view cannot drift. Module doc updated from claim to fact. - N-3: DatagramReader::feed's structurally-unreachable let-else restructured — the header read folds into the let-else's else arm (live-else shape); zero behavior change, wire suite green unchanged. - N-9: is_mid_frame kept; doc names the anticipated batch-2 consumer (the front-door bridges) so dead-surface sweeps don't flag it. Verification: - cargo test: 357 passed, 0 failed (all suites) - cargo clippy --all-targets -- -D warnings: clean - cargo fmt --check: clean - cargo doc --no-deps: clean - cargo check --target wasm32-unknown-unknown: clean - cargo clippy --target wasm32-unknown-unknown -- -D warnings: clean Task: tasks/implementation/fix-hygiene-residue.md (completed)
294 lines
12 KiB
Rust
294 lines
12 KiB
Rust
//! The trivial open-op params, the wire-stable constants, and the
|
|
//! `OperationSpec` builder — `wire.md` §The Open Op, ADR-001, ADR-006.
|
|
//!
|
|
//! 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, ErrorDefinition, 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` declare the `channel:open_failed` contract
|
|
/// (alkcall ADR-049 §3/§4 + ADR-016): the establisher's refusal
|
|
/// reasons (`dial_failed` / `unknown_resource` / `resource_shortage`
|
|
/// / `handler_error` / `timeout` — the ADR-049 §3 vocabulary, the
|
|
/// same five codes [`crate::error::SocksEstablishmentReason`] parses)
|
|
/// plus the registry's own `FORBIDDEN` class (the ACL gate runs
|
|
/// before the establisher; ADR-006). The wrapper's wire shape is
|
|
/// `details: { reason, message }` (ADR-049 §3).
|
|
///
|
|
/// 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 {
|
|
let open_failed_schema = json!({
|
|
"type": "object",
|
|
"properties": {
|
|
"reason": {
|
|
"type": "string",
|
|
"enum": [
|
|
"dial_failed",
|
|
"unknown_resource",
|
|
"resource_shortage",
|
|
"handler_error",
|
|
"timeout"
|
|
]
|
|
},
|
|
"message": { "type": "string" }
|
|
},
|
|
"required": ["reason", "message"]
|
|
});
|
|
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![ErrorDefinition {
|
|
code: "channel:open_failed".to_string(),
|
|
description: "The channel open failed: the registry's ACL gate refused \
|
|
(FORBIDDEN — no socks5:open scope), the params payload did \
|
|
not match the schema (INVALID_INPUT), the per-connection \
|
|
channel cap was hit (channel:too_many_channels), or the \
|
|
establishment phase failed (details.reason ∈ the ADR-049 \
|
|
§3 vocabulary). No channel_id is returned — no phantom \
|
|
channel (ADR-049)."
|
|
.to_string(),
|
|
schema: open_failed_schema,
|
|
http_status: None,
|
|
}],
|
|
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);
|
|
}
|
|
|
|
#[test]
|
|
fn open_spec_declares_the_open_failed_error_definition() {
|
|
let spec = socks5_open_spec();
|
|
let def = spec
|
|
.error_schemas
|
|
.iter()
|
|
.find(|e| e.code == "channel:open_failed")
|
|
.expect("channel:open_failed ErrorDefinition declared");
|
|
assert!(
|
|
!def.description.is_empty(),
|
|
"the failure contract is described"
|
|
);
|
|
let props = &def.schema["properties"];
|
|
assert!(props["message"]["type"] == "string");
|
|
assert_eq!(def.schema["required"], json!(["reason", "message"]));
|
|
let enum_values = props["reason"]["enum"].as_array().expect("reason enum");
|
|
let reasons: Vec<&str> = enum_values.iter().filter_map(|v| v.as_str()).collect();
|
|
// The declared vocabulary is exactly the typed parse table
|
|
// (crate::error::SocksEstablishmentReason) — the schema and
|
|
// the typed view cannot drift.
|
|
assert_eq!(
|
|
reasons,
|
|
vec![
|
|
"dial_failed",
|
|
"unknown_resource",
|
|
"resource_shortage",
|
|
"handler_error",
|
|
"timeout"
|
|
]
|
|
);
|
|
for reason in &reasons {
|
|
assert_eq!(
|
|
crate::error::SocksEstablishmentReason::parse(reason).map(|r| r.as_str()),
|
|
Some(*reason),
|
|
"declared reason {reason} is a typed ADR-049 §3 code"
|
|
);
|
|
}
|
|
}
|
|
}
|