Skip to content

fix: show full anyhow error chain on interactive connect failure - #240

Merged
inureyes merged 4 commits into
mainfrom
fix/issue-238-full-error-chain-interactive
Jul 30, 2026
Merged

fix: show full anyhow error chain on interactive connect failure#240
inureyes merged 4 commits into
mainfrom
fix/issue-238-full-error-chain-interactive

Conversation

@inureyes

@inureyes inureyes commented Jul 30, 2026

Copy link
Copy Markdown
Member

Summary

Interactive-mode connection failures used to print only the outermost anyhow context via eprintln!("{}", e), hiding the failing hop; this adds a shared helper that renders the full anyhow context chain and wires it into both interactive execution sites plus the other user-facing sites in src/commands/ that had the same problem. Because that fix makes the full chain visible for the first time, it also exposed several pre-existing message-construction defects that would have rendered as duplicated or backwards text once the whole chain was shown; those are fixed here too, since a chain that reads backwards or repeats itself defeats the purpose of showing it.

What changed

  • Added src/commands/error_format.rs with format_connection_error(&anyhow::Error) -> String, which renders the full context chain via the alternate Display form ({:#}), matching the format already used by src/executor/parallel.rs (left untouched) so exec-mode and interactive-mode output stay visually consistent.
  • Registered the new module in src/commands/mod.rs.
  • src/commands/interactive/execution.rs: both the PTY-path and traditional-path connection failure prints (lines ~64 and ~133) now use the helper instead of bare {} Display.
  • src/commands/download.rs: the directory-download failure print now uses the helper instead of e.to_string(), since download_dir_from_node goes through the same jump-host chain and was losing the same context.
  • src/commands/ping.rs: the error-chain print now reuses the shared helper instead of its own inline format!("{e:#}"), for consistency; output and per-line indentation are unchanged since the helper does the same formatting.
  • Audited the remaining eprintln!/println! sites in src/commands/ and left them alone: exec.rs's port-forwarding warning wraps a leaf anyhow!() with no added context, the ReadlineError prints in interactive/single_node.rs and interactive/multiplex.rs are not anyhow errors at all, and interactive/multiplex.rs:212 prints an anyhow::Error with {} whose source is a bare leaf russh::Error with no .context() layer added, so {} and {:#} render identically there and switching it would be a no-op.
  • Added unit tests in error_format.rs covering a nested .context() chain (all layers present in output), two different failure hops producing distinguishable messages that each contain their own root cause, and a single-layer error formatting correctly.

Fixed defects the chain rendering would have made visible

  • src/jump/chain.rs: the intermediate jump-hop .with_context() interpolated the jump host twice, producing Failed to connect to jump host bastion (hop 2): bastion. The closure is now a small, unit-tested intermediate_jump_hop_context helper that interpolates the host once, producing Failed to connect to jump host bastion (hop 2). The sibling first-jump and destination contexts were checked and did not have the same defect.
  • src/ssh/client/connection.rs and src/ssh/client/file_transfer.rs: both built their anyhow chain backwards via anyhow!(friendly_message).context(e). Since .context()'s argument becomes the outer layer, the raw SSH error rendered first and the friendly message rendered underneath it as a near-duplicate cause. Both now build anyhow::Error::new(e).context(friendly_message) so the friendly message is outermost and e is the cause.
  • Correcting that ordering exposed a second defect in the same messages: because {:#} joins layers with ": ", a context message that restates its own cause prints the same wording twice on one line. The direct-connect path returned a context message for every error variant, so PasswordWrong rendered as Password authentication failed.: Password authentication failed, and SshError interpolated the underlying russh error that the variant's own Display already carried. connect_error_message now returns Option<String> and yields None for variants whose Display already says everything, so those get no extra layer at all; the messages it does return add remediation guidance without echoing the cause.
  • KeyInvalid's context in both the direct-connect and file-transfer paths no longer re-interpolates the inner key error, which the variant's own Display already includes.
  • Context messages no longer end with a period, which previously rendered as the awkward sequence .: in the middle of a line.
  • Since SshError is now shown directly rather than behind a wrapping layer, its message text became user-visible, so the occured misspelling and the Ssh capitalization are fixed in SshError and SftpError in src/ssh/tokio_client/error.rs.
  • Added regression tests covering the corrected ordering, the variants that must not get a context layer, the inner key error appearing exactly once, and the absence of trailing periods.

Test plan

  • cargo check --lib --tests
  • cargo test --lib (1275 passed, 0 failed)
  • cargo test --lib commands::error_format (3 passed)
  • cargo test --lib commands::interactive (18 passed)
  • cargo test --lib jump::chain (17 passed, including 2 new)
  • cargo test --lib ssh::client::connection (8 passed, including 4 new)
  • cargo test --lib ssh::client::file_transfer (4 passed, new module)
  • cargo clippy --lib --tests -- -D warnings (clean)
  • cargo fmt --all -- --check (clean)
  • Verified src/executor/parallel.rs has no diff (its format!("{e:#}") behavior is unchanged, per the issue's acceptance criteria)
  • Manually traced the four failure scenarios from the issue (first-jump, intermediate-hop, destination channel-open, destination auth) through src/jump/chain.rs and src/jump/chain/tunnel.rs context wrapping to confirm each now surfaces its own distinct innermost cause through the helper

Note on an unrelated pre-existing flake: commands::interactive::utils::tests::test_expand_path_with_tilde fails nondeterministically under the full parallel cargo test --lib because several tests call EnvGuard::set("HOME", ...), mutating process-global env while that test reads dirs::home_dir() twice. It passes in isolation on main and on this branch, and is untouched by this PR. No test added here mutates HOME.

Closes #238

Interactive-mode connection failures printed only the outermost anyhow context via eprintln!("{}", e), hiding the actual failing hop. A user hitting a destination sshd that was not yet accepting connections saw only "Failed to establish jump host connection", with the real cause (a refused direct-tcpip channel open) invisible.

Add format_connection_error() in the new src/commands/error_format.rs module, which renders an anyhow::Error's full context chain via the alternate Display form ({:#}), matching the format already used by src/executor/parallel.rs so exec-mode and interactive-mode output stay consistent. src/executor/parallel.rs itself is untouched.

Wire the helper into both interactive execution sites (PTY path and traditional path in src/commands/interactive/execution.rs) and into the directory-download error path in src/commands/download.rs, which shares the same jump-host connection stack and previously discarded context via e.to_string(). Other eprintln!/println! sites in src/commands/ were audited and left alone: they either print leaf errors with no wrapped context (exec.rs port-forwarding warning) or a foreign error type with no anyhow chain (ReadlineError in interactive/single_node.rs and interactive/multiplex.rs).

Add unit tests in error_format.rs asserting that all layers of a nested .context() chain appear in the formatted output, that different failure hops (first-jump vs destination-auth) produce distinguishable messages containing their own root cause, and that a single-layer error still formats correctly.

Refs #238
@inureyes inureyes added status:review Under review type:bug Something isn't working priority:medium Medium priority issue labels Jul 30, 2026
This PR is a user-facing bug fix but shipped without a CHANGELOG bullet, and since the release commit mechanically rolls the `## [Unreleased]` section into the new version heading, the fix would have been silently absent from the next release notes. The repository convention for a user-facing fix is a `## [Unreleased]` / `### Fixed` bullet referencing the issue number rather than the PR number (see 41642d9 for #234 and 127246b for #189, which likewise opened a fresh Unreleased section immediately after a release), so this adds that section back on top of the just-released v2.3.1 heading with a single bullet describing the interactive connection error chain fix. Documentation only, no code or behavior change.

Refs #238
@inureyes

Copy link
Copy Markdown
Member Author

Implementation Review Summary

Intent

Interactive-mode connection failures must print the full anyhow context chain, via a shared unit-tested helper wired into both interactive execution sites, without changing src/executor/parallel.rs or -vv tracing behavior.

Findings Addressed

Remaining Items

  • src/commands/ping.rs:94 still inlines format!("{e:#}"), which is now byte-identical to the body of format_connection_error (LOW). Behavior is already correct (full chain), so this is a cosmetic reuse opportunity in the same directory the PR audited, deliberately left out of scope.
  • src/commands/interactive/multiplex.rs:212 prints an anyhow::Error with {} and is not listed in the PR description's audit narrative (LOW). Re-audited independently: NodeSession::send_command is self.channel.data(..).await?, a bare ? conversion of a leaf russh::Error with no .context() layer, so {} and {:#} are equivalent there and leaving it alone is the right call. Only the PR description's enumeration is incomplete, not the code.
  • The distinguishes_different_failure_hops test covers two of the four failure classes named in the issue's first acceptance criterion, and builds errors by hand rather than exercising src/jump/chain.rs (LOW). The criterion is satisfied by the distinct context strings already present at chain.rs:272 (first jump host), chain.rs:304 (hop N), chain.rs:329 (destination through chain), tunnel.rs:206 (direct-tcpip channel open), and tunnel.rs:256 (destination auth), each of which now reaches the terminal.

Independent audit of src/commands/

All 92 print!/println!/eprintln! sites were enumerated. No genuinely-wrapped anyhow error print was missed, and no changed site should have been left alone:

  • exec.rs:208: correctly left alone. ForwardingManager::stop_forwarding has exactly one error path, the leaf anyhow!("Forwarding session {id} not found"), with no .context() layer.
  • interactive/single_node.rs:203 and interactive/multiplex.rs:313: correctly left alone, both are rustyline::error::ReadlineError, not anyhow errors.
  • ping.rs:92-101: already renders the full chain with format!("{e:#}").
  • download.rs and upload.rs non-directory paths: failures are reported through ExecutionResult::print_summary, which already uses format!("{e:#}") at src/executor/result_types.rs:103 and :143.
  • download.rs:141 was correctly changed: download_dir_from_node reaches SshClient::download_dir_with_jump_hosts, which adds .with_context() at src/ssh/client/file_transfer.rs:586, :606, and :613 on top of the jump-chain contexts, so e.to_string() was discarding a real chain.

Verification

  • All stated requirements implemented. Both interactive sites (execution.rs:65 PTY path, execution.rs:138 traditional path) call the shared helper.
  • No placeholder/mock code remaining.
  • Integrated into project code flow. format_connection_error has three live call sites and is not orphaned.
  • Project conventions followed. Apache header, module docs, inline format args, pub fn matching sibling commands/ modules; cargo fmt --all --check clean.
  • Existing shared modules reused. The helper matches the format!("{e:#}") form already used across the codebase rather than inventing a new rendering.
  • No unintended structural changes. One new file plus three single-expression edits, no renames or moves.
  • src/executor/parallel.rs behavior unchanged. git diff main...HEAD -- src/executor/parallel.rs is empty and line 517 still reads format!("{e:#}").
  • -vv tracing verbosity unchanged. No tracing:: call, subscriber, or log configuration is touched.
  • Unit test asserts every context layer. includes_all_context_layers asserts all four layers of the nested chain individually.
  • Tests pass. cargo check --lib --tests clean, cargo test --lib commands::error_format 3 passed, cargo test --lib commands::interactive 18 passed, cargo clippy --lib --tests -- -D warnings clean.

@inureyes

Copy link
Copy Markdown
Member Author

Security and performance review

Verdict: no CRITICAL or HIGH findings, nothing auto-fixed, no commits added. The change is sound and I recommend merging. Two follow-up items below, both pre-existing rather than introduced here.

I traced every context layer reachable from the three new call sites through interactive/connection.rs, jump/chain.rs, jump/chain/tunnel.rs, jump/chain/auth.rs, jump/parser/, ssh/auth.rs, ssh/client/connection.rs, ssh/client/file_transfer.rs, ssh/tokio_client/, and the upstream russh / russh::keys / ssh_key Display impls.

No credential material can reach stderr through {:#}

  • Passwords and passphrases: every failure context on these paths is either a static string ("Failed to read password", "Failed to read passphrase", "Password prompt timed out") or interpolates only host and username (jump/chain/auth.rs:293-307). Secret values appear only in rpassword prompt text, never in an error value. Password wraps secrecy::SecretString and has a hand-written redacting Debug (security/password.rs:96-102). BSSH_PASSWORD is never echoed (security/password.rs:140-148).
  • Key material: AuthMethod does hold key_data: Zeroizing<String>, but it is never Debug-formatted in production code. The only {:?} on it is a test panic (ssh/auth.rs:832), and jump/chain/auth.rs:271-285 deliberately hand-matches variants into fixed strings inside a debug! rather than formatting the enum. I checked russh::keys::Error variant by variant (russh-0.62.1/src/keys/mod.rs:89-186): bad-passphrase and decryption failures render as fixed strings such as "The key is encrypted" and "The key is corrupt", UnsupportedKeyType prints only the algorithm name and excludes key_type_raw, and the IO variant is transparent over a std::fs::File::open error that std does not attach a path to. ssh_key::Error is likewise fixed-string only.
  • SSH agent socket: only the literal env var name SSH_AUTH_SOCK ever appears. All four decision points use presence-only checks (ssh/auth.rs:359,382,772 via var_os(..).is_some()), the agent error is discarded at tokio_client/authentication.rs:309, and the one place the socket path is formatted is a debug! (jump/chain/auth.rs:120).
  • Auth tokens and Backend.AI credentials: none exist on these paths.
  • Host key verification failures collapse to the payload-free Error::ServerCheckFailed (tokio_client/connection.rs:527-565), so a changed host key does not surface the offending key or the known_hosts line.

What genuinely does become visible (LOW)

Worth knowing, though all of it is local-context detail shown to the user in their own terminal rather than disclosure to a third party:

  • Private key file paths, including $HOME-derived defaults and canonicalized (symlink-resolved) paths: jump/chain/auth.rs:168,218,357-362,375-381,385-391, ssh/auth.rs:178,182,553,646-649,665,672, ssh/client/connection.rs:57, interactive/connection.rs:158. Paths only, never contents.
  • Jump hop identities beyond what was typed. This is the largest behavioral delta. When -J is absent, the hop chain comes from the bssh config file's jump_host (interactive, via config/resolver.rs:282-326) or from ~/.ssh/config ProxyJump (executor/connection_manager.rs:258-265), and every hop's user@host:port now prints at jump/chain.rs:274,306,429 and jump/chain/tunnel.rs:64,72,100,127,143,157. Previously the outermost layer showed the target host only.
  • The local OS login name via whoami::username() when the user omits user@ (jump/parser/host.rs:62-66, printed at jump/chain/tunnel.rs:157-159).
  • The count of keys loaded in the local SSH agent (jump/chain/auth.rs:445-450).
  • The canonicalized local download destination, so -d ./out surfaces as an absolute path (shared/validation.rs:141-149, printed at ssh/client/file_transfer.rs:589,608,615).

Only worth acting on if bssh output is routinely captured into a shared CI log or artifact. Note that shared/validation.rs:411 sanitize_error_message looks like the natural hook but is the wrong tool: it redacts hostnames, IPs, and usernames, which are exactly the diagnostic details this PR sets out to surface, and it would mangle these particular strings since many contain " to " and " from ". It is also currently dead code, exported but never called.

MEDIUM, pre-existing: unbounded remote-controlled text reaches the terminal

crates/bssh-russh-sftp/src/client/error.rs:12 renders #[error("{}: {}", .0.status_code, .0.error_message)], and error_message is deserialized straight off the SFTP wire (crates/bssh-russh-sftp/src/protocol/status.rs:49). It goes through Display rather than Debug, so newlines, ANSI escapes, and other control bytes pass through verbatim. ssh/client/file_transfer.rs:607-616 wraps it under "Failed to download directory from ...", so e.to_string() previously hid it and the new site prints it.

Since the download.rs site is a println! inside the per-node loop that also emits Successfully downloaded directory and a trailing summary, a hostile node can inject newlines plus crafted text and forge bssh's own status lines, including lines that appear to describe other nodes. For a multi-node fleet tool that is an output-integrity concern rather than a disclosure one.

To be clear about attribution: this is not introduced by this PR. commands/ping.rs:94 and executor/result_types.rs:103,143 already print the full untruncated chain by iterating error_chain.lines(), and result_types.rs:143 is the single-file download display fed by the same SFTP error source. So the PR is a genuine parity change. It does add two more sites, and it also creates the first natural central hook for fixing this: collapsing control characters inside format_connection_error, either by mapping \n, \r, and other control bytes after rendering or by sanitizing each layer while iterating error.chain(), would cover the new sites and could then be adopted by the existing ones. Reasonable as a follow-up issue rather than a blocker.

LOW, pre-existing, now rendered backwards

ssh/client/connection.rs:147 Err(anyhow!(error_msg).context(e)) and ssh/client/file_transfer.rs:678 Err(anyhow!(detailed).context(e)) have their nesting inverted: the friendly message becomes the inner cause and the raw error the outer context. Under {:#} these now render raw error first and friendly message second, sometimes near-duplicated, for example Ssh error occured: X: SSH connection error: X. Swapping the argument order would clean up the output. Separately, jump/chain.rs:306-309 interpolates jump_host twice into one string, so that line prints user@host:port redundantly.

One more note on {:#} semantics that works in the PR's favor: it walks anyhow::Chain, which includes source() links of foreign errors, not just .context() layers. That is why Error::IoError, whose own Display is the bare "I/O error" (ssh/tokio_client/error.rs:42-43), now also yields the inner Connection refused (os error 111). That is exactly the detail issue #238 was about.

Performance: no regression

format!("{error:#}") is one String allocation on the failure path only, once per node, bounded by a chain depth of roughly four to six layers. download.rs is allocation-neutral because e.to_string() already allocated one String. execution.rs adds one String where {} previously formatted directly into the stderr lock, which is immaterial next to the network timeout that preceded it. Nothing changes on the success path and no lock is held across the formatting.

Verification

cargo check --lib --tests clean, cargo test --lib commands::error_format 3 of 3 pass, cargo clippy --lib --tests -- -D warnings clean. Working tree untouched.

inureyes added 2 commits July 30, 2026 12:22
The #238 chain-rendering fix makes the full anyhow context chain visible for the first time, which surfaced three pre-existing defects in the messages being wrapped: they would now render as duplicated or backwards text instead of just being silently truncated by anyhow's default Display.

src/jump/chain.rs's intermediate jump-hop `.with_context()` interpolated the jump host twice ("Failed to connect to jump host bastion (hop 2): bastion"). The closure is now a small testable `intermediate_jump_hop_context` helper that interpolates the host once.

src/ssh/client/connection.rs and src/ssh/client/file_transfer.rs both built their anyhow chain backwards via `anyhow!(friendly_message).context(e)`; `.context()`'s argument becomes the outer layer, so the raw SSH error rendered first and the friendly message rendered underneath it as a near-duplicate cause. Both now use `anyhow::Error::new(e).context(friendly_message)` so the friendly message is outermost and `e` is the cause, and their catch-all match arms no longer interpolate `e`'s own Display text into the friendly message (which would otherwise duplicate the cause verbatim once the ordering is corrected).

src/commands/ping.rs's error-chain print now reuses the shared `format_connection_error` helper instead of its own inline `format!("{e:#}")`; output is unchanged since the helper does the same formatting. src/commands/error_format.rs's test fixture data is updated to match the corrected jump-hop message instead of the old duplicated form. src/executor/parallel.rs is untouched.

Validation:
- cargo check --lib --tests
- cargo test --lib jump::chain (17 passed, including 2 new)
- cargo test --lib ssh::client::connection (6 passed, including 2 new)
- cargo test --lib ssh::client::file_transfer (2 passed, new module)
- cargo test --lib commands::error_format (3 passed)
- cargo clippy --lib --tests -- -D warnings (clean)
- cargo fmt --all -- --check (clean)

Refs #238
Correcting the inverted anyhow nesting exposed a second defect in the same messages. Because anyhow's `{:#}` form joins layers with ": ", a context message that restates its own cause prints the same wording twice on one line.

The direct-connect path returned a context message for every error variant, so `PasswordWrong` rendered as "Password authentication failed.: Password authentication failed" and `SshError` interpolated the russh error that the variant's own Display already carried. `connect_error_message` now returns `Option<String>` and yields `None` for variants whose Display already says everything, so those get no extra layer. The messages it does return add remediation guidance without echoing the cause.

`KeyInvalid`'s context in both the direct-connect and file-transfer paths no longer re-interpolates the inner key error, which the variant's Display already includes. Context messages also no longer end with a period, which rendered as the sequence ".: " mid-line.

Since `SshError` is now shown directly rather than behind a wrapping layer, its message text is user-visible, so the `occured` misspelling and the `Ssh` capitalization are fixed there and in `SftpError`.

Adds regression tests covering the omitted-context variants, the inner key error appearing exactly once, and the absence of trailing periods.
@inureyes inureyes added status:done Completed and removed status:review Under review labels Jul 30, 2026
@inureyes
inureyes merged commit d525ec5 into main Jul 30, 2026
3 checks passed
@inureyes
inureyes deleted the fix/issue-238-full-error-chain-interactive branch July 30, 2026 03:52
inureyes added a commit that referenced this pull request Jul 30, 2026
Two defects found reviewing #239's TOFU implementation.

`russh::keys::check_known_hosts_path` reports `Err(KeyChanged)` only when an entry for the host exists with the same key algorithm, and `Ok(false)` both for a hostname with no entry at all and for a hostname whose entries are all of a different algorithm. accept-new treated the second case as a first use, appending the offered key next to the existing entry and accepting the connection, so a man-in-the-middle that advertises support for only an algorithm the host has not used yet bypassed pinning entirely: SSH negotiation picks the first client algorithm the server also supports, so the attacker chooses it. OpenSSH resists this by reordering its per-host key algorithm proposal to prefer already-known types, which russh cannot do per host, so `verify_accept_new` now consults `known_host_keys_path` before recording and treats any existing entry for the host as a changed key.

The printed removal command did not work for non-standard ports. known_hosts records those as `[host]:port`, but the warning banner and both client-facing guidance messages emitted `ssh-keygen -R <host>`, which matches nothing, and `Error::HostKeyChanged` did not carry the port for the downstream messages to use. The variant now carries the port, the banner follows OpenSSH's `ssh-keygen -f "<file>" -R "<entry>"` form with the resolved default known_hosts path, and every printed argument is double quoted so an unmatched `[...]` glob does not make zsh reject the command.

Both #240 invariants are preserved: no context layer restates its own cause, and no context message ends with a period.

Refs #239
inureyes added a commit that referenced this pull request Jul 30, 2026
Two review passes on the accept-new TOFU implementation reported three remaining gaps that were not yet fixed: known_hosts `@revoked`/`@cert-authority` marker lines were silently ignored, host matching was case-sensitive where OpenSSH is not, and known_hosts permission tightening happened after creation instead of at creation.

russh's `known_host_keys_path` splits each line on `' '` and takes the first field as the host list, so on a marker line such as `@revoked node1 ssh-ed25519 <key>` that first field is the literal string `@revoked`, which never matches `node1`. The line was therefore invisible to the lookup, and a revoked key looked like an ordinary first use: recorded and accepted while printing the normal "Permanently added" notice. A new scan in `host_verification.rs` runs before the ordinary lookup in both accept-new and strict (`yes`)/`DefaultKnownHostsFile` mode: a `@revoked` line whose host matches (exact, comma-list, or `*`/`?` glob form, erring toward matching when in doubt since failing to honor a revocation is worse than an unnecessary rejection) and whose key equals the offered key is a hard rejection with a new `HostKeyRevoked` error, while a matching `@revoked` line naming a different key does not block the offered one. A `@cert-authority` match only warns and falls through to ordinary TOFU, since bssh has no CA signature validation path and failing closed would break every working CA setup with no workaround.

Hostnames are now lowercased once at entry to `verify_accept_new` and `verify_known_hosts_file`, and the normalized value is used for both the lookup and the recorded entry. russh's `match_hostname` is a plain byte compare, so without this a differently-cased hostname bypassed an existing pin and re-TOFU'd instead of hitting the same entry OpenSSH would have written.

`~/.ssh` and `known_hosts` are now created with mode 0700/0600 directly via `DirBuilder`/`OpenOptions` (`#[cfg(unix)]`, with a `#[cfg(not(unix))]` fallback) before `learn_known_hosts_path` runs, instead of letting it create them with the process umask and tightening the mode only afterward. A `mkdir`/`open` syscall that requests the final mode directly cannot be widened by any standard umask, since umask only clears requested bits and neither 0700 nor 0600 carries a group or other bit to clear, so this closes the window instead of narrowing it after the fact. Pre-existing files and directories are left untouched either way, and the prior post-creation chmod is kept as a fallback for the rare case where pre-creation could not run (for example a missing grandparent directory that only `learn_known_hosts_path`'s `create_dir_all` fills in).

`HostKeyRevoked` gets its own guidance arms in `connect_error_message` and `format_ssh_error`, verified against both #240 invariants (no restating the cause, no trailing period), and a regression test confirms it does not trigger interactive password fallback, matching `HostKeyChanged`.

Validation:
- cargo check --lib --tests (clean)
- cargo clippy --lib --tests -- -D warnings (clean)
- cargo fmt --all -- --check (clean)
- cargo test --lib ssh::tokio_client (36 passed, 9 new: revoked matching/non-matching, cert-authority, comma-list/glob/non-standard-port marker matching, case-insensitive hostname matching for accept-new and strict mode)
- cargo test --lib ssh::client (22 passed, 2 new guidance-invariant tests)
- cargo test --lib commands::interactive (20 passed, 1 new password-fallback exclusion test)
- cargo test --lib ssh::known_hosts (5 passed) and cargo test --lib jump::chain (17 passed)

Refs #239
inureyes added a commit that referenced this pull request Jul 30, 2026
## Summary

The documented, recommended default `--strict-host-key-checking accept-new` performed no host key verification at all: it mapped to `ServerCheckMethod::NoCheck`, accepted any server key, never wrote to `~/.ssh/known_hosts`, and never rejected changed keys. This PR implements real TOFU for accept-new, fixes strict (`yes`) mode's silent downgrade to `NoCheck` when the known_hosts file is missing, makes changed keys a first-class error in every known_hosts-based check method, and, after two review passes, closes the gaps those reviews found: an OpenSSH-compatible any-matching-entry acceptance rule, hostname sanitization before recording, `@revoked`/`@cert-authority` known_hosts marker line handling, case-insensitive hostname matching, and a closed permission-creation window on `~/.ssh`/`known_hosts`.

## Correction to the issue's verified facts

The issue's "Verified Implementation Facts" section claims russh 0.62.1 exposes no `learn_known_hosts` helper and that bssh must implement the known_hosts append itself. That claim is incorrect: `russh::keys::known_hosts::learn_known_hosts_path(host, port, pubkey, path)` exists in russh 0.62.1 and already handles parent directory creation, newline-safe appending, the `[host]:port` entry form for non-22 ports, and OpenSSH key serialization. This implementation uses that library helper instead of hand-rolling the append; what the helper does not do, and what this PR adds on top, is restrictive permissions on newly created files (created up front rather than tightened afterward), serialization of concurrent writers, an OpenSSH-compatible any-matching-entry acceptance rule (the helper's own `check_known_hosts_path` short-circuits on the first non-matching same-algorithm entry), `@revoked`/`@cert-authority` marker line handling (the helper's parser reads the marker itself as the literal host field and never matches these lines), and hostname sanitization before recording.

## What changed

- `src/ssh/tokio_client/authentication.rs`: new path-carrying `ServerCheckMethod::AcceptNewKnownHostsFile(String)` variant (plus `with_accept_new_known_hosts_file`), so tests can inject a temp known_hosts path instead of mutating `HOME`.
- `src/ssh/tokio_client/host_verification.rs` (new): implements the trust decision for both accept-new and strict/`DefaultKnownHostsFile` mode through a shared `lookup_known_host`, which reads a host's known_hosts entries directly and applies OpenSSH's rule: any recorded key equal to the offered key verifies, and only a host whose recorded keys none of them match counts as changed. This also closes the alternate-algorithm pinning bypass (a key offered under an algorithm the host had not used yet, which `check_known_hosts_path` cannot distinguish from an unknown host) and avoids the false-positive rejection `check_known_hosts_path`'s short-circuiting collection produces for legitimate layouts such as a stale key kept beside a rotated one or a cluster-style shared line beside a per-node line. Before recording or looking anything up, the module also: rejects a hostname that cannot round-trip through a known_hosts entry (empty, over 255 bytes, whitespace/control characters, or `#`/`,`/`|`/`*`/`?`/`!`/`\` metacharacters, since `learn_known_hosts_path` writes the hostname with no escaping and a malformed entry can never be verified again either); scans for `@revoked`/`@cert-authority` marker lines naming the host (host matching supports the exact, comma-list, and `*`/`?` glob forms, erring toward matching for `@revoked` when in doubt) and hard-rejects a key matching an `@revoked` entry with a new `HostKeyRevoked` error while warning and falling through to ordinary TOFU for a `@cert-authority` match, since bssh has no CA signature validation to fall back to; and normalizes the hostname to lowercase once, using the normalized value for both the lookup and the recorded entry, matching OpenSSH's case-insensitive known_hosts comparison. Unknown hosts are recorded via `learn_known_hosts_path` with the OpenSSH-style `Permanently added '<host>' (<ALGO>) to the list of known hosts.` notice; conflicting keys are rejected with the OpenSSH warning banner (SHA256 fingerprint of the offered key, offending file and line, `ssh-keygen -f "<file>" -R "<entry>"` remediation hint naming the actual `[host]:port`-qualified entry) without touching the recorded entry. Recording failure warns and accepts, matching OpenSSH accept-new semantics.
- Concurrency: the whole check-then-record sequence runs under a process-wide `tokio::sync::Mutex`, so parallel first-time connects to the same new host record exactly one entry and readers never observe a half-written line. A tokio mutex (not `std::sync::Mutex`) was chosen because the guard lives inside the async `check_server_key` handler; the critical section is purely synchronous file I/O today, and the tokio mutex keeps the guard sound if an await point is ever introduced while parking contending tasks instead of blocking runtime workers.
- Permissions: `~/.ssh` and `known_hosts` are created with mode 0700/0600 directly (`DirBuilder`/`OpenOptions` with an explicit mode, `#[cfg(unix)]`) before `learn_known_hosts_path` runs, rather than letting it create them with the process umask and tightening the mode only afterward, closing the window in which an unusually permissive umask could leave either one group- or world-writable; pre-existing files and directories are left untouched either way, verified by test.
- `src/ssh/tokio_client/error.rs`: new `Error::HostKeyChanged { host, port, line }` and `Error::HostKeyRevoked { host, port, line }` variants; the `KnownHostsFile` and `DefaultKnownHostsFile` arms in `check_server_key` no longer collapse `russh::keys::Error::KeyChanged` into the generic `ServerCheckFailed`, so strict mode reports changed and revoked keys specifically too.
- `src/ssh/known_hosts.rs`: `get_check_method` is now a pure mapping with no filesystem side effects (it no longer pre-creates an empty known_hosts file; `learn_known_hosts_path` creates it on demand). `Yes` maps to `KnownHostsFile(default path)` unconditionally, since russh treats a missing file as empty, so unknown hosts are rejected instead of unverified; an undeterminable home directory falls back to `DefaultKnownHostsFile`, which fails closed inside russh. `AcceptNew` maps to `AcceptNewKnownHostsFile(default path)`. Stale "async-ssh2-tokio doesn't support TOFU" comments deleted.
- `src/jump/chain/tunnel.rs`: removed the redundant strict-mode match before `get_check_method` (it already maps `No` to `NoCheck`).
- `src/commands/exec.rs`: the port-forwarding path had its own hand-rolled mode mapping that sent accept-new through strict default-known_hosts checking (rejecting unknown hosts); it now routes through `get_check_method`, so TOFU covers every connection path: direct, first jump hop, intermediate hops, destination through the tunnel, SFTP, interactive (shell and PTY), and port forwarding. All `get_check_method` call sites were individually verified to flow into `ClientHandler` with the real hostname preserved (via `ToSocketAddrsWithHostname` or direct construction), so `[host]:port` entries round-trip on every path.
- `src/ssh/client/connection.rs` and `src/ssh/client/file_transfer.rs`: guidance arms for `HostKeyChanged` and `HostKeyRevoked` (and, in the direct path, `SshError(UnknownKey)` for strict-mode unknown-host rejections) in `connect_error_message` and `format_ssh_error`, following both #240 invariants: the context layer does not restate its cause and does not end with a period, enforced by tests alongside the existing ones.
- `src/commands/interactive/connection.rs`: regression tests that neither `HostKeyChanged` nor `HostKeyRevoked` ever triggers interactive password fallback (retrying with a password would hand credentials to an untrusted endpoint).
- Docs: `docs/architecture/ssh-client.md` host key section updated to the actual behavior, including the any-matching-entry rule, `@revoked`/`@cert-authority` marker handling, and case-insensitive matching. `CHANGELOG.md` gains four `### Security` entries under `[Unreleased]` covering each of the above. The CLI help text (`accept-new - Accept new hosts, reject changed keys (recommended)`) and man page were verified to match reality and are unchanged.

## Deliberately out of scope

Two review passes surfaced additional lower-severity findings that are intentionally not addressed here:
- Running the known_hosts lookup unlocked and taking the process-wide lock only when the host looks unknown. Measured cost of the current fully-serialized lookup is 25 to 400 ms across a 512-node fan-out for realistic known_hosts sizes, and moving the read out of the lock risks reintroducing the duplicate-append race this PR exists to prevent.
- Cross-process locking (`KNOWN_HOSTS_LOCK` is per-process only).
- `get_check_method(AcceptNew)`'s no-home-directory `NoCheck` fallback.
- The dead, divergent duplicate `ServerCheckMethod` enum in `src/shared/auth_types.rs`.

## Test plan

- [x] `cargo test --lib` (1314 passed, 0 failed, exit 0)
- [x] `cargo test --lib ssh::tokio_client` (36 passed, including the host_verification suite: first-use recording, no duplicate on reconnect, changed key rejected with entry preserved, any-matching-entry acceptance for stale-beside-rotated and cluster-shared-line layouts, alternate-algorithm rejection, 8-way concurrent first connects, `[host]:2222` round-trip, created dir/file get 0700/0600 with pre-existing modes preserved, `@revoked` matching-key rejection and non-matching-key pass-through, `@cert-authority` warning-then-TOFU, comma-list/glob/non-standard-port marker matching, case-insensitive hostname matching for both accept-new and strict mode, strict mode parity with accept-new, hostname-sanitization rejection and regression coverage, handler end-to-end TOFU arm)
- [x] `cargo test --lib ssh::known_hosts` (5 passed)
- [x] `cargo test --lib ssh::client` (22 passed, including the `HostKeyChanged`/`HostKeyRevoked` guidance-message invariant tests) and `cargo test --lib jump::chain` (17 passed)
- [x] `cargo test --lib commands::interactive` (20 passed, including the password-fallback exclusion tests for both error variants) and `cargo test --lib commands::exec` (0 tests; `exec.rs` has no unit tests of its own)
- [x] `cargo check --lib --tests`, `cargo clippy --lib --tests -- -D warnings`, and `cargo fmt --all` clean
- [x] `cargo check --lib --target x86_64-pc-windows-gnu` could not complete in this environment: the target is installed but the `aws-lc-sys` build script requires `x86_64-w64-mingw32-gcc`, which is not present; the failure occurs in a C dependency before any bssh code compiles and is unrelated to this change. Windows compatibility is preserved by gating the permission code on `#[cfg(unix)]` with a non-unix fallback arm.

Note on an unrelated pre-existing flake: `commands::interactive::utils::tests::test_expand_path_with_tilde` fails nondeterministically under the full parallel `cargo test --lib` because several tests call `EnvGuard::set("HOME", ...)`, mutating process-global env while that test reads `dirs::home_dir()` twice. It is untouched by this PR, and no test added here mutates `HOME`; the new `ServerCheckMethod::AcceptNewKnownHostsFile` variant carries a path so every new test injects a temp known_hosts location explicitly.

Closes #239
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

priority:medium Medium priority issue status:done Completed type:bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

fix: interactive mode swallows the anyhow error chain on connection failure, hiding the failing hop

1 participant