From 68d3723f6a61318e9f0ead34120bd3eb1da8a926 Mon Sep 17 00:00:00 2001 From: "glm-5.3-flash" Date: Wed, 28 Oct 2026 09:04:42 +0000 Subject: [PATCH] feat(dispatch): land the parse-only dispatch + the fail-closed decline core (w2-dispatch-decline-core) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - src/dispatch.rs: the ExecRoute decision, the skeleton exec-parse probe (service prefix + path shape; wave 3 replaces with the full grammar), the wire-reason shapers (AdministrativelyProhibited for unfeatured/never-feature opens, ConnectFailed for the tunnel seam stub), 7 probe unit rows - src/handler.rs: channel_open_* gates per the feature graph (session ∨ git; tunnel; x11/auth-agent never), parse-only exec_request dispatch before any allocation, session-row routes, request-level declines (subsystem all-names with the sftp OQ-13 and git named classes, env per OQ-09, x11/agent, the four listen requests), the named wave-3 seam stubs; decline-first-alloc-never structural; module docs carry the decline-core contract incl. the inherited auth-agent-open upstream note - tests/dispatch_core.rs: 26 Contract-stamped decline-matrix rows per feature build driven through the loopback harness (none / git-only / session-only / tunnel-only / all-features + shared never-feature rows) - tests/common/harness.rs: extended helper vocabulary (tunnel/x11 opens, pty/env/x11/agent requests, listen requests) — discharges the w2-loopback-harness live-rows note - tests/loopback_harness.rs: the skeleton decline-only session-open row cfg-pinned to the featureless builds it describes Verification: cargo test (91 rows, default build); per-feature dispatch_core rows (git 11 / session 10 / tunnel 4 / all 8 / sftp 3); cargo test --all-features; cargo clippy --all-targets -- -D warnings (and --all-features); cargo fmt --check; cargo doc --no-deps --- src/dispatch.rs | 169 ++++ src/handler.rs | 440 ++++++++++- src/lib.rs | 1 + .../w2-dispatch-decline-core.md | 120 ++- tests/common/harness.rs | 81 ++ tests/dispatch_core.rs | 730 ++++++++++++++++++ tests/loopback_harness.rs | 14 +- 7 files changed, 1543 insertions(+), 12 deletions(-) create mode 100644 src/dispatch.rs create mode 100644 tests/dispatch_core.rs diff --git a/src/dispatch.rs b/src/dispatch.rs new file mode 100644 index 0000000..b615c4b --- /dev/null +++ b/src/dispatch.rs @@ -0,0 +1,169 @@ +//! The parse-only dispatch core (`w2-dispatch-decline-core`) — the +//! routing decision and the wire-decline reason shaping the +//! per-connection handler hooks speak ([`crate::handler`]). +//! +//! **Parse-only** (ADR-006 point 3 as amended): an `exec_request` +//! parses *first* — a git-parseable command routes to the git row, +//! anything else routes to the session pipe row; `pty_request` / +//! `shell_request` route to the session rows. Parsing happens before +//! dispatch and before any resource allocation — **decline first, +//! alloc never** (a leaked channel alloc is a capability leak, the +//! POC-3 stage-A rule). There is no request-less serving mode: a +//! session channel that never receives a request is never served (the +//! SOCKS-detect retired with `dynamic-forward`, ADR-003 §1a), so a +//! request-less session channel declines as unfeatured by +//! construction. +//! +//! ## The skeleton exec parse probe +//! +//! [`classify_exec`] is deliberately **skeleton-level** (service +//! prefix + path shape): it recognizes the two serving git services +//! (`git-upload-pack`, `git-receive-pack`) followed by a space and a +//! path-shaped argument (leading `'` — the vanilla-git quoted shape — +//! or `/` — unquoted absolute). Everything else routes to the session +//! pipe row, which in every build that lacks the `session` feature +//! declines: the POC-3 stage-A contract (unknown repo → collapsed +//! generic denial before any protocol byte; non-git command → +//! parse-then-fail). The full grammar (quoted/unquoted paths, +//! per-length limits from [`crate::Limits::max_exec_command_len`], +//! service-only → decline-with-reason, `git-send-pack` / +//! `git-upload-archive` → unknown-service decline) is wave-3's git +//! task, which replaces this probe wholesale — the [`ExecRoute`] rows +//! and the routing decisions around them are what it must keep. + +use russh::ChannelOpenFailure; + +/// Which dispatch row an `exec_request` command routes to (the +/// parse-only decision, ADR-006 point 3 as amended). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum ExecRoute { + /// A git-parseable command — routes to the git row (the `git` + /// feature gate is the row's other half; the parse is the session + /// build's gate — a session-only build declines here). + Git, + /// Everything else — routes to the session pipe row (the + /// `session` feature gate is the row's other half; unfeatured + /// builds decline here). + Pipe, +} + +/// The skeleton exec parse probe — see the module docs for the exact +/// shape it recognizes and what wave 3 replaces it with. +pub(crate) fn classify_exec(command: &str) -> ExecRoute { + if is_serving_git_command(command) { + ExecRoute::Git + } else { + ExecRoute::Pipe + } +} + +/// The skeleton probe's git recognition: exact serving-service prefix, +/// single separating space, path-shaped argument. +fn is_serving_git_command(command: &str) -> bool { + for service in ["git-upload-pack ", "git-receive-pack "] { + if let Some(argument) = command.strip_prefix(service) + && is_path_shaped(argument) + { + return true; + } + } + false +} + +/// The path shape the skeleton probe accepts: a path-shaped argument +/// starts with `'` (quoted — the vanilla-git wire shape) or `/` +/// (unquoted absolute). +fn is_path_shaped(argument: &str) -> bool { + argument.starts_with('\'') || argument.starts_with('/') +} + +/// The wire decline reason for a **featureless** row — a known channel +/// type this build serves nothing of (unfeatured or never-feature): +/// `administratively prohibited`, the vanilla-observable policy +/// refusal (POC-1 §5 — channel-scoped, auth intact, no hangs). A +/// never-feature (`x11`, auth-agent) and an unfeatured feature row +/// share this class honestly: without the feature there is no +/// capability to configure, so nothing distinguishable exists. +pub(crate) fn unfeatured_open_decline() -> ChannelOpenFailure { + ChannelOpenFailure::AdministrativelyProhibited +} + +/// The wire decline reason for the **tunnel seam stub** — the row is +/// feature-gated `tunnel` but the route-1 bridge (wave 3) does not +/// exist yet, so the fabric open never happened: `connect failed`, the +/// explicit reject shape ADR-006 decision 1 pins (accept only after +/// the fabric open succeeds; failure maps to an explicit SSH +/// `ConnectFailed`). Wave 3 replaces the stub with the real +/// accept-after-open flow. +pub(crate) fn tunnel_seam_stub_decline() -> ChannelOpenFailure { + ChannelOpenFailure::ConnectFailed +} + +#[cfg(test)] +mod tests { + use super::{ExecRoute, classify_exec}; + + fn route(command: &str) -> bool { + classify_exec(command) == ExecRoute::Git + } + + /// - *Contract stamp:* ADR-006 decision 2 (the exec-only git parse + /// — the vanilla wire shape `git-upload-pack '/owner/demo'`). + #[test] + fn vanilla_quoted_upload_pack_parse_is_git() { + assert!(route("git-upload-pack '/owner/demo'")); + } + + /// - *Contract stamp:* ADR-006 decision 2 (the receive-pack half + /// of the exec-only git parse). + #[test] + fn receive_pack_parse_is_git() { + assert!(route("git-receive-pack '/owner/demo'")); + } + + /// - *Contract stamp:* POC-3 §2 (the unquoted-absolute row of the + /// grammar table: `git-upload-pack /r`). + #[test] + fn unquoted_absolute_pack_parse_is_git() { + assert!(route("git-upload-pack /r")); + } + + /// - *Contract stamp:* POC-3 §2 (service-only → declined — SSH + /// exec must carry the repo; the skeleton routes it to the pipe + /// row, which declines in every git-less build; wave 3 gives the + /// decline its own reason). + #[test] + fn service_only_parse_is_not_git() { + assert!(!route("git-upload-pack")); + assert!(!route("git-receive-pack ")); + } + + /// - *Contract stamp:* POC-3 §2 (unknown service → declined + /// honestly; the skeleton routes it off the git row). + #[test] + fn unknown_services_parse_is_not_git() { + assert!(!route("git-send-pack '/r'")); + assert!(!route("git-upload-archive '/r'")); + } + + /// - *Contract stamp:* mapping.md §Session-channel dispatch + /// (non-git → parse-then-fail, the pipe row). + #[test] + fn non_git_commands_parse_to_the_pipe_row() { + assert!(!route("uptime")); + assert!(!route("")); + assert!(!route("git-upload-packrel")); + assert!(!route("git-upload-pack rel")); + assert!(!route("GIT-UPLOAD-PACK '/r'")); + assert!(!route("git-upload-pack$ '/r'")); + assert!(!route("git-upload-pack '/r'")); + } + + /// The probe is total: it returns a route for any input, so the + /// dispatch never allocates on a parse failure. + #[test] + fn probe_is_total_over_arbitrary_input() { + assert!(!route("\u{0}\u{1}")); + assert!(route("git-upload-pack '/\u{e9}'")); + } +} diff --git a/src/handler.rs b/src/handler.rs index d614d87..3f3e595 100644 --- a/src/handler.rs +++ b/src/handler.rs @@ -1,7 +1,7 @@ //! The per-connection server handler — the ADR-004 dispositional auth -//! bridge, with the channel/request dispatch surface still standing at -//! its inherited fail-closed russh defaults (`w2-dispatch-decline-core` -//! fills those; `w2-auth-bridge` filled the auth hooks). +//! bridge, plus the parse-only dispatch and the fail-closed decline +//! core (`w2-dispatch-decline-core`; `w2-auth-bridge` filled the auth +//! hooks). //! //! ## The auth bridge (ADR-004, decisions 1–5) //! @@ -33,6 +33,76 @@ //! Auth declines are *dispositions* (`Auth::Reject`), not errors — the //! error model stays key-source-only. //! +//! ## The parse-only dispatch + the fail-closed decline core +//! +//! (ADR-006 point 3 as amended, ADR-003; the routing decision and the +//! exec-parse skeleton probe live in [`crate::dispatch`].) Every +//! `channel_open_*` hook gates per the feature graph: `session` +//! channels open only where a session-serving feature exists (`session` +//! ∨ `git` — the parse path lives inside session channels); +//! `direct-tcpip` / `forwarded-tcpip` / `direct-streamlocal` gate on +//! the `tunnel` feature; **`x11` always declines** — never +//! feature-gated (ADR-003 §5). Unfeatured and never-feature rows +//! decline **at the open** with a clean wire reason +//! ([`crate::dispatch::unfeatured_open_decline`]) — never a +//! half-open channel; the `tunnel`-featured rows decline at the +//! family-seam stub ([`crate::dispatch::tunnel_seam_stub_decline`]) +//! until wave 3's route-1 bridge replaces it. The bare +//! `auth-agent@openssh.com` channel *type* +//! (`forwarded-streamlocal@openssh.com` likewise) has no russh handler +//! hook at this pin — russh rejects those opens inline +//! (administratively prohibited), which is the decline the never- +//! feature contract needs, but it is an inherited posture: a pin bump +//! that changed it would surface here as an accepted-then-dropped +//! channel (third-party-upstream note; file upstream first). +//! +//! The session rows are **parse-only**: `exec_request` parses first +//! (a git-parseable command routes to the git row, anything else to +//! the session pipe row — [`crate::dispatch::classify_exec`]); +//! `pty_request` / `shell_request` route to the session rows. Nothing +//! is served before a request exists — no request, no serving, so a +//! request-less session channel declines as unfeatured by +//! construction. **Decline first, alloc never**: every decline here +//! precedes any channel/resource allocation (a leaked channel alloc +//! is a capability leak — the POC-3 stage-A rule). The channel handle +//! a confirmed `session` open produces is *not* consumed this wave — +//! the store-and-take handoff (`channels.remove(&id)` → `into_stream` +//! in the spawned serving task) is wave-3 mapping material; the +//! routing decisions around it are already this wave's, because the +//! routing stubs decline at the seam. +//! +//! Request-level declines: `subsystem_request` declines every name — +//! `sftp` is the OQ-13 placeholder (the feature exists, the route +//! does not until alkfs lands); git service names carry only a name, +//! never a repository id, so git can never dispatch as a subsystem +//! (ADR-006 decision 2); everything else is unimplemented. `env_request` +//! declines unconditionally (OQ-09 — no allowlist ships: wire env is +//! not family config). `x11_request` and `agent_request` decline +//! (ADR-003 §5). The global listen requests (`tcpip_forward` +//! / `streamlocal_forward` / the cancel forms) decline — they are +//! `tunnel` rows whose serving flow is wave-3 material, so featured +//! builds decline them too this wave. +//! +//! ## Wire decline reasons (the wave-1 error-model reservation) +//! +//! Two decline classes, chosen for vanilla-client observability (POC-1 +//! §5: channel-scoped decline, auth intact, no hangs): +//! +//! - **Open-level declines** — channel-open failure codes (`x11` +//! never-feature, unfeatured feature rows → administratively +//! prohibited; the tunnel seam stub → connect failed). These happen +//! before the channel exists, so the SSH open-failure code is the +//! honest, richest reason surface. +//! - **Request-level declines** — [`Session::channel_failure`]: the +//! SSH request-reply vocabulary has no failure text, so a request +//! decline's "reason" is the routing decision itself (documented +//! above and per feature build in `mapping.md`'s decline matrix). +//! A *capability* decline answers failure and leaves the channel +//! usable (the POC-1 §5 session-survives-declines posture); a +//! *serving-path* decline (the wave-3 seam stubs) answers failure +//! and closes the channel — the vanilla-clean shape for a row that +//! will spawn its serving path next wave. +//! //! ## Test surface //! //! The handler type is deliberately constructible outside this module @@ -48,9 +118,11 @@ use std::sync::Arc; use alkcall::core::auth::Identity; use russh::keys::Algorithm; -use russh::server::{Auth, PublicKeyOffer, VerifiedPublicKey}; +use russh::server::{Auth, ChannelOpenHandle, Msg, PublicKeyOffer, Session, VerifiedPublicKey}; +use russh::{Channel, ChannelId, Pty}; use crate::config::ServeConfig; +use crate::dispatch; use crate::error::AdapterError; use crate::fingerprint::fingerprint; @@ -116,6 +188,33 @@ impl SessionHandler { .identity_provider .resolve_from_fingerprint(&fingerprint(public_key)) } + + /// The `session` channel-open gate (ADR-003 feature graph): + /// session channels open only where a session-serving feature + /// exists — `session` ∨ `git`, because git's parse path lives + /// inside session channels. + fn serves_session_channels(&self) -> bool { + cfg!(feature = "session") || cfg!(feature = "git") + } + + /// The session-row gate (`pty`/`shell`/pipe-exec): only the + /// `session` feature serves these rows. + fn serves_session_rows(&self) -> bool { + cfg!(feature = "session") + } + + /// The git-row gate (git-parseable exec): only the `git` feature + /// serves this row. + fn serves_git_row(&self) -> bool { + cfg!(feature = "git") + } + + /// The tunnel-row gate (`direct-tcpip` / `forwarded-tcpip` / + /// `direct-streamlocal` opens and the listen flow): only the + /// `tunnel` feature serves these rows. + fn serves_tunnels(&self) -> bool { + cfg!(feature = "tunnel") + } } impl russh::server::Handler for SessionHandler { @@ -186,4 +285,337 @@ impl russh::server::Handler for SessionHandler { ) -> Result { Ok(Auth::reject()) } + + /// The session-channel open gate (ADR-003 feature graph, + /// `session` ∨ `git`): unfeatured builds decline at the open — + /// never a half-open channel. A confirmed open accepts the + /// channel; the parse-only dispatch serves it through its + /// requests (nothing serves request-less — ADR-006 as amended). + async fn channel_open_session( + &mut self, + _channel: Channel, + reply: ChannelOpenHandle, + _session: &mut Session, + ) -> Result<(), Self::Error> { + if self.serves_session_channels() { + reply.accept().await; + } else { + reply.reject(dispatch::unfeatured_open_decline()).await; + } + Ok(()) + } + + /// The x11 channel open: **always declines** — a never-feature + /// (ADR-003 §5), declined in every build, never feature-gated. + async fn channel_open_x11( + &mut self, + _channel: Channel, + _originator_address: &str, + _originator_port: u32, + reply: ChannelOpenHandle, + _session: &mut Session, + ) -> Result<(), Self::Error> { + reply.reject(dispatch::unfeatured_open_decline()).await; + Ok(()) + } + + /// The `direct-tcpip` open gate (`tunnel`): unfeatured declines + /// at the open; featured routes to the tunnel seam stub — the + /// route-1 bridge is wave-3 material, and the stub's decline is + /// the explicit `ConnectFailed` reject shape ADR-006 decision 1 + /// pins (accept only after the fabric open succeeds; the skeleton + /// never opens the fabric). + async fn channel_open_direct_tcpip( + &mut self, + _channel: Channel, + _host_to_connect: &str, + _port_to_connect: u32, + _originator_address: &str, + _originator_port: u32, + reply: ChannelOpenHandle, + _session: &mut Session, + ) -> Result<(), Self::Error> { + if self.serves_tunnels() { + tunnel_row_stub(reply).await; + } else { + reply.reject(dispatch::unfeatured_open_decline()).await; + } + Ok(()) + } + + /// The `forwarded-tcpip` open gate (`tunnel` — the `-R` data + /// leg): the same gate/stub split as `direct-tcpip`. The + /// harness's russh client cannot initiate this channel type (the + /// server opens it toward the client), so its wire rows are + /// wave-3 acceptor material; the gate itself is straight-line + /// identical to the other tunnel rows. + async fn channel_open_forwarded_tcpip( + &mut self, + _channel: Channel, + _host_to_connect: &str, + _port_to_connect: u32, + _originator_address: &str, + _originator_port: u32, + reply: ChannelOpenHandle, + _session: &mut Session, + ) -> Result<(), Self::Error> { + if self.serves_tunnels() { + tunnel_row_stub(reply).await; + } else { + reply.reject(dispatch::unfeatured_open_decline()).await; + } + Ok(()) + } + + /// The `direct-streamlocal` open gate (`tunnel`): the same + /// gate/stub split as the tcp rows. The runtime-Unix gate (OQ-11) + /// gates *serving* on Unix; this wave the stub declines + /// everywhere, so the runtime gate has nothing to ride yet. + async fn channel_open_direct_streamlocal( + &mut self, + _channel: Channel, + _socket_path: &str, + reply: ChannelOpenHandle, + _session: &mut Session, + ) -> Result<(), Self::Error> { + if self.serves_tunnels() { + tunnel_row_stub(reply).await; + } else { + reply.reject(dispatch::unfeatured_open_decline()).await; + } + Ok(()) + } + + /// The parse-only exec dispatch (ADR-006 point 3 as amended): + /// parse first ([`crate::dispatch::classify_exec`]) — before any + /// allocation, decline first, alloc never — then route on the + /// *feature graph* the route carries: a git-parseable command to + /// the git row (a session-only build declines here — the git + /// parse is its gate), anything else to the session pipe row. + /// Invalid UTF-8 parses to the pipe row (parse-then-fail). + async fn exec_request( + &mut self, + channel: ChannelId, + data: &[u8], + session: &mut Session, + ) -> Result<(), Self::Error> { + let command = std::str::from_utf8(data).unwrap_or(""); + match dispatch::classify_exec(command) { + dispatch::ExecRoute::Git => { + if self.serves_git_row() { + git_row_stub(channel, session).await; + } else { + decline_unfeatured_request(channel, session).await; + } + } + dispatch::ExecRoute::Pipe => { + if self.serves_session_rows() { + pipe_row_stub(channel, session).await; + } else { + decline_unfeatured_request(channel, session).await; + } + } + } + Ok(()) + } + + /// The `pty_request` row route (`session`): featured routes to + /// the session-row seam stub; unfeatured declines the capability. + async fn pty_request( + &mut self, + channel: ChannelId, + _term: &str, + _col_width: u32, + _row_height: u32, + _pix_width: u32, + _pix_height: u32, + _modes: &[(Pty, u32)], + session: &mut Session, + ) -> Result<(), Self::Error> { + if self.serves_session_rows() { + pty_row_stub(channel, session).await; + } else { + decline_unfeatured_request(channel, session).await; + } + Ok(()) + } + + /// The `shell_request` row route (`session`): the same shape as + /// `pty_request`. The OQ-08 unset-shell check is the serving + /// path's consumption (wave 3): this wave the stub declines the + /// row regardless, so the unset-declines posture already holds. + async fn shell_request( + &mut self, + channel: ChannelId, + session: &mut Session, + ) -> Result<(), Self::Error> { + if self.serves_session_rows() { + shell_row_stub(channel, session).await; + } else { + decline_unfeatured_request(channel, session).await; + } + Ok(()) + } + + /// Every subsystem name declines (unimplemented on this door): + /// `subsystem("sftp")` is the OQ-13 placeholder — the feature + /// exists, the route does not until alkfs lands; git service + /// names carry only a name, never a repository id, so git can + /// never dispatch as a subsystem (ADR-006 decision 2, POC-3 + /// §A.3's forced decline); everything else is unimplemented. Wire + /// shape: `channel_failure`, the channel left usable (the + /// POC-1 §5 session-survives-declines posture). + async fn subsystem_request( + &mut self, + channel: ChannelId, + _name: &str, + session: &mut Session, + ) -> Result<(), Self::Error> { + decline_unfeatured_request(channel, session).await; + Ok(()) + } + + /// `env_request` declines unconditionally (OQ-09: no allowlist + /// ships — wire env is not family config; the assembly supplies + /// the session env through [`crate::ServeConfig::session_env`]). + async fn env_request( + &mut self, + channel: ChannelId, + _variable_name: &str, + _variable_value: &str, + session: &mut Session, + ) -> Result<(), Self::Error> { + decline_unfeatured_request(channel, session).await; + Ok(()) + } + + /// The `x11-req` request on a session channel: always declines + /// (never-feature, ADR-003 §5). + async fn x11_request( + &mut self, + channel: ChannelId, + _single_connection: bool, + _x11_auth_protocol: &str, + _x11_auth_cookie: &str, + _x11_screen_number: u32, + session: &mut Session, + ) -> Result<(), Self::Error> { + decline_unfeatured_request(channel, session).await; + Ok(()) + } + + /// Agent forwarding on a session session-channel: always declines + /// (never-feature, ADR-003 §5 — agent forwarding is the + /// anti-pattern of the alkvault custody model). `false` is the + /// decline; russh replies the channel failure. + async fn agent_request( + &mut self, + _channel: ChannelId, + _session: &mut Session, + ) -> Result { + Ok(false) + } + + /// The `tcpip-forward` listen request (`-R` ingress): a `tunnel` + /// row whose serving flow is wave-3 material — the gate exists + /// ([`Self::serves_tunnels`]) but even a featured build declines + /// this wave (never advertise a listen that cannot serve). The + /// bound-port `port = 0` picking rule rides the wave-3 flow. + async fn tcpip_forward( + &mut self, + _address: &str, + _port: &mut u32, + _session: &mut Session, + ) -> Result { + Ok(false) + } + + /// Nothing is ever registered this wave — the cancel form + /// declines accordingly. + async fn cancel_tcpip_forward( + &mut self, + _address: &str, + _port: u32, + _session: &mut Session, + ) -> Result { + Ok(false) + } + + /// The `streamlocal-forward` listen request: the same decline as + /// `tcpip-forward`, plus its runtime-Unix gate (OQ-11) riding the + /// wave-3 serving flow it will gate. + async fn streamlocal_forward( + &mut self, + _socket_path: &str, + _session: &mut Session, + ) -> Result { + Ok(false) + } + + /// Nothing is ever registered this wave — the cancel form + /// declines accordingly. + async fn cancel_streamlocal_forward( + &mut self, + _socket_path: &str, + _session: &mut Session, + ) -> Result { + Ok(false) + } +} + +/// The request-level decline shape for an unfeatured/unimplemented +/// capability: fail the request and leave the channel usable (POC-1 +/// §5 — session survives declines, no state poisoning). +async fn decline_unfeatured_request(channel: ChannelId, session: &mut Session) { + let _ = session.channel_failure(channel); +} + +/// The family-seam decline shape for a **routed-but-not-yet-served** +/// row: fail the request and close the channel — the vanilla-clean +/// shape for a serving path that would have owned the channel (wave 3 +/// replaces these stubs with the spawned serving paths; a declined +/// seam never leaks a half-served channel, since the decline IS the +/// row this wave). Decline-first-alloc-never holds structurally: +/// nothing is allocated before the failure lands. +async fn seam_stub_decline(channel: ChannelId, session: &mut Session) { + let _ = session.channel_failure(channel); + let _ = session.close(channel); +} + +/// The git row's seam stub — wave 3 lands the alkgit seam here +/// (`parse command → Service + repo → serve_duplex_resolved`), spawned +/// per ADR-006 decision 4; the skeleton declines at the seam. +async fn git_row_stub(channel: ChannelId, session: &mut Session) { + seam_stub_decline(channel, session).await; +} + +/// The session pipe row's seam stub — wave 3 lands the alktty pipe +/// seam here (`wrapper ++ [cmd]`, the OQ-08 argv append), spawned per +/// ADR-006 decision 4; the skeleton declines at the seam. +async fn pipe_row_stub(channel: ChannelId, session: &mut Session) { + seam_stub_decline(channel, session).await; +} + +/// The session pty row's seam stub — wave 3 lands the alktty +/// `NegotiateRequest.tty` construction here +/// (`drive_session_pre_negotiated`), spawned per ADR-006 decision 4; +/// the skeleton declines at the seam. +async fn pty_row_stub(channel: ChannelId, session: &mut Session) { + seam_stub_decline(channel, session).await; +} + +/// The session shell row's seam stub — wave 3 lands the alktty +/// `NegotiateRequest.cmd` = serve-config shell argv here (OQ-08, no +/// crate default), spawned per ADR-006 decision 4; the skeleton +/// declines at the seam. +async fn shell_row_stub(channel: ChannelId, session: &mut Session) { + seam_stub_decline(channel, session).await; +} + +/// The tunnel row's seam stub: the explicit `ConnectFailed` reject +/// ([`crate::dispatch::tunnel_seam_stub_decline`]) — wave 3 lands the +/// route-1 bridge here (`TunnelSession::open_direct` → accept only +/// after the fabric open succeeds → `take_halves` pump). +async fn tunnel_row_stub(reply: ChannelOpenHandle) { + reply.reject(dispatch::tunnel_seam_stub_decline()).await; } diff --git a/src/lib.rs b/src/lib.rs index 5dfa6f2..de6ea28 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -26,6 +26,7 @@ mod adapter; mod config; +mod dispatch; mod error; mod fingerprint; mod handler; diff --git a/tasks/implementation/w2-dispatch-decline-core.md b/tasks/implementation/w2-dispatch-decline-core.md index 822ca7d..cb1526e 100644 --- a/tasks/implementation/w2-dispatch-decline-core.md +++ b/tasks/implementation/w2-dispatch-decline-core.md @@ -1,7 +1,7 @@ --- id: w2-dispatch-decline-core name: Wave 2 — parse-only dispatch + the fail-closed decline core -status: pending +status: completed depends_on: [w2-loopback-harness] scope: moderate risk: medium @@ -93,8 +93,122 @@ declining stubs this task shapes the routes for. ## Notes -> To be filled by implementation agent +- `src/dispatch.rs` (new): the parse-only dispatch core — the + `ExecRoute` decision (`Git` / `Pipe`), the skeleton exec-parse + probe (`classify_exec`: exact `git-upload-pack`/`git-receive-pack` + service prefix + space + path-shaped argument — leading `'` or + `/`; everything else → the pipe row), and the two wire-decline + reason shapers (`unfeatured_open_decline` → + `AdministrativelyProhibited`, `tunnel_seam_stub_decline` → + `ConnectFailed`). Seven probe unit rows pin the POC-3 stage-A + grammar facts the skeleton carries. The full grammar (quoted/ + unquoted paths, per-length limits, service-only + decline-with-reason unknown-service rows) is wave-3's to replace + it with. +- `src/handler.rs`: every `channel_open_*` hook gates per the feature + graph (session open: `session` ∨ `git`; the three tunnel opens: + `tunnel`; x11: always declines). Request hooks: `exec_request` + parses first then routes on the feature graph; `pty`/`shell` route + under `session`; `subsystem_request` declines every name (sftp = + the OQ-13 placeholder, `git-*` = no repository id in a name-only + request — ADR-006 decision 2); `env_request` declines + unconditionally (OQ-09); `x11_request`/`agent_request` decline + (ADR-003 §5); the four listen requests decline (the flow is wave + 3; never advertise a listen that cannot serve). Decline-first- + alloc-never holds structurally: no hook allocates before its + decline; a confirmed session open accepts and drops the channel + handle (nothing is held this wave — the store-and-take handoff is + wave-3 mapping material, and the routing decisions around it are + already pinned by the seam stubs). +- **Wire decline reasons (the wave-1 error-model reservation, now + spent):** open-level declines carry channel-open failure codes — + unfeatured and never-feature rows answer `AdministrativelyProhibited` + (1) is the honest class: without the feature there is no capability + to distinguish; the tunnel seam stub answers `ConnectFailed` (2), + the explicit reject shape ADR-006 decision 1 pins for failed fabric + opens (accept only after the fabric open succeeds; the skeleton + never opens the fabric). Request-level declines carry + `channel_failure` — the SSH request vocabulary has no failure text, + so the "reason" is the routing decision, documented per build. + Within request declines, two shapes: a *capability* decline answers + failure and leaves the channel usable (POC-1 §5 posture, proven + live by the channel-alive rows); a *serving-path* decline (the seam + stubs) answers failure then closes the channel (vanilla clients + never hang on a row that will spawn next wave). +- **russh 0.65 facts coded around:** only `agent_request` and the + global requests auto-reply from the handler's `bool`; every other + request hook must call `session.channel_failure` itself. The bare + `auth-agent@openssh.com` (and `forwarded-streamlocal@openssh.com`) + channel *types* have no handler hook — russh rejects those opens + inline (administratively prohibited); that is the decline the + never-feature contract needs but it is an inherited posture flagged + in the handler docs (pin-bump change would appear as + accepted-then-dropped — upstream note). `forwarded-tcpip` has the + same gate/stub code shape as its tcp sibling but no client-side + open helper (the server opens it toward the client), so its wire + rows are wave-3 acceptor material. +- **Harness:** the helper vocabulary extended (tunnel/x11 opens; pty/ + env/x11/agent-forward requests; `tcpip_forward`), discharging the + loopback-harness task's recorded expectation that the request + helpers' live rows arrive here. One vehicle row + (`authenticated_session_serves_and_declines_with_an_observed_reason`) + recorded the *skeleton* decline-only behavior; the dispatch core + makes session opens serve under their features, so that row is now + cfg-pinned to the featureless builds it speaks about (its + `ChannelOpenFailure`-reason observation remains the none-build's + proof). +- **Request-less session channels** (ADR-006 as amended): parse-only + means nothing serves before a request arrives (the SOCKS-detect is + retired); a request-less channel is served by nothing and dies at + the wall clock. The wire-observable rows are the unfeatured opens + (none/tunnel builds decline session channels at the open); the + no-detect posture is structural (no serving path exists outside + the request hooks). ## Summary -> To be filled on completion \ No newline at end of file +Landed the parse-only dispatch + the fail-closed decline core +(`w2-dispatch-decline-core`) in three files plus the harness: + +- **`src/dispatch.rs`** — the `ExecRoute` decision, the skeleton + exec-parse probe (service prefix + path shape; wave-3 grammar + replaces), the wire-reason shapers (`AdministrativelyProhibited` + for unfeatured/never-feature opens, `ConnectFailed` for the tunnel + seam stub), 7 probe unit rows. +- **`src/handler.rs`** — the `channel_open_*` gates (session ∨ git; + tunnel; x11/auth-agent never), the parse-only `exec_request` + dispatch, the session-row routes, the request-level declines + (subsystem all-names with the sftp/git named classes, env, x11, + agent), the four listen-request declines, and the named seam + stubs (`git_row_stub`, `pipe_row_stub`, `pty_row_stub`, + `shell_row_stub`, `tunnel_row_stub`) — the wave-3 replacement + points, each documented with its family seam. Module docs now + carry the decline-core contract (gates, decline classes, wire + reasons, decline-first-alloc-never, the parse-only posture, the + inherited-auth-agent-open upstream note). +- **`tests/dispatch_core.rs`** — the skeleton decline-matrix rows per + feature build, 26 rows total, each with a `Contract stamp:`: + shared rows in every build (x11 open, `tcpip-forward` decline, + x11/agent requests where sessions open); none-build + (everything declines at open); git-only (sessions open, git + exec → seam-stub failure+close, plain exec/unknown services → + pipe-row decline with channel alive, pty/shell/subsystem/env/capability + declines, tunnel unfeatured); session-only (git-parseable exec + declines as gate, stub rows failure+close, capability declines, + tunnel unfeatured); tunnel-only (session opens decline, featured + tunnel rows → `ConnectFailed` stubs); all-features (routes hold + with the full graph — git row, pipe row, pty/shell stubs, + `ConnectFailed` stubs, capability declines). +- **`tests/common/harness.rs`** — the declined-vocabulary helpers + (tunnel/x11 opens, pty/env/x11/agent requests, listen requests), + module doc updated to record the discharge of the + `w2-loopback-harness` note. + +Verification (all green): `cargo test` (91 rows across 7 binaries) · +`cargo test --features git/session/tunnel/sftp --test dispatch_core` +(each build's rows) · `cargo test --all-features` · +`cargo clippy --all-targets -- -D warnings` · +`cargo clippy --all-features --all-targets -- -D warnings` · +`cargo fmt --check` · `cargo doc --no-deps` (no warnings). One +pre-existing harness row pinned to the skeleton's decline-only open +behavior was cfg-gated to the featureless builds it describes. \ No newline at end of file diff --git a/tests/common/harness.rs b/tests/common/harness.rs index c70cc0e..c25fd88 100644 --- a/tests/common/harness.rs +++ b/tests/common/harness.rs @@ -56,6 +56,16 @@ //! russh-verbatim shape ([`AuthResult`], [`Error`], //! [`ChannelMsg`]) the rows match on directly. //! +//! The helper set grows with the rows it serves: `w2-loopback-harness` +//! landed the auth/session/exec/shell/subsystem vocabulary, and +//! `w2-dispatch-decline-core` extended it with the decline-matrix +//! vocabulary (the tunnel/x11 opens, the pty/env/x11/agent-forward +//! requests, the listen requests) — its recorded expectation that the +//! "session-request and exit-status/close helpers are exercised by +//! construction this wave ... their live rows arrive with +//! `w2-dispatch-decline-core`" is discharged by exactly that +//! extension, not by a harness rewrite. +//! //! Like the doubles: test functions are *rows* carrying the greppable //! **`Contract stamp:`** doc markers (see the common module's doc). @@ -264,6 +274,40 @@ pub async fn open_session_channel( client.channel_open_session().await } +/// Opens a `direct-tcpip` channel — the tunnel open helper (`ssh -L` +/// / `ssh -D` traffic). The open-failure shape is the `Err` verbatim, +/// the decline-matrix rows' reason surface. +pub async fn open_direct_tcpip( + client: &Handle, + host_to_connect: &str, + port_to_connect: u32, +) -> Result, Error> { + client + .channel_open_direct_tcpip(host_to_connect, port_to_connect, "harness-originator", 0) + .await +} + +/// Opens a `direct-streamlocal` channel — the unix-socket forward +/// helper. The open-failure shape is the `Err` verbatim. +pub async fn open_direct_streamlocal( + client: &Handle, + socket_path: &str, +) -> Result, Error> { + client.channel_open_direct_streamlocal(socket_path).await +} + +/// Opens an x11 channel — the never-feature open helper (ADR-003 §5). +/// The open-failure shape is the `Err` verbatim. +pub async fn open_x11_channel( + client: &Handle, + originator_address: &str, + originator_port: u32, +) -> Result, Error> { + client + .channel_open_x11(originator_address, originator_port) + .await +} + /// Sends an exec request over a channel (`want_reply` set — the /// success/failure shape rides [`ChannelMsg::Success`]/[`ChannelMsg::Failure`] /// through [`next_message`]). @@ -281,6 +325,43 @@ pub async fn request_subsystem(channel: &Channel, name: &str) -> Result<(), channel.request_subsystem(true, name).await } +/// Sends a pty request over a channel (`want_reply` set — the pty row +/// rides the standard success/failure observation). +pub async fn request_pty(channel: &Channel) -> Result<(), Error> { + channel.request_pty(true, "xterm", 80, 24, 0, 0, &[]).await +} + +/// Sends an env request over a channel (`want_reply` set — the OQ-09 +/// unconditional-decline rows' observation). +pub async fn request_env(channel: &Channel, name: &str, value: &str) -> Result<(), Error> { + channel.set_env(true, name, value).await +} + +/// Sends an x11-req request over a channel (`want_reply` set — the +/// never-feature request rows). +pub async fn request_x11(channel: &Channel) -> Result<(), Error> { + channel + .request_x11(true, false, "MIT-MAGIC-COOKIE-1", "harness-cookie", 0) + .await +} + +/// Sends an agent-forwarding request over a channel (`want_reply` set +/// — the never-feature request rows; ADR-003 §5). +pub async fn request_agent_forward(channel: &Channel) -> Result<(), Error> { + channel.agent_forward(true).await +} + +/// Requests a `tcpip-forward` listen (`-R` ingress) — the +/// success/failure shape is the result verbatim +/// ([`Error::RequestDenied`] on a declined listen). +pub async fn tcpip_forward( + client: &Handle, + address: &str, + port: u32, +) -> Result { + client.tcpip_forward(address, port).await +} + /// Observes the next channel message — `None` after the channel has /// closed (russh's EOF-of-conversation shape). pub async fn next_message(channel: &mut Channel) -> Option { diff --git a/tests/dispatch_core.rs b/tests/dispatch_core.rs new file mode 100644 index 0000000..fe6b83d --- /dev/null +++ b/tests/dispatch_core.rs @@ -0,0 +1,730 @@ +//! The wave-2 dispatch/decline-core contract tests +//! (`w2-dispatch-decline-core`) — the skeleton rows of the decline +//! matrix (`mapping.md` §Decline matrix), driven per feature build +//! through the loopback harness. This task tests the rows it lands; +//! the full per-build matrix suite is wave-5's. +//! +//! Feature builds are compile-time, so the rows are feature-gated to +//! the build whose matrix row they pin: the shared rows +//! (never-feature `x11`, the `tcpip-forward` listen decline) run in +//! every build; the capability rows run in the builds that open their +//! subject (a session channel needs `session` or `git`); the +//! per-build matrix rows are pinned to exactly one feature combination +//! (`not(...)` guards) so `--all-features` runs neither a +//! git-only row nor a session-only row by accident. Every feature +//! build is exercised by its own verification run +//! (`cargo test [--features …]`), not by cfg tricks. + +#![allow(dead_code)] + +mod common; + +use std::sync::Arc; + +use common::harness::{ + LoopbackSession, adapter, authenticate_publickey, await_close, drive, next_message, + open_session_channel, open_x11_channel, tcpip_forward, +}; +use common::{MemHostKeyPolicy, MemIdentityProvider, fixture, identity}; +use russh::client::Msg; +use russh::{Channel, ChannelOpenFailure, Error}; + +use alkssh::Limits; + +/// Mounts an authenticated loopback session over the doubles — the +/// dispatch rows' driver: fixture `alpha` resolves through +/// `MemIdentityProvider`, host keys `bravo`, the permissive host-key +/// posture (the explicit test choice). +async fn authenticated() -> LoopbackSession { + let provider = Arc::new(MemIdentityProvider::new()); + provider.register( + fixture("alpha").fingerprint, + identity("dispatch-core", &["ssh"]), + ); + let adapter = adapter(provider, &[fixture("bravo")]).await; + + let policy = Arc::new(MemHostKeyPolicy::with_default(true)); + let mut session = drive(adapter, Arc::clone(&policy), Limits::default()) + .await + .expect("the loopback mounts"); + + let result = + authenticate_publickey(&mut session.client, "dispatch-core", fixture("alpha")).await; + assert!( + result.success(), + "the fixture identity authenticates before every dispatch row" + ); + session +} + +/// Matches an open failure's SSH reason — the wire decline reason the +/// open-gate rows assert. +fn open_failure(err: Error, expected: ChannelOpenFailure, context: &str) { + assert!( + matches!( + &err, + Error::ChannelOpenFailure(reason) if *reason == expected + ), + "{context}: expected open decline with {expected:?}, got {err:?}" + ); +} + +/// The request-level decline observation: the channel answers Failure. +/// A capability decline's channel stays open afterwards — the POC-1 §5 +/// session-survives-declines posture the channel-alive rows lean on. +async fn expect_failure(channel: &mut Channel, context: &str) { + match next_message(channel).await { + Some(russh::ChannelMsg::Failure) => {} + other => panic!("{context}: expected a channel failure, got {other:?}"), + } +} + +/// The serving-path (family-seam stub) decline observation: Failure +/// then Close — the vanilla-clean shape for a row whose serving path +/// would own the channel. +async fn expect_failure_then_close(channel: &mut Channel, context: &str) { + expect_failure(channel, context).await; + assert!( + await_close(channel).await, + "{context}: the seam stub closes the channel after the failure" + ); +} + +/// Proves a declined channel is still usable — a follow-up request +/// reaches the dispatch again instead of the channel being dead. +async fn expect_channel_alive(channel: &mut Channel) { + common::harness::request_subsystem(channel, "sftp") + .await + .expect("the still-open channel carries another request"); + expect_failure(channel, "the surviving channel still dispatches").await; +} + +// --------------------------------------------------------------------------- +// Shared rows — run in every feature build. +// --------------------------------------------------------------------------- + +/// - *Contract stamp:* ADR-003 §5 (x11 is a never-feature — declined +/// in every build, never feature-gated). +#[tokio::test] +async fn x11_channel_opens_decline_in_every_build() { + let session = authenticated().await; + let err = open_x11_channel(&session.client, "127.0.0.1", 0) + .await + .expect_err("x11 opens decline"); + open_failure( + err, + ChannelOpenFailure::AdministrativelyProhibited, + "the never-feature x11 open", + ); + session.unmount().await; +} + +/// - *Contract stamp:* mapping.md §Global-request rows (the `-R` +/// listen flow is a `tunnel` row declined when the flow cannot +/// serve — this wave: every build). +#[tokio::test] +async fn tcpip_forward_listen_requests_decline_in_every_build() { + let session = authenticated().await; + let err = tcpip_forward(&session.client, "127.0.0.1", 8080) + .await + .expect_err("the listen request declines"); + assert!( + matches!(err, Error::RequestDenied), + "expected the declined listen's RequestDenied, got {err:?}" + ); + session.unmount().await; +} + +/// - *Contract stamp:* mapping.md §Decline matrix (git-build row, +/// request side: `x11` / `auth-agent` requests decline; ADR-003 §5). +#[cfg(any(feature = "git", feature = "session"))] +#[tokio::test] +async fn x11_and_agent_requests_decline_wherever_sessions_open() { + use common::harness::{request_agent_forward, request_x11}; + let session = authenticated().await; + let mut channel = open_session_channel(&session.client) + .await + .expect("the session channel opens where a session-serving feature exists"); + + request_x11(&channel) + .await + .expect("the x11-req rides the wire"); + expect_failure(&mut channel, "the never-feature x11 request declines").await; + + request_agent_forward(&channel) + .await + .expect("the agent-forward request rides the wire"); + expect_failure(&mut channel, "the never-feature agent request declines").await; + + expect_channel_alive(&mut channel).await; + session.unmount().await; +} + +// --------------------------------------------------------------------------- +// Default build (no features): everything declines (mapping.md +// §Decline matrix — "none (default): declines everything"). +// --------------------------------------------------------------------------- + +#[cfg(not(any(feature = "session", feature = "git", feature = "tunnel")))] +mod default_build { + use super::*; + use common::harness::{open_direct_streamlocal, open_direct_tcpip}; + + /// - *Contract stamp:* mapping.md §Decline matrix (none-build + /// row: every channel type declines at open — session channels + /// as unfeatured, tunnel rows as unfeatured — plus the + /// never-feature x11). + #[tokio::test] + async fn every_channel_open_declines_in_a_default_build() { + let session = authenticated().await; + + let err = open_session_channel(&session.client) + .await + .expect_err("session channels decline unfeatured"); + open_failure( + err, + ChannelOpenFailure::AdministrativelyProhibited, + "the unfeatured session open", + ); + + let err = open_direct_tcpip(&session.client, "internal.example", 5432) + .await + .expect_err("direct-tcpip declines unfeatured"); + open_failure( + err, + ChannelOpenFailure::AdministrativelyProhibited, + "the unfeatured direct-tcpip open", + ); + + let err = open_direct_streamlocal(&session.client, "/run/alkssh.sock") + .await + .expect_err("direct-streamlocal declines unfeatured"); + open_failure( + err, + ChannelOpenFailure::AdministrativelyProhibited, + "the unfeatured direct-streamlocal open", + ); + + session.unmount().await; + } +} + +// --------------------------------------------------------------------------- +// git-only build: routes git-parseable exec to the git row (stub at +// the seam); declines pty/shell, plain exec, all other opens +// (mapping.md §Decline matrix — git row). +// --------------------------------------------------------------------------- + +#[cfg(all(feature = "git", not(feature = "session"), not(feature = "tunnel")))] +mod git_build { + use super::*; + use common::harness::{ + open_direct_streamlocal, open_direct_tcpip, request_env, request_exec, request_shell, + request_subsystem, + }; + + /// - *Contract stamp:* the task's channel-open gate (session + /// channels open where a session-serving feature exists — `git` + /// qualifies, the parse path lives inside session channels). + #[tokio::test] + async fn session_channels_open_in_a_git_build() { + let session = authenticated().await; + open_session_channel(&session.client) + .await + .expect("session channels open under the git feature"); + session.unmount().await; + } + + /// - *Contract stamp:* ADR-006 point 3 (parse-only: a + /// git-parseable exec routes to the git row — the seam stub + /// declines at the family-seam boundary with the serving-path + /// shape: failure then close). + #[tokio::test] + async fn git_parseable_exec_routes_to_the_git_row_stub() { + let session = authenticated().await; + for command in [ + "git-upload-pack '/owner/demo'", + "git-receive-pack '/owner/demo'", + ] { + let mut channel = open_session_channel(&session.client) + .await + .expect("the session channel opens"); + request_exec(&channel, command) + .await + .expect("the exec request rides the wire"); + expect_failure_then_close( + &mut channel, + &format!("the git row serves its seam stub for {command}"), + ) + .await; + } + session.unmount().await; + } + + /// - *Contract stamp:* mapping.md §Decline matrix (git-build row: + /// plain exec declines; the parse-then-fail shape of POC-3 + /// stage A — the channel survives the decline). + #[tokio::test] + async fn plain_exec_declines_and_keeps_the_channel_in_a_git_build() { + let session = authenticated().await; + let mut channel = open_session_channel(&session.client) + .await + .expect("the session channel opens"); + request_exec(&channel, "uptime") + .await + .expect("the exec request rides the wire"); + expect_failure(&mut channel, "the unfeatured pipe row declines").await; + expect_channel_alive(&mut channel).await; + session.unmount().await; + } + + /// - *Contract stamp:* POC-3 §2 (unknown git services route off + /// the git row — the skeleton probe's honest surface: they take + /// the pipe-row route, not the git row's close-shape). + #[tokio::test] + async fn unknown_git_services_route_to_the_pipe_row() { + let session = authenticated().await; + for command in ["git-send-pack '/r'", "git-upload-archive '/r'"] { + let mut channel = open_session_channel(&session.client) + .await + .expect("the session channel opens"); + request_exec(&channel, command) + .await + .expect("the exec request rides the wire"); + expect_failure( + &mut channel, + &format!("an unknown service does not reach the git row: {command}"), + ) + .await; + expect_channel_alive(&mut channel).await; + } + session.unmount().await; + } + + /// - *Contract stamp:* mapping.md §Decline matrix (git-build row: + /// pty/shell and all session requests decline). + #[tokio::test] + async fn pty_shell_and_capability_requests_decline_in_a_git_build() { + let session = authenticated().await; + + let mut channel = open_session_channel(&session.client) + .await + .expect("the session channel opens"); + request_shell(&channel) + .await + .expect("the shell request rides the wire"); + expect_failure(&mut channel, "the shell row declines unfeatured").await; + expect_channel_alive(&mut channel).await; + + session.unmount().await; + } + + /// - *Contract stamp:* mapping.md §Request-level rows (subsystem: + /// `sftp` is the OQ-13 placeholder decline; git service names + /// carry no repository id — ADR-006 decision 2). + #[tokio::test] + async fn subsystems_decline_with_their_named_reasons_in_a_git_build() { + let session = authenticated().await; + let mut channel = open_session_channel(&session.client) + .await + .expect("the session channel opens"); + for name in ["sftp", "git-upload-pack"] { + request_subsystem(&channel, name) + .await + .expect("the subsystem request rides the wire"); + expect_failure( + &mut channel, + &format!("the subsystem {name} declines with reason"), + ) + .await; + } + expect_channel_alive(&mut channel).await; + session.unmount().await; + } + + /// - *Contract stamp:* OQ-09 (mapping.md §Request-level rows: + /// `env_request` declines unconditionally — no allowlist ships). + #[tokio::test] + async fn env_requests_decline_in_a_git_build() { + let session = authenticated().await; + let mut channel = open_session_channel(&session.client) + .await + .expect("the session channel opens"); + request_env(&channel, "GIT_DIR", "/srv/git") + .await + .expect("the env request rides the wire"); + expect_failure(&mut channel, "the env request declines").await; + expect_channel_alive(&mut channel).await; + session.unmount().await; + } + + /// - *Contract stamp:* mapping.md §Decline matrix (git-build + /// row: all other channel types decline — tunnel unfeatured). + #[tokio::test] + async fn tunnel_opens_decline_unfeatured_in_a_git_build() { + let session = authenticated().await; + + let err = open_direct_tcpip(&session.client, "internal.example", 5432) + .await + .expect_err("direct-tcpip declines unfeatured"); + open_failure( + err, + ChannelOpenFailure::AdministrativelyProhibited, + "direct-tcpip", + ); + + let err = open_direct_streamlocal(&session.client, "/run/alkssh.sock") + .await + .expect_err("direct-streamlocal declines unfeatured"); + open_failure( + err, + ChannelOpenFailure::AdministrativelyProhibited, + "direct-streamlocal", + ); + + session.unmount().await; + } +} + +// --------------------------------------------------------------------------- +// session-only build: declines git-parseable exec (the git parse is +// its gate) and serves its rows only to the seam stubs; declines +// tunnel opens (mapping.md §Decline matrix — session row). +// --------------------------------------------------------------------------- + +#[cfg(all(feature = "session", not(feature = "git"), not(feature = "tunnel")))] +mod session_build { + use super::*; + use common::harness::{ + open_direct_streamlocal, open_direct_tcpip, request_env, request_exec, request_pty, + request_shell, request_subsystem, + }; + + /// - *Contract stamp:* the task's channel-open gate (session + /// channels open where a session-serving feature exists). + #[tokio::test] + async fn session_channels_open_in_a_session_build() { + let session = authenticated().await; + open_session_channel(&session.client) + .await + .expect("session channels open under the session feature"); + session.unmount().await; + } + + /// - *Contract stamp:* mapping.md §Decline matrix (session row: + /// git-parseable exec declines — the `git` parse is its gate; + /// the channel survives the decline, POC-1 §5). + #[tokio::test] + async fn git_parseable_exec_declines_in_a_session_build() { + let session = authenticated().await; + let mut channel = open_session_channel(&session.client) + .await + .expect("the session channel opens"); + request_exec(&channel, "git-upload-pack '/owner/demo'") + .await + .expect("the exec request rides the wire"); + expect_failure( + &mut channel, + "the git parse gates the row off in a session build", + ) + .await; + expect_channel_alive(&mut channel).await; + session.unmount().await; + } + + /// - *Contract stamp:* ADR-006 point 3 (parse-only: non-git exec + /// routes to the session pipe row — the seam stub declines at + /// the family-seam boundary: failure then close). + #[tokio::test] + async fn plain_exec_routes_to_the_pipe_row_stub() { + let session = authenticated().await; + let mut channel = open_session_channel(&session.client) + .await + .expect("the session channel opens"); + request_exec(&channel, "uptime") + .await + .expect("the exec request rides the wire"); + expect_failure_then_close(&mut channel, "the pipe row serves its seam stub").await; + session.unmount().await; + } + + /// - *Contract stamp:* mapping.md §Channel-type rows + /// (`session` → `pty_request` + `shell_request` is a `session` + /// row; the stub decline is the seam shape). + #[tokio::test] + async fn pty_and_shell_route_to_their_row_stubs() { + let session = authenticated().await; + + let mut channel = open_session_channel(&session.client) + .await + .expect("the session channel opens"); + request_pty(&channel) + .await + .expect("the pty request rides the wire"); + expect_failure_then_close(&mut channel, "the pty row serves its seam stub").await; + + let mut channel = open_session_channel(&session.client) + .await + .expect("the session channel opens"); + request_shell(&channel) + .await + .expect("the shell request rides the wire"); + expect_failure_then_close(&mut channel, "the shell row serves its seam stub").await; + + session.unmount().await; + } + + /// - *Contract stamp:* mapping.md §Request-level rows (subsystem: + /// the OQ-13 placeholder and git service names decline-with- + /// reason in every session-serving build). + #[tokio::test] + async fn subsystems_decline_with_their_named_reasons_in_a_session_build() { + let session = authenticated().await; + let mut channel = open_session_channel(&session.client) + .await + .expect("the session channel opens"); + for name in ["sftp", "git-upload-pack"] { + request_subsystem(&channel, name) + .await + .expect("the subsystem request rides the wire"); + expect_failure( + &mut channel, + &format!("the subsystem {name} declines with reason"), + ) + .await; + } + expect_channel_alive(&mut channel).await; + session.unmount().await; + } + + /// - *Contract stamp:* OQ-09 (mapping.md §Request-level rows: + /// `env_request` declines unconditionally). + #[tokio::test] + async fn env_requests_decline_in_a_session_build() { + let session = authenticated().await; + let mut channel = open_session_channel(&session.client) + .await + .expect("the session channel opens"); + request_env(&channel, "GIT_DIR", "/srv/git") + .await + .expect("the env request rides the wire"); + expect_failure(&mut channel, "the env request declines").await; + expect_channel_alive(&mut channel).await; + session.unmount().await; + } + + /// - *Contract stamp:* mapping.md §Decline matrix (session row: + /// other channel types decline — tunnel unfeatured). + #[tokio::test] + async fn tunnel_opens_decline_unfeatured_in_a_session_build() { + let session = authenticated().await; + + let err = open_direct_tcpip(&session.client, "internal.example", 5432) + .await + .expect_err("direct-tcpip declines unfeatured"); + open_failure( + err, + ChannelOpenFailure::AdministrativelyProhibited, + "direct-tcpip", + ); + + let err = open_direct_streamlocal(&session.client, "/run/alkssh.sock") + .await + .expect_err("direct-streamlocal declines unfeatured"); + open_failure( + err, + ChannelOpenFailure::AdministrativelyProhibited, + "direct-streamlocal", + ); + + session.unmount().await; + } +} + +// --------------------------------------------------------------------------- +// tunnel-only build: session channels decline (auto-reject); the +// tunnel rows route to the tunnel seam stub even with the feature on +// (the route-1 bridge is wave 3) — mapping.md §Decline matrix (tunnel +// row, as landed this wave). +// --------------------------------------------------------------------------- + +#[cfg(all(feature = "tunnel", not(feature = "git"), not(feature = "session")))] +mod tunnel_build { + use super::*; + use common::harness::{open_direct_streamlocal, open_direct_tcpip}; + + /// - *Contract stamp:* ADR-006 point 3 as amended (parse-only + /// dispatch: a request-less/deserving-less session channel + /// declines as unfeatured — a tunnel-only build has no + /// session-serving feature, so the open declines). + #[tokio::test] + async fn session_channels_decline_in_a_tunnel_build() { + let session = authenticated().await; + let err = open_session_channel(&session.client) + .await + .expect_err("session channels decline unfeatured"); + open_failure( + err, + ChannelOpenFailure::AdministrativelyProhibited, + "the unfeatured session open in a tunnel build", + ); + session.unmount().await; + } + + /// - *Contract stamp:* the task's channel-open gate (featured + /// tunnel rows route to the seam stub; the stub's decline is + /// the explicit `ConnectFailed` reject ADR-006 decision 1 pins + /// for failed fabric opens). + #[tokio::test] + async fn tunnel_rows_route_to_the_seam_stub_in_a_featured_build() { + let session = authenticated().await; + + let err = open_direct_tcpip(&session.client, "internal.example", 5432) + .await + .expect_err("the featured direct-tcpip row is a declining seam stub"); + open_failure( + err, + ChannelOpenFailure::ConnectFailed, + "the direct-tcpip seam stub", + ); + + let err = open_direct_streamlocal(&session.client, "/run/alkssh.sock") + .await + .expect_err("the featured direct-streamlocal row is a declining seam stub"); + open_failure( + err, + ChannelOpenFailure::ConnectFailed, + "the direct-streamlocal seam stub", + ); + + session.unmount().await; + } +} + +// --------------------------------------------------------------------------- +// All features: the routing decisions hold with the full graph on — +// every serving path is still a seam stub wave-3 material, and the +// decline classes hold. +// --------------------------------------------------------------------------- + +#[cfg(all(feature = "git", feature = "session", feature = "tunnel"))] +mod all_features { + use super::*; + use common::harness::{ + open_direct_streamlocal, open_direct_tcpip, request_env, request_exec, request_pty, + request_shell, request_subsystem, + }; + + /// - *Contract stamp:* mapping.md §Session-channel dispatch (the + /// git-parseable exec routes to the git row in the full build). + #[tokio::test] + async fn git_exec_routes_to_the_git_row_stub_with_all_features() { + let session = authenticated().await; + let mut channel = open_session_channel(&session.client) + .await + .expect("the session channel opens"); + request_exec(&channel, "git-upload-pack '/owner/demo'") + .await + .expect("the exec request rides the wire"); + expect_failure_then_close(&mut channel, "the git row serves its seam stub").await; + session.unmount().await; + } + + /// - *Contract stamp:* mapping.md §Session-channel dispatch + /// (non-git exec routes to the session pipe row in the full + /// build). + #[tokio::test] + async fn plain_exec_routes_to_the_pipe_row_stub_with_all_features() { + let session = authenticated().await; + let mut channel = open_session_channel(&session.client) + .await + .expect("the session channel opens"); + request_exec(&channel, "uptime") + .await + .expect("the exec request rides the wire"); + expect_failure_then_close(&mut channel, "the pipe row serves its seam stub").await; + session.unmount().await; + } + + /// - *Contract stamp:* mapping.md §Channel-type rows (the + /// pty/shell rows route within the session channel). + #[tokio::test] + async fn pty_and_shell_route_to_their_stubs_with_all_features() { + let session = authenticated().await; + + let mut channel = open_session_channel(&session.client) + .await + .expect("the session channel opens"); + request_pty(&channel) + .await + .expect("the pty request rides the wire"); + expect_failure_then_close(&mut channel, "the pty row serves its seam stub").await; + + let mut channel = open_session_channel(&session.client) + .await + .expect("the session channel opens"); + request_shell(&channel) + .await + .expect("the shell request rides the wire"); + expect_failure_then_close(&mut channel, "the shell row serves its seam stub").await; + + session.unmount().await; + } + + /// - *Contract stamp:* the task's channel-open gate (the tunnel + /// rows are seam stubs even with the feature on — the route-1 + /// bridge is wave 3; `ConnectFailed` is the stub's class). + #[tokio::test] + async fn tunnel_rows_route_to_the_seam_stub_with_all_features() { + let session = authenticated().await; + + let err = open_direct_tcpip(&session.client, "internal.example", 5432) + .await + .expect_err("the full-featured direct-tcpip row is a declining seam stub"); + open_failure( + err, + ChannelOpenFailure::ConnectFailed, + "the direct-tcpip seam stub", + ); + + let err = open_direct_streamlocal(&session.client, "/run/alkssh.sock") + .await + .expect_err("the full-featured direct-streamlocal row is a declining seam stub"); + open_failure( + err, + ChannelOpenFailure::ConnectFailed, + "the direct-streamlocal seam stub", + ); + + session.unmount().await; + } + + /// - *Contract stamp:* mapping.md §Request-level rows (the + /// capability declines — sftp placeholder, git subsystem + /// names, env — hold with everything on). + #[tokio::test] + async fn capability_declines_hold_with_all_features() { + let session = authenticated().await; + let mut channel = open_session_channel(&session.client) + .await + .expect("the session channel opens"); + + request_subsystem(&channel, "sftp") + .await + .expect("the subsystem request rides the wire"); + expect_failure(&mut channel, "the sftp placeholder declines").await; + + request_subsystem(&channel, "git-upload-pack") + .await + .expect("the subsystem request rides the wire"); + expect_failure(&mut channel, "the git subsystem declines").await; + + request_env(&channel, "GIT_DIR", "/srv/git") + .await + .expect("the env request rides the wire"); + expect_failure(&mut channel, "the env request declines").await; + + expect_channel_alive(&mut channel).await; + session.unmount().await; + } +} diff --git a/tests/loopback_harness.rs b/tests/loopback_harness.rs index 7543e48..186bea2 100644 --- a/tests/loopback_harness.rs +++ b/tests/loopback_harness.rs @@ -21,8 +21,8 @@ use std::time::Duration; use alkcall::core::auth::IdentityProvider; use common::harness::{ - LOOPBACK_HOST, LOOPBACK_PORT, authenticate_publickey, client_config, drive, - open_session_channel, remaining_methods, serve_config, + LOOPBACK_HOST, LOOPBACK_PORT, authenticate_publickey, client_config, drive, remaining_methods, + serve_config, }; use common::{MemHostKeyPolicy, MemIdentityProvider, fixture, identity}; use russh::{Error, MethodSet}; @@ -90,11 +90,15 @@ async fn in_process_pair_connects_and_authenticates_end_to_end() { /// - *Contract stamp:* ADR-001 (the authenticated session is live and /// serving over the mount) + `mapping.md` §Decline matrix (the -/// skeleton's fail-closed decline is observable through the harness -/// as a ChannelOpenFailure reason — the *specific* reason is -/// `w2-dispatch-decline-core`'s row, not this vehicle's). +/// fail-closed decline is observable through the harness as a +/// ChannelOpenFailure reason — the *specific* reason is +/// `w2-dispatch-decline-core`'s row, which landed it; in builds with +/// a session-serving feature this row never sees a decline, so it +/// is pinned to the featureless builds it speaks about). +#[cfg(not(any(feature = "session", feature = "git")))] #[tokio::test] async fn authenticated_session_serves_and_declines_with_an_observed_reason() { + use common::harness::open_session_channel; let provider = registered_provider(&[fixture("alpha").fingerprint]); let adapter = SshAdapter::new(serve_config( provider as Arc,