Files
alkssh/tests/client_dial.rs
T
glm-5.3-flash 0d3fd48f5d feat(client): land the dial surface — the authenticated connection handle + the fail-closed client handler skeleton (w4-dial)
The client direction's substrate (client.md §The dial surface), the
mirror of w2-server-mount: dial(DialConfig) runs the fail-closed
four-step sequence — decrypt-at-dial key load (every KeySource
posture a distinct no-dial outcome), the bounded russh client Config
from Limits (keepalive 15 s / 3, the wall clock always Some, window/
packet/buffer verbatim), the injected host-key policy at
check_server_key (RejectAll connects to nothing; UnknownKey maps to
DialError::HostKeyRejected — its only producer), and exactly one
publickey-only auth attempt — and returns the authenticated
SshConnection handle everything else in the wave rides.

- DialConfig amendment (the sanctioned course-correction, recorded):
  the required `username` field — the USERAUTH_REQUEST's user string;
  no crate default, never identity, never logged (ADR-004 decision 5).
- The client Handler skeleton (src/client_handler.rs): every
  server_channel_open_* hook overridden explicit-over-inherited with
  a clean AdministrativelyProhibited reject (never russh's
  accept-and-drop default, ADR-003 §4); should_accept_unknown_
  server_channel the explicit false; decline events on the crate
  vocabulary (no-acceptor / never-feature reasons).
- The in-process fake sshd (tests/common/sshd_double.rs): the
  client-direction contract vehicle — a plain russh server on a real
  loopback TCP listener, accept-set identity resolution, the auth_
  succeeded handle carrier for the server-initiated-open rows, the
  per-session outcome channel for the clean-close rows.
- tests/client_dial.rs: 10 contract rows — dial success, the five
  key-source no-dial postures, RejectAll / pin-mismatch / case-2
  policy rows, refused auth, the malformed-address dial-time refusal,
  the bounded-config observable, the six-hook decline wire rows with
  decline events, and the smoke: alkssh-client ↔ alkssh-server over a
  real TCP loopback (real-sshd dial stays wave-5 e2e).

Verification: cargo test + per-feature + --all-features (14 binaries,
all green), cargo clippy ×2 (-D warnings), cargo fmt --check,
cargo doc --no-deps — all clean. taskgraph validate: 35 tasks valid.
2026-10-29 14:07:15 +00:00

475 lines
17 KiB
Rust

