Files
alkssh/src/exit_code.rs
T
glm-5.3-flash 1e583ab55b docs(comments): sweep em dashes from source comments, test prose, and manifest
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.
2026-11-04 20:28:05 +00:00

326 lines
13 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! The 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"
);
}
}