Skip to main content

synth_core/
static_data_addr.rs

1//! Static-data addressing validation (VCR-VER-003, synth #777 / #757).
2//!
3//! WASM active data segments are applied to linear memory **in declaration
4//! order, later-wins**: when two active segments overlap the same range, the
5//! later-declared one overwrites the earlier. synth's `--native-pointer-abi`
6//! relocatable path splits the linear memory into one packed `.data` blob per
7//! segment (`__synth_wasm_seg_K`) and retargets every static-data relocation
8//! `__synth_wasm_data + C` to `__synth_wasm_seg_K + (C - seg_off_K)` (the #354
9//! mixed-split). Choosing the WRONG owning segment for an address `C` that lies
10//! in several overlapping segments is a **silent miscompile**: the reloc reads
11//! a stale earlier segment's bytes instead of the byte the runtime image holds.
12//!
13//! This is exactly #757 — gale's fused `gust:os` node declared three active
14//! segments all at linmem `0x100000`; the string lived in the last segment but
15//! the retargeting (`.position()`) bound its reads to the first segment's
16//! consts (`got=[2,0,0,0,..]` = `__synth_wasm_seg_0+8`). It survived four
17//! releases because value-differential oracles are coverage-limited (7
18//! synthetic reconstructions were all green). The fix resolves overlapping
19//! addresses to the LAST-declared owner (`.rposition()`).
20//!
21//! # What this validator proves (per compilation, by construction)
22//!
23//! Given the module's active data segments (declaration order) and the
24//! retargeting the compiler actually emitted — one [`RelocResolution`]
25//! `(seg_index K, addend A)` per static-data reloc — it reconstructs the
26//! RUNTIME linear-memory image (apply every segment in declaration order,
27//! later-wins) **independently of K**, then asserts: the byte the packed
28//! `.data` serves for that reloc (`seg[K].bytes[A]`) EQUALS the byte the
29//! runtime image holds at the reloc's original access address
30//! (`seg[K].off + A`). A single mismatch — the wrong-segment resolution — is a
31//! [`Verdict::Mismatch`].
32//!
33//! # Concrete, not symbolic; unconditional
34//!
35//! This is a concrete byte-equality over a compiled object, not a ∀-inputs SMT
36//! property, and it depends on nothing but `std` — so it lives in `synth-core`
37//! and runs on **every** compilation (the shipping build is `--features riscv`,
38//! *not* `verify`; a `verify`-gated check would stay dormant in exactly the
39//! build that shipped #757 four times). It mirrors VCR-VER-002's *structure* (a
40//! verdict enum + a per-compilation gate) but does the honest thing — a direct
41//! comparison against an independently reconstructed truth image. The truth
42//! side never touches `K`, so the validator cannot be satisfied by mirroring
43//! the code under test (the mirror-pinning vacuity mode is structurally
44//! excluded). `synth-verify` re-exports this module for the VCR-VER-003 tests.
45//!
46//! # Phase 2 (#777 follow-ups)
47//!
48//! Phase 1 validated the single resolved **addend byte** per reloc. Phase 2
49//! extends coverage to the named follow-up classes:
50//!
51//! 1. **Multi-byte access spans** ([`validate_reloc_resolutions_spanned`]):
52//!    a reloc whose addend byte is runtime-correct can still mis-serve TAIL
53//!    bytes — an i32/i64 load starting in segment `K` whose span crosses into
54//!    a range a LATER-declared segment owns at runtime (staggered overlap), or
55//!    crosses `K`'s packed end into 4-align padding / the next *declared*
56//!    (not next *linmem*) segment. The access width is not recorded on
57//!    [`crate::backend::CodeRelocation`] (the Abs32 literal is a pointer; its
58//!    consumers are ldrb/ldrh/ldr/ldrd), so the span is validated
59//!    conservatively out to [`MAX_ACCESS_BYTES`] with one deliberate
60//!    tolerance: a span byte whose runtime address NO segment covers is
61//!    skipped (implicit-zero linear memory — flagging it would hard-error the
62//!    ubiquitous "pointer near the end of a sparse segment, narrow access"
63//!    shape). A span byte that IS runtime-covered must match what the packed
64//!    blob actually serves at that position, byte-for-byte, so the served
65//!    side reads the EMITTED init blob ([`PackedInit`]) — never a recompute.
66//! 2. **Dense served images** ([`validate_served_image`] /
67//!    [`pack_rom_image`]): the self-contained `--cortex-m` ROM-copy layout
68//!    (#758) serves linear memory from ONE dense flash blob copied to RAM at
69//!    reset — index = linmem offset, so spans/overlaps are structurally
70//!    preserved and the whole obligation reduces to "every blob byte equals
71//!    the runtime image byte (later-wins, zero elsewhere)". The RISC-V
72//!    single-base scheme (#798) ships its active segments as SPARSE
73//!    per-segment records ([`pack_segment_records`], a `.wasm_data` PROGBITS
74//!    section in flash) which the generated startup copies to `s11 + off` in
75//!    record order at reset; the emit path READS BACK the emitted blob
76//!    ([`served_image_from_records`] — never a recompute, that would
77//!    mirror-pin the check) into the dense served image and runs the same
78//!    gate, hard-erroring the compile on any served/runtime disagreement.
79//!    An EMPTY image models a target that ships no initializer bytes at all
80//!    (zeroed RAM serves every address): any nonzero runtime-image byte is
81//!    then a served/runtime mismatch (the silent initializer-drop this
82//!    validator caught on the pre-#798 RV32 path).
83//! 3. **AArch64: N/A** — the `-b aarch64` backend HAS bounds-checked
84//!    linear-memory loads/stores (v0.52 #865), but it emits no data section
85//!    at all and REFUSES a module carrying active data segments loudly
86//!    (#851, `synth-backend-aarch64/src/backend.rs`), so there is no
87//!    served-vs-runtime image to compare; nothing to validate.
88
89use std::collections::HashMap;
90
91/// The widest scalar linear-memory access synth can emit (i64.load /
92/// i64.store — there is no v128 support on these paths). Conservative span
93/// bound used when a reloc's true access width is unknown.
94pub const MAX_ACCESS_BYTES: u32 = 8;
95
96/// One active WASM data segment: its linear-memory offset and its bytes, in
97/// declaration order. The packed `.data` blob stores these bytes verbatim
98/// (4-aligned per segment) under `__synth_wasm_seg_K`; index `K` in the segment
99/// list is the `K` in the symbol name.
100#[derive(Clone, Debug)]
101pub struct DataSegment {
102    /// Linear-memory offset the active segment is applied at (WASM `i32.const`).
103    pub linmem_off: u32,
104    /// The segment's initializer bytes.
105    pub bytes: Vec<u8>,
106}
107
108/// The retargeting the compiler emitted for one static-data relocation: it now
109/// points at `__synth_wasm_seg_{seg_index} + addend`. `seg_index` is the `K`
110/// from the emitted symbol name; `addend` is the emitted in-place REL addend
111/// (`= original_access_addr - seg[K].linmem_off`). This is the value read back
112/// from what the compiler produced — NEVER recomputed by the validator (that
113/// would mirror-pin the check and make it vacuous).
114#[derive(Clone, Debug)]
115pub struct RelocResolution {
116    /// The `K` in the emitted `__synth_wasm_seg_K` symbol.
117    pub seg_index: usize,
118    /// The emitted addend (offset into `seg[K].bytes`).
119    pub addend: u32,
120    /// Optional label for diagnostics (e.g. `"func 3 @ 0x1a"`); not load-bearing.
121    pub label: String,
122}
123
124/// The verdict of the addressing gate.
125#[derive(Clone, Debug, PartialEq, Eq)]
126pub enum Verdict {
127    /// Every static-data reloc resolves to the runtime-correct byte (segments
128    /// applied in declaration order, later-wins). #757 cannot occur.
129    Consistent,
130    /// A reloc resolves to a byte that disagrees with the runtime image — the
131    /// wrong-segment miscompile. Carries the offending resolutions.
132    Mismatch(Vec<AddrMismatch>),
133}
134
135/// A single reloc that reads the wrong byte.
136#[derive(Clone, Debug, PartialEq, Eq)]
137pub struct AddrMismatch {
138    /// The reloc's diagnostic label.
139    pub label: String,
140    /// The emitted `K` (`__synth_wasm_seg_K`).
141    pub seg_index: usize,
142    /// The emitted addend.
143    pub addend: u32,
144    /// The original linear-memory access address of the OFFENDING byte
145    /// (`seg[K].off + addend + span_byte`).
146    pub access_addr: u32,
147    /// The byte the packed `.data` serves at that position.
148    pub served: u8,
149    /// The byte the runtime image holds at `access_addr` (the truth).
150    pub runtime: u8,
151    /// Which byte of the (potentially multi-byte) access diverges: 0 = the
152    /// addend byte itself (the phase-1 check), 1..[`MAX_ACCESS_BYTES`] = a
153    /// tail byte of a conservatively-widened span (phase 2).
154    pub span_byte: u32,
155}
156
157impl AddrMismatch {
158    /// A human-readable one-line diagnostic for the compile-time error.
159    pub fn describe(&self) -> String {
160        let span = if self.span_byte == 0 {
161            String::new()
162        } else {
163            format!(
164                " (span byte +{} of a possibly {}-byte access)",
165                self.span_byte, MAX_ACCESS_BYTES
166            )
167        };
168        format!(
169            "{}: __synth_wasm_seg_{}+0x{:x} -> linmem 0x{:x}{span} serves 0x{:02x} but \
170             the runtime image (segments applied later-wins) owns 0x{:02x}",
171            self.label, self.seg_index, self.addend, self.access_addr, self.served, self.runtime
172        )
173    }
174}
175
176/// Reconstruct the runtime linear-memory image: apply every active segment in
177/// declaration order, later-wins. This is the ground truth and is derived only
178/// from the segment list — never from any reloc resolution.
179fn runtime_image(segments: &[DataSegment]) -> HashMap<u32, u8> {
180    let mut mem = HashMap::new();
181    for seg in segments {
182        for (j, &b) in seg.bytes.iter().enumerate() {
183            mem.insert(seg.linmem_off + j as u32, b);
184        }
185    }
186    mem
187}
188
189/// The per-compilation addressing gate. For every emitted [`RelocResolution`],
190/// assert the packed byte it serves equals the runtime-image byte at the
191/// original access address. See the module docs for the invariant.
192///
193/// Returns [`Verdict::Consistent`] if every reloc agrees, else
194/// [`Verdict::Mismatch`] carrying each offending reloc. Resolutions whose
195/// `seg_index`/`addend` are out of range are reported as mismatches (an
196/// out-of-range resolution is itself a broken retargeting).
197pub fn validate_reloc_resolutions(
198    segments: &[DataSegment],
199    resolutions: &[RelocResolution],
200) -> Verdict {
201    let runtime = runtime_image(segments);
202    let mut bad = Vec::new();
203    for r in resolutions {
204        let Some(seg) = segments.get(r.seg_index) else {
205            bad.push(AddrMismatch {
206                label: r.label.clone(),
207                seg_index: r.seg_index,
208                addend: r.addend,
209                access_addr: 0,
210                served: 0,
211                runtime: 0,
212                span_byte: 0,
213            });
214            continue;
215        };
216        let access_addr = seg.linmem_off + r.addend;
217        // The byte the packed .data serves for this reloc.
218        let Some(&served) = seg.bytes.get(r.addend as usize) else {
219            bad.push(AddrMismatch {
220                label: r.label.clone(),
221                seg_index: r.seg_index,
222                addend: r.addend,
223                access_addr,
224                served: 0,
225                runtime: 0,
226                span_byte: 0,
227            });
228            continue;
229        };
230        // The byte the runtime image (independent of K) holds there.
231        // Every retargeted reloc addresses a byte inside some segment, so the
232        // runtime image is always defined at access_addr; a missing entry would
233        // itself be a broken retargeting, so treat it as a mismatch.
234        let Some(&runtime_byte) = runtime.get(&access_addr) else {
235            bad.push(AddrMismatch {
236                label: r.label.clone(),
237                seg_index: r.seg_index,
238                addend: r.addend,
239                access_addr,
240                served,
241                runtime: 0,
242                span_byte: 0,
243            });
244            continue;
245        };
246        if served != runtime_byte {
247            bad.push(AddrMismatch {
248                label: r.label.clone(),
249                seg_index: r.seg_index,
250                addend: r.addend,
251                access_addr,
252                served,
253                runtime: runtime_byte,
254                span_byte: 0,
255            });
256        }
257    }
258    if bad.is_empty() {
259        Verdict::Consistent
260    } else {
261        Verdict::Mismatch(bad)
262    }
263}
264
265/// Resolve an access address `c` to its owning segment index under a chosen
266/// tie-break policy, mirroring main.rs's `.rposition()` / `.position()` search.
267/// `last_wins = true` is the CORRECT WASM overwrite semantics (`.rposition()`);
268/// `last_wins = false` is the #757 miscompile (`.position()`). Returns the
269/// segment index and the addend `c - seg.linmem_off`, or `None` if `c` is in no
270/// segment. Exposed so the red-first gate can toggle the policy as an argument
271/// (no source revert), and so callers can build resolutions the same way the
272/// compiler does.
273pub fn resolve_owner(segments: &[DataSegment], c: u32, last_wins: bool) -> Option<RelocResolution> {
274    let hit = |(off, len): (u32, usize)| c >= off && c < off + len as u32;
275    let idx = if last_wins {
276        segments
277            .iter()
278            .rposition(|s| hit((s.linmem_off, s.bytes.len())))
279    } else {
280        segments
281            .iter()
282            .position(|s| hit((s.linmem_off, s.bytes.len())))
283    }?;
284    Some(RelocResolution {
285        seg_index: idx,
286        addend: c - segments[idx].linmem_off,
287        label: format!("addr 0x{c:x}"),
288    })
289}
290
291/// The EMITTED packed-`.data` init region of the #354 mixed split: each
292/// segment's bytes at its 4-aligned packed offset, in declaration order,
293/// EXCLUDING the trailing `__synth_globals` slots. Both fields are read back
294/// from what the compiler actually laid out / filled — the validator never
295/// recomputes the packing (that would mirror-pin the check).
296#[derive(Clone, Debug)]
297pub struct PackedInit<'a> {
298    /// Packed offset of each segment inside the init region (declaration
299    /// order, parallel to the segment list).
300    pub seg_packed_off: &'a [u32],
301    /// The init-region bytes the object will ship (segments + 4-align
302    /// padding). A span byte served from BEYOND this region (the globals
303    /// slots, or past the blob) can never be a linear-memory byte.
304    pub bytes: &'a [u8],
305}
306
307/// Phase-2 (#777) per-compilation addressing gate: the phase-1 addend-byte
308/// check PLUS a conservative multi-byte span check per reloc.
309///
310/// For every emitted resolution `(K, A)` and every span byte
311/// `j in 0..`[`MAX_ACCESS_BYTES`]:
312///
313/// - the byte SERVED is read from the emitted init blob at
314///   `packed.seg_packed_off[K] + A + j` (the real artifact — for `j = 0` this
315///   also pins the blob fill itself: a blob that doesn't hold `seg[K].bytes`
316///   verbatim fails here);
317/// - the byte OWED is the runtime image at `seg[K].off + A + j` (segments
318///   applied in declaration order, later-wins, independent of `K`).
319///
320/// `j = 0` keeps phase-1 semantics exactly (a missing byte on either side is
321/// a broken retargeting → mismatch). For `j > 0` the access width is unknown
322/// (see the module docs), so one tolerance applies: when NO segment covers
323/// the runtime address, the byte is implicit-zero linear memory and the span
324/// byte is SKIPPED — a wide access genuinely reaching there would read packed
325/// neighbours instead of zeros, but flagging it would hard-error the common
326/// "pointer near a sparse segment's end, narrow access" shape; exact checking
327/// of that residue needs a recorded access width (named follow-up). When the
328/// runtime address IS covered by some segment, the served byte must match —
329/// including bytes past `K`'s packed end (4-align padding or the next
330/// *declared* segment) and bytes that escape the init region entirely (both
331/// are exactly how a straddling access mis-serves).
332pub fn validate_reloc_resolutions_spanned(
333    segments: &[DataSegment],
334    resolutions: &[RelocResolution],
335    packed: &PackedInit<'_>,
336) -> Verdict {
337    let runtime = runtime_image(segments);
338    let mut bad = Vec::new();
339    // Phase-1 addend-byte check (byte 0, strict on both sides).
340    if let Verdict::Mismatch(m) = validate_reloc_resolutions(segments, resolutions) {
341        bad.extend(m);
342    }
343    for r in resolutions {
344        let Some(seg) = segments.get(r.seg_index) else {
345            continue; // already reported by the phase-1 pass
346        };
347        let Some(&poff) = packed.seg_packed_off.get(r.seg_index) else {
348            continue; // impossible when layout and segments are parallel
349        };
350        for j in 0..MAX_ACCESS_BYTES {
351            let access_addr = seg.linmem_off.wrapping_add(r.addend).wrapping_add(j);
352            // Unknown-width tolerance: runtime-uncovered ⇒ implicit zero ⇒ skip
353            // (for j = 0 a missing runtime byte was already flagged by the
354            // strict phase-1 pass above).
355            let Some(&runtime_byte) = runtime.get(&access_addr) else {
356                continue;
357            };
358            // j = 0: the phase-1 pass already reported a divergent SEGMENT
359            // byte; re-checking here would double-report it. Only the blob
360            // side remains to pin — fall through when the segment byte is
361            // phase-1-green so a blob-fill bug (blob ≠ seg[K].bytes at the
362            // addend byte) still fails.
363            if j == 0 && seg.bytes.get(r.addend as usize) != Some(&runtime_byte) {
364                continue;
365            }
366            let p = poff as usize + r.addend as usize + j as usize;
367            // Served byte: the emitted blob, or "not linear memory at all"
368            // when the span escapes the init region (globals slots / past the
369            // blob) — that escape can never serve a runtime-covered byte.
370            let served = packed.bytes.get(p).copied();
371            if served != Some(runtime_byte) {
372                bad.push(AddrMismatch {
373                    label: r.label.clone(),
374                    seg_index: r.seg_index,
375                    addend: r.addend,
376                    access_addr,
377                    served: served.unwrap_or(0),
378                    runtime: runtime_byte,
379                    span_byte: j,
380                });
381            }
382        }
383    }
384    if bad.is_empty() {
385        Verdict::Consistent
386    } else {
387        Verdict::Mismatch(bad)
388    }
389}
390
391/// The verdict of a dense served-image gate ([`validate_served_image`]).
392#[derive(Clone, Debug, PartialEq, Eq)]
393pub enum ImageVerdict {
394    /// Every linear-memory byte the image (or zeroed RAM) serves equals the
395    /// runtime image byte.
396    Consistent,
397    /// At least one served byte disagrees with the runtime image.
398    Mismatch(Vec<ImageMismatch>),
399}
400
401/// One dense-image byte that disagrees with the runtime image.
402#[derive(Clone, Debug, PartialEq, Eq)]
403pub struct ImageMismatch {
404    /// The linear-memory address (= image index) of the offending byte.
405    pub addr: u32,
406    /// The byte the image serves (0 when the image doesn't reach `addr` —
407    /// zeroed RAM / no initializer shipped).
408    pub served: u8,
409    /// The byte the runtime image holds there (the truth).
410    pub runtime: u8,
411}
412
413impl ImageMismatch {
414    /// A human-readable one-line diagnostic.
415    pub fn describe(&self) -> String {
416        format!(
417            "linmem 0x{:x} serves 0x{:02x} but the runtime image (segments \
418             applied later-wins) owns 0x{:02x}",
419            self.addr, self.served, self.runtime
420        )
421    }
422}
423
424/// The verdict of the self-contained linmem↔globals disjointness geometry gate
425/// ([`validate_linmem_globals_disjoint`], VCR-VER-003 / #761).
426#[derive(Clone, Debug, PartialEq, Eq)]
427pub enum LayoutVerdict {
428    /// The globals table sits entirely at or above the function-visible linear
429    /// memory page ceiling — the two regions cannot alias.
430    Disjoint,
431    /// The globals table base falls INSIDE the function-visible linmem page:
432    /// a store to the overlapping tail of the page would alias a global slot
433    /// (or vice versa) — a silent wrong-value miscompile.
434    Overlap {
435        /// The base the compiled functions address linear memory at
436        /// (`optimized_linmem_base`, = R11 + 0x100 under the #687 contract).
437        func_visible_linmem_base: u32,
438        /// The size of the function-visible linear-memory page in bytes
439        /// (`initial_pages * 64 KiB`).
440        linmem_bytes: u32,
441        /// The absolute base the startup points R9 at (the globals table).
442        globals_base: u32,
443        /// How many bytes the two regions overlap by (the tail of the page
444        /// that aliases the table).
445        overlap_bytes: u32,
446    },
447}
448
449/// VCR-VER-003 geometry gate (#761): on the self-contained `--cortex-m` image
450/// the R9 globals table MUST sit entirely at or above the function-visible
451/// linear-memory page ceiling. The compiled functions address linear memory
452/// starting at `func_visible_linmem_base` (= the startup R11 base + 0x100, the
453/// #687 gap) and can reach any byte in `[base, base + linmem_bytes)`; the
454/// startup materializes the globals table at `globals_base`. If
455/// `globals_base < func_visible_linmem_base + linmem_bytes`, a store to the
456/// overlapping tail of the page lands on a global slot — a silent
457/// global↔linmem ALIAS (the worst class). This is a pure geometry invariant
458/// (no bytes involved), unconditional, and complements the served-image byte
459/// gate above: it catches the placement bug, not a packing bug.
460///
461/// `globals_base == func_visible_linmem_base + linmem_bytes` (table exactly at
462/// the ceiling) is DISJOINT — the page is `[base, base + linmem_bytes)`,
463/// half-open, so its last addressable byte is `base + linmem_bytes - 1`.
464/// No-globals modules (`globals_bytes == 0`) are trivially disjoint.
465pub fn validate_linmem_globals_disjoint(
466    func_visible_linmem_base: u32,
467    linmem_bytes: u32,
468    globals_base: u32,
469    globals_bytes: u32,
470) -> LayoutVerdict {
471    if globals_bytes == 0 {
472        return LayoutVerdict::Disjoint;
473    }
474    // u64 so a hostile base + size cannot wrap and pass the check spuriously.
475    let ceiling = func_visible_linmem_base as u64 + linmem_bytes as u64;
476    if (globals_base as u64) < ceiling {
477        return LayoutVerdict::Overlap {
478            func_visible_linmem_base,
479            linmem_bytes,
480            globals_base,
481            overlap_bytes: (ceiling - globals_base as u64) as u32,
482        };
483    }
484    LayoutVerdict::Disjoint
485}
486
487/// Total extent of the runtime image: `max(off + len)` over the segments
488/// (u64, so a hostile `off + len` cannot wrap — callers bound-check against
489/// the linear-memory size before packing).
490pub fn image_extent(segments: &[DataSegment]) -> u64 {
491    segments
492        .iter()
493        .map(|s| s.linmem_off as u64 + s.bytes.len() as u64)
494        .max()
495        .unwrap_or(0)
496}
497
498/// Pack the #758 dense ROM init image: a `[0, extent)` blob with every active
499/// segment placed AT its linmem offset. `last_wins = true` applies them in
500/// declaration order (WASM instantiation semantics — later segments overwrite
501/// earlier on overlap); `last_wins = false` applies them in REVERSE order
502/// (first-wins — the synthetic miscompile the red-first gate toggles, phase
503/// 1's `resolve_owner` pattern). The caller must have bound-checked
504/// [`image_extent`] against the linear-memory size (u32 + usize safe here
505/// only after that check).
506pub fn pack_rom_image(segments: &[DataSegment], last_wins: bool) -> Vec<u8> {
507    let mut blob = vec![0u8; image_extent(segments) as usize];
508    let place = |blob: &mut Vec<u8>, s: &DataSegment| {
509        let at = s.linmem_off as usize;
510        blob[at..at + s.bytes.len()].copy_from_slice(&s.bytes);
511    };
512    if last_wins {
513        for s in segments {
514            place(&mut blob, s);
515        }
516    } else {
517        for s in segments.iter().rev() {
518            place(&mut blob, s);
519        }
520    }
521    blob
522}
523
524/// Dense served-image gate: for every address in `[0, image_extent)`, the byte
525/// SERVED — `image[addr]`, or `0` when the image doesn't reach `addr` (zeroed
526/// RAM; an empty `image` models a target that ships NO initializer bytes, the
527/// RISC-V single-base scheme) — must equal the runtime image byte (segments
528/// applied in declaration order, later-wins; implicit zero where uncovered).
529///
530/// The truth side is reconstructed only from the segment list, never from the
531/// image, so the gate cannot be satisfied by mirroring the packing code.
532pub fn validate_served_image(segments: &[DataSegment], image: &[u8]) -> ImageVerdict {
533    let runtime = runtime_image(segments);
534    let mut bad = Vec::new();
535    // Every image byte must equal the runtime byte (covered ⇒ later-wins
536    // segment byte; uncovered ⇒ implicit zero, so initializer garbage in a
537    // gap is caught too).
538    for (addr, &served) in image.iter().enumerate() {
539        let owed = runtime.get(&(addr as u32)).copied().unwrap_or(0);
540        if served != owed {
541            bad.push(ImageMismatch {
542                addr: addr as u32,
543                served,
544                runtime: owed,
545            });
546        }
547    }
548    // Every runtime byte BEYOND the image is served by zeroed RAM, so any
549    // nonzero one is un-served (the shipped-no-initializer mismatch). Walk
550    // the covered addresses only — uncovered beyond-image bytes are 0 == 0.
551    let mut beyond: Vec<(u32, u8)> = runtime
552        .into_iter()
553        .filter(|&(addr, owed)| addr as u64 >= image.len() as u64 && owed != 0)
554        .collect();
555    beyond.sort_unstable();
556    for (addr, owed) in beyond {
557        bad.push(ImageMismatch {
558            addr,
559            served: 0,
560            runtime: owed,
561        });
562    }
563    if bad.is_empty() {
564        ImageVerdict::Consistent
565    } else {
566        ImageVerdict::Mismatch(bad)
567    }
568}
569
570// ────────────────────────────────────────────────────────────────────
571// #798: sparse per-segment records — the RV32 `.wasm_data` shipping format
572// ────────────────────────────────────────────────────────────────────
573
574/// Pack active data segments into the sparse per-segment record blob the RV32
575/// backend ships as its `.wasm_data` PROGBITS section (#798). Format, repeated
576/// per segment in DECLARATION order:
577///
578/// ```text
579///   u32 LE  linmem_off     (wasm i32.const segment offset)
580///   u32 LE  len            (initializer byte count)
581///   len     bytes          (the segment's initializer, verbatim)
582///   pad     0..3 zero bytes (4-align the next record header)
583/// ```
584///
585/// The generated startup (`synth riscv-runtime`) walks the records at reset
586/// and byte-copies each to `__linear_memory_base + linmem_off` in record
587/// order, so WASM's later-wins overlap semantics are preserved structurally
588/// by the copy order — iff the records are packed in declaration order (the
589/// red-first unit gate pins that: a reversed pack fails
590/// [`validate_served_image`] on overlapping segments).
591///
592/// Sparse-by-construction: a segment at linmem 1 MiB costs `8 + len` flash
593/// bytes, not a 1 MiB dense image. Zero segments ⇒ empty blob ⇒ the ELF
594/// builder omits the section entirely (byte-identical objects for data-free
595/// modules).
596pub fn pack_segment_records(segments: &[DataSegment]) -> Vec<u8> {
597    let mut out = Vec::new();
598    for s in segments {
599        out.extend_from_slice(&s.linmem_off.to_le_bytes());
600        out.extend_from_slice(&(s.bytes.len() as u32).to_le_bytes());
601        out.extend_from_slice(&s.bytes);
602        while out.len() % 4 != 0 {
603            out.push(0);
604        }
605    }
606    out
607}
608
609/// Parse a `.wasm_data` record blob back into `(linmem_off, bytes)` records,
610/// in record order. Returns `None` on a malformed blob (truncated header or
611/// payload, misaligned trailing bytes) — the read-back side of the #798
612/// served-image gate must fail LOUDLY on garbage, never "best-effort" it.
613pub fn parse_segment_records(blob: &[u8]) -> Option<Vec<DataSegment>> {
614    let mut recs = Vec::new();
615    let mut i = 0usize;
616    while i < blob.len() {
617        let hdr = blob.get(i..i + 8)?;
618        let off = u32::from_le_bytes(hdr[0..4].try_into().unwrap());
619        let len = u32::from_le_bytes(hdr[4..8].try_into().unwrap()) as usize;
620        i += 8;
621        let bytes = blob.get(i..i + len)?.to_vec();
622        i += len;
623        // Consume the 4-align padding (must exist and be within the blob).
624        let aligned = i.next_multiple_of(4);
625        if aligned > blob.len() {
626            return None;
627        }
628        i = aligned;
629        recs.push(DataSegment {
630            linmem_off: off,
631            bytes,
632        });
633    }
634    Some(recs)
635}
636
637/// Reconstruct the dense image the shipped records SERVE: apply every record
638/// to `__linear_memory_base`-relative addresses in RECORD order (later
639/// overwrites earlier), exactly what the generated startup's copy loop does
640/// at reset. This is the read-back side of the #798 gate — it consumes the
641/// EMITTED blob, so feeding it to [`validate_served_image`] against the
642/// declared segment list cannot be satisfied by mirroring the packer.
643/// Returns `None` on a malformed blob, or when a record's `off + len`
644/// overflows `u32` (a hostile extent that could never be served).
645pub fn served_image_from_records(blob: &[u8]) -> Option<Vec<u8>> {
646    let recs = parse_segment_records(blob)?;
647    let extent = recs
648        .iter()
649        .map(|r| r.linmem_off as u64 + r.bytes.len() as u64)
650        .max()
651        .unwrap_or(0);
652    if extent > u32::MAX as u64 {
653        return None;
654    }
655    let mut image = vec![0u8; extent as usize];
656    for r in &recs {
657        let at = r.linmem_off as usize;
658        image[at..at + r.bytes.len()].copy_from_slice(&r.bytes);
659    }
660    Some(image)
661}
662
663#[cfg(test)]
664mod tests {
665    use super::*;
666
667    /// Three active segments ALL at linmem 0x100000 with DISTINCT bytes at the
668    /// overlap offset — the #757 shape. seg_2 (last) owns the runtime bytes.
669    fn overlapping_segments() -> Vec<DataSegment> {
670        vec![
671            // seg_0: stale consts (the wrong bytes #757 read)
672            DataSegment {
673                linmem_off: 0x100000,
674                bytes: vec![0x02, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x20, 0xAA, 0xBB],
675            },
676            // seg_1: a middle segment, also overwritten by seg_2 at the overlap
677            DataSegment {
678                linmem_off: 0x100000,
679                bytes: vec![0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0x10],
680            },
681            // seg_2 (last, wins): "gust:os up\n"-like distinct bytes
682            DataSegment {
683                linmem_off: 0x100000,
684                bytes: b"gust:os up\n".to_vec(),
685            },
686        ]
687    }
688
689    /// The load-bearing non-vacuous gate: the SAME validator must go RED on the
690    /// `.position()` (first-match, wrong) resolution and GREEN on `.rposition()`
691    /// (last-match, correct) — for an overlapping-segment module. A validator
692    /// green on both would be vacuous. The policy is an ARGUMENT (`last_wins`),
693    /// so this is a permanent test, not a one-time source revert. (The
694    /// end-to-end revert-through-main.rs RED lives in the synth-cli fixture.)
695    #[test]
696    fn red_on_first_match_green_on_last_match() {
697        let segs = overlapping_segments();
698        // Access the byte at linmem 0x100008 — the classic #757 access. All
699        // three segments cover it with distinct bytes.
700        let c = 0x100008;
701        assert_eq!(segs[0].bytes[8], 0xAA); // seg_0 stale
702        assert_eq!(segs[2].bytes[8], b'u'); // seg_2 runtime-correct ("...s Up\n"[8])
703
704        // WRONG policy (#757: .position(), first match) -> seg_0 -> RED.
705        let wrong = resolve_owner(&segs, c, /* last_wins */ false).unwrap();
706        assert_eq!(wrong.seg_index, 0, "first-match must pick seg_0");
707        let red = validate_reloc_resolutions(&segs, std::slice::from_ref(&wrong));
708        match red {
709            Verdict::Mismatch(m) => {
710                assert_eq!(m.len(), 1);
711                assert_eq!(m[0].seg_index, 0);
712                assert_eq!(m[0].access_addr, c);
713                assert_eq!(m[0].served, 0xAA, "seg_0 serves the stale byte");
714                assert_eq!(m[0].runtime, b'u', "runtime image (seg_2) owns 'u'");
715            }
716            Verdict::Consistent => {
717                panic!("VACUOUS: validator accepted the #757 wrong-segment resolution")
718            }
719        }
720
721        // CORRECT policy (.rposition(), last match) -> seg_2 -> GREEN.
722        let right = resolve_owner(&segs, c, /* last_wins */ true).unwrap();
723        assert_eq!(right.seg_index, 2, "last-match must pick seg_2");
724        assert_eq!(
725            validate_reloc_resolutions(&segs, std::slice::from_ref(&right)),
726            Verdict::Consistent,
727            "the runtime-correct resolution must pass"
728        );
729    }
730
731    /// Non-overlapping segments: every address is in exactly one segment, so
732    /// first-match == last-match and both policies pass (no regression on the
733    /// common case).
734    #[test]
735    fn non_overlapping_both_policies_consistent() {
736        let segs = vec![
737            DataSegment {
738                linmem_off: 0x1000,
739                bytes: vec![1, 2, 3, 4],
740            },
741            DataSegment {
742                linmem_off: 0x2000,
743                bytes: vec![5, 6, 7, 8],
744            },
745        ];
746        for &c in &[0x1002u32, 0x2003] {
747            let a = resolve_owner(&segs, c, false).unwrap();
748            let b = resolve_owner(&segs, c, true).unwrap();
749            assert_eq!(a.seg_index, b.seg_index);
750            assert_eq!(validate_reloc_resolutions(&segs, &[a]), Verdict::Consistent);
751            assert_eq!(validate_reloc_resolutions(&segs, &[b]), Verdict::Consistent);
752        }
753    }
754
755    /// Partial overlap: a later segment overwrites only the TAIL of an earlier
756    /// one. An address in the overwritten tail must resolve to the later
757    /// segment; first-match (earlier) is RED there.
758    #[test]
759    fn partial_overlap_tail_wins() {
760        let segs = vec![
761            DataSegment {
762                linmem_off: 0x100,
763                bytes: vec![0x10, 0x11, 0x12, 0x13, 0x14, 0x15],
764            },
765            // overwrites [0x104, 0x108) with distinct bytes
766            DataSegment {
767                linmem_off: 0x104,
768                bytes: vec![0xF4, 0xF5, 0xF6, 0xF7],
769            },
770        ];
771        let c = 0x104; // in the overwritten tail
772        let wrong = resolve_owner(&segs, c, false).unwrap();
773        assert_eq!(wrong.seg_index, 0);
774        assert!(matches!(
775            validate_reloc_resolutions(&segs, &[wrong]),
776            Verdict::Mismatch(_)
777        ));
778        let right = resolve_owner(&segs, c, true).unwrap();
779        assert_eq!(right.seg_index, 1);
780        assert_eq!(
781            validate_reloc_resolutions(&segs, &[right]),
782            Verdict::Consistent
783        );
784
785        // An address in the NON-overwritten head resolves to seg_0 under both.
786        let head = resolve_owner(&segs, 0x100, false).unwrap();
787        assert_eq!(head.seg_index, 0);
788        assert_eq!(
789            validate_reloc_resolutions(&segs, &[head]),
790            Verdict::Consistent
791        );
792    }
793
794    /// Pack the mixed-split init region for tests exactly the way main.rs
795    /// lays it out: each segment 4-aligned, declaration order.
796    fn mixed_pack(segments: &[DataSegment]) -> (Vec<u32>, Vec<u8>) {
797        let mut offs = Vec::with_capacity(segments.len());
798        let mut cur = 0u32;
799        for s in segments {
800            cur = cur.next_multiple_of(4);
801            offs.push(cur);
802            cur += s.bytes.len() as u32;
803        }
804        let mut blob = vec![0u8; cur as usize];
805        for (s, &o) in segments.iter().zip(offs.iter()) {
806            blob[o as usize..o as usize + s.bytes.len()].copy_from_slice(&s.bytes);
807        }
808        (offs, blob)
809    }
810
811    /// PHASE-2 RED-FIRST (span class, the #777 follow-up): a STAGGERED overlap
812    /// — seg_1 overwrites only the TAIL of seg_0's range — with a reloc whose
813    /// addend byte is runtime-correct (owned by seg_0) but whose i32-wide span
814    /// crosses into seg_1's runtime-owned bytes. The phase-1 addend-byte
815    /// validator is GREEN on it (that is the hole this class names); the
816    /// spanned validator must be RED, flagging the exact tail byte. A spanned
817    /// validator green here would be vacuous.
818    #[test]
819    fn phase1_green_but_span_red_on_staggered_overlap() {
820        let segs = vec![
821            DataSegment {
822                linmem_off: 0x10004,
823                bytes: vec![0xA0, 0xA1, 0xA2, 0xA3, 0xA4, 0xA5, 0xA6, 0xA7],
824            },
825            // Staggered: overwrites [0x10008, 0x1000C) — seg_0's tail.
826            DataSegment {
827                linmem_off: 0x10008,
828                bytes: vec![0xB0, 0xB1, 0xB2, 0xB3],
829            },
830        ];
831        // Reloc at 0x10006: owner is seg_0 under the CORRECT .rposition()
832        // (seg_1 does not contain 0x10006). An i32 load spans 0x10006..0x1000A
833        // — bytes +2/+3 are seg_1's at runtime, seg_0's stale in the pack.
834        let r = resolve_owner(&segs, 0x10006, true).unwrap();
835        assert_eq!(r.seg_index, 0, "correct owner of the addend byte is seg_0");
836        // Phase-1 (addend byte only) is GREEN — the documented hole.
837        assert_eq!(
838            validate_reloc_resolutions(&segs, std::slice::from_ref(&r)),
839            Verdict::Consistent,
840            "phase 1 must accept the addend byte (it IS runtime-correct)"
841        );
842        // Phase-2 spanned is RED at span byte +2.
843        let (offs, blob) = mixed_pack(&segs);
844        let packed = PackedInit {
845            seg_packed_off: &offs,
846            bytes: &blob,
847        };
848        match validate_reloc_resolutions_spanned(&segs, std::slice::from_ref(&r), &packed) {
849            Verdict::Mismatch(m) => {
850                assert_eq!(m[0].span_byte, 2, "first divergent byte is +2");
851                assert_eq!(m[0].access_addr, 0x10008);
852                assert_eq!(m[0].served, 0xA4, "packed seg_0 serves its stale byte");
853                assert_eq!(m[0].runtime, 0xB0, "runtime image owns seg_1's byte");
854            }
855            Verdict::Consistent => {
856                panic!("VACUOUS: spanned validator accepted a straddling stale-tail access")
857            }
858        }
859    }
860
861    /// Linmem-ADJACENT segments whose packed layout PRESERVES adjacency
862    /// (4-aligned length, next declaration) — a span crossing the boundary is
863    /// served the right bytes, so the spanned validator must stay GREEN (no
864    /// false red on the benign crossing).
865    #[test]
866    fn span_green_on_adjacency_preserving_crossing() {
867        let segs = vec![
868            DataSegment {
869                linmem_off: 0x100,
870                bytes: vec![1, 2, 3, 4],
871            },
872            DataSegment {
873                linmem_off: 0x104,
874                bytes: vec![5, 6, 7, 8],
875            },
876        ];
877        let (offs, blob) = mixed_pack(&segs);
878        let packed = PackedInit {
879            seg_packed_off: &offs,
880            bytes: &blob,
881        };
882        let r = resolve_owner(&segs, 0x102, true).unwrap();
883        assert_eq!(r.seg_index, 0);
884        assert_eq!(
885            validate_reloc_resolutions_spanned(&segs, &[r], &packed),
886            Verdict::Consistent,
887            "packed adjacency == linmem adjacency: the crossing serves the right bytes"
888        );
889    }
890
891    /// Linmem-adjacent segments whose packed layout BREAKS adjacency (seg_0's
892    /// length is not 4-aligned, so the pack inserts padding the linear memory
893    /// doesn't have): a span crossing the boundary reads pad zeros instead of
894    /// the next segment's bytes — RED.
895    #[test]
896    fn span_red_on_padding_shifted_crossing() {
897        let segs = vec![
898            DataSegment {
899                linmem_off: 0x100,
900                bytes: vec![1, 2, 3], // len 3 → packed pads to 4
901            },
902            // Linmem-adjacent at 0x103; packed at offset 4 (shifted by 1).
903            DataSegment {
904                linmem_off: 0x103,
905                bytes: vec![5, 6, 7, 8],
906            },
907        ];
908        let (offs, blob) = mixed_pack(&segs);
909        let packed = PackedInit {
910            seg_packed_off: &offs,
911            bytes: &blob,
912        };
913        let r = resolve_owner(&segs, 0x101, true).unwrap();
914        assert_eq!(r.seg_index, 0);
915        match validate_reloc_resolutions_spanned(&segs, &[r], &packed) {
916            Verdict::Mismatch(m) => {
917                // +2 = 0x103: runtime owns seg_1's first byte (5); the pack
918                // serves its own pad byte (0).
919                assert_eq!(m[0].span_byte, 2);
920                assert_eq!(m[0].access_addr, 0x103);
921                assert_eq!(m[0].served, 0, "the pack serves 4-align padding");
922                assert_eq!(m[0].runtime, 5);
923            }
924            Verdict::Consistent => panic!("VACUOUS: padding-shifted crossing accepted"),
925        }
926    }
927
928    /// The unknown-width tolerance: a reloc near the end of a SPARSE segment
929    /// (no segment covers the bytes beyond it) must stay GREEN — the span
930    /// bytes are implicit-zero linear memory and the common shape is a narrow
931    /// access. This is the documented residue, not a bug.
932    #[test]
933    fn span_green_on_sparse_tail() {
934        let segs = vec![
935            DataSegment {
936                linmem_off: 0x100,
937                bytes: vec![1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12],
938            },
939            // Far away; between them is implicit-zero linmem.
940            DataSegment {
941                linmem_off: 0x400,
942                bytes: vec![0xFF; 4],
943            },
944        ];
945        let (offs, blob) = mixed_pack(&segs);
946        let packed = PackedInit {
947            seg_packed_off: &offs,
948            bytes: &blob,
949        };
950        // Last word of seg_0: the conservative 8-byte span runs past the end
951        // into uncovered linmem — skipped, not flagged.
952        let r = resolve_owner(&segs, 0x108, true).unwrap();
953        assert_eq!(r.seg_index, 0);
954        assert_eq!(
955            validate_reloc_resolutions_spanned(&segs, &[r], &packed),
956            Verdict::Consistent,
957            "uncovered span bytes are implicit-zero linmem — must not false-red"
958        );
959    }
960
961    /// A span that escapes the init region entirely (into the globals slots /
962    /// past the blob) while the runtime address IS segment-covered: RED — the
963    /// pack cannot serve that byte at all. Shape: the LAST-declared segment is
964    /// shorter than an earlier one at the same base, so bytes beyond its end
965    /// are runtime-owned by the earlier segment but packed nowhere after it.
966    #[test]
967    fn span_red_on_init_region_escape() {
968        let segs = vec![
969            DataSegment {
970                linmem_off: 0x100,
971                bytes: vec![0x11; 8], // covers [0x100, 0x108)
972            },
973            DataSegment {
974                linmem_off: 0x100,
975                bytes: vec![0x22; 4], // last-declared owner of [0x100, 0x104)
976            },
977        ];
978        let (offs, blob) = mixed_pack(&segs);
979        assert_eq!(blob.len(), 12, "seg_1 is the final packed segment");
980        let packed = PackedInit {
981            seg_packed_off: &offs,
982            bytes: &blob,
983        };
984        // 0x102 is owned by seg_1 (last); its span bytes +2/+3 (0x104/0x105)
985        // are runtime-owned by seg_0 (0x11) but lie past seg_1's packed end =
986        // past the whole init region.
987        let r = resolve_owner(&segs, 0x102, true).unwrap();
988        assert_eq!(r.seg_index, 1);
989        match validate_reloc_resolutions_spanned(&segs, &[r], &packed) {
990            Verdict::Mismatch(m) => {
991                assert_eq!(m[0].span_byte, 2);
992                assert_eq!(m[0].access_addr, 0x104);
993                assert_eq!(m[0].runtime, 0x11);
994            }
995            Verdict::Consistent => panic!("VACUOUS: init-region escape accepted"),
996        }
997    }
998
999    /// The blob-fill pin at the addend byte: segments and resolution are
1000    /// phase-1-green, but the SHIPPED blob was corrupted at the served
1001    /// position — the spanned validator must flag it at span byte 0 (phase 1
1002    /// reads segment bytes and cannot see it).
1003    #[test]
1004    fn span_red_on_blob_fill_corruption_at_addend_byte() {
1005        let segs = vec![DataSegment {
1006            linmem_off: 0x100,
1007            bytes: vec![1, 2, 3, 4],
1008        }];
1009        let (offs, mut blob) = mixed_pack(&segs);
1010        let r = resolve_owner(&segs, 0x102, true).unwrap();
1011        assert_eq!(
1012            validate_reloc_resolutions(&segs, std::slice::from_ref(&r)),
1013            Verdict::Consistent,
1014            "phase 1 (segment bytes) cannot see a blob-fill bug"
1015        );
1016        blob[2] = 0xEE; // corrupt the byte the reloc actually serves
1017        let packed = PackedInit {
1018            seg_packed_off: &offs,
1019            bytes: &blob,
1020        };
1021        match validate_reloc_resolutions_spanned(&segs, std::slice::from_ref(&r), &packed) {
1022            Verdict::Mismatch(m) => {
1023                assert_eq!(m[0].span_byte, 0);
1024                assert_eq!(m[0].served, 0xEE);
1025                assert_eq!(m[0].runtime, 3);
1026            }
1027            Verdict::Consistent => panic!("VACUOUS: corrupted shipped blob accepted"),
1028        }
1029    }
1030
1031    /// ROM-image RED-FIRST (self-contained class, phase 1's `resolve_owner`
1032    /// pattern — the overwrite policy is an ARGUMENT): on an overlapping
1033    /// module the SAME dense-image validator must be RED on the first-wins
1034    /// pack (`last_wins = false`, the synthetic miscompile) and GREEN on the
1035    /// declaration-order pack (`last_wins = true`, WASM instantiation
1036    /// semantics). Green on both would be vacuous.
1037    #[test]
1038    fn rom_image_red_on_first_wins_green_on_last_wins() {
1039        let segs = overlapping_segments();
1040        let wrong = pack_rom_image(&segs, false);
1041        match validate_served_image(&segs, &wrong) {
1042            ImageVerdict::Mismatch(m) => {
1043                // The classic #757 byte: offset 8 must be seg_2's 'u', but the
1044                // first-wins image left seg_0's 0xAA there.
1045                let at8 = m.iter().find(|x| x.addr == 0x100008).expect("addr 8");
1046                assert_eq!(at8.served, 0xAA);
1047                assert_eq!(at8.runtime, b'u');
1048            }
1049            ImageVerdict::Consistent => {
1050                panic!("VACUOUS: dense-image validator accepted a first-wins pack")
1051            }
1052        }
1053        let right = pack_rom_image(&segs, true);
1054        assert_eq!(
1055            validate_served_image(&segs, &right),
1056            ImageVerdict::Consistent,
1057            "declaration-order (later-wins) pack must validate"
1058        );
1059    }
1060
1061    /// A dense image with initializer garbage in an uncovered GAP is a
1062    /// mismatch (runtime linmem is zero there), and a truncated image whose
1063    /// missing tail is all-zero at runtime is fine (zeroed RAM serves it).
1064    #[test]
1065    fn rom_image_gap_garbage_red_zero_tail_green() {
1066        let segs = vec![
1067            DataSegment {
1068                linmem_off: 0,
1069                bytes: vec![1, 2],
1070            },
1071            DataSegment {
1072                linmem_off: 8,
1073                bytes: vec![0, 0, 0, 0],
1074            },
1075        ];
1076        // Garbage at uncovered addr 4.
1077        let mut img = pack_rom_image(&segs, true);
1078        img[4] = 0xCC;
1079        assert!(matches!(
1080            validate_served_image(&segs, &img),
1081            ImageVerdict::Mismatch(_)
1082        ));
1083        // Image truncated to the nonzero prefix: the all-zero tail (gap +
1084        // zero segment) is served by zeroed RAM — consistent.
1085        assert_eq!(
1086            validate_served_image(&segs, &[1, 2]),
1087            ImageVerdict::Consistent
1088        );
1089    }
1090
1091    /// RISC-V single-base shape: the object ships NO initializer image
1092    /// (`image = &[]`, zeroed RAM serves everything). Nonzero segment bytes
1093    /// are un-served (RED, the silent initializer-drop); an all-zero segment
1094    /// — or an earlier nonzero byte OVERWRITTEN to zero by a later segment —
1095    /// is served correctly by zeroed RAM (GREEN). The overwrite case keeps
1096    /// this non-vacuous as a later-wins check, not a "any nonzero data" grep.
1097    #[test]
1098    fn zero_served_image_red_on_nonzero_green_on_zeroed() {
1099        let nonzero = vec![DataSegment {
1100            linmem_off: 16,
1101            bytes: vec![1, 2, 3, 4],
1102        }];
1103        match validate_served_image(&nonzero, &[]) {
1104            ImageVerdict::Mismatch(m) => {
1105                assert_eq!(m[0].addr, 16);
1106                assert_eq!(m[0].served, 0);
1107                assert_eq!(m[0].runtime, 1);
1108            }
1109            ImageVerdict::Consistent => panic!("VACUOUS: dropped nonzero initializer accepted"),
1110        }
1111        let zeroed = vec![
1112            DataSegment {
1113                linmem_off: 16,
1114                bytes: vec![1, 2, 3, 4],
1115            },
1116            // Later segment overwrites the nonzero bytes with zeros: the
1117            // runtime image is all-zero, so zeroed RAM serves it correctly.
1118            DataSegment {
1119                linmem_off: 16,
1120                bytes: vec![0, 0, 0, 0],
1121            },
1122        ];
1123        assert_eq!(
1124            validate_served_image(&zeroed, &[]),
1125            ImageVerdict::Consistent
1126        );
1127    }
1128
1129    /// Out-of-range resolution (a broken retargeting) is a mismatch.
1130    #[test]
1131    fn out_of_range_is_mismatch() {
1132        let segs = vec![DataSegment {
1133            linmem_off: 0,
1134            bytes: vec![1, 2, 3],
1135        }];
1136        let bad = RelocResolution {
1137            seg_index: 0,
1138            addend: 99,
1139            label: "oob".into(),
1140        };
1141        assert!(matches!(
1142            validate_reloc_resolutions(&segs, &[bad]),
1143            Verdict::Mismatch(_)
1144        ));
1145    }
1146
1147    // ─── #798 sparse per-segment records (RV32 `.wasm_data`) ───────────
1148
1149    /// Round trip: pack → parse recovers the declaration-order records
1150    /// verbatim (offsets, lengths, bytes), across 4-align padding.
1151    #[test]
1152    fn segment_records_round_trip() {
1153        let segs = vec![
1154            DataSegment {
1155                linmem_off: 16,
1156                bytes: vec![1, 2, 3], // len 3 → 1 pad byte
1157            },
1158            DataSegment {
1159                linmem_off: 0x10000,
1160                bytes: vec![0xAA; 8],
1161            },
1162            DataSegment {
1163                linmem_off: 4,
1164                bytes: vec![9], // len 1 → 3 pad bytes
1165            },
1166        ];
1167        let blob = pack_segment_records(&segs);
1168        assert_eq!(blob.len() % 4, 0, "records blob is 4-aligned throughout");
1169        let back = parse_segment_records(&blob).expect("well-formed blob parses");
1170        assert_eq!(back.len(), 3);
1171        for (a, b) in segs.iter().zip(back.iter()) {
1172            assert_eq!(a.linmem_off, b.linmem_off);
1173            assert_eq!(a.bytes, b.bytes);
1174        }
1175    }
1176
1177    /// RED-FIRST (#798 shipping gate, the #757 lesson applied to the copy
1178    /// order): the startup copies records in RECORD order, so a pack that
1179    /// stores overlapping segments in REVERSED declaration order serves the
1180    /// FIRST-declared bytes (first-wins) — the served image read back from
1181    /// that blob must FAIL validate_served_image, and the declaration-order
1182    /// pack must PASS. Green on both would make the read-back gate vacuous.
1183    #[test]
1184    fn records_red_on_reversed_pack_green_on_declaration_order() {
1185        let segs = overlapping_segments();
1186        let mut reversed = segs.clone();
1187        reversed.reverse();
1188        let wrong_blob = pack_segment_records(&reversed);
1189        let wrong_served = served_image_from_records(&wrong_blob).unwrap();
1190        match validate_served_image(&segs, &wrong_served) {
1191            ImageVerdict::Mismatch(m) => {
1192                let at8 = m.iter().find(|x| x.addr == 0x100008).expect("addr 8");
1193                assert_eq!(at8.served, 0xAA, "reversed pack serves seg_0's stale byte");
1194                assert_eq!(at8.runtime, b'u', "runtime image owns seg_2's byte");
1195            }
1196            ImageVerdict::Consistent => {
1197                panic!("VACUOUS: read-back gate accepted a reversed (first-wins) pack")
1198            }
1199        }
1200        let right_blob = pack_segment_records(&segs);
1201        let right_served = served_image_from_records(&right_blob).unwrap();
1202        assert_eq!(
1203            validate_served_image(&segs, &right_served),
1204            ImageVerdict::Consistent,
1205            "declaration-order records must serve the later-wins image"
1206        );
1207    }
1208
1209    /// The served image is SPARSE-tolerant: gaps between records read zero,
1210    /// matching implicit-zero linear memory (zeroed RAM under the RV32
1211    /// scheme), so a far-offset segment validates without a dense flash blob.
1212    #[test]
1213    fn records_far_offset_segment_served_correctly() {
1214        let segs = vec![DataSegment {
1215            linmem_off: 0x10000,
1216            bytes: vec![7, 8, 9, 10],
1217        }];
1218        let blob = pack_segment_records(&segs);
1219        assert_eq!(blob.len(), 12, "8-byte header + 4 bytes, no dense image");
1220        let served = served_image_from_records(&blob).unwrap();
1221        assert_eq!(served.len(), 0x10004);
1222        assert_eq!(
1223            validate_served_image(&segs, &served),
1224            ImageVerdict::Consistent
1225        );
1226    }
1227
1228    /// Malformed blobs (truncated header, truncated payload, missing align
1229    /// padding) parse to None — the read-back must fail loudly, not
1230    /// best-effort.
1231    #[test]
1232    fn records_malformed_blobs_rejected() {
1233        let segs = vec![DataSegment {
1234            linmem_off: 4,
1235            bytes: vec![1, 2, 3, 4, 5],
1236        }];
1237        let blob = pack_segment_records(&segs);
1238        assert!(parse_segment_records(&blob[..4]).is_none(), "cut header");
1239        assert!(parse_segment_records(&blob[..10]).is_none(), "cut payload");
1240        assert!(
1241            parse_segment_records(&blob[..blob.len() - 1]).is_none(),
1242            "cut align padding"
1243        );
1244        assert!(served_image_from_records(&blob[..10]).is_none());
1245        // Empty blob = zero segments: parses to nothing, serves nothing.
1246        assert_eq!(parse_segment_records(&[]).unwrap().len(), 0);
1247        assert_eq!(served_image_from_records(&[]).unwrap().len(), 0);
1248    }
1249
1250    // ---- VCR-VER-003 #761: linmem<->globals disjointness geometry gate ----
1251
1252    /// RED-FIRST non-vacuity: the EXACT pre-fix geometry — `(memory 1)`,
1253    /// function-visible base 0x2000_0100, globals table based (wrongly) on the
1254    /// R11 base 0x2000_0000 + 64 KiB = 0x2001_0000 — must be caught as an
1255    /// OVERLAP of 0x100 bytes (the top of the page aliases the table).
1256    #[test]
1257    fn layout_gate_761_red_r11_based_globals_overlap() {
1258        let func_visible = 0x2000_0100u32;
1259        let linmem = 64 * 1024;
1260        // The BUG: globals based on R11 (0x2000_0000), not the func-visible base.
1261        let bad_globals_base = 0x2000_0000u32 + linmem; // 0x2001_0000
1262        let v = validate_linmem_globals_disjoint(func_visible, linmem, bad_globals_base, 4);
1263        assert_eq!(
1264            v,
1265            LayoutVerdict::Overlap {
1266                func_visible_linmem_base: func_visible,
1267                linmem_bytes: linmem,
1268                globals_base: bad_globals_base,
1269                overlap_bytes: 0x100,
1270            }
1271        );
1272    }
1273
1274    /// GREEN: the FIXED geometry — globals based on the function-visible base +
1275    /// memory size (0x2000_0100 + 64 KiB = 0x2001_0100) sits exactly AT the page
1276    /// ceiling, so the regions are disjoint.
1277    #[test]
1278    fn layout_gate_761_green_func_visible_based_globals_disjoint() {
1279        let func_visible = 0x2000_0100u32;
1280        let linmem = 64 * 1024;
1281        let good_globals_base = func_visible + linmem; // 0x2001_0100 == ceiling
1282        assert_eq!(
1283            validate_linmem_globals_disjoint(func_visible, linmem, good_globals_base, 4),
1284            LayoutVerdict::Disjoint
1285        );
1286    }
1287
1288    /// Boundary: table exactly at the ceiling is disjoint (half-open page); one
1289    /// byte below the ceiling is a 1-byte overlap.
1290    #[test]
1291    fn layout_gate_761_ceiling_boundary_is_exclusive() {
1292        let base = 0x2000_0100u32;
1293        let linmem = 0x1000;
1294        let ceiling = base + linmem;
1295        assert_eq!(
1296            validate_linmem_globals_disjoint(base, linmem, ceiling, 8),
1297            LayoutVerdict::Disjoint
1298        );
1299        match validate_linmem_globals_disjoint(base, linmem, ceiling - 1, 8) {
1300            LayoutVerdict::Overlap { overlap_bytes, .. } => assert_eq!(overlap_bytes, 1),
1301            v => panic!("expected 1-byte overlap, got {v:?}"),
1302        }
1303    }
1304
1305    /// A module with NO globals is trivially disjoint regardless of the bases —
1306    /// the startup emits no R9 block at all.
1307    #[test]
1308    fn layout_gate_761_no_globals_is_disjoint() {
1309        assert_eq!(
1310            validate_linmem_globals_disjoint(0x2000_0100, 64 * 1024, 0x2000_0000, 0),
1311            LayoutVerdict::Disjoint
1312        );
1313    }
1314
1315    /// The `--stack-layout=low` shape: both bases shift up by the stack reserve,
1316    /// so a func-visible-based table stays disjoint (the fix covers both layouts).
1317    #[test]
1318    fn layout_gate_761_low_layout_func_visible_based_disjoint() {
1319        let stack = 0x1000u32;
1320        let func_visible = 0x2000_0100 + stack; // optimized_linmem_base under low
1321        let linmem = 64 * 1024;
1322        assert_eq!(
1323            validate_linmem_globals_disjoint(func_visible, linmem, func_visible + linmem, 4),
1324            LayoutVerdict::Disjoint
1325        );
1326        // ... and the pre-fix R11-based placement would still overlap under low.
1327        let bad = (0x2000_0000 + stack) + linmem;
1328        match validate_linmem_globals_disjoint(func_visible, linmem, bad, 4) {
1329            LayoutVerdict::Overlap { overlap_bytes, .. } => assert_eq!(overlap_bytes, 0x100),
1330            v => panic!("expected 0x100 overlap under low, got {v:?}"),
1331        }
1332    }
1333}