Skip to content

Commit b598e58

Browse files
hyperpolymathclaude
andcommitted
feat(verify): port affinescript.ownership custom-section codec (C2)
Implements the parser and encoder for the `affinescript.ownership` custom-section wire format. The parser is a faithful port of OCaml `Tw_verify.parse_ownership_section_payload`; the encoder mirrors the matching emitter on the affinescript side (`Codegen.build_ownership_section`) so downstream emitters in ephapax (C6) and elsewhere can reuse it. Wire format ----------- Little-endian, byte-aligned: u32le count for each entry: u32le func_idx u8 n_params u8[n] param_kinds (0=Unrestricted, 1=Linear, 2=SharedBorrow, 3=ExclBorrow) u8 ret_kind Cross-impl parity decision -------------------------- The OCaml parser is lenient: reading past the end of the payload yields zero bytes (interpreted as `Unrestricted` kinds and `func_idx = 0`). This Rust port preserves that exactly. A correctly-emitted section will never be truncated, but matching the leniency means the C5 cross-compat suite sees identical results on every payload the OCaml side accepts. Stricter validation is a future opt-in (`parse_strict`), not the default. The `OwnershipKind::from_byte` fallback (anything outside 0..=3 → `Unrestricted`) was already in C1 and is reused here. New API ------- - `OwnershipEntry { func_idx, param_kinds, ret_kind }` — named struct replacing the OCaml 3-tuple for readability. - `parse_ownership_section_payload(&[u8]) -> Vec<OwnershipEntry>` — lenient parser (matches OCaml). - `build_ownership_section_payload(&[OwnershipEntry]) -> Vec<u8>` — inverse encoder; panics if any entry has >255 params (the n_params field is u8, but real wasm modules don't hit this limit). - `OwnershipKind::to_byte(self) -> u8` — encode side of the existing `from_byte`. Tests ----- 11/11 unit tests, covering: - empty payload - count=0 with no entries - single entry with zero params - single entry with all four kind values (encode + decode) - multi-entry round-trip - unknown kind byte falls back to `Unrestricted` (parity guard) - truncated payload reads zeros past EOF (parity guard) - empty round-trip (entries → bytes → entries) - realistic 2-entry round-trip - exact wire-format byte sequence for a known input $ cargo test -p typed-wasm-verify running 11 tests test section::tests::build_emits_correct_wire_format ... ok test section::tests::empty_payload_yields_no_entries ... ok test section::tests::roundtrip_empty ... ok test section::tests::count_zero_yields_no_entries ... ok test section::tests::multiple_entries ... ok test section::tests::roundtrip_realistic ... ok test section::tests::single_entry_no_params ... ok test section::tests::single_entry_with_all_kinds ... ok test section::tests::truncated_payload_reads_zeros_past_end ... ok test section::tests::unknown_kind_byte_decodes_to_unrestricted ... ok test tests::ownership_kind_byte_roundtrip ... ok test result: ok. 11 passed; 0 failed; 0 ignored Stacked on top of #19 (C1 scaffold). Next: C3 — port the per-path use-range analysis and intra-function verifier. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 90e894b commit b598e58

2 files changed

Lines changed: 230 additions & 0 deletions

File tree

crates/typed-wasm-verify/src/lib.rs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,9 @@
1616

1717
use thiserror::Error;
1818

19+
pub mod section;
20+
pub use section::{build_ownership_section_payload, parse_ownership_section_payload, OwnershipEntry};
21+
1922
/// Ownership kinds matching the OCaml `Codegen.ownership_kind` enum.
2023
/// Wire encoding in the `affinescript.ownership` custom section: a single
2124
/// u8 per kind, values 0/1/2/3 as below.
@@ -38,6 +41,11 @@ impl OwnershipKind {
3841
_ => OwnershipKind::Unrestricted,
3942
}
4043
}
44+
45+
/// Encode to the single-byte wire value.
46+
pub fn to_byte(self) -> u8 {
47+
self as u8
48+
}
4149
}
4250

