Skip to main content

sshconfig_lint/rules/
basic.rs

1use std::collections::HashMap;
2use std::path::Path;
3
4use crate::model::{Config, Finding, Item, Severity, Span};
5use crate::rules::Rule;
6
7/// Warns when multiple Host blocks have the same pattern.
8pub struct DuplicateHost;
9
10impl Rule for DuplicateHost {
11    fn name(&self) -> &'static str {
12        "duplicate-host"
13    }
14
15    fn check(&self, config: &Config) -> Vec<Finding> {
16        let mut seen: HashMap<String, Span> = HashMap::new();
17        let mut findings = Vec::new();
18
19        for item in &config.items {
20            if let Item::HostBlock { patterns, span, .. } = item {
21                for pattern in patterns {
22                    if let Some(first_span) = seen.get(pattern) {
23                        findings.push(
24                            Finding::new(
25                                Severity::Warning,
26                                "duplicate-host",
27                                "DUP_HOST",
28                                format!(
29                                    "duplicate Host block '{}' (first seen at line {})",
30                                    pattern, first_span.line
31                                ),
32                                span.clone(),
33                            )
34                            .with_hint("remove one of the duplicate Host blocks"),
35                        );
36                    } else {
37                        seen.insert(pattern.clone(), span.clone());
38                    }
39                }
40            }
41        }
42
43        findings
44    }
45}
46
47/// Errors when an IdentityFile points to a file that doesn't exist.
48/// Skips paths containing `%` or `${` (template variables).
49pub struct IdentityFileExists;
50
51impl Rule for IdentityFileExists {
52    fn name(&self) -> &'static str {
53        "identity-file-exists"
54    }
55
56    fn check(&self, config: &Config) -> Vec<Finding> {
57        let mut findings = Vec::new();
58        collect_identity_findings(&config.items, &mut findings);
59        findings
60    }
61}
62
63fn collect_identity_findings(items: &[Item], findings: &mut Vec<Finding>) {
64    for item in items {
65        match item {
66            Item::Directive {
67                key, value, span, ..
68            } if key.eq_ignore_ascii_case("IdentityFile") => {
69                check_identity_file(value, span, findings);
70            }
71            Item::HostBlock { items, .. } | Item::MatchBlock { items, .. } => {
72                collect_identity_findings(items, findings);
73            }
74            _ => {}
75        }
76    }
77}
78
79fn check_identity_file(value: &str, span: &Span, findings: &mut Vec<Finding>) {
80    // Skip template variables
81    if value.contains('%') || value.contains("${") {
82        return;
83    }
84
85    let expanded = if let Some(rest) = value.strip_prefix("~/") {
86        if let Some(home) = dirs::home_dir() {
87            home.join(rest)
88        } else {
89            return; // Can't resolve ~ without home dir
90        }
91    } else {
92        Path::new(value).to_path_buf()
93    };
94
95    if !expanded.exists() {
96        findings.push(
97            Finding::new(
98                Severity::Error,
99                "identity-file-exists",
100                "MISSING_IDENTITY",
101                format!("IdentityFile not found: {}", value),
102                span.clone(),
103            )
104            .with_hint("check the path or remove the directive"),
105        );
106    }
107}
108
109/// Warns when `Host *` appears before more specific Host blocks.
110/// In OpenSSH, first match wins, so `Host *` should usually come last.
111pub struct WildcardHostOrder;
112
113impl Rule for WildcardHostOrder {
114    fn name(&self) -> &'static str {
115        "wildcard-host-order"
116    }
117
118    fn check(&self, config: &Config) -> Vec<Finding> {
119        let mut findings = Vec::new();
120        let mut wildcard_span: Option<Span> = None;
121
122        for item in &config.items {
123            if let Item::HostBlock { patterns, span, .. } = item {
124                for pattern in patterns {
125                    if pattern == "*" {
126                        if wildcard_span.is_none() {
127                            wildcard_span = Some(span.clone());
128                        }
129                    } else if let Some(ref ws) = wildcard_span {
130                        findings.push(Finding::new(
131                            Severity::Warning,
132                            "wildcard-host-order",
133                            "WILDCARD_ORDER",
134                            format!(
135                                "Host '{}' appears after 'Host *' (line {}); it will never match because Host * already matched",
136                                pattern, ws.line
137                            ),
138                            span.clone(),
139                        ).with_hint("move Host * to the end of the file"));
140                    }
141                }
142            }
143        }
144
145        findings
146    }
147}
148
149#[cfg(test)]
150mod tests {
151    use super::*;
152    use crate::model::{Config, Item, Span};
153    use std::fs;
154    use tempfile::TempDir;
155
156    #[test]
157    fn no_duplicates_no_findings() {
158        let config = Config {
159            items: vec![
160                Item::HostBlock {
161                    patterns: vec!["a".to_string()],
162                    span: Span::new(1),
163                    items: vec![],
164                },
165                Item::HostBlock {
166                    patterns: vec!["b".to_string()],
167                    span: Span::new(3),
168                    items: vec![],
169                },
170            ],
171        };
172        let findings = DuplicateHost.check(&config);
173        assert!(findings.is_empty());
174    }
175
176    #[test]
177    fn duplicate_host_warns() {
178        let config = Config {
179            items: vec![
180                Item::HostBlock {
181                    patterns: vec!["github.com".to_string()],
182                    span: Span::new(1),
183                    items: vec![],
184                },
185                Item::HostBlock {
186                    patterns: vec!["github.com".to_string()],
187                    span: Span::new(5),
188                    items: vec![],
189                },
190            ],
191        };
192        let findings = DuplicateHost.check(&config);
193        assert_eq!(findings.len(), 1);
194        assert_eq!(findings[0].rule, "duplicate-host");
195        assert!(findings[0].message.contains("first seen at line 1"));
196    }
197
198    #[test]
199    fn identity_file_exists_no_error() {
200        let tmp = TempDir::new().unwrap();
201        let key_path = tmp.path().join("id_test");
202        fs::write(&key_path, "fake key").unwrap();
203
204        let config = Config {
205            items: vec![Item::HostBlock {
206                patterns: vec!["a".to_string()],
207                span: Span::new(1),
208                items: vec![Item::Directive {
209                    key: "IdentityFile".into(),
210                    value: key_path.to_string_lossy().into_owned(),
211                    span: Span::new(2),
212                }],
213            }],
214        };
215        let findings = IdentityFileExists.check(&config);
216        assert!(findings.is_empty());
217    }
218
219    #[test]
220    fn identity_file_missing_errors() {
221        let config = Config {
222            items: vec![Item::Directive {
223                key: "IdentityFile".into(),
224                value: "/nonexistent/path/id_nope".into(),
225                span: Span::new(1),
226            }],
227        };
228        let findings = IdentityFileExists.check(&config);
229        assert_eq!(findings.len(), 1);
230        assert_eq!(findings[0].rule, "identity-file-exists");
231    }
232
233    #[test]
234    fn identity_file_skips_templates() {
235        let config = Config {
236            items: vec![
237                Item::Directive {
238                    key: "IdentityFile".into(),
239                    value: "~/.ssh/id_%h".into(),
240                    span: Span::new(1),
241                },
242                Item::Directive {
243                    key: "IdentityFile".into(),
244                    value: "${HOME}/.ssh/id_ed25519".into(),
245                    span: Span::new(2),
246                },
247            ],
248        };
249        let findings = IdentityFileExists.check(&config);
250        assert!(findings.is_empty());
251    }
252
253    #[test]
254    fn wildcard_after_specific_no_warning() {
255        let config = Config {
256            items: vec![
257                Item::HostBlock {
258                    patterns: vec!["github.com".to_string()],
259                    span: Span::new(1),
260                    items: vec![],
261                },
262                Item::HostBlock {
263                    patterns: vec!["*".to_string()],
264                    span: Span::new(5),
265                    items: vec![],
266                },
267            ],
268        };
269        let findings = WildcardHostOrder.check(&config);
270        assert!(findings.is_empty());
271    }
272
273    #[test]
274    fn wildcard_before_specific_warns() {
275        let config = Config {
276            items: vec![
277                Item::HostBlock {
278                    patterns: vec!["*".to_string()],
279                    span: Span::new(1),
280                    items: vec![],
281                },
282                Item::HostBlock {
283                    patterns: vec!["github.com".to_string()],
284                    span: Span::new(5),
285                    items: vec![],
286                },
287            ],
288        };
289        let findings = WildcardHostOrder.check(&config);
290        assert_eq!(findings.len(), 1);
291        assert_eq!(findings[0].rule, "wildcard-host-order");
292        assert!(findings[0].message.contains("github.com"));
293    }
294}