feat(dispatch): land the parse-only dispatch + the fail-closed decline core (w2-dispatch-decline-core)

- 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
This commit is contained in:
glm-5.3-flash committed 2026-10-28 09:04:42 +00:00
1 parent 3b77193e85
commit 68d3723f6a
7 files changed
+1543 -12

No files matched your search

+169
View File
@@ -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}'"));
}
}
+436 -4
View File
@@ -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<Auth, Self::Error> {
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<Msg>,
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<Msg>,
_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<Msg>,
_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<Msg>,
_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<Msg>,
_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<bool, Self::Error> {
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<bool, Self::Error> {
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<bool, Self::Error> {
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<bool, Self::Error> {
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<bool, Self::Error> {
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;
}
+1
View File
@@ -26,6 +26,7 @@
mod adapter;
mod config;
mod dispatch;
mod error;
mod fingerprint;
mod handler;
@@ -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
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.
+81
View File
@@ -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<HarnessClientHandler>,
host_to_connect: &str,
port_to_connect: u32,
) -> Result<Channel<Msg>, 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<HarnessClientHandler>,
socket_path: &str,
) -> Result<Channel<Msg>, 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<HarnessClientHandler>,
originator_address: &str,
originator_port: u32,
) -> Result<Channel<Msg>, 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<Msg>, 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<Msg>) -> 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<Msg>, 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<Msg>) -> 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<Msg>) -> 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<HarnessClientHandler>,
address: &str,
port: u32,
) -> Result<u32, Error> {
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<Msg>) -> Option<ChannelMsg> {
+730
View File
@@ -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<Msg>, 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<Msg>, 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<Msg>) {
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;
}
}
+9 -5
View File
@@ -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<dyn IdentityProvider>,