1use serde::{Deserialize, Serialize};
59use sha2::{Digest, Sha256};
60use std::path::{Path, PathBuf};
61
62use crate::wasm_op::WasmOp;
63
64pub const SAFE_ACCESSES_SCHEMA: &str = "scry/safe-accesses/v1";
66
67pub const ELISION_ATTESTATION_SCHEMA: &str = "synth-proven-safe-elisions-v1";
69
70#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
76pub struct SafeSite {
77 pub func: u32,
80 pub pc: u32,
82 #[serde(default)]
86 pub op: String,
87 pub width: u32,
89}
90
91#[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#[derive(Debug, Clone, PartialEq, Eq, Default)]
119pub struct ProvenSafeIngest {
120 pub accepted: bool,
123 pub refusal: Option<String>,
125 pub scry_version: String,
128 pub declared_module_sha256: String,
131 pub actual_module_sha256: String,
133 pub declared_memory_min_bytes: u64,
135 pub offered: Vec<SafeSite>,
138 pub diagnostics: Vec<String>,
140}
141
142impl ProvenSafeIngest {
143 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 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 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
218pub 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
253pub 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 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 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
375pub 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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
391pub struct AttestedElision {
392 pub func: u32,
393 pub pc: u32,
395 pub op: String,
396 pub width: u32,
397 pub authority: String,
401}
402
403#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
410pub struct ElisionAttestation {
411 pub schema: String,
412 pub synth_version: String,
414 pub scry_version: String,
416 pub module_sha256: String,
419 pub declared_module_sha256: String,
420 pub memory_min_bytes: u32,
422 pub declared_memory_min_bytes: u64,
423 pub safety_bounds: String,
427 pub accepted: bool,
429 #[serde(skip_serializing_if = "Option::is_none")]
431 pub refusal: Option<String>,
432 pub sites_offered: usize,
434 pub sites_elided: usize,
436 pub sites_not_elided: usize,
439 pub elisions: Vec<AttestedElision>,
441 pub diagnostics: Vec<String>,
443}
444
445impl ElisionAttestation {
446 pub fn to_json(&self) -> String {
448 serde_json::to_string_pretty(self).expect("ElisionAttestation serializes")
449 }
450
451 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 #[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 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 #[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 #[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 #[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 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 fn ops() -> Vec<WasmOp> {
639 vec![
640 WasmOp::LocalGet(0), WasmOp::I32Load {
642 offset: 0,
643 align: 2,
644 }, WasmOp::LocalGet(0), WasmOp::I32Load8U {
647 offset: 1,
648 align: 0,
649 }, WasmOp::I32Add, WasmOp::I64Store {
652 offset: 8,
653 align: 3,
654 }, ]
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 #[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 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 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 #[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 #[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 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}