1use std::path::PathBuf;
30
31use crate::error::AnvilError;
32
33pub const MAX_JUMP_HOPS: usize = 8;
36
37#[derive(Debug, Clone, PartialEq, Eq)]
47pub struct JumpHost {
48 pub host: String,
50 pub port: u16,
52 pub user: Option<String>,
57 pub identity_files: Vec<PathBuf>,
61}
62
63pub 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 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
116fn parse_one(entry: &str) -> Result<JumpHost, AnvilError> {
118 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 let (host, port) = match host_port.rsplit_once(':') {
136 Some((h, p)) if !h.contains(':') => {
137 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 assert_eq!(h.clone(), h);
301 }
302}