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}