Skip to main content

sshconfig_lint/
lib.rs

1pub mod lexer;
2pub mod model;
3pub mod parser;
4pub mod report;
5pub mod resolve;
6pub mod rules;
7
8use std::path::Path;
9
10use model::Finding;
11
12/// Sort findings by file then line number for deterministic output.
13fn sort_findings(findings: &mut [Finding]) {
14    findings.sort_by(|a, b| {
15        a.span
16            .file
17            .cmp(&b.span.file)
18            .then(a.span.line.cmp(&b.span.line))
19    });
20}
21
22/// Lint an SSH config from a string. Does not touch the filesystem.
23pub fn lint_str(input: &str) -> Vec<Finding> {
24    let lines = lexer::lex(input);
25    let config = parser::parse(lines);
26    let mut findings = rules::run_all(&config);
27    sort_findings(&mut findings);
28    findings
29}
30
31/// Lint an SSH config from a string, with Include resolution against a base dir.
32pub fn lint_str_with_includes(input: &str, base_dir: &Path) -> Vec<Finding> {
33    let lines = lexer::lex(input);
34    let mut config = parser::parse(lines);
35    let mut findings = resolve::resolve_includes(&mut config, base_dir);
36    findings.extend(rules::run_all(&config));
37    sort_findings(&mut findings);
38    findings
39}
40
41/// Lint an SSH config file by path, resolving Includes.
42pub fn lint_file(path: &Path) -> Result<Vec<Finding>, std::io::Error> {
43    let content = std::fs::read_to_string(path)?;
44    let base_dir = path.parent().unwrap_or(Path::new("."));
45    Ok(lint_str_with_includes(&content, base_dir))
46}
47
48/// Lint an SSH config file by path, skipping Include resolution.
49pub fn lint_file_no_includes(path: &Path) -> Result<Vec<Finding>, std::io::Error> {
50    let content = std::fs::read_to_string(path)?;
51    Ok(lint_str(&content))
52}
53
54/// Returns true if any finding has Error severity.
55pub fn has_errors(findings: &[Finding]) -> bool {
56    findings
57        .iter()
58        .any(|f| f.severity == model::Severity::Error)
59}
60
61/// Returns true if any finding has Warning or Error severity.
62pub fn has_warnings(findings: &[Finding]) -> bool {
63    findings.iter().any(|f| {
64        matches!(
65            f.severity,
66            model::Severity::Warning | model::Severity::Error
67        )
68    })
69}
70
71#[cfg(test)]
72mod tests {
73    use super::*;
74
75    #[test]
76    fn lint_str_empty_returns_empty() {
77        let findings = lint_str("");
78        assert!(findings.is_empty());
79    }
80
81    #[test]
82    fn lint_str_clean_config_no_findings() {
83        let input = "\
84Host github.com
85  User git
86  IdentityFile %d/.ssh/id_ed25519
87
88Host gitlab.com
89  User git
90";
91        let findings = lint_str(input);
92        assert!(findings.is_empty());
93    }
94
95    #[test]
96    fn lint_str_duplicate_host_found() {
97        let input = "\
98Host github.com
99  User git
100
101Host github.com
102  User git2
103";
104        let findings = lint_str(input);
105        assert!(findings.iter().any(|f| f.rule == "duplicate-host"));
106    }
107
108    #[test]
109    fn lint_str_wildcard_before_specific_warns() {
110        let input = "\
111Host *
112  ServerAliveInterval 60
113
114Host github.com
115  User git
116";
117        let findings = lint_str(input);
118        assert!(findings.iter().any(|f| f.rule == "wildcard-host-order"));
119    }
120
121    #[test]
122    fn has_errors_true_when_error_present() {
123        let findings = vec![Finding::new(
124            model::Severity::Error,
125            "test",
126            "TEST",
127            "bad",
128            model::Span::new(1),
129        )];
130        assert!(has_errors(&findings));
131    }
132
133    #[test]
134    fn has_errors_false_when_only_warnings() {
135        let findings = vec![Finding::new(
136            model::Severity::Warning,
137            "test",
138            "TEST",
139            "meh",
140            model::Span::new(1),
141        )];
142        assert!(!has_errors(&findings));
143    }
144
145    #[test]
146    fn has_warnings_true_when_warning_present() {
147        let findings = vec![Finding::new(
148            model::Severity::Warning,
149            "test",
150            "TEST",
151            "meh",
152            model::Span::new(1),
153        )];
154        assert!(has_warnings(&findings));
155    }
156
157    #[test]
158    fn has_warnings_false_when_only_info() {
159        let findings = vec![Finding::new(
160            model::Severity::Info,
161            "test",
162            "TEST",
163            "ok",
164            model::Span::new(1),
165        )];
166        assert!(!has_warnings(&findings));
167    }
168
169    #[test]
170    #[ignore]
171    fn lint_my_real_config() {
172        let home = dirs::home_dir().expect("no home dir");
173        let config_path = home.join(".ssh/config");
174        if !config_path.exists() {
175            eprintln!("~/.ssh/config not found, skipping");
176            return;
177        }
178        let findings = lint_file(&config_path).expect("failed to read config");
179        for f in &findings {
180            eprintln!(
181                "  line {}: [{}] ({}) {}",
182                f.span.line, f.severity, f.rule, f.message
183            );
184        }
185    }
186}