1use std::collections::HashMap;
2use std::path::Path;
3
4use crate::model::{Config, Finding, Item, Severity, Span};
5use crate::rules::Rule;
6
7pub 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
47pub 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 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; }
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
109pub 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}