Skip to content

Commit bf073f6

Browse files
committed
feat(proxy): M13.3 — jump-string parser + JumpHost type (FR-56)
Adds the public `proxy::jump` submodule: JumpHost (struct): - pub fields: host: String, port: u16, user: Option<String>, identity_files: Vec<PathBuf>. - The chain manager (M13.4) populates identity_files by re-running ssh_config::resolve against each hop's name; M13.3 leaves it empty. parse_jump_chain(raw: &str) -> Result<Vec<JumpHost>, AnvilError>: - Accepts the OpenSSH `-J` / `ProxyJump` syntax: `[user@]host[:port]` comma-separated, whitespace-around-commas tolerated. - Defaults the port to 22, leaves user as None when omitted. - Rejects empty strings, trailing/double commas, empty user prefix, empty host, invalid port, and the literal `none` (case-insensitive) with clear actionable error messages. MAX_JUMP_HOPS = 8: - Matches OpenSSH's READCONF_MAX_DEPTH for ProxyJump chains (FR-57). - Enforced at parse time, not chain-construction time, so callers bail out before any network I/O. IPv6 literals (`[::1]:22`) are not in scope for M13 — none of the listed FRs require them and the OpenSSH convention is rare in ProxyJump strings; documented as a follow-up. Tests: +16 unit tests (single-hop bare, single-hop user@host:port, two-hop chain, whitespace tolerance, empty-string/whitespace-only, trailing/double commas, empty user, empty host, invalid port, out-of-range port, none literal in three case variants, max-length chain accepted, length+1 chain rejected, struct PartialEq+Clone sanity). 189 lib tests green, 0 failures. Plan: M13.3 of let-us-plan-on-bright-cosmos.md. Stacked on M13.2 (PR #7).
1 parent 3c3f779 commit bf073f6

2 files changed

Lines changed: 304 additions & 0 deletions

File tree

src/proxy/jump.rs

Lines changed: 302 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,302 @@
1+
// SPDX-License-Identifier: GPL-3.0-or-later
2+
// Rust guideline compliant 2026-03-30
3+
//! `ProxyJump` chain parser + per-hop type.
4+
//!
5+
//! M13.3 adds the parser and the [`JumpHost`] type; M13.4 wires both
6+
//! into [`crate::session::AnvilSession::connect_via_jump_hosts`] (which
7+
//! drives russh's `direct-tcpip` channel for each chained hop).
8+
//!
9+
//! # Jump-string grammar
10+
//!
11+
//! OpenSSH's `ProxyJump` directive (and `-J` flag) accepts a comma-
12+
//! separated list of hops, each in the form:
13+
//!
14+
//! ```text
15+
//! [user@]host[:port]
16+
//! ```
17+
//!
18+
//! Whitespace around commas is ignored. Trailing commas and empty
19+
//! entries are rejected with [`AnvilError::invalid_config`]. The chain
20+
//! length is capped at [`MAX_JUMP_HOPS`] = 8 (matches OpenSSH's
21+
//! `READCONF_MAX_DEPTH` for `ProxyJump` chains).
22+
//!
23+
//! Per-hop `IdentityFile` selection is **not** done here — that
24+
//! requires re-running [`crate::ssh_config::resolve`] against each
25+
//! hop's hostname, which the chain manager (M13.4) does because it
26+
//! has access to the `SshConfigPaths`. This module's job is purely
27+
//! syntactic: turn the raw string into a structured list of hops.
28+
29+
use std::path::PathBuf;
30+
31+
use crate::error::AnvilError;
32+
33+
/// Hard cap on chain length, matching OpenSSH. Any chain longer than
34+
/// this is refused at parse time with a clear error.
35+
pub const MAX_JUMP_HOPS: usize = 8;
36+
37+
/// One hop in a [`ProxyJump`] chain.
38+
///
39+
/// Constructed by [`parse_jump_chain`]. M13.4's chain manager reads
40+
/// `host` and `port` to drive `direct-tcpip`; `user` and
41+
/// `identity_files` are layered into the per-hop [`crate::AnvilConfig`]
42+
/// before the inner SSH handshake.
43+
///
44+
/// `identity_files` is empty after parsing — the chain manager fills
45+
/// it in by resolving each hop's name against the user's `ssh_config`.
46+
#[derive(Debug, Clone, PartialEq, Eq)]
47+
pub struct JumpHost {
48+
/// Bare hostname to connect to.
49+
pub host: String,
50+
/// SSH port. Defaults to 22 when the jump-string omits it.
51+
pub port: u16,
52+
/// Remote username, or `None` when the jump-string omits the
53+
/// `user@` prefix. The chain manager falls back to `ssh_config`'s
54+
/// `User` for the hop, then to the inherited username, then to
55+
/// the `AnvilConfig` builder default (`git`).
56+
pub user: Option<String>,
57+
/// Identity files for this hop. Empty after [`parse_jump_chain`]
58+
/// returns; M13.4's chain manager populates this by resolving the
59+
/// hop's `Host` block.
60+
pub identity_files: Vec<PathBuf>,
61+
}
62+
63+
/// Parses a comma-separated `ProxyJump` chain into ordered [`JumpHost`]s.
64+
///
65+
/// Accepts the `-J` / `ProxyJump` syntax: ``[user@]host[:port][,…]``.
66+
/// Whitespace around commas is ignored. The literal `none` (single
67+
/// element, case-insensitive) is rejected here — callers that
68+
/// recognize `none` as the FR-59 disable sentinel should detect it
69+
/// before calling this function and fall back to a direct connection.
70+
///
71+
/// # Errors
72+
/// - The string is empty or contains only commas / whitespace.
73+
/// - An entry has an empty host (`@`, `:22`, or just whitespace).
74+
/// - An entry has an empty user-portion (`@host`).
75+
/// - The port portion is not parseable as `u16`.
76+
/// - The chain length exceeds [`MAX_JUMP_HOPS`].
77+
/// - The literal `none` is used (callers should handle this before
78+
/// calling).
79+
pub fn parse_jump_chain(raw: &str) -> Result<Vec<JumpHost>, AnvilError> {
80+
let trimmed = raw.trim();
81+
if trimmed.is_empty() {
82+
return Err(AnvilError::invalid_config(
83+
"ProxyJump: empty jump-host string",
84+
));
85+
}
86+
// Reject `none` here so callers cannot accidentally treat the FR-59
87+
// sentinel as a real chain.
88+
if trimmed.eq_ignore_ascii_case("none") {
89+
return Err(AnvilError::invalid_config(
90+
"ProxyJump=none is the disable sentinel; \
91+
callers should detect this before parsing",
92+
));
93+
}
94+
95+
let mut hops: Vec<JumpHost> = Vec::new();
96+
for piece in trimmed.split(',') {
97+
let entry = piece.trim();
98+
if entry.is_empty() {
99+
return Err(AnvilError::invalid_config(format!(
100+
"ProxyJump: empty entry in `{raw}` (trailing or repeated commas)",
101+
)));
102+
}
103+
hops.push(parse_one(entry)?);
104+
}
105+
106+
if hops.len() > MAX_JUMP_HOPS {
107+
return Err(AnvilError::invalid_config(format!(
108+
"ProxyJump: chain length {} exceeds the {MAX_JUMP_HOPS}-hop limit",
109+
hops.len(),
110+
)));
111+
}
112+
113+
Ok(hops)
114+
}
115+
116+
/// Parses one `[user@]host[:port]` entry.
117+
fn parse_one(entry: &str) -> Result<JumpHost, AnvilError> {
118+
// Split off the optional `user@` prefix.
119+
let (user, host_port) = match entry.split_once('@') {
120+
Some((u, hp)) => {
121+
if u.is_empty() {
122+
return Err(AnvilError::invalid_config(format!(
123+
"ProxyJump: empty user in `{entry}` (`@host` without name)",
124+
)));
125+
}
126+
(Some(u.to_owned()), hp)
127+
}
128+
None => (None, entry),
129+
};
130+
131+
// Split off the optional `:port` suffix. IPv6 literals would need
132+
// `[v6]:port` handling; OpenSSH supports that but PRD §5.8.2
133+
// doesn't call it out as a hard requirement. Keep it simple for
134+
// M13 and document as a follow-up if a user complains.
135+
let (host, port) = match host_port.rsplit_once(':') {
136+
Some((h, p)) if !h.contains(':') => {
137+
// `host:port` — h is the host, p is the port.
138+
let port: u16 = p.parse().map_err(|e| {
139+
AnvilError::invalid_config(format!(
140+
"ProxyJump: invalid port `{p}` in `{entry}`: {e}",
141+
))
142+
})?;
143+
(h.to_owned(), port)
144+
}
145+
_ => (host_port.to_owned(), 22),
146+
};
147+
148+
if host.is_empty() {
149+
return Err(AnvilError::invalid_config(format!(
150+
"ProxyJump: empty host in `{entry}`",
151+
)));
152+
}
153+
154+
Ok(JumpHost {
155+
host,
156+
port,
157+
user,
158+
identity_files: Vec::new(),
159+
})
160+
}
161+
162+
#[cfg(test)]
163+
mod tests {
164+
use super::*;
165+
166+
#[test]
167+
fn single_hop_bare_host() {
168+
let chain = parse_jump_chain("bastion.example.com").expect("parse");
169+
assert_eq!(chain.len(), 1);
170+
assert_eq!(chain[0].host, "bastion.example.com");
171+
assert_eq!(chain[0].port, 22);
172+
assert_eq!(chain[0].user, None);
173+
assert!(chain[0].identity_files.is_empty());
174+
}
175+
176+
#[test]
177+
fn single_hop_user_host_port() {
178+
let chain = parse_jump_chain("alice@bastion.example.com:2222").expect("parse");
179+
assert_eq!(chain.len(), 1);
180+
assert_eq!(chain[0].host, "bastion.example.com");
181+
assert_eq!(chain[0].port, 2222);
182+
assert_eq!(chain[0].user.as_deref(), Some("alice"));
183+
}
184+
185+
#[test]
186+
fn two_hops_comma_separated() {
187+
let chain = parse_jump_chain("b1.example.com,alice@b2.example.com:2222").expect("parse");
188+
assert_eq!(chain.len(), 2);
189+
assert_eq!(chain[0].host, "b1.example.com");
190+
assert_eq!(chain[0].port, 22);
191+
assert_eq!(chain[1].host, "b2.example.com");
192+
assert_eq!(chain[1].port, 2222);
193+
assert_eq!(chain[1].user.as_deref(), Some("alice"));
194+
}
195+
196+
#[test]
197+
fn whitespace_around_commas_tolerated() {
198+
let chain = parse_jump_chain("b1 , b2:2222 , c@b3").expect("parse");
199+
assert_eq!(chain.len(), 3);
200+
assert_eq!(chain[0].host, "b1");
201+
assert_eq!(chain[1].host, "b2");
202+
assert_eq!(chain[1].port, 2222);
203+
assert_eq!(chain[2].host, "b3");
204+
assert_eq!(chain[2].user.as_deref(), Some("c"));
205+
}
206+
207+
#[test]
208+
fn empty_string_rejected() {
209+
let err = parse_jump_chain("").expect_err("empty");
210+
assert!(format!("{err}").contains("empty"));
211+
}
212+
213+
#[test]
214+
fn whitespace_only_rejected() {
215+
let err = parse_jump_chain(" ").expect_err("whitespace only");
216+
assert!(format!("{err}").contains("empty"));
217+
}
218+
219+
#[test]
220+
fn trailing_comma_rejected() {
221+
let err = parse_jump_chain("b1,").expect_err("trailing comma");
222+
assert!(format!("{err}").contains("empty entry"));
223+
}
224+
225+
#[test]
226+
fn double_comma_rejected() {
227+
let err = parse_jump_chain("b1,,b2").expect_err("double comma");
228+
assert!(format!("{err}").contains("empty entry"));
229+
}
230+
231+
#[test]
232+
fn empty_user_at_host_rejected() {
233+
let err = parse_jump_chain("@bastion").expect_err("empty user");
234+
assert!(format!("{err}").contains("empty user"));
235+
}
236+
237+
#[test]
238+
fn empty_host_rejected() {
239+
let err = parse_jump_chain("alice@").expect_err("empty host");
240+
assert!(format!("{err}").contains("empty host"));
241+
}
242+
243+
#[test]
244+
fn invalid_port_rejected() {
245+
let err = parse_jump_chain("bastion:not_a_number").expect_err("bad port");
246+
let msg = format!("{err}");
247+
assert!(msg.contains("invalid port"), "got: {msg}");
248+
}
249+
250+
#[test]
251+
fn port_out_of_range_rejected() {
252+
let err = parse_jump_chain("bastion:99999").expect_err("port > u16::MAX");
253+
assert!(format!("{err}").contains("invalid port"));
254+
}
255+
256+
#[test]
257+
fn none_literal_rejected_with_clear_message() {
258+
for raw in ["none", "NONE", "None"] {
259+
let err = parse_jump_chain(raw).expect_err("none sentinel");
260+
assert!(
261+
format!("{err}").contains("disable sentinel"),
262+
"case `{raw}`: expected disable-sentinel error",
263+
);
264+
}
265+
}
266+
267+
#[test]
268+
fn chain_at_max_hops_accepted() {
269+
let raw = (1..=MAX_JUMP_HOPS)
270+
.map(|i| format!("b{i}"))
271+
.collect::<Vec<_>>()
272+
.join(",");
273+
let chain = parse_jump_chain(&raw).expect("parse 8 hops");
274+
assert_eq!(chain.len(), MAX_JUMP_HOPS);
275+
}
276+
277+
#[test]
278+
fn chain_over_max_hops_rejected() {
279+
let raw = (1..=(MAX_JUMP_HOPS + 1))
280+
.map(|i| format!("b{i}"))
281+
.collect::<Vec<_>>()
282+
.join(",");
283+
let err = parse_jump_chain(&raw).expect_err("9 hops");
284+
let msg = format!("{err}");
285+
assert!(
286+
msg.contains("exceeds") && msg.contains(&format!("{MAX_JUMP_HOPS}-hop")),
287+
"got: {msg}",
288+
);
289+
}
290+
291+
#[test]
292+
fn jump_host_struct_round_trip() {
293+
let h = JumpHost {
294+
host: "bastion".to_owned(),
295+
port: 22,
296+
user: Some("git".to_owned()),
297+
identity_files: vec![PathBuf::from("/home/u/.ssh/id_ed25519")],
298+
};
299+
// Sanity: PartialEq + Clone work for the chain manager's needs.
300+
assert_eq!(h.clone(), h);
301+
}
302+
}

src/proxy/mod.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,9 @@
2222
//! M13.3 and M13.4.
2323
2424
pub(crate) mod command;
25+
pub mod jump;
2526
pub(crate) mod stdio;
2627
pub mod tokens;
2728

29+
pub use jump::{parse_jump_chain, JumpHost, MAX_JUMP_HOPS};
2830
pub use tokens::expand_proxy_tokens;

0 commit comments

Comments
 (0)