Files
alkssh/src/error.rs
T
glm-5.3-flash 7e5989f574 feat(client): land the -R leg — the acceptor registry + the reverse-listen rows (w4-reverse-listen)
- src/client_accept.rs: the assembly-mutable acceptor registry
  (per-type fixed slots under `tunnel`; refuse-never-replace install;
  payload = the `TunnelChannel` duplex + the open's echo/originator
  fields — the harness `ForwardedAcceptor` vocabulary grown), the
  reverse-listen rows (bound-port surfaced; the listen/cancel forms;
  the runtime-Unix gate at the listen), and the accepted-opens'
  spawned handoff tasks.
- src/client_handler.rs: the `forwarded-tcpip` /
  `forwarded-streamlocal` hooks route through the registry at
  arrival time — registered + admitted → accept + handoff;
  unregistered/unfeatured/cap-exhausted → the clean
  `AdministrativelyProhibited` decline (never accept-and-drop);
  pre-install arrivals decline, never buffered.
- src/client.rs: the dial wires the empty registry into the handler
  + the connection (`SshConnection::acceptors`, the late-install
  accessor) + the listener budget's dedicated counter.
- src/error.rs: `OpError::NoAcceptor` / `ListenRefused` /
  `ListenCapExceeded`; `RequestDenied` → `ListenRefused` (the
  listen/cancel forms' wire decline class; the port-0 mint rides
  alone at this pin — an explicit port's bound *is* the requested
  one, surfaced).
- tests/common/sshd_double.rs: the fake `sshd`'s `-R` half — the
  listen/cancel hooks bind real sockets (the out-param mint is the
  real bound port), the accept loops open the server-initiated
  forwarded channels + pump per accepted connection, cancels match
  by the raw wire strings and tear the listener down.
- tests/client_reverse_listen.rs: the contract rows (listen + data
  leg, the mint, the pre-wire gates, the declines, late-install
  on/off, the cancels, the ceiling, the unix twins).
- tests/tunnel_mapping.rs: the full-circle client ↔ server-direction
  smoke (both `-R` legs through the alkcall fabric double); real-
  `sshd` `-R` stays wave-5 e2e (recorded).
- client.md: the acceptor's landing record (shape, cap relation,
  gates, unix-gate placement, port-0 parity).
- fix(w4-op-rows, test cfg): the test mod's `family_exit_code`
  import gated to the exit-consuming builds (`--features tunnel`
  did not compile its tests before).

Verification: cargo test (default / each feature / --all-features)
green; cargo clippy --all-targets -- -D warnings (none +
--all-features) clean; cargo fmt --check clean; cargo doc --no-deps
clean (default build; all-features carries only the pre-existing
client_ops private-link warnings).
2026-10-29 17:19:24 +00:00

679 lines
29 KiB
Rust

