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/// The verdict of the self-contained linmem↔globals disjointness geometry gate
423/// ([`validate_linmem_globals_disjoint`], VCR-VER-003 / #761).
424#[derive(Clone, Debug, PartialEq, Eq)]
425pub enum LayoutVerdict {
426 /// The globals table sits entirely at or above the function-visible linear
427 /// memory page ceiling — the two regions cannot alias.
428 Disjoint,
429 /// The globals table base falls INSIDE the function-visible linmem page:
430 /// a store to the overlapping tail of the page would alias a global slot
431 /// (or vice versa) — a silent wrong-value miscompile.
432 Overlap {
433 /// The base the compiled functions address linear memory at
434 /// (`optimized_linmem_base`, = R11 + 0x100 under the #687 contract).
435 func_visible_linmem_base: u32,
436 /// The size of the function-visible linear-memory page in bytes
437 /// (`initial_pages * 64 KiB`).
438 linmem_bytes: u32,
439 /// The absolute base the startup points R9 at (the globals table).
440 globals_base: u32,
441 /// How many bytes the two regions overlap by (the tail of the page
442 /// that aliases the table).
443 overlap_bytes: u32,
444 },
445}
446
447/// VCR-VER-003 geometry gate (#761): on the self-contained `--cortex-m` image
448/// the R9 globals table MUST sit entirely at or above the function-visible
449/// linear-memory page ceiling. The compiled functions address linear memory
450/// starting at `func_visible_linmem_base` (= the startup R11 base + 0x100, the
451/// #687 gap) and can reach any byte in `[base, base + linmem_bytes)`; the
452/// startup materializes the globals table at `globals_base`. If
453/// `globals_base < func_visible_linmem_base + linmem_bytes`, a store to the
454/// overlapping tail of the page lands on a global slot — a silent
455/// global↔linmem ALIAS (the worst class). This is a pure geometry invariant
456/// (no bytes involved), unconditional, and complements the served-image byte
457/// gate above: it catches the placement bug, not a packing bug.
458///
459/// `globals_base == func_visible_linmem_base + linmem_bytes` (table exactly at
460/// the ceiling) is DISJOINT — the page is `[base, base + linmem_bytes)`,
461/// half-open, so its last addressable byte is `base + linmem_bytes - 1`.
462/// No-globals modules (`globals_bytes == 0`) are trivially disjoint.
463pub fn validate_linmem_globals_disjoint(
464 func_visible_linmem_base: u32,
465 linmem_bytes: u32,
466 globals_base: u32,
467 globals_bytes: u32,
468) -> LayoutVerdict {
469 if globals_bytes == 0 {
470 return LayoutVerdict::Disjoint;
471 }
472 // u64 so a hostile base + size cannot wrap and pass the check spuriously.
473 let ceiling = func_visible_linmem_base as u64 + linmem_bytes as u64;
474 if (globals_base as u64) < ceiling {
475 return LayoutVerdict::Overlap {
476 func_visible_linmem_base,
477 linmem_bytes,
478 globals_base,
479 overlap_bytes: (ceiling - globals_base as u64) as u32,
480 };
481 }
482 LayoutVerdict::Disjoint
483}
484
485/// Total extent of the runtime image: `max(off + len)` over the segments
486/// (u64, so a hostile `off + len` cannot wrap — callers bound-check against
487/// the linear-memory size before packing).
488pub fn image_extent(segments: &[DataSegment]) -> u64 {
489 segments
490 .iter()
491 .map(|s| s.linmem_off as u64 + s.bytes.len() as u64)
492 .max()
493 .unwrap_or(0)
494}
495
496/// Pack the #758 dense ROM init image: a `[0, extent)` blob with every active
497/// segment placed AT its linmem offset. `last_wins = true` applies them in
498/// declaration order (WASM instantiation semantics — later segments overwrite
499/// earlier on overlap); `last_wins = false` applies them in REVERSE order
500/// (first-wins — the synthetic miscompile the red-first gate toggles, phase
501/// 1's `resolve_owner` pattern). The caller must have bound-checked
502/// [`image_extent`] against the linear-memory size (u32 + usize safe here
503/// only after that check).
504pub fn pack_rom_image(segments: &[DataSegment], last_wins: bool) -> Vec<u8> {
505 let mut blob = vec![0u8; image_extent(segments) as usize];
506 let place = |blob: &mut Vec<u8>, s: &DataSegment| {
507 let at = s.linmem_off as usize;
508 blob[at..at + s.bytes.len()].copy_from_slice(&s.bytes);
509 };
510 if last_wins {
511 for s in segments {
512 place(&mut blob, s);
513 }
514 } else {
515 for s in segments.iter().rev() {
516 place(&mut blob, s);
517 }
518 }
519 blob
520}
521
522/// Dense served-image gate: for every address in `[0, image_extent)`, the byte
523/// SERVED — `image[addr]`, or `0` when the image doesn't reach `addr` (zeroed
524/// RAM; an empty `image` models a target that ships NO initializer bytes, the
525/// RISC-V single-base scheme) — must equal the runtime image byte (segments
526/// applied in declaration order, later-wins; implicit zero where uncovered).
527///
528/// The truth side is reconstructed only from the segment list, never from the
529/// image, so the gate cannot be satisfied by mirroring the packing code.
530pub fn validate_served_image(segments: &[DataSegment], image: &[u8]) -> ImageVerdict {
531 let runtime = runtime_image(segments);
532 let mut bad = Vec::new();
533 // Every image byte must equal the runtime byte (covered ⇒ later-wins
534 // segment byte; uncovered ⇒ implicit zero, so initializer garbage in a
535 // gap is caught too).
536 for (addr, &served) in image.iter().enumerate() {
537 let owed = runtime.get(&(addr as u32)).copied().unwrap_or(0);
538 if served != owed {
539 bad.push(ImageMismatch {
540 addr: addr as u32,
541 served,
542 runtime: owed,
543 });
544 }
545 }
546 // Every runtime byte BEYOND the image is served by zeroed RAM, so any
547 // nonzero one is un-served (the shipped-no-initializer mismatch). Walk
548 // the covered addresses only — uncovered beyond-image bytes are 0 == 0.
549 let mut beyond: Vec<(u32, u8)> = runtime
550 .into_iter()
551 .filter(|&(addr, owed)| addr as u64 >= image.len() as u64 && owed != 0)
552 .collect();
553 beyond.sort_unstable();
554 for (addr, owed) in beyond {
555 bad.push(ImageMismatch {
556 addr,
557 served: 0,
558 runtime: owed,
559 });
560 }
561 if bad.is_empty() {
562 ImageVerdict::Consistent
563 } else {
564 ImageVerdict::Mismatch(bad)
565 }
566}
567
568// ────────────────────────────────────────────────────────────────────
569// #798: sparse per-segment records — the RV32 `.wasm_data` shipping format
570// ────────────────────────────────────────────────────────────────────
571
572/// Pack active data segments into the sparse per-segment record blob the RV32
573/// backend ships as its `.wasm_data` PROGBITS section (#798). Format, repeated
574/// per segment in DECLARATION order:
575///
576/// ```text
577/// u32 LE linmem_off (wasm i32.const segment offset)
578/// u32 LE len (initializer byte count)
579/// len bytes (the segment's initializer, verbatim)
580/// pad 0..3 zero bytes (4-align the next record header)
581/// ```
582///
583/// The generated startup (`synth riscv-runtime`) walks the records at reset
584/// and byte-copies each to `__linear_memory_base + linmem_off` in record
585/// order, so WASM's later-wins overlap semantics are preserved structurally
586/// by the copy order — iff the records are packed in declaration order (the
587/// red-first unit gate pins that: a reversed pack fails
588/// [`validate_served_image`] on overlapping segments).
589///
590/// Sparse-by-construction: a segment at linmem 1 MiB costs `8 + len` flash
591/// bytes, not a 1 MiB dense image. Zero segments ⇒ empty blob ⇒ the ELF
592/// builder omits the section entirely (byte-identical objects for data-free
593/// modules).
594pub fn pack_segment_records(segments: &[DataSegment]) -> Vec<u8> {
595 let mut out = Vec::new();
596 for s in segments {
597 out.extend_from_slice(&s.linmem_off.to_le_bytes());
598 out.extend_from_slice(&(s.bytes.len() as u32).to_le_bytes());
599 out.extend_from_slice(&s.bytes);
600 while out.len() % 4 != 0 {
601 out.push(0);
602 }
603 }
604 out
605}
606
607/// Parse a `.wasm_data` record blob back into `(linmem_off, bytes)` records,
608/// in record order. Returns `None` on a malformed blob (truncated header or
609/// payload, misaligned trailing bytes) — the read-back side of the #798
610/// served-image gate must fail LOUDLY on garbage, never "best-effort" it.
611pub fn parse_segment_records(blob: &[u8]) -> Option<Vec<DataSegment>> {
612 let mut recs = Vec::new();
613 let mut i = 0usize;
614 while i < blob.len() {
615 let hdr = blob.get(i..i + 8)?;
616 let off = u32::from_le_bytes(hdr[0..4].try_into().unwrap());
617 let len = u32::from_le_bytes(hdr[4..8].try_into().unwrap()) as usize;
618 i += 8;
619 let bytes = blob.get(i..i + len)?.to_vec();
620 i += len;
621 // Consume the 4-align padding (must exist and be within the blob).
622 let aligned = i.next_multiple_of(4);
623 if aligned > blob.len() {
624 return None;
625 }
626 i = aligned;
627 recs.push(DataSegment {
628 linmem_off: off,
629 bytes,
630 });
631 }
632 Some(recs)
633}
634
635/// Reconstruct the dense image the shipped records SERVE: apply every record
636/// to `__linear_memory_base`-relative addresses in RECORD order (later
637/// overwrites earlier), exactly what the generated startup's copy loop does
638/// at reset. This is the read-back side of the #798 gate — it consumes the
639/// EMITTED blob, so feeding it to [`validate_served_image`] against the
640/// declared segment list cannot be satisfied by mirroring the packer.
641/// Returns `None` on a malformed blob, or when a record's `off + len`
642/// overflows `u32` (a hostile extent that could never be served).
643pub fn served_image_from_records(blob: &[u8]) -> Option<Vec<u8>> {
644 let recs = parse_segment_records(blob)?;
645 let extent = recs
646 .iter()
647 .map(|r| r.linmem_off as u64 + r.bytes.len() as u64)
648 .max()
649 .unwrap_or(0);
650 if extent > u32::MAX as u64 {
651 return None;
652 }
653 let mut image = vec![0u8; extent as usize];
654 for r in &recs {
655 let at = r.linmem_off as usize;
656 image[at..at + r.bytes.len()].copy_from_slice(&r.bytes);
657 }
658 Some(image)
659}
660
661#[cfg(test)]
662mod tests {
663 use super::*;
664
665 /// Three active segments ALL at linmem 0x100000 with DISTINCT bytes at the
666 /// overlap offset — the #757 shape. seg_2 (last) owns the runtime bytes.
667 fn overlapping_segments() -> Vec<DataSegment> {
668 vec![
669 // seg_0: stale consts (the wrong bytes #757 read)
670 DataSegment {
671 linmem_off: 0x100000,
672 bytes: vec![0x02, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x20, 0xAA, 0xBB],
673 },
674 // seg_1: a middle segment, also overwritten by seg_2 at the overlap
675 DataSegment {
676 linmem_off: 0x100000,
677 bytes: vec![0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0x10],
678 },
679 // seg_2 (last, wins): "gust:os up\n"-like distinct bytes
680 DataSegment {
681 linmem_off: 0x100000,
682 bytes: b"gust:os up\n".to_vec(),
683 },
684 ]
685 }
686
687 /// The load-bearing non-vacuous gate: the SAME validator must go RED on the
688 /// `.position()` (first-match, wrong) resolution and GREEN on `.rposition()`
689 /// (last-match, correct) — for an overlapping-segment module. A validator
690 /// green on both would be vacuous. The policy is an ARGUMENT (`last_wins`),
691 /// so this is a permanent test, not a one-time source revert. (The
692 /// end-to-end revert-through-main.rs RED lives in the synth-cli fixture.)
693 #[test]
694 fn red_on_first_match_green_on_last_match() {
695 let segs = overlapping_segments();
696 // Access the byte at linmem 0x100008 — the classic #757 access. All
697 // three segments cover it with distinct bytes.
698 let c = 0x100008;
699 assert_eq!(segs[0].bytes[8], 0xAA); // seg_0 stale
700 assert_eq!(segs[2].bytes[8], b'u'); // seg_2 runtime-correct ("...s Up\n"[8])
701
702 // WRONG policy (#757: .position(), first match) -> seg_0 -> RED.
703 let wrong = resolve_owner(&segs, c, /* last_wins */ false).unwrap();
704 assert_eq!(wrong.seg_index, 0, "first-match must pick seg_0");
705 let red = validate_reloc_resolutions(&segs, std::slice::from_ref(&wrong));
706 match red {
707 Verdict::Mismatch(m) => {
708 assert_eq!(m.len(), 1);
709 assert_eq!(m[0].seg_index, 0);
710 assert_eq!(m[0].access_addr, c);
711 assert_eq!(m[0].served, 0xAA, "seg_0 serves the stale byte");
712 assert_eq!(m[0].runtime, b'u', "runtime image (seg_2) owns 'u'");
713 }
714 Verdict::Consistent => {
715 panic!("VACUOUS: validator accepted the #757 wrong-segment resolution")
716 }
717 }
718
719 // CORRECT policy (.rposition(), last match) -> seg_2 -> GREEN.
720 let right = resolve_owner(&segs, c, /* last_wins */ true).unwrap();
721 assert_eq!(right.seg_index, 2, "last-match must pick seg_2");
722 assert_eq!(
723 validate_reloc_resolutions(&segs, std::slice::from_ref(&right)),
724 Verdict::Consistent,
725 "the runtime-correct resolution must pass"
726 );
727 }
728
729 /// Non-overlapping segments: every address is in exactly one segment, so
730 /// first-match == last-match and both policies pass (no regression on the
731 /// common case).
732 #[test]
733 fn non_overlapping_both_policies_consistent() {
734 let segs = vec![
735 DataSegment {
736 linmem_off: 0x1000,
737 bytes: vec![1, 2, 3, 4],
738 },
739 DataSegment {
740 linmem_off: 0x2000,
741 bytes: vec![5, 6, 7, 8],
742 },
743 ];
744 for &c in &[0x1002u32, 0x2003] {
745 let a = resolve_owner(&segs, c, false).unwrap();
746 let b = resolve_owner(&segs, c, true).unwrap();
747 assert_eq!(a.seg_index, b.seg_index);
748 assert_eq!(validate_reloc_resolutions(&segs, &[a]), Verdict::Consistent);
749 assert_eq!(validate_reloc_resolutions(&segs, &[b]), Verdict::Consistent);
750 }
751 }
752
753 /// Partial overlap: a later segment overwrites only the TAIL of an earlier
754 /// one. An address in the overwritten tail must resolve to the later
755 /// segment; first-match (earlier) is RED there.
756 #[test]
757 fn partial_overlap_tail_wins() {
758 let segs = vec![
759 DataSegment {
760 linmem_off: 0x100,
761 bytes: vec![0x10, 0x11, 0x12, 0x13, 0x14, 0x15],
762 },
763 // overwrites [0x104, 0x108) with distinct bytes
764 DataSegment {
765 linmem_off: 0x104,
766 bytes: vec![0xF4, 0xF5, 0xF6, 0xF7],
767 },
768 ];
769 let c = 0x104; // in the overwritten tail
770 let wrong = resolve_owner(&segs, c, false).unwrap();
771 assert_eq!(wrong.seg_index, 0);
772 assert!(matches!(
773 validate_reloc_resolutions(&segs, &[wrong]),
774 Verdict::Mismatch(_)
775 ));
776 let right = resolve_owner(&segs, c, true).unwrap();
777 assert_eq!(right.seg_index, 1);
778 assert_eq!(
779 validate_reloc_resolutions(&segs, &[right]),
780 Verdict::Consistent
781 );
782
783 // An address in the NON-overwritten head resolves to seg_0 under both.
784 let head = resolve_owner(&segs, 0x100, false).unwrap();
785 assert_eq!(head.seg_index, 0);
786 assert_eq!(
787 validate_reloc_resolutions(&segs, &[head]),
788 Verdict::Consistent
789 );
790 }
791
792 /// Pack the mixed-split init region for tests exactly the way main.rs
793 /// lays it out: each segment 4-aligned, declaration order.
794 fn mixed_pack(segments: &[DataSegment]) -> (Vec<u32>, Vec<u8>) {
795 let mut offs = Vec::with_capacity(segments.len());
796 let mut cur = 0u32;
797 for s in segments {
798 cur = cur.next_multiple_of(4);
799 offs.push(cur);
800 cur += s.bytes.len() as u32;
801 }
802 let mut blob = vec![0u8; cur as usize];
803 for (s, &o) in segments.iter().zip(offs.iter()) {
804 blob[o as usize..o as usize + s.bytes.len()].copy_from_slice(&s.bytes);
805 }
806 (offs, blob)
807 }
808
809 /// PHASE-2 RED-FIRST (span class, the #777 follow-up): a STAGGERED overlap
810 /// — seg_1 overwrites only the TAIL of seg_0's range — with a reloc whose
811 /// addend byte is runtime-correct (owned by seg_0) but whose i32-wide span
812 /// crosses into seg_1's runtime-owned bytes. The phase-1 addend-byte
813 /// validator is GREEN on it (that is the hole this class names); the
814 /// spanned validator must be RED, flagging the exact tail byte. A spanned
815 /// validator green here would be vacuous.
816 #[test]
817 fn phase1_green_but_span_red_on_staggered_overlap() {
818 let segs = vec![
819 DataSegment {
820 linmem_off: 0x10004,
821 bytes: vec![0xA0, 0xA1, 0xA2, 0xA3, 0xA4, 0xA5, 0xA6, 0xA7],
822 },
823 // Staggered: overwrites [0x10008, 0x1000C) — seg_0's tail.
824 DataSegment {
825 linmem_off: 0x10008,
826 bytes: vec![0xB0, 0xB1, 0xB2, 0xB3],
827 },
828 ];
829 // Reloc at 0x10006: owner is seg_0 under the CORRECT .rposition()
830 // (seg_1 does not contain 0x10006). An i32 load spans 0x10006..0x1000A
831 // — bytes +2/+3 are seg_1's at runtime, seg_0's stale in the pack.
832 let r = resolve_owner(&segs, 0x10006, true).unwrap();
833 assert_eq!(r.seg_index, 0, "correct owner of the addend byte is seg_0");
834 // Phase-1 (addend byte only) is GREEN — the documented hole.
835 assert_eq!(
836 validate_reloc_resolutions(&segs, std::slice::from_ref(&r)),
837 Verdict::Consistent,
838 "phase 1 must accept the addend byte (it IS runtime-correct)"
839 );
840 // Phase-2 spanned is RED at span byte +2.
841 let (offs, blob) = mixed_pack(&segs);
842 let packed = PackedInit {
843 seg_packed_off: &offs,
844 bytes: &blob,
845 };
846 match validate_reloc_resolutions_spanned(&segs, std::slice::from_ref(&r), &packed) {
847 Verdict::Mismatch(m) => {
848 assert_eq!(m[0].span_byte, 2, "first divergent byte is +2");
849 assert_eq!(m[0].access_addr, 0x10008);
850 assert_eq!(m[0].served, 0xA4, "packed seg_0 serves its stale byte");
851 assert_eq!(m[0].runtime, 0xB0, "runtime image owns seg_1's byte");
852 }
853 Verdict::Consistent => {
854 panic!("VACUOUS: spanned validator accepted a straddling stale-tail access")
855 }
856 }
857 }
858
859 /// Linmem-ADJACENT segments whose packed layout PRESERVES adjacency
860 /// (4-aligned length, next declaration) — a span crossing the boundary is
861 /// served the right bytes, so the spanned validator must stay GREEN (no
862 /// false red on the benign crossing).
863 #[test]
864 fn span_green_on_adjacency_preserving_crossing() {
865 let segs = vec![
866 DataSegment {
867 linmem_off: 0x100,
868 bytes: vec![1, 2, 3, 4],
869 },
870 DataSegment {
871 linmem_off: 0x104,
872 bytes: vec![5, 6, 7, 8],
873 },
874 ];
875 let (offs, blob) = mixed_pack(&segs);
876 let packed = PackedInit {
877 seg_packed_off: &offs,
878 bytes: &blob,
879 };
880 let r = resolve_owner(&segs, 0x102, true).unwrap();
881 assert_eq!(r.seg_index, 0);
882 assert_eq!(
883 validate_reloc_resolutions_spanned(&segs, &[r], &packed),
884 Verdict::Consistent,
885 "packed adjacency == linmem adjacency: the crossing serves the right bytes"
886 );
887 }
888
889 /// Linmem-adjacent segments whose packed layout BREAKS adjacency (seg_0's
890 /// length is not 4-aligned, so the pack inserts padding the linear memory
891 /// doesn't have): a span crossing the boundary reads pad zeros instead of
892 /// the next segment's bytes — RED.
893 #[test]
894 fn span_red_on_padding_shifted_crossing() {
895 let segs = vec![
896 DataSegment {
897 linmem_off: 0x100,
898 bytes: vec![1, 2, 3], // len 3 → packed pads to 4
899 },
900 // Linmem-adjacent at 0x103; packed at offset 4 (shifted by 1).
901 DataSegment {
902 linmem_off: 0x103,
903 bytes: vec![5, 6, 7, 8],
904 },
905 ];
906 let (offs, blob) = mixed_pack(&segs);
907 let packed = PackedInit {
908 seg_packed_off: &offs,
909 bytes: &blob,
910 };
911 let r = resolve_owner(&segs, 0x101, true).unwrap();
912 assert_eq!(r.seg_index, 0);
913 match validate_reloc_resolutions_spanned(&segs, &[r], &packed) {
914 Verdict::Mismatch(m) => {
915 // +2 = 0x103: runtime owns seg_1's first byte (5); the pack
916 // serves its own pad byte (0).
917 assert_eq!(m[0].span_byte, 2);
918 assert_eq!(m[0].access_addr, 0x103);
919 assert_eq!(m[0].served, 0, "the pack serves 4-align padding");
920 assert_eq!(m[0].runtime, 5);
921 }
922 Verdict::Consistent => panic!("VACUOUS: padding-shifted crossing accepted"),
923 }
924 }
925
926 /// The unknown-width tolerance: a reloc near the end of a SPARSE segment
927 /// (no segment covers the bytes beyond it) must stay GREEN — the span
928 /// bytes are implicit-zero linear memory and the common shape is a narrow
929 /// access. This is the documented residue, not a bug.
930 #[test]
931 fn span_green_on_sparse_tail() {
932 let segs = vec![
933 DataSegment {
934 linmem_off: 0x100,
935 bytes: vec![1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12],
936 },
937 // Far away; between them is implicit-zero linmem.
938 DataSegment {
939 linmem_off: 0x400,
940 bytes: vec![0xFF; 4],
941 },
942 ];
943 let (offs, blob) = mixed_pack(&segs);
944 let packed = PackedInit {
945 seg_packed_off: &offs,
946 bytes: &blob,
947 };
948 // Last word of seg_0: the conservative 8-byte span runs past the end
949 // into uncovered linmem — skipped, not flagged.
950 let r = resolve_owner(&segs, 0x108, true).unwrap();
951 assert_eq!(r.seg_index, 0);
952 assert_eq!(
953 validate_reloc_resolutions_spanned(&segs, &[r], &packed),
954 Verdict::Consistent,
955 "uncovered span bytes are implicit-zero linmem — must not false-red"
956 );
957 }
958
959 /// A span that escapes the init region entirely (into the globals slots /
960 /// past the blob) while the runtime address IS segment-covered: RED — the
961 /// pack cannot serve that byte at all. Shape: the LAST-declared segment is
962 /// shorter than an earlier one at the same base, so bytes beyond its end
963 /// are runtime-owned by the earlier segment but packed nowhere after it.
964 #[test]
965 fn span_red_on_init_region_escape() {
966 let segs = vec![
967 DataSegment {
968 linmem_off: 0x100,
969 bytes: vec![0x11; 8], // covers [0x100, 0x108)
970 },
971 DataSegment {
972 linmem_off: 0x100,
973 bytes: vec![0x22; 4], // last-declared owner of [0x100, 0x104)
974 },
975 ];
976 let (offs, blob) = mixed_pack(&segs);
977 assert_eq!(blob.len(), 12, "seg_1 is the final packed segment");
978 let packed = PackedInit {
979 seg_packed_off: &offs,
980 bytes: &blob,
981 };
982 // 0x102 is owned by seg_1 (last); its span bytes +2/+3 (0x104/0x105)
983 // are runtime-owned by seg_0 (0x11) but lie past seg_1's packed end =
984 // past the whole init region.
985 let r = resolve_owner(&segs, 0x102, true).unwrap();
986 assert_eq!(r.seg_index, 1);
987 match validate_reloc_resolutions_spanned(&segs, &[r], &packed) {
988 Verdict::Mismatch(m) => {
989 assert_eq!(m[0].span_byte, 2);
990 assert_eq!(m[0].access_addr, 0x104);
991 assert_eq!(m[0].runtime, 0x11);
992 }
993 Verdict::Consistent => panic!("VACUOUS: init-region escape accepted"),
994 }
995 }
996
997 /// The blob-fill pin at the addend byte: segments and resolution are
998 /// phase-1-green, but the SHIPPED blob was corrupted at the served
999 /// position — the spanned validator must flag it at span byte 0 (phase 1
1000 /// reads segment bytes and cannot see it).
1001 #[test]
1002 fn span_red_on_blob_fill_corruption_at_addend_byte() {
1003 let segs = vec![DataSegment {
1004 linmem_off: 0x100,
1005 bytes: vec![1, 2, 3, 4],
1006 }];
1007 let (offs, mut blob) = mixed_pack(&segs);
1008 let r = resolve_owner(&segs, 0x102, true).unwrap();
1009 assert_eq!(
1010 validate_reloc_resolutions(&segs, std::slice::from_ref(&r)),
1011 Verdict::Consistent,
1012 "phase 1 (segment bytes) cannot see a blob-fill bug"
1013 );
1014 blob[2] = 0xEE; // corrupt the byte the reloc actually serves
1015 let packed = PackedInit {
1016 seg_packed_off: &offs,
1017 bytes: &blob,
1018 };
1019 match validate_reloc_resolutions_spanned(&segs, std::slice::from_ref(&r), &packed) {
1020 Verdict::Mismatch(m) => {
1021 assert_eq!(m[0].span_byte, 0);
1022 assert_eq!(m[0].served, 0xEE);
1023 assert_eq!(m[0].runtime, 3);
1024 }
1025 Verdict::Consistent => panic!("VACUOUS: corrupted shipped blob accepted"),
1026 }
1027 }
1028
1029 /// ROM-image RED-FIRST (self-contained class, phase 1's `resolve_owner`
1030 /// pattern — the overwrite policy is an ARGUMENT): on an overlapping
1031 /// module the SAME dense-image validator must be RED on the first-wins
1032 /// pack (`last_wins = false`, the synthetic miscompile) and GREEN on the
1033 /// declaration-order pack (`last_wins = true`, WASM instantiation
1034 /// semantics). Green on both would be vacuous.
1035 #[test]
1036 fn rom_image_red_on_first_wins_green_on_last_wins() {
1037 let segs = overlapping_segments();
1038 let wrong = pack_rom_image(&segs, false);
1039 match validate_served_image(&segs, &wrong) {
1040 ImageVerdict::Mismatch(m) => {
1041 // The classic #757 byte: offset 8 must be seg_2's 'u', but the
1042 // first-wins image left seg_0's 0xAA there.
1043 let at8 = m.iter().find(|x| x.addr == 0x100008).expect("addr 8");
1044 assert_eq!(at8.served, 0xAA);
1045 assert_eq!(at8.runtime, b'u');
1046 }
1047 ImageVerdict::Consistent => {
1048 panic!("VACUOUS: dense-image validator accepted a first-wins pack")
1049 }
1050 }
1051 let right = pack_rom_image(&segs, true);
1052 assert_eq!(
1053 validate_served_image(&segs, &right),
1054 ImageVerdict::Consistent,
1055 "declaration-order (later-wins) pack must validate"
1056 );
1057 }
1058
1059 /// A dense image with initializer garbage in an uncovered GAP is a
1060 /// mismatch (runtime linmem is zero there), and a truncated image whose
1061 /// missing tail is all-zero at runtime is fine (zeroed RAM serves it).
1062 #[test]
1063 fn rom_image_gap_garbage_red_zero_tail_green() {
1064 let segs = vec![
1065 DataSegment {
1066 linmem_off: 0,
1067 bytes: vec![1, 2],
1068 },
1069 DataSegment {
1070 linmem_off: 8,
1071 bytes: vec![0, 0, 0, 0],
1072 },
1073 ];
1074 // Garbage at uncovered addr 4.
1075 let mut img = pack_rom_image(&segs, true);
1076 img[4] = 0xCC;
1077 assert!(matches!(
1078 validate_served_image(&segs, &img),
1079 ImageVerdict::Mismatch(_)
1080 ));
1081 // Image truncated to the nonzero prefix: the all-zero tail (gap +
1082 // zero segment) is served by zeroed RAM — consistent.
1083 assert_eq!(
1084 validate_served_image(&segs, &[1, 2]),
1085 ImageVerdict::Consistent
1086 );
1087 }
1088
1089 /// RISC-V single-base shape: the object ships NO initializer image
1090 /// (`image = &[]`, zeroed RAM serves everything). Nonzero segment bytes
1091 /// are un-served (RED, the silent initializer-drop); an all-zero segment
1092 /// — or an earlier nonzero byte OVERWRITTEN to zero by a later segment —
1093 /// is served correctly by zeroed RAM (GREEN). The overwrite case keeps
1094 /// this non-vacuous as a later-wins check, not a "any nonzero data" grep.
1095 #[test]
1096 fn zero_served_image_red_on_nonzero_green_on_zeroed() {
1097 let nonzero = vec![DataSegment {
1098 linmem_off: 16,
1099 bytes: vec![1, 2, 3, 4],
1100 }];
1101 match validate_served_image(&nonzero, &[]) {
1102 ImageVerdict::Mismatch(m) => {
1103 assert_eq!(m[0].addr, 16);
1104 assert_eq!(m[0].served, 0);
1105 assert_eq!(m[0].runtime, 1);
1106 }
1107 ImageVerdict::Consistent => panic!("VACUOUS: dropped nonzero initializer accepted"),
1108 }
1109 let zeroed = vec![
1110 DataSegment {
1111 linmem_off: 16,
1112 bytes: vec![1, 2, 3, 4],
1113 },
1114 // Later segment overwrites the nonzero bytes with zeros: the
1115 // runtime image is all-zero, so zeroed RAM serves it correctly.
1116 DataSegment {
1117 linmem_off: 16,
1118 bytes: vec![0, 0, 0, 0],
1119 },
1120 ];
1121 assert_eq!(
1122 validate_served_image(&zeroed, &[]),
1123 ImageVerdict::Consistent
1124 );
1125 }
1126
1127 /// Out-of-range resolution (a broken retargeting) is a mismatch.
1128 #[test]
1129 fn out_of_range_is_mismatch() {
1130 let segs = vec![DataSegment {
1131 linmem_off: 0,
1132 bytes: vec![1, 2, 3],
1133 }];
1134 let bad = RelocResolution {
1135 seg_index: 0,
1136 addend: 99,
1137 label: "oob".into(),
1138 };
1139 assert!(matches!(
1140 validate_reloc_resolutions(&segs, &[bad]),
1141 Verdict::Mismatch(_)
1142 ));
1143 }
1144
1145 // ─── #798 sparse per-segment records (RV32 `.wasm_data`) ───────────
1146
1147 /// Round trip: pack → parse recovers the declaration-order records
1148 /// verbatim (offsets, lengths, bytes), across 4-align padding.
1149 #[test]
1150 fn segment_records_round_trip() {
1151 let segs = vec![
1152 DataSegment {
1153 linmem_off: 16,
1154 bytes: vec![1, 2, 3], // len 3 → 1 pad byte
1155 },
1156 DataSegment {
1157 linmem_off: 0x10000,
1158 bytes: vec![0xAA; 8],
1159 },
1160 DataSegment {
1161 linmem_off: 4,
1162 bytes: vec![9], // len 1 → 3 pad bytes
1163 },
1164 ];
1165 let blob = pack_segment_records(&segs);
1166 assert_eq!(blob.len() % 4, 0, "records blob is 4-aligned throughout");
1167 let back = parse_segment_records(&blob).expect("well-formed blob parses");
1168 assert_eq!(back.len(), 3);
1169 for (a, b) in segs.iter().zip(back.iter()) {
1170 assert_eq!(a.linmem_off, b.linmem_off);
1171 assert_eq!(a.bytes, b.bytes);
1172 }
1173 }
1174
1175 /// RED-FIRST (#798 shipping gate, the #757 lesson applied to the copy
1176 /// order): the startup copies records in RECORD order, so a pack that
1177 /// stores overlapping segments in REVERSED declaration order serves the
1178 /// FIRST-declared bytes (first-wins) — the served image read back from
1179 /// that blob must FAIL validate_served_image, and the declaration-order
1180 /// pack must PASS. Green on both would make the read-back gate vacuous.
1181 #[test]
1182 fn records_red_on_reversed_pack_green_on_declaration_order() {
1183 let segs = overlapping_segments();
1184 let mut reversed = segs.clone();
1185 reversed.reverse();
1186 let wrong_blob = pack_segment_records(&reversed);
1187 let wrong_served = served_image_from_records(&wrong_blob).unwrap();
1188 match validate_served_image(&segs, &wrong_served) {
1189 ImageVerdict::Mismatch(m) => {
1190 let at8 = m.iter().find(|x| x.addr == 0x100008).expect("addr 8");
1191 assert_eq!(at8.served, 0xAA, "reversed pack serves seg_0's stale byte");
1192 assert_eq!(at8.runtime, b'u', "runtime image owns seg_2's byte");
1193 }
1194 ImageVerdict::Consistent => {
1195 panic!("VACUOUS: read-back gate accepted a reversed (first-wins) pack")
1196 }
1197 }
1198 let right_blob = pack_segment_records(&segs);
1199 let right_served = served_image_from_records(&right_blob).unwrap();
1200 assert_eq!(
1201 validate_served_image(&segs, &right_served),
1202 ImageVerdict::Consistent,
1203 "declaration-order records must serve the later-wins image"
1204 );
1205 }
1206
1207 /// The served image is SPARSE-tolerant: gaps between records read zero,
1208 /// matching implicit-zero linear memory (zeroed RAM under the RV32
1209 /// scheme), so a far-offset segment validates without a dense flash blob.
1210 #[test]
1211 fn records_far_offset_segment_served_correctly() {
1212 let segs = vec![DataSegment {
1213 linmem_off: 0x10000,
1214 bytes: vec![7, 8, 9, 10],
1215 }];
1216 let blob = pack_segment_records(&segs);
1217 assert_eq!(blob.len(), 12, "8-byte header + 4 bytes, no dense image");
1218 let served = served_image_from_records(&blob).unwrap();
1219 assert_eq!(served.len(), 0x10004);
1220 assert_eq!(
1221 validate_served_image(&segs, &served),
1222 ImageVerdict::Consistent
1223 );
1224 }
1225
1226 /// Malformed blobs (truncated header, truncated payload, missing align
1227 /// padding) parse to None — the read-back must fail loudly, not
1228 /// best-effort.
1229 #[test]
1230 fn records_malformed_blobs_rejected() {
1231 let segs = vec![DataSegment {
1232 linmem_off: 4,
1233 bytes: vec![1, 2, 3, 4, 5],
1234 }];
1235 let blob = pack_segment_records(&segs);
1236 assert!(parse_segment_records(&blob[..4]).is_none(), "cut header");
1237 assert!(parse_segment_records(&blob[..10]).is_none(), "cut payload");
1238 assert!(
1239 parse_segment_records(&blob[..blob.len() - 1]).is_none(),
1240 "cut align padding"
1241 );
1242 assert!(served_image_from_records(&blob[..10]).is_none());
1243 // Empty blob = zero segments: parses to nothing, serves nothing.
1244 assert_eq!(parse_segment_records(&[]).unwrap().len(), 0);
1245 assert_eq!(served_image_from_records(&[]).unwrap().len(), 0);
1246 }
1247
1248 // ---- VCR-VER-003 #761: linmem<->globals disjointness geometry gate ----
1249
1250 /// RED-FIRST non-vacuity: the EXACT pre-fix geometry — `(memory 1)`,
1251 /// function-visible base 0x2000_0100, globals table based (wrongly) on the
1252 /// R11 base 0x2000_0000 + 64 KiB = 0x2001_0000 — must be caught as an
1253 /// OVERLAP of 0x100 bytes (the top of the page aliases the table).
1254 #[test]
1255 fn layout_gate_761_red_r11_based_globals_overlap() {
1256 let func_visible = 0x2000_0100u32;
1257 let linmem = 64 * 1024;
1258 // The BUG: globals based on R11 (0x2000_0000), not the func-visible base.
1259 let bad_globals_base = 0x2000_0000u32 + linmem; // 0x2001_0000
1260 let v = validate_linmem_globals_disjoint(func_visible, linmem, bad_globals_base, 4);
1261 assert_eq!(
1262 v,
1263 LayoutVerdict::Overlap {
1264 func_visible_linmem_base: func_visible,
1265 linmem_bytes: linmem,
1266 globals_base: bad_globals_base,
1267 overlap_bytes: 0x100,
1268 }
1269 );
1270 }
1271
1272 /// GREEN: the FIXED geometry — globals based on the function-visible base +
1273 /// memory size (0x2000_0100 + 64 KiB = 0x2001_0100) sits exactly AT the page
1274 /// ceiling, so the regions are disjoint.
1275 #[test]
1276 fn layout_gate_761_green_func_visible_based_globals_disjoint() {
1277 let func_visible = 0x2000_0100u32;
1278 let linmem = 64 * 1024;
1279 let good_globals_base = func_visible + linmem; // 0x2001_0100 == ceiling
1280 assert_eq!(
1281 validate_linmem_globals_disjoint(func_visible, linmem, good_globals_base, 4),
1282 LayoutVerdict::Disjoint
1283 );
1284 }
1285
1286 /// Boundary: table exactly at the ceiling is disjoint (half-open page); one
1287 /// byte below the ceiling is a 1-byte overlap.
1288 #[test]
1289 fn layout_gate_761_ceiling_boundary_is_exclusive() {
1290 let base = 0x2000_0100u32;
1291 let linmem = 0x1000;
1292 let ceiling = base + linmem;
1293 assert_eq!(
1294 validate_linmem_globals_disjoint(base, linmem, ceiling, 8),
1295 LayoutVerdict::Disjoint
1296 );
1297 match validate_linmem_globals_disjoint(base, linmem, ceiling - 1, 8) {
1298 LayoutVerdict::Overlap { overlap_bytes, .. } => assert_eq!(overlap_bytes, 1),
1299 v => panic!("expected 1-byte overlap, got {v:?}"),
1300 }
1301 }
1302
1303 /// A module with NO globals is trivially disjoint regardless of the bases —
1304 /// the startup emits no R9 block at all.
1305 #[test]
1306 fn layout_gate_761_no_globals_is_disjoint() {
1307 assert_eq!(
1308 validate_linmem_globals_disjoint(0x2000_0100, 64 * 1024, 0x2000_0000, 0),
1309 LayoutVerdict::Disjoint
1310 );
1311 }
1312
1313 /// The `--stack-layout=low` shape: both bases shift up by the stack reserve,
1314 /// so a func-visible-based table stays disjoint (the fix covers both layouts).
1315 #[test]
1316 fn layout_gate_761_low_layout_func_visible_based_disjoint() {
1317 let stack = 0x1000u32;
1318 let func_visible = 0x2000_0100 + stack; // optimized_linmem_base under low
1319 let linmem = 64 * 1024;
1320 assert_eq!(
1321 validate_linmem_globals_disjoint(func_visible, linmem, func_visible + linmem, 4),
1322 LayoutVerdict::Disjoint
1323 );
1324 // ... and the pre-fix R11-based placement would still overlap under low.
1325 let bad = (0x2000_0000 + stack) + linmem;
1326 match validate_linmem_globals_disjoint(func_visible, linmem, bad, 4) {
1327 LayoutVerdict::Overlap { overlap_bytes, .. } => assert_eq!(overlap_bytes, 0x100),
1328 v => panic!("expected 0x100 overlap under low, got {v:?}"),
1329 }
1330 }
1331}