Skip to content

Commit 7d3cf4b

Browse files
committed
fix: make production panic controls falsifiable
1 parent 774dceb commit 7d3cf4b

3 files changed

Lines changed: 242 additions & 127 deletions

File tree

src/assail/analyzer.rs

Lines changed: 151 additions & 87 deletions
Original file line numberDiff line numberDiff line change
@@ -949,27 +949,14 @@ impl Analyzer {
949949
weak_points: &mut Vec<WeakPoint>,
950950
file_path: &str,
951951
) -> Result<()> {
952-
// Detect if this is a test file - Expanded patterns
953-
let is_test_file = file_path.contains("_tests.")
954-
|| file_path.contains("/tests/")
955-
|| file_path.ends_with("_test.rs")
956-
|| file_path.starts_with("tests/")
957-
|| file_path.contains("/test_")
958-
|| file_path.contains("_spec.") // RSpec, Jest patterns
959-
|| file_path.contains("_bench.") // Benchmark files
960-
|| file_path.contains("/benches/") // Rust bench directory
961-
|| file_path.ends_with("_bench.rs")
962-
|| file_path.contains("__tests__/") // JavaScript
963-
|| file_path.contains("/__tests__/")
964-
|| file_path.ends_with(".test.") // Generic test files
965-
|| file_path.ends_with(".spec.")
966-
|| file_path.contains("_integration.") // Integration tests
967-
|| file_path.contains("_e2e.") // End-to-end tests
968-
|| file_path.contains("_unit.") // Unit tests
969-
|| file_path.contains("/examples/") // Example code (often test-like)
970-
|| file_path.contains("/samples/") // Sample code
971-
|| file_path.contains("_mock.") // Mock files
972-
|| file_path.contains("_stub."); // Stub files
952+
// Use the canonical cross-language classifier rather than a second,
953+
// subtly different Rust-only heuristic. Build scripts are also
954+
// excluded from the panic surface: a build-script panic is a build
955+
// hard-stop, not a production runtime panic in the crate being
956+
// scanned.
957+
let is_test_file = crate::test_context::is_test_path(file_path);
958+
let is_build_script = file_path == "build.rs" || file_path.ends_with("/build.rs");
959+
let panic_surface_excluded = is_test_file || is_build_script;
973960

974961
// Strip string literal contents AND comments before counting so that
975962
// detection-tool source files (which embed patterns as string literals)
@@ -997,7 +984,9 @@ impl Analyzer {
997984
// crypto counts too (a `#[test] fn exercises_unsafe_wrapper()`
998985
// inside a production file would otherwise count toward that
999986
// file's unsafe-block total).
1000-
let code_only = Self::strip_cfg_test_modules_rs(&without_comments);
987+
let code_only = Self::strip_should_panic_items_rs(
988+
&Self::strip_cfg_test_modules_rs(&without_comments),
989+
);
1001990

1002991
stats.unsafe_blocks += code_only.matches("unsafe {").count();
1003992
stats.unsafe_blocks += code_only.matches("unsafe fn").count();
@@ -1016,24 +1005,12 @@ impl Analyzer {
10161005
let unwrap_calls = raw_unwrap + code_only.matches(".expect(").count();
10171006
let safe_unwrap_calls = safe_unwrap;
10181007

1019-
// Apply test file suppression. In test files, normal
1020-
// assert-macro use pushes panic/unwrap counts high with no
1021-
// production-code signal, so we suppress the counts unless they
1022-
// exceed a "clearly excessive" threshold — only the delta above
1023-
// the threshold is attributed to the file's statistics.
1024-
//
1025-
// Panic and unwrap thresholds are evaluated independently: a
1026-
// test file with 30 panics + 5 unwraps should report 10 panics
1027-
// (30 − 20) and 0 unwraps, not suppress everything because the
1028-
// unwrap count is normal. The previous version declared both
1029-
// thresholds but only compared panics, silently dropping any
1030-
// excessive unwrap signal.
1031-
let (effective_panic_sites, effective_unwrap_calls) = if is_test_file {
1032-
let panic_threshold = 20;
1033-
let unwrap_threshold = 10;
1034-
let eff_panics = panic_sites.saturating_sub(panic_threshold);
1035-
let eff_unwraps = unwrap_calls.saturating_sub(unwrap_threshold);
1036-
(eff_panics, eff_unwraps)
1008+
// Test, benchmark, #[should_panic], and build-script code is not part
1009+
// of the production panic surface. Suppress it completely; a
1010+
// threshold-based exception makes a sufficiently large test suite
1011+
// look like production code and defeats the precision contract.
1012+
let (effective_panic_sites, effective_unwrap_calls) = if panic_surface_excluded {
1013+
(0, 0)
10371014
} else {
10381015
(panic_sites, unwrap_calls) // Production code: count all
10391016
};
@@ -1138,16 +1115,16 @@ impl Analyzer {
11381115
});
11391116
}
11401117

1141-
if stats.unwrap_calls > 5 {
1118+
if effective_panic_sites > 0 || effective_unwrap_calls > 0 {
11421119
weak_points.push(WeakPoint {
11431120
file: None,
11441121
line: None,
11451122
category: WeakPointCategory::PanicPath,
11461123
location: Some(file_path.to_string()),
11471124
severity: Severity::Medium,
11481125
description: format!(
1149-
"{} unwrap/expect calls in {}",
1150-
stats.unwrap_calls, file_path
1126+
"{} panic sites and {} unwrap/expect calls in {}",
1127+
effective_panic_sites, effective_unwrap_calls, file_path
11511128
),
11521129
recommended_attack: vec![AttackAxis::Memory, AttackAxis::Disk],
11531130
suppressed: false,
@@ -1543,14 +1520,15 @@ impl Analyzer {
15431520
out
15441521
}
15451522

1546-
/// Strip the bodies of `#[cfg(test)] mod <name> { … }` blocks from
1547-
/// `content`, leaving the attribute, `mod` keyword, name, and
1548-
/// enclosing braces in place but replacing everything between the
1523+
/// Strip the bodies of `#[cfg(test)]` modules and functions from
1524+
/// `content`, leaving the attribute, item signature, and enclosing
1525+
/// braces in place but replacing everything between the
15491526
/// braces with whitespace. Newlines in the body are preserved so
15501527
/// line numbers downstream stay stable.
15511528
///
15521529
/// This is the Rust analogue of Zig's `count_unsafe_in_test_blocks`
1553-
/// and is used to treat an inline `#[cfg(test)] mod tests { … }`
1530+
/// and is used to treat an inline `#[cfg(test)] mod tests { … }` or
1531+
/// `#[cfg(test)] fn test() { … }`
15541532
/// inside a production file as test context for substring-based
15551533
/// dangerous-pattern checks — the same way a whole file under
15561534
/// `/tests/` is already treated as test context by `is_test_file`.
@@ -1628,26 +1606,42 @@ impl Analyzer {
16281606
}
16291607
}
16301608

1631-
if k + 4 > n || &bytes[k..k + 4] != b"mod " {
1609+
let is_mod = k + 4 <= n && &bytes[k..k + 4] == b"mod ";
1610+
let is_fn = (k + 3 <= n && &bytes[k..k + 3] == b"fn ")
1611+
|| (k + 8 <= n && &bytes[k..k + 8] == b"async fn");
1612+
if !is_mod && !is_fn {
16321613
out.extend_from_slice(&bytes[attr_start..attr_end]);
16331614
i = attr_end;
16341615
continue;
16351616
}
1636-
k += 4;
1637-
while k < n && (bytes[k] as char).is_whitespace() {
1638-
k += 1;
1639-
}
1640-
while k < n && (bytes[k].is_ascii_alphanumeric() || bytes[k] == b'_') {
1641-
k += 1;
1642-
}
1643-
while k < n && (bytes[k] as char).is_whitespace() {
1644-
k += 1;
1645-
}
1646-
1647-
if k >= n || bytes[k] != b'{' {
1648-
out.extend_from_slice(&bytes[attr_start..attr_end]);
1649-
i = attr_end;
1650-
continue;
1617+
if is_mod {
1618+
k += 4;
1619+
while k < n && (bytes[k] as char).is_whitespace() {
1620+
k += 1;
1621+
}
1622+
while k < n && (bytes[k].is_ascii_alphanumeric() || bytes[k] == b'_') {
1623+
k += 1;
1624+
}
1625+
while k < n && (bytes[k] as char).is_whitespace() {
1626+
k += 1;
1627+
}
1628+
if k >= n || bytes[k] != b'{' {
1629+
out.extend_from_slice(&bytes[attr_start..attr_end]);
1630+
i = attr_end;
1631+
continue;
1632+
}
1633+
} else {
1634+
// A function signature has parameters and possibly a return
1635+
// type between its name and body; scan to the first body
1636+
// brace while leaving declarations without a body untouched.
1637+
while k < n && bytes[k] != b'{' && bytes[k] != b';' {
1638+
k += 1;
1639+
}
1640+
if k >= n || bytes[k] != b'{' {
1641+
out.extend_from_slice(&bytes[attr_start..attr_end]);
1642+
i = attr_end;
1643+
continue;
1644+
}
16511645
}
16521646

16531647
let body_start = k + 1;
@@ -1678,6 +1672,78 @@ impl Analyzer {
16781672
String::from_utf8_lossy(&out).into_owned()
16791673
}
16801674

1675+
/// Strip the bodies of `#[should_panic]` items from Rust source while
1676+
/// preserving line numbers. `#[should_panic]` is meaningful on test
1677+
/// functions even when the function is not inside a `#[cfg(test)]` module
1678+
/// or a conventional `tests/` path.
1679+
fn strip_should_panic_items_rs(content: &str) -> String {
1680+
let bytes = content.as_bytes();
1681+
let n = bytes.len();
1682+
let mut out = Vec::with_capacity(n);
1683+
let mut i = 0;
1684+
1685+
while i < n {
1686+
if i + 14 > n || &bytes[i..i + 14] != b"#[should_panic" {
1687+
out.push(bytes[i]);
1688+
i += 1;
1689+
continue;
1690+
}
1691+
1692+
let attr_start = i;
1693+
let mut attr_end = i + 14;
1694+
while attr_end < n && bytes[attr_end] != b']' {
1695+
attr_end += 1;
1696+
}
1697+
if attr_end >= n {
1698+
out.push(bytes[i]);
1699+
i += 1;
1700+
continue;
1701+
}
1702+
attr_end += 1;
1703+
1704+
let mut body_start = attr_end;
1705+
while body_start < n && (bytes[body_start] as char).is_whitespace() {
1706+
body_start += 1;
1707+
}
1708+
let mut brace = body_start;
1709+
while brace < n && bytes[brace] != b'{' && bytes[brace] != b';' {
1710+
brace += 1;
1711+
}
1712+
// A malformed attribute or an item without a body is left
1713+
// untouched so the detector fails open.
1714+
if brace >= n || bytes[brace] != b'{' {
1715+
out.extend_from_slice(&bytes[attr_start..attr_end]);
1716+
i = attr_end;
1717+
continue;
1718+
}
1719+
1720+
let mut depth = 1i32;
1721+
let mut end = brace + 1;
1722+
while end < n && depth > 0 {
1723+
match bytes[end] {
1724+
b'{' => depth += 1,
1725+
b'}' => depth -= 1,
1726+
_ => {}
1727+
}
1728+
end += 1;
1729+
}
1730+
if depth != 0 {
1731+
out.extend_from_slice(&bytes[attr_start..attr_end]);
1732+
i = attr_end;
1733+
continue;
1734+
}
1735+
1736+
out.extend_from_slice(&bytes[attr_start..brace + 1]);
1737+
for byte in &bytes[brace + 1..end - 1] {
1738+
out.push(if *byte == b'\n' { b'\n' } else { b' ' });
1739+
}
1740+
out.push(b'}');
1741+
i = end;
1742+
}
1743+
1744+
String::from_utf8_lossy(&out).into_owned()
1745+
}
1746+
16811747
/// Classify the argument list of `#[cfg(...)]` as selecting for
16821748
/// `test` (true) or not (false). Uses [`strip_not_test_groups`] to
16831749
/// drop `not(test)` groups first, then looks for a bareword `test`.
@@ -6566,26 +6632,18 @@ fn safe_wrapper() {
65666632
}
65676633

65686634
// ---------------------------------------------------------------
6569-
// 5. analyze() on a Rust file containing `.unwrap()` — PanicPath
6635+
// 5. analyze() on a Rust file containing one explicit panic — PanicPath
65706636
// ---------------------------------------------------------------
65716637

65726638
#[test]
6573-
fn analyze_rust_detects_panic_path_from_unwrap() {
6639+
fn analyze_rust_detects_panic_path_from_explicit_panic() {
65746640
let tmp = TempDir::new().unwrap();
6575-
let rust_file = tmp.path().join("unwrappy.rs");
6576-
// The analyzer triggers PanicPath when unwrap_calls > 5,
6577-
// so we need at least 6 unwrap calls.
6641+
let rust_file = tmp.path().join("panic_control.rs");
65786642
fs::write(
65796643
&rust_file,
65806644
r#"
6581-
fn lots_of_unwraps() {
6582-
let a = Some(1).unwrap();
6583-
let b = Some(2).unwrap();
6584-
let c = Some(3).unwrap();
6585-
let d = Some(4).unwrap();
6586-
let e = Some(5).unwrap();
6587-
let f = Some(6).unwrap();
6588-
let g = Some(7).unwrap();
6645+
fn production_positive_control() {
6646+
panic!("production positive control");
65896647
}
65906648
"#,
65916649
)
@@ -6602,7 +6660,7 @@ fn lots_of_unwraps() {
66026660

66036661
assert!(
66046662
!panic_points.is_empty(),
6605-
"Should detect PanicPath weak point when >5 unwrap() calls are present"
6663+
"Should detect a single production panic"
66066664
);
66076665
}
66086666

@@ -6768,21 +6826,19 @@ func main() {
67686826
}
67696827

67706828
// ---------------------------------------------------------------
6771-
// 11. Rust file with few unwraps should NOT trigger PanicPath
6829+
// 11. Rust file with one production unwrap should trigger PanicPath
67726830
// ---------------------------------------------------------------
67736831

67746832
#[test]
6775-
fn analyze_rust_few_unwraps_no_panic_path() {
6833+
fn analyze_rust_single_unwrap_triggers_panic_path() {
67766834
let tmp = TempDir::new().unwrap();
67776835
let rust_file = tmp.path().join("safe.rs");
6778-
// Only 3 unwrap calls — threshold is >5
6836+
// A single production unwrap is sufficient for the recall contract.
67796837
fs::write(
67806838
&rust_file,
67816839
r#"
6782-
fn few_unwraps() {
6840+
fn one_unwrap() {
67836841
let a = Some(1).unwrap();
6784-
let b = Some(2).unwrap();
6785-
let c = Some(3).unwrap();
67866842
}
67876843
"#,
67886844
)
@@ -6797,10 +6853,7 @@ fn few_unwraps() {
67976853
.filter(|wp| wp.category == WeakPointCategory::PanicPath)
67986854
.collect();
67996855

6800-
assert!(
6801-
panic_points.is_empty(),
6802-
"Should NOT trigger PanicPath when unwrap count is <= 5"
6803-
);
6856+
assert!(!panic_points.is_empty(), "A production unwrap must be reported");
68046857
}
68056858

68066859
// ---------------------------------------------------------------
@@ -7519,6 +7572,17 @@ trailing_production_code();
75197572
assert!(out.contains("fn prod"));
75207573
}
75217574

7575+
#[test]
7576+
fn strip_cfg_test_function_and_should_panic_bodies() {
7577+
let src = "#[cfg(test)]\nfn cfg_test() { panic!(\"cfg\"); }\n#[should_panic]\nfn expected() { panic!(\"expected\"); }\nfn prod() { panic!(\"prod\"); }";
7578+
let cfg_stripped = Analyzer::strip_cfg_test_modules_rs(src);
7579+
let out = Analyzer::strip_should_panic_items_rs(&cfg_stripped);
7580+
assert!(!out.contains("panic!(\"cfg\")"));
7581+
assert!(!out.contains("panic!(\"expected\")"));
7582+
assert!(out.contains("panic!(\"prod\")"));
7583+
assert_eq!(out.lines().count(), src.lines().count());
7584+
}
7585+
75227586
#[test]
75237587
fn strip_cfg_test_modules_handles_pub_mod() {
75247588
let src = "\

0 commit comments

Comments
 (0)