1use std::path::{Path, PathBuf};
2
3use anyhow::{Context, Result};
4
5use super::model::{
6 ConfigElement, Directive, HostBlock, IncludeDirective, IncludedFile, SshConfigFile,
7};
8
9const MAX_INCLUDE_DEPTH: usize = 5;
10
11impl SshConfigFile {
12 pub fn parse(path: &Path) -> Result<Self> {
15 Self::parse_with_depth(path, 0)
16 }
17
18 fn parse_with_depth(path: &Path, depth: usize) -> Result<Self> {
19 let content = if path.exists() {
20 std::fs::read_to_string(path)
21 .with_context(|| format!("Failed to read SSH config at {}", path.display()))?
22 } else {
23 String::new()
24 };
25
26 let crlf = content.contains("\r\n");
27 let config_dir = path.parent().map(|p| p.to_path_buf());
28 let elements = Self::parse_content_with_includes(&content, config_dir.as_deref(), depth);
29
30 Ok(SshConfigFile {
31 elements,
32 path: path.to_path_buf(),
33 crlf,
34 })
35 }
36
37 #[allow(dead_code)]
40 pub fn parse_content(content: &str) -> Vec<ConfigElement> {
41 Self::parse_content_with_includes(content, None, MAX_INCLUDE_DEPTH)
42 }
43
44 fn parse_content_with_includes(
46 content: &str,
47 config_dir: Option<&Path>,
48 depth: usize,
49 ) -> Vec<ConfigElement> {
50 let mut elements = Vec::new();
51 let mut current_block: Option<HostBlock> = None;
52
53 for line in content.lines() {
54 let trimmed = line.trim();
55
56 let is_indented = line.starts_with(' ') || line.starts_with('\t');
60 if !(current_block.is_some() && is_indented) {
61 if let Some(pattern) = Self::parse_include_line(trimmed) {
62 if let Some(block) = current_block.take() {
63 elements.push(ConfigElement::HostBlock(block));
64 }
65 let resolved = if depth < MAX_INCLUDE_DEPTH {
66 Self::resolve_include(pattern, config_dir, depth)
67 } else {
68 Vec::new()
69 };
70 elements.push(ConfigElement::Include(IncludeDirective {
71 raw_line: line.to_string(),
72 pattern: pattern.to_string(),
73 resolved_files: resolved,
74 }));
75 continue;
76 }
77 }
78
79 if let Some(pattern) = Self::parse_host_line(trimmed) {
81 if let Some(block) = current_block.take() {
83 elements.push(ConfigElement::HostBlock(block));
84 }
85 current_block = Some(HostBlock {
86 host_pattern: pattern,
87 raw_host_line: line.to_string(),
88 directives: Vec::new(),
89 });
90 continue;
91 }
92
93 if let Some(ref mut block) = current_block {
95 if trimmed.is_empty() || trimmed.starts_with('#') {
96 block.directives.push(Directive {
98 key: String::new(),
99 value: String::new(),
100 raw_line: line.to_string(),
101 is_non_directive: true,
102 });
103 } else if let Some((key, value)) = Self::parse_directive(trimmed) {
104 block.directives.push(Directive {
105 key,
106 value,
107 raw_line: line.to_string(),
108 is_non_directive: false,
109 });
110 } else {
111 block.directives.push(Directive {
113 key: String::new(),
114 value: String::new(),
115 raw_line: line.to_string(),
116 is_non_directive: true,
117 });
118 }
119 } else {
120 elements.push(ConfigElement::GlobalLine(line.to_string()));
122 }
123 }
124
125 if let Some(block) = current_block {
127 elements.push(ConfigElement::HostBlock(block));
128 }
129
130 elements
131 }
132
133 fn parse_include_line(trimmed: &str) -> Option<&str> {
136 let bytes = trimmed.as_bytes();
137 if bytes.len() > 8
139 && bytes[..7].eq_ignore_ascii_case(b"include")
140 && bytes[7].is_ascii_whitespace()
141 {
142 let pattern = trimmed[8..].trim();
144 if !pattern.is_empty() {
145 return Some(pattern);
146 }
147 }
148 None
149 }
150
151 fn resolve_include(
153 pattern: &str,
154 config_dir: Option<&Path>,
155 depth: usize,
156 ) -> Vec<IncludedFile> {
157 let expanded = Self::expand_tilde(pattern);
158
159 let glob_pattern = if expanded.starts_with('/') {
161 expanded
162 } else if let Some(dir) = config_dir {
163 dir.join(&expanded).to_string_lossy().to_string()
164 } else {
165 return Vec::new();
166 };
167
168 let mut files = Vec::new();
169 if let Ok(paths) = glob::glob(&glob_pattern) {
170 let mut matched: Vec<PathBuf> = paths.filter_map(|p| p.ok()).collect();
171 matched.sort();
172 for path in matched {
173 if path.is_file() {
174 if let Ok(content) = std::fs::read_to_string(&path) {
175 let elements = Self::parse_content_with_includes(
176 &content,
177 path.parent(),
178 depth + 1,
179 );
180 files.push(IncludedFile {
181 path: path.clone(),
182 elements,
183 });
184 }
185 }
186 }
187 }
188 files
189 }
190
191 pub(crate) fn expand_tilde(pattern: &str) -> String {
193 if let Some(rest) = pattern.strip_prefix("~/") {
194 if let Some(home) = dirs::home_dir() {
195 return format!("{}/{}", home.display(), rest);
196 }
197 }
198 pattern.to_string()
199 }
200
201 fn parse_host_line(trimmed: &str) -> Option<String> {
205 let mut parts = trimmed.splitn(2, [' ', '\t']);
207 let keyword = parts.next()?;
208 if !keyword.eq_ignore_ascii_case("host") {
209 return None;
210 }
211 let pattern = parts.next()?.trim().to_string();
213 if !pattern.is_empty() {
214 return Some(pattern);
215 }
216 None
217 }
218
219 fn parse_directive(trimmed: &str) -> Option<(String, String)> {
221 let (key, value) = if let Some(eq_pos) = trimmed.find('=') {
223 let key = trimmed[..eq_pos].trim();
224 let value = trimmed[eq_pos + 1..].trim();
225 (key, value)
226 } else {
227 let mut parts = trimmed.splitn(2, char::is_whitespace);
228 let key = parts.next()?;
229 let value = parts.next().unwrap_or("").trim();
230 (key, value)
231 };
232
233 if key.is_empty() {
234 return None;
235 }
236
237 let value = if let Some(pos) = value.find(" #").or_else(|| value.find("\t#")) {
240 value[..pos].trim_end()
241 } else {
242 value
243 };
244
245 Some((key.to_string(), value.to_string()))
246 }
247}
248
249#[cfg(test)]
250mod tests {
251 use super::*;
252 use std::path::PathBuf;
253
254 fn parse_str(content: &str) -> SshConfigFile {
255 SshConfigFile {
256 elements: SshConfigFile::parse_content(content),
257 path: PathBuf::from("/tmp/test_config"),
258 crlf: content.contains("\r\n"),
259 }
260 }
261
262 #[test]
263 fn test_empty_config() {
264 let config = parse_str("");
265 assert!(config.host_entries().is_empty());
266 }
267
268 #[test]
269 fn test_basic_host() {
270 let config = parse_str(
271 "Host myserver\n HostName 192.168.1.10\n User admin\n Port 2222\n",
272 );
273 let entries = config.host_entries();
274 assert_eq!(entries.len(), 1);
275 assert_eq!(entries[0].alias, "myserver");
276 assert_eq!(entries[0].hostname, "192.168.1.10");
277 assert_eq!(entries[0].user, "admin");
278 assert_eq!(entries[0].port, 2222);
279 }
280
281 #[test]
282 fn test_multiple_hosts() {
283 let content = "\
284Host alpha
285 HostName alpha.example.com
286 User deploy
287
288Host beta
289 HostName beta.example.com
290 User root
291 Port 22022
292";
293 let config = parse_str(content);
294 let entries = config.host_entries();
295 assert_eq!(entries.len(), 2);
296 assert_eq!(entries[0].alias, "alpha");
297 assert_eq!(entries[1].alias, "beta");
298 assert_eq!(entries[1].port, 22022);
299 }
300
301 #[test]
302 fn test_wildcard_host_filtered() {
303 let content = "\
304Host *
305 ServerAliveInterval 60
306
307Host myserver
308 HostName 10.0.0.1
309";
310 let config = parse_str(content);
311 let entries = config.host_entries();
312 assert_eq!(entries.len(), 1);
313 assert_eq!(entries[0].alias, "myserver");
314 }
315
316 #[test]
317 fn test_comments_preserved() {
318 let content = "\
319# Global comment
320Host myserver
321 # This is a comment
322 HostName 10.0.0.1
323 User admin
324";
325 let config = parse_str(content);
326 assert!(matches!(&config.elements[0], ConfigElement::GlobalLine(s) if s == "# Global comment"));
328 if let ConfigElement::HostBlock(block) = &config.elements[1] {
330 assert!(block.directives[0].is_non_directive);
331 assert_eq!(block.directives[0].raw_line, " # This is a comment");
332 } else {
333 panic!("Expected HostBlock");
334 }
335 }
336
337 #[test]
338 fn test_identity_file_and_proxy_jump() {
339 let content = "\
340Host bastion
341 HostName bastion.example.com
342 User admin
343 IdentityFile ~/.ssh/id_ed25519
344 ProxyJump gateway
345";
346 let config = parse_str(content);
347 let entries = config.host_entries();
348 assert_eq!(entries[0].identity_file, "~/.ssh/id_ed25519");
349 assert_eq!(entries[0].proxy_jump, "gateway");
350 }
351
352 #[test]
353 fn test_unknown_directives_preserved() {
354 let content = "\
355Host myserver
356 HostName 10.0.0.1
357 ForwardAgent yes
358 LocalForward 8080 localhost:80
359";
360 let config = parse_str(content);
361 if let ConfigElement::HostBlock(block) = &config.elements[0] {
362 assert_eq!(block.directives.len(), 3);
363 assert_eq!(block.directives[1].key, "ForwardAgent");
364 assert_eq!(block.directives[1].value, "yes");
365 assert_eq!(block.directives[2].key, "LocalForward");
366 } else {
367 panic!("Expected HostBlock");
368 }
369 }
370
371 #[test]
372 fn test_include_directive_parsed() {
373 let content = "\
374Include config.d/*
375
376Host myserver
377 HostName 10.0.0.1
378";
379 let config = parse_str(content);
380 assert!(matches!(&config.elements[0], ConfigElement::Include(inc) if inc.raw_line == "Include config.d/*"));
382 assert!(matches!(&config.elements[1], ConfigElement::GlobalLine(s) if s.is_empty()));
384 assert!(matches!(&config.elements[2], ConfigElement::HostBlock(_)));
385 }
386
387 #[test]
388 fn test_include_round_trip() {
389 let content = "\
390Include ~/.ssh/config.d/*
391
392Host myserver
393 HostName 10.0.0.1
394";
395 let config = parse_str(content);
396 assert_eq!(config.serialize(), content);
397 }
398
399 #[test]
400 fn test_ssh_command() {
401 use crate::ssh_config::model::HostEntry;
402 let entry = HostEntry {
403 alias: "myserver".to_string(),
404 hostname: "10.0.0.1".to_string(),
405 ..Default::default()
406 };
407 assert_eq!(entry.ssh_command(), "ssh myserver");
408 }
409
410 #[test]
411 fn test_unicode_comment_no_panic() {
412 let content = "# abcde\u{00e9} test\n\nHost myserver\n HostName 10.0.0.1\n";
415 let config = parse_str(content);
416 let entries = config.host_entries();
417 assert_eq!(entries.len(), 1);
418 assert_eq!(entries[0].alias, "myserver");
419 }
420
421 #[test]
422 fn test_unicode_multibyte_line_no_panic() {
423 let content = "# \u{3042}\u{3042}\u{3042}xyz\n\nHost myserver\n HostName 10.0.0.1\n";
425 let config = parse_str(content);
426 let entries = config.host_entries();
427 assert_eq!(entries.len(), 1);
428 }
429
430 #[test]
431 fn test_host_with_tab_separator() {
432 let content = "Host\tmyserver\n HostName 10.0.0.1\n";
433 let config = parse_str(content);
434 let entries = config.host_entries();
435 assert_eq!(entries.len(), 1);
436 assert_eq!(entries[0].alias, "myserver");
437 }
438
439 #[test]
440 fn test_include_with_tab_separator() {
441 let content = "Include\tconfig.d/*\n\nHost myserver\n HostName 10.0.0.1\n";
442 let config = parse_str(content);
443 assert!(matches!(&config.elements[0], ConfigElement::Include(inc) if inc.pattern == "config.d/*"));
444 }
445
446 #[test]
447 fn test_hostname_not_confused_with_host() {
448 let content = "Host myserver\n HostName example.com\n";
450 let config = parse_str(content);
451 let entries = config.host_entries();
452 assert_eq!(entries.len(), 1);
453 assert_eq!(entries[0].hostname, "example.com");
454 }
455}