Skip to content

Commit 2b0842b

Browse files
committed
Merge harden/fuzz-miri: Invariant-1 no-panic fuzzer + Miri subset + generator fuel fix
2 parents 88cf93e + 3e21263 commit 2b0842b

6 files changed

Lines changed: 1441 additions & 32 deletions

File tree

Makefile

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
# Convenience targets for the RSScript dev container.
2+
#
3+
# Run everything inside the Docker dev environment (see docs/DOCKER.md):
4+
# docker compose run --rm dev make <target>
5+
#
6+
# These are thin wrappers around the cargo invocations documented in
7+
# docs/DEVELOPMENT.md; they exist so CI and contributors invoke the *same*
8+
# command.
9+
10+
.PHONY: miri fuzz-no-panic
11+
12+
# Tier C (runtime hardening): run Miri over the largest pure-Rust subset it can
13+
# soundly interpret — the `rss-testgen` seed decoder (pure arithmetic / control
14+
# flow, no I/O, no FFI). Miri double-checks that subset for undefined behaviour
15+
# (out-of-bounds, use-after-free, invalid-value, data races).
16+
#
17+
# What Miri canNOT cover here, by construction:
18+
# * the vm-jit tier — it executes generated *native machine code*, which Miri
19+
# (a MIR interpreter) cannot run at all;
20+
# * the FFI / syscall seams (native plugin cdylibs, process/network/filesystem
21+
# in `rsscript-runtime` and `reir`) — Miri's isolation blocks real syscalls,
22+
# so those test binaries abort under Miri rather than reporting UB.
23+
# Invariant 2 (`panic = "abort"`) is what keeps those out-of-Miri-scope seams
24+
# safe in production; Miri's job is the pure value/logic core.
25+
#
26+
# Requires a nightly toolchain with the `miri` component:
27+
# rustup toolchain install nightly --component miri,rust-src
28+
miri:
29+
cargo +nightly miri test -p rss-testgen --lib
30+
31+
# Smoke-run the Invariant-1 no-panic fuzz target (requires `cargo install
32+
# cargo-fuzz` and a nightly toolchain). Bounded so it is CI-friendly.
33+
fuzz-no-panic:
34+
cd fuzz && cargo +nightly fuzz run no_panic -- -runs=20000 -max_total_time=60

crates/rss-testgen/src/generator.rs

