Files
alkssh/tests/tty_mapping.rs
T
glm-5.3-flash b1e569d1a8 feat(session): land the session mapping — the translation bridge + the exit derivation (w3-tty-session)
The biggest single-row surface of the spine: pty/shell/pipe-exec serve
through the published alktty seam (drive_session_pre_negotiated) over a
translation bridge, re-landing POC-3 stage D as contract:

- src/tty_map.rs (session feature): the bridge (SSH data → Chunk::stdin
  through a bounded mpsc + framing task; SSH EOF → the zero-length
  stdin sentinel — verified against alktty's pump semantics; stdout →
  data; stderr → extended_data(1); exit chunk → the derivation
  contract), the locally-built negotiation (OQ-08: shell argv verbatim,
  wrapper ++ [cmd], cwd/env session-side, ttymodes → the reserved {}
  modes JSON), and the spawned serving path.
- serve_tty_row: driven chunks→SSH inline so the exit report is the
  path's final act (status before close, ADR-006 decision 4); two
  cancel signals (per-channel watch fired by channel_close, connection
  teardown fired by SessionHandler::Drop) abort the alktty session
  task — the ADR-056 kill-on-drop contract is the cancelled session's
  cleanup.
- Derivation contract rules 1–6 in derive_exit + the crate-local
  signum→name table: −N → exit-signal(name), −1 → exit-status(255),
  core_dumped never, unmapped negatives → 255, the PTY-path rule-5
  fidelity limit documented; wire rows pin the shapes.
- Negotiation-error frames (alktty's 0x00-length-prefixed error
  framing) → negotiation-failed serving decline + exit-status(255) +
  close — the one could-not-start claim this door makes (nothing ran).
- Control rows: window_change/signal → the per-channel BridgeSurface
  (bounded mpsc, try_send, never awaited on the packet loop); signal
  names verbatim (no decline shape — fire-and-forget); pre-serve
  resizes update the stored pty params (final size wins).
- Unset-declines (OQ-08) keep the git row's serving-path shape
  (failure then close, spawned): shell-unconfigured /
  wrapper-unconfigured / seam-unconfigured; pipe-less shell SERVES
  pipe-mode (the recorded vanilla-sshd posture); env_request decline
  unchanged (OQ-09).
- tests: the hand-rolled TtyBackend shim + ownership witness doubles;
  serving rows through the harness, the unset-field decline matrix,
  derivation rows on the wire, negotiation-failure rows, the identity
  ride asserted live at the seam, the kill witness; the real-tool
  smoke — OpenSSH -tt against a real PTY via dev-dep alktty local
  (test graph only), skip-with-reason posture. Wave-2 stub rows
  migrated to the new decline reasons (dispatch_core, observe).

Verification: cargo test (113) / --features session (149) / git (142) /
tunnel (115) / --all-features (161) — all green; cargo clippy
--all-targets -- -D warnings ×5 feature configs; cargo fmt --check;
cargo doc --no-deps ×3 builds clean; taskgraph validate.
2026-10-28 20:29:59 +00:00

967 lines
38 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 russh::client::Msg;
use russh::{Channel, ChannelMsg, Sig};
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};
/// 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),
) -> ServeConfig {
let mut serve = harness_serve_config(provider, &[fixture("bravo")], Limits::default());
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 {
let provider = Arc::new(MemIdentityProvider::new());
provider.register(
fixture("alpha").fingerprint,
identity("ssh-rider", &["ssh"]),
);
let adapter = SshAdapter::new(session_config(provider, mutate))
.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;
}
#[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:?}"
);
}
}