//! The crate's error taxonomy — fail-closed, no catch-alls (ADR-005
//! decision 4: the vault's error taxonomy is the adapter's to reshape,
//! never to collapse).
//!
//! Deliberately **not** error types, per the family contracts:
//!
//! - **Auth declines** — dispositions (`Auth::Reject`, ADR-004),
//! never errors; a decline is an authorized outcome, not a failure.
//! - **Wire decline reasons** — SSH-shaped responses to the client
//! (the wave-2 decline core's material), never this taxonomy.
//! - **Config validation** — the OQ-08/serve-config fields are
//! `Option` and decline at request time, not construction time; no
//! config-error type exists unless a need is found.
use thiserror::Error;
/// Every way a vault-backed key source can fail to produce key
/// material (ADR-005 decision 4 — the failure matrix as distinct
/// variants). All paths are fail-closed: a failed load never yields a
/// dial, and a failed host-key load never yields a server.
///
/// The vault itself is a secret store on this path — the error
/// taxonomy here is the adapter's reshaping of the vault's, never a
/// re-export of it. Variants are deliberately closed: no catch-all,
/// no `Other`, no `Unknown`.
#[derive(Debug, Clone, Error)]
pub enum KeySourceError {
/// The vault was locked when the key was requested.
///
/// ADR-005 semantics: the vault participates only at use time
/// (decrypt-at-use), so a locked vault blocks exactly that — no
/// key material can be decrypted, so there is no dial; on the
/// host-key path the load feeds server construction
/// (decrypt-at-construction), so no server is constructed
/// (POC-2 §5). Live sessions are unaffected: the decoded
/// `PrivateKey` is a dial's lifetime, not a vault-coupled lease.
///
/// Fail-closed consequence: operation refused. Never unlock —
/// the vault's lifecycle is the assembly's, never the adapter's.
#[error("vault is locked; no key material available until unlocked")]
Locked,
/// No blob exists at the requested key location.
///
/// ADR-005 semantics: the assembly source has no
/// `EncryptedData` record for the requested key — no vault call
/// is needed to know (POC-2 §3). Distinct from every other
/// variant on purpose: an absent key is a configuration/
/// registration gap, while a locked/decryption failure is a
/// custody condition at an existing record.
///
/// Fail-closed consequence: no dial; on the host-key path, no
/// server construction. Never a fallback key or an anonymous
/// dial.
#[error("no key material at the requested key location")]
Absent,
/// Decryption failed — wrong mnemonic or tampered ciphertext.
///
/// ADR-005 semantics: the AES-256-GCM open failed (POC-2 §3):
/// the mnemonic derives an unrelated key, or the ciphertext was
/// altered in flight — the refuse-at-crypto class. The vault
/// reports this class as opaque message text, which is the
/// honest content here.
///
/// Fail-closed consequence: no key material exists, so no dial;
/// on the host-key path, no server construction.
#[error("key material decryption failed: {0}")]
Decryption(String),
/// The record's key version is one the vault cannot resolve.
///
/// ADR-005 semantics: the vault declines *before* touching
/// crypto — an unknown/unresolvable `key_version` (reserved/legacy
/// v1 is the TS_PBKDF2 class; a future version the vault predates
/// is the other class) is refused at derivation-path resolution
/// (POC-2 §3). `EncryptedData` remains opaque: the crate never
/// interprets versions — this variant carries only the vault's
/// refusal message.
///
/// Fail-closed consequence: no dial; on the host-key path, no
/// server construction. Rotation (higher version the vault
/// supports) is the storage-side migration — a version the
/// vault cannot derive never becomes a partially usable key.
#[error("vault cannot resolve the record's version: {0}")]
UnknownKeyVersion(String),
/// The decrypted plaintext arrived but is not decodable key
/// material — the one-time [`decode_secret_key`](russh::keys::decode_secret_key)
/// failure.
///
/// ADR-005 semantics: the vault decrypted *something*
/// successfully and the plaintext is not an OpenSSH
/// private-key PEM (decision 1's inner format). Distinct from
/// [`Decryption`](KeySourceError::Decryption) on purpose — the
/// custody boundary held, what was guarded is not a key. The
/// parse error's message is honest content; the typed parse
/// error is deliberately not preserved so the RC-pinned
/// `ssh-key` error type never enters the crate's public API.
///
/// Fail-closed consequence: no dial; on the host-key path, no
/// server construction.
#[error("decrypted plaintext is not decodable key material: {0}")]
Decode(String),
}
/// The adapter (server-direction) error surface — the failure classes
/// the mount point can reach, deliberately closed:
///
/// - **Construction** — the [`SshAdapter::new`](crate::SshAdapter::new)
/// setup failed *before any session exists*: a failed host-key load
/// (the [`KeySourceError`] variant is preserved verbatim — locked /
/// absent / decryption / unknown-version / decode each stays itself)
/// or a refused `-R` listen-leg registration (`ListenRegistration`,
/// `w3-tunnel-listen` — a fabric that could mint listens the door
/// cannot serve is worse than a half-configured adapter).
/// **No adapter is constructed** (ADR-005 decision 6 — never a
/// half-configured adapter, never a keyless server that fails
/// later).
/// - **Wire** — the russh session stack's own error, mapped
/// transparently (`From<russh::Error>`, the wave-1 error-model's
/// reserved `From` requirement of the per-connection
/// [`russh::server::Handler`]): KEX, transport, and IO failures of
/// a mounted session. Auth declines are *not* this class — they are
/// dispositions (ADR-004), and wire decline reasons are the
/// dispatch's SSH-shaped responses, never this taxonomy.
///
/// There is no catch-all variant: everything the crate's server
/// direction can fail is one of these classes or a decline.
#[derive(Debug, Error)]
pub enum AdapterError {
/// The host-key load at adapter construction refused — fail-closed
/// (ADR-005 decision 6). The [`KeySourceError`] variant is the
/// original refusal class, preserved un-collapsed.
#[error("ssh server construction refused: {0}")]
Construction(#[from] KeySourceError),
/// The `-R` listen leg's door-side openable registration refused
/// at construction (`w3-tunnel-listen`): fail-closed, never a
/// half-configured adapter — a construction where the fabric could
/// mint listens the door cannot serve is worse than no adapter.
/// The string carries the registration failure detail.
///
/// This stays the construction-time class: the wire-time sibling
/// (`w4-router-collision`, the `ListenRouter::register`
/// token-collision refusal) is a request-time **decline
/// disposition** (`RequestDenied`, reason `token-collision`) —
/// a global-request hook's `Err` (this taxonomy's only route into
/// the request surface) would kill the whole russh session, and
/// wire decline reasons are never this taxonomy.
#[error("ssh server construction refused (listen registration): {0}")]
ListenRegistration(String),
/// A mounted russh session errored (transport / KEX / IO class).
#[error("ssh session error: {0}")]
Wire(#[from] russh::Error),
}
/// The dial surface's failure classes (the client direction's error
/// counterpart of [`AdapterError`]) — deliberately closed, every
/// variant a no-connection outcome (ADR-005 decision 4: failures are
/// fail-closed and distinct, never collapsed, never a fallback dial):
///
/// - **KeySource** — the decrypt-at-dial load refused. The
/// [`KeySourceError`] variant is preserved verbatim (locked / absent
/// / decryption / unknown-version / decode each stays itself — the
/// ADR-005 decision 4 matrix read through the dial seam). No key
/// material, no dial.
/// - **Address** — the dial target is not a parseable `host:port`.
/// Checked at dial time (the config carries no construction-time
/// validation, the family contract); a malformed target is a
/// refusal before any connection.
/// - **HostKeyRejected** — the injected host-key policy
/// ([`crate::HostKeyPolicy`]) declined the presented server key at
/// `check_server_key` (a `false` verdict is the decline
/// disposition; russh surfaces it as its `UnknownKey` wire error,
/// which this crate maps here because that hook is the only
/// producer). The trust decision is the policy's — its own record
/// (its decision log) carries the reasons; the crate never
/// second-guesses or retries.
/// - **AuthRejected** — the remote end refused the publickey
/// authentication (the family contract is exactly one
/// publickey-only attempt; there is no retry, no fallback method).
/// - **Wire** — the russh session stack's own error, mapped
/// transparently, including the dial's connect/resolve/IO classes.
#[derive(Debug, Error)]
pub enum DialError {
/// The dial key's decrypt-at-dial load refused — every
/// [`KeySourceError`] variant is a no-dial outcome, preserved
/// un-collapsed (ADR-005 decision 4).
#[error("dial key source refused: {0}")]
KeySource(#[from] KeySourceError),
/// The dial target is not a parseable `host:port` address — the
/// wire never dialed. The string carries the address as given.
#[error("dial target is not a usable host:port address: {0}")]
Address(String),
/// The host-key policy declined the presented server key — the
/// dial aborts (a disposition the dial cannot proceed on; the
/// crate default [`RejectAll`](crate::RejectAll) policy makes
/// every dial refuse, by design).
#[error("host-key policy declined the presented server key")]
HostKeyRejected,
/// The remote end refused the publickey authentication — the
/// family's one-attempt publickey-only posture (no retries, no
/// fallback methods, never another key).
#[error("authentication was refused by the remote end")]
AuthRejected,
/// A russh session transport error (connect / KEX / IO class).
/// The `From<russh::Error>` conversion below is the variant's
/// only mapping (it carries the one semantic upgrade, see its
/// docs), so no `#[from]` lives on the field.
#[error("ssh dial wire error: {0}")]
Wire(russh::Error),
}
impl From<russh::Error> for DialError {
/// The transparent russh mapping with one semantic upgrade:
/// `UnknownKey` is the wire shape of a host-key policy decline
/// (the client's `check_server_key` hook is the only producer),
/// so it becomes [`DialError::HostKeyRejected`] — every other
/// error stays the transparent [`DialError::Wire`] class.
fn from(error: russh::Error) -> Self {
match error {
russh::Error::UnknownKey => Self::HostKeyRejected,
other => Self::Wire(other),
}
}
}
/// The family-typed mirror of the SSH `CHANNEL_OPEN` rejection
/// reasons (RFC 4250 §1.5 / RFC 4254 §5.2) — the op rows translate
/// the wire's typed-open decline into this closed vocabulary
/// (`client.md` §op rows: the family surface carries no vanilla types).
/// The mapping is one-to-one with russh's
/// [`russh::ChannelOpenFailure`], whose `Other` arm mirrors its
/// `code`/`reason` pair verbatim — a decline reason the client
/// *could not map* is never silently collapsed onto a known class.
#[derive(Debug, Clone, Error, PartialEq, Eq)]
pub enum OpenDeclineReason {
/// The SSH `administratively prohibited` class — a policy
/// refusal at the server (the door's own unfeatured/cap
/// declines answer with this family's server-direction mirror).
#[error("the server refused the open (administratively prohibited)")]
Prohibited,
/// The SSH `connect failed` class — the target could not be
/// bridged (a dead `-L` target, an impossible one).
#[error("the server rejected the open (connect failed)")]
ConnectFailed,
/// The SSH `unknown channel type` class.
#[error("the server rejected the open (unknown channel type)")]
UnknownChannelType,
/// The SSH `resource shortage` class.
#[error("the server rejected the open (resource shortage)")]
ResourceShortage,
/// A rejection reason with no standard code — the wire's own
/// `code`/`reason` pair rides (the mirror never invents a class
/// for an unmappable one).
#[error("the server rejected the open (code {code}: {reason})")]
Other { code: u32, reason: String },
}
impl From<russh::ChannelOpenFailure> for OpenDeclineReason {
/// The one-to-one mirror (see the type docs).
fn from(failure: russh::ChannelOpenFailure) -> Self {
match failure {
russh::ChannelOpenFailure::AdministrativelyProhibited => Self::Prohibited,
russh::ChannelOpenFailure::ConnectFailed => Self::ConnectFailed,
russh::ChannelOpenFailure::UnknownChannelType => Self::UnknownChannelType,
russh::ChannelOpenFailure::ResourceShortage => Self::ResourceShortage,
russh::ChannelOpenFailure::Other { code, reason } => Self::Other { code, reason },
}
}
}
/// The client direction's op-row failure classes (`w4-op-rows`) —
/// the exec/pty / tunnel / git-pack op surfaces' closed taxonomy
/// (`RemoteSession` / `TunnelChannel` / `GitPackSession` — the
/// feature-gated members live in the crate root's exports), deliberately distinct from [`DialError`]:
/// an op runs on an *already-dialled* connection, so its failure is
/// never a no-connection outcome — the connection stays usable
/// ([`OpError::OpenDeclined`] / [`OpError::RequestRefused`] and
/// [`OpError::ChannelEnded`] are channel/op-scoped, mirroring the
/// server direction's decline shapes: channel-scoped, auth intact,
/// no state poisoning), and every op row's decline is fail-closed.
///
/// There is no catch-all variant: everything an op can fail is one
/// of these classes or a decline class of its own.
///
#[derive(Debug, Error)]
pub enum OpError {
/// The server refused the op's **channel open** — the typed
/// open's decline (POC-1 §6: a rejected open resolves on the
/// awaiting call, never a `wait()` match). The family-mirrored
/// reason rides; the connection stays usable (row-pinned).
#[error("the server declined the op's channel open: {0}")]
OpenDeclined(#[from] OpenDeclineReason),
/// The server refused the op's **request** on the opened channel
/// — the SSH-REQUEST reply's `Failure` class, observed through
/// the `wait()`-drain (POC-3 §D.3: Success/Failure arrive on the
/// channel queue, not as `Err` from the request call). The
/// channel is closed behind the refusal (the row owned it).
#[error("the server refused the op's channel request")]
RequestRefused,
/// The channel ended before the op's request reply arrived (the
/// `wait()`-drain saw the channel's end) — the op cannot
/// proceed, nothing is fabricated.
#[error("the channel ended before the op's request reply arrived")]
ChannelEnded,
/// The op's command exceeds the exec-length budget
/// (`Limits::max_exec_command_len`) — the per-length cap's
/// client-mirror verdict, answered **before any wire op** (bounded
/// resources; the server direction's pre-parse cap mirrored at
/// the client's command-construction gate).
#[error("the op's command exceeds the configured exec-command length cap")]
CommandTooLong,
/// The git row's repo id is outside the ADR-008 grammar — the
/// git row's validation gate mirrored (the server direction's
/// `repo_id_validates` verdict), answered before any wire op.
#[error("the repository id is outside the repo-id grammar")]
InvalidRepoId,
/// Unix sockets are unavailable on this platform — the
/// `direct-streamlocal` twin's runtime-Unix gate (OQ-11:
/// universal compile, clean off-Unix decline **before any wire
/// op**), mirrored from the server direction's gate.
#[error("unix socket forwarding is unavailable on this platform")]
UnixUnavailable,
/// The reverse-forward op's channel type has **no acceptor
/// registered** (ADR-003 §4's client-side gate surfaced to the
/// *issuing* side: a reverse listen whose data leg could not serve
/// arrivals is issued never — the half-capability posture,
/// answered before any wire op). Featured-but-unregistered is the
/// exact class: the unset-declines convention mirrored (the
/// acceptor slots are assembly-mutable, install-first is the
/// recorded order).
#[error("no acceptor is registered for the op's forwarded channel type")]
NoAcceptor,
/// The peer refused the op's reverse-forward global request (the
/// `RequestDenied` wire decline class) — a listen denial or the
/// cancel forms' unknown-listener answer (sshd parity: match by
/// the raw wire strings). Channel-scoped honesty: the connection
/// stays usable (row-pinned), and the wire's own reason classes
/// are never invented here — the closed family class mirrors the
/// wire shape, `RequestRefused`'s global-request counterpart.
#[error("the peer refused the reverse-forward request")]
ListenRefused,
/// The op's reverse-listen admission hit the connection's
/// dedicated listener ceiling (the listener counter sharing
/// `Limits::max_concurrent_channels` — the server-side
/// listener-counter posture mirrored; the cap relation recorded in
/// `client_accept.rs`) — answered **before any wire op**: a
/// request the budget cannot track is issued never.
#[error("the connection's reverse-listen count is at its ceiling")]
ListenCapExceeded,
/// The sftp route is the OQ-13 decline placeholder (the feature
/// exists, the route does not until alkfs lands, on either
/// direction) — answered before any wire op, never a
/// half-opened subsystem channel.
#[error("the sftp route is not served (the OQ-13 decline placeholder)")]
SftpNotServed,
/// A russh session transport error underlying the op (the
/// transparent class, as [`DialError::Wire`]).
#[error("ssh op wire error: {0}")]
Wire(russh::Error),
}
impl From<russh::Error> for OpError {
/// The mirror points: a typed open's rejection is the wire shape
/// `russh::Error::ChannelOpenFailure` and maps to
/// [`OpError::OpenDeclined`] (family-mirrored
/// [`OpenDeclineReason`]); a denied reverse-forward *global*
/// request (`RequestDenied` — the listen/cancel forms' only wire
/// decline shape at this pin, the rows' only producer) maps to
/// [`OpError::ListenRefused`]; every other russh error stays the
/// transparent [`OpError::Wire`].
fn from(error: russh::Error) -> Self {
match error {
russh::Error::ChannelOpenFailure(failure) => Self::OpenDeclined(failure.into()),
russh::Error::RequestDenied => Self::ListenRefused,
other => Self::Wire(other),
}
}
}
#[cfg(test)]
mod tests {
use super::{AdapterError, DialError, KeySourceError, OpError, OpenDeclineReason};
fn all_variants() -> [KeySourceError; 5] {
[
KeySourceError::Locked,
KeySourceError::Absent,
KeySourceError::Decryption("decryption error: aead::Error".into()),
KeySourceError::UnknownKeyVersion("invalid path: reserved version".into()),
KeySourceError::Decode("PEM parse failed".into()),
]
}
#[test]
fn closed_taxonomy_no_catch_all() {
let exhaustive: fn(&KeySourceError) -> &'static str = |e| match e {
KeySourceError::Locked => "locked",
KeySourceError::Absent => "absent",
KeySourceError::Decryption(_) => "decryption",
KeySourceError::UnknownKeyVersion(_) => "unknown-version",
KeySourceError::Decode(_) => "decode",
};
for v in all_variants() {
assert!(!exhaustive(&v).is_empty());
}
}
#[test]
fn display_is_explicit_and_fail_closed() {
let [locked, absent, decryption, unknown, decode] = all_variants();
assert_eq!(
locked.to_string(),
"vault is locked; no key material available until unlocked"
);
assert_eq!(
absent.to_string(),
"no key material at the requested key location"
);
assert_eq!(
decryption.to_string(),
"key material decryption failed: decryption error: aead::Error"
);
assert_eq!(
unknown.to_string(),
"vault cannot resolve the record's version: invalid path: reserved version"
);
assert_eq!(
decode.to_string(),
"decrypted plaintext is not decodable key material: PEM parse failed"
);
}
#[test]
fn debug_is_informative() {
let [.., decode] = all_variants();
let debug = format!("{decode:?}");
assert!(debug.starts_with("Decode("), "{debug}");
assert!(debug.contains("PEM parse failed"), "{debug}");
}
#[test]
fn clone_carries_payload() {
for v in all_variants() {
let _ = v.clone();
}
}
fn all_dial_variants() -> Vec<DialError> {
let mut all: Vec<DialError> = all_variants()
.into_iter()
.map(DialError::KeySource)
.collect();
all.push(DialError::Address("not an address".into()));
all.push(DialError::HostKeyRejected);
all.push(DialError::AuthRejected);
all.push(DialError::Wire(russh::Error::Disconnect));
all
}
#[test]
fn dial_error_closed_taxonomy_no_catch_all() {
let exhaustive: fn(&DialError) -> &'static str = |e| match e {
DialError::KeySource(_) => "key-source",
DialError::Address(_) => "address",
DialError::HostKeyRejected => "host-key-rejected",
DialError::AuthRejected => "auth-rejected",
DialError::Wire(_) => "wire",
};
for v in all_dial_variants() {
assert!(!exhaustive(&v).is_empty());
}
}
#[test]
fn dial_error_key_source_variants_preserve_the_class() {
for source in all_variants() {
let wrapped = DialError::KeySource(source.clone());
match wrapped {
DialError::KeySource(seen) => {
assert_eq!(seen.to_string(), source.to_string())
}
other => panic!("unexpected variant {other:?}"),
}
}
}
/// The host-key policy's `false` verdict is russh's `UnknownKey`
/// wire error — the client's `check_server_key` hook is the only
/// producer, so the transparent mapping upgrades exactly that one
/// class and leaves everything else `Wire`.
///
/// - *Contract stamp:* `host_key.rs` (the verdict type pinned to
/// the hook shape); `client.rs` (the dial surface's decline
/// class).
#[test]
fn dial_error_unknown_key_maps_to_host_key_rejected() {
let mapped: DialError = russh::Error::UnknownKey.into();
assert!(
matches!(mapped, DialError::HostKeyRejected),
"expected HostKeyRejected, got {mapped:?}"
);
let mapped: DialError = russh::Error::Disconnect.into();
assert!(
matches!(mapped, DialError::Wire(russh::Error::Disconnect)),
"expected wire-transparent mapping, got {mapped:?}"
);
}
fn all_adapter_variants() -> Vec<AdapterError> {
let mut all: Vec<AdapterError> = all_variants()
.into_iter()
.map(AdapterError::Construction)
.collect();
all.push(AdapterError::ListenRegistration("duplicate".into()));
all.push(AdapterError::Wire(russh::Error::UnknownKey));
all
}
#[test]
fn adapter_error_closed_taxonomy_two_classes() {
let exhaustive: fn(&AdapterError) -> &'static str = match_fn();
for v in all_adapter_variants() {
assert!(!exhaustive(&v).is_empty());
}
}
fn match_fn() -> fn(&AdapterError) -> &'static str {
|e| match e {
AdapterError::Construction(_) => "construction",
AdapterError::ListenRegistration(_) => "construction",
AdapterError::Wire(_) => "wire",
}
}
#[test]
fn construction_preserves_the_key_source_variant() {
for source in all_variants() {
let wrapped = AdapterError::Construction(source.clone());
match wrapped {
AdapterError::Construction(seen) => {
assert_eq!(seen.to_string(), source.to_string())
}
other => panic!("unexpected variant {other:?}"),
}
}
}
#[test]
fn russh_wire_error_is_transparently_mapped() {
let mapped: AdapterError = russh::Error::UnknownKey.into();
assert!(matches!(
mapped,
AdapterError::Wire(russh::Error::UnknownKey)
));
assert_eq!(mapped.to_string(), "ssh session error: Unknown server key");
}
fn all_open_decline_reasons() -> Vec<(russh::ChannelOpenFailure, OpenDeclineReason)> {
use russh::ChannelOpenFailure as F;
vec![
(F::AdministrativelyProhibited, OpenDeclineReason::Prohibited),
(F::ConnectFailed, OpenDeclineReason::ConnectFailed),
(F::UnknownChannelType, OpenDeclineReason::UnknownChannelType),
(F::ResourceShortage, OpenDeclineReason::ResourceShortage),
(
F::Other {
code: 919,
reason: "because".into(),
},
OpenDeclineReason::Other {
code: 919,
reason: "because".into(),
},
),
]
}
/// - *Contract stamp:* `client.md` §op rows (no vanilla-concept
/// leaks up) + POC-1 §6 (the typed open's decline resolves on
/// the awaiting call) — the wire class mirrors one-to-one into
/// the family vocabulary, `Other` verbatim, nothing collapsed.
#[test]
fn open_decline_mirror_preserves_every_class() {
for (wire, expected) in all_open_decline_reasons() {
let mapped: OpenDeclineReason = wire.into();
assert_eq!(mapped, expected);
}
}
fn all_op_variants() -> Vec<OpError> {
let mut all: Vec<OpError> = all_open_decline_reasons()
.into_iter()
.map(|(_, reason)| OpError::OpenDeclined(reason))
.collect();
all.extend([
OpError::RequestRefused,
OpError::ChannelEnded,
OpError::CommandTooLong,
OpError::InvalidRepoId,
OpError::UnixUnavailable,
OpError::NoAcceptor,
OpError::ListenRefused,
OpError::ListenCapExceeded,
OpError::SftpNotServed,
OpError::Wire(russh::Error::Disconnect),
]);
all
}
#[test]
fn op_error_closed_taxonomy_no_catch_all() {
let exhaustive: fn(&OpError) -> &'static str = |e| match e {
OpError::OpenDeclined(_) => "open-declined",
OpError::RequestRefused => "request-refused",
OpError::ChannelEnded => "channel-ended",
OpError::CommandTooLong => "command-too-long",
OpError::InvalidRepoId => "invalid-repo-id",
OpError::UnixUnavailable => "unix-unavailable",
OpError::NoAcceptor => "no-acceptor",
OpError::ListenRefused => "listen-refused",
OpError::ListenCapExceeded => "listen-cap-exceeded",
OpError::SftpNotServed => "sftp-not-served",
OpError::Wire(_) => "wire",
};
for v in all_op_variants() {
assert!(!exhaustive(&v).is_empty());
}
}
/// - *Contract stamp:* the reverse-forward global request's wire
/// decline class (`RequestDenied` — the listen/cancel forms'
/// only producer shape at this pin): `ListenRefused`, never the
/// transparent wire class; the typed-open mapping unchanged.
#[test]
fn op_error_request_denied_maps_to_listen_refused() {
let mapped: OpError = russh::Error::RequestDenied.into();
assert!(matches!(mapped, OpError::ListenRefused), "got {mapped:?}");
}
/// - *Contract stamp:* POC-1 §6 — a rejected channel open
/// surfaces on the awaiting typed call as
/// `russh::Error::ChannelOpenFailure(..)`; the op rows map it
/// into [`OpError::OpenDeclined`] and every other wire error
/// stays transparent.
#[test]
fn op_error_open_failure_maps_to_the_class() {
let mapped: OpError =
russh::Error::ChannelOpenFailure(russh::ChannelOpenFailure::ConnectFailed).into();
assert!(matches!(
mapped,
OpError::OpenDeclined(OpenDeclineReason::ConnectFailed)
));
let mapped: OpError = russh::Error::Disconnect.into();
assert!(matches!(mapped, OpError::Wire(russh::Error::Disconnect)));
}
}