Skip to content

Commit fc32791

Browse files
feat(codegen): emit typedwasm.regions + access-sites carriers (closes #251) (#346)
## What Producer side of typed-wasm **ADR-0003** (`typedwasm.access-sites`, wire format = proposal 0002 encoding B, LEB128 per field) **plus its dependent carrier ADR-0002 `typedwasm.regions`** — which #251 didn't list, but the spec requires: a verifier rejects access-sites without regions (`MissingDependentCarrier`). ## Design — simpler than the issue projected Ephapax's only region-allocated runtime values are strings with an 8-byte header (`ptr`@+0, `len`@+4), so the v1 regions table is a single region `ephapax.string { ptr: U32, len: U32 }`. All typed accesses are the string-header loads/stores inside the 3 runtime helpers (`string_new` 2, `string_len` 1, `string_concat` 6 = **9 sites**) — their fields are statically known **at the emission site**, so the typecheck→codegen threading the issue called "the bulk of the work" isn't needed for v1. Byte offsets are exact: captured via `Function::byte_len()` immediately before each access instruction (offset from body start incl. locals vector, per the proposal's convention). Allocator bump-pointer bookkeeping and raw `memory.copy` byte moves are deliberately unlisted (untyped — obligation ¶5); ephapax runs no post-codegen rewrite passes, so emit-time offsets satisfy obligation ¶1. ## Changes - **`src/ephapax-wasm/src/carriers.rs`** (new): codecs for both sections; LEB128 helpers reject overlong encodings per spec; 5 unit tests incl. round-trip. - **`lib.rs`**: string helpers report `(field_id, byte_offset)` site lists; `append_runtime_funcs` collects them; `emit_carrier_sections()` emits both custom sections alongside `typedwasm.ownership`. - **New e2e test**: compiles a module, parses both sections back, asserts the regions payload matches the canonical schema and that **every access-site offset points at an actual `i32.load` (0x28) / `i32.store` (0x36) opcode in the emitted bytes**. ## Verification `cargo test -p ephapax-wasm`: 95 passed. `cargo test -p ephapax-cli`: all 19 test binaries green, including the typed-wasm seam-conformance suite. Closes #251. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 02865d1 commit fc32791

2 files changed

Lines changed: 392 additions & 10 deletions

File tree

src/ephapax-wasm/src/carriers.rs

Lines changed: 226 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,226 @@
1+
// SPDX-License-Identifier: MPL-2.0
2+
// Owner: Jonathan D.A. Jewell <j.d.a.jewell@open.ac.uk>
3+
//
4+
//! In-tree codecs for the `typedwasm.regions` and
5+
//! `typedwasm.access-sites` wasm custom sections (typed-wasm proposals
6+
//! 0001 + 0002, both `[accepted]` and promoted to ADR-0002 / ADR-0003).
7+
//!
8+
//! Like `ownership.rs`, this module deliberately does NOT depend on
9+
//! `typed-wasm-verify`: the producer and the verifier implement the
10+
//! shared wire contract independently, and the seam-conformance tests
11+
//! keep them honest.
12+
//!
13+
//! ## The ephapax v1 schema
14+
//!
15+
//! Ephapax's only region-allocated runtime values are strings, laid out
16+
//! as an 8-byte header: `ptr: u32` at +0 (address of the byte buffer)
17+
//! and `len: u32` at +4. The v1 `typedwasm.regions` table therefore
18+
//! declares a single region:
19+
//!
20+
//! ```text
21+
//! region 0 "ephapax.string" { field 0 "ptr": U32, field 1 "len": U32 }
22+
//! ```
23+
//!
24+
//! Both fields are wire-kind Scalar (the byte buffer the `ptr` value
25+
//! addresses is bump-allocated and not itself a declared region, so the
26+
//! pointer kinds — which require a valid `target_region` — do not
27+
//! apply). Every string-header `i32.load` / `i32.store` the codegen
28+
//! emits is a typed access against one of these two fields; the raw
29+
//! byte-buffer copies (`memory.copy`) and the allocator's own
30+
//! bump-pointer bookkeeping at `mem[0]` / `mem[8]` are NOT typed
31+
//! accesses and are deliberately unlisted (proposal 0002 §"Producer
32+
//! obligations" ¶5).
33+
34+
pub const REGIONS_SECTION_NAME: &str = "typedwasm.regions";
35+
pub const ACCESS_SITES_SECTION_NAME: &str = "typedwasm.access-sites";
36+
37+
pub const REGIONS_VERSION: u16 = 1;
38+
pub const ACCESS_SITES_VERSION: u16 = 1;
39+
40+
/// Region index of `ephapax.string` in the v1 table.
41+
pub const REGION_STRING: u32 = 0;
42+
/// Field indices within `ephapax.string`.
43+
pub const FIELD_PTR: u32 = 0;
44+
pub const FIELD_LEN: u32 = 1;
45+
46+
/// Wire codepoints from proposal 0001 (kind / wasm_ty tables).
47+
const KIND_SCALAR: u8 = 0;
48+
const WASM_TY_U32: u8 = 2;
49+
const NO_TARGET_REGION: u32 = 0xFFFF_FFFF;
50+
const NON_NULL: u8 = 0;
51+
52+
/// One `typedwasm.access-sites` entry: the producer's assertion that
53+
/// the instruction at `byte_offset` within function `func_idx`'s body
54+
/// is a typed access against `(region_id, field_id)`.
55+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
56+
pub struct AccessSite {
57+
/// Index in the module's function index space (imports included —
58+
/// the same space `call` immediates and `typedwasm.ownership`
59+
/// entries use).
60+
pub func_idx: u32,
61+
/// Byte offset of the access opcode's first byte within the
62+
/// function body (counting from the byte after the body's size
63+
/// prefix, i.e. the start of the locals vector).
64+
pub byte_offset: u32,
65+
pub region_id: u32,
66+
pub field_id: u32,
67+
}
68+
69+
fn write_leb128_u32(out: &mut Vec<u8>, mut v: u32) {
70+
loop {
71+
let byte = (v & 0x7f) as u8;
72+
v >>= 7;
73+
if v == 0 {
74+
out.push(byte);
75+
return;
76+
}
77+
out.push(byte | 0x80);
78+
}
79+
}
80+
81+
fn read_leb128_u32(bytes: &[u8], pos: &mut usize) -> Option<u32> {
82+
let mut result: u32 = 0;
83+
let mut shift = 0u32;
84+
loop {
85+
let byte = *bytes.get(*pos)?;
86+
*pos += 1;
87+
if shift == 28 && (byte & 0x70) != 0 {
88+
return None; // overflows u32
89+
}
90+
result |= u32::from(byte & 0x7f) << shift;
91+
if byte & 0x80 == 0 {
92+
// Reject overlong encodings (proposal 0002: consumers MUST
93+
// reject non-shortest forms).
94+
if byte == 0 && shift != 0 {
95+
return None;
96+
}
97+
return Some(result);
98+
}
99+
shift += 7;
100+
if shift > 28 {
101+
return None;
102+
}
103+
}
104+
}
105+
106+
fn write_str(out: &mut Vec<u8>, s: &str) {
107+
out.extend_from_slice(&(s.len() as u32).to_le_bytes());
108+
out.extend_from_slice(s.as_bytes());
109+
}
110+
111+
/// Build the fixed ephapax v1 `typedwasm.regions` payload (proposal
112+
/// 0001 wire format).
113+
pub fn build_regions_section_payload() -> Vec<u8> {
114+
let mut out = Vec::new();
115+
out.extend_from_slice(&REGIONS_VERSION.to_le_bytes());
116+
out.extend_from_slice(&1u32.to_le_bytes()); // region_count
117+
write_str(&mut out, "ephapax.string");
118+
out.extend_from_slice(&2u32.to_le_bytes()); // field_count
119+
for name in ["ptr", "len"] {
120+
write_str(&mut out, name);
121+
out.push(KIND_SCALAR);
122+
out.push(WASM_TY_U32);
123+
out.extend_from_slice(&NO_TARGET_REGION.to_le_bytes());
124+
out.push(NON_NULL);
125+
out.extend_from_slice(&1u32.to_le_bytes()); // cardinality
126+
}
127+
out.extend_from_slice(&8u32.to_le_bytes()); // region_byte_size
128+
out
129+
}
130+
131+
/// Encode entries to the `typedwasm.access-sites` payload (proposal
132+
/// 0002 wire format — encoding B, LEB128 per field).
133+
pub fn build_access_sites_payload(entries: &[AccessSite]) -> Vec<u8> {
134+
let mut out = Vec::new();
135+
out.extend_from_slice(&ACCESS_SITES_VERSION.to_le_bytes());
136+
write_leb128_u32(&mut out, entries.len() as u32);
137+
for e in entries {
138+
write_leb128_u32(&mut out, e.func_idx);
139+
write_leb128_u32(&mut out, e.byte_offset);
140+
write_leb128_u32(&mut out, e.region_id);
141+
write_leb128_u32(&mut out, e.field_id);
142+
}
143+
out
144+
}
145+
146+
/// Parse a `typedwasm.access-sites` payload. Returns `None` on a
147+
/// malformed payload (wrong version, truncation, overlong LEB128).
148+
pub fn parse_access_sites_payload(bytes: &[u8]) -> Option<Vec<AccessSite>> {
149+
if bytes.len() < 2 {
150+
return None;
151+
}
152+
let version = u16::from_le_bytes([bytes[0], bytes[1]]);
153+
if version != ACCESS_SITES_VERSION {
154+
return None;
155+
}
156+
let mut pos = 2usize;
157+
let count = read_leb128_u32(bytes, &mut pos)?;
158+
let mut entries = Vec::with_capacity(count as usize);
159+
for _ in 0..count {
160+
entries.push(AccessSite {
161+
func_idx: read_leb128_u32(bytes, &mut pos)?,
162+
byte_offset: read_leb128_u32(bytes, &mut pos)?,
163+
region_id: read_leb128_u32(bytes, &mut pos)?,
164+
field_id: read_leb128_u32(bytes, &mut pos)?,
165+
});
166+
}
167+
if pos != bytes.len() {
168+
return None; // trailing garbage
169+
}
170+
Some(entries)
171+
}
172+
173+
#[cfg(test)]
174+
mod tests {
175+
use super::*;
176+
177+
#[test]
178+
fn access_sites_round_trip() {
179+
let entries = vec![
180+
AccessSite { func_idx: 3, byte_offset: 12, region_id: 0, field_id: 0 },
181+
AccessSite { func_idx: 3, byte_offset: 19, region_id: 0, field_id: 1 },
182+
AccessSite { func_idx: 300, byte_offset: 70_000, region_id: 0, field_id: 1 },
183+
];
184+
let payload = build_access_sites_payload(&entries);
185+
assert_eq!(parse_access_sites_payload(&payload), Some(entries));
186+
}
187+
188+
#[test]
189+
fn access_sites_empty_round_trip() {
190+
let payload = build_access_sites_payload(&[]);
191+
assert_eq!(payload, vec![1, 0, 0]); // version u16le + count 0
192+
assert_eq!(parse_access_sites_payload(&payload), Some(vec![]));
193+
}
194+
195+
#[test]
196+
fn rejects_wrong_version_and_truncation() {
197+
let mut payload = build_access_sites_payload(&[AccessSite {
198+
func_idx: 1,
199+
byte_offset: 2,
200+
region_id: 0,
201+
field_id: 1,
202+
}]);
203+
assert!(parse_access_sites_payload(&payload[..payload.len() - 1]).is_none());
204+
payload[0] = 9;
205+
assert!(parse_access_sites_payload(&payload).is_none());
206+
}
207+
208+
#[test]
209+
fn rejects_overlong_leb128() {
210+
// count = 0 encoded overlong as [0x80, 0x00]
211+
let payload = vec![1, 0, 0x80, 0x00];
212+
assert!(parse_access_sites_payload(&payload).is_none());
213+
}
214+
215+
#[test]
216+
fn regions_payload_shape() {
217+
let p = build_regions_section_payload();
218+
assert_eq!(&p[0..2], &1u16.to_le_bytes()); // version
219+
assert_eq!(&p[2..6], &1u32.to_le_bytes()); // one region
220+
assert_eq!(&p[6..10], &14u32.to_le_bytes()); // name len
221+
assert_eq!(&p[10..24], b"ephapax.string");
222+
assert_eq!(&p[24..28], &2u32.to_le_bytes()); // two fields
223+
// trailing region_byte_size == 8
224+
assert_eq!(&p[p.len() - 4..], &8u32.to_le_bytes());
225+
}
226+
}

0 commit comments

Comments
 (0)