Skip to main content

synth_core/
static_data_addr.rs

1//! Static-data addressing validation (VCR-VER-003, synth #777 / #757).
2//!
3//! WASM active data segments are applied to linear memory **in declaration
4//! order, later-wins**: when two active segments overlap the same range, the
5//! later-declared one overwrites the earlier. synth's `--native-pointer-abi`
6//! relocatable path splits the linear memory into one packed `.data` blob per
7//! segment (`__synth_wasm_seg_K`) and retargets every static-data relocation
8//! `__synth_wasm_data + C` to `__synth_wasm_seg_K + (C - seg_off_K)` (the #354
9//! mixed-split). Choosing the WRONG owning segment for an address `C` that lies
10//! in several overlapping segments is a **silent miscompile**: the reloc reads
11//! a stale earlier segment's bytes instead of the byte the runtime image holds.
12//!
13//! This is exactly #757 — gale's fused `gust:os` node declared three active
14//! segments all at linmem `0x100000`; the string lived in the last segment but
15//! the retargeting (`.position()`) bound its reads to the first segment's
16//! consts (`got=[2,0,0,0,..]` = `__synth_wasm_seg_0+8`). It survived four
17//! releases because value-differential oracles are coverage-limited (7
18//! synthetic reconstructions were all green). The fix resolves overlapping
19//! addresses to the LAST-declared owner (`.rposition()`).
20//!
21//! # What this validator proves (per compilation, by construction)
22//!
23//! Given the module's active data segments (declaration order) and the
24//! retargeting the compiler actually emitted — one [`RelocResolution`]
25//! `(seg_index K, addend A)` per static-data reloc — it reconstructs the
26//! RUNTIME linear-memory image (apply every segment in declaration order,
27//! later-wins) **independently of K**, then asserts: the byte the packed
28//! `.data` serves for that reloc (`seg[K].bytes[A]`) EQUALS the byte the
29//! runtime image holds at the reloc's original access address
30//! (`seg[K].off + A`). A single mismatch — the wrong-segment resolution — is a
31//! [`Verdict::Mismatch`].
32//!
33//! # Concrete, not symbolic; unconditional
34//!
35//! This is a concrete byte-equality over a compiled object, not a ∀-inputs SMT
36//! property, and it depends on nothing but `std` — so it lives in `synth-core`
37//! and runs on **every** compilation (the shipping build is `--features riscv`,
38//! *not* `verify`; a `verify`-gated check would stay dormant in exactly the
39//! build that shipped #757 four times). It mirrors VCR-VER-002's *structure* (a
40//! verdict enum + a per-compilation gate) but does the honest thing — a direct
41//! comparison against an independently reconstructed truth image. The truth
42//! side never touches `K`, so the validator cannot be satisfied by mirroring
43//! the code under test (the mirror-pinning vacuity mode is structurally
44//! excluded). `synth-verify` re-exports this module for the VCR-VER-003 tests.
45
46use std::collections::HashMap;
47
48/// One active WASM data segment: its linear-memory offset and its bytes, in
49/// declaration order. The packed `.data` blob stores these bytes verbatim
50/// (4-aligned per segment) under `__synth_wasm_seg_K`; index `K` in the segment
51/// list is the `K` in the symbol name.
52#[derive(Clone, Debug)]
53pub struct DataSegment {
54    /// Linear-memory offset the active segment is applied at (WASM `i32.const`).
55    pub linmem_off: u32,
56    /// The segment's initializer bytes.
57    pub bytes: Vec<u8>,
58}
59
60/// The retargeting the compiler emitted for one static-data relocation: it now
61/// points at `__synth_wasm_seg_{seg_index} + addend`. `seg_index` is the `K`
62/// from the emitted symbol name; `addend` is the emitted in-place REL addend
63/// (`= original_access_addr - seg[K].linmem_off`). This is the value read back
64/// from what the compiler produced — NEVER recomputed by the validator (that
65/// would mirror-pin the check and make it vacuous).
66#[derive(Clone, Debug)]
67pub struct RelocResolution {
68    /// The `K` in the emitted `__synth_wasm_seg_K` symbol.
69    pub seg_index: usize,
70    /// The emitted addend (offset into `seg[K].bytes`).
71    pub addend: u32,
72    /// Optional label for diagnostics (e.g. `"func 3 @ 0x1a"`); not load-bearing.
73    pub label: String,
74}
75
76/// The verdict of the addressing gate.
77#[derive(Clone, Debug, PartialEq, Eq)]
78pub enum Verdict {
79    /// Every static-data reloc resolves to the runtime-correct byte (segments
80    /// applied in declaration order, later-wins). #757 cannot occur.
81    Consistent,
82    /// A reloc resolves to a byte that disagrees with the runtime image — the
83    /// wrong-segment miscompile. Carries the offending resolutions.
84    Mismatch(Vec<AddrMismatch>),
85}
86
87/// A single reloc that reads the wrong byte.
88#[derive(Clone, Debug, PartialEq, Eq)]
89pub struct AddrMismatch {
90    /// The reloc's diagnostic label.
91    pub label: String,
92    /// The emitted `K` (`__synth_wasm_seg_K`).
93    pub seg_index: usize,
94    /// The emitted addend.
95    pub addend: u32,
96    /// The original linear-memory access address (`seg[K].off + addend`).
97    pub access_addr: u32,
98    /// The byte the packed `.data` serves (`seg[K].bytes[addend]`).
99    pub served: u8,
100    /// The byte the runtime image holds at `access_addr` (the truth).
101    pub runtime: u8,
102}
103
104impl AddrMismatch {
105    /// A human-readable one-line diagnostic for the compile-time error.
106    pub fn describe(&self) -> String {
107        format!(
108            "{}: __synth_wasm_seg_{}+0x{:x} -> linmem 0x{:x} serves 0x{:02x} but \
109             the runtime image (segments applied later-wins) owns 0x{:02x}",
110            self.label, self.seg_index, self.addend, self.access_addr, self.served, self.runtime
111        )
112    }
113}
114
115/// Reconstruct the runtime linear-memory image: apply every active segment in
116/// declaration order, later-wins. This is the ground truth and is derived only
117/// from the segment list — never from any reloc resolution.
118fn runtime_image(segments: &[DataSegment]) -> HashMap<u32, u8> {
119    let mut mem = HashMap::new();
120    for seg in segments {
121        for (j, &b) in seg.bytes.iter().enumerate() {
122            mem.insert(seg.linmem_off + j as u32, b);
123        }
124    }
125    mem
126}
127
128/// The per-compilation addressing gate. For every emitted [`RelocResolution`],
129/// assert the packed byte it serves equals the runtime-image byte at the
130/// original access address. See the module docs for the invariant.
131///
132/// Returns [`Verdict::Consistent`] if every reloc agrees, else
133/// [`Verdict::Mismatch`] carrying each offending reloc. Resolutions whose
134/// `seg_index`/`addend` are out of range are reported as mismatches (an
135/// out-of-range resolution is itself a broken retargeting).
136pub fn validate_reloc_resolutions(
137    segments: &[DataSegment],
138    resolutions: &[RelocResolution],
139) -> Verdict {
140    let runtime = runtime_image(segments);
141    let mut bad = Vec::new();
142    for r in resolutions {
143        let Some(seg) = segments.get(r.seg_index) else {
144            bad.push(AddrMismatch {
145                label: r.label.clone(),
146                seg_index: r.seg_index,
147                addend: r.addend,
148                access_addr: 0,
149                served: 0,
150                runtime: 0,
151            });
152            continue;
153        };
154        let access_addr = seg.linmem_off + r.addend;
155        // The byte the packed .data serves for this reloc.
156        let Some(&served) = seg.bytes.get(r.addend as usize) else {
157            bad.push(AddrMismatch {
158                label: r.label.clone(),
159                seg_index: r.seg_index,
160                addend: r.addend,
161                access_addr,
162                served: 0,
163                runtime: 0,
164            });
165            continue;
166        };
167        // The byte the runtime image (independent of K) holds there.
168        // Every retargeted reloc addresses a byte inside some segment, so the
169        // runtime image is always defined at access_addr; a missing entry would
170        // itself be a broken retargeting, so treat it as a mismatch.
171        let Some(&runtime_byte) = runtime.get(&access_addr) else {
172            bad.push(AddrMismatch {
173                label: r.label.clone(),
174                seg_index: r.seg_index,
175                addend: r.addend,
176                access_addr,
177                served,
178                runtime: 0,
179            });
180            continue;
181        };
182        if served != runtime_byte {
183            bad.push(AddrMismatch {
184                label: r.label.clone(),
185                seg_index: r.seg_index,
186                addend: r.addend,
187                access_addr,
188                served,
189                runtime: runtime_byte,
190            });
191        }
192    }
193    if bad.is_empty() {
194        Verdict::Consistent
195    } else {
196        Verdict::Mismatch(bad)
197    }
198}
199
200/// Resolve an access address `c` to its owning segment index under a chosen
201/// tie-break policy, mirroring main.rs's `.rposition()` / `.position()` search.
202/// `last_wins = true` is the CORRECT WASM overwrite semantics (`.rposition()`);
203/// `last_wins = false` is the #757 miscompile (`.position()`). Returns the
204/// segment index and the addend `c - seg.linmem_off`, or `None` if `c` is in no
205/// segment. Exposed so the red-first gate can toggle the policy as an argument
206/// (no source revert), and so callers can build resolutions the same way the
207/// compiler does.
208pub fn resolve_owner(segments: &[DataSegment], c: u32, last_wins: bool) -> Option<RelocResolution> {
209    let hit = |(off, len): (u32, usize)| c >= off && c < off + len as u32;
210    let idx = if last_wins {
211        segments
212            .iter()
213            .rposition(|s| hit((s.linmem_off, s.bytes.len())))
214    } else {
215        segments
216            .iter()
217            .position(|s| hit((s.linmem_off, s.bytes.len())))
218    }?;
219    Some(RelocResolution {
220        seg_index: idx,
221        addend: c - segments[idx].linmem_off,
222        label: format!("addr 0x{c:x}"),
223    })
224}
225
226#[cfg(test)]
227mod tests {
228    use super::*;
229
230    /// Three active segments ALL at linmem 0x100000 with DISTINCT bytes at the
231    /// overlap offset — the #757 shape. seg_2 (last) owns the runtime bytes.
232    fn overlapping_segments() -> Vec<DataSegment> {
233        vec![
234            // seg_0: stale consts (the wrong bytes #757 read)
235            DataSegment {
236                linmem_off: 0x100000,
237                bytes: vec![0x02, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x20, 0xAA, 0xBB],
238            },
239            // seg_1: a middle segment, also overwritten by seg_2 at the overlap
240            DataSegment {
241                linmem_off: 0x100000,
242                bytes: vec![0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0x10],
243            },
244            // seg_2 (last, wins): "gust:os up\n"-like distinct bytes
245            DataSegment {
246                linmem_off: 0x100000,
247                bytes: b"gust:os up\n".to_vec(),
248            },
249        ]
250    }
251
252    /// The load-bearing non-vacuous gate: the SAME validator must go RED on the
253    /// `.position()` (first-match, wrong) resolution and GREEN on `.rposition()`
254    /// (last-match, correct) — for an overlapping-segment module. A validator
255    /// green on both would be vacuous. The policy is an ARGUMENT (`last_wins`),
256    /// so this is a permanent test, not a one-time source revert. (The
257    /// end-to-end revert-through-main.rs RED lives in the synth-cli fixture.)
258    #[test]
259    fn red_on_first_match_green_on_last_match() {
260        let segs = overlapping_segments();
261        // Access the byte at linmem 0x100008 — the classic #757 access. All
262        // three segments cover it with distinct bytes.
263        let c = 0x100008;
264        assert_eq!(segs[0].bytes[8], 0xAA); // seg_0 stale
265        assert_eq!(segs[2].bytes[8], b'u'); // seg_2 runtime-correct ("...s Up\n"[8])
266
267        // WRONG policy (#757: .position(), first match) -> seg_0 -> RED.
268        let wrong = resolve_owner(&segs, c, /* last_wins */ false).unwrap();
269        assert_eq!(wrong.seg_index, 0, "first-match must pick seg_0");
270        let red = validate_reloc_resolutions(&segs, std::slice::from_ref(&wrong));
271        match red {
272            Verdict::Mismatch(m) => {
273                assert_eq!(m.len(), 1);
274                assert_eq!(m[0].seg_index, 0);
275                assert_eq!(m[0].access_addr, c);
276                assert_eq!(m[0].served, 0xAA, "seg_0 serves the stale byte");
277                assert_eq!(m[0].runtime, b'u', "runtime image (seg_2) owns 'u'");
278            }
279            Verdict::Consistent => {
280                panic!("VACUOUS: validator accepted the #757 wrong-segment resolution")
281            }
282        }
283
284        // CORRECT policy (.rposition(), last match) -> seg_2 -> GREEN.
285        let right = resolve_owner(&segs, c, /* last_wins */ true).unwrap();
286        assert_eq!(right.seg_index, 2, "last-match must pick seg_2");
287        assert_eq!(
288            validate_reloc_resolutions(&segs, std::slice::from_ref(&right)),
289            Verdict::Consistent,
290            "the runtime-correct resolution must pass"
291        );
292    }
293
294    /// Non-overlapping segments: every address is in exactly one segment, so
295    /// first-match == last-match and both policies pass (no regression on the
296    /// common case).
297    #[test]
298    fn non_overlapping_both_policies_consistent() {
299        let segs = vec![
300            DataSegment {
301                linmem_off: 0x1000,
302                bytes: vec![1, 2, 3, 4],
303            },
304            DataSegment {
305                linmem_off: 0x2000,
306                bytes: vec![5, 6, 7, 8],
307            },
308        ];
309        for &c in &[0x1002u32, 0x2003] {
310            let a = resolve_owner(&segs, c, false).unwrap();
311            let b = resolve_owner(&segs, c, true).unwrap();
312            assert_eq!(a.seg_index, b.seg_index);
313            assert_eq!(validate_reloc_resolutions(&segs, &[a]), Verdict::Consistent);
314            assert_eq!(validate_reloc_resolutions(&segs, &[b]), Verdict::Consistent);
315        }
316    }
317
318    /// Partial overlap: a later segment overwrites only the TAIL of an earlier
319    /// one. An address in the overwritten tail must resolve to the later
320    /// segment; first-match (earlier) is RED there.
321    #[test]
322    fn partial_overlap_tail_wins() {
323        let segs = vec![
324            DataSegment {
325                linmem_off: 0x100,
326                bytes: vec![0x10, 0x11, 0x12, 0x13, 0x14, 0x15],
327            },
328            // overwrites [0x104, 0x108) with distinct bytes
329            DataSegment {
330                linmem_off: 0x104,
331                bytes: vec![0xF4, 0xF5, 0xF6, 0xF7],
332            },
333        ];
334        let c = 0x104; // in the overwritten tail
335        let wrong = resolve_owner(&segs, c, false).unwrap();
336        assert_eq!(wrong.seg_index, 0);
337        assert!(matches!(
338            validate_reloc_resolutions(&segs, &[wrong]),
339            Verdict::Mismatch(_)
340        ));
341        let right = resolve_owner(&segs, c, true).unwrap();
342        assert_eq!(right.seg_index, 1);
343        assert_eq!(
344            validate_reloc_resolutions(&segs, &[right]),
345            Verdict::Consistent
346        );
347
348        // An address in the NON-overwritten head resolves to seg_0 under both.
349        let head = resolve_owner(&segs, 0x100, false).unwrap();
350        assert_eq!(head.seg_index, 0);
351        assert_eq!(
352            validate_reloc_resolutions(&segs, &[head]),
353            Verdict::Consistent
354        );
355    }
356
357    /// Out-of-range resolution (a broken retargeting) is a mismatch.
358    #[test]
359    fn out_of_range_is_mismatch() {
360        let segs = vec![DataSegment {
361            linmem_off: 0,
362            bytes: vec![1, 2, 3],
363        }];
364        let bad = RelocResolution {
365            seg_index: 0,
366            addend: 99,
367            label: "oob".into(),
368        };
369        assert!(matches!(
370            validate_reloc_resolutions(&segs, &[bad]),
371            Verdict::Mismatch(_)
372        ));
373    }
374}