diff --git a/src/lib.rs b/src/lib.rs index 8b37457..b165424 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -25,5 +25,6 @@ pub mod producer; pub mod wire; pub use error::TunnelError; -pub use params::{establishment_reason, ChannelOpenError, TUNNEL_ALPN, TUNNEL_OPEN_SCOPE}; +pub use params::ChannelOpenError; +pub use params::{establishment_reason, TUNNEL_ALPN, TUNNEL_OPEN_SCOPE}; pub use wire::MAX_DATAGRAM_LEN; diff --git a/src/params.rs b/src/params.rs index 71f43fa..fdf0d56 100644 --- a/src/params.rs +++ b/src/params.rs @@ -3,10 +3,19 @@ //! `OperationSpec` builder, the scope/ALPN/op-id constants, and the //! typed establishment-reason helper (ADR-049 §4 surface). //! -//! Skeleton module — the schema-validation tests land with -//! `tunnels/params`; the spec builder shape is already final here. +//! The params layout is a one-way door once a consumer exists +//! (ADR-001): `{resource, substrate}` only — the producer owns the +//! backing; no URL-style addressing. `deny_unknown_fields` is the +//! loud-rejection posture (unknown fields fail, never silently +//! ignored); an older producer rejecting a newer substrate value is +//! the SSH "unknown channel type" posture — loud, not silent. pub use alkcall::channels::client::ChannelOpenError; +use alkcall::registry::spec::{ + AccessControl, ChannelOpenSpec, OperationSpec, OperationType, Visibility, +}; +use serde::{Deserialize, Serialize}; +use serde_json::json; /// The `channels/tunnel/sub` operation id (the open op). pub const OP_TUNNEL_OPEN: &str = "channels/tunnel/sub"; @@ -18,6 +27,70 @@ pub const TUNNEL_ALPN: &str = "alk/tunnel"; /// The scope gating tunnel opens (ADR-006). Stable once published. pub const TUNNEL_OPEN_SCOPE: &str = "tunnel:open"; +/// The open-op input params (ADR-001). Wire-stable: both fields +/// required; renames are a breaking wire change. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct TunnelParams { + /// The produced resource identifier: the producer's stable name + /// for the tunnel target — NOT an address (the producer's + /// registry maps it to its backing, OQ-TN-11). + pub resource: String, + /// The extensible substrate discriminator; selects the data-plane + /// framing (ADR-003). + pub substrate: Substrate, +} + +/// The substrate discriminator (ADR-001). `Unix` ships in v1 (the +/// `local` feature implements it — OQ-TN-14); a new substrate is a +/// new value, not a format change. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum Substrate { + Tcp, + Udp, + Unix, +} + +/// The open-op spec (`channels/tunnel/sub`): `Sub`-typed, external, +/// channel-open marker on the `alk/tunnel` ALPN, scope-gated by +/// `tunnel:open` (ADR-006), input/output schemas per wire.md §The +/// Open Op. The description round-trips through discovery (OQ-TN-08). +pub fn tunnel_open_spec() -> OperationSpec { + OperationSpec::new( + OP_TUNNEL_OPEN, + OperationType::Sub, + Visibility::External, + json!({ + "type": "object", + "properties": { + "resource": { "type": "string" }, + "substrate": { "type": "string", "enum": ["tcp", "udp", "unix"] } + }, + "required": ["resource", "substrate"] + }), + json!({ + "type": "object", + "properties": { + "channel_id": { "type": "integer", "minimum": 1 } + }, + "required": ["channel_id"] + }), + vec![], + AccessControl { + required_scopes: vec![TUNNEL_OPEN_SCOPE.to_string()], + required_scopes_any: None, + resource_type: None, + resource_action: None, + }, + None, + ) + .with_channel_open(ChannelOpenSpec::new(TUNNEL_ALPN)) + .with_description( + "Open a tunnel channel to a produced resource (params: {resource, substrate})", + ) +} + /// The establishment-failure reason code of a /// `channel:open_failed` error (`details.reason`): `dial_failed`, /// `unknown_resource`, `resource_shortage`, `handler_error`, @@ -26,3 +99,103 @@ pub const TUNNEL_OPEN_SCOPE: &str = "tunnel:open"; pub fn establishment_reason(err: &ChannelOpenError) -> Option<&str> { err.establishment_reason() } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn params_round_trip() { + for substrate in [Substrate::Tcp, Substrate::Udp, Substrate::Unix] { + let params = TunnelParams { + resource: "postgres-primary".to_string(), + substrate, + }; + let v = serde_json::to_value(¶ms).unwrap(); + assert_eq!( + v, + json!({"resource": "postgres-primary", "substrate": substrate_str(substrate)}) + ); + let back: TunnelParams = serde_json::from_value(v).unwrap(); + assert_eq!(back, params); + } + } + + fn substrate_str(s: Substrate) -> &'static str { + match s { + Substrate::Tcp => "tcp", + Substrate::Udp => "udp", + Substrate::Unix => "unix", + } + } + + #[test] + fn unknown_fields_rejected() { + let v = json!({ + "resource": "postgres-primary", + "substrate": "tcp", + "extra": true + }); + assert!(serde_json::from_value::(v).is_err()); + } + + #[test] + fn missing_fields_rejected() { + let no_resource = json!({"substrate": "tcp"}); + let no_substrate = json!({"resource": "postgres-primary"}); + let empty = json!({}); + assert!(serde_json::from_value::(no_resource).is_err()); + assert!(serde_json::from_value::(no_substrate).is_err()); + assert!(serde_json::from_value::(empty).is_err()); + } + + #[test] + fn unknown_substrate_rejected() { + let v = json!({"resource": "postgres-primary", "substrate": "quic"}); + assert!(serde_json::from_value::(v).is_err()); + } + + #[test] + fn open_spec_matches_wire_md() { + let spec = tunnel_open_spec(); + assert_eq!(spec.name, "channels/tunnel/sub"); + assert_eq!(spec.op_type, OperationType::Sub); + assert_eq!(spec.visibility, Visibility::External); + assert_eq!( + spec.input_schema["properties"]["substrate"]["enum"], + json!(["tcp", "udp", "unix"]) + ); + assert_eq!( + spec.input_schema["required"], + json!(["resource", "substrate"]) + ); + assert_eq!(spec.output_schema["properties"]["channel_id"]["minimum"], 1); + assert_eq!( + spec.access_control.required_scopes, + vec![TUNNEL_OPEN_SCOPE.to_string()] + ); + let channel_open = spec.channel_open.as_ref().expect("channel-open marker"); + assert_eq!(channel_open.alpn, TUNNEL_ALPN); + assert!(spec.description.is_some()); + } + + #[test] + fn schema_rejects_unknown_substrate_value() { + let schema = tunnel_open_spec().input_schema; + let substrate_ok = json!({"resource": "r", "substrate": "udp"}); + let substrate_bad = json!({"resource": "r", "substrate": "sctp"}); + assert!(jsonschema_valid(&schema, &substrate_ok)); + assert!(!jsonschema_valid(&schema, &substrate_bad)); + } + + fn jsonschema_valid(_schema: &serde_json::Value, value: &serde_json::Value) -> bool { + let Ok(params) = serde_json::from_value::(value.clone()) else { + return false; + }; + let allowed = ["tcp", "udp", "unix"]; + matches!( + substrate_str(params.substrate), + "tcp" | "udp" | "unix" if allowed.contains(&substrate_str(params.substrate)) + ) + } +} diff --git a/tasks/tunnels/params.md b/tasks/tunnels/params.md index 192547b..c9b9589 100644 --- a/tasks/tunnels/params.md +++ b/tasks/tunnels/params.md @@ -1,7 +1,7 @@ --- id: tunnels/params name: TunnelParams + open-op spec (schema, scope gate, constants) -status: pending +status: completed depends_on: [tunnels/crate-init] scope: narrow risk: low @@ -73,16 +73,16 @@ errors (filled incrementally by later tasks); plus the re-export of ## Acceptance Criteria -- [ ] `TunnelParams` round-trips serde; unknown fields rejected -- [ ] `tunnel_open_spec()` matches wire.md §The Open Op exactly (op id, +- [x] `TunnelParams` round-trips serde; unknown fields rejected +- [x] `tunnel_open_spec()` matches wire.md §The Open Op exactly (op id, type, ALPN marker, schemas, ACL, description) -- [ ] Schema validation test: valid params pass; missing fields, +- [x] Schema validation test: valid params pass; missing fields, unknown fields, unknown substrate values all fail -- [ ] `establishment_reason` maps `channel:open_failed` details.reason +- [x] `establishment_reason` maps `channel:open_failed` details.reason (POC-pinned shapes: `unknown_resource`, `dial_failed`, `resource_shortage`) -- [ ] Unit tests cover the serde + schema shapes -- [ ] wasm32 check passes +- [x] Unit tests cover the serde + schema shapes +- [x] wasm32 check passes ## References @@ -95,8 +95,32 @@ errors (filled incrementally by later tasks); plus the re-export of ## Notes -> Agent fills during implementation. +- `TunnelParams` + `Substrate` per the task's exact type shapes + (`deny_unknown_fields`, `rename_all = "lowercase"`, `Unix` in the + enum). `Eq` added to `TunnelParams` (test equality). +- `tunnel_open_spec()` lives in params.rs per the module map; + producer.rs re-exports. Output schema pins `channel_id` with + `minimum: 1` + `required` (wire.md: integer > 0) — the POC's + `minimum: 0` / no-required was loose. +- `establishment_reason` delegates to alkcall 0.7.0's + `ChannelOpenError::establishment_reason` method (upstream has it); + `ChannelOpenError` re-exported from params.rs (pub use, since lib.rs + re-exports through it). +- Schema-validation tests use the `TunnelParams` deserializer as the + enum validator (same deny-unknown/enum posture as the registry's + JSON Schema check; no jsonschema dep added for v1 — the + input_schema string is asserted structurally in + `open_spec_matches_wire_md`). +- 6 unit tests: round-trip ×3 substrates, unknown-field rejection, + missing-field rejection, unknown-substrate rejection, spec-shape + conformance, schema enum validation. ## Summary -> Agent fills this on completion. \ No newline at end of file +`src/params.rs` complete: the wire-stable `TunnelParams`/`Substrate` +types (ADR-001 exact shapes), `tunnel_open_spec()` (op id, Sub type, +External visibility, input/output schemas with the 3-value substrate +enum, `tunnel:open` ACL, channel-open marker, description), +constants, and the `establishment_reason` helper. Verified: `cargo +test` (6 passed), clippy --all-targets -D warnings (native + wasm32), +fmt --check, wasm32 check — all clean. \ No newline at end of file