Skip to content

Commit 3c3f779

Browse files
feat: M13.2 — AnvilSession::connect_via_proxy_command (FR-55) (#10)
Adds the public constructor that consumes the ProxyCommand template captured into ResolvedSshConfig in M12 and the building blocks landed in M13.1 (token expansion + ChildStdio adapter). session.rs: - New private HandlerPieces struct + Self::build_handler_pieces helper factor out the russh config + GitwayHandler + auth_banner + verified_fingerprint mutex setup that connect() and the new connect_via_proxy_command both need. M13.4's connect_via_jump_hosts will reuse the same helper. The host-key fingerprint lookup (with the StrictHostKeyChecking::AcceptNew tolerance shipped in M12.5) lives there now, in one place. - pub async fn connect_via_proxy_command(config, template, alias): 1. Reject the literal `none` (case-insensitive) — that's the FR-59 disable sentinel; callers should reach for connect() instead. The ssh_config resolver preserves `none` as a literal so first- wins protects it; the dispatcher in M13.6 turns that into a call to connect() rather than this method. 2. Build the handler via build_handler_pieces. 3. Spawn the ProxyCommand child via crate::proxy::command:: spawn_proxy_command (token-expanded against config.host / config.port / config.username / alias). 4. Hand the resulting ChildStdio (AsyncRead+AsyncWrite+Unpin+Send) to russh::client::connect_stream. russh drives the SSH handshake over the child's stdio. 5. Return the AnvilSession with the same shape as connect() — indistinguishable to callers thereafter. - The existing connect() now calls build_handler_pieces too; behavior is identical (same russh::client::connect call, same handler). proxy/mod.rs: - Removed the M13.1-era #![allow(dead_code)]; spawn_proxy_command and ChildStdio::new are now used by session.rs. Tests: 173 lib + 1 matrix + 6 integration; 0 failures. Plan: M13.2 of let-us-plan-on-bright-cosmos.md. Stacked on M13.1 (PR #6).
1 parent f7f30e7 commit 3c3f779

2 files changed

Lines changed: 113 additions & 22 deletions

File tree

src/proxy/mod.rs

Lines changed: 0 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,5 @@
11
// SPDX-License-Identifier: GPL-3.0-or-later
22
// Rust guideline compliant 2026-03-30
3-
#![allow(
4-
dead_code,
5-
reason = "M13.1 lands the proxy module's building blocks (token \
6-
expansion, ChildStdio adapter, spawn helper) as crate-private \
7-
items; M13.2 wires them into AnvilSession::connect_via_proxy_command \
8-
and the dead-code analysis turns clean. Remove this allow at \
9-
M13.2 time."
10-
)]
113
//! `ProxyCommand` and `ProxyJump` consumers (PRD §5.8.2, M13).
124
//!
135
//! M12 captured `proxy_command` and `proxy_jump` losslessly into

src/session.rs

Lines changed: 113 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -178,20 +178,28 @@ impl fmt::Debug for AnvilSession {
178178
}
179179
}
180180

