Files
alkssh/tests/common/mod.rs
T
glm-5.3-flash 3d0253d981 feat(tunnel): land the -R listen flow + the forwarded-tcpip data leg (w3-tunnel-listen)
The tunnel branch's other half is served for real against published
alktunnels 0.4.1 / alkcall 0.10.1, with POC-3 §C.4 as the template:

- tcpip_forward/streamlocal_forward (+ both cancel forms) map the
  vanilla listen request family-ward through TunnelListener::open on
  the held ChannelClient (bounded inline dial — the global request's
  reply + the &mut u32 bound-port out-param are the hook's); the
  wire bind endpoint rides as the resource string verbatim (OpenSSH
  client-side normalization parity). A reply without the minted
  listen token/bound report declines and reaps its fabric listener.
- the door serves the fabric's inbound tunnel/forwarded opens: the
  serve config gains TunnelListenSeam (the seam-config second
  course-correction — the shared ServingConfig registry the assembly
  wired at from_connection_with_serving); alkssh registers the
  forwarded openable (ChannelCore over a clone of the held client's
  manager) at SshAdapter::new — a failed registration is a failed
  construction (AdapterError::ListenRegistration).
- the forwarded-tcpip data leg: token correlation through the shared
  ListenRouter → the in-crate per-session channel cap at the accept
  surface (the acceptor vocabulary) → Handle::
  channel_open_forwarded_tcpip streaming the listen request's own
  args + the actually-bound port (sshd parity) with
  TunnelForwardedParams.peer as the originator → pump_bidi(ssh,
  fabric). Correlation-miss → teardown-at-birth, never a half-open
  SSH channel. Unix arrivals ride forwarded-streamlocal@openssh.com.
- bounded bookkeeping: the per-session listener map packet-loop-only
  with a dedicated max_concurrent_channels-ceiling counter; the
  route map bounded by construction (mints at accepted listens,
  removed at cancels + session teardown — the handler's Drop).
- the harness grew the client-side accept hook
  (server_channel_open_forwarded_tcpip/_streamlocal,
  acceptor-driven) + a hand-rolled tracing subscriber; 18 contract
  rows + both real-tool smokes (ssh -L, ssh -R + curl) green in
  tests/tunnel_mapping.rs; the wave-2 stub listen rows migrated per
  build (unfeatured / seam-unconfigured).

Verified: cargo test across default/git/session/sftp/tunnel and all
combos + --all-features; clippy -D warnings (default, all-features,
combos); cargo fmt --check; cargo doc --no-deps clean;
taskgraph validate (26 tasks).
2026-10-29 04:24:50 +00:00

477 lines
18 KiB
Rust

//! alkssh's wave-1 test doubles — the in-memory seams every later
//! wave's contract tests run against (the alkgit `tests/common/`
//! convention, adapted): an in-memory `IdentityProvider`
//! ([`MemIdentityProvider`]), the in-memory vault seam ([`TestVault`]),
//! an in-memory `HostKeyPolicy` ([`MemHostKeyPolicy`]), and the
//! ed25519 test-key fixtures ([`FIXTURE_KEYS`]).
//!
//! **Location decision (recorded, the OQ-03 freeze-inventory
//! discipline):** the doubles live *only* here — a shared module under
//! `tests/` reached with `mod common;` — and never in the public API:
//! no re-export from `lib.rs`, no feature gate, nothing doubles-related
//! reachable from the crate root. The doubles' shapes are test
//! surface, test-versioned freely; the only `src/`-side consumers are
//! `#[cfg(test)]` modules reading the fixture *files* through
//! `include_str!`, never a public export.
//!
//! **Vault double decision (recorded, flagged for the wave-1 gate):**
//! alkvault 0.1 ships an in-memory construction —
//! `VaultServiceHandle::new()` is an in-process vault that
//! `unlock_new` arms in-memory. The double is therefore a *wrapper*
//! ([`TestVault`]) around that construction, not a local re-impl
//! behind the source trait: the same decrypt error taxonomy, GCM
//! integrity behavior, and rotation semantics the production source
//! seam sees are exercised verbatim, and the POC-2 §3 failure postures
//! (locked / absent / tampered / wrong seed) ride the real seams.
//!
//! **Fixture decision (recorded):** committed static OpenSSH-PEM test
//! keys, minted by `ssh-keygen` at vector-generation time (the
//! independent issuer), never generated at test time — determinism and
//! no tooling dependency. The committed private halves are throwaway
//! fixtures, not secrets: minted for this suite only, never registered
//! anywhere real, never deployed. Each key's fingerprint is recorded
//! byte-verbatim from `ssh-keygen -lf` in
//! `tests/vectors/fingerprints.txt`; [`FIXTURE_KEYS`] carries the
//! recorded values and every fingerprint comparison in the suite
//! asserts parity against them.
//!
//! Each test binary's `mod common;` compiles the whole shared module
//! and no single binary exercises every double — dead_code is allowed
//! module-wide so one binary's suite never fails the next's.
//!
//! The row/stamp discipline (adapted from the sibling crates): test
//! functions are *rows* — the name states the property, not the
//! caller — and each carries a doc-comment list item beginning
//! **`Contract stamp:`** citing the pinning spec §/ADR § (never a bare
//! wave or date). The marker is greppable:
//! `grep -rn "Contract stamp:" tests/` is the inventory.
#![allow(dead_code)]
/// The loopback harness — *the* contract-test vehicle (its own
/// location/transport decisions live in its module docs:
/// `tests/common/harness.rs`).
pub mod harness;
/// The git-seam doubles (`w3-git-mapping`, `git`-gated — alkssh's
/// hand-rolled minimal alkgit quartet per `alkgit/src/backend.rs`'s
/// noop shapes; the module's own doc records the decisions:
/// `tests/common/git_doubles.rs`).
#[cfg(feature = "git")]
pub mod git_doubles;
/// The session-seam doubles (`w3-tty-session`, `session`-gated — the
/// hand-rolled TtyBackend shim + ownership witness; the module's own
/// doc records the decisions: `tests/common/tty_doubles.rs`).
#[cfg(feature = "session")]
pub mod tty_doubles;
use std::collections::HashMap;
use alkcall::core::auth::{AuthToken, Identity, IdentityProvider};
use alkvault::{EncryptedData, VaultServiceHandle};
use async_trait::async_trait;
use russh::keys::{PrivateKey, PublicKey, decode_secret_key, parse_public_key_base64};
use alkssh::{HostKeyPolicy, HostKeySource, KeySourceError, PresentedHostKey};
/// One committed ed25519 test-key fixture: the private OpenSSH-PEM
/// (`tests/vectors/<name>.key`), the public one-line blob
/// (`tests/vectors/<name>.pub`), and the fingerprint recorded
/// byte-verbatim from `ssh-keygen -lf` (`tests/vectors/fingerprints.txt`).
///
/// The `pem`/`blob`/`fingerprint` values are committed constants — throwaway
/// fixture material, never registered or deployed anywhere real.
#[derive(Debug)]
pub struct FixtureKey {
/// The fixture's name (`alpha` … `delta`).
pub name: &'static str,
/// The OpenSSH private-key PEM, included verbatim from the
/// committed fixture file.
pub pem: &'static str,
/// The public key's base64 blob — the OpenSSH one-line encoding,
/// `ssh-ed25519 <blob>` stripped of the key-type prefix and comment.
pub blob: &'static str,
/// The fingerprint recorded from `ssh-keygen -lf` — the
/// independent-issuer value every comparison in the suite asserts
/// parity against (ADR-004 decision 1's contract invariant).
pub fingerprint: &'static str,
}
impl FixtureKey {
/// The decoded public key (parity input; ADR-004 decision 1).
pub fn public_key(&self) -> PublicKey {
parse_public_key_base64(self.blob).expect("committed fixture blob parses")
}
/// The decoded private key — `decode_secret_key(pem, None)`, the
/// ADR-005 decision 2 conversion (the fixtures' inner format is
/// exactly the at-rest contract: unencrypted OpenSSH PEM).
pub fn private_key(&self) -> PrivateKey {
decode_secret_key(self.pem, None).expect("committed fixture PEM decodes")
}
}
/// The wave-1 fixture set: four minted ed25519 keys. `alpha` is the
/// canonical client-dial fixture; `bravo`/`charlie` are the host-key
/// pair (rotation/multi-key rows); `delta` is the independent extra
/// (unregistered-peer, mismatch, and wrong-key rows).
pub const FIXTURE_KEYS: &[FixtureKey] = &[
FixtureKey {
name: "alpha",
pem: include_str!("../vectors/alpha.key"),
blob: "AAAAC3NzaC1lZDI1NTE5AAAAIM28sP5UAl3npJJ8trZgPt2kE+Af6477/Rx6J2VbM14z",
fingerprint: "SHA256:YyaiL42Xl8tcRRUhNCiqQBsiiRWVHHDR9hpXC45EQ6A",
},
FixtureKey {
name: "bravo",
pem: include_str!("../vectors/bravo.key"),
blob: "AAAAC3NzaC1lZDI1NTE5AAAAIMvu9S+hH9Dug7fdiHLeW+B9kee+i495jPAdBH8YaOvQ",
fingerprint: "SHA256:yQf/w2wYBoZ9adDlRGbcNh7AHE3mbqWRCqoGHra1ib0",
},
FixtureKey {
name: "charlie",
pem: include_str!("../vectors/charlie.key"),
blob: "AAAAC3NzaC1lZDI1NTE5AAAAIKL8zAzLkR0jKFY2l+QrBjC3Cv4Q3OBUUCX7rQV1jXQe",
fingerprint: "SHA256:b5jdwosbZUx5nsY5zfr/ZCt+4sJYehVBq7Xl/wstJ9U",
},
FixtureKey {
name: "delta",
pem: include_str!("../vectors/delta.key"),
blob: "AAAAC3NzaC1lZDI1NTE5AAAAIIcMK+njeiGqh8THEBPU9MTYrSJ3WHvxv3ZH11JTS/SU",
fingerprint: "SHA256:4MiQ0KGoTEaklHBTlxPDuQkeu0FTyqEbne10K6Z8CKs",
},
];
/// Looks a fixture up by name — `fixture("alpha")` is the canonical
/// client-dial key.
pub fn fixture(name: &str) -> &'static FixtureKey {
FIXTURE_KEYS
.iter()
.find(|k| k.name == name)
.unwrap_or_else(|| panic!("no fixture named {name}"))
}
/// Builds an [`Identity`] for registration (`id` + scopes; the
/// `resources` map is empty — the wire layer only needs the resolved
/// identity, richer ACL shapes are the assembly's).
pub fn identity(id: &str, scopes: &[&str]) -> Identity {
Identity {
id: id.into(),
scopes: scopes.iter().map(|s| s.to_string()).collect(),
resources: HashMap::new(),
}
}
/// The in-memory [`IdentityProvider`] — registered fingerprints →
/// [`Identity`] resolutions over alkcall's trait verbatim.
///
/// The double the wave-2 auth bridge and dispatch tests resolve
/// against, and the analog of serving `Arc<dyn IdentityProvider>` in
/// [`alkssh::ServeConfig`]: the fingerprint → identity path is ADR-004
/// decision 1's only resolution shape.
pub struct MemIdentityProvider {
identities: parking_lot::RwLock<HashMap<String, Identity>>,
}
impl MemIdentityProvider {
/// An empty registry — nothing resolves until registered
/// (fail-closed: unregistered fingerprints decline).
pub fn new() -> Self {
Self {
identities: parking_lot::RwLock::new(HashMap::new()),
}
}
/// Registers (or replaces) one fingerprint → identity mapping.
pub fn register(&self, fingerprint: &str, identity: Identity) {
self.identities
.write()
.insert(fingerprint.to_string(), identity);
}
/// Removes a registration — the post-resolution-revoke shape.
pub fn revoke(&self, fingerprint: &str) {
self.identities.write().remove(fingerprint);
}
/// The registered fingerprints — inspection for test assertions.
pub fn registered_fingerprints(&self) -> Vec<String> {
let mut keys = self
.identities
.read()
.keys()
.cloned()
.collect::<Vec<String>>();
keys.sort();
keys
}
}
impl Default for MemIdentityProvider {
fn default() -> Self {
Self::new()
}
}
impl IdentityProvider for MemIdentityProvider {
/// The fingerprint path — ADR-004 decision 1's single resolution
/// shape: exact string equality against registered values.
fn resolve_from_fingerprint(&self, fingerprint: &str) -> Option<Identity> {
self.identities.read().get(fingerprint).cloned()
}
/// The token path resolves nothing in the SSH direction —
/// ADR-004's dispositions are fingerprint-only on this surface
/// (`auth-bridge.md`); the double never invents a token shape.
fn resolve_from_token(&self, _: &AuthToken) -> Option<Identity> {
None
}
}
/// The in-memory vault seam — a wrapper over alkvault 0.1's own
/// in-memory construction ([`VaultServiceHandle`], unlocked at
/// construction via `unlock_new`).
///
/// The locked/absent/tampered/mismatch failure postures (POC-2 §3) are
/// exercised *through the real production seams* the source traits
/// call, against this handle — the double exists so tests never touch
/// vault lifecycle decisions themselves: [`TestVault::unlocked`] arms
/// it, `lock` closes it, and the sources keep refusing, never
/// retrying (the crate never unlocks — ADR-005's separation).
#[derive(Clone)]
pub struct TestVault {
handle: VaultServiceHandle,
}
impl TestVault {
/// A freshly armed in-memory vault — unlocked, ready to encrypt.
pub fn unlocked() -> Self {
let handle = VaultServiceHandle::new();
handle
.unlock_new(24)
.expect("in-memory vault arms unlocked");
Self { handle }
}
/// The vault handle the production sources consume — clones share
/// the same vault state (locking through one is visible to all).
pub fn handle(&self) -> VaultServiceHandle {
self.handle.clone()
}
/// Seals one fixture key's PEM into an at-rest record — the
/// ADR-005 inner-format contract (unencrypted OpenSSH PEM in an
/// `EncryptedData` record).
pub fn seal(&self, key: &FixtureKey) -> EncryptedData {
self.encrypt(key.pem)
}
/// Encrypts an arbitrary plaintext (the non-key and tamper rows
/// need it; ADR-005 decision 4's decode-refusal shape).
pub fn encrypt(&self, plaintext: &str) -> EncryptedData {
self.handle
.encrypt(plaintext, alkvault::CURRENT_KEY_VERSION)
.expect("in-memory vault encrypts the plaintext")
}
/// Locks the vault — the locked-vault posture (POC-2 §3, the
/// `KeySourceError::Locked` refusal class).
pub fn lock(&self) {
self.handle.lock();
}
/// The vault's lifecycle state, for posture assertions.
pub fn is_unlocked(&self) -> bool {
self.handle.is_unlocked()
}
}
/// The in-memory [`HostKeySource`] double — a static decrypted key set
/// (wave-2 addition): the wire-shaped rows construct a real
/// `SshAdapter`, whose construction wants a non-empty host-key set, and
/// the direct-hook rows want a source that is never loaded (the handler
/// construction loads nothing — host keys are the russh `Config`'s
/// business, built in [`alkssh::SshAdapter::new`]).
///
/// `absent()` is the empty-set shape — its load refuses with
/// [`KeySourceError::Absent`] (the fail-closed contract), so any row
/// that accidentally loads an absent double fails loudly.
pub struct MemHostKeySource {
keys: Vec<PrivateKey>,
}
impl MemHostKeySource {
/// The never-loaded / absent shape.
pub fn absent() -> Self {
Self { keys: Vec::new() }
}
/// A static decrypted set from the fixture keys — the wire rows'
/// host-key set (e.g. `["bravo"]`, the rotation-pair member).
pub fn from_fixtures(keys: &[&'static FixtureKey]) -> Self {
Self {
keys: keys.iter().map(|k| k.private_key()).collect(),
}
}
}
#[async_trait]
impl HostKeySource for MemHostKeySource {
async fn load(&self) -> Result<Vec<PrivateKey>, KeySourceError> {
if self.keys.is_empty() {
Err(KeySourceError::Absent)
} else {
Ok(self.keys.clone())
}
}
}
/// The in-memory [`HostKeyPolicy`] — a configurable verdict double with
/// an inspectable decision log, for the wave-4 dial tests (and the
/// case-1/case-2 rows of every host-key contract test).
///
/// Verdicts: a rules list of exact `(host, fingerprint)` matches —
/// last matching rule wins — falling back to a configurable
/// default verdict for unlisted pairs. The default of the
/// **default-constructed double is reject** (the crate's fail-closed
/// posture, `OQ-05`); permissive verdicts are the test's explicit
/// choice, mirroring "permissive policy is the assembly's to write".
/// Every decision is recorded with the full [`PresentedHostKey`]
/// context; the log is the inspectable record.
pub struct MemHostKeyPolicy {
default_verdict: bool,
rules: parking_lot::RwLock<Vec<Rule>>,
log: parking_lot::Mutex<Vec<Decision>>,
}
struct Rule {
host: String,
fingerprint: String,
accept: bool,
}
/// One recorded host-key decision — the presented key and the verdict
/// the policy returned.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Decision {
/// The presented key that was decided.
pub presented: PresentedHostKey,
/// The verdict: `true` = accept, `false` = decline.
pub accepted: bool,
}
impl MemHostKeyPolicy {
/// A decision double with the fail-closed default (reject
/// everything without a matching accept rule).
pub fn new() -> Self {
Self::with_default(false)
}
/// A decision double with an explicit default verdict — the
/// case-2 permissive posture (`accepting` doubles) is the test's
/// explicit choice, never a default.
pub fn with_default(default_verdict: bool) -> Self {
Self {
default_verdict,
rules: parking_lot::RwLock::new(Vec::new()),
log: parking_lot::Mutex::new(Vec::new()),
}
}
/// Adds a rule: exact `(host, fingerprint)` → verdict. Last match
/// wins, so later rules override earlier ones.
pub fn rule(&self, host: &str, fingerprint: &str, accept: bool) {
self.rules.write().push(Rule {
host: host.into(),
fingerprint: fingerprint.into(),
accept,
});
}
/// Pins an accept rule for one `(host, fingerprint)` — the case-1
/// trust-anchor shape (hard accept on exact match).
pub fn pin(&self, host: &str, fingerprint: &str) {
self.rule(host, fingerprint, true);
}
/// The decision log — every [`Decision`] in call order.
pub fn decisions(&self) -> Vec<Decision> {
self.log.lock().clone()
}
}
impl Default for MemHostKeyPolicy {
fn default() -> Self {
Self::new()
}
}
#[async_trait]
impl HostKeyPolicy for MemHostKeyPolicy {
async fn decide(&self, presented: &PresentedHostKey) -> bool {
let rules = self.rules.read();
let verdict = rules
.iter()
.rev()
.find(|r| r.host == presented.host && r.fingerprint == presented.fingerprint)
.map(|r| r.accept)
.unwrap_or(self.default_verdict);
drop(rules);
self.log.lock().push(Decision {
presented: presented.clone(),
accepted: verdict,
});
verdict
}
}
/// The leg-2 rows' tracing visibility (`w3-tunnel-listen`): a minimal
/// hand-rolled global [`tracing::Subscriber`] printing every event's
/// field pairs to stderr — the same hand-rolled convention the
/// crate-side vocabulary rows use (no `tracing-subscriber`
/// dependency; events fire from spawned tasks on both sides of the
/// fabric, so the global subscriber is the only capture shape that
/// works). Rows opt in: `trace_global()` once per row.
pub fn trace_global() {
use std::sync::atomic::{AtomicU64, Ordering};
struct TraceSubscriber(AtomicU64);
struct FieldPrinter(u64);
impl tracing::field::Visit for FieldPrinter {
fn record_debug(&mut self, field: &tracing::field::Field, value: &dyn std::fmt::Debug) {
eprintln!("trace[{}]: {} = {value:?}", self.0, field.name());
}
fn record_str(&mut self, field: &tracing::field::Field, value: &str) {
eprintln!("trace[{}]: {} = {value:?}", self.0, field.name());
}
}
impl tracing::Subscriber for TraceSubscriber {
fn enabled(&self, _metadata: &tracing::Metadata<'_>) -> bool {
true
}
fn new_span(&self, _span: &tracing::span::Attributes<'_>) -> tracing::Id {
tracing::Id::from_u64(1)
}
fn record(&self, _span: &tracing::Id, _values: &tracing::span::Record<'_>) {}
fn record_follows_from(&self, _span: &tracing::Id, _follows: &tracing::Id) {}
fn event(&self, event: &tracing::Event<'_>) {
event.record(&mut FieldPrinter(self.0.fetch_add(1, Ordering::Relaxed)));
}
fn enter(&self, _span: &tracing::Id) {}
fn exit(&self, _span: &tracing::Id) {}
fn clone_span(&self, _id: &tracing::Id) -> tracing::Id {
tracing::Id::from_u64(1)
}
}
let _ = tracing::subscriber::set_global_default(TraceSubscriber(AtomicU64::new(0)));
}