//! 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`, 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` 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 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 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 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 { let mut all: Vec = 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 { let mut all: Vec = 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 { let mut all: Vec = 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))); } }