4351
/// An ownership violation found in a wasm function body.
Lines changed: 222 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,222 @@
1+
// SPDX-License-Identifier: PMPL-1.0-or-later
2+
//
3+
// `affinescript.ownership` custom-section codec.
4+
//
5+
// Wire format (little-endian, byte-aligned):
6+
//
7+
// u32le count
8+
// for each entry:
9+
// u32le func_idx
10+
// u8 n_params
11+
// u8[n] param_kinds (0=Unrestricted, 1=Linear, 2=SharedBorrow, 3=ExclBorrow)
12+
// u8 ret_kind
13+
//
14+
// Rust port of `Tw_verify.parse_ownership_section_payload` plus the
15+
// inverse encoder mirroring `Codegen.build_ownership_section`. The OCaml
16+
// parser is lenient on truncation — reading past the buffer end yields
17+
// 0 — and this port matches that behaviour so the cross-compat suite
18+
// (C5) sees identical results on every payload the OCaml side accepts.
19+
20+
use crate::OwnershipKind;
21+
22+
/// One entry in the ownership section: a function's index plus its
23+
/// ownership-annotated signature. Mirrors the 3-tuple
24+
/// `(int * ownership_kind list * ownership_kind)` returned by the OCaml
25+
/// parser, but as a named struct for readability.
26+
#[derive(Debug, Clone, PartialEq, Eq)]
27+
pub struct OwnershipEntry {
28+
pub func_idx: u32,
29+
pub param_kinds: Vec<OwnershipKind>,
30+
pub ret_kind: OwnershipKind,
31+
}
32+
33+
/// Parse the `affinescript.ownership` custom-section payload into
34+
/// structured entries.
35+
///
36+
/// Matches OCaml `Tw_verify.parse_ownership_section_payload` exactly,
37+
/// including the leniency: a truncated payload yields zeros for the
38+
/// missing bytes (interpreted as `Unrestricted` kinds and `func_idx = 0`).
39+
/// A properly-emitted section will never be truncated; this leniency is
40+
/// a defence-in-depth choice that preserves cross-impl parity.
41+
pub fn parse_ownership_section_payload(payload: &[u8]) -> Vec<OwnershipEntry> {
42+
let mut r = LenientReader::new(payload);
43+
let count = r.read_u32_le();
44+
(0..count)
45+
.map(|_| {
46+
let func_idx = r.read_u32_le();
47+
let n_params = r.read_u8();
48+
let param_kinds = (0..n_params)
49+
.map(|_| OwnershipKind::from_byte(r.read_u8()))
50+
.collect();
51+
let ret_kind = OwnershipKind::from_byte(r.read_u8());
52+
OwnershipEntry { func_idx, param_kinds, ret_kind }
53+
})
54+
.collect()
55+
}
56+
57+
/// Encode entries to the `affinescript.ownership` custom-section
58+
/// payload format. The inverse of `parse_ownership_section_payload` for
59+
/// any input that doesn't truncate.
60+
///
61+
/// Mirrors OCaml `Codegen.build_ownership_section` (which lives in the
62+
/// affinescript repo and isn't visible here, but the wire format is the
63+
/// authoritative spec).
64+
///
65+
/// # Panics
66+
///
67+
/// Panics if any entry has more than 255 params (the n_params field is
68+
/// a single byte). Real wasm modules don't have functions with more
69+
/// than 255 params (the engine limit is far lower), so this is
70+
/// unreachable in practice.
71+
pub fn build_ownership_section_payload(entries: &[OwnershipEntry]) -> Vec<u8> {
72+
let count: u32 = entries.len().try_into().expect("entry count must fit in u32");
73+
let mut out = Vec::with_capacity(4 + entries.len() * 8);
74+
out.extend_from_slice(&count.to_le_bytes());
75+
for entry in entries {
76+
out.extend_from_slice(&entry.func_idx.to_le_bytes());
77+
let n_params: u8 = entry.param_kinds.len().try_into().expect("param count must fit in u8");
78+
out.push(n_params);
79+
for k in &entry.param_kinds {
80+
out.push(k.to_byte());
81+
}
82+
out.push(entry.ret_kind.to_byte());
83+
}
84+
out
85+
}
86+
87+
/// Cursor that reads u32le / u8 from a byte slice, returning 0 past EOF.
88+
/// Mirrors the OCaml `read_u32_le` / `read_u8` helpers.
89+
struct LenientReader<'a> {
90+
buf: &'a [u8],
91+
pos: usize,
92+
}
93+
94+
impl<'a> LenientReader<'a> {
95+
fn new(buf: &'a [u8]) -> Self {
96+
Self { buf, pos: 0 }
97+
}
98+
99+
fn read_u32_le(&mut self) -> u32 {
100+
if self.pos + 4 > self.buf.len() {
101+
return 0;
102+
}
103+
let b = &self.buf[self.pos..self.pos + 4];
104+
self.pos += 4;
105+
u32::from_le_bytes([b[0], b[1], b[2], b[3]])
106+
}
107+
108+
fn read_u8(&mut self) -> u8 {
109+
if self.pos >= self.buf.len() {
110+
return 0;
111+
}
112+
let v = self.buf[self.pos];
113+
self.pos += 1;
114+
v
115+
}
116+
}
117+
118+
#[cfg(test)]
119+
mod tests {
120+
use super::*;
121+
use OwnershipKind::*;
122+
123+
fn entry(func_idx: u32, params: Vec<OwnershipKind>, ret: OwnershipKind) -> OwnershipEntry {
124+
OwnershipEntry { func_idx, param_kinds: params, ret_kind: ret }
125+
}
126+
127+
#[test]
128+
fn empty_payload_yields_no_entries() {
129+
assert_eq!(parse_ownership_section_payload(&[]), vec![]);
130+
}
131+
132+
#[test]
133+
fn count_zero_yields_no_entries() {
134+
assert_eq!(parse_ownership_section_payload(&[0, 0, 0, 0]), vec![]);
135+
}
136+
137+
#[test]
138+
fn single_entry_no_params() {
139+
// count=1, func_idx=7, n_params=0, ret_kind=0
140+
let payload = [1, 0, 0, 0, 7, 0, 0, 0, 0, 0];
141+
let parsed = parse_ownership_section_payload(&payload);
142+
assert_eq!(parsed, vec![entry(7, vec![], Unrestricted)]);
143+
}
144+
145+
#[test]
146+
fn single_entry_with_all_kinds() {
147+
// count=1, func_idx=42, n_params=4, params=[Linear, Unrestricted, ExclBorrow, SharedBorrow], ret=Linear
148+
let payload = [1, 0, 0, 0, 42, 0, 0, 0, 4, 1, 0, 3, 2, 1];
149+
let parsed = parse_ownership_section_payload(&payload);
150+
assert_eq!(
151+
parsed,
152+
vec![entry(42, vec![Linear, Unrestricted, ExclBorrow, SharedBorrow], Linear)]
153+
);
154+
}
155+
156+
#[test]
157+
fn multiple_entries() {
158+
let entries = vec![
159+
entry(1, vec![Linear], Unrestricted),
160+
entry(2, vec![ExclBorrow, ExclBorrow], Linear),
161+
entry(99, vec![], SharedBorrow),
162+
];
163+
let bytes = build_ownership_section_payload(&entries);
164+
assert_eq!(parse_ownership_section_payload(&bytes), entries);
165+
}
166+
167+
#[test]
168+
fn unknown_kind_byte_decodes_to_unrestricted() {
169+
// Matches OCaml `kind_of_byte` fallback for cross-impl parity.
170+
// count=1, func_idx=0, n_params=1, param=99, ret=200
171+
let payload = [1, 0, 0, 0, 0, 0, 0, 0, 1, 99, 200];
172+
let parsed = parse_ownership_section_payload(&payload);
173+
assert_eq!(parsed, vec![entry(0, vec![Unrestricted], Unrestricted)]);
174+
}
175+
176+
#[test]
177+
fn truncated_payload_reads_zeros_past_end() {
178+
// count=2, but only one entry's worth of bytes follows.
179+
// Matches OCaml leniency (returns 0 for short reads).
180+
// count=2, then func_idx=5, n_params=1, param=1 (Linear), ret=2 (SharedBorrow)
181+
// ... then nothing — second entry should read all zeros.
182+
let payload = [2, 0, 0, 0, 5, 0, 0, 0, 1, 1, 2];
183+
let parsed = parse_ownership_section_payload(&payload);
184+
assert_eq!(
185+
parsed,
186+
vec![
187+
entry(5, vec![Linear], SharedBorrow),
188+
entry(0, vec![], Unrestricted), // zero-filled
189+
]
190+
);
191+
}
192+
193+
#[test]
194+
fn roundtrip_empty() {
195+
let entries: Vec<OwnershipEntry> = vec![];
196+
let bytes = build_ownership_section_payload(&entries);
197+
assert_eq!(bytes, vec![0, 0, 0, 0]);
198+
assert_eq!(parse_ownership_section_payload(&bytes), entries);
199+
}
200+
201+
#[test]
202+
fn roundtrip_realistic() {
203+
// Realistic shape: an exported `consume_string(s: own String) -> ()`
204+
// and a `borrow_string(s: ref String) -> i32`, both at indices the
205+
// affinescript codegen would produce after the host imports.
206+
let entries = vec![
207+
entry(2, vec![Linear], Unrestricted),
208+
entry(3, vec![SharedBorrow], Unrestricted),
209+
];
210+
let bytes = build_ownership_section_payload(&entries);
211+
let parsed = parse_ownership_section_payload(&bytes);
212+
assert_eq!(parsed, entries);
213+
}
214+
215+
#[test]
216+
fn build_emits_correct_wire_format() {
217+
let entries = vec![entry(7, vec![Linear, ExclBorrow], SharedBorrow)];
218+
let bytes = build_ownership_section_payload(&entries);
219+
// count=1, func_idx=7, n_params=2, params=[1,3], ret=2
220+
assert_eq!(bytes, vec![1, 0, 0, 0, 7, 0, 0, 0, 2, 1, 3, 2]);
221+
}
222+
}

0 commit comments

Comments
 (0)