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.
967 lines
38 KiB
Rust
967 lines
38 KiB
Rust
//! 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:?}"
|
||
);
|
||
}
|
||
}
|