//! 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 = 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" ); } }