- 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).
679 lines
29 KiB
Rust
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)));
|
|
}
|
|
}
|