Skip to main content

anvil_ssh/proxy/
jump.rs

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
29use std::path::PathBuf;
30
31use 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.
35pub 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)]
47pub 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).
79pub 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.
117fn 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)]
163mod 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}