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
149pub struct DeprecatedWeakAlgorithms;
150
151/// Directives whose values are comma-separated algorithm lists.
152const ALGORITHM_DIRECTIVES: &[&str] = &[
153    "ciphers",
154    "macs",
155    "kexalgorithms",
156    "hostkeyalgorithms",
157    "pubkeyacceptedalgorithms",
158    "pubkeyacceptedkeytypes",
159    "casignaturealgorithms",
160];
161
162/// Known deprecated or weak algorithms.
163const WEAK_ALGORITHMS: &[&str] = &[
164    // Ciphers
165    "3des-cbc",
166    "blowfish-cbc",
167    "cast128-cbc",
168    "arcfour",
169    "arcfour128",
170    "arcfour256",
171    "rijndael-cbc@lysator.liu.se",
172    // MACs
173    "hmac-md5",
174    "hmac-md5-96",
175    "hmac-md5-etm@openssh.com",
176    "hmac-md5-96-etm@openssh.com",
177    "hmac-ripemd160",
178    "hmac-ripemd160-etm@openssh.com",
179    "hmac-sha1-96",
180    "hmac-sha1-96-etm@openssh.com",
181    "umac-64@openssh.com",
182    "umac-64-etm@openssh.com",
183    // Key exchange
184    "diffie-hellman-group1-sha1",
185    "diffie-hellman-group14-sha1",
186    "diffie-hellman-group-exchange-sha1",
187    // Host key / signature
188    "ssh-dss",
189    "ssh-rsa",
190];
191
192impl Rule for DeprecatedWeakAlgorithms {
193    fn name(&self) -> &'static str {
194        "deprecated-weak-algorithms"
195    }
196
197    fn check(&self, config: &Config) -> Vec<Finding> {
198        let mut findings = Vec::new();
199        collect_weak_algorithm_findings(&config.items, &mut findings);
200        findings
201    }
202}
203
204fn collect_weak_algorithm_findings(items: &[Item], findings: &mut Vec<Finding>) {
205    for item in items {
206        match item {
207            Item::Directive {
208                key, value, span, ..
209            } if ALGORITHM_DIRECTIVES
210                .iter()
211                .any(|d| d.eq_ignore_ascii_case(key)) =>
212            {
213                check_algorithms(key, value, span, findings);
214            }
215            Item::HostBlock { items, .. } | Item::MatchBlock { items, .. } => {
216                collect_weak_algorithm_findings(items, findings);
217            }
218            _ => {}
219        }
220    }
221}
222
223fn check_algorithms(key: &str, value: &str, span: &Span, findings: &mut Vec<Finding>) {
224    for algo in value.split(',') {
225        let algo = algo.trim();
226        if algo.is_empty() {
227            continue;
228        }
229        // Handle +/- prefix modifiers (e.g. +ssh-rsa)
230        let bare = algo.trim_start_matches(['+', '-', '^']);
231        if WEAK_ALGORITHMS.iter().any(|w| w.eq_ignore_ascii_case(bare)) {
232            findings.push(
233                Finding::new(
234                    Severity::Warning,
235                    "deprecated-weak-algorithms",
236                    "WEAK_ALGO",
237                    format!("weak or deprecated algorithm '{}' in {}", bare, key),
238                    span.clone(),
239                )
240                .with_hint(format!("remove '{}' and use a stronger algorithm", bare)),
241            );
242        }
243    }
244}
245
246pub struct DuplicateDirectives;
247
248impl Rule for DuplicateDirectives {
249    fn name(&self) -> &'static str {
250        "duplicate-directives"
251    }
252
253    fn check(&self, config: &Config) -> Vec<Finding> {
254        let mut findings = Vec::new();
255        collect_duplicate_directives(&config.items, &mut findings);
256        findings
257    }
258}
259
260/// Directives that are allowed (or expected) to appear multiple times.
261const MULTI_VALUE_DIRECTIVES: &[&str] = &[
262    "identityfile",
263    "certificatefile",
264    "localforward",
265    "remoteforward",
266    "dynamicforward",
267    "sendenv",
268    "setenv",
269    "match",
270    "host",
271];
272
273fn collect_duplicate_directives(items: &[Item], findings: &mut Vec<Finding>) {
274    check_scope_for_duplicates(items, findings);
275    for item in items {
276        match item {
277            Item::HostBlock { items, .. } | Item::MatchBlock { items, .. } => {
278                check_scope_for_duplicates(items, findings);
279            }
280            _ => {}
281        }
282    }
283}
284
285fn check_scope_for_duplicates(items: &[Item], findings: &mut Vec<Finding>) {
286    let mut seen: HashMap<String, Span> = HashMap::new();
287    for item in items {
288        if let Item::Directive { key, span, .. } = item {
289            let lower = key.to_ascii_lowercase();
290            if MULTI_VALUE_DIRECTIVES.contains(&lower.as_str()) {
291                continue;
292            }
293            if let Some(first_span) = seen.get(&lower) {
294                findings.push(
295                    Finding::new(
296                        Severity::Warning,
297                        "duplicate-directives",
298                        "DUP_DIRECTIVE",
299                        format!(
300                            "duplicate directive '{}' (first seen at line {})",
301                            key, first_span.line
302                        ),
303                        span.clone(),
304                    )
305                    .with_hint("remove the duplicate; only the first value takes effect"),
306                );
307            } else {
308                seen.insert(lower, span.clone());
309            }
310        }
311    }
312}
313
314#[cfg(test)]
315mod tests {
316    use super::*;
317    use crate::model::{Config, Item, Span};
318    use std::fs;
319    use tempfile::TempDir;
320
321    #[test]
322    fn no_duplicates_no_findings() {
323        let config = Config {
324            items: vec![
325                Item::HostBlock {
326                    patterns: vec!["a".to_string()],
327                    span: Span::new(1),
328                    items: vec![],
329                },
330                Item::HostBlock {
331                    patterns: vec!["b".to_string()],
332                    span: Span::new(3),
333                    items: vec![],
334                },
335            ],
336        };
337        let findings = DuplicateHost.check(&config);
338        assert!(findings.is_empty());
339    }
340
341    #[test]
342    fn duplicate_host_warns() {
343        let config = Config {
344            items: vec![
345                Item::HostBlock {
346                    patterns: vec!["github.com".to_string()],
347                    span: Span::new(1),
348                    items: vec![],
349                },
350                Item::HostBlock {
351                    patterns: vec!["github.com".to_string()],
352                    span: Span::new(5),
353                    items: vec![],
354                },
355            ],
356        };
357        let findings = DuplicateHost.check(&config);
358        assert_eq!(findings.len(), 1);
359        assert_eq!(findings[0].rule, "duplicate-host");
360        assert!(findings[0].message.contains("first seen at line 1"));
361    }
362
363    #[test]
364    fn identity_file_exists_no_error() {
365        let tmp = TempDir::new().unwrap();
366        let key_path = tmp.path().join("id_test");
367        fs::write(&key_path, "fake key").unwrap();
368
369        let config = Config {
370            items: vec![Item::HostBlock {
371                patterns: vec!["a".to_string()],
372                span: Span::new(1),
373                items: vec![Item::Directive {
374                    key: "IdentityFile".into(),
375                    value: key_path.to_string_lossy().into_owned(),
376                    span: Span::new(2),
377                }],
378            }],
379        };
380        let findings = IdentityFileExists.check(&config);
381        assert!(findings.is_empty());
382    }
383
384    #[test]
385    fn identity_file_missing_errors() {
386        let config = Config {
387            items: vec![Item::Directive {
388                key: "IdentityFile".into(),
389                value: "/nonexistent/path/id_nope".into(),
390                span: Span::new(1),
391            }],
392        };
393        let findings = IdentityFileExists.check(&config);
394        assert_eq!(findings.len(), 1);
395        assert_eq!(findings[0].rule, "identity-file-exists");
396    }
397
398    #[test]
399    fn identity_file_skips_templates() {
400        let config = Config {
401            items: vec![
402                Item::Directive {
403                    key: "IdentityFile".into(),
404                    value: "~/.ssh/id_%h".into(),
405                    span: Span::new(1),
406                },
407                Item::Directive {
408                    key: "IdentityFile".into(),
409                    value: "${HOME}/.ssh/id_ed25519".into(),
410                    span: Span::new(2),
411                },
412            ],
413        };
414        let findings = IdentityFileExists.check(&config);
415        assert!(findings.is_empty());
416    }
417
418    #[test]
419    fn wildcard_after_specific_no_warning() {
420        let config = Config {
421            items: vec![
422                Item::HostBlock {
423                    patterns: vec!["github.com".to_string()],
424                    span: Span::new(1),
425                    items: vec![],
426                },
427                Item::HostBlock {
428                    patterns: vec!["*".to_string()],
429                    span: Span::new(5),
430                    items: vec![],
431                },
432            ],
433        };
434        let findings = WildcardHostOrder.check(&config);
435        assert!(findings.is_empty());
436    }
437
438    #[test]
439    fn wildcard_before_specific_warns() {
440        let config = Config {
441            items: vec![
442                Item::HostBlock {
443                    patterns: vec!["*".to_string()],
444                    span: Span::new(1),
445                    items: vec![],
446                },
447                Item::HostBlock {
448                    patterns: vec!["github.com".to_string()],
449                    span: Span::new(5),
450                    items: vec![],
451                },
452            ],
453        };
454        let findings = WildcardHostOrder.check(&config);
455        assert_eq!(findings.len(), 1);
456        assert_eq!(findings[0].rule, "wildcard-host-order");
457        assert!(findings[0].message.contains("github.com"));
458    }
459
460    // ── DeprecatedWeakAlgorithms tests ──
461
462    #[test]
463    fn weak_cipher_warns() {
464        let config = Config {
465            items: vec![Item::Directive {
466                key: "Ciphers".into(),
467                value: "aes128-ctr,3des-cbc,aes256-gcm@openssh.com".into(),
468                span: Span::new(1),
469            }],
470        };
471        let findings = DeprecatedWeakAlgorithms.check(&config);
472        assert_eq!(findings.len(), 1);
473        assert_eq!(findings[0].code, "WEAK_ALGO");
474        assert!(findings[0].message.contains("3des-cbc"));
475        assert!(findings[0].message.contains("Ciphers"));
476    }
477
478    #[test]
479    fn weak_mac_warns() {
480        let config = Config {
481            items: vec![Item::Directive {
482                key: "MACs".into(),
483                value: "hmac-sha2-256,hmac-md5".into(),
484                span: Span::new(3),
485            }],
486        };
487        let findings = DeprecatedWeakAlgorithms.check(&config);
488        assert_eq!(findings.len(), 1);
489        assert!(findings[0].message.contains("hmac-md5"));
490    }
491
492    #[test]
493    fn weak_kex_warns() {
494        let config = Config {
495            items: vec![Item::Directive {
496                key: "KexAlgorithms".into(),
497                value: "diffie-hellman-group1-sha1".into(),
498                span: Span::new(1),
499            }],
500        };
501        let findings = DeprecatedWeakAlgorithms.check(&config);
502        assert_eq!(findings.len(), 1);
503        assert!(findings[0].message.contains("diffie-hellman-group1-sha1"));
504    }
505
506    #[test]
507    fn weak_host_key_algorithm_warns() {
508        let config = Config {
509            items: vec![Item::Directive {
510                key: "HostKeyAlgorithms".into(),
511                value: "ssh-ed25519,ssh-dss".into(),
512                span: Span::new(2),
513            }],
514        };
515        let findings = DeprecatedWeakAlgorithms.check(&config);
516        assert_eq!(findings.len(), 1);
517        assert!(findings[0].message.contains("ssh-dss"));
518    }
519
520    #[test]
521    fn weak_pubkey_accepted_warns() {
522        let config = Config {
523            items: vec![Item::Directive {
524                key: "PubkeyAcceptedAlgorithms".into(),
525                value: "ssh-rsa,ssh-ed25519".into(),
526                span: Span::new(1),
527            }],
528        };
529        let findings = DeprecatedWeakAlgorithms.check(&config);
530        assert_eq!(findings.len(), 1);
531        assert!(findings[0].message.contains("ssh-rsa"));
532    }
533
534    #[test]
535    fn strong_algorithms_no_warning() {
536        let config = Config {
537            items: vec![
538                Item::Directive {
539                    key: "Ciphers".into(),
540                    value: "chacha20-poly1305@openssh.com,aes256-gcm@openssh.com,aes128-gcm@openssh.com".into(),
541                    span: Span::new(1),
542                },
543                Item::Directive {
544                    key: "MACs".into(),
545                    value: "hmac-sha2-256-etm@openssh.com,hmac-sha2-512-etm@openssh.com".into(),
546                    span: Span::new(2),
547                },
548                Item::Directive {
549                    key: "KexAlgorithms".into(),
550                    value: "curve25519-sha256,diffie-hellman-group16-sha512".into(),
551                    span: Span::new(3),
552                },
553            ],
554        };
555        let findings = DeprecatedWeakAlgorithms.check(&config);
556        assert!(findings.is_empty());
557    }
558
559    #[test]
560    fn multiple_weak_algorithms_multiple_findings() {
561        let config = Config {
562            items: vec![Item::Directive {
563                key: "Ciphers".into(),
564                value: "3des-cbc,arcfour,blowfish-cbc".into(),
565                span: Span::new(1),
566            }],
567        };
568        let findings = DeprecatedWeakAlgorithms.check(&config);
569        assert_eq!(findings.len(), 3);
570    }
571
572    #[test]
573    fn weak_algo_inside_host_block() {
574        let config = Config {
575            items: vec![Item::HostBlock {
576                patterns: vec!["legacy-server".to_string()],
577                span: Span::new(1),
578                items: vec![Item::Directive {
579                    key: "Ciphers".into(),
580                    value: "arcfour256".into(),
581                    span: Span::new(2),
582                }],
583            }],
584        };
585        let findings = DeprecatedWeakAlgorithms.check(&config);
586        assert_eq!(findings.len(), 1);
587        assert!(findings[0].message.contains("arcfour256"));
588    }
589
590    #[test]
591    fn weak_algo_with_prefix_modifier() {
592        let config = Config {
593            items: vec![Item::Directive {
594                key: "Ciphers".into(),
595                value: "+3des-cbc".into(),
596                span: Span::new(1),
597            }],
598        };
599        let findings = DeprecatedWeakAlgorithms.check(&config);
600        assert_eq!(findings.len(), 1);
601        assert!(findings[0].message.contains("3des-cbc"));
602    }
603
604    #[test]
605    fn non_algorithm_directive_ignored() {
606        let config = Config {
607            items: vec![Item::Directive {
608                key: "HostName".into(),
609                value: "ssh-rsa.example.com".into(),
610                span: Span::new(1),
611            }],
612        };
613        let findings = DeprecatedWeakAlgorithms.check(&config);
614        assert!(findings.is_empty());
615    }
616
617    #[test]
618    fn weak_algo_has_hint() {
619        let config = Config {
620            items: vec![Item::Directive {
621                key: "MACs".into(),
622                value: "hmac-md5".into(),
623                span: Span::new(1),
624            }],
625        };
626        let findings = DeprecatedWeakAlgorithms.check(&config);
627        assert_eq!(findings.len(), 1);
628        let hint = findings[0].hint.as_deref().unwrap();
629        assert!(hint.contains("hmac-md5"));
630        assert!(hint.contains("stronger algorithm"));
631    }
632
633    // ── DuplicateDirectives tests ──
634
635    #[test]
636    fn duplicate_directives_at_root() {
637        let config = Config {
638            items: vec![
639                Item::Directive {
640                    key: "User".into(),
641                    value: "noah".into(),
642                    span: Span::new(1),
643                },
644                Item::Directive {
645                    key: "User".into(),
646                    value: "noah2".into(),
647                    span: Span::new(2),
648                },
649            ],
650        };
651        let findings = DuplicateDirectives.check(&config);
652        assert_eq!(findings.len(), 1);
653        assert_eq!(findings[0].rule, "duplicate-directives");
654        assert_eq!(findings[0].code, "DUP_DIRECTIVE");
655        assert!(findings[0].message.contains("User"));
656        assert!(findings[0].message.contains("first seen at line 1"));
657    }
658
659    #[test]
660    fn duplicate_directives_inside_host_block() {
661        let config = Config {
662            items: vec![Item::HostBlock {
663                patterns: vec!["example.com".to_string()],
664                span: Span::new(1),
665                items: vec![
666                    Item::Directive {
667                        key: "HostName".into(),
668                        value: "1.2.3.4".into(),
669                        span: Span::new(2),
670                    },
671                    Item::Directive {
672                        key: "HostName".into(),
673                        value: "5.6.7.8".into(),
674                        span: Span::new(3),
675                    },
676                ],
677            }],
678        };
679        let findings = DuplicateDirectives.check(&config);
680        assert_eq!(findings.len(), 1);
681        assert!(findings[0].message.contains("HostName"));
682    }
683
684    #[test]
685    fn duplicate_directives_case_insensitive() {
686        let config = Config {
687            items: vec![
688                Item::Directive {
689                    key: "User".into(),
690                    value: "alice".into(),
691                    span: Span::new(1),
692                },
693                Item::Directive {
694                    key: "user".into(),
695                    value: "bob".into(),
696                    span: Span::new(2),
697                },
698            ],
699        };
700        let findings = DuplicateDirectives.check(&config);
701        assert_eq!(findings.len(), 1);
702    }
703
704    #[test]
705    fn duplicate_directives_allows_identity_file() {
706        let config = Config {
707            items: vec![Item::HostBlock {
708                patterns: vec!["server".to_string()],
709                span: Span::new(1),
710                items: vec![
711                    Item::Directive {
712                        key: "IdentityFile".into(),
713                        value: "~/.ssh/id_ed25519".into(),
714                        span: Span::new(2),
715                    },
716                    Item::Directive {
717                        key: "IdentityFile".into(),
718                        value: "~/.ssh/id_rsa".into(),
719                        span: Span::new(3),
720                    },
721                ],
722            }],
723        };
724        let findings = DuplicateDirectives.check(&config);
725        assert!(findings.is_empty());
726    }
727
728    #[test]
729    fn duplicate_directives_allows_multi_value_directives() {
730        let config = Config {
731            items: vec![
732                Item::Directive {
733                    key: "SendEnv".into(),
734                    value: "LANG".into(),
735                    span: Span::new(1),
736                },
737                Item::Directive {
738                    key: "SendEnv".into(),
739                    value: "LC_*".into(),
740                    span: Span::new(2),
741                },
742                Item::Directive {
743                    key: "LocalForward".into(),
744                    value: "8080 localhost:80".into(),
745                    span: Span::new(3),
746                },
747                Item::Directive {
748                    key: "LocalForward".into(),
749                    value: "9090 localhost:90".into(),
750                    span: Span::new(4),
751                },
752            ],
753        };
754        let findings = DuplicateDirectives.check(&config);
755        assert!(findings.is_empty());
756    }
757
758    #[test]
759    fn no_duplicate_directives_no_findings() {
760        let config = Config {
761            items: vec![Item::HostBlock {
762                patterns: vec!["server".to_string()],
763                span: Span::new(1),
764                items: vec![
765                    Item::Directive {
766                        key: "User".into(),
767                        value: "git".into(),
768                        span: Span::new(2),
769                    },
770                    Item::Directive {
771                        key: "HostName".into(),
772                        value: "1.2.3.4".into(),
773                        span: Span::new(3),
774                    },
775                    Item::Directive {
776                        key: "Port".into(),
777                        value: "22".into(),
778                        span: Span::new(4),
779                    },
780                ],
781            }],
782        };
783        let findings = DuplicateDirectives.check(&config);
784        assert!(findings.is_empty());
785    }
786
787    #[test]
788    fn duplicate_directives_separate_scopes_ok() {
789        // Same directive in different Host blocks should NOT warn
790        let config = Config {
791            items: vec![
792                Item::HostBlock {
793                    patterns: vec!["a".to_string()],
794                    span: Span::new(1),
795                    items: vec![Item::Directive {
796                        key: "User".into(),
797                        value: "alice".into(),
798                        span: Span::new(2),
799                    }],
800                },
801                Item::HostBlock {
802                    patterns: vec!["b".to_string()],
803                    span: Span::new(4),
804                    items: vec![Item::Directive {
805                        key: "User".into(),
806                        value: "bob".into(),
807                        span: Span::new(5),
808                    }],
809                },
810            ],
811        };
812        let findings = DuplicateDirectives.check(&config);
813        assert!(findings.is_empty());
814    }
815
816    #[test]
817    fn duplicate_directives_has_hint() {
818        let config = Config {
819            items: vec![
820                Item::Directive {
821                    key: "Port".into(),
822                    value: "22".into(),
823                    span: Span::new(1),
824                },
825                Item::Directive {
826                    key: "Port".into(),
827                    value: "2222".into(),
828                    span: Span::new(2),
829                },
830            ],
831        };
832        let findings = DuplicateDirectives.check(&config);
833        assert_eq!(findings.len(), 1);
834        let hint = findings[0].hint.as_deref().unwrap();
835        assert!(hint.contains("first value takes effect"));
836    }
837
838    #[test]
839    fn duplicate_directives_inside_match_block() {
840        let config = Config {
841            items: vec![Item::MatchBlock {
842                criteria: "host example.com".into(),
843                span: Span::new(1),
844                items: vec![
845                    Item::Directive {
846                        key: "ForwardAgent".into(),
847                        value: "yes".into(),
848                        span: Span::new(2),
849                    },
850                    Item::Directive {
851                        key: "ForwardAgent".into(),
852                        value: "no".into(),
853                        span: Span::new(3),
854                    },
855                ],
856            }],
857        };
858        let findings = DuplicateDirectives.check(&config);
859        assert_eq!(findings.len(), 1);
860        assert!(findings[0].message.contains("ForwardAgent"));
861    }
862}