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: nothing trusted, one named reason.
144    fn refuse(reason: impl Into<String>) -> Self {
145        let reason = reason.into();
146        Self {
147            accepted: false,
148            diagnostics: vec![reason.clone()],
149            refusal: Some(reason),
150            ..Self::default()
151        }
152    }
153
154    /// Sites this document offered for one function, in `pc` order.
155    pub fn offered_for_func(&self, func: u32) -> Vec<&SafeSite> {
156        let mut v: Vec<&SafeSite> = self.offered.iter().filter(|s| s.func == func).collect();
157        v.sort_by_key(|s| s.pc);
158        v
159    }
160
161    /// **The self-checking key.** Validate this function's offered sites
162    /// against the operator stream synth actually decoded, and return the
163    /// operator indices that survive — the per-site elision marks.
164    ///
165    /// An entry is DROPPED (with a diagnostic pushed into `notes`) when:
166    /// - `pc` is out of range for this function's op count;
167    /// - the op at `pc` is not a linear-memory access;
168    /// - the op's access width differs from the declared `width`.
169    ///
170    /// Dropping rather than refusing the whole file is deliberate and matches
171    /// the `wsc.facts` per-record rule: a newer scry proving an access class
172    /// synth does not guard must not invalidate the sites it does guard. The
173    /// safety direction is preserved either way — a dropped entry keeps its
174    /// guard.
175    pub fn validate_function(
176        &self,
177        func: u32,
178        ops: &[WasmOp],
179        notes: &mut Vec<String>,
180    ) -> Vec<usize> {
181        let mut marks = Vec::new();
182        for site in self.offered_for_func(func) {
183            let idx = site.pc as usize;
184            let Some(op) = ops.get(idx) else {
185                notes.push(format!(
186                    "func {func} pc {} — out of range (function has {} operators); \
187                     entry DROPPED, guard retained. If the producer emitted wasm BYTE \
188                     OFFSETS, the key space is wrong: `pc` is the 0-based OPERATOR index",
189                    site.pc,
190                    ops.len()
191                ));
192                continue;
193            };
194            let Some(actual_width) = access_width(op) else {
195                notes.push(format!(
196                    "func {func} pc {} — the operator there is {op:?}, not a linear-memory \
197                     access; entry DROPPED, guard retained (claimed op '{}')",
198                    site.pc, site.op
199                ));
200                continue;
201            };
202            if actual_width != site.width {
203                notes.push(format!(
204                    "func {func} pc {} — declared width {} B disagrees with the decoded \
205                     operator {op:?} ({actual_width} B); entry DROPPED, guard retained",
206                    site.pc, site.width
207                ));
208                continue;
209            }
210            marks.push(idx);
211        }
212        marks.sort_unstable();
213        marks.dedup();
214        marks
215    }
216}
217
218/// Access width in bytes of a linear-memory operator, or `None` when the
219/// operator is not a linear-memory access.
220///
221/// This is the width the BOUNDS CHECK covers (the number of bytes touched),
222/// not the value width: `i32.load8_u` touches 1 byte, `i64.load32_u` touches 4.
223pub fn access_width(op: &WasmOp) -> Option<u32> {
224    Some(match op {
225        WasmOp::I32Load8S { .. }
226        | WasmOp::I32Load8U { .. }
227        | WasmOp::I32Store8 { .. }
228        | WasmOp::I64Load8S { .. }
229        | WasmOp::I64Load8U { .. }
230        | WasmOp::I64Store8 { .. } => 1,
231        WasmOp::I32Load16S { .. }
232        | WasmOp::I32Load16U { .. }
233        | WasmOp::I32Store16 { .. }
234        | WasmOp::I64Load16S { .. }
235        | WasmOp::I64Load16U { .. }
236        | WasmOp::I64Store16 { .. } => 2,
237        WasmOp::I32Load { .. }
238        | WasmOp::I32Store { .. }
239        | WasmOp::I64Load32S { .. }
240        | WasmOp::I64Load32U { .. }
241        | WasmOp::I64Store32 { .. }
242        | WasmOp::F32Load { .. }
243        | WasmOp::F32Store { .. } => 4,
244        WasmOp::I64Load { .. }
245        | WasmOp::I64Store { .. }
246        | WasmOp::F64Load { .. }
247        | WasmOp::F64Store { .. } => 8,
248        WasmOp::V128Load { .. } | WasmOp::V128Store { .. } => 16,
249        _ => return None,
250    })
251}
252
253/// Ingest a `safe-accesses.json`, binding it to `module_bytes` (the EXACT
254/// bytes synth is compiling) and to `memory_min_bytes` (synth's own declared
255/// linear-memory minimum). Total: every input yields a verdict, never an error.
256///
257/// `module_bytes` must be the post-`.wat`-parse, post-loom, post-arena-bind
258/// buffer handed to the decoder — see the module docs for why that choice
259/// makes index skew and byte skew one gate.
260pub fn ingest(path: &Path, module_bytes: &[u8], memory_min_bytes: u32) -> ProvenSafeIngest {
261    let actual = hex_sha256(module_bytes);
262
263    let text = match std::fs::read_to_string(path) {
264        Ok(t) => t,
265        Err(e) => {
266            return ProvenSafeIngest {
267                actual_module_sha256: actual,
268                ..ProvenSafeIngest::refuse(format!(
269                    "--proven-safe {}: cannot read the file ({e}); NO bounds guard is \
270                     elided (fail closed) and the compile continues unchanged",
271                    path.display()
272                ))
273            };
274        }
275    };
276
277    let doc: RawDocument = match serde_json::from_str(&text) {
278        Ok(d) => d,
279        Err(e) => {
280            return ProvenSafeIngest {
281                actual_module_sha256: actual,
282                ..ProvenSafeIngest::refuse(format!(
283                    "--proven-safe {}: not a well-formed `{SAFE_ACCESSES_SCHEMA}` document \
284                     ({e}); NO bounds guard is elided (fail closed) and the compile \
285                     continues unchanged",
286                    path.display()
287                ))
288            };
289        }
290    };
291
292    if doc.schema != SAFE_ACCESSES_SCHEMA {
293        return ProvenSafeIngest {
294            actual_module_sha256: actual,
295            scry_version: doc.scry_version.clone(),
296            declared_module_sha256: doc.module_sha256.clone(),
297            declared_memory_min_bytes: doc.memory_min_bytes,
298            ..ProvenSafeIngest::refuse(format!(
299                "--proven-safe {}: schema is '{}', expected '{SAFE_ACCESSES_SCHEMA}'; \
300                 NO bounds guard is elided (fail closed)",
301                path.display(),
302                doc.schema
303            ))
304        };
305    }
306
307    // ---- GATE 1: the verdicts must be bound to THIS module. ----
308    // A stale analysis is a memory-safety hole, not a stale optimization.
309    if !doc.module_sha256.eq_ignore_ascii_case(&actual) {
310        return ProvenSafeIngest {
311            actual_module_sha256: actual.clone(),
312            scry_version: doc.scry_version.clone(),
313            declared_module_sha256: doc.module_sha256.clone(),
314            declared_memory_min_bytes: doc.memory_min_bytes,
315            ..ProvenSafeIngest::refuse(format!(
316                "--proven-safe {}: REFUSED — module_sha256 mismatch. The file was produced \
317                 for {}, but this compile's module hashes to {actual}. Eliding a bounds \
318                 check on a stale analysis is a memory-safety hole, not a stale \
319                 optimization, so NO guard is elided. (The hash covers the bytes handed to \
320                 the decoder — after .wat parsing, after loom, and after the #418 \
321                 arena-bind rewrite — so a module rewrite that shifts operator indices \
322                 lands here too.)",
323                path.display(),
324                if doc.module_sha256.is_empty() {
325                    "<empty>"
326                } else {
327                    &doc.module_sha256
328                }
329            ))
330        };
331    }
332
333    // ---- GATE 2: the verdicts must be proven against THIS memory floor. ----
334    // A matching hash implies these agree; a mismatch means the producer is
335    // broken, and a broken prover must not be trusted.
336    if doc.memory_min_bytes != u64::from(memory_min_bytes) {
337        return ProvenSafeIngest {
338            actual_module_sha256: actual,
339            scry_version: doc.scry_version.clone(),
340            declared_module_sha256: doc.module_sha256.clone(),
341            declared_memory_min_bytes: doc.memory_min_bytes,
342            ..ProvenSafeIngest::refuse(format!(
343                "--proven-safe {}: REFUSED — memory_min_bytes disagreement. The verdicts \
344                 were proven against a {} B floor; synth's declared linear-memory minimum \
345                 for this module is {memory_min_bytes} B. The module_sha256 MATCHED, so \
346                 these should be equal — a disagreement means the producer is broken, and \
347                 verdicts from a broken prover are not trusted. NO guard is elided.",
348                path.display(),
349                doc.memory_min_bytes
350            ))
351        };
352    }
353
354    let mut diagnostics = Vec::new();
355    if doc.proven_safe.is_empty() {
356        diagnostics.push(format!(
357            "--proven-safe {}: accepted, but the document proves ZERO access sites — \
358             nothing to elide (every guard is retained)",
359            path.display()
360        ));
361    }
362
363    ProvenSafeIngest {
364        accepted: true,
365        refusal: None,
366        scry_version: doc.scry_version,
367        declared_module_sha256: doc.module_sha256,
368        actual_module_sha256: actual,
369        declared_memory_min_bytes: doc.memory_min_bytes,
370        offered: doc.proven_safe,
371        diagnostics,
372    }
373}
374
375/// Lowercase hex sha256 — the `module_sha256` convention.
376pub fn hex_sha256(bytes: &[u8]) -> String {
377    let digest = Sha256::digest(bytes);
378    let mut s = String::with_capacity(64);
379    for b in digest {
380        s.push_str(&format!("{b:02x}"));
381    }
382    s
383}
384
385// =============================================================================
386// Attestation (loop step 6 — what sigil reads)
387// =============================================================================
388
389/// One elided site, as attested.
390#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
391pub struct AttestedElision {
392    pub func: u32,
393    /// Operator index within the function body.
394    pub pc: u32,
395    pub op: String,
396    pub width: u32,
397    /// Who authorized this elision. `"scry/safe-accesses/v1"` today; the
398    /// field exists so a future site elided on a DIFFERENT authority (e.g.
399    /// #494's per-site ordeal certificate) is distinguishable in one file.
400    pub authority: String,
401}
402
403/// The `synth-proven-safe-elisions-v1` sidecar.
404///
405/// **Emitted on refusal too**, carrying `accepted: false` plus the reason and
406/// the offered-but-not-elided count. A sidecar that only appears on success is
407/// a brag sheet, not an attestation: sigil must be able to tell "nothing to
408/// elide" from "file rejected".
409#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
410pub struct ElisionAttestation {
411    pub schema: String,
412    /// `CARGO_PKG_VERSION` of the synth that produced the ELF.
413    pub synth_version: String,
414    /// The producer's version string, as declared.
415    pub scry_version: String,
416    /// The sha256 synth computed over the module it compiled. This is the
417    /// authoritative binding — `declared_module_sha256` is what the file said.
418    pub module_sha256: String,
419    pub declared_module_sha256: String,
420    /// Synth's declared linear-memory minimum, in bytes.
421    pub memory_min_bytes: u32,
422    pub declared_memory_min_bytes: u64,
423    /// The `--safety-bounds` mode in force. Elisions only change emitted bytes
424    /// under `software`; any other mode is recorded so an auditor sees why the
425    /// elision count is zero.
426    pub safety_bounds: String,
427    /// Did the document clear the whole-file gates?
428    pub accepted: bool,
429    /// Why not, when `accepted` is false.
430    #[serde(skip_serializing_if = "Option::is_none")]
431    pub refusal: Option<String>,
432    /// How many sites the document offered.
433    pub sites_offered: usize,
434    /// How many survived key validation AND were actually stripped.
435    pub sites_elided: usize,
436    /// Offered sites that were dropped — bad key, wrong width, a specialized
437    /// function, or a non-`software` bounds mode. `offered - elided`.
438    pub sites_not_elided: usize,
439    /// The elision set proper.
440    pub elisions: Vec<AttestedElision>,
441    /// Every warning the ingestion and validation emitted, verbatim.
442    pub diagnostics: Vec<String>,
443}
444
445impl ElisionAttestation {
446    /// Serialize to pretty JSON.
447    pub fn to_json(&self) -> String {
448        serde_json::to_string_pretty(self).expect("ElisionAttestation serializes")
449    }
450
451    /// `foo.elf` → `foo.proven-safe-elisions.json`, mirroring
452    /// [`crate::SafetyManifest::sidecar_path`]'s shape.
453    pub fn sidecar_path(elf_path: &Path) -> PathBuf {
454        let mut p = elf_path.to_path_buf();
455        let stem = elf_path
456            .file_stem()
457            .map(|s| s.to_string_lossy().to_string())
458            .unwrap_or_else(|| "out".to_string());
459        p.set_file_name(format!("{stem}.proven-safe-elisions.json"));
460        p
461    }
462}
463
464#[cfg(test)]
465mod tests {
466    use super::*;
467    use std::io::Write;
468
469    fn write_tmp(name: &str, body: &str) -> PathBuf {
470        let dir = std::env::temp_dir().join("proven_safe_901_unit");
471        std::fs::create_dir_all(&dir).expect("mk tempdir");
472        let p = dir.join(name);
473        let mut f = std::fs::File::create(&p).expect("create");
474        f.write_all(body.as_bytes()).expect("write");
475        p
476    }
477
478    const MODULE: &[u8] = b"\0asm\x01\0\0\0 pretend this is a module";
479
480    fn doc(hash: &str, min: u64, sites: &str) -> String {
481        format!(
482            r#"{{ "schema": "scry/safe-accesses/v1", "scry_version": "3.2.4",
483                  "module_sha256": "{hash}", "memory_min_bytes": {min},
484                  "premises": {{ "bounded_memory": true }},
485                  "counts": {{ "access_sites": 9, "proven_safe": 1 }},
486                  "proven_safe": [{sites}] }}"#
487        )
488    }
489
490    // ---- PROPERTY 1: fail closed on module_sha256 mismatch ------------------
491
492    #[test]
493    fn hash_mismatch_refuses_everything_901() {
494        let stale = "0".repeat(64);
495        let p = write_tmp(
496            "stale.json",
497            &doc(
498                &stale,
499                65536,
500                r#"{"func":0,"pc":1,"op":"i32.load","width":4}"#,
501            ),
502        );
503        let r = ingest(&p, MODULE, 65536);
504        assert!(!r.accepted, "a stale analysis must never be accepted");
505        assert!(
506            r.offered.is_empty(),
507            "a refused document is not trusted piecemeal"
508        );
509        let why = r.refusal.expect("a refusal names its reason");
510        assert!(why.contains("module_sha256 mismatch"), "{why}");
511        // Both values are named so the operator can tell WHICH module it was for.
512        assert!(why.contains(&stale), "{why}");
513        assert!(why.contains(&hex_sha256(MODULE)), "{why}");
514    }
515
516    #[test]
517    fn matching_hash_is_accepted_and_case_insensitive_901() {
518        let h = hex_sha256(MODULE).to_uppercase();
519        let p = write_tmp(
520            "good.json",
521            &doc(&h, 65536, r#"{"func":0,"pc":1,"op":"i32.load","width":4}"#),
522        );
523        let r = ingest(&p, MODULE, 65536);
524        assert!(r.accepted, "{:?}", r.refusal);
525        assert_eq!(r.offered.len(), 1);
526        assert_eq!(r.scry_version, "3.2.4");
527    }
528
529    /// Any change to the module — a single flipped byte, which is what a
530    /// pre-compile rewrite looks like — refuses.
531    #[test]
532    fn one_flipped_module_byte_refuses_901() {
533        let p = write_tmp(
534            "flip.json",
535            &doc(
536                &hex_sha256(MODULE),
537                65536,
538                r#"{"func":0,"pc":1,"op":"i32.load","width":4}"#,
539            ),
540        );
541        let mut rewritten = MODULE.to_vec();
542        rewritten.push(0x00);
543        let r = ingest(&p, &rewritten, 65536);
544        assert!(!r.accepted);
545        assert!(r.refusal.unwrap().contains("module_sha256 mismatch"));
546    }
547
548    // ---- PROPERTY: fail closed on the memory floor --------------------------
549
550    #[test]
551    fn memory_min_bytes_disagreement_refuses_901() {
552        let p = write_tmp(
553            "floor.json",
554            &doc(
555                &hex_sha256(MODULE),
556                131072,
557                r#"{"func":0,"pc":1,"op":"i32.load","width":4}"#,
558            ),
559        );
560        let r = ingest(&p, MODULE, 65536);
561        assert!(!r.accepted);
562        let why = r.refusal.unwrap();
563        assert!(why.contains("memory_min_bytes disagreement"), "{why}");
564        assert!(why.contains("131072") && why.contains("65536"), "{why}");
565    }
566
567    // ---- PROPERTY: malformed ⇒ no elisions, a diagnostic, never an error ----
568
569    #[test]
570    fn malformed_missing_and_wrong_schema_all_refuse_without_erroring_901() {
571        let h = hex_sha256(MODULE);
572        let cases = vec![
573            ("missing", None),
574            ("garbage.json", Some("this is not json {{{".to_string())),
575            ("empty.json", Some(String::new())),
576            (
577                "wrongschema.json",
578                Some(doc(&h, 65536, "").replace(SAFE_ACCESSES_SCHEMA, "scry/safe-accesses/v2")),
579            ),
580            (
581                "nohash.json",
582                Some(r#"{"schema":"scry/safe-accesses/v1","memory_min_bytes":65536}"#.to_string()),
583            ),
584            (
585                "sitegarbage.json",
586                Some(doc(&h, 65536, r#"{"func":"four","pc":1,"width":4}"#)),
587            ),
588        ];
589        for (name, body) in cases {
590            let p = match body {
591                Some(b) => write_tmp(name, &b),
592                None => std::env::temp_dir().join("proven_safe_901_unit/definitely-absent.json"),
593            };
594            let r = ingest(&p, MODULE, 65536);
595            assert!(!r.accepted, "'{name}' must not be accepted");
596            assert!(r.offered.is_empty(), "'{name}' offered sites");
597            assert!(r.refusal.is_some(), "'{name}' refused without a reason");
598            assert!(!r.diagnostics.is_empty(), "'{name}' refused silently");
599        }
600    }
601
602    #[test]
603    fn unknown_fields_are_tolerated_901() {
604        // A newer scry adding fields must not break an older synth.
605        let p = write_tmp(
606            "future.json",
607            &format!(
608                r#"{{ "schema": "scry/safe-accesses/v1", "scry_version": "9.9.9",
609                      "module_sha256": "{}", "memory_min_bytes": 65536,
610                      "brand_new_field": {{ "nested": [1,2,3] }},
611                      "proven_safe": [{{"func":0,"pc":1,"op":"i32.load","width":4,
612                                        "confidence":"high"}}] }}"#,
613                hex_sha256(MODULE)
614            ),
615        );
616        let r = ingest(&p, MODULE, 65536);
617        assert!(r.accepted, "{:?}", r.refusal);
618        assert_eq!(r.offered.len(), 1);
619    }
620
621    #[test]
622    fn accepted_but_empty_is_diagnosed_901() {
623        let p = write_tmp("none.json", &doc(&hex_sha256(MODULE), 65536, ""));
624        let r = ingest(&p, MODULE, 65536);
625        assert!(r.accepted);
626        assert!(r.offered.is_empty());
627        assert!(
628            r.diagnostics
629                .iter()
630                .any(|d| d.contains("ZERO access sites")),
631            "an accepted-but-vacuous document must say so: {:?}",
632            r.diagnostics
633        );
634    }
635
636    // ---- PROPERTY 4: the key is self-checking -------------------------------
637
638    fn ops() -> Vec<WasmOp> {
639        vec![
640            WasmOp::LocalGet(0), // 0
641            WasmOp::I32Load {
642                offset: 0,
643                align: 2,
644            }, // 1  — 4 B
645            WasmOp::LocalGet(0), // 2
646            WasmOp::I32Load8U {
647                offset: 1,
648                align: 0,
649            }, // 3  — 1 B
650            WasmOp::I32Add,      // 4
651            WasmOp::I64Store {
652                offset: 8,
653                align: 3,
654            }, // 5  — 8 B
655        ]
656    }
657
658    #[test]
659    fn valid_sites_become_marks_901() {
660        let p = write_tmp(
661            "marks.json",
662            &doc(
663                &hex_sha256(MODULE),
664                65536,
665                r#"{"func":0,"pc":5,"op":"i64.store","width":8},
666                   {"func":0,"pc":1,"op":"i32.load","width":4},
667                   {"func":0,"pc":3,"op":"i32.load8_u","width":1}"#,
668            ),
669        );
670        let r = ingest(&p, MODULE, 65536);
671        let mut notes = Vec::new();
672        assert_eq!(r.validate_function(0, &ops(), &mut notes), vec![1, 3, 5]);
673        assert!(notes.is_empty(), "{notes:?}");
674    }
675
676    /// If the producer ever emits wasm BYTE OFFSETS instead of operator
677    /// indices, the entries fall out of range or land on non-access operators —
678    /// so the build elides nothing LOUDLY instead of stripping the wrong guard.
679    #[test]
680    fn byte_offsets_instead_of_op_indices_elide_nothing_loudly_901() {
681        let p = write_tmp(
682            "byteoffsets.json",
683            &doc(
684                &hex_sha256(MODULE),
685                65536,
686                r#"{"func":0,"pc":41,"op":"i32.load","width":4},
687                   {"func":0,"pc":137,"op":"i32.load8_u","width":1}"#,
688            ),
689        );
690        let r = ingest(&p, MODULE, 65536);
691        assert!(
692            r.accepted,
693            "the FILE is well formed — only the keys are wrong"
694        );
695        let mut notes = Vec::new();
696        assert_eq!(
697            r.validate_function(0, &ops(), &mut notes),
698            Vec::<usize>::new()
699        );
700        assert_eq!(notes.len(), 2);
701        assert!(
702            notes.iter().all(|n| n.contains("out of range")),
703            "{notes:?}"
704        );
705        assert!(notes[0].contains("OPERATOR index"), "{notes:?}");
706    }
707
708    #[test]
709    fn non_access_operator_is_dropped_901() {
710        let p = write_tmp(
711            "nonaccess.json",
712            &doc(
713                &hex_sha256(MODULE),
714                65536,
715                r#"{"func":0,"pc":4,"op":"i32.load","width":4}"#,
716            ),
717        );
718        let mut notes = Vec::new();
719        let marks = ingest(&p, MODULE, 65536).validate_function(0, &ops(), &mut notes);
720        assert_eq!(marks, Vec::<usize>::new());
721        assert!(notes[0].contains("not a linear-memory access"), "{notes:?}");
722    }
723
724    #[test]
725    fn width_disagreement_is_dropped_901() {
726        // pc 3 is an i32.load8_u (1 B) but the file claims 4 B: the file and
727        // the module disagree about which BYTES are covered — drop it.
728        let p = write_tmp(
729            "width.json",
730            &doc(
731                &hex_sha256(MODULE),
732                65536,
733                r#"{"func":0,"pc":3,"op":"i32.load","width":4},
734                   {"func":0,"pc":1,"op":"i32.load","width":4}"#,
735            ),
736        );
737        let mut notes = Vec::new();
738        let marks = ingest(&p, MODULE, 65536).validate_function(0, &ops(), &mut notes);
739        assert_eq!(
740            marks,
741            vec![1],
742            "the sound entry survives, the skewed one does not"
743        );
744        assert_eq!(notes.len(), 1);
745        assert!(
746            notes[0].contains("disagrees with the decoded operator"),
747            "{notes:?}"
748        );
749    }
750
751    #[test]
752    fn sites_are_keyed_per_function_901() {
753        let p = write_tmp(
754            "perfunc.json",
755            &doc(
756                &hex_sha256(MODULE),
757                65536,
758                r#"{"func":7,"pc":1,"op":"i32.load","width":4}"#,
759            ),
760        );
761        let r = ingest(&p, MODULE, 65536);
762        let mut notes = Vec::new();
763        // Function 0 gets nothing: the verdict is func 7's.
764        assert_eq!(
765            r.validate_function(0, &ops(), &mut notes),
766            Vec::<usize>::new()
767        );
768        assert!(notes.is_empty());
769        assert_eq!(r.validate_function(7, &ops(), &mut notes), vec![1]);
770    }
771
772    // ---- access_width ------------------------------------------------------
773
774    #[test]
775    fn access_width_covers_the_bytes_touched_not_the_value_width_901() {
776        assert_eq!(
777            access_width(&WasmOp::I64Load32U {
778                offset: 0,
779                align: 2
780            }),
781            Some(4)
782        );
783        assert_eq!(
784            access_width(&WasmOp::I64Store8 {
785                offset: 0,
786                align: 0
787            }),
788            Some(1)
789        );
790        assert_eq!(
791            access_width(&WasmOp::I32Load16S {
792                offset: 0,
793                align: 1
794            }),
795            Some(2)
796        );
797        assert_eq!(
798            access_width(&WasmOp::F64Load {
799                offset: 0,
800                align: 3
801            }),
802            Some(8)
803        );
804        assert_eq!(access_width(&WasmOp::I32Add), None);
805        assert_eq!(access_width(&WasmOp::LocalGet(0)), None);
806    }
807
808    // ---- attestation -------------------------------------------------------
809
810    #[test]
811    fn attestation_sidecar_path_mirrors_the_safety_manifest_901() {
812        assert_eq!(
813            ElisionAttestation::sidecar_path(Path::new("/tmp/foo.elf")),
814            PathBuf::from("/tmp/foo.proven-safe-elisions.json")
815        );
816        assert_eq!(
817            ElisionAttestation::sidecar_path(Path::new("out")),
818            PathBuf::from("out.proven-safe-elisions.json")
819        );
820    }
821
822    #[test]
823    fn refusal_is_attested_not_hidden_901() {
824        let a = ElisionAttestation {
825            schema: ELISION_ATTESTATION_SCHEMA.to_string(),
826            synth_version: "0.55.0".to_string(),
827            scry_version: "3.2.4".to_string(),
828            module_sha256: "aa".repeat(32),
829            declared_module_sha256: "bb".repeat(32),
830            memory_min_bytes: 65536,
831            declared_memory_min_bytes: 65536,
832            safety_bounds: "software".to_string(),
833            accepted: false,
834            refusal: Some("module_sha256 mismatch".to_string()),
835            sites_offered: 8,
836            sites_elided: 0,
837            sites_not_elided: 8,
838            elisions: Vec::new(),
839            diagnostics: vec!["refused".to_string()],
840        };
841        let json = a.to_json();
842        // sigil must be able to tell "nothing to elide" from "file rejected".
843        assert!(json.contains("\"accepted\": false"));
844        assert!(json.contains("module_sha256 mismatch"));
845        assert!(json.contains("\"sites_offered\": 8"));
846        assert!(json.contains("\"sites_elided\": 0"));
847        let back: ElisionAttestation = serde_json::from_str(&json).expect("round-trips");
848        assert_eq!(back, a);
849    }
850}