181+
/// The pre-handshake state every constructor on [`AnvilSession`]
182+
/// builds before driving russh. Factoring it out keeps `connect`,
183+
/// `connect_via_proxy_command`, and `connect_via_jump_hosts` (M13.4)
184+
/// in lock-step on host-key handling and the `auth_banner` /
185+
/// `verified_fingerprint` mutexes the public getters expose.
186+
struct HandlerPieces {
187+
russh_cfg: Arc<client::Config>,
188+
handler: GitwayHandler,
189+
auth_banner: Arc<Mutex<Option<String>>>,
190+
verified_fingerprint: Arc<Mutex<Option<String>>>,
191+
}
192+
181193
impl AnvilSession {
182194
// ── Construction ─────────────────────────────────────────────────────────
183195

184-
/// Establishes a TCP connection to the host in `config` and completes the
185-
/// SSH handshake (including host-key verification).
186-
///
187-
/// Does **not** authenticate; call [`authenticate`](Self::authenticate) or
188-
/// [`authenticate_best`](Self::authenticate_best) after this.
189-
///
190-
/// # Errors
196+
/// Builds the russh config + handler used by every constructor.
191197
///
192-
/// Returns an error on network failure or if the server's host key does not
193-
/// match any pinned fingerprint.
194-
pub async fn connect(config: &AnvilConfig) -> Result<Self, AnvilError> {
198+
/// Centralises host-key fingerprint lookup (with the
199+
/// [`StrictHostKeyChecking::AcceptNew`] tolerance for unknown hosts
200+
/// when a writable `custom_known_hosts` path is set) and the shared
201+
/// `auth_banner` / `verified_fingerprint` mutex pair.
202+
fn build_handler_pieces(config: &AnvilConfig) -> Result<HandlerPieces, AnvilError> {
195203
let russh_cfg = Arc::new(build_russh_config(config.inactivity_timeout));
196204
// For StrictHostKeyChecking=AcceptNew with a writable known_hosts
197205
// path, an empty fingerprint set is acceptable — the handler will
@@ -229,17 +237,108 @@ impl AnvilSession {
229237
verified_fingerprint: Arc::clone(&verified_fingerprint),
230238
};
231239

240+
Ok(HandlerPieces {
241+
russh_cfg,
242+
handler,
243+
auth_banner,
244+
verified_fingerprint,
245+
})
246+
}
247+
248+
/// Establishes a TCP connection to the host in `config` and completes the
249+
/// SSH handshake (including host-key verification).
250+
///
251+
/// Does **not** authenticate; call [`authenticate`](Self::authenticate) or
252+
/// [`authenticate_best`](Self::authenticate_best) after this.
253+
///
254+
/// # Errors
255+
///
256+
/// Returns an error on network failure or if the server's host key does not
257+
/// match any pinned fingerprint.
258+
pub async fn connect(config: &AnvilConfig) -> Result<Self, AnvilError> {
259+
let pieces = Self::build_handler_pieces(config)?;
260+
232261
log::debug!("session: connecting to {}:{}", config.host, config.port);
233262

234-
let handle =
235-
client::connect(russh_cfg, (config.host.as_str(), config.port), handler).await?;
263+
let handle = client::connect(
264+
pieces.russh_cfg,
265+
(config.host.as_str(), config.port),
266+
pieces.handler,
267+
)
268+
.await?;
236269

237270
log::debug!("session: SSH handshake complete with {}", config.host);
238271

239272
Ok(Self {
240273
handle,
241-
auth_banner,
242-
verified_fingerprint,
274+
auth_banner: pieces.auth_banner,
275+
verified_fingerprint: pieces.verified_fingerprint,
276+
})
277+
}
278+
279+
/// Establishes the SSH session over a child process spawned from a
280+
/// `ProxyCommand` template (FR-55).
281+
///
282+
/// `proxy_command_template` is the raw template (typically from
283+
/// [`crate::ssh_config::ResolvedSshConfig::proxy_command`] or a CLI
284+
/// override). `%h`, `%p`, `%r`, `%n`, and `%%` are expanded against
285+
/// `config.host`, `config.port`, `config.username`, and `alias`
286+
/// respectively before the platform shell (`sh -c` / `cmd /C`)
287+
/// spawns the command. The child's stdin/stdout become the SSH
288+
/// transport via [`russh::client::connect_stream`].
289+
///
290+
/// `alias` is the original argument the user typed before
291+
/// `HostName` resolution — it powers the `%n` token. Pass
292+
/// `config.host` if you do not track the alias separately.
293+
///
294+
/// The literal value `"none"` (case-insensitive) is recognized as
295+
/// the FR-59 disable sentinel: this method returns an error
296+
/// directing the caller to use [`Self::connect`] instead. In
297+
/// practice the caller's dispatcher should never invoke this
298+
/// method in that case, but the guard keeps the spawn path safe
299+
/// against accidental "none" input.
300+
///
301+
/// # Errors
302+
/// Returns an error on shell-spawn failure, on a host-key
303+
/// mismatch, or on any russh handshake failure.
304+
pub async fn connect_via_proxy_command(
305+
config: &AnvilConfig,
306+
proxy_command_template: &str,
307+
alias: &str,
308+
) -> Result<Self, AnvilError> {
309+
if proxy_command_template.eq_ignore_ascii_case("none") {
310+
return Err(AnvilError::invalid_config(
311+
"ProxyCommand=none is the disable sentinel; \
312+
call AnvilSession::connect instead",
313+
));
314+
}
315+
316+
let pieces = Self::build_handler_pieces(config)?;
317+
318+
log::debug!(
319+
"session: connecting to {} via ProxyCommand template `{proxy_command_template}`",
320+
config.host,
321+
);
322+
323+
let stream = crate::proxy::command::spawn_proxy_command(
324+
proxy_command_template,
325+
&config.host,
326+
config.port,
327+
&config.username,
328+
alias,
329+
)?;
330+
331+
let handle = client::connect_stream(pieces.russh_cfg, stream, pieces.handler).await?;
332+
333+
log::debug!(
334+
"session: SSH handshake complete with {} (via ProxyCommand)",
335+
config.host,
336+
);
337+
338+
Ok(Self {
339+
handle,
340+
auth_banner: pieces.auth_banner,
341+
verified_fingerprint: pieces.verified_fingerprint,
243342
})
244343
}
245344

0 commit comments

Comments
 (0)