Same treatment for the prose the compiler carries: doc and line comments across src/ and tests/, test assertion and error-message strings (prose surfaces: the tunnel-map registration/establish errors, the key-source decode payload, assertion messages), scripts/matrix.sh comment lines, the fuzz sidecar's comments and README (corpus seeds untouched), and the Cargo.toml comment headers plus the crates.io description. The AGENTS.md no-em-dash rule (convention 14) now holds for the whole tracked tree. Verification: repo-wide git grep is clean (single exempt hit: the verification command's own search pattern), cargo test (18 suites, zero failures, default and per-feature builds via scripts/matrix.sh), clippy --all-targets -D warnings and clippy all-features, fmt --check, doc --no-deps, and the fuzz corpus gate (fuzz/shared, 11 tests) all green.
326 lines
13 KiB
Rust
326 lines
13 KiB
Rust
//! The exit-currency module: mapping.md §Exit chunk's derivation
|
||
//! contract (rules 1–6, normative) as code, *shared by both
|
||
//! directions* (`w4-op-rows` extracted it out of `tty_map.rs`, whose
|
||
//! server-side session rows were its first and only consumer).
|
||
//!
|
||
//! The alktty exit chunk (`{"type":"exit","code":i32}`,
|
||
//! exit-chunk-is-last per alktty's ADR-004 on the alknet ADR-055
|
||
//! lineage) is the family's sole exit currency; the wire speaks SSH
|
||
//! exit reports. The two arms of the same contract:
|
||
//!
|
||
//! - **The server direction** ([`derive_exit`]): the family chunk →
|
||
//! exactly one SSH exit report shape, **never widened**.
|
||
//! - **The client direction** (`w4-op-rows`): the SSH exit report →
|
||
//! the family `i32` code ([`signal_code_from_name`] and the
|
||
//! direct `u32 → i32` identity), the exact inverse arm riding the
|
||
//! same table. The mirror consumes the convention as-is, including
|
||
//! its one collision (the `−1` sentinel vs a genuine SIGHUP;
|
||
//! mapping.md rule 3's symmetric-fidelity cost).
|
||
//!
|
||
//! No widening in either arm: the chunk is not enriched, the wire
|
||
//! report is not enriched, and the *core_dumped* bool is never
|
||
//! asserted (alktty discards it; nothing better is knowable).
|
||
//!
|
||
//! ## The server arm's rules (mapping.md, normative)
|
||
//!
|
||
//! 1. `code ≥ 0` → `exit-status(N)`.
|
||
//! 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**; SSH's `exit-signal` bool
|
||
//! stays false on every row.
|
||
//! 3. `−1` → `exit-status(255)`, never a signal claim: 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: the table is the only
|
||
//! negative recognizer.
|
||
//! 5. **The PTY-path fidelity limit (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 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 client arm's rules (`w4-op-rows`, the mirror)
|
||
//!
|
||
//! 1. `exit-status(N)` → the family code `N` (direct identity).
|
||
//! 2. `exit-signal(name)` → the family code `−N` via the same
|
||
//! [`SIGNAL_NAMES`] table read backwards
|
||
//! ([`signal_code_from_name`]); the mapping never invents a
|
||
//! signum.
|
||
//! 3. A name with no table entry → `−1` (the sentinel; the same
|
||
//! honest could-not-determine semantic rule 3 consumes on the
|
||
//! server arm, including a genuine SIGHUP, whose mirror rides
|
||
//! `−1` by the same accepted collision).
|
||
//! 4. No exit report at all ends a session with none, the
|
||
//! no-exit-chunk close family's mirror ("reports nothing",
|
||
//! mapping.md): the caller observes the session's end, never a
|
||
//! fabricated code.
|
||
|
||
/// One SSH exit report shape derived from an exit chunk;
|
||
/// [`derive_exit`]'s outcome.
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||
#[cfg(feature = "session")]
|
||
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` where N ≥ 2 (rule 2).
|
||
/// The name is the table's value, never an invention.
|
||
Signal(&'static str),
|
||
}
|
||
|
||
/// The crate-local Unix signum→name table (server arm rule 2 / client
|
||
/// arm rule 2): the 1–31 conventional Unix signals, as the exit
|
||
/// chunk's producers encode them (`−signum`) and as the wire's
|
||
/// `exit-signal` senders name them. Server-side work: the signal
|
||
/// semantics belong to the serving platform 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, existing 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 (the §module
|
||
/// docs' rules 1–6; normative).
|
||
#[cfg(feature = "session")]
|
||
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 client arm's inverse read (§module docs, rules 1–3): the SSH
|
||
/// `exit-signal` sender's name → the family exit-code convention's
|
||
/// value (`−signum`), `−1` for a name with no table entry (the
|
||
/// sentinel; the mapping never invents a signum, and a genuine SIGHUP
|
||
/// rides it by the same accepted collision).
|
||
pub(crate) fn signal_code_from_name(name: &str) -> i32 {
|
||
match SIGNAL_NAMES.iter().find(|(_, n)| *n == name) {
|
||
Some((signum, _)) => -(*signum as i32),
|
||
None => -1,
|
||
}
|
||
}
|
||
|
||
/// 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, since vanilla clients parse arbitrary names).
|
||
#[cfg(feature = "session")]
|
||
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 (mapping.md §the control rows; the client
|
||
/// arm's `exit-signal` consumption reads through this before
|
||
/// [`signal_code_from_name`]).
|
||
#[cfg(any(feature = "session", feature = "git"))]
|
||
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(),
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
#[cfg(feature = "session")]
|
||
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 is `−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 signum");
|
||
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");
|
||
}
|
||
}
|
||
|
||
/// The client arm's mirror row, runnable in every build the arm
|
||
/// compiles in (session or git; the git-only builds carry the client
|
||
/// rows too).
|
||
#[cfg(test)]
|
||
#[cfg(any(feature = "session", feature = "git"))]
|
||
mod client_arm_rows {
|
||
use super::SIGNAL_NAMES;
|
||
|
||
/// - *Contract stamp:* the client arm (§module docs' rules 1–3),
|
||
/// the inverse read through the same table, never widened:
|
||
/// mapped names ride `−signum`, unmapped names ride the `−1`
|
||
/// sentinel (the mapping never invents a signum).
|
||
#[test]
|
||
fn signal_code_from_name_mirrors_through_the_same_table() {
|
||
for (signum, name) in SIGNAL_NAMES.iter() {
|
||
assert_eq!(super::signal_code_from_name(name), -(*signum as i32));
|
||
}
|
||
assert_eq!(super::signal_code_from_name("WHATEVER"), -1);
|
||
}
|
||
|
||
/// - *Contract stamp:* the two arms round-trip through the one
|
||
/// table (client arm → server arm); the `−1` sentinel collision
|
||
/// rides rule 3 both directions, accepted.
|
||
#[cfg(feature = "session")]
|
||
#[test]
|
||
fn the_arms_round_trip_and_the_collision_rides_rule_3() {
|
||
use super::{ExitReport, derive_exit};
|
||
for (_, name) in SIGNAL_NAMES.iter().skip(1) {
|
||
assert_eq!(
|
||
derive_exit(super::signal_code_from_name(name)),
|
||
ExitReport::Signal(name),
|
||
"the arms round-trip for {name}"
|
||
);
|
||
}
|
||
assert_eq!(
|
||
derive_exit(super::signal_code_from_name("HUP")),
|
||
ExitReport::Status(255),
|
||
"the HUP collision rides rule 3 both directions, accepted"
|
||
);
|
||
}
|
||
}
|