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.
This commit is contained in:
1 parent
7aac2d11a2
commit
b1e569d1a8
13 files changed
+3018
-155
No files matched your search
Generated
+156
-11
@@ -76,7 +76,7 @@ dependencies = [
|
||||
"parking_lot",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror",
|
||||
"thiserror 2.0.21",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"uuid",
|
||||
@@ -102,7 +102,7 @@ dependencies = [
|
||||
"parking_lot",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror",
|
||||
"thiserror 2.0.21",
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
]
|
||||
@@ -116,11 +116,14 @@ dependencies = [
|
||||
"alktty",
|
||||
"alkvault",
|
||||
"async-trait",
|
||||
"bytes",
|
||||
"gix-hash",
|
||||
"parking_lot",
|
||||
"russh",
|
||||
"thiserror",
|
||||
"serde_json",
|
||||
"thiserror 2.0.21",
|
||||
"tokio",
|
||||
"tokio-stream",
|
||||
"tracing",
|
||||
"zeroize",
|
||||
]
|
||||
@@ -137,11 +140,13 @@ dependencies = [
|
||||
"futures",
|
||||
"futures-core",
|
||||
"libc",
|
||||
"portable-pty",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror",
|
||||
"thiserror 2.0.21",
|
||||
"tokio",
|
||||
"tokio-stream",
|
||||
"tokio-util",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
@@ -160,7 +165,7 @@ dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2 0.10.9",
|
||||
"thiserror",
|
||||
"thiserror 2.0.21",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
@@ -179,6 +184,12 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anyhow"
|
||||
version = "1.0.104"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
|
||||
|
||||
[[package]]
|
||||
name = "argon2"
|
||||
version = "0.6.0"
|
||||
@@ -432,6 +443,12 @@ version = "1.0.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600"
|
||||
|
||||
[[package]]
|
||||
name = "cfg_aliases"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fd16c4719339c4530435d38e511904438d07cce7950afa3718a84ac36c10e89e"
|
||||
|
||||
[[package]]
|
||||
name = "cfg_aliases"
|
||||
version = "0.2.2"
|
||||
@@ -661,7 +678,7 @@ version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "71958f69fb2de856c5dd51ac9377e986ca1aa5919f931bf5395a807277f889a0"
|
||||
dependencies = [
|
||||
"thiserror",
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -729,6 +746,12 @@ dependencies = [
|
||||
"syn 3.0.9",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "downcast-rs"
|
||||
version = "1.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2"
|
||||
|
||||
[[package]]
|
||||
name = "dunce"
|
||||
version = "1.0.5"
|
||||
@@ -889,6 +912,17 @@ version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "64cd1e32ddd350061ae6edb1b082d7c54915b5c672c389143b9a63403a109f24"
|
||||
|
||||
[[package]]
|
||||
name = "filedescriptor"
|
||||
version = "0.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e40758ed24c9b2eeb76c35fb0aebc66c626084edd827e07e1552279814c6682d"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"thiserror 1.0.69",
|
||||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "find-msvc-tools"
|
||||
version = "0.1.15"
|
||||
@@ -2018,6 +2052,18 @@ dependencies = [
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nix"
|
||||
version = "0.28.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ab2156c4fce2f8df6c499cc1c763e4394b7482525bf2a9701c9d79d215f519e4"
|
||||
dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
"cfg-if",
|
||||
"cfg_aliases 0.1.1",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nix"
|
||||
version = "0.31.3"
|
||||
@@ -2026,7 +2072,7 @@ checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d"
|
||||
dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
"cfg-if",
|
||||
"cfg_aliases",
|
||||
"cfg_aliases 0.2.2",
|
||||
"libc",
|
||||
]
|
||||
|
||||
@@ -2193,7 +2239,7 @@ dependencies = [
|
||||
"log",
|
||||
"rand 0.10.3",
|
||||
"sha2 0.11.1",
|
||||
"thiserror",
|
||||
"thiserror 2.0.21",
|
||||
"tokio",
|
||||
"windows",
|
||||
"windows-strings",
|
||||
@@ -2351,6 +2397,27 @@ dependencies = [
|
||||
"portable-atomic",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "portable-pty"
|
||||
version = "0.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b4a596a2b3d2752d94f51fac2d4a96737b8705dddd311a32b9af47211f08671e"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bitflags 1.3.2",
|
||||
"downcast-rs",
|
||||
"filedescriptor",
|
||||
"lazy_static",
|
||||
"libc",
|
||||
"log",
|
||||
"nix 0.28.0",
|
||||
"serial2",
|
||||
"shared_library",
|
||||
"shell-words",
|
||||
"winapi",
|
||||
"winreg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "potential_utf"
|
||||
version = "0.1.6"
|
||||
@@ -2628,7 +2695,7 @@ dependencies = [
|
||||
"ssh-encoding",
|
||||
"ssh-key",
|
||||
"subtle",
|
||||
"thiserror",
|
||||
"thiserror 2.0.21",
|
||||
"tokio",
|
||||
"typenum",
|
||||
"universal-hash",
|
||||
@@ -2642,7 +2709,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3aec6cb630dbe85d72ffd7bcd95f07e1bd69f9f270ee8adfa1afe443a6331438"
|
||||
dependencies = [
|
||||
"log",
|
||||
"nix",
|
||||
"nix 0.31.3",
|
||||
"ssh-encoding",
|
||||
"windows-sys",
|
||||
]
|
||||
@@ -2797,6 +2864,17 @@ dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serial2"
|
||||
version = "0.2.38"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b16809bc35793b19ce4e0c53924bc0dce3937f15487997cfdaed936004180730"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"windows-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sha1"
|
||||
version = "0.11.0"
|
||||
@@ -2858,6 +2936,22 @@ dependencies = [
|
||||
"sponge-cursor",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "shared_library"
|
||||
version = "0.1.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5a9e7e0f2bfae24d8a5b5a66c5b257a83c7412304311512a0c054cd5e619da11"
|
||||
dependencies = [
|
||||
"lazy_static",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "shell-words"
|
||||
version = "1.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77"
|
||||
|
||||
[[package]]
|
||||
name = "shlex"
|
||||
version = "2.0.1"
|
||||
@@ -3037,13 +3131,33 @@ dependencies = [
|
||||
"windows-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thiserror"
|
||||
version = "1.0.69"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
|
||||
dependencies = [
|
||||
"thiserror-impl 1.0.69",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thiserror"
|
||||
version = "2.0.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e"
|
||||
dependencies = [
|
||||
"thiserror-impl",
|
||||
"thiserror-impl 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thiserror-impl"
|
||||
version = "1.0.69"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3330,6 +3444,22 @@ dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winapi"
|
||||
version = "0.3.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"
|
||||
dependencies = [
|
||||
"winapi-i686-pc-windows-gnu",
|
||||
"winapi-x86_64-pc-windows-gnu",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winapi-i686-pc-windows-gnu"
|
||||
version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
|
||||
|
||||
[[package]]
|
||||
name = "winapi-util"
|
||||
version = "0.1.11"
|
||||
@@ -3339,6 +3469,12 @@ dependencies = [
|
||||
"windows-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winapi-x86_64-pc-windows-gnu"
|
||||
version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
|
||||
|
||||
[[package]]
|
||||
name = "windows"
|
||||
version = "0.62.2"
|
||||
@@ -3458,6 +3594,15 @@ dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winreg"
|
||||
version = "0.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "80d0f4e272c85def139476380b12f9ac60926689dd2e01d4923222f40580869d"
|
||||
dependencies = [
|
||||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen"
|
||||
version = "0.57.1"
|
||||
|
||||
+19
-1
@@ -34,7 +34,7 @@ name = "alkssh"
|
||||
# the wave-3 config fields; alktunnels still lands with its first
|
||||
# consuming code, w3-tunnel-direct).
|
||||
default = []
|
||||
session = ["dep:alktty"]
|
||||
session = ["dep:alktty", "dep:serde_json", "dep:bytes"]
|
||||
git = ["dep:alkgit"]
|
||||
tunnel = []
|
||||
sftp = []
|
||||
@@ -58,6 +58,14 @@ alkvault = "0.1"
|
||||
# touches a real backend; the `local` shapes stay alktty-side.
|
||||
alktty = { version = "0.8.1", default-features = false, optional = true }
|
||||
alkgit = { version = "0.1.0", default-features = false, optional = true }
|
||||
# session-only: the OQ-44 reserved ttymodes JSON (`TerminalParamsWire.modes`)
|
||||
# and the exit-chunk / negotiation-error-frame JSON reads in `tty_map.rs`.
|
||||
# Same pin alktty carries (crate resolution stays single-versioned).
|
||||
serde_json = { version = "1", optional = true }
|
||||
# session-only: the alktty chunk plane is `bytes`-based (`Chunk.bytes`) —
|
||||
# the bridge builds sentinel/ctrl chunks with it. Same pin russh/alktty
|
||||
# carry (crate resolution stays single-versioned).
|
||||
bytes = { version = "1", optional = true }
|
||||
tokio = { version = "1.54", features = ["full"] }
|
||||
thiserror = "2.0"
|
||||
tracing = "0.1"
|
||||
@@ -69,6 +77,16 @@ zeroize = "1.9"
|
||||
# parameters and alkgit does not re-export them — test-graph only,
|
||||
# version matched to alkgit's `^0.28` req (same crate in the lockfile).
|
||||
gix-hash = { version = "0.28", features = ["sha1"] }
|
||||
# The session shim's scripted stdout/stderr streams ride tokio's
|
||||
# `ReceiverStream` — test-graph only (the crate proper hand-rolls no
|
||||
# streams).
|
||||
tokio-stream = "0.1"
|
||||
# The real-tool pty smoke (`w3-tty-session`): alktty's `local` backend
|
||||
# composition — the dev-dep `features = ["local"]` unification applies
|
||||
# to the TEST graph only (feature unification folds `local` into the
|
||||
# optional alktty dep in test builds; the published lib never enables
|
||||
# it and `src/` never names a local shape). Same 0.8.1 pin.
|
||||
alktty = { version = "0.8.1", default-features = false, features = ["local"] }
|
||||
|
||||
[profile.release]
|
||||
lto = "thin"
|
||||
@@ -69,13 +69,13 @@ the served listen flow is implementation-phase contract-test territory.*
|
||||
|
||||
| SSH request | Family mapping |
|
||||
|---|---|
|
||||
| `pty_request` | `NegotiateRequest.tty = TerminalParamsWire{…}` (POC-3 §D.1: live `stty size` honored) |
|
||||
| `shell_request` | `NegotiateRequest.cmd` = the **assembly-supplied serve-config shell argv** (no crate default; unset → clean decline, never a fallback — OQ-08 resolved; never wire-supplied) |
|
||||
| `exec_request` | parse → git dispatch (`git`) or alktty pipe `cmd` = exec wrapper argv with the command string appended (`wrapper ++ [cmd]`, OQ-08 — vanilla quoting semantics, never naively split; wrapper unset → clean decline); unknown → `channel_failure`, no resource alloc |
|
||||
| `pty_request` | `NegotiateRequest.tty = TerminalParamsWire{…}` (POC-3 §D.1: live `stty size` honored); ttymodes → the reserved modes JSON (OQ-44); a stored pty is consumed by the later `shell_request`/`exec_request` on the channel (`ssh -t host "cmd"`). A pre-serve `window_change_request` resizes the stored params (the final size wins) |
|
||||
| `shell_request` | `NegotiateRequest.cmd` = the **assembly-supplied serve-config shell argv** verbatim (no crate default; unset → the serving-path decline — failure then close; never a fallback — OQ-08 resolved; never wire-supplied). **A shell request without a preceding pty serves pipe-mode (`tty = None`)** — vanilla sshd's posture; `ssh -T` is legitimate vanilla |
|
||||
| `exec_request` | parse → git dispatch (`git`) or alktty pipe `cmd` = exec wrapper argv with the command string appended (`wrapper ++ [cmd]`, OQ-08 — vanilla quoting semantics, never naively split; wrapper unset → the serving-path decline — failure then close; never a fallback); unknown → `channel_failure`, no resource alloc |
|
||||
| `subsystem_request` | decline (per the rows table; honest, with reason) |
|
||||
| `env_request` | **declined unconditionally** (OQ-09 resolved; no allowlist ships) — wire env is not family config |
|
||||
| `window_change_request` | resize control (POC-3 §D.1: live resize proven) |
|
||||
| `signal` | signal control (alktty names verbatim); PTY mode reaches the process group, pipe mode is pid-only (backend semantics — fidelity note, OQ-10) |
|
||||
| `window_change_request` | resize control through the serving bridge (POC-3 §D.1: live resize proven); fire-and-forget — no SSH reply, so a full/closed control surface drops the event |
|
||||
| `signal` | signal control (alktty names verbatim — no door-side filter; PTY mode reaches the process group, pipe mode is pid-only — backend semantics, fidelity note, OQ-10); fire-and-forget, no decline shape |
|
||||
| data / `extended_data(1)` | the translation bridge (rows table) |
|
||||
| `exit_status` | from the alktty exit chunk — sent **before** close, from the spawned task via `Session::handle()` (POC-3 §D: proven both legs). Derivation contract rules 1, 3–6 below |
|
||||
| `exit_signal` | negative exit-chunk codes (the Unix pipe path's signal encoding) → `exit-signal(name)` — derivation contract rule 2; `−1` → `exit-status(255)`, never a signal claim (rule 3). PTY path: signal-terminated children arrive as a plain code 1 (portable-pty's exit surface) → `exit-status(1)` — the signal-identity loss is an upstream alktty/portable-pty constraint (rule 5) |
|
||||
|
||||
+422
-89
@@ -70,7 +70,7 @@
|
||||
//! capability leak — the POC-3 stage-A rule); the per-length
|
||||
//! `max_exec_command_len` cap is enforced before the parse itself.
|
||||
//!
|
||||
//! ## The store-and-take channel handoff (`w3-git-mapping`)
|
||||
//! The store-and-take channel handoff (`w3-git-mapping`)
|
||||
//!
|
||||
//! A confirmed `session` open **stores** its [`Channel`] in
|
||||
//! per-connection handler state ([`Self::take_stored_channel`]'s
|
||||
@@ -83,9 +83,43 @@
|
||||
//! mutation happens on the loop's task — single-writer, no lock. A
|
||||
//! git exec on a channel whose stored handle is already gone
|
||||
//! (re-served, or never held — unvanilla) declines serving-path.
|
||||
//! The other session rows currently leave the handle stored until
|
||||
//! the close releases it (the `w3-tty-session` mapping will take it
|
||||
//! the same way).
|
||||
//!
|
||||
//! ## The session rows' real serving paths (`w3-tty-session`)
|
||||
//!
|
||||
//! The pty/shell/pipe rows replaced their wave-3 skeleton stubs with
|
||||
//! the alktty translation bridge ([`crate::tty_map`]): the pty row
|
||||
//! stores the negotiated `TerminalParamsWire` ([`Self::ptys`]'s
|
||||
//! supplier — taken by the later shell/exec request on the same
|
||||
//! channel, `ssh -t host "cmd"`'s pty-then-exec); the shell and pipe
|
||||
//! rows take the stored channel the same way the git row does and
|
||||
//! spawn `serve_tty_row` over the configured seam — locally-built
|
||||
//! negotiation, unset-fields-decline (OQ-08: `shell_argv` unset →
|
||||
//! the shell row declines, `exec_wrapper_argv` unset → the pipe row
|
||||
//! declines, `session_seam` unset → both rows and the pty row
|
||||
//! decline `seam-unconfigured`; every one of them the serving-path
|
||||
//! failure+close shape from a spawned task). A routed request
|
||||
//! answers `channel_success` inline (the request routed; the exit
|
||||
//! chunk reports the outcome later, status-before-close from the
|
||||
//! spawned task).
|
||||
//!
|
||||
//! ## The control rows' per-channel surface (`w3-tty-session`)
|
||||
//!
|
||||
//! `window_change_request` and `signal` arrive on the packet loop
|
||||
//! **after** the serving task owns the wire — the spawned bridge is
|
||||
//! reached through [`Self::bridges`]' per-channel surface
|
||||
//! ([`crate::tty_map::BridgeSurface`]: the bounded control mpsc's
|
||||
//! `try_send` — never awaited on the packet loop — plus the
|
||||
//! per-channel cancel). A resize for a channel whose pty is stored
|
||||
//! but not yet serving updates the stored params (the final size
|
||||
//! wins); signal names are accepted verbatim (the backend owns the
|
||||
//! semantics). Unfeatured builds emit the request-level decline
|
||||
//! event with no reply — the control rows are fire-and-forget, no
|
||||
//! SSH reply exists to send. [`Self::channel_close`] releases the
|
||||
//! surface (firing the cancel: the alktty kill-on-drop contract is
|
||||
//! the cancelled session's cleanup) along with the channel and pty
|
||||
//! stores. The **connection teardown** signal ([`crate::tty_map::
|
||||
//! Teardown`]) fires from this handler's `Drop` so a serving task
|
||||
//! can never outlive the russh session that spawned it.
|
||||
//!
|
||||
//! ## The spawn-and-return structural constraint (ADR-006 decision 4)
|
||||
//!
|
||||
@@ -94,15 +128,17 @@
|
||||
//! routing and deadlocks against the peer's follow-up bytes (POC-3
|
||||
//! proved by construction with receive-pack). Every serving path is
|
||||
//! therefore spawned through [`crate::serve::spawn_serving_path`] and
|
||||
//! the handler returns fast: the session-row seam stubs clone
|
||||
//! `session.handle()`, spawn their decline through the clone
|
||||
//! ([`crate::serve`]), and are gone from the hook's stack — no hook
|
||||
//! the handler returns fast: the serving rows (the git row's alkgit
|
||||
//! dispatch, the session rows' config-driven declines and
|
||||
//! [`crate::tty_map`]'s bridge) all clone `session.handle()`, spawn
|
||||
//! through the clone, and are gone from the hook's stack — no hook
|
||||
//! awaits a serving path inline. The decline keeps its wire shape
|
||||
//! (failure then close), riding the spawn. Exit/status reporting from
|
||||
//! spawned tasks rides the same cloned handle
|
||||
//! ([`crate::serve::report_exit_status_then_close`] — the mechanics;
|
||||
//! the derivation rules are wave-3 `mapping.md` material), pinned
|
||||
//! end-to-end by the mechanics rows in `tests/spawn_return.rs`.
|
||||
//! the tty rows' derivation rules live in [`crate::tty_map`]), pinned
|
||||
//! end-to-end by the mechanics rows in `tests/spawn_return.rs` and the
|
||||
//! mapping rows in `tests/git_mapping.rs` / `tests/tty_mapping.rs`.
|
||||
//!
|
||||
//! The open-level declines (unfeatured/never-feature rows, the tunnel
|
||||
//! seam stub) stay inline by class: they answer the open *before* any
|
||||
@@ -141,16 +177,21 @@
|
||||
//! session-survives-declines posture) — including the git grammar's
|
||||
//! declinables (`unknown-service` / `service-only` /
|
||||
//! `invalid-repo-id`, the parsed reply shapes, `w3-git-mapping`);
|
||||
//! a *serving-path* decline (a routable row that owns the channel:
|
||||
//! the seam-unconfigured git row and the unvanilla
|
||||
//! channel-not-held / identity-less guards) answers failure and
|
||||
//! closes the channel — the vanilla-clean shape for a row that owns
|
||||
//! the channel. The serving-path decline rides the spawned serving
|
||||
//! task (ADR-006 decision 4, §The spawn-and-return structural
|
||||
//! constraint above); the wire shape is unchanged. The real
|
||||
//! serve's own outcomes are not declines: the substrate maps them
|
||||
//! (denial band before any protocol byte; exit-status then close —
|
||||
//! the `exit_code` mapping decision recorded in [`crate::git_map`]).
|
||||
//! a *serving-path* decline (a routable row that owns the request's
|
||||
//! whole outcome: the seam-unconfigured git row, the session rows'
|
||||
//! unset-field and unset-seam declines — `w3-tty-session`'s OQ-08
|
||||
//! shapes — and the identity-less guard) answers failure and closes
|
||||
//! the channel — the vanilla-clean shape for a row whose serving
|
||||
//! path cannot exist. The serving-path decline rides the spawned
|
||||
//! serving task (ADR-006 decision 4, §The spawn-and-return
|
||||
//! structural constraint above); the wire shape is unchanged. The
|
||||
//! `channel-not-held` guard is the inline request-level shape (the
|
||||
//! channel that could not be held stays usable). The real serve's
|
||||
//! own outcomes are not declines: the substrate maps them (denial
|
||||
//! band before any protocol byte; exit-status then close — the
|
||||
//! `exit_code` mapping decision recorded in [`crate::git_map`];
|
||||
//! the tty bridge's negotiation-error and exit-chunk decisions in
|
||||
//! [`crate::tty_map`]).
|
||||
//!
|
||||
//! ## The event vocabulary on every decline (`w3-decline-tracing`)
|
||||
//!
|
||||
@@ -199,20 +240,27 @@ use std::sync::Arc;
|
||||
|
||||
use alkcall::core::auth::Identity;
|
||||
use russh::keys::Algorithm;
|
||||
use russh::server::{
|
||||
Auth, ChannelOpenHandle, Handle, Msg, PublicKeyOffer, Session, VerifiedPublicKey,
|
||||
};
|
||||
use russh::{Channel, ChannelId, Pty};
|
||||
#[cfg(any(feature = "git", feature = "session"))]
|
||||
use russh::server::Handle;
|
||||
use russh::server::{Auth, ChannelOpenHandle, Msg, PublicKeyOffer, Session, VerifiedPublicKey};
|
||||
use russh::{Channel, ChannelId, Pty, Sig};
|
||||
|
||||
use crate::channel_cap::ChannelCap;
|
||||
use crate::config::ServeConfig;
|
||||
#[cfg(feature = "session")]
|
||||
use crate::config::SessionSeam;
|
||||
use crate::dispatch::{self, ExecRoute};
|
||||
use crate::error::AdapterError;
|
||||
use crate::fingerprint::fingerprint;
|
||||
#[cfg(feature = "git")]
|
||||
use crate::git_map;
|
||||
use crate::observe;
|
||||
#[cfg(any(feature = "git", feature = "session"))]
|
||||
use crate::serve;
|
||||
#[cfg(feature = "session")]
|
||||
use crate::tty_map;
|
||||
#[cfg(feature = "session")]
|
||||
use alktty::negotiation::NegotiateRequest;
|
||||
|
||||
/// The per-connection handler (one per mounted stream): russh constructs
|
||||
/// nothing here — [`crate::SshAdapter::handle`] builds one per accepted
|
||||
@@ -231,10 +279,39 @@ pub struct SessionHandler {
|
||||
/// The store-and-take handoff (`w3-git-mapping`): confirmed
|
||||
/// session channels held from the open until their row takes or
|
||||
/// the close releases them. Written only by the packet loop's
|
||||
/// hooks (`channel_open_session` inserts; `exec_request` removes
|
||||
/// for the git row; `channel_close` removes) — single-writer, no
|
||||
/// lock.
|
||||
/// hooks (`channel_open_session` inserts; the git row's and the
|
||||
/// session rows' serving dispatches remove for their taken
|
||||
/// channel; `channel_close` removes) — single-writer, no lock.
|
||||
channels: HashMap<ChannelId, Channel<Msg>>,
|
||||
/// The session rows' negotiated pty params (`w3-tty-session`):
|
||||
/// `pty_request` stores, the later `shell_request`/`exec_request`
|
||||
/// on the same channel consumes (`ssh -t host "cmd"`'s
|
||||
/// pty-then-exec), the close releases. Packet-loop only.
|
||||
#[cfg(feature = "session")]
|
||||
ptys: HashMap<ChannelId, alktty::negotiation::TerminalParamsWire>,
|
||||
/// The per-channel control surfaces (`w3-tty-session`): installed
|
||||
/// when a session row's serving task spawns, forwarded to by
|
||||
/// `window_change_request`/`signal`, fired-and-removed by
|
||||
/// `channel_close`. Packet-loop only.
|
||||
#[cfg(feature = "session")]
|
||||
bridges: HashMap<ChannelId, tty_map::BridgeSurface>,
|
||||
/// The connection-teardown signal (`w3-tty-session`): fired by
|
||||
/// this handler's [`Drop`](Self::drop) so a serving task never
|
||||
/// outlives the russh session that spawned it (the alktty
|
||||
/// kill-on-drop contract is the cancelled session's cleanup).
|
||||
#[cfg(feature = "session")]
|
||||
teardown: tty_map::Teardown,
|
||||
}
|
||||
|
||||
#[cfg(feature = "session")]
|
||||
impl Drop for SessionHandler {
|
||||
/// Fires the session rows' teardown signal (`w3-tty-session`):
|
||||
/// every spawned serving task of this connection watches it and
|
||||
/// cancels its alktty session (the kill-on-drop cleanup) instead
|
||||
/// of outliving the connection.
|
||||
fn drop(&mut self) {
|
||||
self.teardown.fire();
|
||||
}
|
||||
}
|
||||
|
||||
impl SessionHandler {
|
||||
@@ -248,15 +325,22 @@ impl SessionHandler {
|
||||
channel_cap: ChannelCap::default(),
|
||||
resolved_identity: None,
|
||||
channels: HashMap::new(),
|
||||
#[cfg(feature = "session")]
|
||||
ptys: HashMap::new(),
|
||||
#[cfg(feature = "session")]
|
||||
bridges: HashMap::new(),
|
||||
#[cfg(feature = "session")]
|
||||
teardown: tty_map::Teardown::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Takes a stored channel handle (the store half of the
|
||||
/// store-and-take handoff): the git row's dispatch removes the
|
||||
/// handle the open stored, handing the stream into the spawned
|
||||
/// serving task. `None` when nothing is held — a re-served or
|
||||
/// never-held channel (unvanilla; declined serving-path).
|
||||
#[cfg(feature = "git")]
|
||||
/// store-and-take handoff): the git row's and the session rows'
|
||||
/// dispatches remove the handle the open stored, handing the
|
||||
/// stream into the spawned serving task. `None` when nothing is
|
||||
/// held — a re-served or never-held channel (unvanilla; declined
|
||||
/// serving-path).
|
||||
#[cfg(any(feature = "git", feature = "session"))]
|
||||
fn take_stored_channel(&mut self, channel: ChannelId) -> Option<Channel<Msg>> {
|
||||
self.channels.remove(&channel)
|
||||
}
|
||||
@@ -343,6 +427,7 @@ impl SessionHandler {
|
||||
|
||||
/// The session-row gate (`pty`/`shell`/pipe-exec): only the
|
||||
/// `session` feature serves these rows.
|
||||
#[cfg(feature = "session")]
|
||||
fn serves_session_rows(&self) -> bool {
|
||||
cfg!(feature = "session")
|
||||
}
|
||||
@@ -452,6 +537,13 @@ impl russh::server::Handler for SessionHandler {
|
||||
_session: &mut Session,
|
||||
) -> Result<(), Self::Error> {
|
||||
self.channels.remove(&channel);
|
||||
#[cfg(feature = "session")]
|
||||
{
|
||||
self.ptys.remove(&channel);
|
||||
if let Some(surface) = self.bridges.remove(&channel) {
|
||||
tty_map::fire_cancel(&surface.cancel);
|
||||
}
|
||||
}
|
||||
self.channel_cap.release();
|
||||
Ok(())
|
||||
}
|
||||
@@ -644,62 +736,220 @@ impl russh::server::Handler for SessionHandler {
|
||||
decline_unfeatured_request(channel, session).await;
|
||||
}
|
||||
ExecRoute::Pipe => {
|
||||
#[cfg(feature = "session")]
|
||||
if self.serves_session_rows() {
|
||||
pipe_row_stub(channel, session);
|
||||
} else {
|
||||
observe::decline_request(
|
||||
"exec_request",
|
||||
"unfeatured",
|
||||
channel.number(),
|
||||
Some("pipe"),
|
||||
);
|
||||
decline_unfeatured_request(channel, session).await;
|
||||
pipe_row_dispatch(self, channel, session, command.to_owned());
|
||||
return Ok(());
|
||||
}
|
||||
#[cfg(not(feature = "session"))]
|
||||
let _ = &command;
|
||||
observe::decline_request(
|
||||
"exec_request",
|
||||
"unfeatured",
|
||||
channel.number(),
|
||||
Some("pipe"),
|
||||
);
|
||||
decline_unfeatured_request(channel, session).await;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The `pty_request` row route (`session`): featured routes to
|
||||
/// the session-row seam stub; unfeatured declines the capability.
|
||||
/// The `pty_request` row (`session`): the args →
|
||||
/// `TerminalParamsWire` (ttymodes → the reserved `{}` JSON,
|
||||
/// OQ-44), stored per channel for the later shell/exec request on
|
||||
/// the same channel — after the seam check (a pty whose session
|
||||
/// can never serve is a declined allocation,
|
||||
/// `seam-unconfigured`). Unfeatured builds decline the
|
||||
/// capability. The successful row answers `channel_success`
|
||||
/// (POC-3 §D.1's shape — serving starts at the shell/exec
|
||||
/// request).
|
||||
async fn pty_request(
|
||||
&mut self,
|
||||
channel: ChannelId,
|
||||
_term: &str,
|
||||
_col_width: u32,
|
||||
_row_height: u32,
|
||||
_pix_width: u32,
|
||||
_pix_height: u32,
|
||||
term: &str,
|
||||
col_width: u32,
|
||||
row_height: u32,
|
||||
pix_width: u32,
|
||||
pix_height: u32,
|
||||
_modes: &[(Pty, u32)],
|
||||
session: &mut Session,
|
||||
) -> Result<(), Self::Error> {
|
||||
if self.serves_session_rows() {
|
||||
pty_row_stub(channel, session);
|
||||
} else {
|
||||
#[cfg(feature = "session")]
|
||||
match self.serve.session_seam.clone() {
|
||||
Some(_) => {
|
||||
self.ptys.insert(
|
||||
channel,
|
||||
tty_map::terminal_params(term, col_width, row_height, pix_width, pix_height),
|
||||
);
|
||||
let _ = session.channel_success(channel);
|
||||
}
|
||||
None => {
|
||||
spawn_seam_stub(channel, session, "pty_request", None, "seam-unconfigured");
|
||||
}
|
||||
}
|
||||
#[cfg(not(feature = "session"))]
|
||||
{
|
||||
let _ = (term, col_width, row_height, pix_width, pix_height);
|
||||
observe::decline_request("pty_request", "unfeatured", channel.number(), None);
|
||||
decline_unfeatured_request(channel, session).await;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The `shell_request` row route (`session`): the same shape as
|
||||
/// `pty_request`. The OQ-08 unset-shell check is the serving
|
||||
/// path's consumption (wave 3): this wave the stub declines the
|
||||
/// row regardless, so the unset-declines posture already holds.
|
||||
/// The `shell_request` row (`session`): the serve-config shell
|
||||
/// argv, verbatim, as `NegotiateRequest.cmd`; the stored pty
|
||||
/// params consumed (`None` → pipe-mode shell — decided: a pty-less
|
||||
/// shell request is served, `ssh -T host` is legitimate vanilla);
|
||||
/// unset-declines first (OQ-08: `shell_argv` unset →
|
||||
/// `shell-unconfigured`; the seam unset → `seam-unconfigured`
|
||||
/// — both the spawned serving-path shape). A routed request
|
||||
/// answers `channel_success` inline; the exit chunk reports the
|
||||
/// outcome, status-before-close from the spawned task.
|
||||
async fn shell_request(
|
||||
&mut self,
|
||||
channel: ChannelId,
|
||||
session: &mut Session,
|
||||
) -> Result<(), Self::Error> {
|
||||
if self.serves_session_rows() {
|
||||
shell_row_stub(channel, session);
|
||||
} else {
|
||||
#[cfg(feature = "session")]
|
||||
match (
|
||||
self.serve.shell_argv.clone(),
|
||||
self.serve.session_seam.clone(),
|
||||
) {
|
||||
(None, _) => {
|
||||
spawn_seam_stub(
|
||||
channel,
|
||||
session,
|
||||
"shell_request",
|
||||
None,
|
||||
"shell-unconfigured",
|
||||
);
|
||||
}
|
||||
(_, None) => {
|
||||
spawn_seam_stub(channel, session, "shell_request", None, "seam-unconfigured");
|
||||
}
|
||||
(Some(shell_argv), Some(seam)) => {
|
||||
let tty = self.ptys.remove(&channel);
|
||||
let Some(taken) = self.take_stored_channel(channel) else {
|
||||
observe::decline_request(
|
||||
"shell_request",
|
||||
"channel-not-held",
|
||||
channel.number(),
|
||||
None,
|
||||
);
|
||||
let _ = session.channel_failure(channel);
|
||||
return Ok(());
|
||||
};
|
||||
let request = tty_map::negotiate_request(
|
||||
seam.backend.clone(),
|
||||
tty,
|
||||
shell_argv,
|
||||
self.serve.session_cwd.clone(),
|
||||
self.serve.session_env.clone(),
|
||||
);
|
||||
spawn_tty_row(
|
||||
self,
|
||||
channel,
|
||||
session,
|
||||
"shell_request",
|
||||
None,
|
||||
taken,
|
||||
request,
|
||||
seam,
|
||||
);
|
||||
}
|
||||
}
|
||||
#[cfg(not(feature = "session"))]
|
||||
{
|
||||
observe::decline_request("shell_request", "unfeatured", channel.number(), None);
|
||||
decline_unfeatured_request(channel, session).await;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `window_change_request` (`session`): the resize control row —
|
||||
/// forwarded into the serving bridge's bounded control surface
|
||||
/// (`try_send`, never awaited on the packet loop); for a channel
|
||||
/// whose pty is stored but not yet serving, the stored params
|
||||
/// resize instead (the final size wins — vanilla sshd's own
|
||||
/// ordering); with neither, the event is dropped. Fire-and-forget
|
||||
/// on the wire — no SSH reply exists, so a decline sends none
|
||||
/// (the event is the record). Signal names: see [`Self::signal`].
|
||||
async fn window_change_request(
|
||||
&mut self,
|
||||
channel: ChannelId,
|
||||
col_width: u32,
|
||||
row_height: u32,
|
||||
pix_width: u32,
|
||||
pix_height: u32,
|
||||
_session: &mut Session,
|
||||
) -> Result<(), Self::Error> {
|
||||
#[cfg(feature = "session")]
|
||||
{
|
||||
let cols = tty_map::clamp_u16(col_width);
|
||||
let rows = tty_map::clamp_u16(row_height);
|
||||
let pix_w = tty_map::clamp_u16(pix_width);
|
||||
let pix_h = tty_map::clamp_u16(pix_height);
|
||||
if let Some(surface) = self.bridges.get(&channel) {
|
||||
let message = alktty::ControlMessage::Resize {
|
||||
cols,
|
||||
rows,
|
||||
pixel_width: pix_w,
|
||||
pixel_height: pix_h,
|
||||
};
|
||||
let _ = surface.forward_control(&message);
|
||||
} else if let Some(params) = self.ptys.get_mut(&channel) {
|
||||
params.cols = cols;
|
||||
params.rows = rows;
|
||||
params.pixel_width = pix_w;
|
||||
params.pixel_height = pix_h;
|
||||
params.modes = serde_json::json!({});
|
||||
}
|
||||
}
|
||||
#[cfg(not(feature = "session"))]
|
||||
{
|
||||
let _ = (col_width, row_height, pix_width, pix_height);
|
||||
observe::decline_request(
|
||||
"window_change_request",
|
||||
"unfeatured",
|
||||
channel.number(),
|
||||
None,
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The `signal` row (`session`): accepted verbatim — no
|
||||
/// alkssh-side name filter (the backend owns the semantics;
|
||||
/// alktty's control handle applies its own default kill for
|
||||
/// names it rejects, and the pipe-mode pid-only vs PTY
|
||||
/// process-group scope is backend semantics) — so there is no
|
||||
/// decline shape: the request is fire-and-forget on the SSH
|
||||
/// wire, and the bridge's bounded surface is the only path. An
|
||||
/// unfeatured build emits the decline event with no reply.
|
||||
async fn signal(
|
||||
&mut self,
|
||||
channel: ChannelId,
|
||||
signal: Sig,
|
||||
_session: &mut Session,
|
||||
) -> Result<(), Self::Error> {
|
||||
#[cfg(feature = "session")]
|
||||
{
|
||||
if let Some(surface) = self.bridges.get(&channel) {
|
||||
let message = alktty::ControlMessage::Signal {
|
||||
name: tty_map::sig_control_name(&signal),
|
||||
};
|
||||
let _ = surface.forward_control(&message);
|
||||
}
|
||||
}
|
||||
#[cfg(not(feature = "session"))]
|
||||
{
|
||||
let _ = signal;
|
||||
observe::decline_request("signal", "unfeatured", channel.number(), None);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Every subsystem name declines (unimplemented on this door):
|
||||
/// `subsystem("sftp")` is the OQ-13 placeholder — the feature
|
||||
/// exists, the route does not until alkfs lands; git service
|
||||
@@ -823,21 +1073,22 @@ async fn decline_unfeatured_request(channel: ChannelId, session: &mut Session) {
|
||||
let _ = session.channel_failure(channel);
|
||||
}
|
||||
|
||||
/// The family-seam decline shape for a **routed-but-not-yet-served**
|
||||
/// row — now the spawned serving task's body: fail the request and
|
||||
/// close the channel, through the cloned handle the task was handed.
|
||||
/// The vanilla-clean shape for a serving path that owns the channel
|
||||
/// (wave 3 replaces these stubs with the real serving paths; a
|
||||
/// declined seam never leaks a half-served channel, since the decline
|
||||
/// IS the row this wave). Decline-first-alloc-never holds structurally:
|
||||
/// nothing is allocated before the failure lands — the spawn's handle
|
||||
/// clone is the only resource ever taken.
|
||||
/// The spawned serving-path decline body (`w3-git-mapping`'s shape,
|
||||
/// reused by `w3-tty-session`'s config-driven declines): fail the
|
||||
/// request and close the channel, through the cloned handle the task
|
||||
/// was handed — the vanilla-clean shape for a routed row that owns
|
||||
/// the request's whole outcome. The helper name keeps the wave-3
|
||||
/// history (the decline IS the path it spawns). Decline-first-alloc-
|
||||
/// never holds structurally: nothing is allocated before the failure
|
||||
/// lands — the spawn's handle clone is the only resource ever taken.
|
||||
///
|
||||
/// The serving-path lifecycle events ride this body
|
||||
/// ([`crate::observe`]): started (the task's first action), the
|
||||
/// serving-path decline (class `serving`, reason `seam-stub`), and —
|
||||
/// after the wire reporting has completed — ended, or failed when a
|
||||
/// report send could not complete (the one warn in the vocabulary).
|
||||
/// serving-path decline (class `serving`, the caller's vocabulary
|
||||
/// reason), and — after the wire reporting has completed — ended, or
|
||||
/// failed when a report send could not complete (the one warn in the
|
||||
/// vocabulary).
|
||||
#[cfg(any(feature = "git", feature = "session"))]
|
||||
async fn seam_stub_decline(
|
||||
handle: Handle,
|
||||
channel: ChannelId,
|
||||
@@ -862,6 +1113,7 @@ async fn seam_stub_decline(
|
||||
/// task, and the hook returns — no serving path awaits inline. The
|
||||
/// row never serves, so the decline *is* the path: failure + close,
|
||||
/// event-shaped ([`crate::observe`]) with the caller's reason.
|
||||
#[cfg(any(feature = "git", feature = "session"))]
|
||||
fn spawn_seam_stub(
|
||||
channel: ChannelId,
|
||||
session: &mut Session,
|
||||
@@ -929,28 +1181,109 @@ fn git_row_dispatch(
|
||||
let _ = session.channel_success(channel);
|
||||
}
|
||||
|
||||
/// The session pipe row's seam stub — wave 3 lands the alktty pipe
|
||||
/// seam here (`wrapper ++ [cmd]`, the OQ-08 argv append), spawned per
|
||||
/// ADR-006 decision 4; the skeleton declines at the seam, on its own
|
||||
/// task.
|
||||
fn pipe_row_stub(channel: ChannelId, session: &mut Session) {
|
||||
spawn_seam_stub(channel, session, "exec_request", Some("pipe"), "seam-stub");
|
||||
/// The session pipe row's dispatch (`w3-tty-session`): unset-declines
|
||||
/// first (OQ-08 — `exec_wrapper_argv` unset → `wrapper-unconfigured`;
|
||||
/// the seam unset → `seam-unconfigured`; both the spawned serving-path
|
||||
/// shape, never a fallback), then the store-and-take handoff
|
||||
/// (`channel-not-held` → the inline unvanilla decline, the git row's
|
||||
/// shape), the wire command appended to the wrapper argv (`wrapper ++
|
||||
/// [cmd]` — never naively split), the stored pty params consumed if a
|
||||
/// pty preceded, and the spawned bridge. A routed exec answers
|
||||
/// `channel_success` inline (the request routed; the exit chunk
|
||||
/// reports the serving outcome later).
|
||||
#[cfg(feature = "session")]
|
||||
fn pipe_row_dispatch(
|
||||
handler: &mut SessionHandler,
|
||||
channel: ChannelId,
|
||||
session: &mut Session,
|
||||
command: String,
|
||||
) {
|
||||
let Some(mut cmd) = handler.serve.exec_wrapper_argv.clone() else {
|
||||
spawn_seam_stub(
|
||||
channel,
|
||||
session,
|
||||
"exec_request",
|
||||
Some("pipe"),
|
||||
"wrapper-unconfigured",
|
||||
);
|
||||
return;
|
||||
};
|
||||
let Some(seam) = handler.serve.session_seam.clone() else {
|
||||
spawn_seam_stub(
|
||||
channel,
|
||||
session,
|
||||
"exec_request",
|
||||
Some("pipe"),
|
||||
"seam-unconfigured",
|
||||
);
|
||||
return;
|
||||
};
|
||||
let tty = handler.ptys.remove(&channel);
|
||||
let Some(taken) = handler.take_stored_channel(channel) else {
|
||||
observe::decline_request(
|
||||
"exec_request",
|
||||
"channel-not-held",
|
||||
channel.number(),
|
||||
Some("pipe"),
|
||||
);
|
||||
let _ = session.channel_failure(channel);
|
||||
return;
|
||||
};
|
||||
cmd.push(command);
|
||||
let request = tty_map::negotiate_request(
|
||||
seam.backend.clone(),
|
||||
tty,
|
||||
cmd,
|
||||
handler.serve.session_cwd.clone(),
|
||||
handler.serve.session_env.clone(),
|
||||
);
|
||||
spawn_tty_row(
|
||||
handler,
|
||||
channel,
|
||||
session,
|
||||
"exec_request",
|
||||
Some("pipe"),
|
||||
taken,
|
||||
request,
|
||||
seam,
|
||||
);
|
||||
}
|
||||
|
||||
/// The session pty row's seam stub — wave 3 lands the alktty
|
||||
/// `NegotiateRequest.tty` construction here
|
||||
/// (`drive_session_pre_negotiated`), spawned per ADR-006 decision 4;
|
||||
/// the skeleton declines at the seam, on its own task.
|
||||
fn pty_row_stub(channel: ChannelId, session: &mut Session) {
|
||||
spawn_seam_stub(channel, session, "pty_request", None, "seam-stub");
|
||||
}
|
||||
|
||||
/// The session shell row's seam stub — wave 3 lands the alktty
|
||||
/// `NegotiateRequest.cmd` = serve-config shell argv here (OQ-08, no
|
||||
/// crate default), spawned per ADR-006 decision 4; the skeleton
|
||||
/// declines at the seam, on its own task.
|
||||
fn shell_row_stub(channel: ChannelId, session: &mut Session) {
|
||||
spawn_seam_stub(channel, session, "shell_request", None, "seam-stub");
|
||||
/// Installs the per-channel bridge surface, clones the session
|
||||
/// handle, and spawns the spawned serving path — the session rows'
|
||||
/// spawn step (ADR-006 decision 4: the handler returns fast; the
|
||||
/// routed request answers `channel_success` inline).
|
||||
#[cfg(feature = "session")]
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn spawn_tty_row(
|
||||
handler: &mut SessionHandler,
|
||||
channel: ChannelId,
|
||||
session: &mut Session,
|
||||
row: &'static str,
|
||||
exec_route: Option<&'static str>,
|
||||
taken: Channel<Msg>,
|
||||
request: NegotiateRequest,
|
||||
seam: SessionSeam,
|
||||
) {
|
||||
let (surface, chunk_rx, cancel_rx) = tty_map::build_surface();
|
||||
let stdin_tx = surface.sender.clone();
|
||||
handler.bridges.insert(channel, surface);
|
||||
let identity = handler.resolved_identity.clone();
|
||||
let handle = session.handle();
|
||||
serve::spawn_serving_path(tty_map::serve_tty_row(
|
||||
row,
|
||||
exec_route,
|
||||
handle,
|
||||
taken,
|
||||
request,
|
||||
identity,
|
||||
seam,
|
||||
stdin_tx,
|
||||
chunk_rx,
|
||||
cancel_rx,
|
||||
handler.teardown.subscribe(),
|
||||
));
|
||||
let _ = session.channel_success(channel);
|
||||
}
|
||||
|
||||
/// The tunnel row's seam stub: the explicit `ConnectFailed` reject
|
||||
|
||||
@@ -37,6 +37,8 @@ mod host_key;
|
||||
mod key_source;
|
||||
mod observe;
|
||||
mod serve;
|
||||
#[cfg(feature = "session")]
|
||||
mod tty_map;
|
||||
|
||||
pub use adapter::SshAdapter;
|
||||
#[cfg(feature = "git")]
|
||||
|
||||
+17
-4
@@ -65,9 +65,15 @@
|
||||
//! classifications: `"unfeatured"` (feature-gate absence),
|
||||
//! `"never-feature"` (ADR-003 §5 rows), `"cap-exceeded"`,
|
||||
//! `"connect-failed"` (the tunnel seam stub's `ConnectFailed`),
|
||||
//! `"seam-stub"` (the session-row seam stubs),
|
||||
//! `"seam-unconfigured"` (the config-driven git-row decline —
|
||||
//! feature on, [`crate::GitSeam`] unset; `w3-git-mapping`),
|
||||
//! `"seam-unconfigured"` (the config-driven row decline — the git
|
||||
//! row and, now, the session rows: feature on, seam unset;
|
||||
//! `w3-git-mapping` / `w3-tty-session`),
|
||||
//! `"shell-unconfigured"` / `"wrapper-unconfigured"` (`w3-tty-
|
||||
//! session`: the OQ-08 unset-field rows — feature on, the
|
||||
//! serve-config field unset, never a fallback),
|
||||
//! `"negotiation-failed"` (`w3-tty-session`: the alktty
|
||||
//! negotiation error frame the bridge received — the session never
|
||||
//! started; the frame's JSON detail stays alktty-side),
|
||||
//! `"unknown-service"` / `"service-only"` / `"invalid-repo-id"`
|
||||
//! (the exec grammar's git declinables — the parse core's two and
|
||||
//! the git row's validation gate; no service token or repo string
|
||||
@@ -210,8 +216,9 @@ pub(crate) fn decline_global_request(row: &'static str, reason: &'static str) {
|
||||
/// A serving-path decline: a routed row whose serving task answers
|
||||
/// failure-then-close rather than serving. This wave that is the seam
|
||||
/// stub (the wire shape pinned in `tests/spawn_return.rs`); the wave-3
|
||||
/// mappings emit it from their real serving tasks wherever serving
|
||||
/// mappings emit it from their real spawned paths wherever serving
|
||||
/// itself cannot proceed — the class the spawned paths inherit.
|
||||
#[cfg(any(feature = "git", feature = "session"))]
|
||||
pub(crate) fn decline_serving(
|
||||
row: &'static str,
|
||||
reason: &'static str,
|
||||
@@ -232,6 +239,7 @@ pub(crate) fn decline_serving(
|
||||
/// Spawned-path started — emitted as the spawned task's first action,
|
||||
/// so "started" is the task's own honest start, not the hook's spawn
|
||||
/// call. The mapping tasks' serving paths inherit this shape.
|
||||
#[cfg(any(feature = "git", feature = "session"))]
|
||||
pub(crate) fn serving_path_started(
|
||||
row: &'static str,
|
||||
channel_id: u32,
|
||||
@@ -249,6 +257,7 @@ pub(crate) fn serving_path_started(
|
||||
/// Spawned-path ended — the serving future finished (cleanly, or with
|
||||
/// the decline it was wired to answer); emitted *after* the path's
|
||||
/// wire reporting has completed.
|
||||
#[cfg(any(feature = "git", feature = "session"))]
|
||||
pub(crate) fn serving_path_ended(
|
||||
row: &'static str,
|
||||
channel_id: u32,
|
||||
@@ -269,6 +278,7 @@ pub(crate) fn serving_path_ended(
|
||||
/// the family's recoverable-failure level. Only russh protocol errors
|
||||
/// ride the `error` field (§Safety posture); the mapping tasks emit
|
||||
/// this from their real paths when serving fails before reporting.
|
||||
#[cfg(any(feature = "git", feature = "session"))]
|
||||
pub(crate) fn serving_path_failed<E: std::fmt::Debug>(
|
||||
row: &'static str,
|
||||
channel_id: u32,
|
||||
@@ -292,7 +302,9 @@ mod tests {
|
||||
/// A fake error shape — the failure row only pins that a Debug
|
||||
/// error value rides the `error` field.
|
||||
#[derive(Debug)]
|
||||
#[cfg(any(feature = "git", feature = "session"))]
|
||||
struct Probe;
|
||||
#[cfg(any(feature = "git", feature = "session"))]
|
||||
static PROBE: Probe = Probe;
|
||||
|
||||
use super::*;
|
||||
@@ -420,6 +432,7 @@ mod tests {
|
||||
/// - *Contract stamp:* the `exec_route` field — present on the
|
||||
/// exec row, absent elsewhere (a `None` field is not recorded
|
||||
/// by `tracing`). The pty/shell lifecycle rows carry no route.
|
||||
#[cfg(any(feature = "git", feature = "session"))]
|
||||
#[test]
|
||||
fn serving_path_lifecycle_carries_started_ended_and_the_warn_failure() {
|
||||
let fields = capture(|| serving_path_started("pty_request", 1, None));
|
||||
|
||||
+895
@@ -0,0 +1,895 @@
|
||||
//! The session mapping (`w3-tty-session`) — the biggest single-row
|
||||
//! surface of the spine ([`crate::dispatch::ExecRoute::Pipe`] + the
|
||||
//! `pty_request`/`shell_request` rows): pty/shell/pipe-exec serve
|
||||
//! through the published alktty seam
|
||||
//! (`drive_session_pre_negotiated(writer, reader, req, backends,
|
||||
//! ownership, identity)`) over a **translation bridge, not a byte
|
||||
//! pump** (POC-3 §D.1): alktty's data plane is chunk-framed both
|
||||
//! directions (`alktty::wire` — 5-byte header + payload), the SSH
|
||||
//! channel is a plain byte plane, so the bridge wraps the channel
|
||||
//! with small adapters around a pair of in-memory duplexes.
|
||||
//!
|
||||
//! ## The negotiation is built locally, never wire-derived (OQ-08)
|
||||
//!
|
||||
//! The serve-config is the whole truth:
|
||||
//!
|
||||
//! - `pty_request` args → `alktty::negotiation::TerminalParamsWire`
|
||||
//! (`term`/`cols`/`rows`/pixel dims; SSH ttymodes → the **reserved
|
||||
//! modes JSON `{}`** — alktty OQ-44's reservation: backends must
|
||||
//! ignore modes in v1, so the door writes `{}` and defers
|
||||
//! faithfully). Stored per channel (the POC-3 accepted-but-held
|
||||
//! shape, [`crate::handler`]'s store) and consumed by the later
|
||||
//! `shell_request`/`exec_request` on the same channel
|
||||
//! (`ssh -t host "cmd"` sends pty-then-exec).
|
||||
//! - `shell_request` → `NegotiateRequest{carriage: "raw", backend:
|
||||
//! the seam selector, tty: the consumed pty params, cmd: the
|
||||
//! serve-config shell argv **verbatim**, cwd: session_cwd, env:
|
||||
//! session_env}` — the wire never carries command, args, cwd or
|
||||
//! env (OQ-08/OQ-09; the `env_request` row stays declined
|
||||
//! unconditionally). **A `shell_request` without a preceding pty
|
||||
//! serves pipe-mode (`tty = None`)** — decided: vanilla sshd serves
|
||||
//! a shell without a terminal too (`ssh -T host` is legitimate
|
||||
//! vanilla), so a pty-less shell request is served, never declined.
|
||||
//! - `exec_request` (pipe row) → `cmd = exec_wrapper_argv ++ [cmd]`
|
||||
//! (the OQ-08 argv append — vanilla quoting semantics; the wire
|
||||
//! command string is **never naively split**), `tty =
|
||||
//! Some(consumed pty)` when a pty preceded, else `None` (pipe
|
||||
//! mode).
|
||||
//! - **Unset-declines** (OQ-08, never a fallback): `shell_argv`
|
||||
//! unset → `shell_request` declines; `exec_wrapper_argv` unset →
|
||||
//! the pipe row declines. Wire shape (decided + recorded): the
|
||||
//! **serving-path shape — `channel_failure` then channel close,
|
||||
//! from a spawned task** ([`crate::serve`]'s mechanics; the same
|
||||
//! spawned-stub body the git row uses) — the class of a routable
|
||||
//! row that owns the request's whole outcome; the session channel
|
||||
//! closes the way it would when the request itself cannot exist.
|
||||
//! `session_seam` unset (feature on, seam absent) → the same
|
||||
//! shape, reason `seam-unconfigured` (the decline matrix's
|
||||
//! config-driven note); `pty_request` checks the seam too — a pty
|
||||
//! whose session can never serve is a doomed allocation. Field
|
||||
//! checks precede the seam check (the row's own unset field is the
|
||||
//! first decline); the decline never takes the channel and never
|
||||
//! consumes stored pty state.
|
||||
//! - The unvanilla guards keep the git row's shapes: `channel-not-
|
||||
//! held` (a routed row whose channel handle is already gone)
|
||||
//! answers request-level failure inline; `no-identity` (post-auth
|
||||
//! unreachable, fail-closed) is the spawned serving-path decline.
|
||||
//!
|
||||
//! ## The translation bridge
|
||||
//!
|
||||
//! Two small adapters around a duplex pair the serving task drives
|
||||
//! (the POC-3 §D.1 shape, re-landed as contract):
|
||||
//!
|
||||
//! - **SSH → chunks** (the `client_recv` side of alktty's session):
|
||||
//! a bounded [`tokio::sync::mpsc`] (the
|
||||
//! [`CONTROL_SURFACE_BOUND`] bound) feeds a framing task that
|
||||
//! writes `alktty::wire::Chunk`s into the duplex — the same mpsc
|
||||
//! the control rows ride ([`BridgeSurface`], §below). SSH `data` →
|
||||
//! `Chunk::stdin`; SSH channel EOF → **the zero-length
|
||||
//! stdin-chunk sentinel** — decided (verified against alktty 0.8.1's
|
||||
//! adapter pump, `adapter.rs::pump_client_to_backend`): a
|
||||
//! zero-length stdin chunk shuts the backend's stdin, *identical
|
||||
//! in effect* to `ControlMessage::Eof` on `STREAM_CTRL_IN`; the
|
||||
//! sentinel rides the data path the SSH EOF already chose, so no
|
||||
//! extra control frame is sent. Bounded end-to-end: the mpsc is
|
||||
//! bounded, the duplexes bounded, and russh's per-channel window
|
||||
//! bounds the SSH side.
|
||||
//! - **Chunks → SSH**: the `client_send` side of alktty's session is
|
||||
//! read through a **4-byte first-window disambiguation** (alktty's
|
||||
//! own framing-disambiguation invariant, ADR-052 §5: a negotiation
|
||||
//! *error frame* is length-prefixed JSON whose first length byte
|
||||
//! is `0x00`; a raw chunk's first byte is a `stream_type` ≤ 4).
|
||||
//! stdout chunks → SSH `data`; stderr chunks → SSH
|
||||
//! `extended_data(1)` (PTY mode merges upstream — the backend's
|
||||
//! stderr stream is `None` there, so the translation is universal:
|
||||
//! no mode-switch exists); the exit chunk (`STREAM_CTRL_OUT`) →
|
||||
//! the derivation contract (§below). No unbounded buffering in
|
||||
//! either adapter (AGENTS.md convention 9).
|
||||
//! - **The negotiation-error frame is the bridge's serving-path
|
||||
//! decline class**: alktty re-runs validation + allocation inline
|
||||
//! even on the pre-negotiated path (carriage/cmd/backend lookup/
|
||||
//! ownership), and a failure answers a length-prefixed error
|
||||
//! frame instead of chunks. Decided + recorded: the bridge maps
|
||||
//! that frame to a serving-path decline event
|
||||
//! (`negotiation-failed`) and an **`exit-status(255)` then close**
|
||||
//! — the one place this door claims 255: the session **never
|
||||
//! started** (no exit chunk exists, nothing ran, so 255 is the
|
||||
//! honest could-not-start semantic — contrast the git row, whose
|
||||
//! service *runs* its substrate and honestly reports `1`). The
|
||||
//! error frame's JSON detail stays alktty-side; only the
|
||||
//! vocabulary value rides the event.
|
||||
//! - A bridge read that ends **without** an exit chunk reports
|
||||
//! nothing (the chunk is the sole fidelity input; its absence is
|
||||
//! neither 0 nor 255 — no fabricated report): plain close, and
|
||||
//! the lifecycle's ended event.
|
||||
//!
|
||||
//! ## The control rows
|
||||
//!
|
||||
//! The requests arrive on the packet loop after the serving task
|
||||
//! owns the wire, and reach the spawned bridge through the
|
||||
//! **per-channel control surface** ([`BridgeSurface`] — handler
|
||||
//! state while the row serves): a bounded mpsc whose send half
|
||||
//! forward_control `try_send`s the control chunk (never awaited on
|
||||
//! the packet loop). `window_change_request` →
|
||||
//! `ControlMessage::Resize`; `signal` → `ControlMessage::Signal`
|
||||
//! (alktty's uppercase-name vocabulary). Decisions, recorded:
|
||||
//! the SSH control rows are fire-and-forget (no reply exists on
|
||||
//! the wire), so **a full or closed surface drops the control
|
||||
//! event** — the bound keeps a stalled bridge from stalling the
|
||||
//! packet loop (bounded resources, convention 9); **signal names
|
||||
//! are accepted verbatim** — no alkssh-side name filter: the
|
||||
//! backend owns the rejection semantics (alktty passes the name to
|
||||
//! the backend's control handle, whose semantics decide), and the
|
||||
//! pipe-mode pid-only vs PTY process-group *scope* is backend
|
||||
//! semantics (the mapping row note), so there is **no decline
|
||||
//! shape** for a name — nothing on the wire can answer. A resize
|
||||
//! arriving for a channel whose pty is stored but not yet serving
|
||||
//! updates the stored params instead (the final size wins —
|
||||
//! vanilla sshd's own ordering); a resize with neither a serving
|
||||
//! bridge nor a stored pty is dropped.
|
||||
//!
|
||||
//! ## The exit derivation contract (mapping.md §Exit chunk, rules
|
||||
//! 1–6 — normative)
|
||||
//!
|
||||
//! The alktty exit chunk (`{"type":"exit","code":i32}`,
|
||||
//! exit-chunk-is-last per alktty's ADR-005 lineage) is the sole
|
||||
//! fidelity input, **never widened**: [`derive_exit`] maps it to
|
||||
//! exactly one SSH exit report shape:
|
||||
//!
|
||||
//! 1. `code ≥ 0` → `exit-status(N)` (rule 1).
|
||||
//! 2. negative `−N`, N ≥ 2 → `exit-signal(name)` via the
|
||||
//! crate-local **Unix signum→name table** ([`SIGNAL_NAMES`]).
|
||||
//! Server-side work — the signal semantics belong to the serving
|
||||
//! platform where the backend reaps. Deliberately *not* derived
|
||||
//! from alktty's `signal_from_name` inverse, which is a forward
|
||||
//! name→number map for the control row, not a derivation table.
|
||||
//! `core_dumped` is **never asserted** — alktty discards the
|
||||
//! core-dump bit entirely, so nothing better is knowable from the
|
||||
//! chunk, and SSH's `exit-signal` bool stays false on every row.
|
||||
//! 3. `−1` → `exit-status(255)`, never a signal claim (rule 3 — the
|
||||
//! could-not-determine sentinel collides with the SIGHUP
|
||||
//! encoding on the pipe path, accepted symmetrically per the
|
||||
//! mapping: neither a fabricated signal for a wait failure nor a
|
||||
//! real one).
|
||||
//! 4. Non-Unix adds no other negatives (rule 4): the table above is
|
||||
//! the only negative recognizer.
|
||||
//! 5. **The PTY-path fidelity limit (rule 5, upstream)**:
|
||||
//! portable-pty 0.9 reaps a signal-terminated Unix child as a
|
||||
//! plain code (the signal number is unrecoverable through its
|
||||
//! trait surface), so signal-terminated PTY sessions arrive as
|
||||
//! `exit-status(1)` — a documented alktty/portable-pty ask; this
|
||||
//! module's table in practice sees the *pipe* path's negative
|
||||
//! encoding only. Faithful `exit-signal` there needs the alktty
|
||||
//! backend to conform to its own ADR's negative-code convention;
|
||||
//! alkssh's contract adapts automatically.
|
||||
//! 6. Unmapped negatives → rule 3 (`exit-status(255)`) — the
|
||||
//! mapping never invents a name (defensive case: the
|
||||
//! convention's producers are `−1` and `−signum` only).
|
||||
//!
|
||||
//! The report is sent **before** close, from the spawned serving
|
||||
//! task (ADR-006 decision 4, via the cloned `Session::handle()`).
|
||||
//!
|
||||
//! ## Cancellation (bounded resources + alktty's kill-on-drop)
|
||||
//!
|
||||
//! The serving task watches two signals: the **per-channel cancel**
|
||||
//! (fired by `channel_close`, or surfaced by the surface's drop) and
|
||||
//! the **connection teardown** ([`Teardown`] — fired by the
|
||||
//! handler's `Drop`; the russh session ended, however it ended). On
|
||||
//! either, the task aborts the alktty session task — dropping its
|
||||
//! not-yet-resolved `exit_code` future, which is alktty's
|
||||
//! kill-on-drop contract firing (its ADR-056) — then closes the SSH
|
||||
//! channel and ends. Nothing is fabricated on a cancel. The
|
||||
//! detached stdin/framing tasks are bounded by their duplex/mpsc/
|
||||
//! russh-window bounds and end on the same signals' effects.
|
||||
|
||||
use alkcall::core::auth::Identity;
|
||||
use alktty::ControlMessage;
|
||||
use alktty::negotiation::{NegotiateRequest, TerminalParamsWire};
|
||||
use alktty::wire::{
|
||||
Chunk, ChunkWriter, MAX_CHUNK_LEN, STREAM_CTRL_IN, STREAM_CTRL_OUT, STREAM_STDERR,
|
||||
STREAM_STDIN, STREAM_STDOUT,
|
||||
};
|
||||
use bytes::Bytes;
|
||||
use russh::ChannelId;
|
||||
use russh::server::Handle;
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::sync::{mpsc, watch};
|
||||
|
||||
use crate::config::SessionSeam;
|
||||
use crate::observe;
|
||||
|
||||
/// The negotiation frame the bridge speaks locally — every locally
|
||||
/// constructed request reads `"raw"` (alktty's v1 carriage).
|
||||
pub(crate) const CARRIAGE: &str = "raw";
|
||||
|
||||
/// The per-channel control surface's bound: control rows are rare
|
||||
/// (resize on window drag, signal), so the bound never bites vanilla
|
||||
/// traffic while keeping the forwarding surface bounded
|
||||
/// (§the control rows). The shim's scripted rows exercise a handful.
|
||||
pub(crate) const CONTROL_SURFACE_BOUND: usize = 64;
|
||||
|
||||
/// The translation bridge duplex capacity — the wave-2 loopback
|
||||
/// harness's per-direction convention, kept for the bridge pairs.
|
||||
const BRIDGE_DUPLEX_CAPACITY: usize = 65_536;
|
||||
|
||||
/// The stdin pump's read buffer — the SSH-side chunk granularity
|
||||
/// (per-chunk payloads are bounded in practice by russh's
|
||||
/// `maximum_packet_size`, here by [`MAX_CHUNK_LEN`] on the frame).
|
||||
const STDIN_PUMP_BUF: usize = 16 * 1024;
|
||||
|
||||
/// One SSH exit report shape derived from an exit chunk —
|
||||
/// [`derive_exit`]'s outcome.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) enum ExitReport {
|
||||
/// `exit-status(N)` — codes ≥ 0, the `−1` sentinel, and every
|
||||
/// unmapped negative (rules 1, 3, 4, 6).
|
||||
Status(u32),
|
||||
/// `exit-signal(name)` — a mapped negative `−N`, N ≥ 2 (rule 2).
|
||||
/// The name is the table's value, never an invention.
|
||||
Signal(&'static str),
|
||||
}
|
||||
|
||||
/// The crate-local Unix signum→name table (rule 2): the 1–31
|
||||
/// conventional Unix signals, as the exit chunk's producers encode
|
||||
/// them (`−signum`). Server-side work — the serving platform owns
|
||||
/// the signal semantics where the backend reaps. Deliberately **not**
|
||||
/// derived from alktty's `signal_from_name` (a forward name→number
|
||||
/// map for the control row, not a derivation table; deliberately
|
||||
/// narrower too — it exists for what a front-end sends, not for what
|
||||
/// a child can die by).
|
||||
pub(crate) const SIGNAL_NAMES: &[(u32, &str)] = &[
|
||||
(1, "HUP"),
|
||||
(2, "INT"),
|
||||
(3, "QUIT"),
|
||||
(4, "ILL"),
|
||||
(5, "TRAP"),
|
||||
(6, "ABRT"),
|
||||
(7, "BUS"),
|
||||
(8, "FPE"),
|
||||
(9, "KILL"),
|
||||
(10, "USR1"),
|
||||
(11, "SEGV"),
|
||||
(12, "USR2"),
|
||||
(13, "PIPE"),
|
||||
(14, "ALRM"),
|
||||
(15, "TERM"),
|
||||
(16, "STKFLT"),
|
||||
(17, "CHLD"),
|
||||
(18, "CONT"),
|
||||
(19, "STOP"),
|
||||
(20, "TSTP"),
|
||||
(21, "TTIN"),
|
||||
(22, "TTOU"),
|
||||
(23, "URG"),
|
||||
(24, "XCPU"),
|
||||
(25, "XFSZ"),
|
||||
(26, "VTALRM"),
|
||||
(27, "PROF"),
|
||||
(28, "WINCH"),
|
||||
(29, "IO"),
|
||||
(30, "PWR"),
|
||||
(31, "SYS"),
|
||||
];
|
||||
|
||||
/// The derivation contract's whole decision procedure (§module docs,
|
||||
/// mapping.md rules 1–6 — normative).
|
||||
pub(crate) fn derive_exit(code: i32) -> ExitReport {
|
||||
match code {
|
||||
code if code >= 0 => ExitReport::Status(code as u32),
|
||||
-1 => ExitReport::Status(255),
|
||||
other => match other.checked_abs() {
|
||||
Some(signum) => match SIGNAL_NAMES.iter().find(|(n, _)| *n == signum as u32) {
|
||||
Some((_, name)) => ExitReport::Signal(name),
|
||||
None => ExitReport::Status(255),
|
||||
},
|
||||
None => ExitReport::Status(255),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// The table's name → russh's `Sig` vocabulary (the
|
||||
/// `exit_signal_request` parameter). russh's known set rides its
|
||||
/// variant names; anything else rides `Sig::Custom` (RFC 4254 §6.10's
|
||||
/// signal-name encoding — vanilla clients parse arbitrary names).
|
||||
pub(crate) fn sig_from_name(name: &str) -> russh::Sig {
|
||||
match name {
|
||||
"ABRT" => russh::Sig::ABRT,
|
||||
"ALRM" => russh::Sig::ALRM,
|
||||
"FPE" => russh::Sig::FPE,
|
||||
"HUP" => russh::Sig::HUP,
|
||||
"ILL" => russh::Sig::ILL,
|
||||
"INT" => russh::Sig::INT,
|
||||
"KILL" => russh::Sig::KILL,
|
||||
"PIPE" => russh::Sig::PIPE,
|
||||
"QUIT" => russh::Sig::QUIT,
|
||||
"SEGV" => russh::Sig::SEGV,
|
||||
"TERM" => russh::Sig::TERM,
|
||||
"USR1" => russh::Sig::USR1,
|
||||
other => russh::Sig::Custom(other.to_owned()),
|
||||
}
|
||||
}
|
||||
|
||||
/// The SSH signal row's name translation: russh's `Sig` → the name
|
||||
/// string alktty's control vocabulary carries (the uppercase names;
|
||||
/// `Custom` carrying its own). Names flow to the backend verbatim —
|
||||
/// no alkssh-side filter (§the control rows).
|
||||
pub(crate) fn sig_control_name(sig: &russh::Sig) -> String {
|
||||
match sig {
|
||||
russh::Sig::ABRT => "ABRT".to_owned(),
|
||||
russh::Sig::ALRM => "ALRM".to_owned(),
|
||||
russh::Sig::FPE => "FPE".to_owned(),
|
||||
russh::Sig::HUP => "HUP".to_owned(),
|
||||
russh::Sig::ILL => "ILL".to_owned(),
|
||||
russh::Sig::INT => "INT".to_owned(),
|
||||
russh::Sig::KILL => "KILL".to_owned(),
|
||||
russh::Sig::PIPE => "PIPE".to_owned(),
|
||||
russh::Sig::QUIT => "QUIT".to_owned(),
|
||||
russh::Sig::SEGV => "SEGV".to_owned(),
|
||||
russh::Sig::TERM => "TERM".to_owned(),
|
||||
russh::Sig::USR1 => "USR1".to_owned(),
|
||||
russh::Sig::Custom(name) => name.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The session dimension clamp: u32 wire dims → the wire's u16 (a
|
||||
/// dimension beyond 65535 is nonsense on both papers; the cap, not
|
||||
/// a truncation of useful data).
|
||||
pub(crate) fn clamp_u16(value: u32) -> u16 {
|
||||
value.min(u16::MAX as u32) as u16
|
||||
}
|
||||
|
||||
/// The `pty_request` args → `TerminalParamsWire` conversion (§the
|
||||
/// negotiation section): SSH ttymodes → the **reserved modes JSON
|
||||
/// `{}`** (alktty OQ-44's reservation — the map is honest: nothing
|
||||
/// is carried, exactly what the reservation asks).
|
||||
pub(crate) fn terminal_params(
|
||||
term: &str,
|
||||
cols: u32,
|
||||
rows: u32,
|
||||
pix_width: u32,
|
||||
pix_height: u32,
|
||||
) -> TerminalParamsWire {
|
||||
TerminalParamsWire {
|
||||
term: Some(term.to_owned()),
|
||||
cols: clamp_u16(cols),
|
||||
rows: clamp_u16(rows),
|
||||
pixel_width: clamp_u16(pix_width),
|
||||
pixel_height: clamp_u16(pix_height),
|
||||
modes: serde_json::json!({}),
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds the locally-constructed `NegotiateRequest` (§the
|
||||
/// negotiation section): the serve-config's values verbatim; the
|
||||
/// reserved backend_params empty (the seam selector is the `backend`
|
||||
/// string; no backend-specific selector fields exist on this door).
|
||||
pub(crate) fn negotiate_request(
|
||||
seam_backend: String,
|
||||
tty: Option<TerminalParamsWire>,
|
||||
cmd: Vec<String>,
|
||||
cwd: Option<std::path::PathBuf>,
|
||||
env: std::collections::BTreeMap<String, String>,
|
||||
) -> NegotiateRequest {
|
||||
NegotiateRequest {
|
||||
carriage: CARRIAGE.to_owned(),
|
||||
backend: seam_backend,
|
||||
tty,
|
||||
cmd,
|
||||
cwd,
|
||||
env: env.into_iter().collect(),
|
||||
backend_params: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// One parsed bridge-side frame from alktty's `client_send` half —
|
||||
/// the 4-byte first-window disambiguation's outcome (§the
|
||||
/// translation bridge).
|
||||
enum BridgeFrame {
|
||||
/// A raw alktty chunk (data or server→client control).
|
||||
Chunk(Chunk),
|
||||
/// The length-prefixed negotiation error frame — the session
|
||||
/// failed validation/allocation/ownership at the seam. The bytes
|
||||
/// are alktty-side detail (never read here — only the
|
||||
/// vocabulary value rides the decline event).
|
||||
NegotiationError,
|
||||
/// The half ended (alktty's task completed or was cancelled)
|
||||
/// with no exit chunk having been observed.
|
||||
Closed,
|
||||
/// The half broke (a non-EOF I/O error, or a rogue frame).
|
||||
Break,
|
||||
}
|
||||
|
||||
/// Reads one bridge frame — the disambiguation invariant: the first
|
||||
/// byte `0x00` is a negotiation error frame's length-prefix high
|
||||
/// byte; anything ≤ 4 is a chunk `stream_type`. Bounds-checked
|
||||
/// against [`MAX_CHUNK_LEN`] both directions — a rogue length never
|
||||
/// triggers an oversized read.
|
||||
async fn read_bridge_frame(reader: &mut (impl tokio::io::AsyncRead + Unpin)) -> BridgeFrame {
|
||||
let mut head = [0u8; 4];
|
||||
match reader.read_exact(&mut head).await {
|
||||
Ok(_) => {}
|
||||
Err(e) if e.kind() == std::io::ErrorKind::UnexpectedEof => return BridgeFrame::Closed,
|
||||
Err(_) => return BridgeFrame::Break,
|
||||
}
|
||||
if head[0] == 0x00 {
|
||||
let length = u32::from_be_bytes([0, head[1], head[2], head[3]]);
|
||||
if length as usize > MAX_CHUNK_LEN as usize {
|
||||
return BridgeFrame::Break;
|
||||
}
|
||||
let mut body = vec![0u8; length as usize];
|
||||
return match reader.read_exact(&mut body).await {
|
||||
Ok(_) => BridgeFrame::NegotiationError,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::UnexpectedEof => BridgeFrame::Closed,
|
||||
Err(_) => BridgeFrame::Break,
|
||||
};
|
||||
}
|
||||
let stream_type = head[0];
|
||||
let mut last = [0u8; 1];
|
||||
match reader.read_exact(&mut last).await {
|
||||
Ok(_) => {}
|
||||
Err(_) => return BridgeFrame::Break,
|
||||
}
|
||||
let length = u32::from_be_bytes([head[1], head[2], head[3], last[0]]);
|
||||
if length as usize > MAX_CHUNK_LEN as usize {
|
||||
return BridgeFrame::Break;
|
||||
}
|
||||
let mut body = vec![0u8; length as usize];
|
||||
match reader.read_exact(&mut body).await {
|
||||
Ok(_) => BridgeFrame::Chunk(Chunk {
|
||||
stream_type,
|
||||
bytes: Bytes::from(body),
|
||||
}),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::UnexpectedEof => BridgeFrame::Closed,
|
||||
Err(_) => BridgeFrame::Break,
|
||||
}
|
||||
}
|
||||
|
||||
/// The exit chunk's JSON payload → the derived code
|
||||
/// (`{"type":"exit","code":N}`). Anything else on `STREAM_CTRL_OUT`
|
||||
/// has no contract meaning — and an exit chunk that cannot parse has
|
||||
/// no fidelity input — both map to `None` (no report; the plain
|
||||
/// close posture).
|
||||
fn exit_chunk_code(payload: &[u8]) -> Option<i32> {
|
||||
ControlMessage::from_slice(payload)
|
||||
.ok()
|
||||
.and_then(|msg| match msg {
|
||||
ControlMessage::Exit { code } => Some(code),
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
|
||||
/// The per-channel bridge surface — handler state while a session row
|
||||
/// serves (§the control rows). The handler's `window_change_request`
|
||||
/// / `signal` hooks reach the spawned bridge **through this struct**
|
||||
/// (the requests arrive on the packet loop after the serving task
|
||||
/// owns the wire): the bounded control-surface send half, plus the
|
||||
/// per-channel cancel signal `channel_close` fires. Packet-loop only
|
||||
/// (single-writer; no lock).
|
||||
pub(crate) struct BridgeSurface {
|
||||
/// The control-surface send half — the same bounded mpsc the
|
||||
/// serving task's stdin pump feeds.
|
||||
pub(crate) sender: mpsc::Sender<Chunk>,
|
||||
/// The per-channel cancel signal — `channel_close` fires it. The
|
||||
/// serving task interprets the fire *or* the half's drop as the
|
||||
/// cancel.
|
||||
pub(crate) cancel: watch::Sender<bool>,
|
||||
}
|
||||
|
||||
impl BridgeSurface {
|
||||
/// Forwards one control row into the bridge — `try_send`, never
|
||||
/// awaited on the packet loop (§the control rows); a full or
|
||||
/// closed surface drops the event.
|
||||
pub(crate) fn forward_control(&self, message: &ControlMessage) -> bool {
|
||||
let Ok(payload) = message.to_json() else {
|
||||
return false;
|
||||
};
|
||||
self.sender
|
||||
.try_send(Chunk {
|
||||
stream_type: STREAM_CTRL_IN,
|
||||
bytes: payload,
|
||||
})
|
||||
.is_ok()
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds the pair the dispatch hands into the serving task: the
|
||||
/// surface the handler stores and its consumed receiver counterpart
|
||||
/// (the bridge's framed-input mpsc) plus the cancel watch halves.
|
||||
/// The surface's `sender` clone is the stdin pump's producer inside
|
||||
/// the serving path.
|
||||
pub(crate) fn build_surface() -> (BridgeSurface, mpsc::Receiver<Chunk>, watch::Receiver<bool>) {
|
||||
let (sender, receiver) = mpsc::channel(CONTROL_SURFACE_BOUND);
|
||||
let (cancel, cancel_rx) = watch::channel(false);
|
||||
(BridgeSurface { sender, cancel }, receiver, cancel_rx)
|
||||
}
|
||||
|
||||
/// Fires the cancel watch (the `channel_close` hook's action).
|
||||
pub(crate) fn fire_cancel(cancel: &watch::Sender<bool>) {
|
||||
let _ = cancel.send(true);
|
||||
}
|
||||
|
||||
/// The connection-teardown signal: fired by the handler's `Drop`
|
||||
/// (the russh session ended, however it ended). Subscribe before the
|
||||
/// serving task spawns; the receiver also reads the sender's drop as
|
||||
/// the cancel.
|
||||
pub(crate) struct Teardown {
|
||||
sender: watch::Sender<bool>,
|
||||
}
|
||||
|
||||
impl Teardown {
|
||||
pub(crate) fn new() -> Self {
|
||||
Self {
|
||||
sender: watch::Sender::new(false),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn subscribe(&self) -> watch::Receiver<bool> {
|
||||
self.sender.subscribe()
|
||||
}
|
||||
|
||||
pub(crate) fn fire(&mut self) {
|
||||
let _ = self.sender.send(true);
|
||||
}
|
||||
}
|
||||
|
||||
/// Reads the SSH half of the channel and feeds stdin chunks — the
|
||||
/// SSH `data` → `Chunk::stdin` adapter (§the translation bridge).
|
||||
/// On EOF or a read error it sends **the zero-length stdin-chunk
|
||||
/// sentinel** (the EOF translation decision: identical in effect to
|
||||
/// alktty's `ControlMessage::Eof` — the backend's stdin shuts while
|
||||
/// the session keeps pumping) and ends.
|
||||
async fn stdin_pump(
|
||||
ssh_read: impl tokio::io::AsyncRead + Unpin + 'static,
|
||||
chunks: mpsc::Sender<Chunk>,
|
||||
) {
|
||||
let mut read_half = ssh_read;
|
||||
let mut buf = vec![0u8; STDIN_PUMP_BUF];
|
||||
loop {
|
||||
match read_half.read(&mut buf).await {
|
||||
Ok(0) => {
|
||||
let _ = chunks
|
||||
.send(Chunk {
|
||||
stream_type: STREAM_STDIN,
|
||||
bytes: Bytes::new(),
|
||||
})
|
||||
.await;
|
||||
return;
|
||||
}
|
||||
Ok(n) => {
|
||||
let chunk = Chunk {
|
||||
stream_type: STREAM_STDIN,
|
||||
bytes: Bytes::copy_from_slice(&buf[..n]),
|
||||
};
|
||||
if chunks.send(chunk).await.is_err() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
Err(_) => {
|
||||
let _ = chunks
|
||||
.send(Chunk {
|
||||
stream_type: STREAM_STDIN,
|
||||
bytes: Bytes::new(),
|
||||
})
|
||||
.await;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Consumes the framed-input mpsc and writes alktty chunks into the
|
||||
/// session's `client_recv` duplex half — the framing task. Ends after
|
||||
/// the EOF sentinel is written (nothing rides the data path past
|
||||
/// SSH EOF), on a mpsc close (every sender gone — the cancelled or
|
||||
/// completed session), or on a framing failure.
|
||||
async fn framing_task(mut chunks: mpsc::Receiver<Chunk>, writer: tokio::io::DuplexStream) {
|
||||
let mut writer = ChunkWriter::new(writer);
|
||||
while let Some(chunk) = chunks.recv().await {
|
||||
if writer.write_chunk(&chunk).await.is_err() {
|
||||
return;
|
||||
}
|
||||
if chunk.stream_type == STREAM_STDIN && chunk.bytes.is_empty() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The exit report + close + lifecycle event, in the ADR-006
|
||||
/// decision-4 order: **status before close, from the spawned task**.
|
||||
/// A failed send means the session is gone — the lifecycle's one
|
||||
/// `warn` records it (the report that could not land).
|
||||
async fn report_exit(
|
||||
handle: &Handle,
|
||||
row: &'static str,
|
||||
exec_route: Option<&'static str>,
|
||||
channel_id: ChannelId,
|
||||
channel_number: u32,
|
||||
report: ExitReport,
|
||||
) {
|
||||
let error = match report {
|
||||
ExitReport::Status(code) => handle.exit_status_request(channel_id, code).await.err(),
|
||||
ExitReport::Signal(name) => handle
|
||||
.exit_signal_request(
|
||||
channel_id,
|
||||
sig_from_name(name),
|
||||
false,
|
||||
String::new(),
|
||||
"en".to_owned(),
|
||||
)
|
||||
.await
|
||||
.err(),
|
||||
};
|
||||
let error = match error {
|
||||
Some(error) => Some(error),
|
||||
None => handle.close(channel_id).await.err(),
|
||||
};
|
||||
match error {
|
||||
Some(error) => observe::serving_path_failed(row, channel_number, &error, exec_route),
|
||||
None => observe::serving_path_ended(row, channel_number, exec_route),
|
||||
}
|
||||
}
|
||||
|
||||
/// Fails the request and closes the channel from the serving path —
|
||||
/// the serving-path decline shape ([`crate::serve`]'s mechanics) with
|
||||
/// the lifecycle events and the given reason. Used for `no-identity`.
|
||||
async fn fail_and_close(
|
||||
handle: &Handle,
|
||||
row: &'static str,
|
||||
exec_route: Option<&'static str>,
|
||||
channel_id: ChannelId,
|
||||
channel_number: u32,
|
||||
reason: &'static str,
|
||||
) {
|
||||
observe::decline_serving(row, reason, channel_number, exec_route);
|
||||
let error = match handle.channel_failure(channel_id).await {
|
||||
Ok(()) => handle.close(channel_id).await.err(),
|
||||
Err(error) => Some(error),
|
||||
};
|
||||
match error {
|
||||
Some(error) => observe::serving_path_failed(row, channel_number, &error, exec_route),
|
||||
None => observe::serving_path_ended(row, channel_number, exec_route),
|
||||
}
|
||||
}
|
||||
|
||||
/// The spawned session-row serving path (§module docs): the store's
|
||||
/// taken channel rides the translation bridge into
|
||||
/// [`alktty::drive_session_pre_negotiated`]; the alktty session task
|
||||
/// and the SSH→chunks pump are spawned here, and *this* future drives
|
||||
/// the chunks→SSH direction inline so the exit report is the path's
|
||||
/// own final act (status before close — ADR-006 decision 4). The
|
||||
/// channel was taken by the dispatch; the spawned task owns the
|
||||
/// stream and its report surface.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(crate) async fn serve_tty_row(
|
||||
row: &'static str,
|
||||
exec_route: Option<&'static str>,
|
||||
handle: Handle,
|
||||
channel: russh::Channel<russh::server::Msg>,
|
||||
request: NegotiateRequest,
|
||||
identity: Option<Identity>,
|
||||
seam: SessionSeam,
|
||||
stdin_tx: mpsc::Sender<Chunk>,
|
||||
chunk_rx: mpsc::Receiver<Chunk>,
|
||||
cancel_rx: watch::Receiver<bool>,
|
||||
teardown_rx: watch::Receiver<bool>,
|
||||
) {
|
||||
let channel_id = channel.id();
|
||||
let channel_number = channel_id.number();
|
||||
observe::serving_path_started(row, channel_number, exec_route);
|
||||
|
||||
let Some(identity) = identity else {
|
||||
fail_and_close(
|
||||
&handle,
|
||||
row,
|
||||
exec_route,
|
||||
channel_id,
|
||||
channel_number,
|
||||
"no-identity",
|
||||
)
|
||||
.await;
|
||||
return;
|
||||
};
|
||||
|
||||
let (ssh_read, mut ssh_write) = tokio::io::split(channel.into_stream());
|
||||
let (chunk_in, chunk_in_b) = tokio::io::duplex(BRIDGE_DUPLEX_CAPACITY);
|
||||
let (chunk_out, chunk_out_b) = tokio::io::duplex(BRIDGE_DUPLEX_CAPACITY);
|
||||
let mut chunk_out = chunk_out;
|
||||
|
||||
tokio::spawn(framing_task(chunk_rx, chunk_in));
|
||||
tokio::spawn(stdin_pump(ssh_read, stdin_tx));
|
||||
|
||||
let alktty_session = tokio::spawn(alktty::drive_session_pre_negotiated(
|
||||
chunk_out_b,
|
||||
chunk_in_b,
|
||||
request,
|
||||
seam.backends,
|
||||
seam.ownership,
|
||||
Some(identity),
|
||||
));
|
||||
|
||||
drive_chunks_to_ssh(
|
||||
&handle,
|
||||
row,
|
||||
exec_route,
|
||||
channel_id,
|
||||
channel_number,
|
||||
&mut ssh_write,
|
||||
&mut chunk_out,
|
||||
cancel_rx,
|
||||
teardown_rx,
|
||||
alktty_session,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
/// The chunks→SSH direction, driven inline by the serving path
|
||||
/// (§the translation bridge): data/`extended_data(1)`/exit-chunk
|
||||
/// translation, the negotiation-error-frame decline class, and the
|
||||
/// two cancel signals. Each terminal path ends the lifecycle exactly
|
||||
/// once.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn drive_chunks_to_ssh(
|
||||
handle: &Handle,
|
||||
row: &'static str,
|
||||
exec_route: Option<&'static str>,
|
||||
channel_id: ChannelId,
|
||||
channel_number: u32,
|
||||
ssh_write: &mut (impl tokio::io::AsyncWrite + Unpin),
|
||||
chunk_out: &mut tokio::io::DuplexStream,
|
||||
cancel_rx: watch::Receiver<bool>,
|
||||
teardown_rx: watch::Receiver<bool>,
|
||||
alktty_session: tokio::task::JoinHandle<()>,
|
||||
) {
|
||||
let mut cancel_rx = cancel_rx;
|
||||
let mut teardown_rx = teardown_rx;
|
||||
loop {
|
||||
tokio::select! {
|
||||
frame = read_bridge_frame(chunk_out) => {
|
||||
match frame {
|
||||
BridgeFrame::Chunk(chunk) => {
|
||||
match chunk.stream_type {
|
||||
STREAM_STDOUT => {
|
||||
if ssh_write.write_all(&chunk.bytes).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
STREAM_STDERR => {
|
||||
let _ = handle
|
||||
.extended_data(channel_id, 1, chunk.bytes.to_vec())
|
||||
.await;
|
||||
}
|
||||
STREAM_CTRL_OUT => {
|
||||
match exit_chunk_code(&chunk.bytes).map(derive_exit) {
|
||||
Some(report) => {
|
||||
report_exit(
|
||||
handle,
|
||||
row,
|
||||
exec_route,
|
||||
channel_id,
|
||||
channel_number,
|
||||
report,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
None => {
|
||||
let _ = handle.close(channel_id).await;
|
||||
observe::serving_path_ended(
|
||||
row,
|
||||
channel_number,
|
||||
exec_route,
|
||||
);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
BridgeFrame::NegotiationError => {
|
||||
observe::decline_serving(
|
||||
row,
|
||||
"negotiation-failed",
|
||||
channel_number,
|
||||
exec_route,
|
||||
);
|
||||
let _ = handle.exit_status_request(channel_id, 255).await;
|
||||
let _ = handle.close(channel_id).await;
|
||||
observe::serving_path_ended(row, channel_number, exec_route);
|
||||
return;
|
||||
}
|
||||
BridgeFrame::Closed | BridgeFrame::Break => {
|
||||
let _ = handle.close(channel_id).await;
|
||||
observe::serving_path_ended(row, channel_number, exec_route);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
_ = cancel_rx.changed() => break,
|
||||
_ = teardown_rx.changed() => break,
|
||||
}
|
||||
}
|
||||
alktty_session.abort();
|
||||
let _ = handle.close(channel_id).await;
|
||||
observe::serving_path_ended(row, channel_number, exec_route);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{ExitReport, SIGNAL_NAMES, derive_exit, sig_control_name, sig_from_name};
|
||||
use russh::Sig;
|
||||
|
||||
/// - *Contract stamp:* mapping.md §Exit chunk rule 1 (`code ≥ 0`
|
||||
/// → `exit-status(N)` — the chunk carries the whole truth for
|
||||
/// clean exits).
|
||||
#[test]
|
||||
fn non_negative_codes_map_to_exit_status() {
|
||||
assert_eq!(derive_exit(0), ExitReport::Status(0));
|
||||
assert_eq!(derive_exit(1), ExitReport::Status(1));
|
||||
assert_eq!(derive_exit(42), ExitReport::Status(42));
|
||||
assert_eq!(derive_exit(255), ExitReport::Status(255));
|
||||
}
|
||||
|
||||
/// - *Contract stamp:* mapping.md §Exit chunk rule 2 (negative
|
||||
/// `−N`, N ≥ 2 → `exit-signal(name)` via the table — the POC-3
|
||||
/// §D.2 live evidence: `−15` → `exit-signal("TERM")`).
|
||||
#[test]
|
||||
fn negative_signals_map_through_the_table() {
|
||||
for (signum, name) in SIGNAL_NAMES.iter().skip(1) {
|
||||
assert_eq!(derive_exit(-(*signum as i32)), ExitReport::Signal(name));
|
||||
}
|
||||
assert_eq!(derive_exit(-15), ExitReport::Signal("TERM"));
|
||||
assert_eq!(derive_exit(-2), ExitReport::Signal("INT"));
|
||||
}
|
||||
|
||||
/// - *Contract stamp:* mapping.md §Exit chunk rule 3 (`−1` →
|
||||
/// `exit-status(255)`, never a signal claim — the sentinel/
|
||||
/// SIGHUP collision accepted symmetrically).
|
||||
#[test]
|
||||
fn the_sentinel_is_not_a_signal() {
|
||||
assert_eq!(derive_exit(-1), ExitReport::Status(255));
|
||||
}
|
||||
|
||||
/// - *Contract stamp:* mapping.md §Exit chunk rule 6 (unmapped
|
||||
/// negatives → rule 3 — the mapping never invents a name).
|
||||
#[test]
|
||||
fn unmapped_negatives_report_255() {
|
||||
for code in [i32::MIN, -33, -32, -100, -1_000_000] {
|
||||
assert_eq!(derive_exit(code), ExitReport::Status(255));
|
||||
}
|
||||
}
|
||||
|
||||
/// - *Contract stamp:* the table bound — every entry is the
|
||||
/// conventional Unix signal set 1–31, sorted, names unique and
|
||||
/// uppercase (the vocabulary the derivation table promises).
|
||||
#[test]
|
||||
fn the_table_is_the_conventional_unix_set() {
|
||||
for (n, name) in SIGNAL_NAMES.iter() {
|
||||
assert!((1..=31).contains(n), "signum {n} outside 1..=31");
|
||||
assert!(
|
||||
name.chars()
|
||||
.all(|c| c.is_ascii_uppercase() || c.is_ascii_digit()),
|
||||
"signal names are uppercase-and-digit vocabulary: {name}"
|
||||
);
|
||||
}
|
||||
let mut nums: Vec<u32> = SIGNAL_NAMES.iter().map(|(n, _)| *n).collect();
|
||||
nums.sort_unstable();
|
||||
nums.dedup();
|
||||
assert_eq!(nums.len(), SIGNAL_NAMES.len(), "no duplicate signa");
|
||||
assert_eq!(nums.first(), Some(&1));
|
||||
assert_eq!(nums.last(), Some(&31));
|
||||
}
|
||||
|
||||
/// The name→`Sig` translation: russh's known set rides variant
|
||||
/// names; everything else rides `Sig::Custom` (the RFC's
|
||||
/// signal-name encoding).
|
||||
#[test]
|
||||
fn sig_from_name_covers_known_and_custom() {
|
||||
assert!(matches!(sig_from_name("TERM"), Sig::TERM));
|
||||
assert!(matches!(sig_from_name("USR1"), Sig::USR1));
|
||||
assert!(matches!(sig_from_name("USR2"), Sig::Custom(_)));
|
||||
assert!(matches!(sig_from_name("TSTP"), Sig::Custom(_)));
|
||||
assert_eq!(sig_control_name(&Sig::TERM), "TERM");
|
||||
assert_eq!(sig_control_name(&Sig::USR1), "USR1");
|
||||
assert_eq!(sig_control_name(&Sig::Custom("ABRT".to_owned())), "ABRT");
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
id: w3-tty-session
|
||||
name: Wave 3 — session → alktty (translation bridge + exit derivation)
|
||||
status: pending
|
||||
status: completed
|
||||
depends_on: [w3-git-mapping]
|
||||
scope: moderate
|
||||
risk: medium
|
||||
@@ -98,26 +98,26 @@ proved, re-landed as contract:
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] pty/shell/pipe-exec rows serve through
|
||||
- [x] pty/shell/pipe-exec rows serve through
|
||||
`drive_session_pre_negotiated` (spawned, handler fast); the
|
||||
store-and-take + per-channel PTY state reused from the git
|
||||
task's handoff; pipe-less shell decided + recorded
|
||||
- [ ] `pty_request` → `TerminalParamsWire` (ttymodes → the reserved
|
||||
- [x] `pty_request` → `TerminalParamsWire` (ttymodes → the reserved
|
||||
JSON), `shell_request` → shell argv, exec → `wrapper ++ [cmd]`,
|
||||
cwd/env session-side; unset-declines on both OQ-08 rows with
|
||||
recorded wire shapes
|
||||
- [ ] The bridge translated per row (stdin chunks + EOF shape
|
||||
- [x] The bridge translated per row (stdin chunks + EOF shape
|
||||
verified against alktty; stdout→data; stderr→`extended_data(1)`;
|
||||
resize/signal CTRL_IN reach the serving path through a bounded
|
||||
per-channel surface)
|
||||
- [ ] The derivation contract rules 1–6 in code with the signum→name
|
||||
- [x] The derivation contract rules 1–6 in code with the signum→name
|
||||
table; `−1`→255; `core_dumped` never; PTY-path rule-5 documented;
|
||||
exit-status before close from the spawned task — pinned by rows
|
||||
- [ ] The decline matrix per build green through the harness (unset-
|
||||
- [x] The decline matrix per build green through the harness (unset-
|
||||
field rows included); identity ride asserted; the `env_request`
|
||||
decline unchanged; skeleton stub rows migrated
|
||||
- [ ] The real-tool pty smoke row (posture decided + recorded)
|
||||
- [ ] `cargo test` (+ per-feature + `--all-features` rows), clippy ×2
|
||||
- [x] The real-tool pty smoke row (posture decided + recorded)
|
||||
- [x] `cargo test` (+ per-feature + `--all-features` rows), clippy ×2
|
||||
(`-D warnings`), fmt, `cargo doc --no-deps` pass
|
||||
|
||||
## References
|
||||
@@ -140,4 +140,137 @@ proved, re-landed as contract:
|
||||
|
||||
## Notes
|
||||
|
||||
> To be filled at implementation time.
|
||||
### Decisions recorded (implementation, 2026-10-28)
|
||||
|
||||
- **The module** (`src/tty_map.rs`, feature `session`): the session
|
||||
rows live in one module following the `git_map` pattern — the
|
||||
bridge, the locally-built negotiation, the derivation contract, and
|
||||
the serving path. `serde_json` (the OQ-44 modes JSON + the exit
|
||||
chunk/error-frame reads) and `bytes` (the chunk plane) joined as
|
||||
session-gated optional deps at alktty's own pins.
|
||||
|
||||
- **The negotiation** (locally built, never wire-derived — OQ-08):
|
||||
`pty_request` args → `TerminalParamsWire` with SSH ttymodes → the
|
||||
**reserved `{}` modes JSON** (alktty OQ-44's reservation, deferred
|
||||
there; the map is honest — nothing is carried). Shell argv rides the
|
||||
serve-config **verbatim**; exec rides `wrapper ++ [cmd]` (never
|
||||
naively split); cwd/env come from `session_cwd`/`session_env` only.
|
||||
`backend_params` stays empty (no backend-specific selector fields on
|
||||
this door; the seam selector is the `backend` string).
|
||||
**Pipe-less shell decided: a `shell_request` without a preceding
|
||||
pty SERVES pipe-mode (`tty = None`)** — vanilla sshd serves a shell
|
||||
without a terminal too (`ssh -T` is legitimate vanilla).
|
||||
|
||||
- **Unset-declines (OQ-08) — wire shape decided + recorded**: the
|
||||
**serving-path shape (`channel_failure` then close, from a spawned
|
||||
task)** for `shell_argv`-unset (`shell-unconfigured`),
|
||||
`exec_wrapper_argv`-unset (`wrapper-unconfigured`), and the
|
||||
`session_seam`-unset rows (`seam-unconfigured` — the git row's
|
||||
recorded shape); the pty row checks the seam first (a doomed
|
||||
allocation is declined before its params are stored — decline first,
|
||||
alloc never). Field checks precede the seam check. The unvanilla
|
||||
guards keep the git row's shapes: `channel-not-held` → inline
|
||||
request-level failure; `no-identity` → the spawned serving-path
|
||||
decline. A routed request answers `channel_success` inline (the
|
||||
POC-3 §A.5 shape).
|
||||
|
||||
- **The translation bridge** (the POC-3 §D.1 shape, two adapters
|
||||
around a duplex pair): SSH `data` → `Chunk::stdin` through a bounded
|
||||
mpsc (bound 64) feeding a framing task; SSH channel EOF → the
|
||||
**zero-length stdin-chunk sentinel** — decided and verified against
|
||||
alktty 0.8.1's `pump_client_to_backend`: a zero-length stdin chunk
|
||||
shuts the backend's stdin, identical in effect to
|
||||
`ControlMessage::Eof`, and rides the data path the SSH EOF already
|
||||
chose (no extra control frame). stdout chunks → SSH `data`; stderr
|
||||
chunks → `extended_data(1)`; the exit chunk → the derivation
|
||||
contract. All buffers bounded (convention 9): mpsc 64, duplexes
|
||||
64 KiB, frames bounds-checked against alktty's `MAX_CHUNK_LEN`.
|
||||
|
||||
- **The negotiation-error frame is a serving-path decline class**
|
||||
(decided + recorded): alktty re-runs validation/allocation/ownership
|
||||
inline even on the pre-negotiated path and answers failures with a
|
||||
length-prefixed error frame (first byte `0x00` — alktty's ADR-052 §5
|
||||
disambiguation invariant). The bridge reads it via a 4-byte
|
||||
first-window disambiguation and maps it to `negotiation-failed` +
|
||||
**`exit-status(255)` + close — the one place this door claims 255**:
|
||||
the session never started, nothing ran, so 255 is the honest
|
||||
could-not-start semantic (contrast the git row, whose service runs
|
||||
and honestly reports `1`; `255` is never fabricated for a real
|
||||
outcome). A bridge read ending **without** an exit chunk reports
|
||||
nothing (sole-fidelity-input posture: absence is neither 0 nor 255)
|
||||
— plain close.
|
||||
|
||||
- **The control rows**: `window_change_request` →
|
||||
`ControlMessage::Resize`, `signal` → `ControlMessage::Signal`,
|
||||
forwarded through the **per-channel `BridgeSurface`** (handler
|
||||
state while the row serves): a bounded mpsc whose `try_send` is
|
||||
never awaited on the packet loop — a full/closed surface drops the
|
||||
control event (fire-and-forget rows carry no SSH reply to decline
|
||||
with; bounded resources, convention 9). **Signal names are accepted
|
||||
verbatim — no decline shape**: the backend owns the rejection
|
||||
semantics, nothing on the wire can answer. A pre-serve resize
|
||||
updates the stored pty params instead (the final size wins —
|
||||
vanilla sshd's ordering).
|
||||
|
||||
- **The derivation contract** (mapping.md rules 1–6, in code + unit
|
||||
rows + wire rows): `derive_exit` — `code ≥ 0` → `exit-status(N)`;
|
||||
negative `−N`, N ≥ 2 → `exit-signal(name)` via the crate-local
|
||||
[`SIGNAL_NAMES`] signum→name table (1–31, server-side work,
|
||||
deliberately not alktty's `signal_from_name` inverse); `−1` →
|
||||
`exit-status(255)` (rule 3, the SIGHUP collision accepted
|
||||
symmetrically); `core_dumped` **never asserted** (the bool stays
|
||||
false on every exit-signal row); unmapped negatives (and
|
||||
`i32::MIN`'s checked-abs failure) → 255 (rule 6); the PTY-path
|
||||
rule-5 fidelity limit documented in the module docs. Report sent
|
||||
**before** close from the spawned task; names ride russh's `Sig`
|
||||
(known variants verbatim, `Sig::Custom` for the rest — RFC 4254
|
||||
§6.10).
|
||||
|
||||
- **Cancellation** (bounded resources + alktty's kill-on-drop): the
|
||||
serving path watches two signals — the per-channel cancel (fired by
|
||||
`channel_close` via the surface's watch) and the connection
|
||||
teardown (fired by the handler's `Drop`; the russh session ended
|
||||
however it ended). On either: the serving task **aborts the alktty
|
||||
session task**, dropping its not-yet-resolved `exit_code` future —
|
||||
alktty's ADR-056 kill-on-drop contract is the cancelled session's
|
||||
cleanup — then closes the channel and ends. Witnessed live by the
|
||||
shim's kill log row.
|
||||
|
||||
- **The contract tests** (`tests/tty_mapping.rs`, session-gated;
|
||||
`tests/common/tty_doubles.rs`): the shim double (hand-rolled per
|
||||
the `TtyHandle` field shapes — alktty's `MockBackend` is
|
||||
`pub(crate)` and never importable) records TtyParams, consumes
|
||||
stdin/EOF/resizes/signals, emits scripted chunks and exit codes,
|
||||
and carries the kill-on-drop witness; the ownership witness double
|
||||
observes the identity at the seam. The wave-2 stub rows migrated:
|
||||
`dispatch_core.rs`'s session-build rows now pin the
|
||||
unset-decline reasons (`seam-unconfigured` / `wrapper-unconfigured`,
|
||||
same serving-path wire shape); `observe.rs`'s two session rows
|
||||
updated (and a one-line pre-existing clippy lint in its git-build
|
||||
cap row fixed). `env_request` decline unchanged (OQ-09).
|
||||
|
||||
- **The identity ride** (the parked wave-2 item, discharged): asserted
|
||||
through alktty's ADR-050 ownership check — the shim's
|
||||
`resource_id` guarantees the check evaluates, and the ownership
|
||||
double records the resolved identity; the deny shape also pins the
|
||||
negotiation-error wire shape.
|
||||
|
||||
- **Real-tool smoke posture (recorded)**: one OpenSSH `-tt` pty
|
||||
session (`ssh -tt … smoke-rider@127.0.0.1` with piped stdin) through
|
||||
a TCP-mounted adapter against a **real PTY via a dev-dep alktty
|
||||
`local` composition** — the decided posture: a shim row cannot show
|
||||
what vanilla OpenSSH really sends, and POC-3 stage D proved exactly
|
||||
this composition. The dev-dep `features = ["local"]` unification
|
||||
applies to the test graph only (the published lib never enables it;
|
||||
`src/` never names a local shape). Requires ssh + sh; absent
|
||||
tooling is skipped **with a printed reason** — never silently green.
|
||||
The smoke pins the pty lifecycle (serve, stdin ride, clean exit →
|
||||
exit-status(0) before close); the `stty size`-grade fidelity matrix
|
||||
is wave 5's.
|
||||
|
||||
- **Verification**: `cargo test` (default), `cargo test --features
|
||||
session` (incl. the smoke + shim rows), `cargo test --features git`,
|
||||
`cargo test --features tunnel`, `cargo test --all-features`;
|
||||
`cargo clippy --all-targets -- -D warnings` ×5 (default / session /
|
||||
git / tunnel / all-features); `cargo fmt --check`; `cargo doc
|
||||
--no-deps` (default + session + all-features) — all green.
|
||||
@@ -60,6 +60,12 @@ pub mod harness;
|
||||
#[cfg(feature = "git")]
|
||||
pub mod git_doubles;
|
||||
|
||||
/// The session-seam doubles (`w3-tty-session`, `session`-gated — the
|
||||
/// hand-rolled TtyBackend shim + ownership witness; the module's own
|
||||
/// doc records the decisions: `tests/common/tty_doubles.rs`).
|
||||
#[cfg(feature = "session")]
|
||||
pub mod tty_doubles;
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use alkcall::core::auth::{AuthToken, Identity, IdentityProvider};
|
||||
|
||||
@@ -0,0 +1,336 @@
|
||||
//! The `session`-seam test doubles (`w3-tty-session`): the alkssh-side
|
||||
//! **TtyBackend shim double** — alktty's `MockBackend`/`testing.rs` are
|
||||
//! `pub(crate)` + `cfg(test)` and never importable, so the suite
|
||||
//! hand-rolls the shim per the `TtyHandle` field shapes: a backend
|
||||
//! that records the `TtyParams` it was allocated, consumes stdin
|
||||
//! (recording bytes, the EOF shutdown and the abort kill), emits
|
||||
//! scripted stdout/stderr chunks and a scripted exit code, and
|
||||
//! surfaces a control handle recording resizes/signals. Plus the
|
||||
//! ownership witness double — the identity that reaches
|
||||
//! `drive_session_pre_negotiated`'s ADR-050 ownership check is the
|
||||
//! identity ride's live witness (the task's "identity-landing item").
|
||||
//!
|
||||
//! Every stream/future here is test-scripted and instantly-completed
|
||||
//! or never-completing — no real process, no real PTY (the real-tool
|
||||
//! smoke composes alktty's own `local` backend instead, dev-dep only).
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::future::Future;
|
||||
use std::pin::Pin;
|
||||
use std::sync::Arc;
|
||||
use std::task::{Context, Poll};
|
||||
|
||||
use alkcall::core::auth::Identity;
|
||||
use alkcall::core::ownership::OwnershipProvider;
|
||||
use alktty::backend::{TtyBackend, TtyControl, TtyError, TtyHandle, TtyParams};
|
||||
use async_trait::async_trait;
|
||||
use bytes::Bytes;
|
||||
use tokio::io::AsyncWrite;
|
||||
use tokio_stream::wrappers::ReceiverStream;
|
||||
|
||||
/// The shim's shared observation surface — every row's assertions
|
||||
/// come off this (`Record TtyParams`, consume stdin/EOF/resizes/
|
||||
/// signals, the abort kill witness).
|
||||
#[derive(Debug, Default)]
|
||||
pub struct TtyShimObservation {
|
||||
/// The `TtyParams` of every allocation, in order.
|
||||
pub allocations: parking_lot::Mutex<Vec<TtyParams>>,
|
||||
/// Bytes written to the shim's stdin, concatenated per allocation.
|
||||
pub stdin: parking_lot::Mutex<Vec<u8>>,
|
||||
/// Whether a stdin shutdown (the EOF translation's destination)
|
||||
/// was observed — one flag per shutdown, but one allocation per
|
||||
/// row in practice.
|
||||
pub stdin_eof: parking_lot::Mutex<Vec<bool>>,
|
||||
/// Resizes from the control handle (`cols`, `rows`).
|
||||
pub resizes: parking_lot::Mutex<Vec<(u16, u16)>>,
|
||||
/// Signals from the control handle — the name strings, verbatim.
|
||||
pub signals: parking_lot::Mutex<Vec<String>>,
|
||||
/// The abort kill witness: pushed once per `exit_code` future
|
||||
/// dropped *unresolved* — alktty's kill-on-drop contract
|
||||
/// (ADR-056) observed through the shim.
|
||||
pub kills: Arc<parking_lot::Mutex<Vec<bool>>>,
|
||||
}
|
||||
|
||||
/// The shim's scripted per-allocation behavior — what the shim emits
|
||||
/// and what exit code it resolves with.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct ShimScript {
|
||||
/// stdout chunks, in emission order, then end-of-stream.
|
||||
pub stdout: Vec<Vec<u8>>,
|
||||
/// stderr chunks, in emission order, then end-of-stream (the shim
|
||||
/// carries the stderr stream only when these are non-empty *and*
|
||||
/// the allocation is pipe-mode).
|
||||
pub stderr: Vec<Vec<u8>>,
|
||||
/// The exit code: `Ok(code)` resolves instantly; `None` never
|
||||
/// resolves (the cancel row's never-ending session).
|
||||
pub exit: Option<i32>,
|
||||
}
|
||||
|
||||
/// The TtyBackend shim double — a hand-rolled minimal backend per the
|
||||
/// `TtyHandle` field shapes (see the module doc). Clone-able (the
|
||||
/// observation + script are `Arc`s).
|
||||
#[derive(Clone, Default)]
|
||||
pub struct TtyShim {
|
||||
observation: Arc<TtyShimObservation>,
|
||||
script: Arc<parking_lot::Mutex<ShimScript>>,
|
||||
}
|
||||
|
||||
impl TtyShim {
|
||||
/// A shim with an empty script (allocates, pumps nothing, resolves
|
||||
/// exit-status 0 by default).
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Sets the scripted behavior for subsequent allocations.
|
||||
pub fn set_script(&self, script: ShimScript) {
|
||||
*self.script.lock() = script;
|
||||
}
|
||||
|
||||
/// The observation surface.
|
||||
pub fn observation(&self) -> &Arc<TtyShimObservation> {
|
||||
&self.observation
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl TtyBackend for TtyShim {
|
||||
/// Records the params verbatim and hands back the scripted
|
||||
/// `TtyHandle`.
|
||||
async fn allocate(&self, params: &TtyParams) -> Result<TtyHandle, TtyError> {
|
||||
self.observation.allocations.lock().push(params.clone());
|
||||
let script = self.script.lock().clone();
|
||||
|
||||
let stdin = ShimStdin {
|
||||
observation: Arc::clone(&self.observation),
|
||||
};
|
||||
|
||||
let stdout = scripted_stream(&script.stdout);
|
||||
let stderr = if script.terminal_is_pty(params) {
|
||||
None
|
||||
} else {
|
||||
Some(scripted_stream(&script.stderr))
|
||||
};
|
||||
|
||||
let exit_code: alktty::backend::BoxFuture<Result<i32, TtyError>> = Box::pin(GuardedExit {
|
||||
inner: ShimExit { code: script.exit },
|
||||
resolved: false,
|
||||
kills: Arc::clone(&self.observation.kills),
|
||||
});
|
||||
|
||||
let control = Some(alktty::backend::TtyControlHandle::new(Arc::new(
|
||||
ShimControl {
|
||||
observation: Arc::clone(&self.observation),
|
||||
},
|
||||
)));
|
||||
|
||||
Ok(TtyHandle {
|
||||
stdin: Box::new(stdin),
|
||||
stdout,
|
||||
stderr,
|
||||
exit_code,
|
||||
control,
|
||||
})
|
||||
}
|
||||
|
||||
/// The ADR-050 check input — every allocation targets this shim
|
||||
/// resource, so the ownership witness row (the identity surface)
|
||||
/// always evaluates.
|
||||
fn resource_id(&self, _params: &TtyParams) -> Option<(&'static str, String)> {
|
||||
Some(("shim", "demo".to_owned()))
|
||||
}
|
||||
}
|
||||
|
||||
impl ShimScript {
|
||||
/// Whether the allocation is PTY-mode (the TtyParams's terminal is
|
||||
/// not the NegotiateRequest.tty this module speaks — the adapter's
|
||||
/// `From<NegotiateRequest>` conversion is what the seam consumes;
|
||||
/// the shim sees the converted `TtyParams`).
|
||||
fn terminal_is_pty(&self, params: &TtyParams) -> bool {
|
||||
params.terminal.is_some()
|
||||
}
|
||||
}
|
||||
|
||||
/// A scripted stream — the chunks in order, then end. Instantly
|
||||
/// drained (the alktty pumps consume; the exit chunk arrives after —
|
||||
/// the adapter's exit-chunk-is-last invariant orders everything).
|
||||
fn scripted_stream(chunks: &[Vec<u8>]) -> Pin<Box<dyn tokio_stream::Stream<Item = Bytes> + Send>> {
|
||||
let (tx, rx) = tokio::sync::mpsc::channel(chunks.len().max(1) + 1);
|
||||
for chunk in chunks {
|
||||
let _ = tx.try_send(Bytes::copy_from_slice(chunk));
|
||||
}
|
||||
drop(tx);
|
||||
Box::pin(ReceiverStream::new(rx))
|
||||
}
|
||||
|
||||
/// The shim's stdin — an `AsyncWrite` recording bytes and the EOF
|
||||
/// shutdown (`poll_shutdown` = the zero-length sentinel's effect at
|
||||
/// the backend).
|
||||
struct ShimStdin {
|
||||
observation: Arc<TtyShimObservation>,
|
||||
}
|
||||
|
||||
impl AsyncWrite for ShimStdin {
|
||||
fn poll_write(
|
||||
self: Pin<&mut Self>,
|
||||
_cx: &mut Context<'_>,
|
||||
buf: &[u8],
|
||||
) -> Poll<std::io::Result<usize>> {
|
||||
self.observation.stdin.lock().extend_from_slice(buf);
|
||||
Poll::Ready(Ok(buf.len()))
|
||||
}
|
||||
|
||||
fn poll_flush(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<std::io::Result<()>> {
|
||||
Poll::Ready(Ok(()))
|
||||
}
|
||||
|
||||
fn poll_shutdown(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<std::io::Result<()>> {
|
||||
self.observation.stdin_eof.lock().push(true);
|
||||
Poll::Ready(Ok(()))
|
||||
}
|
||||
}
|
||||
|
||||
/// The scripted exit-code future: `Some(code)` resolves instantly,
|
||||
/// `None` pends forever (the cancel row's never-ending session).
|
||||
struct ShimExit {
|
||||
code: Option<i32>,
|
||||
}
|
||||
|
||||
impl Future for ShimExit {
|
||||
type Output = Result<i32, TtyError>;
|
||||
|
||||
fn poll(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<Self::Output> {
|
||||
match self.code {
|
||||
Some(code) => Poll::Ready(Ok(code)),
|
||||
None => Poll::Pending,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Wrap [`ShimExit`] with alktty's ADR-056 kill-on-drop contract
|
||||
/// witness: an unresolved drop records the kill; a resolved drop (the
|
||||
/// future completed) must not.
|
||||
struct GuardedExit {
|
||||
inner: ShimExit,
|
||||
resolved: bool,
|
||||
kills: Arc<parking_lot::Mutex<Vec<bool>>>,
|
||||
}
|
||||
|
||||
impl Unpin for GuardedExit {}
|
||||
|
||||
impl Future for GuardedExit {
|
||||
type Output = Result<i32, TtyError>;
|
||||
|
||||
fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
|
||||
let me = self.get_mut();
|
||||
let out = Pin::new(&mut me.inner).poll(cx);
|
||||
if matches!(&out, Poll::Ready(Ok(_))) {
|
||||
me.resolved = true;
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for GuardedExit {
|
||||
fn drop(&mut self) {
|
||||
if !self.resolved {
|
||||
self.kills.lock().push(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The shim's control surface — records resizes and signals verbatim.
|
||||
struct ShimControl {
|
||||
observation: Arc<TtyShimObservation>,
|
||||
}
|
||||
|
||||
impl TtyControl for ShimControl {
|
||||
fn resize(&self, cols: u16, rows: u16, _pixel_width: u16, _pixel_height: u16) {
|
||||
self.observation.resizes.lock().push((cols, rows));
|
||||
}
|
||||
|
||||
fn signal(&self, name: &str) {
|
||||
self.observation.signals.lock().push(name.to_owned());
|
||||
}
|
||||
}
|
||||
|
||||
/// A shim-backed `SessionSeam` in the `session`-seam shape the
|
||||
/// adapters take — `backends` map holds the shim under `"shim"`, the
|
||||
/// selector names it, ownership unset (the ADR-050 check skips).
|
||||
pub fn tty_seam(shim: &Arc<TtyShim>) -> alkssh::SessionSeam {
|
||||
let mut backends = HashMap::new();
|
||||
backends.insert("shim".to_owned(), Arc::clone(shim) as Arc<dyn TtyBackend>);
|
||||
alkssh::SessionSeam {
|
||||
backends: Arc::new(backends),
|
||||
backend: "shim".to_owned(),
|
||||
ownership: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The ownership witness double ([`alkcall::core::ownership::
|
||||
/// OwnershipProvider`]): records every identity evaluation and rules
|
||||
/// by its configured verdict (the deny shape produces the negotiation
|
||||
/// error frame on the wire — the bridge's `negotiation-failed`
|
||||
/// decline).
|
||||
#[derive(Debug)]
|
||||
pub struct MemOwnership {
|
||||
pub owns: bool,
|
||||
/// The identity ids that reached `owns` — the identity ride's
|
||||
/// live witness.
|
||||
pub ids: parking_lot::Mutex<Vec<String>>,
|
||||
}
|
||||
|
||||
impl MemOwnership {
|
||||
pub fn new(owns: bool) -> Self {
|
||||
Self {
|
||||
owns,
|
||||
ids: parking_lot::Mutex::new(Vec::new()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl OwnershipProvider for MemOwnership {
|
||||
fn owns(
|
||||
&self,
|
||||
identity: &Identity,
|
||||
_resource_type: &str,
|
||||
_resource_id: &str,
|
||||
_action: &str,
|
||||
) -> bool {
|
||||
self.ids.lock().push(identity.id.clone());
|
||||
self.owns
|
||||
}
|
||||
|
||||
fn owned_resources(&self, _identity: &Identity, _resource_type: &str) -> Vec<String> {
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
fn owns_any(&self, _identity: &Identity, _resource_type: &str) -> bool {
|
||||
self.owns
|
||||
}
|
||||
}
|
||||
|
||||
/// A shim-backed seam with the ownership provider present — the
|
||||
/// witness rows' builder.
|
||||
pub fn tty_seam_with_ownership(
|
||||
shim: &Arc<TtyShim>,
|
||||
ownership: Arc<MemOwnership>,
|
||||
) -> alkssh::SessionSeam {
|
||||
let mut backends = HashMap::new();
|
||||
backends.insert("shim".to_owned(), Arc::clone(shim) as Arc<dyn TtyBackend>);
|
||||
alkssh::SessionSeam {
|
||||
backends: Arc::new(backends),
|
||||
backend: "shim".to_owned(),
|
||||
ownership: Some(ownership),
|
||||
}
|
||||
}
|
||||
|
||||
/// A seam whose selector names no backend in its own map — the
|
||||
/// `unknown_backend` negotiation error frame, driven from config.
|
||||
pub fn misconfigured_seam() -> alkssh::SessionSeam {
|
||||
alkssh::SessionSeam {
|
||||
backends: Arc::new(HashMap::new()),
|
||||
backend: "absent".to_owned(),
|
||||
ownership: None,
|
||||
}
|
||||
}
|
||||
+35
-27
@@ -394,8 +394,10 @@ mod git_build {
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// session-only build: declines git-parseable exec (the git parse is
|
||||
// its gate) and serves its rows only to the seam stubs; declines
|
||||
// tunnel opens (mapping.md §Decline matrix — session row).
|
||||
// its gate); with no seam/config this build's session rows decline at
|
||||
// their config gates (the unset-declines contract, serving-path
|
||||
// shape: `w3-tty-session`); declines tunnel opens (mapping.md
|
||||
// §Decline matrix — session row).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(all(feature = "session", not(feature = "git"), not(feature = "tunnel")))]
|
||||
@@ -438,11 +440,13 @@ mod session_build {
|
||||
session.unmount().await;
|
||||
}
|
||||
|
||||
/// - *Contract stamp:* ADR-006 point 3 (parse-only: non-git exec
|
||||
/// routes to the session pipe row — the seam stub declines at
|
||||
/// the family-seam boundary: failure then close).
|
||||
/// - *Contract stamp:* OQ-08 + the recorded wire shape
|
||||
/// (`w3-tty-session`: `exec_wrapper_argv` unset → the pipe row
|
||||
/// declines — the serving-path shape, failure then close, never
|
||||
/// a fallback). The row's serving surface (everything
|
||||
/// configured) is `tests/tty_mapping.rs`'s.
|
||||
#[tokio::test]
|
||||
async fn plain_exec_routes_to_the_pipe_row_stub() {
|
||||
async fn plain_exec_declines_wrapper_unconfigured_in_a_session_build() {
|
||||
let session = authenticated().await;
|
||||
let mut channel = open_session_channel(&session.client)
|
||||
.await
|
||||
@@ -450,15 +454,16 @@ mod session_build {
|
||||
request_exec(&channel, "uptime")
|
||||
.await
|
||||
.expect("the exec request rides the wire");
|
||||
expect_failure_then_close(&mut channel, "the pipe row serves its seam stub").await;
|
||||
expect_failure_then_close(&mut channel, "the unset-wrapper pipe row").await;
|
||||
session.unmount().await;
|
||||
}
|
||||
|
||||
/// - *Contract stamp:* mapping.md §Channel-type rows
|
||||
/// (`session` → `pty_request` + `shell_request` is a `session`
|
||||
/// row; the stub decline is the seam shape).
|
||||
/// - *Contract stamp:* mapping.md §Channel-type rows + OQ-08 (the
|
||||
/// `session` feature alone configures nothing: `session_seam`
|
||||
/// unset → the pty/shell rows decline `seam-unconfigured`, the
|
||||
/// serving-path shape this build pins).
|
||||
#[tokio::test]
|
||||
async fn pty_and_shell_route_to_their_row_stubs() {
|
||||
async fn pty_and_shell_decline_seam_unconfigured_in_a_session_build() {
|
||||
let session = authenticated().await;
|
||||
|
||||
let mut channel = open_session_channel(&session.client)
|
||||
@@ -467,7 +472,7 @@ mod session_build {
|
||||
request_pty(&channel)
|
||||
.await
|
||||
.expect("the pty request rides the wire");
|
||||
expect_failure_then_close(&mut channel, "the pty row serves its seam stub").await;
|
||||
expect_failure_then_close(&mut channel, "the unset-seam pty row").await;
|
||||
|
||||
let mut channel = open_session_channel(&session.client)
|
||||
.await
|
||||
@@ -475,7 +480,7 @@ mod session_build {
|
||||
request_shell(&channel)
|
||||
.await
|
||||
.expect("the shell request rides the wire");
|
||||
expect_failure_then_close(&mut channel, "the shell row serves its seam stub").await;
|
||||
expect_failure_then_close(&mut channel, "the unset-seam shell row").await;
|
||||
|
||||
session.unmount().await;
|
||||
}
|
||||
@@ -609,10 +614,10 @@ mod tunnel_build {
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// All features: the routing decisions hold with the full graph on —
|
||||
// the session rows and the tunnel rows are still their seam stubs
|
||||
// (w3-tty-session / w3-tunnel-direct material), and the git row
|
||||
// declines at its config gate this binary builds no seam for (the
|
||||
// serving rows ride tests/git_mapping.rs).
|
||||
// the git row is still its config-driven decline in this binary (the
|
||||
// serving rows ride tests/git_mapping.rs), and the session rows are
|
||||
// still their config-driven declines (the serving rows ride
|
||||
// tests/tty_mapping.rs).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(all(feature = "git", feature = "session", feature = "tunnel"))]
|
||||
@@ -638,11 +643,12 @@ mod all_features {
|
||||
session.unmount().await;
|
||||
}
|
||||
|
||||
/// - *Contract stamp:* mapping.md §Session-channel dispatch
|
||||
/// (non-git exec routes to the session pipe row in the full
|
||||
/// build).
|
||||
/// - *Contract stamp:* OQ-08 + the recorded wire shape (the
|
||||
/// unset-wrapper pipe row declines — the serving-path shape;
|
||||
/// `w3-tty-session`). The serving surface rides
|
||||
/// `tests/tty_mapping.rs`.
|
||||
#[tokio::test]
|
||||
async fn plain_exec_routes_to_the_pipe_row_stub_with_all_features() {
|
||||
async fn plain_exec_declines_wrapper_unconfigured_with_all_features() {
|
||||
let session = authenticated().await;
|
||||
let mut channel = open_session_channel(&session.client)
|
||||
.await
|
||||
@@ -650,14 +656,16 @@ mod all_features {
|
||||
request_exec(&channel, "uptime")
|
||||
.await
|
||||
.expect("the exec request rides the wire");
|
||||
expect_failure_then_close(&mut channel, "the pipe row serves its seam stub").await;
|
||||
expect_failure_then_close(&mut channel, "the unset-wrapper pipe row").await;
|
||||
session.unmount().await;
|
||||
}
|
||||
|
||||
/// - *Contract stamp:* mapping.md §Channel-type rows (the
|
||||
/// pty/shell rows route within the session channel).
|
||||
/// - *Contract stamp:* mapping.md §Channel-type rows + OQ-08 (the
|
||||
/// unset-seam pty/shell declines with the full graph on — the
|
||||
/// feature alone configures nothing; the serving surface rides
|
||||
/// `tests/tty_mapping.rs`).
|
||||
#[tokio::test]
|
||||
async fn pty_and_shell_route_to_their_stubs_with_all_features() {
|
||||
async fn pty_and_shell_decline_seam_unconfigured_with_all_features() {
|
||||
let session = authenticated().await;
|
||||
|
||||
let mut channel = open_session_channel(&session.client)
|
||||
@@ -666,7 +674,7 @@ mod all_features {
|
||||
request_pty(&channel)
|
||||
.await
|
||||
.expect("the pty request rides the wire");
|
||||
expect_failure_then_close(&mut channel, "the pty row serves its seam stub").await;
|
||||
expect_failure_then_close(&mut channel, "the unset-seam pty row").await;
|
||||
|
||||
let mut channel = open_session_channel(&session.client)
|
||||
.await
|
||||
@@ -674,7 +682,7 @@ mod all_features {
|
||||
request_shell(&channel)
|
||||
.await
|
||||
.expect("the shell request rides the wire");
|
||||
expect_failure_then_close(&mut channel, "the shell row serves its seam stub").await;
|
||||
expect_failure_then_close(&mut channel, "the unset-seam shell row").await;
|
||||
|
||||
session.unmount().await;
|
||||
}
|
||||
|
||||
+17
-9
@@ -552,8 +552,10 @@ mod git_build {
|
||||
fixture("alpha").fingerprint,
|
||||
identity("observe-cap", &["ssh"]),
|
||||
);
|
||||
let mut limits = Limits::default();
|
||||
limits.max_concurrent_channels = 0;
|
||||
let limits = Limits {
|
||||
max_concurrent_channels: 0,
|
||||
..Limits::default()
|
||||
};
|
||||
let adapter = alkssh::SshAdapter::new(common::harness::serve_config(
|
||||
provider,
|
||||
&[fixture("bravo")],
|
||||
@@ -591,11 +593,13 @@ mod session_build {
|
||||
use super::*;
|
||||
use common::harness::{open_session_channel, request_exec, request_pty};
|
||||
|
||||
/// - *Contract stamp:* ADR-006 decision 4 (the pty serving path is
|
||||
/// spawned; the lifecycle rides the task — what the wave-3 tty
|
||||
/// mapping's real path inherits).
|
||||
/// - *Contract stamp:* ADR-006 decision 4 (the pty serving decline
|
||||
/// is spawned; the lifecycle rides the task) + OQ-08 (the
|
||||
/// unset-seam decline: feature on, seam unset — the config-
|
||||
/// driven class the real paths inherit,
|
||||
/// `w3-tty-session`).
|
||||
#[tokio::test]
|
||||
async fn pty_seam_stub_declines_with_the_full_serving_lifecycle() {
|
||||
async fn pty_declines_seam_unconfigured_with_the_full_serving_lifecycle() {
|
||||
let session = authenticated("session-pty-row").await;
|
||||
let mut channel = open_session_channel(&session.client)
|
||||
.await
|
||||
@@ -606,7 +610,7 @@ mod session_build {
|
||||
let decline = await_event(&[
|
||||
("row", "pty_request"),
|
||||
("decline_class", "serving"),
|
||||
("reason", "seam-stub"),
|
||||
("reason", "seam-unconfigured"),
|
||||
])
|
||||
.await;
|
||||
assert_decline(&decline, &[("row", "pty_request")]);
|
||||
@@ -636,8 +640,12 @@ mod session_build {
|
||||
session.unmount().await;
|
||||
}
|
||||
|
||||
/// - *Contract stamp:* OQ-08 + the event vocabulary (`w3-tty-
|
||||
/// session`: the unset-field pipe decline —
|
||||
/// `wrapper-unconfigured`, the serving-path class from a
|
||||
/// spawned task).
|
||||
#[tokio::test]
|
||||
async fn pipe_seam_stub_declines_with_the_full_serving_lifecycle() {
|
||||
async fn pipe_declines_wrapper_unconfigured_with_the_full_serving_lifecycle() {
|
||||
let session = authenticated("session-pipe-row").await;
|
||||
let mut channel = open_session_channel(&session.client)
|
||||
.await
|
||||
@@ -650,7 +658,7 @@ mod session_build {
|
||||
let decline = await_event(&[
|
||||
("row", "exec_request"),
|
||||
("decline_class", "serving"),
|
||||
("reason", "seam-stub"),
|
||||
("reason", "wrapper-unconfigured"),
|
||||
("exec_route", "pipe"),
|
||||
])
|
||||
.await;
|
||||
|
||||
@@ -0,0 +1,966 @@
|
||||
//! 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:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user