Skip to content

Commit 75ddb56

Browse files
Haofeiclaude
andcommitted
Add hostile-input / fuzz suite (review #8)
The front end is the first boundary for untrusted / AI-generated source, so it must never panic and should fail closed. Adds tests/hostile.rs: - tests/corpus/malformed/ — unterminated string/multiline, unbalanced parens, bad native decl, unicode bidi, NUL-in-ident, out-of-range int. Every file must not panic; non-`gap-` files must also report a diagnostic. - adversarial inline strings (bidi overrides, deep nesting, bad hex, big repeats). - proptest fuzz targets: arbitrary text, arbitrary UTF-8 bytes, and RSScript token-soup all run through analyze_source without panicking (proptest shrinks any crash to a minimal reproducer). Found by the suite: an integer literal larger than i64 is currently accepted with no diagnostic (recorded as tests/corpus/malformed/gap-out-of-range-int.rss; front-end range validation is the follow-up fix). The complement — REIR capability-bypass cases — already lives in reir reconciliation tests. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 21f3979 commit 75ddb56

8 files changed

Lines changed: 108 additions & 0 deletions
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
native fn X.y(a: read String -> String
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
// KNOWN GAP (found by fuzz): an integer literal larger than i64 is
2+
// currently accepted with no diagnostic. Tracked for a checker range-check.
3+
fn f() -> Int {
4+
return 99999999999999999999999999999999999999
5+
}
35 Bytes
Binary file not shown.
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
fn f(((((((((((((((((((((
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
struct ‮‭ Weird {}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
fn f() {
2+
let s = """
3+
unterminated multiline
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
"unterminated string literal

tests/hostile.rs

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
//! Hostile-input suite: the front end is the first supply-chain boundary for
2+
//! AI-generated and untrusted source, so it must never panic and must fail
3+
//! closed (produce diagnostics) on malformed input rather than silently
4+
//! yielding a "clean" partial result.
5+
6+
use proptest::prelude::*;
7+
8+
/// Analyze every file under tests/corpus/malformed/. None may panic. Files not
9+
/// prefixed `gap-` must also fail closed (report a diagnostic); `gap-` files are
10+
/// known fail-open gaps the fuzzer surfaced (still must not panic).
11+
#[test]
12+
fn malformed_corpus_never_panics_and_fails_closed() {
13+
let dir = std::path::Path::new("tests/corpus/malformed");
14+
let mut entries: Vec<_> = std::fs::read_dir(dir)
15+
.expect("malformed corpus dir exists")
16+
.filter_map(|e| e.ok().map(|e| e.path()))
17+
.filter(|p| p.extension().map(|x| x == "rss").unwrap_or(false))
18+
.collect();
19+
entries.sort();
20+
assert!(!entries.is_empty(), "malformed corpus is empty");
21+
22+
for path in entries {
23+
let source = std::fs::read_to_string(&path).unwrap_or_default();
24+
let name = path.display().to_string();
25+
let file_name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
26+
let result = std::panic::catch_unwind(|| rsscript::analyze_source(&name, &source));
27+
assert!(result.is_ok(), "analyzer panicked on malformed input {name}");
28+
let diagnostics = result.unwrap();
29+
if !file_name.starts_with("gap-") {
30+
assert!(
31+
!diagnostics.is_empty(),
32+
"malformed input {name} produced no diagnostics (should fail closed)"
33+
);
34+
}
35+
}
36+
}
37+
38+
/// A few explicit adversarial strings (kept inline so the intent is visible).
39+
#[test]
40+
fn adversarial_strings_do_not_panic() {
41+
let inputs = [
42+
"",
43+
"\"",
44+
"\u{202e}\u{202d}",
45+
"let x = \"\\(",
46+
"fn f() { match x {",
47+
"\u{0}\u{0}\u{0}",
48+
"fn f() -> Int { return 0x }",
49+
&"(".repeat(5000),
50+
&"fn f(){}\n".repeat(2000),
51+
];
52+
for (index, source) in inputs.iter().enumerate() {
53+
let result =
54+
std::panic::catch_unwind(|| rsscript::analyze_source("adversarial.rss", source));
55+
assert!(
56+
result.is_ok(),
57+
"analyzer panicked on adversarial input #{index}: {source:?}"
58+
);
59+
}
60+
}
61+
62+
proptest! {
63+
// proptest catches panics and shrinks to a minimal reproducer, so a bare
64+
// call is the fuzz target: any string the generator produces must not crash
65+
// the front end.
66+
#![proptest_config(ProptestConfig { cases: 400, ..ProptestConfig::default() })]
67+
68+
#[test]
69+
fn arbitrary_text_never_panics_the_front_end(source in ".{0,400}") {
70+
let _ = rsscript::analyze_source("fuzz.rss", &source);
71+
}
72+
73+
#[test]
74+
fn arbitrary_utf8_bytes_never_panic(bytes in proptest::collection::vec(any::<u8>(), 0..400)) {
75+
if let Ok(source) = String::from_utf8(bytes) {
76+
let _ = rsscript::analyze_source("fuzz.rss", &source);
77+
}
78+
}
79+
80+
// Source biased toward RSScript tokens is more likely to reach deep parser
81+
// and checker paths.
82+
#[test]
83+
fn token_soup_never_panics(
84+
tokens in proptest::collection::vec(
85+
prop::sample::select(vec![
86+
"fn", "let", "return", "struct", "native", "effects", "read", "mut",
87+
"take", "fresh", "match", "(", ")", "{", "}", "<", ">", ":", ",",
88+
"->", "Int", "String", "x", "\"", "|", "=", "0", "99999999999",
89+
]),
90+
0..80,
91+
),
92+
) {
93+
let source = tokens.join(" ");
94+
let _ = rsscript::analyze_source("fuzz.rss", &source);
95+
}
96+
}

0 commit comments

Comments
 (0)