Skip to main content

jugar_probar/lint/
panic_paths.rs

1//! Panic Path Detection Linter (PROBAR-WASM-006)
2//!
3//! Static analysis to detect panic-inducing patterns in WASM code.
4//!
5//! ## Motivation
6//!
7//! In WASM, panic paths cause `wasm_bindgen::throw_str` which terminates
8//! the entire WASM instance. Unlike native Rust where panics can be caught,
9//! WASM panics are unrecoverable and break the user experience.
10//!
11//! ## Detection Rules
12//!
13//! | Rule ID | Description | Severity |
14//! |---------|-------------|----------|
15//! | WASM-PANIC-001 | `unwrap()` call | Error |
16//! | WASM-PANIC-002 | `expect()` call | Error |
17//! | WASM-PANIC-003 | `panic!()` macro | Error |
18//! | WASM-PANIC-004 | `unreachable!()` macro | Warning |
19//! | WASM-PANIC-005 | `todo!()` macro | Error |
20//! | WASM-PANIC-006 | `unimplemented!()` macro | Error |
21//! | WASM-PANIC-007 | Index access without bounds check | Warning |
22//!
23//! ## Example
24//!
25//! ```rust,ignore
26//! // BAD: Will panic in WASM
27//! let value = some_option.unwrap();
28//!
29//! // GOOD: Proper error handling
30//! let value = some_option.ok_or(MyError::Missing)?;
31//! ```
32
33use super::{LintError, LintSeverity, StateSyncReport};
34use syn::visit::Visit;
35use syn::{ExprMethodCall, Macro};
36
37/// Patterns that indicate panic paths
38const PANIC_METHODS: &[&str] = &["unwrap", "expect"];
39
40/// Macros that always panic
41const PANIC_MACROS: &[&str] = &["panic", "unreachable", "todo", "unimplemented"];
42
43/// AST visitor for detecting panic paths
44#[derive(Debug)]
45pub struct PanicPathVisitor {
46    /// Current file being analyzed
47    file: String,
48    /// Collected errors
49    errors: Vec<LintError>,
50    /// Source code for line lookups
51    source: String,
52    /// Whether we're inside a test module (relaxed rules)
53    in_test_module: bool,
54    /// Whether we're inside an unsafe block
55    in_unsafe_block: bool,
56}
57
58impl PanicPathVisitor {
59    /// Create a new panic path visitor
60    #[must_use]
61    pub fn new(file: String, source: String) -> Self {
62        Self {
63            file,
64            errors: Vec::new(),
65            source,
66            in_test_module: false,
67            in_unsafe_block: false,
68        }
69    }
70
71    /// Get the line number for a span
72    fn span_to_line(&self, span: proc_macro2::Span) -> usize {
73        span.start().line
74    }
75
76    /// Get the column for a span
77    fn span_to_column(&self, span: proc_macro2::Span) -> usize {
78        span.start().column + 1
79    }
80
81    /// Get the line content for context
82    fn get_line_content(&self, line: usize) -> String {
83        self.source
84            .lines()
85            .nth(line.saturating_sub(1))
86            .unwrap_or("")
87            .trim()
88            .to_string()
89    }
90
91    /// Check if a method name is a panic method
92    fn is_panic_method(method: &str) -> bool {
93        PANIC_METHODS.contains(&method)
94    }
95
96    /// Check if a macro path is a panic macro
97    fn is_panic_macro(path: &syn::Path) -> bool {
98        if let Some(ident) = path.get_ident() {
99            let name = ident.to_string();
100            return PANIC_MACROS.contains(&name.as_str());
101        }
102        // Check for std::panic, core::panic, etc.
103        if let Some(last) = path.segments.last() {
104            let name = last.ident.to_string();
105            return PANIC_MACROS.contains(&name.as_str());
106        }
107        false
108    }
109
110    /// Get severity for a panic macro
111    fn macro_severity(name: &str) -> LintSeverity {
112        match name {
113            "unreachable" => LintSeverity::Warning, // Sometimes intentional
114            _ => LintSeverity::Error,
115        }
116    }
117
118    /// Get suggestion for a panic method
119    fn suggestion_for_method(method: &str) -> String {
120        match method {
121            "unwrap" => {
122                "Use `ok_or(err)?` or `unwrap_or_default()` instead of `unwrap()`".to_string()
123            }
124            "expect" => "Use `ok_or(err)?` or `unwrap_or_else(|| default)` instead of `expect()`"
125                .to_string(),
126            _ => format!("Avoid `{method}()` in WASM code"),
127        }
128    }
129
130    /// Get suggestion for a panic macro
131    fn suggestion_for_macro(name: &str) -> String {
132        match name {
133            "panic" => {
134                "Return a Result or use `wasm_bindgen::throw_str` for controlled errors".to_string()
135            }
136            "unreachable" => {
137                "Use `unreachable!()` only when truly unreachable; prefer `debug_assert!`"
138                    .to_string()
139            }
140            "todo" => "Implement the function or return `Err(\"not implemented\")`.to_string()`"
141                .to_string(),
142            "unimplemented" => {
143                "Implement the function or return an error instead of panicking".to_string()
144            }
145            _ => format!("Avoid `{name}!()` in WASM code"),
146        }
147    }
148
149    /// Convert to report
150    #[must_use]
151    pub fn into_report(self, lines_analyzed: usize) -> StateSyncReport {
152        StateSyncReport {
153            errors: self.errors,
154            files_analyzed: 1,
155            lines_analyzed,
156        }
157    }
158
159    /// Get errors
160    #[must_use]
161    pub fn errors(&self) -> &[LintError] {
162        &self.errors
163    }
164}
165
166impl<'ast> Visit<'ast> for PanicPathVisitor {
167    fn visit_item_mod(&mut self, node: &'ast syn::ItemMod) {
168        // Check if this is a test module
169        let is_test = node.attrs.iter().any(|attr| {
170            attr.path().is_ident("cfg")
171                && attr
172                    .meta
173                    .require_list()
174                    .ok()
175                    .and_then(|list| list.parse_args::<syn::Ident>().ok())
176                    .is_some_and(|ident| ident == "test")
177        });
178
179        let was_in_test = self.in_test_module;
180        if is_test {
181            self.in_test_module = true;
182        }
183
184        syn::visit::visit_item_mod(self, node);
185
186        self.in_test_module = was_in_test;
187    }
188
189    fn visit_expr_unsafe(&mut self, node: &'ast syn::ExprUnsafe) {
190        let was_unsafe = self.in_unsafe_block;
191        self.in_unsafe_block = true;
192        syn::visit::visit_expr_unsafe(self, node);
193        self.in_unsafe_block = was_unsafe;
194    }
195
196    fn visit_expr_method_call(&mut self, node: &'ast ExprMethodCall) {
197        // Skip if in test module
198        if self.in_test_module {
199            syn::visit::visit_expr_method_call(self, node);
200            return;
201        }
202
203        let method_name = node.method.to_string();
204
205        if Self::is_panic_method(&method_name) {
206            let line = self.span_to_line(node.method.span());
207            let column = self.span_to_column(node.method.span());
208            let line_content = self.get_line_content(line);
209
210            // Check for allow attribute on the line (basic heuristic)
211            let has_allow = line_content.contains("#[allow(")
212                || line_content.contains("// SAFETY:")
213                || line_content.contains("// PANIC:");
214
215            if !has_allow {
216                let rule = match method_name.as_str() {
217                    "unwrap" => "WASM-PANIC-001",
218                    "expect" => "WASM-PANIC-002",
219                    _ => "WASM-PANIC-000",
220                };
221
222                self.errors.push(LintError {
223                    rule: rule.to_string(),
224                    message: format!(
225                        "`{method_name}()` can panic, which terminates WASM execution"
226                    ),
227                    file: self.file.clone(),
228                    line,
229                    column,
230                    severity: LintSeverity::Error,
231                    suggestion: Some(Self::suggestion_for_method(&method_name)),
232                });
233            }
234        }
235
236        syn::visit::visit_expr_method_call(self, node);
237    }
238
239    fn visit_macro(&mut self, node: &'ast Macro) {
240        // Skip if in test module
241        if self.in_test_module {
242            syn::visit::visit_macro(self, node);
243            return;
244        }
245
246        if Self::is_panic_macro(&node.path) {
247            let macro_name = node
248                .path
249                .segments
250                .last()
251                .map(|s| s.ident.to_string())
252                .unwrap_or_default();
253
254            let line = self.span_to_line(
255                node.path
256                    .segments
257                    .first()
258                    .map_or_else(|| proc_macro2::Span::call_site(), |s| s.ident.span()),
259            );
260            let column = self.span_to_column(
261                node.path
262                    .segments
263                    .first()
264                    .map_or_else(|| proc_macro2::Span::call_site(), |s| s.ident.span()),
265            );
266
267            let rule = match macro_name.as_str() {
268                "panic" => "WASM-PANIC-003",
269                "unreachable" => "WASM-PANIC-004",
270                "todo" => "WASM-PANIC-005",
271                "unimplemented" => "WASM-PANIC-006",
272                _ => "WASM-PANIC-000",
273            };
274
275            self.errors.push(LintError {
276                rule: rule.to_string(),
277                message: format!("`{macro_name}!()` panics, which terminates WASM execution"),
278                file: self.file.clone(),
279                line,
280                column,
281                severity: Self::macro_severity(&macro_name),
282                suggestion: Some(Self::suggestion_for_macro(&macro_name)),
283            });
284        }
285
286        syn::visit::visit_macro(self, node);
287    }
288
289    fn visit_expr_index(&mut self, node: &'ast syn::ExprIndex) {
290        // Skip if in test module or unsafe block
291        if self.in_test_module || self.in_unsafe_block {
292            syn::visit::visit_expr_index(self, node);
293            return;
294        }
295
296        // Direct indexing like arr[i] can panic
297        let line = self.span_to_line(node.bracket_token.span.open());
298        let column = self.span_to_column(node.bracket_token.span.open());
299
300        self.errors.push(LintError {
301            rule: "WASM-PANIC-007".to_string(),
302            message: "Direct indexing can panic on out-of-bounds access".to_string(),
303            file: self.file.clone(),
304            line,
305            column,
306            severity: LintSeverity::Warning,
307            suggestion: Some("Use `.get(index)` with proper error handling instead".to_string()),
308        });
309
310        syn::visit::visit_expr_index(self, node);
311    }
312}
313
314/// Lint source code for panic paths
315///
316/// # Arguments
317/// * `source` - Rust source code to analyze
318/// * `file` - File name for error reporting
319///
320/// # Returns
321/// A report containing all panic path violations found
322///
323/// # Errors
324/// Returns error if source cannot be parsed
325pub fn lint_panic_paths(source: &str, file: &str) -> Result<StateSyncReport, String> {
326    let syntax = syn::parse_file(source).map_err(|e| format!("Parse error: {e}"))?;
327
328    let lines = source.lines().count();
329    let mut visitor = PanicPathVisitor::new(file.to_string(), source.to_string());
330
331    visitor.visit_file(&syntax);
332
333    Ok(visitor.into_report(lines))
334}
335
336/// Summary of panic path analysis
337#[derive(Debug, Default)]
338pub struct PanicPathSummary {
339    /// Total unwrap() calls
340    pub unwrap_count: usize,
341    /// Total expect() calls
342    pub expect_count: usize,
343    /// Total panic!() macros
344    pub panic_count: usize,
345    /// Total unreachable!() macros
346    pub unreachable_count: usize,
347    /// Total todo!() macros
348    pub todo_count: usize,
349    /// Total unimplemented!() macros
350    pub unimplemented_count: usize,
351    /// Total index operations
352    pub index_count: usize,
353}
354
355impl PanicPathSummary {
356    /// Create summary from report
357    #[must_use]
358    pub fn from_report(report: &StateSyncReport) -> Self {
359        let mut summary = Self::default();
360
361        for error in &report.errors {
362            match error.rule.as_str() {
363                "WASM-PANIC-001" => summary.unwrap_count += 1,
364                "WASM-PANIC-002" => summary.expect_count += 1,
365                "WASM-PANIC-003" => summary.panic_count += 1,
366                "WASM-PANIC-004" => summary.unreachable_count += 1,
367                "WASM-PANIC-005" => summary.todo_count += 1,
368                "WASM-PANIC-006" => summary.unimplemented_count += 1,
369                "WASM-PANIC-007" => summary.index_count += 1,
370                _ => {}
371            }
372        }
373
374        summary
375    }
376
377    /// Total panic path count
378    #[must_use]
379    pub fn total(&self) -> usize {
380        self.unwrap_count
381            + self.expect_count
382            + self.panic_count
383            + self.unreachable_count
384            + self.todo_count
385            + self.unimplemented_count
386            + self.index_count
387    }
388
389    /// Total error-level count (excludes warnings)
390    #[must_use]
391    pub fn error_count(&self) -> usize {
392        self.unwrap_count
393            + self.expect_count
394            + self.panic_count
395            + self.todo_count
396            + self.unimplemented_count
397    }
398}
399
400#[cfg(test)]
401mod tests {
402    use super::*;
403
404    #[test]
405    fn test_detect_unwrap() {
406        let source = r#"
407            fn example() {
408                let x = Some(5);
409                let y = x.unwrap();
410            }
411        "#;
412
413        let report = lint_panic_paths(source, "test.rs").expect("parse failed");
414        assert!(!report.errors.is_empty());
415        assert!(report.errors.iter().any(|e| e.rule == "WASM-PANIC-001"));
416    }
417
418    #[test]
419    fn test_detect_expect() {
420        let source = r#"
421            fn example() {
422                let x = Some(5);
423                let y = x.expect("should exist");
424            }
425        "#;
426
427        let report = lint_panic_paths(source, "test.rs").expect("parse failed");
428        assert!(report.errors.iter().any(|e| e.rule == "WASM-PANIC-002"));
429    }
430
431    #[test]
432    fn test_detect_panic_macro() {
433        let source = r#"
434            fn example() {
435                panic!("something went wrong");
436            }
437        "#;
438
439        let report = lint_panic_paths(source, "test.rs").expect("parse failed");
440        assert!(report.errors.iter().any(|e| e.rule == "WASM-PANIC-003"));
441    }
442
443    #[test]
444    fn test_detect_unreachable() {
445        let source = r#"
446            fn example(x: bool) {
447                if x {
448                    return;
449                }
450                unreachable!();
451            }
452        "#;
453
454        let report = lint_panic_paths(source, "test.rs").expect("parse failed");
455        assert!(report.errors.iter().any(|e| e.rule == "WASM-PANIC-004"));
456        // unreachable should be a warning, not error
457        let unreachable_error = report
458            .errors
459            .iter()
460            .find(|e| e.rule == "WASM-PANIC-004")
461            .unwrap();
462        assert_eq!(unreachable_error.severity, LintSeverity::Warning);
463    }
464
465    #[test]
466    fn test_detect_todo() {
467        let source = r#"
468            fn example() {
469                todo!("implement this");
470            }
471        "#;
472
473        let report = lint_panic_paths(source, "test.rs").expect("parse failed");
474        assert!(report.errors.iter().any(|e| e.rule == "WASM-PANIC-005"));
475    }
476
477    #[test]
478    fn test_detect_unimplemented() {
479        let source = r#"
480            fn example() {
481                unimplemented!();
482            }
483        "#;
484
485        let report = lint_panic_paths(source, "test.rs").expect("parse failed");
486        assert!(report.errors.iter().any(|e| e.rule == "WASM-PANIC-006"));
487    }
488
489    #[test]
490    fn test_detect_index() {
491        let source = r#"
492            fn example() {
493                let arr = [1, 2, 3];
494                let x = arr[0];
495            }
496        "#;
497
498        let report = lint_panic_paths(source, "test.rs").expect("parse failed");
499        assert!(report.errors.iter().any(|e| e.rule == "WASM-PANIC-007"));
500    }
501
502    #[test]
503    fn test_skip_in_test_module() {
504        let source = r#"
505            #[cfg(test)]
506            mod tests {
507                fn test_example() {
508                    let x = Some(5);
509                    let y = x.unwrap();  // Should be skipped
510                }
511            }
512        "#;
513
514        let report = lint_panic_paths(source, "test.rs").expect("parse failed");
515        // Should have no errors since we're in a test module
516        assert!(
517            report.errors.is_empty(),
518            "Test modules should be skipped: {:?}",
519            report.errors
520        );
521    }
522
523    #[test]
524    fn test_summary() {
525        let source = r#"
526            fn example() {
527                let x = Some(5);
528                x.unwrap();
529                x.unwrap();
530                x.expect("msg");
531                panic!("oops");
532                todo!();
533            }
534        "#;
535
536        let report = lint_panic_paths(source, "test.rs").expect("parse failed");
537        let summary = PanicPathSummary::from_report(&report);
538
539        assert_eq!(summary.unwrap_count, 2);
540        assert_eq!(summary.expect_count, 1);
541        assert_eq!(summary.panic_count, 1);
542        assert_eq!(summary.todo_count, 1);
543        assert_eq!(summary.total(), 5);
544    }
545
546    #[test]
547    fn test_clean_code_passes() {
548        let source = r#"
549            fn example() -> Option<i32> {
550                let x = Some(5);
551                let y = x?;
552                Some(y + 1)
553            }
554
555            fn example2() -> Result<i32, &'static str> {
556                let x: Option<i32> = Some(5);
557                let y = x.ok_or("missing")?;
558                Ok(y + 1)
559            }
560        "#;
561
562        let report = lint_panic_paths(source, "test.rs").expect("parse failed");
563        // Clean code should have no panic path errors
564        assert!(
565            report.errors.is_empty(),
566            "Clean code should pass: {:?}",
567            report.errors
568        );
569    }
570}