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