Skip to main content

synth_core/
proven_safe.rs

1//! `safe-accesses.json` ingestion — VCR-MEM-004 / #901.
2//!
3//! scry (the PROVER, scry#114 / FEAT-046) emits a **non-exhaustive** list of
4//! wasm memory accesses its sound abstract interpretation proved in-bounds
5//! against the memory's guaranteed MINIMUM size. synth consumes it to skip
6//! emitting the `--safety-bounds software` guard at exactly those sites: the
7//! elision is proof-carrying (scry proves the access, synth's Rocq suite
8//! proves the lowering) and attested (`sigil` reads the sidecar this module
9//! emits).
10//!
11//! ```json
12//! { "schema": "scry/safe-accesses/v1", "scry_version": "3.2.4",
13//!   "module_sha256": "<hex>", "memory_min_bytes": 65536,
14//!   "proven_safe": [ { "func": 4, "pc": 41, "op": "i32.load", "width": 4 } ] }
15//! ```
16//!
17//! # Why this file is nearly all refusal logic
18//!
19//! Eliding a bounds check on a stale or mis-keyed analysis is a **memory-safety
20//! hole**, not a stale optimization. So every question this module can ask is
21//! answered fail-closed, and "fail closed" always means the SAME thing: an
22//! EMPTY [`synth_memory::ProvenSafeSites`]-shaped verdict set, which degrades
23//! the module to the ordinary software bounds check. It never means an error
24//! and never means partial trust.
25//!
26//! 1. **`module_sha256` mismatch ⇒ refuse.** Verified against the exact bytes
27//!    handed to the decoder — i.e. AFTER `.wat` parsing, after loom, and after
28//!    the #418 arena-bind rewrite. That is deliberate: those rewrites shift
29//!    function and operator indices, so binding the hash to the post-rewrite
30//!    bytes makes index skew and byte skew the SAME gate. A file produced for
31//!    the pre-rewrite module simply fails the hash and elides nothing.
32//! 2. **`memory_min_bytes` disagreement ⇒ refuse.** The verdicts are proven
33//!    against scry's declared floor; if synth's declared minimum for this
34//!    module differs, every verdict is unsound HERE. A matching hash implies
35//!    the two agree, so a mismatch means the producer is broken — and trusting
36//!    a broken prover is the hole this module exists to close.
37//! 3. **Wrong/absent schema, malformed JSON, unreadable file ⇒ refuse** with a
38//!    diagnostic and exit 0. This is the `wsc.facts` fail-safe skew rule
39//!    ([`crate::wsc_facts`], loom#231 Q4) applied to a JSON carrier: the
40//!    verdicts are an optional accelerator, so no input may turn a successful
41//!    compile into a failed one.
42//! 4. **The key is self-checking.** `(func, pc)` is the wasmparser operator
43//!    index space: `pc` is the 0-based index of the operator within the
44//!    function body — the space scry's own guard refinement walks
45//!    (`ops[pc..pc+4]`, scry#114 §2) and the space synth's `op_offsets`
46//!    side-table and #494's elision marks are already stated in.
47//!    [`ProvenSafeIngest::validate_function`] checks each entry against the
48//!    DECODED stream: the op at `pc` must exist, must be a memory access, and
49//!    its access width must equal the declared `width`. An entry that fails is
50//!    DROPPED with a counted diagnostic. So if a producer ever emits byte
51//!    offsets instead of operator indices, essentially every entry fails and
52//!    the build elides NOTHING loudly — instead of stripping the guard off the
53//!    wrong access silently.
54//!
55//! Absence from `proven_safe` means **"not proven"**, never "unsafe": an
56//! unlisted site keeps its guard.
57
58use serde::{Deserialize, Serialize};
59use sha2::{Digest, Sha256};
60use std::path::{Path, PathBuf};
61
62use crate::wasm_op::WasmOp;
63
64/// The producer schema this consumer understands (scry#114).
65pub const SAFE_ACCESSES_SCHEMA: &str = "scry/safe-accesses/v1";
66
67/// The attestation schema synth emits for sigil.
68pub const ELISION_ATTESTATION_SCHEMA: &str = "synth-proven-safe-elisions-v1";
69
70// =============================================================================
71// The producer document
72// =============================================================================
73
74/// One access site scry claims to have proven in-bounds.
75#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
76pub struct SafeSite {
77    /// Full wasm function index (imported functions first, then local ones) —
78    /// the same space as `FunctionOps::index`.
79    pub func: u32,
80    /// 0-based operator index within the function body.
81    pub pc: u32,
82    /// The operator mnemonic scry saw (e.g. `"i32.load"`). Diagnostic only —
83    /// the WIDTH is what gets machine-checked, since it is the field whose
84    /// disagreement would change which bytes are covered.
85    #[serde(default)]
86    pub op: String,
87    /// Access width in bytes. Checked against the decoded op.
88    pub width: u32,
89}
90
91/// The raw `safe-accesses.json` shape. Unknown fields are IGNORED
92/// (forward-compatible with a newer scry adding `premises`/`counts`
93/// alongside), but the fields synth's soundness rests on are all required —
94/// a missing one is a parse failure, hence a refusal.
95#[derive(Debug, Clone, Deserialize)]
96struct RawDocument {
97    schema: String,
98    #[serde(default)]
99    scry_version: String,
100    module_sha256: String,
101    memory_min_bytes: u64,
102    #[serde(default)]
103    proven_safe: Vec<SafeSite>,
104}
105
106// =============================================================================
107// Ingestion
108// =============================================================================
109
110/// The result of ingesting a `safe-accesses.json`. TOTAL by design — there is
111/// deliberately no `Result` in [`ingest`]'s return type, mirroring
112/// [`crate::wsc_facts::parse_wsc_facts`]: no input may turn a successful
113/// compile into a failed one.
114///
115/// When [`ProvenSafeIngest::accepted`] is false, [`ProvenSafeIngest::offered`]
116/// is EMPTY — a refused document is not trusted piecemeal — and
117/// [`ProvenSafeIngest::refusal`] names why.
118#[derive(Debug, Clone, PartialEq, Eq, Default)]
119pub struct ProvenSafeIngest {
120    /// Did the document clear every whole-file gate (schema, hash, memory
121    /// floor)? False ⇒ elide nothing.
122    pub accepted: bool,
123    /// `Some(reason)` exactly when `accepted` is false.
124    pub refusal: Option<String>,
125    /// The producer version string, for the attestation. Empty if absent or
126    /// the document was unreadable.
127    pub scry_version: String,
128    /// The `module_sha256` the document declared (hex, as written). Empty when
129    /// the document did not parse.
130    pub declared_module_sha256: String,
131    /// The sha256 synth computed over the module it is actually compiling.
132    pub actual_module_sha256: String,
133    /// The `memory_min_bytes` the document declared.
134    pub declared_memory_min_bytes: u64,
135    /// Sites the document offered, once the whole-file gates passed. These are
136    /// candidates: each still faces [`ProvenSafeIngest::validate_function`].
137    pub offered: Vec<SafeSite>,
138    /// Human-facing diagnostics (warnings). Never an error.
139    pub diagnostics: Vec<String>,
140}
141
142impl ProvenSafeIngest {
143    /// A refusal the CALLER establishes, for a gate that cannot be checked
144    /// inside this module. #932: the memory floor is derived by the CLI from
145    /// the decoded module, so "no floor could be established" is refusable only
146    /// out there — and it must refuse, not fall back to a `0` floor, because
147    /// every verdict validated against a zero-byte floor is vacuous.
148    pub fn refused(reason: impl Into<String>) -> Self {
149        Self::refuse(reason)
150    }
151
152    /// A refusal: nothing trusted, one named reason.
153    fn refuse(reason: impl Into<String>) -> Self {
154        let reason = reason.into();
155        Self {
156            accepted: false,
157            diagnostics: vec![reason.clone()],
158            refusal: Some(reason),
159            ..Self::default()
160        }
161    }
162
163    /// Sites this document offered for one function, in `pc` order.
164    pub fn offered_for_func(&self, func: u32) -> Vec<&SafeSite> {
165        let mut v: Vec<&SafeSite> = self.offered.iter().filter(|s| s.func == func).collect();
166        v.sort_by_key(|s| s.pc);
167        v
168    }
169
170    /// **The self-checking key.** Validate this function's offered sites
171    /// against the operator stream synth actually decoded, and return the
172    /// operator indices that survive — the per-site elision marks.
173    ///
174    /// An entry is DROPPED (with a diagnostic pushed into `notes`) when:
175    /// - `pc` is out of range for this function's op count;
176    /// - the op at `pc` is not a linear-memory access;
177    /// - the op's access width differs from the declared `width`.
178    ///
179    /// Dropping rather than refusing the whole file is deliberate and matches
180    /// the `wsc.facts` per-record rule: a newer scry proving an access class
181    /// synth does not guard must not invalidate the sites it does guard. The
182    /// safety direction is preserved either way — a dropped entry keeps its
183    /// guard.
184    pub fn validate_function(
185        &self,
186        func: u32,
187        ops: &[WasmOp],
188        notes: &mut Vec<String>,
189    ) -> Vec<usize> {
190        let mut marks = Vec::new();
191        for site in self.offered_for_func(func) {
192            let idx = site.pc as usize;
193            let Some(op) = ops.get(idx) else {
194                notes.push(format!(
195                    "func {func} pc {} — out of range (function has {} operators); \
196                     entry DROPPED, guard retained. If the producer emitted wasm BYTE \
197                     OFFSETS, the key space is wrong: `pc` is the 0-based OPERATOR index",
198                    site.pc,
199                    ops.len()
200                ));
201                continue;
202            };
203            let Some(actual_width) = access_width(op) else {
204                notes.push(format!(
205                    "func {func} pc {} — the operator there is {op:?}, not a linear-memory \
206                     access; entry DROPPED, guard retained (claimed op '{}')",
207                    site.pc, site.op
208                ));
209                continue;
210            };
211            if actual_width != site.width {
212                notes.push(format!(
213                    "func {func} pc {} — declared width {} B disagrees with the decoded \
214                     operator {op:?} ({actual_width} B); entry DROPPED, guard retained",
215                    site.pc, site.width
216                ));
217                continue;
218            }
219            marks.push(idx);
220        }
221        marks.sort_unstable();
222        marks.dedup();
223        marks
224    }
225}
226
227/// Access width in bytes of a linear-memory operator, or `None` when the
228/// operator is not a linear-memory access.
229///
230/// This is the width the BOUNDS CHECK covers (the number of bytes touched),
231/// not the value width: `i32.load8_u` touches 1 byte, `i64.load32_u` touches 4.
232pub fn access_width(op: &WasmOp) -> Option<u32> {
233    Some(match op {
234        WasmOp::I32Load8S { .. }
235        | WasmOp::I32Load8U { .. }
236        | WasmOp::I32Store8 { .. }
237        | WasmOp::I64Load8S { .. }
238        | WasmOp::I64Load8U { .. }
239        | WasmOp::I64Store8 { .. } => 1,
240        WasmOp::I32Load16S { .. }
241        | WasmOp::I32Load16U { .. }
242        | WasmOp::I32Store16 { .. }
243        | WasmOp::I64Load16S { .. }
244        | WasmOp::I64Load16U { .. }
245        | WasmOp::I64Store16 { .. } => 2,
246        WasmOp::I32Load { .. }
247        | WasmOp::I32Store { .. }
248        | WasmOp::I64Load32S { .. }
249        | WasmOp::I64Load32U { .. }
250        | WasmOp::I64Store32 { .. }
251        | WasmOp::F32Load { .. }
252        | WasmOp::F32Store { .. } => 4,
253        WasmOp::I64Load { .. }
254        | WasmOp::I64Store { .. }
255        | WasmOp::F64Load { .. }
256        | WasmOp::F64Store { .. } => 8,
257        WasmOp::V128Load { .. } | WasmOp::V128Store { .. } => 16,
258        _ => return None,
259    })
260}
261
262/// Ingest a `safe-accesses.json`, binding it to `module_bytes` (the EXACT
263/// bytes synth is compiling) and to `memory_min_bytes` (synth's own declared
264/// linear-memory minimum). Total: every input yields a verdict, never an error.
265///
266/// `module_bytes` must be the post-`.wat`-parse, post-loom, post-arena-bind
267/// buffer handed to the decoder — see the module docs for why that choice
268/// makes index skew and byte skew one gate.
269pub fn ingest(path: &Path, module_bytes: &[u8], memory_min_bytes: u32) -> ProvenSafeIngest {
270    let actual = hex_sha256(module_bytes);
271
272    let text = match std::fs::read_to_string(path) {
273        Ok(t) => t,
274        Err(e) => {
275            return ProvenSafeIngest {
276                actual_module_sha256: actual,
277                ..ProvenSafeIngest::refuse(format!(
278                    "--proven-safe {}: cannot read the file ({e}); NO bounds guard is \
279                     elided (fail closed) and the compile continues unchanged",
280                    path.display()
281                ))
282            };
283        }
284    };
285
286    let doc: RawDocument = match serde_json::from_str(&text) {
287        Ok(d) => d,
288        Err(e) => {
289            return ProvenSafeIngest {
290                actual_module_sha256: actual,
291                ..ProvenSafeIngest::refuse(format!(
292                    "--proven-safe {}: not a well-formed `{SAFE_ACCESSES_SCHEMA}` document \
293                     ({e}); NO bounds guard is elided (fail closed) and the compile \
294                     continues unchanged",
295                    path.display()
296                ))
297            };
298        }
299    };
300
301    if doc.schema != SAFE_ACCESSES_SCHEMA {
302        return ProvenSafeIngest {
303            actual_module_sha256: actual,
304            scry_version: doc.scry_version.clone(),
305            declared_module_sha256: doc.module_sha256.clone(),
306            declared_memory_min_bytes: doc.memory_min_bytes,
307            ..ProvenSafeIngest::refuse(format!(
308                "--proven-safe {}: schema is '{}', expected '{SAFE_ACCESSES_SCHEMA}'; \
309                 NO bounds guard is elided (fail closed)",
310                path.display(),
311                doc.schema
312            ))
313        };
314    }
315
316    // ---- GATE 1: the verdicts must be bound to THIS module. ----
317    // A stale analysis is a memory-safety hole, not a stale optimization.
318    if !doc.module_sha256.eq_ignore_ascii_case(&actual) {
319        return ProvenSafeIngest {
320            actual_module_sha256: actual.clone(),
321            scry_version: doc.scry_version.clone(),
322            declared_module_sha256: doc.module_sha256.clone(),
323            declared_memory_min_bytes: doc.memory_min_bytes,
324            ..ProvenSafeIngest::refuse(format!(
325                "--proven-safe {}: REFUSED — module_sha256 mismatch. The file was produced \
326                 for {}, but this compile's module hashes to {actual}. Eliding a bounds \
327                 check on a stale analysis is a memory-safety hole, not a stale \
328                 optimization, so NO guard is elided. (The hash covers the bytes handed to \
329                 the decoder — after .wat parsing, after loom, and after the #418 \
330                 arena-bind rewrite — so a module rewrite that shifts operator indices \
331                 lands here too.)",
332                path.display(),
333                if doc.module_sha256.is_empty() {
334                    "<empty>"
335                } else {
336                    &doc.module_sha256
337                }
338            ))
339        };
340    }
341
342    // ---- GATE 2: the verdicts must be proven against THIS memory floor. ----
343    // A matching hash implies these agree; a mismatch means the producer is
344    // broken, and a broken prover must not be trusted.
345    if doc.memory_min_bytes != u64::from(memory_min_bytes) {
346        return ProvenSafeIngest {
347            actual_module_sha256: actual,
348            scry_version: doc.scry_version.clone(),
349            declared_module_sha256: doc.module_sha256.clone(),
350            declared_memory_min_bytes: doc.memory_min_bytes,
351            ..ProvenSafeIngest::refuse(format!(
352                "--proven-safe {}: REFUSED — memory_min_bytes disagreement. The verdicts \
353                 were proven against a {} B floor; synth's declared linear-memory minimum \
354                 for this module is {memory_min_bytes} B. The module_sha256 MATCHED, so \
355                 these should be equal — a disagreement means the producer is broken, and \
356                 verdicts from a broken prover are not trusted. NO guard is elided.",
357                path.display(),
358                doc.memory_min_bytes
359            ))
360        };
361    }
362
363    let mut diagnostics = Vec::new();
364    if doc.proven_safe.is_empty() {
365        diagnostics.push(format!(
366            "--proven-safe {}: accepted, but the document proves ZERO access sites — \
367             nothing to elide (every guard is retained)",
368            path.display()
369        ));
370    }
371
372    ProvenSafeIngest {
373        accepted: true,
374        refusal: None,
375        scry_version: doc.scry_version,
376        declared_module_sha256: doc.module_sha256,
377        actual_module_sha256: actual,
378        declared_memory_min_bytes: doc.memory_min_bytes,
379        offered: doc.proven_safe,
380        diagnostics,
381    }
382}
383
384/// Lowercase hex sha256 — the `module_sha256` convention.
385pub fn hex_sha256(bytes: &[u8]) -> String {
386    let digest = Sha256::digest(bytes);
387    let mut s = String::with_capacity(64);
388    for b in digest {
389        s.push_str(&format!("{b:02x}"));
390    }
391    s
392}
393
394// =============================================================================
395// Attestation (loop step 6 — what sigil reads)
396// =============================================================================
397
398/// One elided site, as attested.
399#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
400pub struct AttestedElision {
401    pub func: u32,
402    /// Operator index within the function body.
403    pub pc: u32,
404    pub op: String,
405    pub width: u32,
406    /// Who authorized this elision. `"scry/safe-accesses/v1"` today; the
407    /// field exists so a future site elided on a DIFFERENT authority (e.g.
408    /// #494's per-site ordeal certificate) is distinguishable in one file.
409    pub authority: String,
410}
411
412/// The `synth-proven-safe-elisions-v1` sidecar.
413///
414/// **Emitted on refusal too**, carrying `accepted: false` plus the reason and
415/// the offered-but-not-elided count. A sidecar that only appears on success is
416/// a brag sheet, not an attestation: sigil must be able to tell "nothing to
417/// elide" from "file rejected".
418#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
419pub struct ElisionAttestation {
420    pub schema: String,
421    /// `CARGO_PKG_VERSION` of the synth that produced the ELF.
422    pub synth_version: String,
423    /// The producer's version string, as declared.
424    pub scry_version: String,
425    /// The sha256 synth computed over the module it compiled. This is the
426    /// authoritative binding — `declared_module_sha256` is what the file said.
427    pub module_sha256: String,
428    pub declared_module_sha256: String,
429    /// Synth's declared linear-memory minimum, in bytes. RQ-57-SENTINEL:
430    /// `None` = NO floor could be established (e.g. the module's memory is
431    /// imported, #932) — serialized as an explicit `null`, never as a `0` that
432    /// collides with a real (and self-refuting) zero-byte floor. The #932 fix
433    /// commented the CLI's `unwrap_or(0)` "unreachable by construction"; the
434    /// RQ-57 sweep proved that FALSE — refusal attestations reach it — so the
435    /// absence is now typed instead of argued.
436    pub memory_min_bytes: Option<u32>,
437    pub declared_memory_min_bytes: u64,
438    /// The `--safety-bounds` mode in force. Elisions only change emitted bytes
439    /// under `software`; any other mode is recorded so an auditor sees why the
440    /// elision count is zero.
441    pub safety_bounds: String,
442    /// Did the document clear the whole-file gates?
443    pub accepted: bool,
444    /// Why not, when `accepted` is false.
445    #[serde(skip_serializing_if = "Option::is_none")]
446    pub refusal: Option<String>,
447    /// How many sites the document offered.
448    pub sites_offered: usize,
449    /// How many survived key validation AND were actually stripped.
450    pub sites_elided: usize,
451    /// Offered sites that were dropped — bad key, wrong width, a specialized
452    /// function, or a non-`software` bounds mode. `offered - elided`.
453    pub sites_not_elided: usize,
454    /// The elision set proper.
455    pub elisions: Vec<AttestedElision>,
456    /// Every warning the ingestion and validation emitted, verbatim.
457    pub diagnostics: Vec<String>,
458}
459
460impl ElisionAttestation {
461    /// Serialize to pretty JSON.
462    pub fn to_json(&self) -> String {
463        serde_json::to_string_pretty(self).expect("ElisionAttestation serializes")
464    }
465
466    /// `foo.elf` → `foo.proven-safe-elisions.json`, mirroring
467    /// [`crate::SafetyManifest::sidecar_path`]'s shape.
468    pub fn sidecar_path(elf_path: &Path) -> PathBuf {
469        let mut p = elf_path.to_path_buf();
470        let stem = elf_path
471            .file_stem()
472            .map(|s| s.to_string_lossy().to_string())
473            .unwrap_or_else(|| "out".to_string());
474        p.set_file_name(format!("{stem}.proven-safe-elisions.json"));
475        p
476    }
477}
478
479#[cfg(test)]
480mod tests {
481    use super::*;
482    use std::io::Write;
483
484    fn write_tmp(name: &str, body: &str) -> PathBuf {
485        let dir = std::env::temp_dir().join("proven_safe_901_unit");
486        std::fs::create_dir_all(&dir).expect("mk tempdir");
487        let p = dir.join(name);
488        let mut f = std::fs::File::create(&p).expect("create");
489        f.write_all(body.as_bytes()).expect("write");
490        p
491    }
492
493    const MODULE: &[u8] = b"\0asm\x01\0\0\0 pretend this is a module";
494
495    fn doc(hash: &str, min: u64, sites: &str) -> String {
496        format!(
497            r#"{{ "schema": "scry/safe-accesses/v1", "scry_version": "3.2.4",
498                  "module_sha256": "{hash}", "memory_min_bytes": {min},
499                  "premises": {{ "bounded_memory": true }},
500                  "counts": {{ "access_sites": 9, "proven_safe": 1 }},
501                  "proven_safe": [{sites}] }}"#
502        )
503    }
504
505    // ---- PROPERTY 1: fail closed on module_sha256 mismatch ------------------
506
507    #[test]
508    fn hash_mismatch_refuses_everything_901() {
509        let stale = "0".repeat(64);
510        let p = write_tmp(
511            "stale.json",
512            &doc(
513                &stale,
514                65536,
515                r#"{"func":0,"pc":1,"op":"i32.load","width":4}"#,
516            ),
517        );
518        let r = ingest(&p, MODULE, 65536);
519        assert!(!r.accepted, "a stale analysis must never be accepted");
520        assert!(
521            r.offered.is_empty(),
522            "a refused document is not trusted piecemeal"
523        );
524        let why = r.refusal.expect("a refusal names its reason");
525        assert!(why.contains("module_sha256 mismatch"), "{why}");
526        // Both values are named so the operator can tell WHICH module it was for.
527        assert!(why.contains(&stale), "{why}");
528        assert!(why.contains(&hex_sha256(MODULE)), "{why}");
529    }
530
531    #[test]
532    fn matching_hash_is_accepted_and_case_insensitive_901() {
533        let h = hex_sha256(MODULE).to_uppercase();
534        let p = write_tmp(
535            "good.json",
536            &doc(&h, 65536, r#"{"func":0,"pc":1,"op":"i32.load","width":4}"#),
537        );
538        let r = ingest(&p, MODULE, 65536);
539        assert!(r.accepted, "{:?}", r.refusal);
540        assert_eq!(r.offered.len(), 1);
541        assert_eq!(r.scry_version, "3.2.4");
542    }
543
544    /// Any change to the module — a single flipped byte, which is what a
545    /// pre-compile rewrite looks like — refuses.
546    #[test]
547    fn one_flipped_module_byte_refuses_901() {
548        let p = write_tmp(
549            "flip.json",
550            &doc(
551                &hex_sha256(MODULE),
552                65536,
553                r#"{"func":0,"pc":1,"op":"i32.load","width":4}"#,
554            ),
555        );
556        let mut rewritten = MODULE.to_vec();
557        rewritten.push(0x00);
558        let r = ingest(&p, &rewritten, 65536);
559        assert!(!r.accepted);
560        assert!(r.refusal.unwrap().contains("module_sha256 mismatch"));
561    }
562
563    // ---- PROPERTY: fail closed on the memory floor --------------------------
564
565    #[test]
566    fn memory_min_bytes_disagreement_refuses_901() {
567        let p = write_tmp(
568            "floor.json",
569            &doc(
570                &hex_sha256(MODULE),
571                131072,
572                r#"{"func":0,"pc":1,"op":"i32.load","width":4}"#,
573            ),
574        );
575        let r = ingest(&p, MODULE, 65536);
576        assert!(!r.accepted);
577        let why = r.refusal.unwrap();
578        assert!(why.contains("memory_min_bytes disagreement"), "{why}");
579        assert!(why.contains("131072") && why.contains("65536"), "{why}");
580    }
581
582    // ---- PROPERTY: malformed ⇒ no elisions, a diagnostic, never an error ----
583
584    #[test]
585    fn malformed_missing_and_wrong_schema_all_refuse_without_erroring_901() {
586        let h = hex_sha256(MODULE);
587        let cases = vec![
588            ("missing", None),
589            ("garbage.json", Some("this is not json {{{".to_string())),
590            ("empty.json", Some(String::new())),
591            (
592                "wrongschema.json",
593                Some(doc(&h, 65536, "").replace(SAFE_ACCESSES_SCHEMA, "scry/safe-accesses/v2")),
594            ),
595            (
596                "nohash.json",
597                Some(r#"{"schema":"scry/safe-accesses/v1","memory_min_bytes":65536}"#.to_string()),
598            ),
599            (
600                "sitegarbage.json",
601                Some(doc(&h, 65536, r#"{"func":"four","pc":1,"width":4}"#)),
602            ),
603        ];
604        for (name, body) in cases {
605            let p = match body {
606                Some(b) => write_tmp(name, &b),
607                None => std::env::temp_dir().join("proven_safe_901_unit/definitely-absent.json"),
608            };
609            let r = ingest(&p, MODULE, 65536);
610            assert!(!r.accepted, "'{name}' must not be accepted");
611            assert!(r.offered.is_empty(), "'{name}' offered sites");
612            assert!(r.refusal.is_some(), "'{name}' refused without a reason");
613            assert!(!r.diagnostics.is_empty(), "'{name}' refused silently");
614        }
615    }
616
617    #[test]
618    fn unknown_fields_are_tolerated_901() {
619        // A newer scry adding fields must not break an older synth.
620        let p = write_tmp(
621            "future.json",
622            &format!(
623                r#"{{ "schema": "scry/safe-accesses/v1", "scry_version": "9.9.9",
624                      "module_sha256": "{}", "memory_min_bytes": 65536,
625                      "brand_new_field": {{ "nested": [1,2,3] }},
626                      "proven_safe": [{{"func":0,"pc":1,"op":"i32.load","width":4,
627                                        "confidence":"high"}}] }}"#,
628                hex_sha256(MODULE)
629            ),
630        );
631        let r = ingest(&p, MODULE, 65536);
632        assert!(r.accepted, "{:?}", r.refusal);
633        assert_eq!(r.offered.len(), 1);
634    }
635
636    #[test]
637    fn accepted_but_empty_is_diagnosed_901() {
638        let p = write_tmp("none.json", &doc(&hex_sha256(MODULE), 65536, ""));
639        let r = ingest(&p, MODULE, 65536);
640        assert!(r.accepted);
641        assert!(r.offered.is_empty());
642        assert!(
643            r.diagnostics
644                .iter()
645                .any(|d| d.contains("ZERO access sites")),
646            "an accepted-but-vacuous document must say so: {:?}",
647            r.diagnostics
648        );
649    }
650
651    // ---- PROPERTY 4: the key is self-checking -------------------------------
652
653    fn ops() -> Vec<WasmOp> {
654        vec![
655            WasmOp::LocalGet(0), // 0
656            WasmOp::I32Load {
657                offset: 0,
658                align: 2,
659            }, // 1  — 4 B
660            WasmOp::LocalGet(0), // 2
661            WasmOp::I32Load8U {
662                offset: 1,
663                align: 0,
664            }, // 3  — 1 B
665            WasmOp::I32Add,      // 4
666            WasmOp::I64Store {
667                offset: 8,
668                align: 3,
669            }, // 5  — 8 B
670        ]
671    }
672
673    #[test]
674    fn valid_sites_become_marks_901() {
675        let p = write_tmp(
676            "marks.json",
677            &doc(
678                &hex_sha256(MODULE),
679                65536,
680                r#"{"func":0,"pc":5,"op":"i64.store","width":8},
681                   {"func":0,"pc":1,"op":"i32.load","width":4},
682                   {"func":0,"pc":3,"op":"i32.load8_u","width":1}"#,
683            ),
684        );
685        let r = ingest(&p, MODULE, 65536);
686        let mut notes = Vec::new();
687        assert_eq!(r.validate_function(0, &ops(), &mut notes), vec![1, 3, 5]);
688        assert!(notes.is_empty(), "{notes:?}");
689    }
690
691    /// If the producer ever emits wasm BYTE OFFSETS instead of operator
692    /// indices, the entries fall out of range or land on non-access operators —
693    /// so the build elides nothing LOUDLY instead of stripping the wrong guard.
694    #[test]
695    fn byte_offsets_instead_of_op_indices_elide_nothing_loudly_901() {
696        let p = write_tmp(
697            "byteoffsets.json",
698            &doc(
699                &hex_sha256(MODULE),
700                65536,
701                r#"{"func":0,"pc":41,"op":"i32.load","width":4},
702                   {"func":0,"pc":137,"op":"i32.load8_u","width":1}"#,
703            ),
704        );
705        let r = ingest(&p, MODULE, 65536);
706        assert!(
707            r.accepted,
708            "the FILE is well formed — only the keys are wrong"
709        );
710        let mut notes = Vec::new();
711        assert_eq!(
712            r.validate_function(0, &ops(), &mut notes),
713            Vec::<usize>::new()
714        );
715        assert_eq!(notes.len(), 2);
716        assert!(
717            notes.iter().all(|n| n.contains("out of range")),
718            "{notes:?}"
719        );
720        assert!(notes[0].contains("OPERATOR index"), "{notes:?}");
721    }
722
723    #[test]
724    fn non_access_operator_is_dropped_901() {
725        let p = write_tmp(
726            "nonaccess.json",
727            &doc(
728                &hex_sha256(MODULE),
729                65536,
730                r#"{"func":0,"pc":4,"op":"i32.load","width":4}"#,
731            ),
732        );
733        let mut notes = Vec::new();
734        let marks = ingest(&p, MODULE, 65536).validate_function(0, &ops(), &mut notes);
735        assert_eq!(marks, Vec::<usize>::new());
736        assert!(notes[0].contains("not a linear-memory access"), "{notes:?}");
737    }
738
739    #[test]
740    fn width_disagreement_is_dropped_901() {
741        // pc 3 is an i32.load8_u (1 B) but the file claims 4 B: the file and
742        // the module disagree about which BYTES are covered — drop it.
743        let p = write_tmp(
744            "width.json",
745            &doc(
746                &hex_sha256(MODULE),
747                65536,
748                r#"{"func":0,"pc":3,"op":"i32.load","width":4},
749                   {"func":0,"pc":1,"op":"i32.load","width":4}"#,
750            ),
751        );
752        let mut notes = Vec::new();
753        let marks = ingest(&p, MODULE, 65536).validate_function(0, &ops(), &mut notes);
754        assert_eq!(
755            marks,
756            vec![1],
757            "the sound entry survives, the skewed one does not"
758        );
759        assert_eq!(notes.len(), 1);
760        assert!(
761            notes[0].contains("disagrees with the decoded operator"),
762            "{notes:?}"
763        );
764    }
765
766    #[test]
767    fn sites_are_keyed_per_function_901() {
768        let p = write_tmp(
769            "perfunc.json",
770            &doc(
771                &hex_sha256(MODULE),
772                65536,
773                r#"{"func":7,"pc":1,"op":"i32.load","width":4}"#,
774            ),
775        );
776        let r = ingest(&p, MODULE, 65536);
777        let mut notes = Vec::new();
778        // Function 0 gets nothing: the verdict is func 7's.
779        assert_eq!(
780            r.validate_function(0, &ops(), &mut notes),
781            Vec::<usize>::new()
782        );
783        assert!(notes.is_empty());
784        assert_eq!(r.validate_function(7, &ops(), &mut notes), vec![1]);
785    }
786
787    // ---- access_width ------------------------------------------------------
788
789    #[test]
790    fn access_width_covers_the_bytes_touched_not_the_value_width_901() {
791        assert_eq!(
792            access_width(&WasmOp::I64Load32U {
793                offset: 0,
794                align: 2
795            }),
796            Some(4)
797        );
798        assert_eq!(
799            access_width(&WasmOp::I64Store8 {
800                offset: 0,
801                align: 0
802            }),
803            Some(1)
804        );
805        assert_eq!(
806            access_width(&WasmOp::I32Load16S {
807                offset: 0,
808                align: 1
809            }),
810            Some(2)
811        );
812        assert_eq!(
813            access_width(&WasmOp::F64Load {
814                offset: 0,
815                align: 3
816            }),
817            Some(8)
818        );
819        assert_eq!(access_width(&WasmOp::I32Add), None);
820        assert_eq!(access_width(&WasmOp::LocalGet(0)), None);
821    }
822
823    // ---- attestation -------------------------------------------------------
824
825    #[test]
826    fn attestation_sidecar_path_mirrors_the_safety_manifest_901() {
827        assert_eq!(
828            ElisionAttestation::sidecar_path(Path::new("/tmp/foo.elf")),
829            PathBuf::from("/tmp/foo.proven-safe-elisions.json")
830        );
831        assert_eq!(
832            ElisionAttestation::sidecar_path(Path::new("out")),
833            PathBuf::from("out.proven-safe-elisions.json")
834        );
835    }
836
837    #[test]
838    fn refusal_is_attested_not_hidden_901() {
839        let a = ElisionAttestation {
840            schema: ELISION_ATTESTATION_SCHEMA.to_string(),
841            synth_version: "0.55.0".to_string(),
842            scry_version: "3.2.4".to_string(),
843            module_sha256: "aa".repeat(32),
844            declared_module_sha256: "bb".repeat(32),
845            memory_min_bytes: Some(65536),
846            declared_memory_min_bytes: 65536,
847            safety_bounds: "software".to_string(),
848            accepted: false,
849            refusal: Some("module_sha256 mismatch".to_string()),
850            sites_offered: 8,
851            sites_elided: 0,
852            sites_not_elided: 8,
853            elisions: Vec::new(),
854            diagnostics: vec!["refused".to_string()],
855        };
856        let json = a.to_json();
857        // sigil must be able to tell "nothing to elide" from "file rejected".
858        assert!(json.contains("\"accepted\": false"));
859        assert!(json.contains("module_sha256 mismatch"));
860        assert!(json.contains("\"sites_offered\": 8"));
861        assert!(json.contains("\"sites_elided\": 0"));
862        let back: ElisionAttestation = serde_json::from_str(&json).expect("round-trips");
863        assert_eq!(back, a);
864    }
865
866    /// RQ-57-SENTINEL: an attestation with NO establishable floor (imported
867    /// memory, #932) must serialize the absence as an explicit `null` — never
868    /// as a `0` that reads as "synth declared a zero-byte floor". The #932 fix
869    /// left the CLI's `unwrap_or(0)` behind a comment calling it unreachable;
870    /// the sweep proved refusal attestations DO reach it, so v0.56.x shipped
871    /// `"memory_min_bytes": 0` on exactly the #932 shape.
872    #[test]
873    fn no_floor_attests_null_not_zero_rq57() {
874        let a = ElisionAttestation {
875            schema: ELISION_ATTESTATION_SCHEMA.to_string(),
876            synth_version: "0.56.1".to_string(),
877            scry_version: "3.2.4".to_string(),
878            module_sha256: "aa".repeat(32),
879            declared_module_sha256: "aa".repeat(32),
880            memory_min_bytes: None,
881            declared_memory_min_bytes: 65536,
882            safety_bounds: "software".to_string(),
883            accepted: false,
884            refusal: Some("no memory floor can be established".to_string()),
885            sites_offered: 1,
886            sites_elided: 0,
887            sites_not_elided: 1,
888            elisions: Vec::new(),
889            diagnostics: Vec::new(),
890        };
891        let json = a.to_json();
892        assert!(
893            json.contains("\"memory_min_bytes\": null"),
894            "absence must be an explicit null, got:\n{json}"
895        );
896        assert!(
897            !json.contains("\"memory_min_bytes\": 0"),
898            "the invented-0 floor must be unrepresentable, got:\n{json}"
899        );
900        let back: ElisionAttestation = serde_json::from_str(&json).expect("round-trips");
901        assert_eq!(back, a);
902    }
903}