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/// ## References
8///
9/// [1] G. Padua and W. Shang, "Revisiting Exception Handling Practices
10///     with Exception Flow Analysis," Empirical Software Engineering,
11///     vol. 23, no. 6, pp. 3337–3383, 2018.
12///     doi: 10.1007/s10664-018-9601-8.
13///
14/// [2] A. Rahman, C. Parnin, and L. Williams, "The Seven Sins: Security
15///     Smells in Infrastructure as Code Scripts," in Proc. 41st Int. Conf.
16///     Software Engineering (ICSE), 2019, pp. 164–175.
17///     doi: 10.1109/ICSE.2019.00033.
18pub struct ErrorHandlingAnalyzer {
19    pub max_unwraps_per_function: usize,
20}
21
22impl Default for ErrorHandlingAnalyzer {
23    fn default() -> Self {
24        Self {
25            max_unwraps_per_function: 3,
26        }
27    }
28}
29
30impl Plugin for ErrorHandlingAnalyzer {
31    fn name(&self) -> &str {
32        "error_handling"
33    }
34
35    fn smells(&self) -> Vec<String> {
36        vec!["empty_catch".into(), "unwrap_abuse".into()]
37    }
38
39    fn description(&self) -> &str {
40        "Empty catch blocks, unwrap/expect abuse"
41    }
42
43    fn analyze(&self, ctx: &AnalysisContext) -> Vec<Finding> {
44        let mut findings = Vec::new();
45        let lines: Vec<&str> = ctx.file.content.lines().collect();
46
47        for f in &ctx.model.functions {
48            let sites = collect_unwrap_sites(&lines, f.start_line, f.end_line);
49            if sites.len() > self.max_unwraps_per_function {
50                for site in &sites {
51                    findings.push(build_unwrap_finding(
52                        ctx,
53                        f,
54                        site,
55                        sites.len(),
56                        self.max_unwraps_per_function,
57                    ));
58                }
59            }
60        }
61
62        detect_empty_catch(&lines, ctx, &mut findings);
63        findings
64    }
65}
66
67/// A single `.unwrap()` / `.expect(` call site inside a function body.
68struct UnwrapSite {
69    /// 1-based line number in the source file.
70    line: usize,
71    /// 0-based column of the start of the matching substring (`.unwrap()` or `.expect(`).
72    start_col: usize,
73    /// 0-based column of the end of the matching substring.
74    end_col: usize,
75    /// Raw matched substring, used in the message for clarity.
76    matched: &'static str,
77}
78
79fn collect_unwrap_sites(lines: &[&str], start: usize, end: usize) -> Vec<UnwrapSite> {
80    let mut sites = Vec::new();
81    for (idx, line) in lines
82        .iter()
83        .enumerate()
84        .take(end.min(lines.len()))
85        .skip(start.saturating_sub(1))
86    {
87        let trimmed = line.trim();
88        if trimmed.starts_with("//") || trimmed.starts_with('#') {
89            continue;
90        }
91        push_matches(&mut sites, idx + 1, line, ".unwrap()");
92        push_matches(&mut sites, idx + 1, line, ".expect(");
93    }
94    sites
95}
96
97fn push_matches(sites: &mut Vec<UnwrapSite>, line: usize, text: &str, needle: &'static str) {
98    let mut search_from = 0;
99    while let Some(pos) = text[search_from..].find(needle) {
100        let abs = search_from + pos;
101        sites.push(UnwrapSite {
102            line,
103            start_col: abs,
104            end_col: abs + needle.len(),
105            matched: needle,
106        });
107        search_from = abs + needle.len();
108    }
109}
110
111fn build_unwrap_finding(
112    ctx: &AnalysisContext,
113    f: &crate::FunctionInfo,
114    site: &UnwrapSite,
115    total: usize,
116    threshold: usize,
117) -> Finding {
118    Finding {
119        smell_name: "unwrap_abuse".into(),
120        category: SmellCategory::Security,
121        severity: Severity::Warning,
122        location: Location {
123            path: ctx.file.path.clone(),
124            start_line: site.line,
125            start_col: site.start_col,
126            end_line: site.line,
127            end_col: site.end_col,
128            name: Some(f.name.clone()),
129        },
130        message: format!(
131            "`{}` in `{}` (function has {total} unwrap/expect calls, threshold: {threshold})",
132            site.matched, f.name
133        ),
134        suggested_refactorings: vec!["Use ? operator".into(), "Handle errors explicitly".into()],
135        ..Default::default()
136    }
137}
138
139fn detect_empty_catch(lines: &[&str], ctx: &AnalysisContext, findings: &mut Vec<Finding>) {
140    for (i, line) in lines.iter().enumerate() {
141        let trimmed = line.trim();
142        let is_catch = trimmed.starts_with("catch")
143            || trimmed.starts_with("except")
144            || trimmed.starts_with("} catch")
145            || trimmed.starts_with("rescue");
146        if !is_catch {
147            continue;
148        }
149        if let Some(next) = lines.iter().skip(i + 1).find(|l| !l.trim().is_empty()) {
150            let next_trimmed = next.trim();
151            if next_trimmed == "}" || next_trimmed == "pass" || next_trimmed.is_empty() {
152                let col = line.find(trimmed).unwrap_or(0);
153                findings.push(Finding {
154                    smell_name: "empty_catch".into(),
155                    category: SmellCategory::Security,
156                    severity: Severity::Warning,
157                    location: Location {
158                        path: ctx.file.path.clone(),
159                        start_line: i + 1,
160                        start_col: col,
161                        end_line: i + 2,
162                        name: None,
163                        ..Default::default()
164                    },
165                    message: "Empty catch/except block — errors are silently swallowed".into(),
166                    suggested_refactorings: vec![
167                        "Log the error".into(),
168                        "Re-throw or handle explicitly".into(),
169                    ],
170                    ..Default::default()
171                });
172            }
173        }
174    }
175}