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).
806 lines
29 KiB
Rust
806 lines
29 KiB
Rust
//! The `w3-decline-tracing` wire rows — the event presence pin: every
|
|
//! decline class in `src/` (the open-level declines, the request-level
|
|
//! capability declines, the seam-stub serving declines with their
|
|
//! spawned-path lifecycle, and the auth dispositions) emits its
|
|
//! event through the vocabulary (`src/observe.rs`), driven over the
|
|
//! real wire through the loopback harness.
|
|
//!
|
|
//! **Test convention (recorded):** the family's only tracing-test
|
|
//! precedent is alktunnels' fmt-subscriber dev-dep, used for debug
|
|
//! *output* — there is no event-capture convention to adopt, so these
|
|
//! rows pin the convention: a hand-rolled minimal
|
|
//! [`tracing::Subscriber`] capturing events into a shared buffer,
|
|
//! installed once as the global default (the server side runs on
|
|
//! spawned tasks — a thread-local default cannot see them; a global
|
|
//! capture needs no extra dependency). The install is up front — the
|
|
//! mount helpers call [`capture`] before any wire exchange, so no
|
|
//! event is emitted below the not-yet-installed global. Rows assert
|
|
//! presence by field-matching (`row` + `decline_class` + `reason` +
|
|
//! build), and the vocabulary assertion checks each captured decline's exact
|
|
//! field set — the shape the three wave-3 mapping branches inherit.
|
|
//! The helper-level shapes (field order, the `warn` level of the
|
|
//! failure arm) are pinned in `src/observe.rs`'s own unit rows.
|
|
//!
|
|
//! Feature builds are compile-time, so the build-specific rows are
|
|
//! feature-gated to the build whose decline matrix they drive (the
|
|
//! `dispatch_core.rs` convention); the shared decline rows run in
|
|
//! every build.
|
|
|
|
#![allow(dead_code)]
|
|
|
|
mod common;
|
|
|
|
use std::sync::{Arc, Mutex, OnceLock};
|
|
use std::time::{Duration, Instant};
|
|
|
|
use common::harness::{
|
|
adapter, authenticate_publickey, await_close, drive, next_message, open_x11_channel,
|
|
tcpip_forward,
|
|
};
|
|
use common::{MemHostKeyPolicy, MemIdentityProvider, fixture, identity};
|
|
use russh::client::Msg;
|
|
use russh::{Channel, ChannelMsg};
|
|
|
|
use alkssh::Limits;
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// The capture subscriber — the recorded test convention (see module docs).
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[derive(Debug, Clone)]
|
|
struct Captured {
|
|
level: String,
|
|
fields: Vec<(String, String)>,
|
|
}
|
|
|
|
impl Captured {
|
|
fn field(&self, name: &str) -> Option<&str> {
|
|
self.fields
|
|
.iter()
|
|
.find(|(n, _)| n == name)
|
|
.map(|(_, v)| v.as_str())
|
|
}
|
|
|
|
fn has(&self, want: &[(&str, &str)]) -> bool {
|
|
want.iter()
|
|
.all(|(n, v)| self.field(n).is_some_and(|got| got == *v))
|
|
}
|
|
}
|
|
|
|
struct FieldVisitor(Vec<(String, String)>);
|
|
|
|
impl tracing::field::Visit for FieldVisitor {
|
|
fn record_str(&mut self, field: &tracing::field::Field, value: &str) {
|
|
self.0.push((field.name().to_owned(), value.to_owned()));
|
|
}
|
|
|
|
fn record_debug(&mut self, field: &tracing::field::Field, value: &dyn std::fmt::Debug) {
|
|
self.0.push((field.name().to_owned(), format!("{value:?}")));
|
|
}
|
|
}
|
|
|
|
#[derive(Default)]
|
|
struct GlobalCapture(Mutex<Vec<Captured>>);
|
|
|
|
impl tracing::Subscriber for GlobalCapture {
|
|
fn enabled(&self, _metadata: &tracing::Metadata<'_>) -> bool {
|
|
true
|
|
}
|
|
|
|
fn new_span(&self, _span: &tracing::span::Attributes<'_>) -> tracing::Id {
|
|
static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
|
|
tracing::Id::from_u64(NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed))
|
|
}
|
|
|
|
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<'_>) {
|
|
let mut visitor = FieldVisitor(Vec::new());
|
|
event.record(&mut visitor);
|
|
self.0.lock().unwrap().push(Captured {
|
|
level: event.metadata().level().to_string(),
|
|
fields: visitor.0,
|
|
});
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|
|
|
|
fn capture() -> &'static Arc<GlobalCapture> {
|
|
static CAPTURE: OnceLock<Arc<GlobalCapture>> = OnceLock::new();
|
|
CAPTURE.get_or_init(|| {
|
|
let capture = Arc::new(GlobalCapture::default());
|
|
tracing::subscriber::set_global_default(Arc::clone(&capture))
|
|
.expect("the observe suite installs the global capture exactly once");
|
|
capture
|
|
})
|
|
}
|
|
|
|
/// The shared capture snapshot — every test reads the whole buffer and
|
|
/// matches fields, so parallel rows never order against each other.
|
|
fn captured() -> Vec<Captured> {
|
|
capture().0.lock().unwrap().clone()
|
|
}
|
|
|
|
/// Waits until an event matching every field pair appears (the
|
|
/// spawned-task surfaces land their events asynchronously), then
|
|
/// returns it.
|
|
async fn await_event(want: &[(&str, &str)]) -> Captured {
|
|
let deadline = Instant::now() + Duration::from_secs(10);
|
|
loop {
|
|
if let Some(event) = captured().into_iter().find(|e| e.has(want)) {
|
|
return event;
|
|
}
|
|
if Instant::now() > deadline {
|
|
panic!("no tracing event matching {want:?} within the window");
|
|
}
|
|
tokio::time::sleep(Duration::from_millis(10)).await;
|
|
}
|
|
}
|
|
|
|
/// The expected `feature_build` value, derived from `cfg!` — the
|
|
/// vocabulary assertion checks every captured decline against it (the
|
|
/// same convention as `src/observe.rs`'s cfg-derived unit row).
|
|
fn expected_build() -> &'static str {
|
|
match (
|
|
cfg!(feature = "session"),
|
|
cfg!(feature = "git"),
|
|
cfg!(feature = "tunnel"),
|
|
cfg!(feature = "sftp"),
|
|
) {
|
|
(false, false, false, false) => "none",
|
|
(true, false, false, false) => "session",
|
|
(false, true, false, false) => "git",
|
|
(false, false, true, false) => "tunnel",
|
|
(false, false, false, true) => "sftp",
|
|
(true, true, false, false) => "session+git",
|
|
(true, false, true, false) => "session+tunnel",
|
|
(true, false, false, true) => "session+sftp",
|
|
(false, true, true, false) => "git+tunnel",
|
|
(false, true, false, true) => "git+sftp",
|
|
(false, false, true, true) => "tunnel+sftp",
|
|
(true, true, true, false) => "session+git+tunnel",
|
|
(true, true, false, true) => "session+git+sftp",
|
|
(true, false, true, true) => "session+tunnel+sftp",
|
|
(false, true, true, true) => "git+tunnel+sftp",
|
|
(true, true, true, true) => "session+git+tunnel+sftp",
|
|
}
|
|
}
|
|
|
|
/// The global-request rows — the no-channel surfaces (the event
|
|
/// vocabulary assertion needs to know the field set these carry).
|
|
fn is_global_row(row: Option<&str>) -> bool {
|
|
row.is_some_and(|row| {
|
|
matches!(
|
|
row,
|
|
"tcpip_forward"
|
|
| "cancel_tcpip_forward"
|
|
| "streamlocal_forward"
|
|
| "cancel_streamlocal_forward"
|
|
)
|
|
})
|
|
}
|
|
|
|
/// The vocabulary assertion: a captured decline carries the exact
|
|
/// field set (nothing more — the safety posture rides on this: no
|
|
/// dynamic extra fields exist), with the build/class/reason/row the
|
|
/// caller matched on.
|
|
fn assert_decline(event: &Captured, want: &[(&str, &str)]) {
|
|
assert!(
|
|
event.has(want),
|
|
"decline event missing the matched fields {want:?}: {event:?}"
|
|
);
|
|
assert_eq!(
|
|
event.field("feature_build"),
|
|
Some(expected_build()),
|
|
"every event is self-describing per build"
|
|
);
|
|
let class = event
|
|
.field("decline_class")
|
|
.expect("a decline event carries its class");
|
|
let row = event.field("row");
|
|
let mut expected: Vec<String> = ["feature_build", "row", "decline_class", "reason", "message"]
|
|
.iter()
|
|
.map(|n| n.to_string())
|
|
.collect();
|
|
let channel_carrying = class != "auth" && !is_global_row(row);
|
|
if channel_carrying {
|
|
assert!(
|
|
event.field("channel_id").is_some(),
|
|
"a channel decline carries the wire channel id: {event:?}"
|
|
);
|
|
expected.push("channel_id".to_owned());
|
|
} else {
|
|
assert!(
|
|
event.field("channel_id").is_none(),
|
|
"an auth/global decline has no channel id: {event:?}"
|
|
);
|
|
}
|
|
if row == Some("exec_request") {
|
|
expected.push("exec_route".to_owned());
|
|
assert!(
|
|
event.field("exec_route").is_some(),
|
|
"an exec-row decline carries the parse route: {event:?}"
|
|
);
|
|
}
|
|
let mut names: Vec<String> = event.fields.iter().map(|(n, _)| n.clone()).collect();
|
|
names.sort();
|
|
expected.sort();
|
|
assert_eq!(
|
|
names, expected,
|
|
"a decline carries exactly the vocabulary, nothing more (no dynamic fields)"
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Shared rows — run in every feature build.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// - *Contract stamp:* ADR-003 §5 (x11 is a never-feature — the open
|
|
/// declines in every build; the event pins the deciding context).
|
|
#[tokio::test]
|
|
async fn x11_open_decline_emits_the_never_feature_event() {
|
|
let session = authenticated("x11-open-row").await;
|
|
open_x11_channel(&session.client, "127.0.0.1", 0)
|
|
.await
|
|
.expect_err("x11 opens decline");
|
|
let event = await_event(&[
|
|
("row", "x11"),
|
|
("decline_class", "open"),
|
|
("reason", "never-feature"),
|
|
])
|
|
.await;
|
|
assert_decline(&event, &[("row", "x11"), ("reason", "never-feature")]);
|
|
session.unmount().await;
|
|
}
|
|
|
|
/// - *Contract stamp:* mapping.md §Global-request rows (the `-R`
|
|
/// listen form declines this build's flow — the event carries no
|
|
/// channel id: global requests have none). The reason is the
|
|
/// build's gate: `unfeatured` where the feature is off,
|
|
/// `seam-unconfigured` where the tunnel feature is on but no
|
|
/// fabric leg/listen seam is configured (the unset-declines
|
|
/// posture — a bare `authenticated()` session configures neither).
|
|
#[cfg(not(feature = "tunnel"))]
|
|
#[tokio::test]
|
|
async fn tcpip_forward_decline_emits_the_global_request_event() {
|
|
let session = authenticated("forward-row").await;
|
|
tcpip_forward(&session.client, "127.0.0.1", 8080)
|
|
.await
|
|
.expect_err("the listen request declines");
|
|
assert_decline(
|
|
&await_event(&[
|
|
("row", "tcpip_forward"),
|
|
("decline_class", "request"),
|
|
("reason", "unfeatured"),
|
|
])
|
|
.await,
|
|
&[("row", "tcpip_forward"), ("reason", "unfeatured")],
|
|
);
|
|
session.unmount().await;
|
|
}
|
|
|
|
/// - *Contract stamp:* mapping.md §Global-request rows + the
|
|
/// decline-matrix's config-driven notes (the `w3-tunnel-listen`
|
|
/// migrated stub row): a featured build whose fabric leg/listen
|
|
/// seam is unconfigured declines `seam-unconfigured` — the feature
|
|
/// alone configures nothing.
|
|
#[cfg(feature = "tunnel")]
|
|
#[tokio::test]
|
|
async fn tcpip_forward_decline_emits_the_seam_unconfigured_event() {
|
|
let session = authenticated("forward-row").await;
|
|
tcpip_forward(&session.client, "127.0.0.1", 8080)
|
|
.await
|
|
.expect_err("the unconfigured listen declines");
|
|
assert_decline(
|
|
&await_event(&[
|
|
("row", "tcpip_forward"),
|
|
("decline_class", "request"),
|
|
("reason", "seam-unconfigured"),
|
|
])
|
|
.await,
|
|
&[("row", "tcpip_forward"), ("reason", "seam-unconfigured")],
|
|
);
|
|
session.unmount().await;
|
|
}
|
|
|
|
/// - *Contract stamp:* ADR-004 (the bridge rejects fail-closed —
|
|
/// `None` resolution → reject disposition; the event pins the
|
|
/// decline without exposing who).
|
|
#[tokio::test]
|
|
async fn unresolved_fingerprint_auth_decline_emits_the_auth_event() {
|
|
let mut session = mounted().await;
|
|
let result =
|
|
authenticate_publickey(&mut session.client, "unregistered", fixture("charlie")).await;
|
|
assert!(!result.success(), "the unregistered fixture rejects");
|
|
let event = await_event(&[
|
|
("row", "auth_publickey"),
|
|
("decline_class", "auth"),
|
|
("reason", "fingerprint-unresolved"),
|
|
])
|
|
.await;
|
|
assert_decline(
|
|
&event,
|
|
&[
|
|
("row", "auth_publickey"),
|
|
("reason", "fingerprint-unresolved"),
|
|
],
|
|
);
|
|
session.unmount().await;
|
|
}
|
|
|
|
/// - *Contract stamp:* ADR-004 decision 3 (auth_none: explicit
|
|
/// family-contract decline).
|
|
#[tokio::test]
|
|
async fn auth_none_decline_emits_the_auth_event() {
|
|
let mut session = mounted().await;
|
|
let result = session
|
|
.client
|
|
.authenticate_none("observe-none")
|
|
.await
|
|
.expect("the auth exchange completes");
|
|
assert!(
|
|
!result.success(),
|
|
"auth_none rejects (the explicit disposition)"
|
|
);
|
|
let event = await_event(&[
|
|
("row", "auth_none"),
|
|
("decline_class", "auth"),
|
|
("reason", "method-unimplemented"),
|
|
])
|
|
.await;
|
|
assert_decline(
|
|
&event,
|
|
&[("row", "auth_none"), ("reason", "method-unimplemented")],
|
|
);
|
|
session.unmount().await;
|
|
}
|
|
|
|
/// - *Contract stamp:* auth.md §Dispositions (log exposure is
|
|
/// deployment policy — the crate's events are safe-by-default: no
|
|
/// username, secret, or fingerprint ever enters an event field or
|
|
/// message; `w3-decline-tracing`'s safety posture, pinned by
|
|
/// scanning every captured event across the whole binary's rows).
|
|
#[tokio::test]
|
|
async fn no_captured_event_ever_carries_a_username_or_fingerprint() {
|
|
let session = authenticated("safety-row").await;
|
|
open_x11_channel(&session.client, "127.0.0.1", 0)
|
|
.await
|
|
.expect_err("x11 declines");
|
|
session.unmount().await;
|
|
|
|
await_event(&[("row", "x11")]).await;
|
|
for event in captured() {
|
|
let message = event.field("message").unwrap_or("");
|
|
for username in [
|
|
"x11-open-row",
|
|
"forward-row",
|
|
"fp-row",
|
|
"none-row",
|
|
"safety-row",
|
|
"unregistered",
|
|
"observe-none",
|
|
] {
|
|
assert!(
|
|
!message.contains(username),
|
|
"no event message names the username, got {message:?}"
|
|
);
|
|
}
|
|
for (name, value) in &event.fields {
|
|
let lowered = name.to_lowercase();
|
|
assert!(
|
|
!lowered.contains("user"),
|
|
"no field ever names a username-like input: {name}"
|
|
);
|
|
assert!(
|
|
!lowered.contains("fingerprint"),
|
|
"the fingerprint is not an event field (auth.md: safe-by-default), got {name}"
|
|
);
|
|
assert!(
|
|
!value.contains("SHA256:"),
|
|
"no event value carries a fingerprint, got {value:?}"
|
|
);
|
|
for username in ["x11-open-row", "fp-row", "unregistered"] {
|
|
assert!(
|
|
!value.contains(username),
|
|
"no event value names the username, got {value:?}"
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Feature-build-specific rows (the dispatch_core.rs convention).
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[cfg(not(any(feature = "session", feature = "git", feature = "tunnel")))]
|
|
mod default_build {
|
|
use super::*;
|
|
use common::harness::open_session_channel;
|
|
|
|
/// - *Contract stamp:* mapping.md §Decline matrix (the none-build
|
|
/// row: everything declines — the event answers the booked
|
|
/// finding: the decline's deciding context is observable).
|
|
#[tokio::test]
|
|
async fn unfeatured_session_open_emits_the_open_event() {
|
|
let session = authenticated("none-build-row").await;
|
|
open_session_channel(&session.client)
|
|
.await
|
|
.expect_err("a default build declines session channels");
|
|
let event = await_event(&[
|
|
("row", "session"),
|
|
("decline_class", "open"),
|
|
("reason", "unfeatured"),
|
|
])
|
|
.await;
|
|
assert_decline(&event, &[("row", "session"), ("reason", "unfeatured")]);
|
|
session.unmount().await;
|
|
}
|
|
}
|
|
|
|
#[cfg(all(feature = "git", not(feature = "session"), not(feature = "tunnel")))]
|
|
mod git_build {
|
|
use super::*;
|
|
use common::harness::{
|
|
open_session_channel, request_agent_forward, request_env, request_exec, request_pty,
|
|
request_subsystem, request_x11,
|
|
};
|
|
|
|
/// - *Contract stamp:* ADR-006 decision 4 (spawn-and-return — the
|
|
/// serving path is spawned; the lifecycle events started /
|
|
/// decline / ended ride the spawned task, in wiring order). The
|
|
/// git row's decline in a seam-less build is config-driven
|
|
/// (`w3-git-mapping`: the unset-declines contract — the reason
|
|
/// names the seam, not the stub).
|
|
#[tokio::test]
|
|
async fn git_seam_stub_declines_with_the_full_serving_lifecycle() {
|
|
let session = authenticated("git-stub-row").await;
|
|
let mut channel = open_session_channel(&session.client)
|
|
.await
|
|
.expect("session channels open where a session-serving feature exists");
|
|
request_exec(&channel, "git-upload-pack '/owner/demo'")
|
|
.await
|
|
.expect("the exec request rides the wire");
|
|
expect_failure_then_close(&mut channel).await;
|
|
|
|
let decline = await_event(&[
|
|
("row", "exec_request"),
|
|
("decline_class", "serving"),
|
|
("reason", "seam-unconfigured"),
|
|
("exec_route", "git"),
|
|
])
|
|
.await;
|
|
assert_decline(&decline, &[("row", "exec_request"), ("exec_route", "git")]);
|
|
await_event(&[("message", "serving path started"), ("exec_route", "git")]).await;
|
|
await_event(&[("message", "serving path ended"), ("exec_route", "git")]).await;
|
|
session.unmount().await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn unfeatured_pipe_exec_decline_emits_the_request_event_with_the_route() {
|
|
let session = authenticated("git-pipe-row").await;
|
|
let mut channel = open_session_channel(&session.client)
|
|
.await
|
|
.expect("session channels open where a session-serving feature exists");
|
|
request_exec(&channel, "uptime")
|
|
.await
|
|
.expect("the exec rides the wire");
|
|
expect_failure(&mut channel).await;
|
|
let event = await_event(&[
|
|
("row", "exec_request"),
|
|
("decline_class", "request"),
|
|
("reason", "unfeatured"),
|
|
("exec_route", "pipe"),
|
|
])
|
|
.await;
|
|
assert_decline(&event, &[("row", "exec_request"), ("exec_route", "pipe")]);
|
|
session.unmount().await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn subsystem_env_pty_and_never_feature_request_declines_emit_events() {
|
|
let session = authenticated("git-req-row").await;
|
|
let mut channel = open_session_channel(&session.client)
|
|
.await
|
|
.expect("session channels open where a session-serving feature exists");
|
|
|
|
request_subsystem(&channel, "sftp")
|
|
.await
|
|
.expect("rides the wire");
|
|
expect_failure(&mut channel).await;
|
|
let event = await_event(&[
|
|
("row", "subsystem_request"),
|
|
("decline_class", "request"),
|
|
("reason", "unimplemented"),
|
|
])
|
|
.await;
|
|
assert_decline(&event, &[("row", "subsystem_request")]);
|
|
|
|
request_pty(&channel).await.expect("rides the wire");
|
|
expect_failure(&mut channel).await;
|
|
let event = await_event(&[
|
|
("row", "pty_request"),
|
|
("decline_class", "request"),
|
|
("reason", "unfeatured"),
|
|
])
|
|
.await;
|
|
assert_decline(&event, &[("row", "pty_request")]);
|
|
|
|
request_env(&channel, "SOME_VAR", "value")
|
|
.await
|
|
.expect("rides the wire");
|
|
expect_failure(&mut channel).await;
|
|
let event = await_event(&[
|
|
("row", "env_request"),
|
|
("decline_class", "request"),
|
|
("reason", "declined-by-design"),
|
|
])
|
|
.await;
|
|
assert_decline(&event, &[("row", "env_request")]);
|
|
|
|
request_x11(&channel).await.expect("rides the wire");
|
|
expect_failure(&mut channel).await;
|
|
let event = await_event(&[
|
|
("row", "x11_request"),
|
|
("decline_class", "request"),
|
|
("reason", "never-feature"),
|
|
])
|
|
.await;
|
|
assert_decline(&event, &[("row", "x11_request")]);
|
|
|
|
request_agent_forward(&channel)
|
|
.await
|
|
.expect("rides the wire");
|
|
expect_failure(&mut channel).await;
|
|
let event = await_event(&[
|
|
("row", "agent_request"),
|
|
("decline_class", "request"),
|
|
("reason", "never-feature"),
|
|
])
|
|
.await;
|
|
assert_decline(&event, &[("row", "agent_request")]);
|
|
|
|
session.unmount().await;
|
|
}
|
|
|
|
/// - *Contract stamp:* overview.md §Bounded-resources policy (the
|
|
/// cap is refused fail-closed at the open; the event carries the
|
|
/// cap reason).
|
|
#[tokio::test]
|
|
async fn cap_exceeded_session_open_emits_the_cap_event() {
|
|
let provider = Arc::new(MemIdentityProvider::new());
|
|
provider.register(
|
|
fixture("alpha").fingerprint,
|
|
identity("observe-cap", &["ssh"]),
|
|
);
|
|
let limits = Limits {
|
|
max_concurrent_channels: 0,
|
|
..Limits::default()
|
|
};
|
|
let adapter = alkssh::SshAdapter::new(common::harness::serve_config(
|
|
provider,
|
|
&[fixture("bravo")],
|
|
limits,
|
|
))
|
|
.await
|
|
.expect("adapter constructs");
|
|
let mut session = drive(
|
|
adapter,
|
|
Arc::new(MemHostKeyPolicy::with_default(true)),
|
|
limits,
|
|
)
|
|
.await
|
|
.expect("the loopback mounts");
|
|
let result =
|
|
authenticate_publickey(&mut session.client, "observe-cap", fixture("alpha")).await;
|
|
assert!(result.success(), "the fixture identity authenticates");
|
|
open_session_channel(&session.client)
|
|
.await
|
|
.expect_err("the zero-cap build refuses session opens");
|
|
|
|
let event = await_event(&[
|
|
("row", "session"),
|
|
("decline_class", "open"),
|
|
("reason", "cap-exceeded"),
|
|
])
|
|
.await;
|
|
assert_decline(&event, &[("row", "session"), ("reason", "cap-exceeded")]);
|
|
session.unmount().await;
|
|
}
|
|
}
|
|
|
|
#[cfg(all(feature = "session", not(feature = "git"), not(feature = "tunnel")))]
|
|
mod session_build {
|
|
use super::*;
|
|
use common::harness::{open_session_channel, request_exec, request_pty};
|
|
|
|
/// - *Contract stamp:* ADR-006 decision 4 (the pty serving decline
|
|
/// is spawned; the lifecycle rides the task) + OQ-08 (the
|
|
/// unset-seam decline: feature on, seam unset — the config-
|
|
/// driven class the real paths inherit,
|
|
/// `w3-tty-session`).
|
|
#[tokio::test]
|
|
async fn pty_declines_seam_unconfigured_with_the_full_serving_lifecycle() {
|
|
let session = authenticated("session-pty-row").await;
|
|
let mut channel = open_session_channel(&session.client)
|
|
.await
|
|
.expect("session channels open");
|
|
request_pty(&channel).await.expect("rides the wire");
|
|
expect_failure_then_close(&mut channel).await;
|
|
|
|
let decline = await_event(&[
|
|
("row", "pty_request"),
|
|
("decline_class", "serving"),
|
|
("reason", "seam-unconfigured"),
|
|
])
|
|
.await;
|
|
assert_decline(&decline, &[("row", "pty_request")]);
|
|
await_event(&[("message", "serving path started"), ("row", "pty_request")]).await;
|
|
await_event(&[("message", "serving path ended"), ("row", "pty_request")]).await;
|
|
session.unmount().await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn unfeatured_git_exec_decline_emits_the_request_event_with_the_route() {
|
|
let session = authenticated("session-git-row").await;
|
|
let mut channel = open_session_channel(&session.client)
|
|
.await
|
|
.expect("session channels open");
|
|
request_exec(&channel, "git-upload-pack '/owner/demo'")
|
|
.await
|
|
.expect("rides the wire");
|
|
expect_failure(&mut channel).await;
|
|
let event = await_event(&[
|
|
("row", "exec_request"),
|
|
("decline_class", "request"),
|
|
("reason", "unfeatured"),
|
|
("exec_route", "git"),
|
|
])
|
|
.await;
|
|
assert_decline(&event, &[("row", "exec_request"), ("exec_route", "git")]);
|
|
session.unmount().await;
|
|
}
|
|
|
|
/// - *Contract stamp:* OQ-08 + the event vocabulary (`w3-tty-
|
|
/// session`: the unset-field pipe decline —
|
|
/// `wrapper-unconfigured`, the serving-path class from a
|
|
/// spawned task).
|
|
#[tokio::test]
|
|
async fn pipe_declines_wrapper_unconfigured_with_the_full_serving_lifecycle() {
|
|
let session = authenticated("session-pipe-row").await;
|
|
let mut channel = open_session_channel(&session.client)
|
|
.await
|
|
.expect("session channels open");
|
|
request_exec(&channel, "uptime")
|
|
.await
|
|
.expect("rides the wire");
|
|
expect_failure_then_close(&mut channel).await;
|
|
|
|
let decline = await_event(&[
|
|
("row", "exec_request"),
|
|
("decline_class", "serving"),
|
|
("reason", "wrapper-unconfigured"),
|
|
("exec_route", "pipe"),
|
|
])
|
|
.await;
|
|
assert_decline(&decline, &[("row", "exec_request"), ("exec_route", "pipe")]);
|
|
await_event(&[("message", "serving path ended"), ("exec_route", "pipe")]).await;
|
|
session.unmount().await;
|
|
}
|
|
}
|
|
|
|
#[cfg(all(feature = "tunnel", not(feature = "session"), not(feature = "git")))]
|
|
mod tunnel_build {
|
|
use super::*;
|
|
use common::harness::open_session_channel;
|
|
|
|
/// - *Contract stamp:* the unset-declines posture (`w3-seam-config`,
|
|
/// carried through `w3-tunnel-direct`: the route-1 bridge's
|
|
/// pre-admit classes) — the direct row's absent-handle decline
|
|
/// emits the `seam-unconfigured` open-class event (the fabric
|
|
/// double's real decline events ride tests/tunnel_mapping.rs).
|
|
#[tokio::test]
|
|
async fn tunnel_direct_row_absent_handle_decline_emits_the_event() {
|
|
let session = authenticated("tunnel-stub-row").await;
|
|
common::harness::open_direct_tcpip(&session.client, "internal.example", 5432)
|
|
.await
|
|
.expect_err("the tunnel row declines on the absent handle");
|
|
let event = await_event(&[
|
|
("row", "direct-tcpip"),
|
|
("decline_class", "open"),
|
|
("reason", "seam-unconfigured"),
|
|
])
|
|
.await;
|
|
assert_decline(
|
|
&event,
|
|
&[("row", "direct-tcpip"), ("reason", "seam-unconfigured")],
|
|
);
|
|
session.unmount().await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn unfeatured_session_open_in_a_tunnel_build_emits_the_event() {
|
|
let session = authenticated("tunnel-none-row").await;
|
|
open_session_channel(&session.client)
|
|
.await
|
|
.expect_err("a tunnel-only build declines session channels");
|
|
let event = await_event(&[
|
|
("row", "session"),
|
|
("decline_class", "open"),
|
|
("reason", "unfeatured"),
|
|
])
|
|
.await;
|
|
assert_decline(&event, &[("row", "session"), ("reason", "unfeatured")]);
|
|
session.unmount().await;
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Local drivers (dispatch_core.rs's shapes, copied per-binary).
|
|
// ---------------------------------------------------------------------------
|
|
|
|
async fn authenticated(username: &str) -> common::harness::LoopbackSession {
|
|
capture();
|
|
let provider = Arc::new(MemIdentityProvider::new());
|
|
provider.register(fixture("alpha").fingerprint, identity(username, &["ssh"]));
|
|
let adapter = adapter(provider, &[fixture("bravo")]).await;
|
|
let mut session = drive(
|
|
adapter,
|
|
Arc::new(MemHostKeyPolicy::with_default(true)),
|
|
Limits::default(),
|
|
)
|
|
.await
|
|
.expect("the loopback mounts");
|
|
let result = authenticate_publickey(&mut session.client, username, fixture("alpha")).await;
|
|
assert!(
|
|
result.success(),
|
|
"the fixture identity authenticates before every observe row"
|
|
);
|
|
session
|
|
}
|
|
|
|
/// A mounted (kex-complete, **unauthenticated**) loopback session —
|
|
/// the auth-decline rows' driver: an auth disposition is observed
|
|
/// before any acceptance exists, so these rows must not pre-derive
|
|
/// themselves through [`authenticated`] (a second auth exchange on an
|
|
/// authenticated session is not a supported russh surface — mounted
|
|
/// fresh is the honest shape, matching `auth_bridge.rs`'s rows).
|
|
async fn mounted() -> common::harness::LoopbackSession {
|
|
capture();
|
|
let provider = Arc::new(MemIdentityProvider::new());
|
|
let adapter = adapter(provider, &[fixture("bravo")]).await;
|
|
drive(
|
|
adapter,
|
|
Arc::new(MemHostKeyPolicy::with_default(true)),
|
|
Limits::default(),
|
|
)
|
|
.await
|
|
.expect("the loopback mounts")
|
|
}
|
|
|
|
async fn expect_failure(channel: &mut Channel<Msg>) {
|
|
match next_message(channel).await {
|
|
Some(ChannelMsg::Failure) => {}
|
|
other => panic!("expected a channel failure, got {other:?}"),
|
|
}
|
|
}
|
|
|
|
async fn expect_failure_then_close(channel: &mut Channel<Msg>) {
|
|
expect_failure(channel).await;
|
|
assert!(
|
|
await_close(channel).await,
|
|
"the seam stub closes the channel"
|
|
);
|
|
// Yield so the spawned task's post-close events (ended) are likely
|
|
// to have landed before the unmount — await_event's polling covers
|
|
// the rest.
|
|
tokio::time::sleep(Duration::from_millis(50)).await;
|
|
}
|