Skip to main content

synth_core/
arena_bind.rs

1//! #418 — bind the passed-through embedder import `env::__cabi_arena_realloc`
2//! to a synthesized in-module arena allocator, unlocking the fully
3//! SELF-CONTAINED dissolve.
4//!
5//! ## The seam this closes
6//!
7//! The BYO-OS lean-MCU dissolve (gale#89) builds grow-free components with the
8//! wit-bindgen `cabi-realloc-extern` feature: `cabi_realloc` stays exported but
9//! its body routes to an embedder-provided `env::__cabi_arena_realloc` import.
10//! On the `--relocatable` host-link path that import is DELIBERATELY left as an
11//! undefined symbol the TCB's native allocator satisfies at link (#420 locks
12//! that layering — nothing here changes it). But on the default SELF-CONTAINED
13//! path the same import previously degraded the compile to an ET_REL "link me
14//! with the Kiln bridge" object: the one unresolved seam blocking a fully
15//! self-contained image.
16//!
17//! ## The binding (a wasm→wasm rewrite, not new codegen)
18//!
19//! When the arena import is the module's ONLY import, replace it with a
20//! DEFINED WebAssembly function implementing the #418 contract, compiled
21//! through synth's ordinary pipeline like any other module function:
22//!
23//! - signature `(old_ptr, old_len, align, new_len) -> ptr` (`i32×4 → i32`);
24//! - `old_len == 0 && new_len == 0` → return `align` verbatim;
25//! - bump allocation from a fresh mutable cursor global (appended at the end
26//!   of the global index space), aligned up per call;
27//! - realloc preserves `min(old_len, new_len)` bytes (byte-copy loop);
28//! - BOUNDED arena `[arena_base, arena_end)` — exhaustion (or a zero /
29//!   non-power-of-two-shaped `align` wrap) executes `unreachable`, i.e. traps,
30//!   and NEVER calls `memory.grow`.
31//!
32//! `arena_base` mirrors the shipped used-extent rule (`main.rs` #237/#354):
33//! `max(data_end, every i32-const global init ≤ linmem)` — the latter covers
34//! both the `__stack_pointer` class and wasm-ld's `__data_end`/`__heap_base`
35//! layout globals — rounded up to 16 (floor 16 so the allocator never returns
36//! a null-looking pointer). `arena_end` = the initial linear-memory size
37//! (grow-free modules never extend it).
38//!
39//! ## Why the rewrite is index-preserving
40//!
41//! The wasm function index space is imports-first. Removing the SOLE function
42//! import and prepending the allocator as the FIRST defined function gives it
43//! the import's old index — every `call`, `ref.func`, export, and element
44//! entry keeps its meaning without any remapping. Untouched sections are
45//! copied byte-for-byte; only import (dropped), function/code (one entry
46//! prepended) and global (one entry appended) change.
47
48use anyhow::{Context, Result, bail};
49use wasm_encoder::{BlockType, Function, MemArg, ValType};
50use wasmparser::{Parser, Payload};
51
52/// The core-module field name of the embedder arena import (#418). The kebab
53/// `cabi-arena-realloc` is component-surface only and never reaches synth.
54pub const ARENA_IMPORT_MODULE: &str = "env";
55pub const ARENA_IMPORT_FIELD: &str = "__cabi_arena_realloc";
56
57/// Outcome of [`bind_cabi_arena_realloc`].
58#[derive(Debug)]
59pub enum ArenaBind {
60    /// No arena import in the module — silent byte-identical pass-through
61    /// (the overwhelmingly common case; not worth a log line).
62    NoArenaImport,
63    /// The arena import is present but the module keeps the host-linked
64    /// seam (reason worth logging); byte-identical pass-through.
65    KeptHostSeam(&'static str),
66    /// The import was bound: compile `bytes` instead of the input.
67    Bound(BoundArena),
68}
69
70/// A successful #418 binding.
71#[derive(Debug)]
72pub struct BoundArena {
73    /// The rewritten module (arena import replaced by a defined allocator).
74    pub bytes: Vec<u8>,
75    /// First allocatable wasm address (16-aligned, above all statics).
76    pub arena_base: u32,
77    /// One past the last allocatable wasm address (= initial memory size).
78    pub arena_end: u32,
79}
80
81/// Everything learned in the analysis pass.
82struct Scan {
83    /// (import type index) when `env::__cabi_arena_realloc` is present.
84    arena_type_idx: Option<u32>,
85    /// Whether the arena import's declared type is `(i32×4) -> i32`.
86    arena_sig_ok: bool,
87    total_imports: u32,
88    /// Initial pages of memory 0, if declared (None = no memory).
89    memory_pages: Option<u64>,
90    memory64: bool,
91    defined_globals: u32,
92    /// Max `off + len` over active i32-const-offset segments on memory 0.
93    data_end: u32,
94    /// A data segment whose offset synth cannot evaluate statically.
95    non_const_data_offset: bool,
96    /// Max i32-const global init (any mutability) — the `__stack_pointer` /
97    /// `__heap_base` / `__data_end` class, filtered to `<= linmem` later.
98    global_inits: Vec<u32>,
99    has_function_section: bool,
100    has_code_section: bool,
101}
102
103fn scan(wasm: &[u8]) -> Result<Scan> {
104    let mut s = Scan {
105        arena_type_idx: None,
106        arena_sig_ok: false,
107        total_imports: 0,
108        memory_pages: None,
109        memory64: false,
110        defined_globals: 0,
111        data_end: 0,
112        non_const_data_offset: false,
113        global_inits: Vec::new(),
114        has_function_section: false,
115        has_code_section: false,
116    };
117    let mut func_types: Vec<bool> = Vec::new(); // per type index: is (i32×4)->i32
118    for payload in Parser::new(0).parse_all(wasm) {
119        match payload.context("parse wasm (#418 arena-bind scan)")? {
120            Payload::TypeSection(reader) => {
121                for rec_group in reader {
122                    for sub_ty in rec_group.context("parse type section (#418)")?.types() {
123                        let ok = match &sub_ty.composite_type.inner {
124                            wasmparser::CompositeInnerType::Func(f) => {
125                                f.params().len() == 4
126                                    && f.params().iter().all(|t| *t == wasmparser::ValType::I32)
127                                    && f.results() == [wasmparser::ValType::I32]
128                            }
129                            _ => false,
130                        };
131                        func_types.push(ok);
132                    }
133                }
134            }
135            Payload::ImportSection(reader) => {
136                // wasmparser 0.221+ compact-imports grouping: flatten back to
137                // individual `Import`s (same idiom as wasm_decoder.rs).
138                for import in reader.into_imports() {
139                    let import = import.context("parse import (#418)")?;
140                    s.total_imports += 1;
141                    if import.module == ARENA_IMPORT_MODULE
142                        && import.name == ARENA_IMPORT_FIELD
143                        && let wasmparser::TypeRef::Func(type_idx) = import.ty
144                    {
145                        s.arena_type_idx = Some(type_idx);
146                        s.arena_sig_ok =
147                            func_types.get(type_idx as usize).copied().unwrap_or(false);
148                    }
149                }
150            }
151            Payload::MemorySection(reader) => {
152                for (i, mem) in reader.into_iter().enumerate() {
153                    let mem = mem.context("parse memory (#418)")?;
154                    if i == 0 {
155                        s.memory_pages = Some(mem.initial);
156                        s.memory64 = mem.memory64;
157                    }
158                }
159            }
160            Payload::GlobalSection(reader) => {
161                for global in reader {
162                    let global = global.context("parse global (#418)")?;
163                    s.defined_globals += 1;
164                    // i32.const inits mark the static layout (SP top,
165                    // __heap_base/__data_end) — mirror the used-extent rule.
166                    let mut ops = global.init_expr.get_operators_reader();
167                    if let Ok(wasmparser::Operator::I32Const { value }) = ops.read()
168                        && value > 0
169                    {
170                        s.global_inits.push(value as u32);
171                    }
172                }
173            }
174            Payload::DataSection(reader) => {
175                for seg in reader {
176                    let seg = seg.context("parse data segment (#418)")?;
177                    if let wasmparser::DataKind::Active {
178                        memory_index,
179                        offset_expr,
180                    } = seg.kind
181                    {
182                        if memory_index != 0 {
183                            continue; // multi-memory declines self-contained anyway
184                        }
185                        let mut ops = offset_expr.get_operators_reader();
186                        match ops.read() {
187                            Ok(wasmparser::Operator::I32Const { value }) => {
188                                let end = (value as u32).saturating_add(seg.data.len() as u32);
189                                s.data_end = s.data_end.max(end);
190                            }
191                            _ => s.non_const_data_offset = true,
192                        }
193                    }
194                }
195            }
196            Payload::FunctionSection(_) => s.has_function_section = true,
197            Payload::CodeSectionStart { .. } => s.has_code_section = true,
198            _ => {}
199        }
200    }
201    Ok(s)
202}
203
204/// Encode the allocator body (the #418 contract — see module docs).
205///
206/// Params: 0 = old_ptr, 1 = old_len, 2 = align, 3 = new_len.
207/// Locals: 4 = aligned, 5 = end, 6 = n (copy length), 7 = i.
208fn allocator_body(cursor_global: u32, arena_end: u32) -> Function {
209    let mem = MemArg {
210        offset: 0,
211        align: 0,
212        memory_index: 0,
213    };
214    let mut f = Function::new([(4, ValType::I32)]);
215    // contract: old_len == 0 && new_len == 0 -> return align verbatim
216    f.instructions()
217        .local_get(1)
218        .i32_eqz()
219        .local_get(3)
220        .i32_eqz()
221        .i32_and()
222        .if_(BlockType::Empty)
223        .local_get(2)
224        .return_()
225        .end()
226        // align == 0 is contract-violating input (power of two >= 1): trap
227        // rather than compute a wrapped mask.
228        .local_get(2)
229        .i32_eqz()
230        .if_(BlockType::Empty)
231        .unreachable()
232        .end()
233        // aligned = (cursor + align - 1) & ~(align - 1)
234        .global_get(cursor_global)
235        .local_get(2)
236        .i32_add()
237        .i32_const(1)
238        .i32_sub()
239        .local_get(2)
240        .i32_const(1)
241        .i32_sub()
242        .i32_const(-1)
243        .i32_xor()
244        .i32_and()
245        .local_set(4)
246        // unsigned wrap while rounding up -> trap
247        .local_get(4)
248        .global_get(cursor_global)
249        .i32_lt_u()
250        .if_(BlockType::Empty)
251        .unreachable()
252        .end()
253        // end = aligned + new_len; unsigned wrap -> trap
254        .local_get(4)
255        .local_get(3)
256        .i32_add()
257        .local_tee(5)
258        .local_get(4)
259        .i32_lt_u()
260        .if_(BlockType::Empty)
261        .unreachable()
262        .end()
263        // BOUNDED arena: end > arena_end -> trap (never memory.grow)
264        .local_get(5)
265        .i32_const(arena_end as i32)
266        .i32_gt_u()
267        .if_(BlockType::Empty)
268        .unreachable()
269        .end()
270        // commit the bump
271        .local_get(5)
272        .global_set(cursor_global)
273        // n = min(old_len, new_len) — realloc preserves the prefix
274        .local_get(1)
275        .local_get(3)
276        .local_get(1)
277        .local_get(3)
278        .i32_lt_u()
279        .select()
280        .local_set(6)
281        // byte-copy loop: dst = aligned + i, src = old_ptr + i, i < n
282        .block(BlockType::Empty)
283        .loop_(BlockType::Empty)
284        .local_get(7)
285        .local_get(6)
286        .i32_ge_u()
287        .br_if(1)
288        .local_get(4)
289        .local_get(7)
290        .i32_add()
291        .local_get(0)
292        .local_get(7)
293        .i32_add()
294        .i32_load8_u(mem)
295        .i32_store8(mem)
296        .local_get(7)
297        .i32_const(1)
298        .i32_add()
299        .local_set(7)
300        .br(0)
301        .end()
302        .end()
303        .local_get(4)
304        .end();
305    f
306}
307
308/// Read a LEB128 u32 from `bytes`, returning (value, length).
309fn read_uleb(bytes: &[u8]) -> Result<(u32, usize)> {
310    let mut value: u32 = 0;
311    let mut shift = 0;
312    for (i, &b) in bytes.iter().enumerate().take(5) {
313        value |= u32::from(b & 0x7F) << shift;
314        if b & 0x80 == 0 {
315            return Ok((value, i + 1));
316        }
317        shift += 7;
318    }
319    bail!("malformed LEB128 count in section (#418)");
320}
321
322fn write_uleb(mut value: u32, out: &mut Vec<u8>) {
323    loop {
324        let mut b = (value & 0x7F) as u8;
325        value >>= 7;
326        if value != 0 {
327            b |= 0x80;
328        }
329        out.push(b);
330        if value == 0 {
331            return;
332        }
333    }
334}
335
336fn write_sleb(mut value: i32, out: &mut Vec<u8>) {
337    loop {
338        let b = (value & 0x7F) as u8;
339        value >>= 7;
340        let sign = b & 0x40;
341        if (value == 0 && sign == 0) || (value == -1 && sign != 0) {
342            out.push(b);
343            return;
344        }
345        out.push(b | 0x80);
346    }
347}
348
349/// A raw section body with one entry PREPENDED (count bumped, existing
350/// entries byte-copied verbatim).
351fn prepend_entry(contents: &[u8], entry: &[u8]) -> Result<Vec<u8>> {
352    let (count, len) = read_uleb(contents)?;
353    let mut out = Vec::with_capacity(contents.len() + entry.len() + 1);
354    write_uleb(count + 1, &mut out);
355    out.extend_from_slice(entry);
356    out.extend_from_slice(&contents[len..]);
357    Ok(out)
358}
359
360/// A raw section body with one entry APPENDED (count bumped, existing
361/// entries byte-copied verbatim).
362fn append_entry(contents: &[u8], entry: &[u8]) -> Result<Vec<u8>> {
363    let (count, len) = read_uleb(contents)?;
364    let mut out = Vec::with_capacity(contents.len() + entry.len() + 1);
365    write_uleb(count + 1, &mut out);
366    out.extend_from_slice(&contents[len..]);
367    out.extend_from_slice(entry);
368    Ok(out)
369}
370
371/// The encoded cursor global entry: `(global (mut i32) (i32.const base))`.
372fn cursor_global_entry(arena_base: u32) -> Vec<u8> {
373    let mut e = vec![0x7F, 0x01, 0x41]; // valtype i32, mutable, i32.const
374    write_sleb(arena_base as i32, &mut e);
375    e.push(0x0B); // end
376    e
377}
378
379/// Bind a sole `env::__cabi_arena_realloc` function import to a synthesized
380/// in-module arena allocator (#418). See the module docs for the contract.
381///
382/// - `Ok(NoArenaImport)` / `Ok(KeptHostSeam)` — pass the original bytes
383///   through byte-identically (the latter: arena import present but the
384///   module has OTHER imports too, so it cannot self-contain regardless).
385/// - `Ok(Bound)` — compile the returned bytes instead.
386/// - `Err` — the arena import is present and this is the self-contained
387///   path, but the module is not soundly bindable: refuse LOUDLY rather
388///   than emit a silently-degraded object.
389pub fn bind_cabi_arena_realloc(wasm: &[u8]) -> Result<ArenaBind> {
390    let s = scan(wasm)?;
391    let Some(arena_type_idx) = s.arena_type_idx else {
392        return Ok(ArenaBind::NoArenaImport);
393    };
394    if s.total_imports > 1 {
395        // Other embedder imports remain — the image cannot self-contain, so
396        // the arena import stays on the documented pass-through seam
397        // (undefined symbol, host-linked) alongside them.
398        return Ok(ArenaBind::KeptHostSeam(
399            "module has other imports — keeping the host-linked seam",
400        ));
401    }
402    if !s.arena_sig_ok {
403        bail!(
404            "#418: env::{ARENA_IMPORT_FIELD} is imported with a signature \
405             other than (i32, i32, i32, i32) -> i32 — not the canonical-ABI \
406             arena realloc contract; refusing to bind (compile with \
407             --no-bind-cabi-arena to keep it an external symbol)"
408        );
409    }
410    let Some(pages) = s.memory_pages else {
411        bail!(
412            "#418: cannot bind env::{ARENA_IMPORT_FIELD}: the module declares \
413             no linear memory to allocate from"
414        );
415    };
416    if s.memory64 {
417        bail!("#418: cannot bind env::{ARENA_IMPORT_FIELD}: memory64 module");
418    }
419    if s.non_const_data_offset {
420        bail!(
421            "#418: cannot bind env::{ARENA_IMPORT_FIELD}: a data segment has \
422             a non-constant offset, so the static-data extent (the arena \
423             floor) cannot be derived soundly"
424        );
425    }
426    if !s.has_function_section || !s.has_code_section {
427        bail!(
428            "#418: cannot bind env::{ARENA_IMPORT_FIELD}: the module defines \
429             no functions (nothing synth could route the binding through)"
430        );
431    }
432
433    let arena_end: u32 = u32::try_from(pages.saturating_mul(64 * 1024))
434        .unwrap_or(u32::MAX)
435        .min(0xFFFF_0000);
436    // Arena floor: above every byte the module statically claims — active
437    // data segments plus the i32-const global-init class (__stack_pointer
438    // top, wasm-ld's __heap_base/__data_end), mirroring the shipped
439    // used-extent rule. Floor 16, rounded up to 16.
440    let global_top = s
441        .global_inits
442        .iter()
443        .copied()
444        .filter(|&v| v <= arena_end)
445        .max()
446        .unwrap_or(0);
447    let arena_base = s.data_end.max(global_top).max(16).next_multiple_of(16);
448    if arena_base >= arena_end {
449        bail!(
450            "#418: cannot bind env::{ARENA_IMPORT_FIELD}: the static layout \
451             (data + stack + wasm-ld layout globals) extends to {arena_base} \
452             bytes but linear memory is only {arena_end} bytes — no arena \
453             region left; every allocation would trap"
454        );
455    }
456
457    // The cursor global goes at the END of the global index space; with the
458    // sole import removed there are no imported globals, so its index is the
459    // defined-global count.
460    let cursor_global = s.defined_globals;
461    let mut body = Vec::new();
462    wasm_encoder::Encode::encode(&allocator_body(cursor_global, arena_end), &mut body);
463
464    // ── Rewrite pass: byte-copy everything except the four touched sections.
465    let mut module = wasm_encoder::Module::new();
466    let mut global_emitted = false;
467    let mut function_emitted = false;
468    // Insert the (possibly missing) global section at its canonical position:
469    // just before the first section that must FOLLOW it.
470    let ensure_globals = |module: &mut wasm_encoder::Module, emitted: &mut bool| {
471        if !*emitted {
472            let mut out = Vec::new();
473            write_uleb(1, &mut out);
474            out.extend_from_slice(&cursor_global_entry(arena_base));
475            module.section(&wasm_encoder::RawSection {
476                id: wasm_encoder::SectionId::Global as u8,
477                data: &out,
478            });
479            *emitted = true;
480        }
481    };
482
483    for payload in Parser::new(0).parse_all(wasm) {
484        let payload = payload.context("parse wasm (#418 arena-bind rewrite)")?;
485        match &payload {
486            Payload::Version { .. } | Payload::End(_) => {}
487            Payload::ImportSection(_) => {
488                // The sole import is the arena import — drop the section.
489            }
490            Payload::FunctionSection(reader) => {
491                let mut entry = Vec::new();
492                write_uleb(arena_type_idx, &mut entry);
493                let contents = &wasm[section_span(reader.range(), wasm)?];
494                module.section(&wasm_encoder::RawSection {
495                    id: wasm_encoder::SectionId::Function as u8,
496                    data: &prepend_entry(contents, &entry)?,
497                });
498                function_emitted = true;
499            }
500            Payload::GlobalSection(reader) => {
501                let contents = &wasm[section_span(reader.range(), wasm)?];
502                module.section(&wasm_encoder::RawSection {
503                    id: wasm_encoder::SectionId::Global as u8,
504                    data: &append_entry(contents, &cursor_global_entry(arena_base))?,
505                });
506                global_emitted = true;
507            }
508            Payload::ExportSection(_)
509            | Payload::StartSection { .. }
510            | Payload::ElementSection(_)
511            | Payload::DataCountSection { .. }
512            | Payload::DataSection(_) => {
513                ensure_globals(&mut module, &mut global_emitted);
514                copy_raw(&mut module, &payload, wasm)?;
515            }
516            Payload::CodeSectionStart { range, .. } => {
517                ensure_globals(&mut module, &mut global_emitted);
518                // `range` spans the full section contents (count + bodies);
519                // `size` would EXCLUDE the count leb — do not use it here.
520                let contents = &wasm[section_span(range.clone(), wasm)?];
521                module.section(&wasm_encoder::RawSection {
522                    id: wasm_encoder::SectionId::Code as u8,
523                    data: &prepend_entry(contents, &body)?,
524                });
525            }
526            Payload::CodeSectionEntry(_) => {} // consumed via CodeSectionStart
527            other => copy_raw(&mut module, other, wasm)?,
528        }
529    }
530    if !function_emitted {
531        bail!("#418 internal: function section not re-emitted"); // unreachable: scanned above
532    }
533
534    let bytes = module.finish();
535    // Internal gate: the rewrite must be a VALID module — a malformed rewrite
536    // here would otherwise surface as a confusing decode error downstream.
537    wasmparser::Validator::new()
538        .validate_all(&bytes)
539        .context("#418 internal: arena-bind rewrite produced an invalid module (bug)")?;
540    Ok(ArenaBind::Bound(BoundArena {
541        bytes,
542        arena_base,
543        arena_end,
544    }))
545}
546
547/// A wasmparser section range, narrowed to a slice range PROVEN to lie inside
548/// `wasm` — or a loud refusal (#965, RQ-83-RANGEWIDEN).
549///
550/// wasm-tools 0.261 widened `Payload`'s section ranges from `Range<usize>` to
551/// `Range<u64>` for the 64-bit-memory story. `as usize` would compile and
552/// SILENTLY TRUNCATE a malformed 4 GiB-plus offset into a wrong-bytes read,
553/// which is the silent-miscompile class this crate exists to prevent — and it is
554/// invisible to the compiler, because `as` accepts both widths. So the conversion
555/// REFUSES instead.
556///
557/// It checks the END against the buffer too, not just the width: a range that
558/// converts cleanly can still point past the input, and `&wasm[range]` would then
559/// panic rather than return an error. On a 64-bit host the `try_from` arms are
560/// unreachable today; they are the half that stays correct on a 32-bit one.
561fn section_span(range: core::ops::Range<u64>, wasm: &[u8]) -> Result<core::ops::Range<usize>> {
562    let Ok(start) = usize::try_from(range.start) else {
563        bail!(
564            "#965: section range start {} exceeds this platform's usize — \
565             refusing rather than truncating",
566            range.start
567        );
568    };
569    let Ok(end) = usize::try_from(range.end) else {
570        bail!(
571            "#965: section range end {} exceeds this platform's usize — \
572             refusing rather than truncating",
573            range.end
574        );
575    };
576    if start > end || end > wasm.len() {
577        bail!(
578            "#965: section range {start}..{end} does not lie inside a {}-byte \
579             module — refusing rather than indexing out of bounds",
580            wasm.len()
581        );
582    }
583    Ok(start..end)
584}
585
586/// Byte-copy one section verbatim.
587fn copy_raw(module: &mut wasm_encoder::Module, payload: &Payload<'_>, wasm: &[u8]) -> Result<()> {
588    let Some((id, range)) = payload.as_section() else {
589        bail!("#418 internal: unhandled non-section payload {payload:?}");
590    };
591    module.section(&wasm_encoder::RawSection {
592        id,
593        data: &wasm[section_span(range, wasm)?],
594    });
595    Ok(())
596}
597
598#[cfg(test)]
599mod tests {
600    use super::*;
601
602    // ---- section_span: the REFUSAL is the deliverable (#965, RQ-83-RANGEWIDEN) ----
603    //
604    // REACH, stated because it is not uniform across the three arms. The
605    // `end > wasm.len()` / `start > end` arm is reachable and tested below. The two
606    // `usize::try_from` arms are NOT reachable on any 64-bit target, where
607    // `u64 -> usize` is infallible; they exist for a 32-bit host and are UNPROVEN
608    // here. A test cannot be written for them on this platform, and asserting
609    // otherwise would be the vacuous-green class this file's own lane exists to catch.
610
611    #[test]
612    fn section_span_refuses_a_range_past_the_module_end() {
613        let wasm = [0u8; 10];
614        let err = section_span(0..100, &wasm).expect_err("a 0..100 range in 10 bytes must refuse");
615        let msg = format!("{err}");
616        assert!(
617            msg.contains("does not lie inside") && msg.contains("10-byte"),
618            "the refusal must name the overrun and the real module size, got: {msg}"
619        );
620    }
621
622    #[test]
623    fn section_span_refuses_an_inverted_range() {
624        let wasm = [0u8; 10];
625        // The bounds come from locals rather than literals: clippy's
626        // `reversed_empty_ranges` correctly flags a literal `8..4` as an empty
627        // range, and its suggested `(4..8).rev()` is a DIFFERENT subject — this
628        // test is about an inverted range reaching the helper, not about
629        // iteration.
630        let (start, end) = (8u64, 4u64);
631        let err = section_span(start..end, &wasm).expect_err("start > end must refuse");
632        assert!(
633            format!("{err}").contains("does not lie inside"),
634            "an inverted range must refuse rather than panic or wrap"
635        );
636    }
637
638    #[test]
639    fn section_span_refuses_one_byte_past_the_end() {
640        // The off-by-one is the interesting boundary: `end == len` is legal,
641        // `end == len + 1` is not. A `<` instead of `<=` here would pass the
642        // 0..100 case above and still index out of bounds.
643        let wasm = [0u8; 10];
644        assert!(
645            section_span(0..10, &wasm).is_ok(),
646            "end == len must be accepted"
647        );
648        assert!(
649            section_span(0..11, &wasm).is_err(),
650            "end == len + 1 must refuse"
651        );
652    }
653
654    #[test]
655    fn section_span_accepts_an_in_bounds_range_and_preserves_its_bounds() {
656        let wasm = [0u8; 10];
657        let span = section_span(2..7, &wasm).expect("an in-bounds range must be accepted");
658        assert_eq!(
659            span,
660            2usize..7usize,
661            "the narrowing must not move the bounds"
662        );
663        // An empty span inside the buffer is legal and must stay empty.
664        assert_eq!(section_span(5..5, &wasm).unwrap(), 5usize..5usize);
665        assert!(
666            wasm[span].len() == 5,
667            "the returned span must actually index"
668        );
669    }
670
671    #[test]
672    fn section_span_refuses_rather_than_truncating_the_value_an_as_cast_would_discard() {
673        // POSITIVE CONTROL for the whole point of the helper. `0x1_0000_0003 as usize`
674        // is lossless on a 64-bit host, so the danger here is not the cast but the
675        // BOUNDS: an offset far past a small module must refuse, where
676        // `&wasm[start..end]` would have panicked and `as u32` would have silently
677        // read byte 3. Both wrong answers are excluded by one check.
678        let wasm = [0u8; 10];
679        let err = section_span(0x1_0000_0003..0x1_0000_0007, &wasm)
680            .expect_err("a 4 GiB-plus offset into a 10-byte module must refuse");
681        assert!(
682            format!("{err}").contains("#965"),
683            "the refusal must cite its analysis"
684        );
685    }
686
687    fn fixture() -> Vec<u8> {
688        wat::parse_str(
689            r#"(module
690                 (import "env" "__cabi_arena_realloc"
691                   (func $arena (param i32 i32 i32 i32) (result i32)))
692                 (memory (export "memory") 1)
693                 (global $sp (mut i32) (i32.const 4096))
694                 (global (export "__heap_base") i32 (i32.const 6144))
695                 (data (i32.const 5120) "0123456789abcdef")
696                 (func (export "cabi_realloc") (param i32 i32 i32 i32) (result i32)
697                   local.get 0 local.get 1 local.get 2 local.get 3 call $arena))"#,
698        )
699        .unwrap()
700    }
701
702    #[test]
703    fn binds_sole_arena_import() {
704        let ArenaBind::Bound(b) = bind_cabi_arena_realloc(&fixture()).unwrap() else {
705            panic!("expected Bound");
706        };
707        // base above data end (5136) and __heap_base (6144), 16-aligned
708        assert_eq!(b.arena_base, 6144);
709        assert_eq!(b.arena_end, 65536);
710        // no imports remain; index space preserved (cabi_realloc still calls
711        // function 0, which is now the DEFINED allocator).
712        let mut num_imports = 0;
713        let mut num_funcs = 0;
714        let mut num_globals = 0;
715        for p in Parser::new(0).parse_all(&b.bytes) {
716            match p.unwrap() {
717                Payload::ImportSection(r) => num_imports += r.count(),
718                Payload::FunctionSection(r) => num_funcs = r.count(),
719                Payload::GlobalSection(r) => num_globals = r.count(),
720                _ => {}
721            }
722        }
723        assert_eq!(num_imports, 0);
724        assert_eq!(num_funcs, 2); // allocator + cabi_realloc
725        assert_eq!(num_globals, 3); // sp, __heap_base, cursor
726    }
727
728    #[test]
729    fn bound_module_executes_contract() {
730        // The rewritten module must satisfy the #418 contract under a real
731        // wasm interpreter — validated structurally here (full execution
732        // differential: scripts/repro/cabi_arena_bind_418_differential.py).
733        let ArenaBind::Bound(b) = bind_cabi_arena_realloc(&fixture()).unwrap() else {
734            panic!("expected Bound");
735        };
736        wasmparser::Validator::new().validate_all(&b.bytes).unwrap();
737    }
738
739    #[test]
740    fn no_arena_import_passes_through() {
741        let wasm =
742            wat::parse_str(r#"(module (memory 1) (func (export "f") (result i32) i32.const 7))"#)
743                .unwrap();
744        assert!(matches!(
745            bind_cabi_arena_realloc(&wasm).unwrap(),
746            ArenaBind::NoArenaImport
747        ));
748    }
749
750    #[test]
751    fn other_imports_keep_host_seam() {
752        let wasm = wat::parse_str(
753            r#"(module
754                 (import "env" "k_spin_lock" (func (param i32)))
755                 (import "env" "__cabi_arena_realloc"
756                   (func $arena (param i32 i32 i32 i32) (result i32)))
757                 (memory 1)
758                 (func (export "f") (param i32 i32 i32 i32) (result i32)
759                   local.get 0 local.get 1 local.get 2 local.get 3 call $arena))"#,
760        )
761        .unwrap();
762        assert!(matches!(
763            bind_cabi_arena_realloc(&wasm).unwrap(),
764            ArenaBind::KeptHostSeam(_)
765        ));
766    }
767
768    #[test]
769    fn wrong_signature_declines_loudly() {
770        let wasm = wat::parse_str(
771            r#"(module
772                 (import "env" "__cabi_arena_realloc"
773                   (func $arena (param i32 i32) (result i32)))
774                 (memory 1)
775                 (func (export "f") (param i32 i32) (result i32)
776                   local.get 0 local.get 1 call $arena))"#,
777        )
778        .unwrap();
779        let err = bind_cabi_arena_realloc(&wasm).unwrap_err().to_string();
780        assert!(err.contains("#418"), "{err}");
781        assert!(err.contains("signature"), "{err}");
782    }
783
784    #[test]
785    fn no_memory_declines_loudly() {
786        let wasm = wat::parse_str(
787            r#"(module
788                 (import "env" "__cabi_arena_realloc"
789                   (func $arena (param i32 i32 i32 i32) (result i32)))
790                 (func (export "f") (result i32)
791                   i32.const 0 i32.const 0 i32.const 8 i32.const 4 call $arena))"#,
792        )
793        .unwrap();
794        let err = bind_cabi_arena_realloc(&wasm).unwrap_err().to_string();
795        assert!(err.contains("no linear memory"), "{err}");
796    }
797
798    #[test]
799    fn full_static_layout_declines_loudly() {
800        // __heap_base == memory size: no arena region left.
801        let wasm = wat::parse_str(
802            r#"(module
803                 (import "env" "__cabi_arena_realloc"
804                   (func $arena (param i32 i32 i32 i32) (result i32)))
805                 (memory 1)
806                 (global (export "__heap_base") i32 (i32.const 65536))
807                 (func (export "f") (result i32)
808                   i32.const 0 i32.const 0 i32.const 8 i32.const 4 call $arena))"#,
809        )
810        .unwrap();
811        let err = bind_cabi_arena_realloc(&wasm).unwrap_err().to_string();
812        assert!(err.contains("no arena region left"), "{err}");
813    }
814
815    #[test]
816    fn module_without_globals_gets_global_section() {
817        let wasm = wat::parse_str(
818            r#"(module
819                 (import "env" "__cabi_arena_realloc"
820                   (func $arena (param i32 i32 i32 i32) (result i32)))
821                 (memory 1)
822                 (data (i32.const 64) "xyzw")
823                 (func (export "f") (result i32)
824                   i32.const 0 i32.const 0 i32.const 8 i32.const 4 call $arena))"#,
825        )
826        .unwrap();
827        let ArenaBind::Bound(b) = bind_cabi_arena_realloc(&wasm).unwrap() else {
828            panic!("expected Bound");
829        };
830        assert_eq!(b.arena_base, 80); // data end 68 -> 16-aligned
831        let mut num_globals = 0;
832        for p in Parser::new(0).parse_all(&b.bytes) {
833            if let Payload::GlobalSection(r) = p.unwrap() {
834                num_globals = r.count();
835            }
836        }
837        assert_eq!(num_globals, 1);
838    }
839}