Files
alkssh/tests/common/harness.rs
T
glm-5.3-flash 68d3723f6a 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
2026-10-28 09:04:42 +00:00

396 lines
16 KiB
Rust

//! alkssh's loopback harness (`w2-loopback-harness`) — the in-process
//! client/server pair that is *the* contract-test vehicle for the
//! crate: the wave-2 decline matrix, every wave-3 mapping's contract
//! tests, and the client-direction smokes all drive the crate through
//! this module.
//!
//! **Transport decision (recorded, the `w2-loopback-harness` flag):**
//! the server-side [`SshAdapter::handle`](alkssh::SshAdapter::handle)
//! mount runs over a **tokio duplex pair** ([`tokio::io::duplex`],
//! [`DUPLEX_CAPACITY`] bytes each direction), and the client side
//! connects with [`russh::client::connect_stream`] — russh's real
//! codec, kex and transport run over the pair verbatim, so the wire
//! path is fully exercised. The one thing it deliberately does not
//! carry is OS-socket semantics; TCP loopback (`127.0.0.1:0`) was the
//! alternative and is rejected: real-socket nondeterminism (listener
//! lifecycle, loopback timing, accept-loop flakiness under slow CI)
//! buys only what the real-tool smokes of the mapping waves and the
//! e2e wave already cover cheaply. The duplex pair is deterministic,
//! needs no port, and cannot collide — the no-TCP-port-dependency
//! shape the acceptance criteria name.
//!
//! **Placement decision (recorded, the `w2-loopback-harness`
//! counterpart of the wave-1 doubles' location decision):** the
//! harness lives as a sibling module of the wave-1 doubles, under
//! `tests/common/`, reached with the same `mod common;` convention —
//! test surface, test-versioned freely, never a public API export
//! (no re-export from `lib.rs`, no feature gate). Unlike the doubles
//! it is a *vehicle* convention, not just a seam: later waves'
//! contract tests do not re-derive a mount/connect pair; they call
//! [`drive`] (or compose its parts), so a transport revisit stays a
//! one-module change.
//!
//! **Client-side posture (recorded):** the wave-4 dial surface does
//! not exist yet; the harness drives a russh client directly with the
//! doubles: a fixture key ([`authenticate_publickey`]), the
//! [`MemHostKeyPolicy`] decision double bridged into russh's
//! `check_server_key` hook ([`HarnessClientHandler`]), and the
//! keepalive / wall-clock posture from [`alkssh::Limits`]
//! ([`client_config`]). The permissive posture for routine rows is
//! the double's `with_default(true)` — an explicit test choice; the
//! accept-all posture exists only here, never as a crate built-in,
//! and the fail-closed rows construct `with_default(false)`.
//!
//! **Host identity on the loopback (recorded):** a duplex pair has no
//! endpoint, so the presented host key is always labeled with
//! [`LOOPBACK_HOST`] and [`LOOPBACK_PORT`]; host-key policy rows pin
//! rules against those values verbatim. The presented key's
//! fingerprint is the crate's single fingerprint path
//! ([`alkssh::fingerprint`]) — exactly the decision input the wave-4
//! dial wiring will compute at russh's own hook, so this bridge is
//! that wiring's rehearsal.
//!
//! Helpers stay thin — the russh client API is the vocabulary; these
//! remove boilerplate (config assembly, mount/spawn, fixture-key
//! plumbing, drain loops), never semantics: every observation is a
//! 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).
#![allow(dead_code)]
use std::sync::Arc;
use alkcall::core::auth::IdentityProvider;
use russh::client::{AuthResult, Handle, Msg};
use russh::keys::PrivateKeyWithHashAlg;
use russh::{Channel, ChannelMsg, Disconnect, Error, MethodSet};
use alkssh::{
AdapterError, HostKeyPolicy, Limits, PresentedHostKey, ServeConfig, SshAdapter, fingerprint,
};
use super::{FixtureKey, MemHostKeyPolicy, MemHostKeySource};
/// The host label the loopback presents to the host-key policy — a
/// duplex pair has no endpoint, so every decision is labeled with
/// this constant (see the module docs §Host identity).
pub const LOOPBACK_HOST: &str = "loopback";
/// The port label for the loopback's presented host key — `0`, the
/// no-port marker: the transport has no TCP port by design.
pub const LOOPBACK_PORT: u16 = 0;
/// Each duplex direction's buffer — the `w2-server-mount` wire-smoke
/// precedent's capacity, kept as the harness convention.
pub const DUPLEX_CAPACITY: usize = 65_536;
/// The harness's russh client config — the keepalive / wall-clock
/// posture from [`Limits`]: `keepalive_interval` / `keepalive_max`
/// mapped verbatim, `inactivity_timeout` carried as always-set (the
/// crate never constructs a session with no wall clock,
/// [`alkssh::Limits`]' in-crate posture extended to the harness's
/// client direction). Everything else stays russh's own default.
pub fn client_config(limits: Limits) -> Arc<russh::client::Config> {
Arc::new(russh::client::Config {
inactivity_timeout: Some(limits.inactivity_timeout),
keepalive_interval: limits.keepalive_interval,
keepalive_max: limits.keepalive_max,
..Default::default()
})
}
/// The harness's russh client handler — the [`MemHostKeyPolicy`]
/// decision double bridged into russh's `check_server_key` hook, with
/// the decision input exactly as the wave-4 dial wiring will shape it
/// ([`PresentedHostKey`] over the crate's fingerprint path).
pub struct HarnessClientHandler {
policy: Arc<MemHostKeyPolicy>,
}
impl HarnessClientHandler {
/// Builds the handler over a policy double — `with_default(true)`
/// for the routine permissive posture, `with_default(false)` (or a
/// pinned rule) for the fail-closed rows.
pub fn new(policy: Arc<MemHostKeyPolicy>) -> Self {
Self { policy }
}
}
impl russh::client::Handler for HarnessClientHandler {
type Error = Error;
/// The policy decision, verbatim: the presented key's fingerprint
/// is computed on the crate's single fingerprint path, the
/// endpoint labels are the loopback constants, and the policy's
/// `bool` verdict rides russh's hook straight through.
async fn check_server_key(
&mut self,
server_public_key: &russh::keys::PublicKeyOrCertificate,
) -> Result<bool, Self::Error> {
let presented = PresentedHostKey {
host: LOOPBACK_HOST.into(),
port: LOOPBACK_PORT,
fingerprint: fingerprint(&server_public_key.public_key()),
};
Ok(self.policy.decide(&presented).await)
}
}
/// Assembles the server side from the wave-1 doubles — a
/// [`ServeConfig`] over the given identity provider and fixture host
/// keys, with the session limits applied.
pub fn serve_config(
provider: Arc<dyn IdentityProvider>,
host_keys: &[&'static FixtureKey],
limits: Limits,
) -> ServeConfig {
let mut serve = ServeConfig::new(
provider,
Arc::new(MemHostKeySource::from_fixtures(host_keys)),
);
serve.limits = limits;
serve
}
/// Constructs the adapter from the doubles — [`serve_config`] with the
/// default [`Limits`]; the decrypt-at-construction failure paths are
/// the construction tests' business (`w2-server-mount`), so a harness
/// row's construction failing is a bug, not an observation.
pub async fn adapter(
provider: Arc<dyn IdentityProvider>,
host_keys: &[&'static FixtureKey],
) -> SshAdapter {
SshAdapter::new(serve_config(provider, host_keys, Limits::default()))
.await
.expect("adapter constructs (fixture host keys load)")
}
/// One driven loopback session: the mounted server task plus the
/// connected client handle and its policy double.
pub struct LoopbackSession {
/// The connected russh client — the helpers' driver.
pub client: Handle<HarnessClientHandler>,
/// The policy double the connection's host-key decision rode —
/// decision-log inspection for the rows.
pub host_key_policy: Arc<MemHostKeyPolicy>,
server_task: tokio::task::JoinHandle<Result<(), AdapterError>>,
}
impl LoopbackSession {
/// Ends one loopback row cleanly: disconnects the client, waits
/// for the server task, and requires a clean session end on both
/// sides — the harness's every-row termination posture.
pub async fn unmount(self) {
self.client
.disconnect(Disconnect::ByApplication, "row done", "en")
.await
.expect("client disconnects cleanly");
let outcome = self.server_task.await.expect("server task ends");
if let Err(err) = outcome {
panic!("session end must be clean, got {err:?}");
}
}
}
/// Drives one loopback session: mounts the adapter over a duplex pair
/// (spawned to completion, the assembly's accept-loop shape), connects
/// a harness client against it and returns the pair. The adapter is
/// consumed (the spawned task owns it) — build a fresh one per row;
/// the doubles make that cheap.
///
/// Fallible at the connect step on purpose: a fail-closed host-key
/// policy verdict is a disposition the rows observe
/// (`Error::UnknownKey`); a routine row's `Err` is its bug.
pub async fn drive(
adapter: SshAdapter,
host_key_policy: Arc<MemHostKeyPolicy>,
limits: Limits,
) -> Result<LoopbackSession, Error> {
let (client_io, server_io) = tokio::io::duplex(DUPLEX_CAPACITY);
let server_task = tokio::spawn(async move { adapter.handle(server_io).await });
let client = russh::client::connect_stream(
client_config(limits),
client_io,
HarnessClientHandler::new(Arc::clone(&host_key_policy)),
)
.await?;
Ok(LoopbackSession {
client,
host_key_policy,
server_task,
})
}
/// Authenticates one fixture key — the auth helper: the fixture
/// plumbing (`Arc` + `PrivateKeyWithHashAlg`) removed; the
/// [`AuthResult`] comes back verbatim for the success / failure-shape
/// rows to match on.
pub async fn authenticate_publickey(
client: &mut Handle<HarnessClientHandler>,
username: &str,
key: &FixtureKey,
) -> AuthResult {
let private = Arc::new(key.private_key());
client
.authenticate_publickey(username, PrivateKeyWithHashAlg::new(private, None))
.await
.expect("the auth exchange completes over the wire")
}
/// The failure shape's advertisement half: the remaining-methods set
/// carried by an [`AuthResult::Failure`] — `None` when the result is
/// a success (the rows' success/failure observation shape).
pub fn remaining_methods(result: &AuthResult) -> Option<MethodSet> {
match result {
AuthResult::Failure {
remaining_methods, ..
} => Some(remaining_methods.clone()),
_ => None,
}
}
/// Opens a session channel — the open helper. Success is a confirmed
/// [`Channel`]; the open-failure shape is the `Err` verbatim
/// ([`Error::ChannelOpenFailure`] carries the SSH decline reason the
/// decline-matrix rows match on).
pub async fn open_session_channel(
client: &Handle<HarnessClientHandler>,
) -> Result<Channel<Msg>, Error> {
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`]).
pub async fn request_exec(channel: &Channel<Msg>, command: &str) -> Result<(), Error> {
channel.exec(true, command).await
}
/// Sends a shell request over a channel (`want_reply` set).
pub async fn request_shell(channel: &Channel<Msg>) -> Result<(), Error> {
channel.request_shell(true).await
}
/// Sends a subsystem request over a channel (`want_reply` set).
pub async fn request_subsystem(channel: &Channel<Msg>, name: &str) -> Result<(), Error> {
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> {
channel.wait().await
}
/// Awaits the channel's exit status — drains interim messages until
/// [`ChannelMsg::ExitStatus`] (the wave-3 derivation contract's
/// observation shape) or the channel's end, which is `None`.
pub async fn await_exit_status(channel: &mut Channel<Msg>) -> Option<u32> {
loop {
match next_message(channel).await {
Some(ChannelMsg::ExitStatus { exit_status }) => return Some(exit_status),
Some(_) => continue,
None => return None,
}
}
}
/// Awaits the channel's close — drains interim messages until
/// [`ChannelMsg::Close`] (observed: `true`) or the channel's end
/// (`false`).
pub async fn await_close(channel: &mut Channel<Msg>) -> bool {
loop {
match next_message(channel).await {
Some(ChannelMsg::Close) => return true,
Some(_) => continue,
None => return false,
}
}
}