Skip to content

Commit b8ab31f

Browse files
Phase 1: round-trip soundness property corpus (#130)
ECHIDNA-style property corpus for the producer: 512 deterministically-generated well-formed modules must each satisfy verify(emit(m)) == OK (structural validation + verify_from_module L7/L10 + verify_access_sites L2); negative controls (double-free, &mut alias, leak) must be rejected. Generated at the IR level (no in-process .twasm parser yet — #127); the parse(src)-fed form follows once the front-end -> IR seam lands. - tests/corpus.rs: seeded LCG generator + property assertions (no new deps) - CHANGELOG + crate README updated; .claude/settings.json allowlists `cargo run` Verified locally (cargo test --workspace --locked: codegen 25 + verifier 80; fmt + clippy clean) since Actions credit is exhausted. Part of Phase 1 (#49); #130 stays open for the source-level (parse) form. https://claude.ai/code/session_01Rq4da8i2uGnDUfanSB1Hx4
1 parent 2bad703 commit b8ab31f

5 files changed

Lines changed: 239 additions & 3 deletions

File tree

.claude/README.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,9 @@ Checked-in, team-shared configuration for Claude Code sessions on typed-wasm.
2020
A **non-destructive** allowlist that cuts permission prompts for the commands used routinely in
2121
this repo: read-only git inspection (`status/log/diff/fetch/show/ls-files/ls-tree/branch/
2222
rev-parse/merge-base/for-each-ref/check-attr`, `git bundle verify`/`list-heads`), the cargo
23-
build/test/lint loop (`cargo build/test/clippy`, `cargo fmt --check`, `cargo metadata/audit`
24-
these touch only `target/`, never the source tree), `diff -rq`, and `curl` **restricted to
23+
dev loop (`cargo build/test/run/clippy`, `cargo fmt --check`, `cargo metadata/audit`) — which is
24+
non-destructive to the source tree (build artifacts land in `target/`; `tw build` writes only the
25+
`.wasm`/`.wat` you name), `diff -rq`, and `curl` **restricted to
2526
`https://raw.githubusercontent.com/`** (used to compare sibling-estate repos without a full
2627
clone). No push, no source-mutating, and no destructive commands are granted — those still
2728
prompt.

.claude/settings.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
"Bash(git bundle verify:*)",
1919
"Bash(git bundle list-heads:*)",
2020
"Bash(cargo build:*)",
21+
"Bash(cargo run:*)",
2122
"Bash(cargo test:*)",
2223
"Bash(cargo clippy:*)",
2324
"Bash(cargo fmt --check:*)",

CHANGELOG.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1010

1111
## [Unreleased]
1212

13+
### Round-trip soundness corpus: `verify(emit(m)) == OK`, property-tested (#130) (2026-05-31)
14+
15+
ECHIDNA-style property corpus for the producer: a deterministically-generated set
16+
of **512 well-formed modules** (random scalar regions + getter/setter functions)
17+
must each satisfy `verify(emit(m)) == OK` — full structural validation +
18+
`verify_from_module` (L7/L10) + `verify_access_sites` (L2). Negative controls
19+
(double-free, `&mut` aliasing, leak) must be **rejected**, so the property has
20+
teeth. Generated at the IR level (the producer has no in-process `.twasm` parser —
21+
#127); a `verify(codegen(parse(src)))` corpus over real `.twasm` sources follows
22+
once the front-end → IR seam lands. (`crates/typed-wasm-codegen/tests/corpus.rs`.)
23+
1324
### `tw-verify` CLI: standalone wasm verification (2026-05-31)
1425

1526
Adds a binary front-end to the (previously lib-only) `typed-wasm-verify`

crates/typed-wasm-codegen/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,5 +78,5 @@ Full coverage (all 10 levels × all 6 examples + the front-end → IR seam) is #
7878

7979
- **#127** — remaining example coverage (`04`) + the front-end → IR JSON seam (the matrix above tracks per-example status).
8080
- **#129** — full offset → source-line map (DWARF or wasm sourcemap); needs source spans from the #127 seam + accurate instruction-offset tracking. The `name` section landed here is the first increment.
81-
- **#130**promote the round-trip tests into the ECHIDNA property corpus.
81+
- **#130**✅ landed: `verify(emit(m)) == OK` property corpus over 512 generated modules (`tests/corpus.rs`); the `parse(src)`-fed form awaits the #127 seam.
8282
- **#140** — L13 positive-form / region-imports (`examples/02`); proposal 0003 `[draft]`.
Lines changed: 223 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,223 @@
1+
// SPDX-License-Identifier: MPL-2.0
2+
//
3+
// Round-trip soundness corpus — Phase 1 deliverable 2 (#130).
4+
//
5+
// Property: for every well-formed module the producer builds,
6+
// verify(emit(module)) == OK
7+
// (full structural validation + verify_from_module + verify_access_sites).
8+
// Enforced ECHIDNA-style over a deterministically-generated corpus, plus
9+
// negative controls that MUST be rejected so the property has teeth.
10+
//
11+
// The corpus is generated at the IR level — the producer has no in-process
12+
// `.twasm` parser yet (#127). A `verify(codegen(parse(src)))` corpus over
13+
// real `.twasm` sources follows once the front-end → IR seam lands.
14+
15+
use typed_wasm_codegen::{
16+
emit, example01, example03, Access, Body, Field, FieldTy, Func, Memory, Module, Op, Ownership,
17+
Region, Scalar, Stmt, Wty,
18+
};
19+
use typed_wasm_verify::{
20+
verify_access_sites_from_module, verify_from_module, OwnershipError, VerifyError,
21+
};
22+
23+
/// Tiny deterministic PRNG (LCG) — keeps the corpus reproducible with no
24+
/// external dev-dependency.
25+
struct Rng(u64);
26+
27+
impl Rng {
28+
fn next_u32(&mut self) -> u32 {
29+
self.0 = self
30+
.0
31+
.wrapping_mul(6364136223846793005)
32+
.wrapping_add(1442695040888963407);
33+
(self.0 >> 33) as u32
34+
}
35+
fn upto(&mut self, n: u32) -> u32 {
36+
self.next_u32() % n
37+
}
38+
}
39+
40+
const SCALARS: [Scalar; 8] = [
41+
Scalar::I32,
42+
Scalar::U32,
43+
Scalar::F32,
44+
Scalar::F64,
45+
Scalar::I64,
46+
Scalar::U8,
47+
Scalar::Bool,
48+
Scalar::I16,
49+
];
50+
51+
/// The wasm value type a scalar leaf loads/stores as.
52+
fn scalar_to_wty(s: Scalar) -> Wty {
53+
match s {
54+
Scalar::F32 => Wty::F32,
55+
Scalar::F64 => Wty::F64,
56+
Scalar::I64 | Scalar::U64 => Wty::I64,
57+
_ => Wty::I32, // i8/i16/i32/u8/u16/u32/bool move through i32
58+
}
59+
}
60+
61+
/// Generate a well-formed module: random scalar regions + getter/setter
62+
/// functions, each reading/writing a real field through a once-read base
63+
/// local (so the ownership annotations stay clean).
64+
fn gen_valid(seed: u64) -> Module {
65+
let mut rng = Rng(seed.wrapping_mul(2654435761).wrapping_add(1));
66+
67+
let n_regions = 1 + rng.upto(2) as usize; // 1..=2
68+
let mut regions = Vec::new();
69+
for r in 0..n_regions {
70+
let n_fields = 2 + rng.upto(5) as usize; // 2..=6
71+
let fields = (0..n_fields)
72+
.map(|i| Field::scalar(&format!("f{r}_{i}"), SCALARS[rng.upto(8) as usize]))
73+
.collect();
74+
regions.push(Region::new(&format!("R{r}"), fields));
75+
}
76+
77+
let n_funcs = 1 + rng.upto(4) as usize; // 1..=4
78+
let mut funcs = Vec::new();
79+
let mut ownership = Vec::new();
80+
for k in 0..n_funcs {
81+
let region = rng.upto(n_regions as u32) as usize;
82+
let field = rng.upto(regions[region].fields.len() as u32) as usize;
83+
let scalar = match regions[region].fields[field].ty {
84+
FieldTy::Scalar(s) => s,
85+
_ => Scalar::I32,
86+
};
87+
let wty = scalar_to_wty(scalar);
88+
let idx = if rng.upto(2) == 0 { Some(1u32) } else { None };
89+
90+
if rng.upto(2) == 0 {
91+
funcs.push(Func {
92+
name: format!("get{k}"),
93+
params: vec![Wty::I32, Wty::I32],
94+
results: vec![wty],
95+
body: Body::Typed {
96+
handles: vec![0],
97+
stmts: vec![Stmt::Return(Access::field(0, idx, region, field))],
98+
},
99+
export: true,
100+
});
101+
ownership.push((k, vec![Ownership::SharedBorrow, Ownership::Unrestricted]));
102+
} else {
103+
funcs.push(Func {
104+
name: format!("set{k}"),
105+
params: vec![Wty::I32, Wty::I32, wty],
106+
results: vec![],
107+
body: Body::Typed {
108+
handles: vec![0],
109+
stmts: vec![Stmt::Set {
110+
access: Access::field(0, idx, region, field),
111+
value: 2,
112+
}],
113+
},
114+
export: true,
115+
});
116+
ownership.push((
117+
k,
118+
vec![
119+
Ownership::ExclBorrow,
120+
Ownership::Unrestricted,
121+
Ownership::Unrestricted,
122+
],
123+
));
124+
}
125+
}
126+
127+
Module {
128+
regions,
129+
memory: Some(Memory {
130+
min_pages: 4,
131+
max_pages: Some(64),
132+
}),
133+
imports: vec![],
134+
funcs,
135+
ownership,
136+
}
137+
}
138+
139+
/// The soundness property: emitted bytes validate AND pass both verifier passes.
140+
fn assert_round_trips(m: &Module) {
141+
let bytes = emit(m);
142+
wasmparser::Validator::new()
143+
.validate_all(&bytes)
144+
.expect("emitted module must be valid wasm");
145+
verify_from_module(&bytes).expect("emitted module must pass L7/L10 ownership");
146+
let violations =
147+
verify_access_sites_from_module(&bytes).expect("access-sites section must parse");
148+
assert!(
149+
violations.is_empty(),
150+
"L2 access-site violations: {violations:?}"
151+
);
152+
}
153+
154+
#[test]
155+
fn wired_examples_round_trip() {
156+
assert_round_trips(&example01());
157+
assert_round_trips(&example03());
158+
}
159+
160+
#[test]
161+
fn generated_corpus_round_trips() {
162+
// 512 deterministically-generated modules; every one must satisfy
163+
// verify(emit(m)) == OK.
164+
for seed in 0..512u64 {
165+
assert_round_trips(&gen_valid(seed));
166+
}
167+
}
168+
169+
// ── Negative controls — the property must have teeth ──────────────────
170+
171+
fn one_func_module(kind: Ownership, body: Vec<Op>) -> Module {
172+
Module {
173+
regions: vec![],
174+
memory: None,
175+
imports: vec![],
176+
funcs: vec![Func {
177+
name: "subject".into(),
178+
params: vec![Wty::I32],
179+
results: vec![],
180+
body: Body::Ops(body),
181+
export: true,
182+
}],
183+
ownership: vec![(0, vec![kind])],
184+
}
185+
}
186+
187+
fn expect_ownership_reject(m: &Module, pred: impl Fn(&OwnershipError) -> bool, what: &str) {
188+
let bytes = emit(m);
189+
match verify_from_module(&bytes) {
190+
Err(VerifyError::Ownership(errs)) => {
191+
assert!(errs.iter().any(pred), "expected {what}, got {errs:?}")
192+
}
193+
other => panic!("expected {what} to be rejected, got {other:?}"),
194+
}
195+
}
196+
197+
#[test]
198+
fn malformed_modules_are_rejected() {
199+
// Double-free: a Linear (own) handle used twice.
200+
expect_ownership_reject(
201+
&one_func_module(
202+
Ownership::Linear,
203+
vec![Op::LocalGet(0), Op::LocalGet(0), Op::Drop, Op::Drop],
204+
),
205+
|e| matches!(e, OwnershipError::LinearUsedMultiple { .. }),
206+
"LinearUsedMultiple",
207+
);
208+
// Aliasing: a &mut (ExclBorrow) handle referenced twice.
209+
expect_ownership_reject(
210+
&one_func_module(
211+
Ownership::ExclBorrow,
212+
vec![Op::LocalGet(0), Op::LocalGet(0), Op::Drop, Op::Drop],
213+
),
214+
|e| matches!(e, OwnershipError::ExclBorrowAliased { .. }),
215+
"ExclBorrowAliased",
216+
);
217+
// Leak: a Linear (own) handle never consumed.
218+
expect_ownership_reject(
219+
&one_func_module(Ownership::Linear, vec![]),
220+
|e| matches!(e, OwnershipError::LinearNotUsed { .. }),
221+
"LinearNotUsed",
222+
);
223+
}

0 commit comments

Comments
 (0)