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 config_dir = path.parent().map(|p| p.to_path_buf());
27 let elements = Self::parse_content_with_includes(&content, config_dir.as_deref(), depth);
28
29 Ok(SshConfigFile {
30 elements,
31 path: path.to_path_buf(),
32 })
33 }
34
35 #[allow(dead_code)]
38 pub fn parse_content(content: &str) -> Vec<ConfigElement> {
39 Self::parse_content_with_includes(content, None, MAX_INCLUDE_DEPTH)
40 }
41
42 fn parse_content_with_includes(
44 content: &str,
45 config_dir: Option<&Path>,
46 depth: usize,
47 ) -> Vec<ConfigElement> {
48 let mut elements = Vec::new();
49 let mut current_block: Option<HostBlock> = None;
50
51 for line in content.lines() {
52 let trimmed = line.trim();
53
54 if let Some(pattern) = Self::parse_include_line(trimmed) {
56 if let Some(block) = current_block.take() {
57 elements.push(ConfigElement::HostBlock(block));
58 }
59 let resolved = if depth < MAX_INCLUDE_DEPTH {
60 Self::resolve_include(pattern, config_dir, depth)
61 } else {
62 Vec::new()
63 };
64 elements.push(ConfigElement::Include(IncludeDirective {
65 raw_line: line.to_string(),
66 pattern: pattern.to_string(),
67 resolved_files: resolved,
68 }));
69 continue;
70 }
71
72 if let Some(pattern) = Self::parse_host_line(trimmed) {
74 if let Some(block) = current_block.take() {
76 elements.push(ConfigElement::HostBlock(block));
77 }
78 current_block = Some(HostBlock {
79 host_pattern: pattern,
80 raw_host_line: line.to_string(),
81 directives: Vec::new(),
82 });
83 continue;
84 }
85
86 if let Some(ref mut block) = current_block {
88 if trimmed.is_empty() || trimmed.starts_with('#') {
89 block.directives.push(Directive {
91 key: String::new(),
92 value: String::new(),
93 raw_line: line.to_string(),
94 is_non_directive: true,
95 });
96 } else if let Some((key, value)) = Self::parse_directive(trimmed) {
97 block.directives.push(Directive {
98 key,
99 value,
100 raw_line: line.to_string(),
101 is_non_directive: false,
102 });
103 } else {
104 block.directives.push(Directive {
106 key: String::new(),
107 value: String::new(),
108 raw_line: line.to_string(),
109 is_non_directive: true,
110 });
111 }
112 } else {
113 elements.push(ConfigElement::GlobalLine(line.to_string()));
115 }
116 }
117
118 if let Some(block) = current_block {
120 elements.push(ConfigElement::HostBlock(block));
121 }
122
123 elements
124 }
125
126 fn parse_include_line(trimmed: &str) -> Option<&str> {
128 if trimmed.len() > 8 && trimmed[..8].eq_ignore_ascii_case("include ") {
130 let pattern = trimmed[8..].trim();
131 if !pattern.is_empty() {
132 return Some(pattern);
133 }
134 }
135 None
136 }
137
138 fn resolve_include(
140 pattern: &str,
141 config_dir: Option<&Path>,
142 depth: usize,
143 ) -> Vec<IncludedFile> {
144 let expanded = Self::expand_tilde(pattern);
145
146 let glob_pattern = if expanded.starts_with('/') {
148 expanded
149 } else if let Some(dir) = config_dir {
150 dir.join(&expanded).to_string_lossy().to_string()
151 } else {
152 return Vec::new();
153 };
154
155 let mut files = Vec::new();
156 if let Ok(paths) = glob::glob(&glob_pattern) {
157 let mut matched: Vec<PathBuf> = paths.filter_map(|p| p.ok()).collect();
158 matched.sort();
159 for path in matched {
160 if path.is_file() {
161 if let Ok(content) = std::fs::read_to_string(&path) {
162 let elements = Self::parse_content_with_includes(
163 &content,
164 path.parent(),
165 depth + 1,
166 );
167 files.push(IncludedFile {
168 path: path.clone(),
169 elements,
170 });
171 }
172 }
173 }
174 }
175 files
176 }
177
178 fn expand_tilde(pattern: &str) -> String {
180 if let Some(rest) = pattern.strip_prefix("~/") {
181 if let Some(home) = dirs::home_dir() {
182 return format!("{}/{}", home.display(), rest);
183 }
184 }
185 pattern.to_string()
186 }
187
188 fn parse_host_line(trimmed: &str) -> Option<String> {
191 let lower = trimmed.to_lowercase();
192 if lower.starts_with("host ") && !lower.starts_with("hostname") {
193 let pattern = trimmed[5..].trim().to_string();
194 if !pattern.is_empty() {
195 return Some(pattern);
196 }
197 }
198 None
199 }
200
201 fn parse_directive(trimmed: &str) -> Option<(String, String)> {
203 let (key, value) = if let Some(eq_pos) = trimmed.find('=') {
205 let key = trimmed[..eq_pos].trim();
206 let value = trimmed[eq_pos + 1..].trim();
207 (key, value)
208 } else {
209 let mut parts = trimmed.splitn(2, char::is_whitespace);
210 let key = parts.next()?;
211 let value = parts.next().unwrap_or("").trim();
212 (key, value)
213 };
214
215 if key.is_empty() {
216 return None;
217 }
218
219 Some((key.to_string(), value.to_string()))
220 }
221}
222
223#[cfg(test)]
224mod tests {
225 use super::*;
226 use std::path::PathBuf;
227
228 fn parse_str(content: &str) -> SshConfigFile {
229 SshConfigFile {
230 elements: SshConfigFile::parse_content(content),
231 path: PathBuf::from("/tmp/test_config"),
232 }
233 }
234
235 #[test]
236 fn test_empty_config() {
237 let config = parse_str("");
238 assert!(config.host_entries().is_empty());
239 }
240
241 #[test]
242 fn test_basic_host() {
243 let config = parse_str(
244 "Host myserver\n HostName 192.168.1.10\n User admin\n Port 2222\n",
245 );
246 let entries = config.host_entries();
247 assert_eq!(entries.len(), 1);
248 assert_eq!(entries[0].alias, "myserver");
249 assert_eq!(entries[0].hostname, "192.168.1.10");
250 assert_eq!(entries[0].user, "admin");
251 assert_eq!(entries[0].port, 2222);
252 }
253
254 #[test]
255 fn test_multiple_hosts() {
256 let content = "\
257Host alpha
258 HostName alpha.example.com
259 User deploy
260
261Host beta
262 HostName beta.example.com
263 User root
264 Port 22022
265";
266 let config = parse_str(content);
267 let entries = config.host_entries();
268 assert_eq!(entries.len(), 2);
269 assert_eq!(entries[0].alias, "alpha");
270 assert_eq!(entries[1].alias, "beta");
271 assert_eq!(entries[1].port, 22022);
272 }
273
274 #[test]
275 fn test_wildcard_host_filtered() {
276 let content = "\
277Host *
278 ServerAliveInterval 60
279
280Host myserver
281 HostName 10.0.0.1
282";
283 let config = parse_str(content);
284 let entries = config.host_entries();
285 assert_eq!(entries.len(), 1);
286 assert_eq!(entries[0].alias, "myserver");
287 }
288
289 #[test]
290 fn test_comments_preserved() {
291 let content = "\
292# Global comment
293Host myserver
294 # This is a comment
295 HostName 10.0.0.1
296 User admin
297";
298 let config = parse_str(content);
299 assert!(matches!(&config.elements[0], ConfigElement::GlobalLine(s) if s == "# Global comment"));
301 if let ConfigElement::HostBlock(block) = &config.elements[1] {
303 assert!(block.directives[0].is_non_directive);
304 assert_eq!(block.directives[0].raw_line, " # This is a comment");
305 } else {
306 panic!("Expected HostBlock");
307 }
308 }
309
310 #[test]
311 fn test_identity_file_and_proxy_jump() {
312 let content = "\
313Host bastion
314 HostName bastion.example.com
315 User admin
316 IdentityFile ~/.ssh/id_ed25519
317 ProxyJump gateway
318";
319 let config = parse_str(content);
320 let entries = config.host_entries();
321 assert_eq!(entries[0].identity_file, "~/.ssh/id_ed25519");
322 assert_eq!(entries[0].proxy_jump, "gateway");
323 }
324
325 #[test]
326 fn test_unknown_directives_preserved() {
327 let content = "\
328Host myserver
329 HostName 10.0.0.1
330 ForwardAgent yes
331 LocalForward 8080 localhost:80
332";
333 let config = parse_str(content);
334 if let ConfigElement::HostBlock(block) = &config.elements[0] {
335 assert_eq!(block.directives.len(), 3);
336 assert_eq!(block.directives[1].key, "ForwardAgent");
337 assert_eq!(block.directives[1].value, "yes");
338 assert_eq!(block.directives[2].key, "LocalForward");
339 } else {
340 panic!("Expected HostBlock");
341 }
342 }
343
344 #[test]
345 fn test_include_directive_parsed() {
346 let content = "\
347Include config.d/*
348
349Host myserver
350 HostName 10.0.0.1
351";
352 let config = parse_str(content);
353 assert!(matches!(&config.elements[0], ConfigElement::Include(inc) if inc.raw_line == "Include config.d/*"));
355 assert!(matches!(&config.elements[1], ConfigElement::GlobalLine(s) if s.is_empty()));
357 assert!(matches!(&config.elements[2], ConfigElement::HostBlock(_)));
358 }
359
360 #[test]
361 fn test_include_round_trip() {
362 let content = "\
363Include ~/.ssh/config.d/*
364
365Host myserver
366 HostName 10.0.0.1
367";
368 let config = parse_str(content);
369 assert_eq!(config.serialize(), content);
370 }
371
372 #[test]
373 fn test_ssh_command() {
374 use crate::ssh_config::model::HostEntry;
375 let entry = HostEntry {
376 alias: "myserver".to_string(),
377 hostname: "10.0.0.1".to_string(),
378 ..Default::default()
379 };
380 assert_eq!(entry.ssh_command(), "ssh myserver");
381 }
382}