alksocks 0.3.0

SOCKS5 (RFC 1928) proxy protocol over alkcall channels: targeted-forked fast-socks5 typestate core plus producer/consumer sessions. Producer/consumer protocol crate.
Documentation
//! 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(&registry_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"
            );
        }
    }
}