1use super::include::{expand_include_pattern, resolve_include_pattern};
4use super::model::{SshHost, SshHostTreeModel};
5use super::path::expand_tilde;
6use crate::inventory::{ConnectionProtocol, FolderId, TreeFolder, sort_tree_folder_by_host_name};
7use crate::log_debug;
8use crate::validation::validate_vault_entry_name;
9use std::collections::HashSet;
10use std::fs::File;
11use std::io::{self, BufRead, BufReader};
12use std::path::{Path, PathBuf};
13
14#[derive(Debug, Default)]
15struct ParsedConfigFile {
16 hosts: Vec<SshHost>,
17 include_patterns: Vec<String>,
18 unsupported_blocks: usize,
19}
20
21#[derive(Debug, Clone, Copy)]
22struct ParseOptions {
23 filter_runtime_only_hosts: bool,
24}
25
26#[derive(Debug, Clone)]
27pub struct MigrationParseResult {
29 pub root: TreeFolder,
31 pub hosts: Vec<SshHost>,
33 pub unsupported_blocks: usize,
35}
36
37impl ParseOptions {
38 const RUNTIME: Self = Self {
39 filter_runtime_only_hosts: true,
40 };
41
42 const MIGRATION: Self = Self {
43 filter_runtime_only_hosts: false,
44 };
45}
46
47fn parse_protocol_tag(value: &str) -> ConnectionProtocol {
48 ConnectionProtocol::from(value)
49}
50
51pub fn parse_ssh_config(config_path: &Path) -> io::Result<Vec<SshHost>> {
53 Ok(build_ssh_host_tree(config_path)?.hosts)
54}
55
56pub fn parse_ssh_config_for_migration(config_path: &Path) -> io::Result<MigrationParseResult> {
58 let mut hosts = Vec::new();
59 let mut visited = HashSet::new();
60 let mut next_id: FolderId = 0;
61 let mut unsupported_blocks = 0usize;
62 let root_name = config_path.file_name().and_then(|segment| segment.to_str()).unwrap_or("config").to_string();
63
64 let root = parse_tree_folder(
65 config_path,
66 &root_name,
67 &mut hosts,
68 &mut visited,
69 &mut next_id,
70 ParseOptions::MIGRATION,
71 &mut unsupported_blocks,
72 )?
73 .unwrap_or_else(|| TreeFolder {
74 id: 0,
75 name: root_name,
76 path: config_path.to_path_buf(),
77 children: Vec::new(),
78 host_indices: Vec::new(),
79 });
80
81 Ok(MigrationParseResult {
82 root,
83 hosts,
84 unsupported_blocks,
85 })
86}
87
88pub(super) fn build_ssh_host_tree(config_path: &Path) -> io::Result<SshHostTreeModel> {
89 let mut hosts = Vec::new();
90 let mut visited = HashSet::new();
91 let mut next_id: FolderId = 0;
92 let root_name = config_path.file_name().and_then(|segment| segment.to_str()).unwrap_or("config").to_string();
93
94 let mut unsupported_blocks = 0usize;
95 let mut root = parse_tree_folder(
96 config_path,
97 &root_name,
98 &mut hosts,
99 &mut visited,
100 &mut next_id,
101 ParseOptions::RUNTIME,
102 &mut unsupported_blocks,
103 )?
104 .unwrap_or_else(|| TreeFolder {
105 id: 0,
106 name: root_name,
107 path: config_path.to_path_buf(),
108 children: Vec::new(),
109 host_indices: Vec::new(),
110 });
111 sort_tree_folder_by_host_name(&mut root, &hosts, |host| host.name.as_str());
112
113 Ok(SshHostTreeModel { root, hosts })
114}
115
116fn parse_tree_folder(
117 config_path: &Path,
118 name: &str,
119 hosts: &mut Vec<SshHost>,
120 visited: &mut HashSet<PathBuf>,
121 next_id: &mut FolderId,
122 options: ParseOptions,
123 unsupported_blocks: &mut usize,
124) -> io::Result<Option<TreeFolder>> {
125 let canonical = config_path.canonicalize().unwrap_or_else(|_| config_path.to_path_buf());
126
127 if !visited.insert(canonical.clone()) {
128 log_debug!("Skipping already visited SSH include file (possible include cycle): {}", canonical.display());
130 return Ok(None);
131 }
132
133 let parsed = parse_config_file(&canonical, options)?;
134 *unsupported_blocks += parsed.unsupported_blocks;
135 let folder_id = *next_id;
136 *next_id += 1;
137
138 let mut host_indices = Vec::new();
139 for host in parsed.hosts {
140 host_indices.push(hosts.len());
141 hosts.push(host);
142 }
143
144 let mut children = Vec::new();
145 let parent_dir = canonical.parent().unwrap_or(Path::new("."));
146
147 for include_pattern in parsed.include_patterns {
148 let resolved_pattern = resolve_include_pattern(&include_pattern, parent_dir);
149 for include_path in expand_include_pattern(&resolved_pattern) {
150 let child_name = include_path.file_name().and_then(|segment| segment.to_str()).unwrap_or("include").to_string();
151
152 if let Some(child) = parse_tree_folder(&include_path, &child_name, hosts, visited, next_id, options, unsupported_blocks)? {
153 children.push(child);
154 }
155 }
156 }
157
158 Ok(Some(TreeFolder {
159 id: folder_id,
160 name: name.to_string(),
161 path: canonical,
162 children,
163 host_indices,
164 }))
165}
166
167fn finalize_current_hosts(parsed: &mut ParsedConfigFile, current_hosts: &mut Vec<SshHost>, options: ParseOptions) {
168 for host in current_hosts.drain(..) {
169 if options.filter_runtime_only_hosts && (host.name.contains('*') || host.name.contains('?') || host.hidden) {
171 continue;
172 }
173 parsed.hosts.push(host);
174 }
175}
176
177fn parse_bool_like(value: &str) -> Option<bool> {
178 match value.trim().to_ascii_lowercase().as_str() {
179 "1" | "true" | "yes" | "on" => Some(true),
180 "0" | "false" | "no" | "off" => Some(false),
181 _ => None,
182 }
183}
184
185fn normalize_yes_no_string(value: &str) -> String {
186 match parse_bool_like(value) {
187 Some(true) => "yes".to_string(),
188 Some(false) => "no".to_string(),
189 None => value.trim().to_string(),
190 }
191}
192
193fn push_other_option(host: &mut SshHost, key: &str, value: &str) {
194 host.other_options.entry(key.to_string()).or_default().push(value.to_string());
195}
196
197fn parse_config_file(config_path: &Path, options: ParseOptions) -> io::Result<ParsedConfigFile> {
198 let file = File::open(config_path)?;
199 let reader = BufReader::new(file);
200
201 let mut parsed = ParsedConfigFile::default();
202 let mut current_hosts: Vec<SshHost> = Vec::new();
203 let mut in_match_block = false;
204
205 for line in reader.lines() {
206 let line = line?;
207 let trimmed = line.trim();
208
209 if trimmed.is_empty() {
210 continue;
211 }
212
213 if trimmed.starts_with('#') {
214 if let Some(desc) = trimmed.strip_prefix("#_Desc") {
215 let desc = desc.trim().to_string();
216 for host in &mut current_hosts {
217 host.description = Some(desc.clone());
218 }
219 }
220 if let Some(protocol) = trimmed.strip_prefix("#_Protocol") {
221 let protocol = parse_protocol_tag(protocol.trim());
222 for host in &mut current_hosts {
223 host.protocol = protocol.clone();
224 }
225 }
226 if let Some(profile) = trimmed.strip_prefix("#_Profile") {
227 let profile = profile.trim().to_string();
228 for host in &mut current_hosts {
229 host.profile = Some(profile.clone());
230 }
231 }
232 if let Some(pass_val) = trimmed.strip_prefix("#_pass") {
233 let pass_key = pass_val.trim();
234 if validate_vault_entry_name(pass_key) {
235 for host in &mut current_hosts {
236 host.pass_key = Some(pass_key.to_string());
237 }
238 } else {
239 log_debug!("Ignoring invalid #_pass key name: {:?}", pass_key);
240 for host in &mut current_hosts {
241 host.pass_key = None;
242 }
243 }
244 }
245 if let Some(domain) = trimmed.strip_prefix("#_RdpDomain") {
246 let domain = domain.trim();
247 let domain = (!domain.is_empty()).then(|| domain.to_string());
248 for host in &mut current_hosts {
249 host.rdp_domain = domain.clone();
250 }
251 }
252 if let Some(args) = trimmed.strip_prefix("#_RdpArgs") {
253 let args: Vec<String> = args.split_whitespace().map(str::to_string).collect();
254 if !args.is_empty() {
255 for host in &mut current_hosts {
256 host.rdp_args.extend(args.iter().cloned());
257 }
258 }
259 }
260 if let Some(hidden_val) = trimmed.strip_prefix("#_hidden") {
261 let hidden = parse_bool_like(hidden_val).unwrap_or(false);
262 for host in &mut current_hosts {
263 host.hidden = hidden;
264 }
265 }
266 continue;
267 }
268
269 let parts: Vec<&str> = trimmed.splitn(2, char::is_whitespace).collect();
270 if parts.len() < 2 {
271 continue;
272 }
273
274 let keyword = parts[0].to_ascii_lowercase();
275 let value = parts[1].trim();
276
277 if in_match_block && keyword != "host" && keyword != "match" {
278 continue;
279 }
280
281 match keyword.as_str() {
282 "host" => {
283 in_match_block = false;
284 finalize_current_hosts(&mut parsed, &mut current_hosts, options);
285
286 current_hosts = value.split_whitespace().map(|alias| SshHost::new(alias.to_string())).collect();
287 if current_hosts.is_empty() {
288 current_hosts.push(SshHost::new(value.to_string()));
289 }
290 }
291 "hostname" => {
292 for host in &mut current_hosts {
293 host.hostname = Some(value.to_string());
294 }
295 }
296 "user" => {
297 for host in &mut current_hosts {
298 host.user = Some(value.to_string());
299 }
300 }
301 "port" => {
302 if let Ok(port) = value.parse::<u16>() {
303 for host in &mut current_hosts {
304 host.port = Some(port);
305 }
306 }
307 }
308 "identityfile" => {
309 let identity = expand_tilde(value);
310 for host in &mut current_hosts {
311 host.identity_files.push(identity.clone());
312 }
313 }
314 "identitiesonly" => {
315 let parsed_bool = parse_bool_like(value);
316 for host in &mut current_hosts {
317 host.identities_only = parsed_bool;
318 }
319 }
320 "proxyjump" => {
321 for host in &mut current_hosts {
322 host.proxy_jump = Some(value.to_string());
323 }
324 }
325 "proxycommand" => {
326 for host in &mut current_hosts {
327 host.proxy_command = Some(value.to_string());
328 }
329 }
330 "forwardagent" => {
331 for host in &mut current_hosts {
332 host.forward_agent = Some(normalize_yes_no_string(value));
333 }
334 }
335 "localforward" => {
336 for host in &mut current_hosts {
337 host.local_forward.push(value.to_string());
338 }
339 }
340 "remoteforward" => {
341 for host in &mut current_hosts {
342 host.remote_forward.push(value.to_string());
343 }
344 }
345 "include" => {
346 for token in value.split_whitespace() {
347 parsed.include_patterns.push(token.to_string());
348 }
349 }
350 "match" => {
351 finalize_current_hosts(&mut parsed, &mut current_hosts, options);
352 parsed.unsupported_blocks += 1;
353 in_match_block = true;
354 }
355 _ => {
356 for host in &mut current_hosts {
357 push_other_option(host, &keyword, value);
358 }
359 }
360 }
361 }
362
363 finalize_current_hosts(&mut parsed, &mut current_hosts, options);
364 Ok(parsed)
365}
366
367#[cfg(test)]
368#[path = "../test/ssh_config/parser.rs"]
369mod tests;