Skip to main content

cha_core/plugins/
error_handling.rs

1use crate::{AnalysisContext, Finding, Location, Plugin, Severity, SmellCategory};
2
3/// Detect error handling smells:
4/// - Empty catch/except blocks (silently swallowed errors)
5/// - Excessive unwrap()/expect() calls in Rust
6///
7/// Both checks use tree-sitter queries when AST is available, so substring
8/// matches inside strings or comments don't trigger false positives.
9///
10/// ## References
11///
12/// [1] G. Padua and W. Shang, "Revisiting Exception Handling Practices
13///     with Exception Flow Analysis," Empirical Software Engineering,
14///     vol. 23, no. 6, pp. 3337–3383, 2018.
15///     doi: 10.1007/s10664-018-9601-8.
16///
17/// [2] A. Rahman, C. Parnin, and L. Williams, "The Seven Sins: Security
18///     Smells in Infrastructure as Code Scripts," in Proc. 41st Int. Conf.
19///     Software Engineering (ICSE), 2019, pp. 164–175.
20///     doi: 10.1109/ICSE.2019.00033.
21pub struct ErrorHandlingAnalyzer {
22    pub max_unwraps_per_function: usize,
23}
24
25impl Default for ErrorHandlingAnalyzer {
26    fn default() -> Self {
27        Self {
28            max_unwraps_per_function: 3,
29        }
30    }
31}
32
33impl Plugin for ErrorHandlingAnalyzer {
34    fn name(&self) -> &str {
35        "error_handling"
36    }
37
38    fn smells(&self) -> Vec<String> {
39        vec!["empty_catch".into(), "unwrap_abuse".into()]
40    }
41
42    fn description(&self) -> &str {
43        "Empty catch blocks, unwrap/expect abuse"
44    }
45
46    fn analyze(&self, ctx: &AnalysisContext) -> Vec<Finding> {
47        let mut findings = Vec::new();
48        let unwrap_sites = collect_unwrap_sites(ctx);
49        for f in &ctx.model.functions {
50            let in_fn: Vec<&UnwrapSite> = unwrap_sites
51                .iter()
52                .filter(|s| s.line >= f.start_line && s.line <= f.end_line)
53                .collect();
54            if in_fn.len() > self.max_unwraps_per_function {
55                for site in &in_fn {
56                    findings.push(build_unwrap_finding(
57                        ctx,
58                        f,
59                        site,
60                        in_fn.len(),
61                        self.max_unwraps_per_function,
62                    ));
63                }
64            }
65        }
66        detect_empty_catch(ctx, &mut findings);
67        findings
68    }
69}
70
71/// A single `.unwrap()` / `.expect(...)` call site.
72struct UnwrapSite {
73    line: usize,
74    start_col: usize,
75    end_col: usize,
76    matched: String,
77}
78
79fn collect_unwrap_sites(ctx: &AnalysisContext) -> Vec<UnwrapSite> {
80    if let (Some(tree), Some(lang)) = (ctx.tree, ctx.ts_language) {
81        if ctx.model.language != "rust" {
82            // unwrap_abuse only meaningful in Rust. Python `.get(default)`,
83            // JS optional chaining etc are separate detections.
84            return Vec::new();
85        }
86        let source = ctx.file.content.as_bytes();
87        let pattern = r#"(call_expression function: (field_expression field: (field_identifier) @m (#match? @m "^(unwrap|expect)$"))) @site"#;
88        let mut out = Vec::new();
89        for matches in crate::query::run_query(tree, lang, source, pattern) {
90            let Some(site_cap) = matches.iter().find(|c| c.capture_name == "site") else {
91                continue;
92            };
93            let m_cap = matches.iter().find(|c| c.capture_name == "m");
94            let matched = match m_cap {
95                Some(c) if c.text == "expect" => "expect",
96                _ => "unwrap",
97            }
98            .to_string();
99            out.push(UnwrapSite {
100                line: site_cap.start_line as usize,
101                start_col: site_cap.start_col as usize,
102                end_col: site_cap.end_col as usize,
103                matched,
104            });
105        }
106        return out;
107    }
108    // Tree unavailable — legacy substring fallback for unit tests that build
109    // SourceModel without a real parse.
110    collect_unwrap_sites_legacy(&ctx.file.content)
111}
112
113fn collect_unwrap_sites_legacy(content: &str) -> Vec<UnwrapSite> {
114    let mut out = Vec::new();
115    for (i, line) in content.lines().enumerate() {
116        let trimmed = line.trim();
117        if trimmed.starts_with("//") || trimmed.starts_with('#') {
118            continue;
119        }
120        push_legacy_match(&mut out, i + 1, line, ".unwrap()", "unwrap");
121        push_legacy_match(&mut out, i + 1, line, ".expect(", "expect");
122    }
123    out
124}
125
126fn push_legacy_match(
127    sites: &mut Vec<UnwrapSite>,
128    line: usize,
129    text: &str,
130    needle: &str,
131    matched: &str,
132) {
133    let mut search_from = 0;
134    while let Some(pos) = text[search_from..].find(needle) {
135        let abs = search_from + pos;
136        sites.push(UnwrapSite {
137            line,
138            start_col: abs,
139            end_col: abs + needle.len(),
140            matched: matched.to_string(),
141        });
142        search_from = abs + needle.len();
143    }
144}
145
146fn build_unwrap_finding(
147    ctx: &AnalysisContext,
148    f: &crate::FunctionInfo,
149    site: &UnwrapSite,
150    total: usize,
151    threshold: usize,
152) -> Finding {
153    Finding {
154        smell_name: "unwrap_abuse".into(),
155        category: SmellCategory::Security,
156        severity: Severity::Warning,
157        location: Location {
158            path: ctx.file.path.clone(),
159            start_line: site.line,
160            start_col: site.start_col,
161            end_line: site.line,
162            end_col: site.end_col,
163            name: Some(f.name.clone()),
164        },
165        message: format!(
166            "`.{}()` in `{}` (function has {total} unwrap/expect calls, threshold: {threshold})",
167            site.matched, f.name
168        ),
169        suggested_refactorings: vec!["Use ? operator".into(), "Handle errors explicitly".into()],
170        ..Default::default()
171    }
172}
173
174/// Rust has no `catch` syntax — skip. Substring "catch" in a Rust string or
175/// comment used to false-positive.
176fn detect_empty_catch(ctx: &AnalysisContext, findings: &mut Vec<Finding>) {
177    let (Some(tree), Some(lang)) = (ctx.tree, ctx.ts_language) else {
178        return;
179    };
180    let source = ctx.file.content.as_bytes();
181    let patterns: &[&str] = match ctx.model.language.as_str() {
182        // TS/JS: catch_clause body is a statement_block; flag if no statements.
183        "typescript" => &["(catch_clause body: (statement_block) @body) @site"],
184        // Python: except_clause body is a block; flag if it's just `pass`.
185        "python" => &["(except_clause body: (block) @body) @site"],
186        _ => return,
187    };
188    for pat in patterns {
189        for matches in crate::query::run_query(tree, lang, source, pat) {
190            let Some(body) = matches.iter().find(|c| c.capture_name == "body") else {
191                continue;
192            };
193            let Some(site) = matches.iter().find(|c| c.capture_name == "site") else {
194                continue;
195            };
196            if !is_body_empty(body) {
197                continue;
198            }
199            findings.push(Finding {
200                smell_name: "empty_catch".into(),
201                category: SmellCategory::Security,
202                severity: Severity::Warning,
203                location: Location {
204                    path: ctx.file.path.clone(),
205                    start_line: site.start_line as usize,
206                    start_col: site.start_col as usize,
207                    end_line: site.end_line as usize,
208                    end_col: site.end_col as usize,
209                    name: None,
210                },
211                message: "Empty catch/except block — errors are silently swallowed".into(),
212                suggested_refactorings: vec![
213                    "Log the error".into(),
214                    "Re-throw or handle explicitly".into(),
215                ],
216                ..Default::default()
217            });
218        }
219    }
220}
221
222fn is_body_empty(body: &crate::query::QueryMatch) -> bool {
223    let trimmed = body.text.trim();
224    trimmed == "{}" || trimmed == "pass" || trimmed.is_empty()
225}