Skip to content

Commit 8b12fec

Browse files
hyperpolymathclaude
andcommitted
test(corpus): 12-case UnboundedAllocation regression corpus (Task #25)
Locks in the detector's TP/TN verdicts against concrete Rust source fixtures. Each case carries a pre-declared expected outcome so future detector changes can't quietly regress either: TP — detector MUST fire: tp_unbounded_read_to_string fs::read_to_string, no bound tp_unbounded_read_to_end File::read_to_end, no bound tp_bare_unbounded_identifier fn unbounded() { ... } tp_with_capacity_tiny Vec::with_capacity(0) + push loop tp_infinite_bare_keyword bare `infinite` variable TN — detector MUST NOT fire: tn_bounded_take_before_read_to_string .take(LIMIT)...read_* tn_tokio_unbounded_channel_substring tokio unbounded_channel tn_has_unbounded_allocations_variable_substring self-reference FP tn_f64_is_infinite_negation f64::is_infinite() tn_delimiter_does_not_disarm_unbounded `delimiter` FN lock tn_case_insensitive_uppercase_limit_const (?i)\blimit regex tn_test_module_strip #[cfg(test)] mod The `delimiter` case (tn name, but it's really a TP — `delimiter` contains "limit" substring but doesn't disarm) is the regression lock for the FN exposed by the word-boundary refactor: the old substring check `contains("limit")` was silently disarming any file containing `value_delimiter`. New \blimit regex correctly still fires on genuinely unbounded reads in those files. 12/12 tests pass. 190/190 lib tests still pass. This forms the initial reference corpus for Task #25's zero-FN goal. Grow it by adding one case per detector change — either a new TP proving we catch a missed pattern, or a new TN proving a claimed FP is actually suppressed. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 9126b98 commit 8b12fec

1 file changed

Lines changed: 233 additions & 0 deletions

File tree

tests/unbounded_corpus.rs

Lines changed: 233 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,233 @@
1+
// SPDX-License-Identifier: PMPL-1.0-or-later
2+
// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk>
3+
4+
//! Curated corpus for the UnboundedAllocation detector (PA015).
5+
//!
6+
//! Each case is a minimal Rust source with a pre-declared expected
7+
//! verdict: does the detector correctly fire (or correctly not fire)
8+
//! on this input? These are regression locks — they enforce the
9+
//! detector's "zero-FN on corpus" property (Task #25 of the assail
10+
//! narrow-path plan).
11+
//!
12+
//! When adding a new case, classify it in one of three buckets:
13+
//!
14+
//! TP — True Positive: detector SHOULD fire (real unbounded alloc)
15+
//! TN — True Negative: detector SHOULD NOT fire (bounded / irrelevant)
16+
//! FN — False Negative we explicitly don't yet catch (xfail) —
17+
//! document the pattern and file an issue; the test is
18+
//! `#[ignore]` until the detector improves.
19+
//!
20+
//! FP (False Positive) cases do not get their own bucket — they're
21+
//! handled by narrowing the detector and appearing in the TN bucket.
22+
23+
use panic_attack::assail;
24+
use panic_attack::types::WeakPointCategory;
25+
use std::io::Write;
26+
27+
/// Helper: write `src` to a tempdir as `case.rs`, analyse the dir,
28+
/// return the count of UnboundedAllocation findings.
29+
fn unbounded_count_for(src: &str) -> usize {
30+
let dir = tempfile::tempdir().expect("tempdir");
31+
let file = dir.path().join("case.rs");
32+
std::fs::File::create(&file)
33+
.unwrap()
34+
.write_all(src.as_bytes())
35+
.unwrap();
36+
let report = assail::analyze(dir.path()).expect("analyze");
37+
report
38+
.weak_points
39+
.iter()
40+
.filter(|wp| wp.category == WeakPointCategory::UnboundedAllocation)
41+
.count()
42+
}
43+
44+
fn assert_fires(src: &str, msg: &str) {
45+
let n = unbounded_count_for(src);
46+
assert!(n >= 1, "TP case must fire {}: got {} findings", msg, n);
47+
}
48+
49+
fn assert_does_not_fire(src: &str, msg: &str) {
50+
let n = unbounded_count_for(src);
51+
assert_eq!(n, 0, "TN case must NOT fire {}: got {} findings", msg, n);
52+
}
53+
54+
// ─────────────────────────────────────────────────────────────────────
55+
// True positive corpus — detector MUST fire on each of these
56+
// ─────────────────────────────────────────────────────────────────────
57+
58+
#[test]
59+
fn tp_unbounded_read_to_string() {
60+
assert_fires(
61+
r#"
62+
pub fn slurp(p: &str) -> std::io::Result<String> {
63+
std::fs::read_to_string(p)
64+
}
65+
"#,
66+
"unbounded fs::read_to_string",
67+
);
68+
}
69+
70+
#[test]
71+
fn tp_unbounded_read_to_end() {
72+
assert_fires(
73+
r#"
74+
use std::io::Read;
75+
pub fn slurp(p: &str) -> std::io::Result<Vec<u8>> {
76+
let mut buf = Vec::new();
77+
std::fs::File::open(p)?.read_to_end(&mut buf)?;
78+
Ok(buf)
79+
}
80+
"#,
81+
"unbounded File::read_to_end",
82+
);
83+
}
84+
85+
#[test]
86+
fn tp_bare_unbounded_identifier() {
87+
assert_fires(
88+
r#"
89+
pub fn unbounded() -> Vec<u8> { Vec::new() }
90+
"#,
91+
"bare fn unbounded()",
92+
);
93+
}
94+
95+
#[test]
96+
fn tp_with_capacity_tiny() {
97+
assert_fires(
98+
r#"
99+
pub fn make() -> Vec<u8> {
100+
let mut v = Vec::with_capacity(0);
101+
for i in 0..1_000_000 { v.push(i as u8); }
102+
v
103+
}
104+
"#,
105+
"Vec::with_capacity(0) followed by push loop",
106+
);
107+
}
108+
109+
#[test]
110+
fn tp_infinite_bare_keyword() {
111+
assert_fires(
112+
r#"
113+
pub fn run(n: u64) -> u64 {
114+
let infinite = n;
115+
infinite
116+
}
117+
"#,
118+
"bare `infinite` keyword (no is_infinite negation)",
119+
);
120+
}
121+
122+
// ─────────────────────────────────────────────────────────────────────
123+
// True negative corpus — detector MUST NOT fire
124+
// ─────────────────────────────────────────────────────────────────────
125+
126+
#[test]
127+
fn tn_bounded_take_before_read_to_string() {
128+
assert_does_not_fire(
129+
r#"
130+
use std::io::Read;
131+
const LIMIT: u64 = 64 * 1024 * 1024;
132+
pub fn slurp(p: &str) -> std::io::Result<String> {
133+
let mut buf = String::new();
134+
std::fs::File::open(p)?.take(LIMIT).read_to_string(&mut buf)?;
135+
Ok(buf)
136+
}
137+
"#,
138+
".take(LIMIT) bound + LIMIT const both disarm",
139+
);
140+
}
141+
142+
#[test]
143+
fn tn_tokio_unbounded_channel_substring() {
144+
assert_does_not_fire(
145+
r#"
146+
pub fn pipe() {
147+
let (_tx, _rx) = tokio::sync::mpsc::unbounded_channel::<u8>();
148+
}
149+
"#,
150+
"tokio::mpsc::unbounded_channel (unbounded in identifier)",
151+
);
152+
}
153+
154+
#[test]
155+
fn tn_has_unbounded_allocations_variable_substring() {
156+
assert_does_not_fire(
157+
r#"
158+
pub fn analyze(body: &str) -> bool {
159+
let has_unbounded_allocations = body.len() > 0;
160+
let unbounded_vec_patterns = 0usize;
161+
has_unbounded_allocations && unbounded_vec_patterns == 0
162+
}
163+
"#,
164+
"detector self-reference (identifier with `unbounded_` prefix)",
165+
);
166+
}
167+
168+
#[test]
169+
fn tn_f64_is_infinite_negation() {
170+
assert_does_not_fire(
171+
r#"
172+
pub fn check(x: f64) -> bool {
173+
x.is_infinite()
174+
}
175+
"#,
176+
"f64::is_infinite is benign — negation disarms `infinite` keyword",
177+
);
178+
}
179+
180+
#[test]
181+
fn tn_delimiter_does_not_disarm_unbounded() {
182+
// `value_delimiter` CONTAINS the substring "limit" but is NOT a
183+
// bounded-read marker. The word-boundary regex must not disarm
184+
// the read_to_string check here — genuine unbounded allocation.
185+
assert_fires(
186+
r#"
187+
#[arg(short, long, value_delimiter = ',')]
188+
pub fn fake_clap_arg() {}
189+
190+
pub fn slurp(p: &str) -> std::io::Result<String> {
191+
std::fs::read_to_string(p)
192+
}
193+
"#,
194+
"`delimiter` contains 'limit' substring but does NOT disarm — \
195+
old substring contains(\"limit\") was the FN source; \
196+
new \\blimit regex correctly still fires",
197+
);
198+
}
199+
200+
#[test]
201+
fn tn_case_insensitive_uppercase_limit_const_disarms() {
202+
assert_does_not_fire(
203+
r#"
204+
use std::io::Read;
205+
const READ_LIMIT: u64 = 4096;
206+
pub fn slurp(p: &str) -> std::io::Result<String> {
207+
let mut buf = String::new();
208+
std::fs::File::open(p)?.take(READ_LIMIT).read_to_string(&mut buf)?;
209+
Ok(buf)
210+
}
211+
"#,
212+
"uppercase const LIMIT disarms via (?i) flag",
213+
);
214+
}
215+
216+
#[test]
217+
fn tn_test_module_strip() {
218+
assert_does_not_fire(
219+
r#"
220+
pub fn prod() -> i64 { 42 }
221+
222+
#[cfg(test)]
223+
mod tests {
224+
#[test]
225+
fn choreography_unbounded_loop() {
226+
assert_eq!(1, 1);
227+
}
228+
}
229+
"#,
230+
"#[cfg(test)] mod tests body is stripped; unbounded keyword \
231+
inside test identifier must not fire",
232+
);
233+
}

0 commit comments

Comments
 (0)