Files
alkssh/tests/tty_mapping.rs
T
glm-5.3-flash 0506f233fc test(tty): wire-witness the channel_close cancel path + the no-exit-chunk close shape, record the write_all no-deadline limit (w4-tty-coverage)
- wire row: a client close on a served pipe row fires the per-channel
  cancel (kill-on-drop witnessed before teardown, no exit report
  fabricated, ended lifecycle event) and releases the cap (over-cap
  open refused at 1, re-open admits + routes after the close)
- wire row: the Closed no-exit-chunk close shape, constructed through
  the real path (an oversized shim chunk dies at alktty's own
  ChunkWriter validation -> bridge EOF, scripted exit never forwarded,
  plain close, nothing fabricated); Break's frame classification
  pinned by a unit row (wire-unreachable through the validated writer
  at this pin - recorded)
- module docs: the stdout write_all no-deadline known limit recorded
  (the gate's disposition: bounded backpressure, inactivity-timeout
  liveness; a deadline would be a behavior change)

Verification: cargo test (default/session/session+git/git/tunnel/sftp/
--all-features, tty rows 5x repeat), clippy x2 -D warnings, fmt, doc,
taskgraph validate
2026-10-29 07:46:16 +00:00

1272 lines
50 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! The `w3-tty-session` contract rows — the session branch of the
//! spine served for real: pty/shell/pipe-exec through the published
//! alktty seam (`drive_session_pre_negotiated`) over the translation
//! bridge, the locally-built negotiation, the control rows, the exit
//! derivation contract (mapping.md rules 1–6), and the
//! negotiation-error decline class — all observed through the
//! loopback harness against the hand-rolled TtyBackend shim
//! (`tests/common/tty_doubles.rs`). The decline-matrix rows *per
//! feature build* stay in `tests/dispatch_core.rs`; this binary adds
//! the session rows' serving surface, the unset-field declines (which
//! need a configured seam to isolate), and the real-tool pty smoke
//! (feature-gated — in a `session`-less build the binary compiles
//! empty, per the crates' compile-time feature isolation).
#![allow(dead_code)]
mod common;
#[cfg(feature = "session")]
mod serving {
use std::path::PathBuf;
use std::sync::Arc;
use alktty::backend::TerminalParams;
use alktty::wire::MAX_CHUNK_LEN;
use russh::ChannelOpenFailure;
use russh::client::Msg;
use russh::{Channel, ChannelMsg, Error, Sig};
use super::common::event_capture as ec;
use super::common::harness::{
LoopbackSession, authenticate_publickey, drive, next_message, open_session_channel,
request_exec, request_pty, request_shell, request_subsystem,
serve_config as harness_serve_config,
};
use super::common::tty_doubles as td;
use super::common::{MemHostKeyPolicy, MemIdentityProvider, fixture, identity};
use alkssh::{Limits, ServeConfig, SshAdapter};
/// The observation surface on the wire — the terminal-observation
/// enum these rows drive (russh-verbatim shapes, drained in order
/// until the deadline).
#[derive(Debug)]
pub(super) enum Observed {
Data(Vec<u8>),
Stderr(Vec<u8>),
ExitStatus(u32),
ExitSignal { signal_name: Sig, core_dumped: bool },
Close,
Failure,
}
/// The next observation with a deadline — None past 10 s (nothing
/// else can arrive; a row hanging past the deadline is its bug).
pub(super) async fn next_observation(channel: &mut Channel<Msg>) -> Option<Observed> {
tokio::time::timeout(std::time::Duration::from_secs(10), async {
loop {
match next_message(channel).await {
Some(ChannelMsg::Data { data }) => return Some(Observed::Data(data.to_vec())),
Some(ChannelMsg::ExtendedData { data, ext: 1 }) => {
return Some(Observed::Stderr(data.to_vec()));
}
Some(ChannelMsg::ExitStatus { exit_status }) => {
return Some(Observed::ExitStatus(exit_status));
}
Some(ChannelMsg::ExitSignal {
signal_name,
core_dumped,
..
}) => {
return Some(Observed::ExitSignal {
signal_name,
core_dumped,
});
}
Some(ChannelMsg::Close) => return Some(Observed::Close),
Some(ChannelMsg::Failure) => return Some(Observed::Failure),
Some(_) => continue,
None => return None,
}
}
})
.await
.ok()
.flatten()
}
/// Drains observations until the terminal outcome (status or
/// signal) — collecting the data rides for the ride rows, and
/// asserting the close follows after (the status-before-close
/// order).
pub(super) async fn collect_terminal(
channel: &mut Channel<Msg>,
) -> (Vec<Vec<u8>>, Vec<Vec<u8>>, Observed) {
let (mut stdout, mut stderr) = (Vec::new(), Vec::new());
loop {
match next_observation(channel).await {
Some(Observed::Data(data)) => stdout.push(data),
Some(Observed::Stderr(data)) => stderr.push(data),
Some(Observed::ExitStatus(code)) => {
let terminal = Observed::ExitStatus(code);
assert_observed_close_after(channel).await;
return (stdout, stderr, terminal);
}
Some(Observed::ExitSignal {
signal_name,
core_dumped,
}) => {
let terminal = Observed::ExitSignal {
signal_name,
core_dumped,
};
assert_observed_close_after(channel).await;
return (stdout, stderr, terminal);
}
other => panic!("expected the terminal outcome, got {other:?}"),
}
}
}
/// The status-before-close contract's second half: a close must
/// follow the terminal observation.
pub(super) async fn assert_observed_close_after(channel: &mut Channel<Msg>) {
match next_observation(channel).await {
Some(Observed::Close) => {}
other => panic!("expected the channel close after the terminal outcome, got {other:?}"),
}
}
/// Waits for one Success reply (want-reply requests) — with a
/// deadline; a Failure or a silent close is the panic, the rows
/// drive Success paths.
pub(super) async fn expect_success(channel: &mut Channel<Msg>, context: &str) {
if tokio::time::timeout(std::time::Duration::from_secs(10), async {
loop {
match next_message(channel).await {
Some(ChannelMsg::Success) => return true,
Some(ChannelMsg::Failure) => panic!("{context}: unexpected request failure"),
Some(_) => continue,
None => panic!("{context}: the channel closed before the Success reply"),
}
}
})
.await
.is_err()
{
panic!("{context}: the Success reply never arrived");
}
}
/// The shim-allocation poll: the first recorded `TtyParams`, once
/// the bridge's session actually allocated.
pub(super) async fn await_allocation(shim: &Arc<td::TtyShim>) -> alktty::backend::TtyParams {
for _ in 0..200 {
if let Some(params) = shim.observation().allocations.lock().first() {
return params.clone();
}
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
}
panic!("the shim never allocated (no session reached the seam)");
}
/// The shim-resize poll.
pub(super) async fn await_resize(shim: &Arc<td::TtyShim>) -> (u16, u16) {
for _ in 0..200 {
if let Some(resize) = shim.observation().resizes.lock().last() {
return *resize;
}
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
}
panic!("no resize reached the shim's control");
}
/// The shim-signal poll.
pub(super) async fn await_signal(shim: &Arc<td::TtyShim>) -> String {
for _ in 0..200 {
if let Some(name) = shim.observation().signals.lock().last() {
return name.clone();
}
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
}
panic!("no signal reached the shim's control");
}
/// Builds the session rows' serve-config — everything set (the
/// shim seam, the shell argv, the exec wrapper, session cwd/env),
/// overridable per row through the mutator. The identity resolves
/// from the `alpha` fixture into `ssh-rider` (the witness id).
pub(super) fn session_config(
provider: Arc<MemIdentityProvider>,
mutate: impl FnOnce(&mut ServeConfig),
limits: Limits,
) -> ServeConfig {
let mut serve = harness_serve_config(provider, &[fixture("bravo")], limits);
serve.shell_argv = Some(vec!["/bin/none".to_owned(), "-login".to_owned()]);
serve.exec_wrapper_argv = Some(vec!["/bin/wrap".to_owned(), "-x".to_owned()]);
serve.session_cwd = Some(PathBuf::from("/srv/session"));
serve
.session_env
.insert("TEST_SESSION_ENV".to_owned(), "rides".to_owned());
mutate(&mut serve);
serve
}
/// Mounts an authenticated loopback session over the given config
/// — the rows' driver (fixture `alpha` → `ssh-rider`).
pub(super) async fn session_with(mutate: impl FnOnce(&mut ServeConfig)) -> LoopbackSession {
session_with_limits(mutate, Limits::default()).await
}
/// [`session_with`] over non-default [`Limits`] — the rows that
/// tune a budget (the channel-cap release row's ceiling of 1).
pub(super) async fn session_with_limits(
mutate: impl FnOnce(&mut ServeConfig),
limits: Limits,
) -> LoopbackSession {
let provider = Arc::new(MemIdentityProvider::new());
provider.register(
fixture("alpha").fingerprint,
identity("ssh-rider", &["ssh"]),
);
let adapter = SshAdapter::new(session_config(provider, mutate, limits))
.await
.expect("the session build's adapter constructs");
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, "ssh-rider", fixture("alpha")).await;
assert!(
result.success(),
"the fixture identity authenticates before every session row"
);
session
}
/// A shim-backed seam — the rows' default (no ownership provider).
pub(super) fn shim_seam(shim: &Arc<td::TtyShim>) -> impl FnOnce(&mut ServeConfig) {
let seam = td::tty_seam(shim);
move |serve: &mut ServeConfig| serve.session_seam = Some(seam)
}
/// Opens a session channel and sends the pty+shell request pair —
/// replaced per-row below (the rows drive their own sequences).
#[tokio::test]
async fn pty_shell_serves_the_negotiated_shell_row_through_the_bridge() {
let shim = Arc::new(td::TtyShim::new());
shim.set_script(td::ShimScript {
stdout: vec![b"shell-out".to_vec()],
stderr: vec![],
exit: Some(0),
});
let session = session_with(shim_seam(&shim)).await;
let mut channel = open_session_channel(&session.client)
.await
.expect("the session channel opens");
request_pty(&channel).await.expect("the pty succeeded");
expect_success(&mut channel, "the pty request").await;
request_shell(&channel).await.expect("the shell routed");
expect_success(&mut channel, "the shell request").await;
let (stdout, _stderr, terminal) = collect_terminal(&mut channel).await;
assert_eq!(
stdout.concat(),
b"shell-out".to_vec(),
"the scripted stdout chunk rode SSH data"
);
let Observed::ExitStatus(0) = terminal else {
panic!("the exit chunk derived exit-status(0), got {terminal:?}")
};
let params = await_allocation(&shim).await;
assert_eq!(
params.cmd,
vec!["/bin/none".to_owned(), "-login".to_owned()],
"the shell argv rode the negotiation verbatim (OQ-08)"
);
let TerminalParams {
term,
cols,
rows,
pixel_width,
pixel_height,
..
} = params.terminal.expect("the pty params reached the seam");
assert_eq!(term.as_deref(), Some("xterm"));
assert_eq!((cols, rows), (80, 24), "the pty dims rode");
assert_eq!((pixel_width, pixel_height), (0, 0));
assert_eq!(
params.cwd.as_deref(),
Some(std::path::Path::new("/srv/session")),
"session_cwd rides the negotiation, not the wire"
);
assert_eq!(
params.env.get("TEST_SESSION_ENV").map(String::as_str),
Some("rides"),
"session_env rides the negotiation, not the wire"
);
assert!(
params.backend_params.is_empty(),
"no backend selector fields exist on this door"
);
session.unmount().await;
}
#[tokio::test]
async fn pipe_exec_serves_the_wrapper_appended_command_in_pipe_mode() {
let shim = Arc::new(td::TtyShim::new());
shim.set_script(td::ShimScript {
stdout: vec![b"outs".to_vec()],
stderr: vec![b"errd".to_vec()],
exit: Some(0),
});
let session = session_with(shim_seam(&shim)).await;
let mut channel = open_session_channel(&session.client)
.await
.expect("the session channel opens");
request_exec(&channel, "uptime")
.await
.expect("the exec routed");
expect_success(&mut channel, "the routed exec").await;
let (stdout, stderr, terminal) = collect_terminal(&mut channel).await;
assert_eq!(stdout.concat(), b"outs".to_vec(), "stdout rode SSH data");
assert_eq!(
stderr.concat(),
b"errd".to_vec(),
"stderr rode SSH extended_data(1)"
);
let Observed::ExitStatus(0) = terminal else {
panic!("the exit chunk derived exit-status(0), got {terminal:?}")
};
let params = await_allocation(&shim).await;
assert_eq!(
params.cmd,
vec!["/bin/wrap".to_owned(), "-x".to_owned(), "uptime".to_owned()],
"the wire command appended to the wrapper argv — never naively split"
);
assert!(
params.terminal.is_none(),
"no pty preceded: the exec row serves pipe mode"
);
session.unmount().await;
}
#[tokio::test]
async fn pty_then_exec_serves_pty_mode_with_the_stored_params() {
let shim = Arc::new(td::TtyShim::new());
shim.set_script(td::ShimScript {
stdout: vec![],
stderr: vec![],
exit: Some(0),
});
let session = session_with(shim_seam(&shim)).await;
let mut channel = open_session_channel(&session.client)
.await
.expect("the session channel opens");
request_pty(&channel).await.expect("the pty succeeded");
expect_success(&mut channel, "the pty request").await;
request_exec(&channel, "echo done").await.expect("routed");
expect_success(&mut channel, "the routed exec").await;
let (_stdout, _stderr, terminal) = collect_terminal(&mut channel).await;
let Observed::ExitStatus(0) = terminal else {
panic!("exit-status(0), got {terminal:?}")
};
let params = await_allocation(&shim).await;
assert_eq!(
params.cmd,
vec![
"/bin/wrap".to_owned(),
"-x".to_owned(),
"echo done".to_owned()
]
);
let TerminalParams { cols, rows, .. } =
params.terminal.expect("pty-then-exec serves PTY mode");
assert_eq!(
(cols, rows),
(80, 24),
"`ssh -t host \"cmd\"` serves PTY-mode"
);
session.unmount().await;
}
#[tokio::test]
async fn shell_without_a_pty_serves_pipe_mode() {
let shim = Arc::new(td::TtyShim::new());
shim.set_script(td::ShimScript {
stdout: vec![],
stderr: vec![],
exit: Some(0),
});
let session = session_with(shim_seam(&shim)).await;
let mut channel = open_session_channel(&session.client)
.await
.expect("the session channel opens");
request_shell(&channel).await.expect("the shell routed");
expect_success(&mut channel, "the shell request").await;
let (_stdout, _stderr, terminal) = collect_terminal(&mut channel).await;
let Observed::ExitStatus(0) = terminal else {
panic!("exit-status(0), got {terminal:?}")
};
let params = await_allocation(&shim).await;
assert!(
params.terminal.is_none(),
"the decided row: a shell without a preceding pty serves pipe-mode"
);
session.unmount().await;
}
// ------------------------------------------------------------------
// The channel-not-held guard (`w4-channel-not-held-rows`): a second
// request on an already-served channel declines request-level — the
// store-and-take handoff holds a channel exactly once, the wave-3
// gate's booked finding 2.
// ------------------------------------------------------------------
/// - *Contract stamp:* mapping.md §Request-level rows (the exec
/// row's store-and-take handoff: the `channel-not-held` class —
/// a second exec on an already-served pipe channel answers
/// `channel_failure`, inline, the first serve rides on) + the
/// event vocabulary (`src/observe.rs`: class `request`, reason
/// `channel-not-held`, route `pipe`; the exit-`None` script
/// keeps the serve live and the wire quiescent, so the row is
/// race-free by construction).
#[tokio::test]
async fn second_exec_on_a_served_pipe_channel_declines_channel_not_held() {
ec::install();
let shim = Arc::new(td::TtyShim::new());
shim.set_script(td::ShimScript {
stdout: vec![],
stderr: vec![],
exit: None,
});
let session = session_with(shim_seam(&shim)).await;
let mut channel = open_session_channel(&session.client)
.await
.expect("the session channel opens");
request_exec(&channel, "uptime")
.await
.expect("the first exec routed");
expect_success(&mut channel, "the served pipe exec").await;
request_exec(&channel, "uptime")
.await
.expect("the second exec rides the wire");
match next_message(&mut channel).await {
Some(ChannelMsg::Failure) => {}
other => {
panic!("a second exec on a served channel answers channel_failure, got {other:?}")
}
}
let event = ec::await_event(&[
("row", "exec_request"),
("decline_class", "request"),
("reason", "channel-not-held"),
("exec_route", "pipe"),
])
.await;
ec::assert_request_decline(&event, true);
request_subsystem(&channel, "sftp")
.await
.expect("the alive channel carries another request");
match next_message(&mut channel).await {
Some(ChannelMsg::Failure) => {}
other => panic!("the inline decline leaves the channel usable, got {other:?}"),
}
session.unmount().await;
}
/// - *Contract stamp:* mapping.md §Request-level rows (the
/// `shell_request` row's store-and-take handoff: the
/// `channel-not-held` class — a second shell request on an
/// already-served shell channel answers `channel_failure`,
/// inline, the first serve rides on) + the event vocabulary
/// (`src/observe.rs`: class `request`, reason
/// `channel-not-held`; the lifecycle row carries no
/// `exec_route` — absent-field shape pinned on the wire).
#[tokio::test]
async fn second_shell_request_on_a_served_shell_channel_declines_channel_not_held() {
ec::install();
let shim = Arc::new(td::TtyShim::new());
shim.set_script(td::ShimScript {
stdout: vec![],
stderr: vec![],
exit: None,
});
let session = session_with(shim_seam(&shim)).await;
let mut channel = open_session_channel(&session.client)
.await
.expect("the session channel opens");
request_shell(&channel)
.await
.expect("the first shell request routed");
expect_success(&mut channel, "the served shell row").await;
request_shell(&channel)
.await
.expect("the second shell request rides the wire");
match next_message(&mut channel).await {
Some(ChannelMsg::Failure) => {}
other => panic!(
"a second shell request on a served channel answers channel_failure, \
got {other:?}"
),
}
let event = ec::await_event(&[
("row", "shell_request"),
("decline_class", "request"),
("reason", "channel-not-held"),
])
.await;
ec::assert_request_decline(&event, false);
request_subsystem(&channel, "sftp")
.await
.expect("the alive channel carries another request");
match next_message(&mut channel).await {
Some(ChannelMsg::Failure) => {}
other => panic!("the inline decline leaves the channel usable, got {other:?}"),
}
session.unmount().await;
}
// ------------------------------------------------------------------
// The channel_close-fired per-channel cancel path on the wire
// (`w4-tty-coverage`, wave-3 gate booked item 3): the cap release +
// the per-channel state teardown, wire-witnessed — the disjoint
// release sites the gate verified per row (the wire rows here
// witness the established-channel-close site, `channel_cap.rs`
// release site 1).
// ------------------------------------------------------------------
/// - *Contract stamp:* mapping.md §Bounded resources /
/// `channel_cap.rs` release site 1 + `handler.rs`
/// §the control rows' per-channel surface (a client close on a
/// served row fires the `channel_close` hook: the bridge surface
/// fires the per-channel cancel — the serving task aborts the
/// alktty session, alktty's kill-on-drop contract fires, nothing
/// fabricated on the wire — and the cap releases, disjoint from
/// the bridge-failure and arrival sites).
#[tokio::test]
async fn a_client_close_on_a_served_row_fires_the_per_channel_cancel_and_releases_the_cap() {
ec::install();
let shim = Arc::new(td::TtyShim::new());
shim.set_script(td::ShimScript {
stdout: vec![],
stderr: vec![],
exit: None,
});
let session = session_with_limits(
shim_seam(&shim),
Limits {
max_concurrent_channels: 1,
..Limits::default()
},
)
.await;
let mut served = open_session_channel(&session.client)
.await
.expect("the serving channel opens (the cap admits)");
request_exec(&served, "uptime")
.await
.expect("the exec routed");
expect_success(&mut served, "the served pipe exec").await;
let refused = open_session_channel(&session.client)
.await
.expect_err("beyond the cap of 1: refused at the open");
assert!(
matches!(
refused,
Error::ChannelOpenFailure(ChannelOpenFailure::AdministrativelyProhibited)
),
"the cap-exceeded refusal is the AdministrativelyProhibited open decline, \
got {refused:?}"
);
served.close().await.expect("the client close rides");
for _ in 0..200 {
if !shim.observation().kills.lock().is_empty() {
break;
}
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
}
assert!(
!shim.observation().kills.lock().is_empty(),
"the close-fired per-channel cancel aborted the alktty session — \
alktty's kill-on-drop contract fired (witnessed before unmount, \
so this is the channel_close path, not the teardown)"
);
match next_observation(&mut served).await {
Some(Observed::Close) | None => {}
other => panic!("the cancel fabricates no exit report, got {other:?}"),
}
let _ = ec::await_event(&[
("row", "exec_request"),
("message", "serving path ended"),
("exec_route", "pipe"),
])
.await;
let mut recovered = open_session_channel(&session.client)
.await
.expect("the cap recovered after the close — the channel_close hook released");
request_exec(&recovered, "uptime")
.await
.expect("the recovered channel routes");
expect_success(&mut recovered, "the recovered channel serves").await;
session.unmount().await;
}
// ------------------------------------------------------------------
// The Closed/Break no-exit-chunk close shape on the wire
// (`w4-tty-coverage`): a bridge read that ends without an exit
// chunk reports nothing — the derivation contract's absent-report
// posture pinned, not just the happy paths. (The frame-level
// Closed/Break classification rows live in `src/tty_map.rs`'s test
// mod; the Break classification is unreachable through alktty's
// own validated `ChunkWriter` — a defensive arm.)
// ------------------------------------------------------------------
/// - *Contract stamp:* mapping.md §Exit chunk (the contract's
/// close-shape class: the bridge read ends **without** an exit
/// chunk → no report, plain close, the lifecycle's ended event —
/// its absence is neither 0 nor 255). The shape is constructed
/// through the real path: the shim's stdout carries a payload
/// beyond alktty's own `MAX_CHUNK_LEN` wire bound — alktty's
/// `ChunkWriter` rejects it locally, its drainer shuts the write
/// half down, and the bridge's next read is EOF (`Closed`) with
/// the scripted exit never forwarded. Nothing fabricated.
#[tokio::test]
async fn a_bridge_end_without_an_exit_chunk_reports_nothing_and_closes_plain() {
ec::install();
let shim = Arc::new(td::TtyShim::new());
shim.set_script(td::ShimScript {
stdout: vec![b"bridge-ok".to_vec(), vec![0u8; MAX_CHUNK_LEN as usize + 1]],
stderr: vec![],
exit: Some(0),
});
let session = session_with(shim_seam(&shim)).await;
let mut channel = open_session_channel(&session.client)
.await
.expect("the channel opens");
request_exec(&channel, "uptime")
.await
.expect("the exec routed");
expect_success(&mut channel, "the routed exec").await;
assert!(
!shim.observation().allocations.lock().is_empty(),
"the session truly served (the no-report close shape, not a decline)"
);
let mut saw_data = false;
let mut saw_close = false;
loop {
match next_observation(&mut channel).await {
Some(Observed::Data(data)) if data == b"bridge-ok".to_vec() => {
saw_data = true;
}
Some(Observed::Close) => {
saw_close = true;
break;
}
Some(Observed::ExitStatus(_)) | Some(Observed::ExitSignal { .. }) => panic!(
"the no-exit-chunk close shape fabricates no exit report, \
the scripted exit never rode"
),
None => break,
Some(Observed::Data(other)) => {
panic!("unexpected data after the chunk plane died: {other:?}")
}
Some(_) => continue,
}
}
assert!(
saw_data,
"the in-bounds chunk rode SSH data before the chunk plane died"
);
assert!(saw_close, "the Closed shape ends with a plain close");
let _ = ec::await_event(&[
("row", "exec_request"),
("message", "serving path ended"),
("exec_route", "pipe"),
])
.await;
session.unmount().await;
}
#[tokio::test]
async fn stdin_bytes_and_the_eof_sentinel_reach_the_backend() {
let shim = Arc::new(td::TtyShim::new());
shim.set_script(td::ShimScript {
stdout: vec![],
stderr: vec![],
exit: None,
});
let session = session_with(shim_seam(&shim)).await;
let channel = open_session_channel(&session.client)
.await
.expect("the session channel opens");
request_shell(&channel).await.expect("the shell routed");
let _ = await_allocation(&shim).await;
channel
.data_bytes(&b"typed data"[..])
.await
.expect("the stdin ride");
let data_reached = |shim: &Arc<td::TtyShim>| {
let log = shim.observation().stdin.lock().clone();
log == b"typed data".to_vec()
};
for _ in 0..200 {
if data_reached(&shim) {
break;
}
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
}
assert!(
data_reached(&shim),
"SSH data → Chunk::stdin reached the backend verbatim"
);
channel.eof().await.expect("the channel EOF rides the wire");
for _ in 0..200 {
if !shim.observation().stdin_eof.lock().is_empty() {
break;
}
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
}
assert!(
!shim.observation().stdin_eof.lock().is_empty(),
"the SSH EOF translated to the zero-length stdin-chunk sentinel — \
the backend's stdin shutdown was observed"
);
// The session never resolves its exit (script exit = None);
// the disconnect is the connection-level cancel: the serving
// task aborts the alktty session — the kill-on-drop witness
// recorded, nothing fabricated on the wire.
session.unmount().await;
for _ in 0..100 {
if !shim.observation().kills.lock().is_empty() {
break;
}
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
}
assert!(
!shim.observation().kills.lock().is_empty(),
"the cancellation aborted the never-resolving session — \
alktty's kill-on-drop contract fired"
);
}
#[tokio::test]
async fn window_change_reaches_the_bridge_and_preserve_resize_updates_the_stored_params() {
let shim = Arc::new(td::TtyShim::new());
shim.set_script(td::ShimScript {
stdout: vec![],
stderr: vec![],
exit: None,
});
let session = session_with(shim_seam(&shim)).await;
// Pre-serve resize: after the pty is stored, before the shell
// request — the stored params resize (the final size wins).
let channel = open_session_channel(&session.client)
.await
.expect("the session channel opens");
request_pty(&channel).await.expect("the pty succeeded");
channel
.window_change(120, 40, 0, 0)
.await
.expect("the window-change rides (fire-and-forget)");
request_shell(&channel).await.expect("the shell routed");
let params = await_allocation(&shim).await;
let TerminalParams { cols, rows, .. } =
params.terminal.expect("the stored pty served the session");
assert_eq!((cols, rows), (120, 40), "the pre-serve resize won");
// Serving resize: the bridge forwards through the bounded
// control surface.
channel
.window_change(200, 50, 0, 0)
.await
.expect("the window-change rides");
let (resized_cols, resized_rows) = await_resize(&shim).await;
assert_eq!(
(resized_cols, resized_rows),
(200, 50),
"`window_change_request` → `ControlMessage::Resize` CTRL_IN"
);
session.unmount().await;
}
#[tokio::test]
async fn signal_request_forwards_the_name_verbatim_to_the_backend() {
let shim = Arc::new(td::TtyShim::new());
shim.set_script(td::ShimScript {
stdout: vec![],
stderr: vec![],
exit: None,
});
let session = session_with(shim_seam(&shim)).await;
let channel = open_session_channel(&session.client)
.await
.expect("the session channel opens");
request_pty(&channel).await.expect("the pty succeeded");
request_shell(&channel).await.expect("the shell routed");
let _ = await_allocation(&shim).await;
channel
.signal(Sig::TERM)
.await
.expect("the signal rides (fire-and-forget)");
let name = await_signal(&shim).await;
assert_eq!(name, "TERM", "accepted verbatim — no alkssh-side filter");
session.unmount().await;
}
// ------------------------------------------------------------------
// The exit derivation contract (mapping.md rules 1–6), on the wire.
// ------------------------------------------------------------------
/// - *Contract stamp:* mapping.md §Exit chunk rules 1, 2, 6 (code
/// ≥ 0 → `exit-status(N)`; negative `−N`, N ≥ 2 →
/// `exit-signal(name)` via the table; POC-3 §D.2's `−15` →
/// `TERM` live evidence) + rules 3 (the `−1` sentinel → 255,
/// never a signal claim) — each rule's wire row. `core_dumped`
/// is never asserted (the bool stays false).
#[tokio::test]
async fn exit_chunks_derive_by_the_contract_rules() {
for exit_code in [0, 1, -15, -2, -33, -1] {
let shim = Arc::new(td::TtyShim::new());
shim.set_script(td::ShimScript {
stdout: vec![],
stderr: vec![b"e".to_vec()],
exit: Some(exit_code),
});
let session = session_with(shim_seam(&shim)).await;
let mut channel = open_session_channel(&session.client)
.await
.expect("the channel opens");
request_exec(&channel, "noop").await.expect("routed");
expect_success(&mut channel, "the routed exec").await;
let (_stdout, _stderr, terminal) = collect_terminal(&mut channel).await;
match (exit_code, &terminal) {
(code, Observed::ExitStatus(status)) if code >= 0 && *status == code as u32 => {}
(
-15,
Observed::ExitSignal {
signal_name,
core_dumped,
},
) => {
assert!(matches!(signal_name, Sig::TERM), "rule 2: −15 → TERM");
assert!(
!core_dumped,
"core_dumped is never asserted — the bool stays false"
);
}
(
-2,
Observed::ExitSignal {
signal_name,
core_dumped,
},
) => {
assert!(matches!(signal_name, Sig::INT), "rule 2: −2 → INT");
assert!(!core_dumped);
}
(-33, Observed::ExitStatus(255)) => {}
(-1, Observed::ExitStatus(255)) => {}
_ => panic!(
"exit chunk {exit_code}: expected the derived contract shape, got {terminal:?}"
),
}
session.unmount().await;
}
}
// ------------------------------------------------------------------
// The negotiation-error decline class (the bridge's serving-path
// decline: the session never started → exit-status(255) + close).
// ------------------------------------------------------------------
/// - *Contract stamp:* mapping.md §the seams + the task's
/// negotiate-then-serve decision (the alktty error frame →
/// `negotiation-failed`, `exit-status(255)`, close — the one
/// could-not-start claim this door makes: nothing ran).
#[tokio::test]
async fn unknown_backend_answers_255_then_close() {
let session = session_with(|serve: &mut ServeConfig| {
serve.session_seam = Some(td::misconfigured_seam());
})
.await;
let mut channel = open_session_channel(&session.client)
.await
.expect("the channel opens");
request_exec(&channel, "uptime").await.expect("routed");
let (_stdout, _stderr, terminal) = collect_terminal(&mut channel).await;
let Observed::ExitStatus(255) = terminal else {
panic!("could-not-start reports exit-status(255), got {terminal:?}")
};
session.unmount().await;
}
#[tokio::test]
async fn ownership_denial_answers_255_then_close_and_witnesses_the_identity() {
let shim = Arc::new(td::TtyShim::new());
shim.set_script(td::ShimScript {
stdout: vec![],
stderr: vec![],
exit: None,
});
let ownership = Arc::new(td::MemOwnership::new(false));
let witness = Arc::clone(&ownership);
let session = session_with(|serve: &mut ServeConfig| {
serve.session_seam = Some(td::tty_seam_with_ownership(&shim, Arc::clone(&ownership)));
})
.await;
let mut channel = open_session_channel(&session.client)
.await
.expect("the channel opens");
request_exec(&channel, "uptime").await.expect("routed");
let (_stdout, _stderr, terminal) = collect_terminal(&mut channel).await;
let Observed::ExitStatus(255) = terminal else {
panic!("the denied ownership is could-not-start (255), got {terminal:?}")
};
let ids = witness.ids.lock().clone();
assert_eq!(
ids,
vec!["ssh-rider".to_owned()],
"the resolved identity rode to drive_session_pre_negotiated — \
the parked wave-2 identity-landing item asserted live"
);
session.unmount().await;
}
#[tokio::test]
async fn the_identity_rides_to_the_seam_on_a_serving_row() {
let shim = Arc::new(td::TtyShim::new());
shim.set_script(td::ShimScript {
stdout: vec![],
stderr: vec![],
exit: Some(0),
});
let ownership = Arc::new(td::MemOwnership::new(true));
let witness = Arc::clone(&ownership);
let session = session_with(move |serve: &mut ServeConfig| {
serve.session_seam = Some(td::tty_seam_with_ownership(&shim, ownership));
})
.await;
let mut channel = open_session_channel(&session.client)
.await
.expect("the channel opens");
request_exec(&channel, "noop").await.expect("routed");
let (_stdout, _stderr, terminal) = collect_terminal(&mut channel).await;
let Observed::ExitStatus(0) = terminal else {
panic!("exit-status(0), got {terminal:?}")
};
let ids = witness.ids.lock().clone();
assert!(
ids.iter().any(|id| id == "ssh-rider"),
"the identity reached the seam's ownership check on the serving path"
);
session.unmount().await;
}
}
#[cfg(feature = "session")]
mod unset_declines {
use std::sync::Arc;
use super::common::harness::{
LoopbackSession, authenticate_publickey, drive, next_message, open_session_channel,
request_exec, request_pty, request_shell, serve_config as harness_serve_config,
};
use super::common::tty_doubles as td;
use super::common::{MemHostKeyPolicy, MemIdentityProvider, fixture, identity};
use alkssh::{Limits, ServeConfig, SshAdapter};
use russh::client::Msg;
use russh::{Channel, ChannelMsg};
/// An authenticated loopback over a mutated config — the
/// unset-field rows' driver.
async fn session_with(mutate: impl FnOnce(&mut ServeConfig)) -> LoopbackSession {
let provider = Arc::new(MemIdentityProvider::new());
provider.register(
fixture("alpha").fingerprint,
identity("unset-rider", &["ssh"]),
);
let mut serve = harness_serve_config(provider, &[fixture("bravo")], Limits::default());
serve.exec_wrapper_argv = Some(vec!["/bin/wrap".to_owned()]);
serve.shell_argv = Some(vec!["/bin/none".to_owned()]);
mutate(&mut serve);
let adapter = SshAdapter::new(serve)
.await
.expect("the adapter constructs");
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, "unset-rider", fixture("alpha")).await;
assert!(result.success());
session
}
async fn expect_failure_then_close(channel: &mut Channel<Msg>, context: &str) {
match next_message(channel).await {
Some(ChannelMsg::Failure) => {}
other => panic!("{context}: expected failure, got {other:?}"),
}
loop {
match next_message(channel).await {
Some(ChannelMsg::Close) => return,
None => panic!("{context}: expected the close after the failure"),
Some(_) => continue,
}
}
}
/// - *Contract stamp:* OQ-08 (`shell_argv` unset → `shell_request`
/// declines — never a fallback) + the recorded wire shape (the
/// serving-path decline: failure then close, from a spawned
/// task).
#[tokio::test]
async fn shell_argv_unset_declines_the_shell_row() {
let shim = Arc::new(td::TtyShim::new());
let shim_for_seam = Arc::clone(&shim);
let session = session_with(move |serve: &mut ServeConfig| {
serve.shell_argv = None;
serve.session_seam = Some(td::tty_seam(&shim_for_seam));
})
.await;
let mut channel = open_session_channel(&session.client)
.await
.expect("the channel opens");
request_shell(&channel).await.expect("rides the wire");
expect_failure_then_close(&mut channel, "the unset shell row").await;
assert!(
shim.observation().allocations.lock().is_empty(),
"a declined row never allocates (decline-first, alloc-never)"
);
session.unmount().await;
}
/// - *Contract stamp:* OQ-08 (`exec_wrapper_argv` unset → the
/// pipe row declines) + the recorded wire shape.
#[tokio::test]
async fn exec_wrapper_unset_declines_the_pipe_row() {
let shim = Arc::new(td::TtyShim::new());
let shim_for_seam = Arc::clone(&shim);
let session = session_with(move |serve: &mut ServeConfig| {
serve.exec_wrapper_argv = None;
serve.session_seam = Some(td::tty_seam(&shim_for_seam));
})
.await;
let mut channel = open_session_channel(&session.client)
.await
.expect("the channel opens");
request_exec(&channel, "uptime").await.expect("rides");
expect_failure_then_close(&mut channel, "the unset wrapper row").await;
assert!(
shim.observation().allocations.lock().is_empty(),
"a declined row never allocates"
);
session.unmount().await;
}
/// - *Contract stamp:* the decline matrix's config-driven note
/// (`session_seam` unset → the session rows decline even though
/// the `session` feature is on) + the recorded wire shape; the
/// pty row declines too (a doomed allocation is declined first,
/// alloc never).
#[tokio::test]
async fn seam_unset_declines_every_session_row() {
let session = session_with(|_serve: &mut ServeConfig| {}).await;
let mut channel = open_session_channel(&session.client)
.await
.expect("the channel opens");
request_pty(&channel).await.expect("rides the wire");
expect_failure_then_close(&mut channel, "the unset-seam pty row").await;
let mut channel = open_session_channel(&session.client)
.await
.expect("the channel opens");
request_shell(&channel).await.expect("rides the wire");
expect_failure_then_close(&mut channel, "the unset-seam shell row").await;
let mut channel = open_session_channel(&session.client)
.await
.expect("the channel opens");
request_exec(&channel, "uptime").await.expect("rides");
expect_failure_then_close(&mut channel, "the unset-seam pipe row").await;
session.unmount().await;
}
}
#[cfg(all(unix, feature = "session"))]
mod real_tool {
//! The real-tool smoke (`w3-tty-session`): one OpenSSH pty
//! session (`ssh -tt`, a real PTY via a dev-dep alktty `local`
//! composition — the posture decided: the session rows' smoke
//! composes the REAL backend, not the shim, because a plain shim
//! row cannot observe what vanilla OpenSSH actually sends; POC-3
//! stage D proved the same composition) through a TCP-mounted
//! adapter. Posture (recorded): requires `ssh` and `sh` in the
//! environment; when absent it is **skipped with a printed
//! reason** (skipped-with-reason at most, never silently green).
//! The smoke owns the pty lifecycle rows only (data rides, clean
//! exit → exit-status before close; the `stty size`-grade
//! fidelity matrix is wave 5's).
use std::collections::HashMap;
use std::process::Command;
use std::sync::Arc;
use alkssh::SessionSeam;
use alkssh::{Limits, SshAdapter};
use alktty::LocalTtyBackend;
use alktty::backend::TtyBackend;
use super::common::harness::serve_config as harness_serve_config;
use super::common::{MemIdentityProvider, fixture, identity};
/// The `local` composition for the smoke — alktty's own backend in
/// the seam map (dev-dep unification: the `local` feature rides the
/// test graph only).
fn local_seam() -> SessionSeam {
let mut backends = HashMap::new();
backends.insert(
"local".to_owned(),
Arc::new(LocalTtyBackend::new()) as Arc<dyn TtyBackend>,
);
SessionSeam {
backends: Arc::new(backends),
backend: "local".to_owned(),
ownership: None,
}
}
#[tokio::test]
async fn real_openssh_pty_session_smoke() {
if Command::new("ssh").arg("-V").output().is_err() {
eprintln!("SKIPPED (with reason): real-tool pty smoke — ssh is unavailable");
return;
}
if Command::new("sh").arg("-c").arg("exit 0").output().is_err() {
eprintln!("SKIPPED (with reason): real-tool pty smoke — sh is unavailable");
return;
}
let work = std::env::temp_dir().join(format!("alkssh-tty-smoke-{}", std::process::id()));
std::fs::create_dir_all(&work).expect("the smoke workdir creates");
let key_path = work.join("id_ed25519");
std::fs::write(&key_path, fixture("alpha").pem).expect("the fixture key writes");
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&key_path, std::fs::Permissions::from_mode(0o600))
.expect("the key file perms set");
let provider = Arc::new(MemIdentityProvider::new());
provider.register(
fixture("alpha").fingerprint,
identity("smoke-rider", &["ssh"]),
);
let mut serve = harness_serve_config(provider, &[fixture("bravo")], Limits::default());
serve.shell_argv = Some(vec!["/bin/sh".to_owned(), "-i".to_owned()]);
serve.session_seam = Some(local_seam());
let adapter = Arc::new(
SshAdapter::new(serve)
.await
.expect("the smoke adapter constructs"),
);
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
.await
.expect("the smoke listener binds");
let port = listener.local_addr().expect("local addr").port();
let accept_task = tokio::spawn(async move {
loop {
let Ok((stream, _)) = listener.accept().await else {
return;
};
let adapter = Arc::clone(&adapter);
tokio::spawn(async move {
let _ = adapter.handle(stream).await;
});
}
});
let outcome = tokio::time::timeout(std::time::Duration::from_secs(30), async {
let mut child = tokio::process::Command::new("ssh")
.args(["-tt", "-p", &port.to_string(), "-i"])
.arg(&key_path)
.args([
"-o",
"BatchMode=yes",
"-o",
"IdentitiesOnly=yes",
"-o",
"StrictHostKeyChecking=no",
"-o",
"UserKnownHostsFile=/dev/null",
"-o",
"ConnectTimeout=10",
])
.arg("smoke-rider@127.0.0.1")
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.spawn()
.expect("ssh spawns");
{
use tokio::io::AsyncWriteExt;
let mut stdin = child.stdin.take().expect("the child stdin is piped");
stdin
.write_all(b"echo alkssh-tty-smoke; exit\n")
.await
.expect("the smoke stdin writes");
stdin.flush().await.expect("the smoke stdin flushes");
}
let output = child.wait_with_output().await.expect("ssh completes");
(
output.status.success(),
String::from_utf8_lossy(&output.stdout).to_string(),
)
})
.await;
accept_task.abort();
let _ = std::fs::remove_dir_all(&work);
let (success, stdout) = match outcome {
Ok((success, stdout)) => (success, stdout),
Err(_) => panic!("the real ssh pty smoke exceeded its 30 s deadline"),
};
assert!(
success,
"vanilla OpenSSH's pty+shell session through the door exits 0 \
(the exit chunk derived exit-status(0) before close) — stdout: {stdout:?}"
);
}
}