Skip to main content

cossh/ssh_config/
parser.rs

1//! SSH config file parser and include tree builder.
2
3use 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)]
27/// Result payload used by inventory migration to preserve folder structure.
28pub struct MigrationParseResult {
29    /// Parsed include-tree root.
30    pub root: TreeFolder,
31    /// Flattened host list discovered across includes.
32    pub hosts: Vec<SshHost>,
33    /// Number of unsupported `Match` blocks skipped.
34    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
51/// Parse an SSH config file and return a list of visible hosts.
52pub fn parse_ssh_config(config_path: &Path) -> io::Result<Vec<SshHost>> {
53    Ok(build_ssh_host_tree(config_path)?.hosts)
54}
55
56/// Parse an SSH config file for YAML inventory migration.
57pub 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        // Avoid recursive include loops and duplicate host ingestion.
129        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        // Runtime host browsing hides wildcard aliases and explicitly hidden hosts.
170        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;