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.
430    pub memory_min_bytes: u32,
431    pub declared_memory_min_bytes: u64,
432    /// The `--safety-bounds` mode in force. Elisions only change emitted bytes
433    /// under `software`; any other mode is recorded so an auditor sees why the
434    /// elision count is zero.
435    pub safety_bounds: String,
436    /// Did the document clear the whole-file gates?
437    pub accepted: bool,
438    /// Why not, when `accepted` is false.
439    #[serde(skip_serializing_if = "Option::is_none")]
440    pub refusal: Option<String>,
441    /// How many sites the document offered.
442    pub sites_offered: usize,
443    /// How many survived key validation AND were actually stripped.
444    pub sites_elided: usize,
445    /// Offered sites that were dropped — bad key, wrong width, a specialized
446    /// function, or a non-`software` bounds mode. `offered - elided`.
447    pub sites_not_elided: usize,
448    /// The elision set proper.
449    pub elisions: Vec<AttestedElision>,
450    /// Every warning the ingestion and validation emitted, verbatim.
451    pub diagnostics: Vec<String>,
452}
453
454impl ElisionAttestation {
455    /// Serialize to pretty JSON.
456    pub fn to_json(&self) -> String {
457        serde_json::to_string_pretty(self).expect("ElisionAttestation serializes")
458    }
459
460    /// `foo.elf` → `foo.proven-safe-elisions.json`, mirroring
461    /// [`crate::SafetyManifest::sidecar_path`]'s shape.
462    pub fn sidecar_path(elf_path: &Path) -> PathBuf {
463        let mut p = elf_path.to_path_buf();
464        let stem = elf_path
465            .file_stem()
466            .map(|s| s.to_string_lossy().to_string())
467            .unwrap_or_else(|| "out".to_string());
468        p.set_file_name(format!("{stem}.proven-safe-elisions.json"));
469        p
470    }
471}
472
473#[cfg(test)]
474mod tests {
475    use super::*;
476    use std::io::Write;
477
478    fn write_tmp(name: &str, body: &str) -> PathBuf {
479        let dir = std::env::temp_dir().join("proven_safe_901_unit");
480        std::fs::create_dir_all(&dir).expect("mk tempdir");
481        let p = dir.join(name);
482        let mut f = std::fs::File::create(&p).expect("create");
483        f.write_all(body.as_bytes()).expect("write");
484        p
485    }
486
487    const MODULE: &[u8] = b"\0asm\x01\0\0\0 pretend this is a module";
488
489    fn doc(hash: &str, min: u64, sites: &str) -> String {
490        format!(
491            r#"{{ "schema": "scry/safe-accesses/v1", "scry_version": "3.2.4",
492                  "module_sha256": "{hash}", "memory_min_bytes": {min},
493                  "premises": {{ "bounded_memory": true }},
494                  "counts": {{ "access_sites": 9, "proven_safe": 1 }},
495                  "proven_safe": [{sites}] }}"#
496        )
497    }
498
499    // ---- PROPERTY 1: fail closed on module_sha256 mismatch ------------------
500
501    #[test]
502    fn hash_mismatch_refuses_everything_901() {
503        let stale = "0".repeat(64);
504        let p = write_tmp(
505            "stale.json",
506            &doc(
507                &stale,
508                65536,
509                r#"{"func":0,"pc":1,"op":"i32.load","width":4}"#,
510            ),
511        );
512        let r = ingest(&p, MODULE, 65536);
513        assert!(!r.accepted, "a stale analysis must never be accepted");
514        assert!(
515            r.offered.is_empty(),
516            "a refused document is not trusted piecemeal"
517        );
518        let why = r.refusal.expect("a refusal names its reason");
519        assert!(why.contains("module_sha256 mismatch"), "{why}");
520        // Both values are named so the operator can tell WHICH module it was for.
521        assert!(why.contains(&stale), "{why}");
522        assert!(why.contains(&hex_sha256(MODULE)), "{why}");
523    }
524
525    #[test]
526    fn matching_hash_is_accepted_and_case_insensitive_901() {
527        let h = hex_sha256(MODULE).to_uppercase();
528        let p = write_tmp(
529            "good.json",
530            &doc(&h, 65536, r#"{"func":0,"pc":1,"op":"i32.load","width":4}"#),
531        );
532        let r = ingest(&p, MODULE, 65536);
533        assert!(r.accepted, "{:?}", r.refusal);
534        assert_eq!(r.offered.len(), 1);
535        assert_eq!(r.scry_version, "3.2.4");
536    }
537
538    /// Any change to the module — a single flipped byte, which is what a
539    /// pre-compile rewrite looks like — refuses.
540    #[test]
541    fn one_flipped_module_byte_refuses_901() {
542        let p = write_tmp(
543            "flip.json",
544            &doc(
545                &hex_sha256(MODULE),
546                65536,
547                r#"{"func":0,"pc":1,"op":"i32.load","width":4}"#,
548            ),
549        );
550        let mut rewritten = MODULE.to_vec();
551        rewritten.push(0x00);
552        let r = ingest(&p, &rewritten, 65536);
553        assert!(!r.accepted);
554        assert!(r.refusal.unwrap().contains("module_sha256 mismatch"));
555    }
556
557    // ---- PROPERTY: fail closed on the memory floor --------------------------
558
559    #[test]
560    fn memory_min_bytes_disagreement_refuses_901() {
561        let p = write_tmp(
562            "floor.json",
563            &doc(
564                &hex_sha256(MODULE),
565                131072,
566                r#"{"func":0,"pc":1,"op":"i32.load","width":4}"#,
567            ),
568        );
569        let r = ingest(&p, MODULE, 65536);
570        assert!(!r.accepted);
571        let why = r.refusal.unwrap();
572        assert!(why.contains("memory_min_bytes disagreement"), "{why}");
573        assert!(why.contains("131072") && why.contains("65536"), "{why}");
574    }
575
576    // ---- PROPERTY: malformed ⇒ no elisions, a diagnostic, never an error ----
577
578    #[test]
579    fn malformed_missing_and_wrong_schema_all_refuse_without_erroring_901() {
580        let h = hex_sha256(MODULE);
581        let cases = vec![
582            ("missing", None),
583            ("garbage.json", Some("this is not json {{{".to_string())),
584            ("empty.json", Some(String::new())),
585            (
586                "wrongschema.json",
587                Some(doc(&h, 65536, "").replace(SAFE_ACCESSES_SCHEMA, "scry/safe-accesses/v2")),
588            ),
589            (
590                "nohash.json",
591                Some(r#"{"schema":"scry/safe-accesses/v1","memory_min_bytes":65536}"#.to_string()),
592            ),
593            (
594                "sitegarbage.json",
595                Some(doc(&h, 65536, r#"{"func":"four","pc":1,"width":4}"#)),
596            ),
597        ];
598        for (name, body) in cases {
599            let p = match body {
600                Some(b) => write_tmp(name, &b),
601                None => std::env::temp_dir().join("proven_safe_901_unit/definitely-absent.json"),
602            };
603            let r = ingest(&p, MODULE, 65536);
604            assert!(!r.accepted, "'{name}' must not be accepted");
605            assert!(r.offered.is_empty(), "'{name}' offered sites");
606            assert!(r.refusal.is_some(), "'{name}' refused without a reason");
607            assert!(!r.diagnostics.is_empty(), "'{name}' refused silently");
608        }
609    }
610
611    #[test]
612    fn unknown_fields_are_tolerated_901() {
613        // A newer scry adding fields must not break an older synth.
614        let p = write_tmp(
615            "future.json",
616            &format!(
617                r#"{{ "schema": "scry/safe-accesses/v1", "scry_version": "9.9.9",
618                      "module_sha256": "{}", "memory_min_bytes": 65536,
619                      "brand_new_field": {{ "nested": [1,2,3] }},
620                      "proven_safe": [{{"func":0,"pc":1,"op":"i32.load","width":4,
621                                        "confidence":"high"}}] }}"#,
622                hex_sha256(MODULE)
623            ),
624        );
625        let r = ingest(&p, MODULE, 65536);
626        assert!(r.accepted, "{:?}", r.refusal);
627        assert_eq!(r.offered.len(), 1);
628    }
629
630    #[test]
631    fn accepted_but_empty_is_diagnosed_901() {
632        let p = write_tmp("none.json", &doc(&hex_sha256(MODULE), 65536, ""));
633        let r = ingest(&p, MODULE, 65536);
634        assert!(r.accepted);
635        assert!(r.offered.is_empty());
636        assert!(
637            r.diagnostics
638                .iter()
639                .any(|d| d.contains("ZERO access sites")),
640            "an accepted-but-vacuous document must say so: {:?}",
641            r.diagnostics
642        );
643    }
644
645    // ---- PROPERTY 4: the key is self-checking -------------------------------
646
647    fn ops() -> Vec<WasmOp> {
648        vec![
649            WasmOp::LocalGet(0), // 0
650            WasmOp::I32Load {
651                offset: 0,
652                align: 2,
653            }, // 1  — 4 B
654            WasmOp::LocalGet(0), // 2
655            WasmOp::I32Load8U {
656                offset: 1,
657                align: 0,
658            }, // 3  — 1 B
659            WasmOp::I32Add,      // 4
660            WasmOp::I64Store {
661                offset: 8,
662                align: 3,
663            }, // 5  — 8 B
664        ]
665    }
666
667    #[test]
668    fn valid_sites_become_marks_901() {
669        let p = write_tmp(
670            "marks.json",
671            &doc(
672                &hex_sha256(MODULE),
673                65536,
674                r#"{"func":0,"pc":5,"op":"i64.store","width":8},
675                   {"func":0,"pc":1,"op":"i32.load","width":4},
676                   {"func":0,"pc":3,"op":"i32.load8_u","width":1}"#,
677            ),
678        );
679        let r = ingest(&p, MODULE, 65536);
680        let mut notes = Vec::new();
681        assert_eq!(r.validate_function(0, &ops(), &mut notes), vec![1, 3, 5]);
682        assert!(notes.is_empty(), "{notes:?}");
683    }
684
685    /// If the producer ever emits wasm BYTE OFFSETS instead of operator
686    /// indices, the entries fall out of range or land on non-access operators —
687    /// so the build elides nothing LOUDLY instead of stripping the wrong guard.
688    #[test]
689    fn byte_offsets_instead_of_op_indices_elide_nothing_loudly_901() {
690        let p = write_tmp(
691            "byteoffsets.json",
692            &doc(
693                &hex_sha256(MODULE),
694                65536,
695                r#"{"func":0,"pc":41,"op":"i32.load","width":4},
696                   {"func":0,"pc":137,"op":"i32.load8_u","width":1}"#,
697            ),
698        );
699        let r = ingest(&p, MODULE, 65536);
700        assert!(
701            r.accepted,
702            "the FILE is well formed — only the keys are wrong"
703        );
704        let mut notes = Vec::new();
705        assert_eq!(
706            r.validate_function(0, &ops(), &mut notes),
707            Vec::<usize>::new()
708        );
709        assert_eq!(notes.len(), 2);
710        assert!(
711            notes.iter().all(|n| n.contains("out of range")),
712            "{notes:?}"
713        );
714        assert!(notes[0].contains("OPERATOR index"), "{notes:?}");
715    }
716
717    #[test]
718    fn non_access_operator_is_dropped_901() {
719        let p = write_tmp(
720            "nonaccess.json",
721            &doc(
722                &hex_sha256(MODULE),
723                65536,
724                r#"{"func":0,"pc":4,"op":"i32.load","width":4}"#,
725            ),
726        );
727        let mut notes = Vec::new();
728        let marks = ingest(&p, MODULE, 65536).validate_function(0, &ops(), &mut notes);
729        assert_eq!(marks, Vec::<usize>::new());
730        assert!(notes[0].contains("not a linear-memory access"), "{notes:?}");
731    }
732
733    #[test]
734    fn width_disagreement_is_dropped_901() {
735        // pc 3 is an i32.load8_u (1 B) but the file claims 4 B: the file and
736        // the module disagree about which BYTES are covered — drop it.
737        let p = write_tmp(
738            "width.json",
739            &doc(
740                &hex_sha256(MODULE),
741                65536,
742                r#"{"func":0,"pc":3,"op":"i32.load","width":4},
743                   {"func":0,"pc":1,"op":"i32.load","width":4}"#,
744            ),
745        );
746        let mut notes = Vec::new();
747        let marks = ingest(&p, MODULE, 65536).validate_function(0, &ops(), &mut notes);
748        assert_eq!(
749            marks,
750            vec![1],
751            "the sound entry survives, the skewed one does not"
752        );
753        assert_eq!(notes.len(), 1);
754        assert!(
755            notes[0].contains("disagrees with the decoded operator"),
756            "{notes:?}"
757        );
758    }
759
760    #[test]
761    fn sites_are_keyed_per_function_901() {
762        let p = write_tmp(
763            "perfunc.json",
764            &doc(
765                &hex_sha256(MODULE),
766                65536,
767                r#"{"func":7,"pc":1,"op":"i32.load","width":4}"#,
768            ),
769        );
770        let r = ingest(&p, MODULE, 65536);
771        let mut notes = Vec::new();
772        // Function 0 gets nothing: the verdict is func 7's.
773        assert_eq!(
774            r.validate_function(0, &ops(), &mut notes),
775            Vec::<usize>::new()
776        );
777        assert!(notes.is_empty());
778        assert_eq!(r.validate_function(7, &ops(), &mut notes), vec![1]);
779    }
780
781    // ---- access_width ------------------------------------------------------
782
783    #[test]
784    fn access_width_covers_the_bytes_touched_not_the_value_width_901() {
785        assert_eq!(
786            access_width(&WasmOp::I64Load32U {
787                offset: 0,
788                align: 2
789            }),
790            Some(4)
791        );
792        assert_eq!(
793            access_width(&WasmOp::I64Store8 {
794                offset: 0,
795                align: 0
796            }),
797            Some(1)
798        );
799        assert_eq!(
800            access_width(&WasmOp::I32Load16S {
801                offset: 0,
802                align: 1
803            }),
804            Some(2)
805        );
806        assert_eq!(
807            access_width(&WasmOp::F64Load {
808                offset: 0,
809                align: 3
810            }),
811            Some(8)
812        );
813        assert_eq!(access_width(&WasmOp::I32Add), None);
814        assert_eq!(access_width(&WasmOp::LocalGet(0)), None);
815    }
816
817    // ---- attestation -------------------------------------------------------
818
819    #[test]
820    fn attestation_sidecar_path_mirrors_the_safety_manifest_901() {
821        assert_eq!(
822            ElisionAttestation::sidecar_path(Path::new("/tmp/foo.elf")),
823            PathBuf::from("/tmp/foo.proven-safe-elisions.json")
824        );
825        assert_eq!(
826            ElisionAttestation::sidecar_path(Path::new("out")),
827            PathBuf::from("out.proven-safe-elisions.json")
828        );
829    }
830
831    #[test]
832    fn refusal_is_attested_not_hidden_901() {
833        let a = ElisionAttestation {
834            schema: ELISION_ATTESTATION_SCHEMA.to_string(),
835            synth_version: "0.55.0".to_string(),
836            scry_version: "3.2.4".to_string(),
837            module_sha256: "aa".repeat(32),
838            declared_module_sha256: "bb".repeat(32),
839            memory_min_bytes: 65536,
840            declared_memory_min_bytes: 65536,
841            safety_bounds: "software".to_string(),
842            accepted: false,
843            refusal: Some("module_sha256 mismatch".to_string()),
844            sites_offered: 8,
845            sites_elided: 0,
846            sites_not_elided: 8,
847            elisions: Vec::new(),
848            diagnostics: vec!["refused".to_string()],
849        };
850        let json = a.to_json();
851        // sigil must be able to tell "nothing to elide" from "file rejected".
852        assert!(json.contains("\"accepted\": false"));
853        assert!(json.contains("module_sha256 mismatch"));
854        assert!(json.contains("\"sites_offered\": 8"));
855        assert!(json.contains("\"sites_elided\": 0"));
856        let back: ElisionAttestation = serde_json::from_str(&json).expect("round-trips");
857        assert_eq!(back, a);
858    }
859}