//! The dial surface's contract rows (`w4-dial`) — driven through the
//! in-process fake `sshd` ([`sshd_double`](common::sshd_double), the
//! client-direction vehicle) plus the crate-to-crate smoke against
//! the alkssh server direction. The row/stamp discipline
//! (`tests/common/mod.rs`'s doc) applies verbatim.
mod common;
use std::sync::Arc;
use std::time::Duration;
use alkssh::{
DialConfig, DialError, HostKeyPolicy, KeySource, KeySourceError, Limits, RejectAll,
ServeConfig, SshAdapter, VaultKeySource, dial, fingerprint, russh_client_config,
};
use common::{
MemHostKeyPolicy, MemHostKeySource, MemIdentityProvider, TestVault, event_capture, fixture,
identity, sshd_double,
};
use russh::{ChannelOpenFailure, Error};
use sshd_double::SshdDouble;
/// A vault-backed key source over the alpha fixture — the standard
/// custody posture (the fixture PEM sealed, decrypt-at-dial behind
/// the production source).
fn alpha_key_source() -> Arc<dyn KeySource> {
let vault = TestVault::unlocked();
let blob = vault.seal(fixture("alpha"));
Arc::new(VaultKeySource::new(vault.handle(), Some(blob)))
}
/// The dial-target address of a new double; one fake `sshd` per row
/// (the vehicle's shape decision).
async fn spawn_double() -> (SshdDouble, sshd_double::SshdAccepts) {
sshd_double::spawn(vec![fixture("bravo").private_key()], true).await
}
fn port_of(double: &SshdDouble) -> u16 {
double
.address
.rsplit_once(':')
.and_then(|(_, port)| port.parse().ok())
.expect("the double's address carries its port")
}
/// The ADR-005 failure postures, one vault-backed source each.
/// `127.0.0.1:9` is deliberately unbound in every no-dial row's dial
/// (see the row's doc).
fn postured_key_source(posture: &'static str) -> (Arc<dyn KeySource>, KeySourceError) {
let vault = TestVault::unlocked();
let alpha = fixture("alpha");
match posture {
"locked" => {
let blob = vault.seal(alpha);
vault.lock();
(
Arc::new(VaultKeySource::new(vault.handle(), Some(blob))),
KeySourceError::Locked,
)
}
"absent" => (
Arc::new(VaultKeySource::new(vault.handle(), None)),
KeySourceError::Absent,
),
"decryption" => {
let mut blob = vault.seal(alpha);
blob.data = "not the ciphertext".into();
(
Arc::new(VaultKeySource::new(vault.handle(), Some(blob))),
KeySourceError::Decryption(String::new()),
)
}
"unknown-version" => {
let mut blob = vault.seal(alpha);
blob.key_version = 0;
(
Arc::new(VaultKeySource::new(vault.handle(), Some(blob))),
KeySourceError::UnknownKeyVersion(String::new()),
)
}
"decode" => {
let blob = vault.encrypt("this is not a key");
(
Arc::new(VaultKeySource::new(vault.handle(), Some(blob))),
KeySourceError::Decode(String::new()),
)
}
other => panic!("no posture named {other}"),
}
}
/// - *Contract stamp:* the dial surface's success row: decrypt-at-dial
/// → the policy accept → the single publickey attempt — an
/// authenticated connection, the dial data carried on its surface,
/// and the clean close the peer observes.
#[tokio::test]
async fn dial_success_lands_an_authenticated_connection() {
let (mut double, accepts) = spawn_double().await;
accepts.register(fixture("alpha").fingerprint);
let policy = Arc::new(MemHostKeyPolicy::new());
policy.pin("127.0.0.1", fixture("bravo").fingerprint);
let config = DialConfig::new(
double.address.clone(),
"runner",
alpha_key_source(),
Arc::clone(&policy) as Arc<dyn HostKeyPolicy>,
);
let connection = dial(config)
.await
.expect("the dial succeeds through the fake sshd");
assert_eq!(connection.address(), double.address);
assert_eq!(connection.username(), "runner");
assert_eq!(connection.limits(), &Limits::default());
assert!(!connection.is_closed(), "the connection is live");
let decisions = policy.decisions();
assert_eq!(decisions.len(), 1, "one policy decision rode the dial");
assert!(decisions[0].accepted, "the pinned pair accepted");
assert_eq!(decisions[0].presented.host, "127.0.0.1");
assert_eq!(decisions[0].presented.port, port_of(&double));
assert_eq!(
decisions[0].presented.fingerprint,
fixture("bravo").fingerprint,
"the presented fingerprint is the crate's single fingerprint path value"
);
connection.close().await;
let outcome = double.next_session_outcome().await;
assert!(
matches!(outcome, Some(Ok(()))),
"the clean close is the double's clean session end, got {outcome:?}"
);
double.shutdown();
}
/// `127.0.0.1:9` is deliberately unbound in the no-dial rows: a dial
/// that reached the socket would fail with a wire class — only a
/// refused key load can produce the key-source classes here.
///
/// - *Contract stamp:* ADR-005 decision 2 (decrypt-at-*dial*) +
/// decision 4 (every failure mode fails closed, distinct shapes):
/// every [`KeySourceError`] class is a no-dial outcome, each variant
/// preserved through [`DialError::KeySource`].
#[tokio::test]
async fn every_key_source_posture_is_a_no_dial_outcome() {
for posture in [
"locked",
"absent",
"decryption",
"unknown-version",
"decode",
] {
let (source, expected) = postured_key_source(posture);
let config = DialConfig::new("127.0.0.1:9", "runner", source, Arc::new(RejectAll));
let err = dial(config)
.await
.expect_err("a refused key source is a no-dial outcome");
assert!(
matches!(err, DialError::KeySource(ref e) if std::mem::discriminant(e) == std::mem::discriminant(&expected)),
"posture {posture}: expected {expected:?} class, got {err:?}"
);
}
}
/// - *Contract stamp:* OQ-05 resolution (`host_key.rs`): the crate's
/// built-in default policy connects to nothing — the dial refuses
/// as the decline class (`DialError::HostKeyRejected`), never a
/// wire crash class, never a fallback.
#[tokio::test]
async fn the_crate_default_policy_connects_to_nothing() {
let (double, _accepts) = spawn_double().await;
let config = DialConfig::new(
double.address.clone(),
"runner",
alpha_key_source(),
Arc::new(RejectAll),
);
let err = dial(config)
.await
.expect_err("the default policy declines every presented key");
assert!(
matches!(err, DialError::HostKeyRejected),
"expected HostKeyRejected, got {err:?}"
);
double.shutdown();
}
/// - *Contract stamp:* OQ-05 resolution, case 1 (known beforehand):
/// an assembly pin **hard-declines on mismatch** — the dial aborts;
/// the policy's own log carries the context (the presented key's
/// real fingerprint rode the policy; the crate never second-guesses).
#[tokio::test]
async fn pin_mismatch_hard_declines() {
let (double, _accepts) = spawn_double().await;
let policy = Arc::new(MemHostKeyPolicy::new());
policy.pin("127.0.0.1", fixture("charlie").fingerprint);
let config = DialConfig::new(
double.address.clone(),
"runner",
alpha_key_source(),
Arc::clone(&policy) as Arc<dyn HostKeyPolicy>,
);
let err = dial(config)
.await
.expect_err("a mismatched pin is a hard decline");
assert!(matches!(err, DialError::HostKeyRejected), "got {err:?}");
let decisions = policy.decisions();
assert_eq!(decisions.len(), 1);
assert!(!decisions[0].accepted);
assert_eq!(
decisions[0].presented.fingerprint,
fixture("bravo").fingerprint,
"the presented key (not the pin) is what the policy saw"
);
double.shutdown();
}
/// - *Contract stamp:* OQ-05 resolution, case 2 (unknown beforehand):
/// a permissive assembly policy decides — the dial proceeds; the
/// crate ships no accept-all of its own (the permissive posture
/// was this row's explicit choice, `MemHostKeyPolicy::
/// with_default(true)`).
#[tokio::test]
async fn case2_policy_decides_and_the_dial_proceeds() {
let (mut double, accepts) = spawn_double().await;
accepts.register(fixture("alpha").fingerprint);
let config = DialConfig::new(
double.address.clone(),
"runner",
alpha_key_source(),
Arc::new(MemHostKeyPolicy::with_default(true)),
);
let connection = dial(config)
.await
.expect("the explicit permissive policy accepts");
assert!(!connection.is_closed());
connection.close().await;
let outcome = double.next_session_outcome().await;
assert!(matches!(outcome, Some(Ok(()))), "clean session end");
double.shutdown();
}
/// - *Contract stamp:* the publickey-only family contract: the
/// remote end refusing the single attempt is the dial's
/// [`DialError::AuthRejected`] — no retry, no fallback method, the
/// attempt closed behind the refusal.
#[tokio::test]
async fn refused_authentication_is_the_dials_no_connection_outcome() {
let (double, _accepts) = spawn_double().await;
let config = DialConfig::new(
double.address.clone(),
"runner",
alpha_key_source(),
Arc::new(MemHostKeyPolicy::with_default(true)),
);
let err = dial(config)
.await
.expect_err("the unregistered key is refused by the remote end");
assert!(matches!(err, DialError::AuthRejected), "got {err:?}");
double.shutdown();
}
/// - *Contract stamp:* the no-construction-validation contract, the
/// dial-time side: a malformed address refuses *at the dial* (the
/// config carries no validation; the wire is never dialed).
#[tokio::test]
async fn malformed_address_refuses_at_the_dial() {
let config = DialConfig::new(
"runner.example",
"runner",
alpha_key_source(),
Arc::new(MemHostKeyPolicy::with_default(true)),
);
let err = dial(config).await.expect_err("refuses");
assert!(
matches!(err, DialError::Address(_)),
"expected Address, got {err:?}"
);
}
/// - *Contract stamp:* the bounded-config wiring (the task's
/// acceptance): the dial's russh `Config` carries the
/// [`Limits`]-mapped knobs — the keepalive 15 s / 3 posture, the
/// always-set wall clock, the window/packet/buffer knobs verbatim —
/// observed from outside the crate through the doc-hidden mapping.
#[test]
fn the_bounded_config_wiring_is_observable() {
let config = russh_client_config(&Limits::default());
assert_eq!(config.keepalive_interval, Some(Duration::from_secs(15)));
assert_eq!(config.keepalive_max, 3);
assert_eq!(config.inactivity_timeout, Some(Duration::from_secs(600)));
assert_eq!(config.window_size, 2_097_152);
assert_eq!(config.maximum_packet_size, 32_768);
assert_eq!(config.channel_buffer_size, 100);
}
/// - *Contract stamp:* ADR-003 §4 (the client-side fail-closed
/// acceptor): the dialed client's skeleton declines **every**
/// server-initiated open on the wire — the double's initiator
/// observes each open as an unconfirmed refusal, never russh's
/// accept-and-drop default; the never-feature rows (x11, agent)
/// decline too; the declines consulted no host-key policy; and the
/// connection lives through all of it (the clean close is the
/// double's `Ok` session end).
///
/// (`should_accept_unknown_server_channel` has no wire row: russh's
/// server surface exposes no initiator for an unknown type and the
/// `ChannelId` is deliberately unconstructible outside the library
/// — the skeleton's explicit `false` is code-pinned, not
/// row-pinned; recorded in the task.)
#[tokio::test]
async fn server_initiated_opens_decline_cleanly_on_the_wire() {
event_capture::install();
let (mut double, accepts) = spawn_double().await;
accepts.register(fixture("alpha").fingerprint);
let policy = Arc::new(MemHostKeyPolicy::new());
policy.pin("127.0.0.1", fixture("bravo").fingerprint);
let config = DialConfig::new(
double.address.clone(),
"runner",
alpha_key_source(),
Arc::clone(&policy) as Arc<dyn HostKeyPolicy>,
);
let connection = dial(config).await.expect("the dial succeeds");
let server = double.session_handle().await;
for outcome in [
server.channel_open_session().await,
server
.channel_open_direct_tcpip("hub.example", 4096, "originator", 0)
.await,
server
.channel_open_forwarded_tcpip("hub.example", 4096, "originator", 0)
.await,
server
.channel_open_forwarded_streamlocal("/run/hub.sock")
.await,
server.channel_open_x11("originator", 0).await,
server.channel_open_agent().await,
] {
match outcome {
Err(Error::ChannelOpenFailure(reason)) => {
assert_eq!(
reason,
ChannelOpenFailure::AdministrativelyProhibited,
"the skeleton's clean decline reason"
);
}
other => panic!("every server-initiated open declines cleanly, got {other:?}"),
}
}
assert_eq!(
policy.decisions().len(),
1,
"the declines consulted no host-key policy — one decision, the dial's"
);
let forwarded = event_capture::await_event(&[
("row", "forwarded-tcpip"),
("decline_class", "open"),
("reason", "no-acceptor"),
])
.await;
assert_eq!(
forwarded.field("message"),
Some("decline: channel open rejected")
);
assert!(forwarded.field("channel_id").is_some());
assert_eq!(forwarded.level, "DEBUG");
assert_eq!(
forwarded.field("feature_build"),
Some(event_capture::expected_build())
);
let never = event_capture::await_event(&[
("row", "x11"),
("decline_class", "open"),
("reason", "never-feature"),
])
.await;
assert_eq!(
never.field("message"),
Some("decline: channel open rejected")
);
connection.close().await;
let outcome = double.next_session_outcome().await;
assert!(
matches!(outcome, Some(Ok(()))),
"the connection stayed usable through the declines: the clean close is a clean session end"
);
double.shutdown();
}
/// The smoke posture (`w4-dial`'s decided + recorded item): the
/// alkssh client ↔ alkssh server in-process loopback over a real TCP
/// socket — the cheap real-stack smoke (both russh stacks, the full
/// auth bridge server-side, the full custody + policy chain
/// client-side). A real-`sshd` dial is wave-5 e2e territory.
///
/// - *Contract stamp:* the task's smoke item; `client.md` §The dial
/// surface (the family-runner end: the dial's fingerprint resolves
/// through the injected `IdentityProvider` server-side).
#[tokio::test]
async fn smoke_alkssh_client_dials_the_alkssh_server_direction() {
let provider = Arc::new(MemIdentityProvider::new());
provider.register(
&fingerprint(&fixture("alpha").public_key()),
identity("smoke-runner", &["relay:connect"]),
);
let serve = ServeConfig::new(
provider,
Arc::new(MemHostKeySource::from_fixtures(&[fixture("bravo")])),
);
let adapter = SshAdapter::new(serve).await.expect("the server constructs");
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
.await
.expect("the smoke's loopback listener binds");
let addr = listener.local_addr().expect("the smoke has an address");
let (outcome_tx, mut outcome_rx) = tokio::sync::mpsc::unbounded_channel();
tokio::spawn(async move {
loop {
let (socket, _) = match listener.accept().await {
Ok(accepted) => accepted,
Err(_) => break,
};
// The smoke has exactly one connection; the handle call
// runs it to completion inline (the accept loop makes no
// next accept until the session ends).
let _ = outcome_tx.send(adapter.handle(socket).await);
}
});
let policy = Arc::new(MemHostKeyPolicy::new());
policy.pin("127.0.0.1", fixture("bravo").fingerprint);
let config = DialConfig::new(
addr.to_string(),
"runner",
alpha_key_source(),
Arc::clone(&policy) as Arc<dyn HostKeyPolicy>,
);
let connection = dial(config)
.await
.expect("the alkssh client connects to the alkssh server");
assert_eq!(policy.decisions().len(), 1);
assert_eq!(
policy.decisions()[0].presented.fingerprint,
fixture("bravo").fingerprint
);
assert!(!connection.is_closed());
connection.close().await;
let deadline = tokio::time::Instant::now() + Duration::from_secs(10);
let outcome = tokio::time::timeout_at(deadline, outcome_rx.recv()).await;
match outcome {
Ok(Some(Ok(()))) => {}
Ok(Some(Err(err))) => panic!("the smoke's server session must end clean, got {err:?}"),
Ok(None) | Err(_) => panic!("the smoke's server session never ended"),
}
}