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