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 pub fn refused(reason: impl Into<String>) -> Self {
149 Self::refuse(reason)
150 }
151
152 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 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 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
227pub 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
262pub 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 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 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
384pub 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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
400pub struct AttestedElision {
401 pub func: u32,
402 pub pc: u32,
404 pub op: String,
405 pub width: u32,
406 pub authority: String,
410}
411
412#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
419pub struct ElisionAttestation {
420 pub schema: String,
421 pub synth_version: String,
423 pub scry_version: String,
425 pub module_sha256: String,
428 pub declared_module_sha256: String,
429 pub memory_min_bytes: u32,
431 pub declared_memory_min_bytes: u64,
432 pub safety_bounds: String,
436 pub accepted: bool,
438 #[serde(skip_serializing_if = "Option::is_none")]
440 pub refusal: Option<String>,
441 pub sites_offered: usize,
443 pub sites_elided: usize,
445 pub sites_not_elided: usize,
448 pub elisions: Vec<AttestedElision>,
450 pub diagnostics: Vec<String>,
452}
453
454impl ElisionAttestation {
455 pub fn to_json(&self) -> String {
457 serde_json::to_string_pretty(self).expect("ElisionAttestation serializes")
458 }
459
460 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 #[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 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 #[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 #[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 #[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 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 fn ops() -> Vec<WasmOp> {
648 vec![
649 WasmOp::LocalGet(0), WasmOp::I32Load {
651 offset: 0,
652 align: 2,
653 }, WasmOp::LocalGet(0), WasmOp::I32Load8U {
656 offset: 1,
657 align: 0,
658 }, WasmOp::I32Add, WasmOp::I64Store {
661 offset: 8,
662 align: 3,
663 }, ]
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 #[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 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 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 #[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 #[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 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}