Lines changed: 19 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -742,40 +742,49 @@ impl<'a> Generator<'a> {
742742

743743
fn gen_expr(&mut self, ty: &Ty, scope: &Scope, fuel: u32) -> String {
744744
if fuel == 0 {
745-
return self.gen_atom(ty, scope);
745+
return self.gen_atom(ty, scope, 0);
746746
}
747747
// Occasionally satisfy an `Int` target with a monomorphized generic call.
748748
if *ty == Ty::Int && !self.generic_fns.is_empty() && fuel > 0 && self.seed.choice(4) == 0 {
749749
return self.gen_generic_call(scope, fuel);
750750
}
751751
match self.seed.weighted(&[3, 2, 4]) {
752-
0 => self.gen_atom(ty, scope),
752+
0 => self.gen_atom(ty, scope, fuel),
753753
1 => self
754754
.gen_call(ty, scope, fuel)
755-
.unwrap_or_else(|| self.gen_atom(ty, scope)),
755+
.unwrap_or_else(|| self.gen_atom(ty, scope, fuel)),
756756
_ => self.gen_compound(ty, scope, fuel),
757757
}
758758
}
759759

760-
/// A leaf: an in-scope variable, or a freshly constructed value.
761-
fn gen_atom(&mut self, ty: &Ty, scope: &Scope) -> String {
760+
/// A leaf: an in-scope variable, or a freshly constructed value. `fuel` is the
761+
/// remaining recursion budget; construction of a compound payload consumes one
762+
/// unit, so at `fuel == 0` only nullary literals/atoms are produced — this is
763+
/// what guarantees termination even when a function returning `T` takes a
764+
/// compound-of-`T` parameter (the `T -> Option<T> -> T -> …` cycle).
765+
fn gen_atom(&mut self, ty: &Ty, scope: &Scope, fuel: u32) -> String {
762766
let vars = scope.bindings_of(ty);
763767
if !vars.is_empty() && self.seed.bool() {
764768
return vars[self.seed.choice(vars.len())].name.clone();
765769
}
766-
self.gen_construct(ty, scope)
770+
self.gen_construct(ty, scope, fuel)
767771
}
768772

769773
/// Construct a value of `ty` (literal for scalars; constructor for compounds).
770-
fn gen_construct(&mut self, ty: &Ty, scope: &Scope) -> String {
774+
/// Compound payloads are generated with strictly-decreasing `fuel` so that
775+
/// `Some`/`Ok` nesting cannot reset the recursion budget — at `fuel == 0` the
776+
/// payload is a plain `gen_construct` (a literal/atom that never re-enters
777+
/// `gen_expr`), which is what bounds generation.
778+
fn gen_construct(&mut self, ty: &Ty, scope: &Scope, fuel: u32) -> String {
779+
let payload_fuel = fuel.saturating_sub(1);
771780
match ty {
772781
Ty::Int | Ty::Bool | Ty::Float | Ty::String => self.gen_literal(ty),
773782
Ty::Option(inner) => {
774-
let value = self.gen_expr(inner, scope, 1);
783+
let value = self.gen_expr(inner, scope, payload_fuel);
775784
format!("Some({value})")
776785
}
777786
Ty::Result(ok, _) => {
778-
let value = self.gen_expr(ok, scope, 1);
787+
let value = self.gen_expr(ok, scope, payload_fuel);
779788
format!("Ok({value})")
780789
}
781790
Ty::Struct(name) => {
@@ -921,7 +930,7 @@ impl<'a> Generator<'a> {
921930
| Ty::List(_)
922931
| Ty::Map(_, _)
923932
| Ty::Set(_)
924-
| Ty::Deque(_) => self.gen_construct(ty, scope),
933+
| Ty::Deque(_) => self.gen_construct(ty, scope, fuel),
925934
}
926935
}
927936

docs/hardening-todo.md

Lines changed: 31 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -63,19 +63,43 @@ prefixes (the enum is intentionally minimal: `Diagnostics` / `Runtime`).
6363

6464
## Tier C — longevity (lower urgency)
6565

66-
- [ ] **C5 Miri in CI** over a small test subset (we have none today) — also
67-
double-checks the `unsafe` in `vm-jit`. Add a `cargo miri test` target/job;
68-
if Miri flags `vm-jit`, document/fix.
66+
- [x] **C5 Miri in CI** over a small test subset (we had none) — landed as the
67+
`make miri` target: `cargo +nightly miri test -p rss-testgen --lib`. That is
68+
the largest subset Miri can *soundly* interpret here — the `rss-testgen` seed
69+
decoder is pure arithmetic/control-flow with no I/O or FFI, so Miri can check
70+
it for UB (it reports clean). **What Miri cannot cover, by construction:**
71+
- the **vm-jit** tier executes generated *native machine code*; Miri is a MIR
72+
interpreter and cannot run native code at all, so the JIT execute path is
73+
permanently out of Miri's scope (this is why Invariant 2's `panic = "abort"`
74+
carries the safety burden at the JIT/FFI seams instead);
75+
- the **FFI / syscall** seams (`rsscript-runtime`'s tokio/reqwest/process/fs,
76+
`reir`'s filesystem adapters, native plugin cdylibs) abort under Miri's
77+
isolation — that is *unsupported I/O*, not detected UB, so those crates are
78+
deliberately excluded rather than run-and-silenced.
79+
Setup (offline-installable in the dev container):
80+
`rustup toolchain install nightly --component miri,rust-src`.
6981
- [ ] **C6 Cycle/leak policy.** The value model makes accidental cycles unlikely;
7082
decide explicitly — document the weak-discipline + add leak tests, or plan a
7183
cycle collector for very-long-running apps.
7284

7385
## Meta — prove the invariant continuously
7486

75-
- [ ] **M Invariant-1 fuzz target.** New `fuzz/fuzz_targets/no_panic.rs`: feed
76-
random valid programs, assert the runtime only ever returns `Ok`/`EvalError`,
77-
never panics. Converts "we hardened it" into "CI keeps it hardened" — the
78-
natural extension of the existing `fail_closed`/`hostile.rs` ethos.
87+
- [x] **M Invariant-1 fuzz target.** Landed `fuzz/fuzz_targets/no_panic.rs`: a
88+
seed decodes (via `rss-testgen`) to a well-typed program which, if the checker
89+
accepts it, is evaluated on the reg-VM through
90+
`reg_vm_eval_source_main_with_limits` under generous-but-finite `VmLimits`
91+
(`max_depth: 16_384`, `step_budget: 50_000_000`, `mem_budget: 512 MiB`). The
92+
result must be `Ok`/`EvalError`; any panic/abort is recorded by libFuzzer as a
93+
crash. Converts "we hardened it" into "CI keeps it hardened" — the natural
94+
extension of the `fail_closed`/`hostile.rs` ethos. Run it via
95+
`make fuzz-no-panic` (or `cargo +nightly fuzz run no_panic`).
96+
- *Bug it already caught:* the first smoke run surfaced a native stack
97+
overflow — not in the runtime, but in the `rss-testgen` **generator** itself:
98+
`gen_construct` reset the recursion `fuel` to a constant `1` for `Some`/`Ok`
99+
payloads, so a generated `fn f(x: Result<Float,String>) -> Float` produced an
100+
unbounded `Float → Result<Float> → Float → …` construction. Fixed by
101+
threading strictly-decreasing fuel through `gen_atom`/`gen_construct` (this
102+
bug latently affected the existing `differential`/`fail_closed` targets too).
79103

80104
## Verification discipline
81105

0 commit comments

Comments
 (0)