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: Option<u32>,
437 pub declared_memory_min_bytes: u64,
438 pub safety_bounds: String,
442 pub accepted: bool,
444 #[serde(skip_serializing_if = "Option::is_none")]
446 pub refusal: Option<String>,
447 pub sites_offered: usize,
449 pub sites_elided: usize,
451 pub sites_not_elided: usize,
454 pub elisions: Vec<AttestedElision>,
456 pub diagnostics: Vec<String>,
458}
459
460impl ElisionAttestation {
461 pub fn to_json(&self) -> String {
463 serde_json::to_string_pretty(self).expect("ElisionAttestation serializes")
464 }
465
466 pub fn sidecar_path(elf_path: &Path) -> PathBuf {
469 let mut p = elf_path.to_path_buf();
470 let stem = elf_path
471 .file_stem()
472 .map(|s| s.to_string_lossy().to_string())
473 .unwrap_or_else(|| "out".to_string());
474 p.set_file_name(format!("{stem}.proven-safe-elisions.json"));
475 p
476 }
477}
478
479#[cfg(test)]
480mod tests {
481 use super::*;
482 use std::io::Write;
483 use std::sync::atomic::{AtomicU32, Ordering};
484
485 struct Fixtures(PathBuf);
507
508 impl Fixtures {
509 fn new() -> Self {
510 static SEQ: AtomicU32 = AtomicU32::new(0);
511 let n = SEQ.fetch_add(1, Ordering::Relaxed);
512 let dir = std::env::temp_dir()
513 .join(format!("proven_safe_901_unit-{}-{n}", std::process::id()));
514 let _ = std::fs::remove_dir_all(&dir);
516 std::fs::create_dir_all(&dir).expect("mk tempdir");
517 Self(dir)
518 }
519
520 fn write(&self, name: &str, body: &str) -> PathBuf {
521 let p = self.0.join(name);
522 let mut f = std::fs::File::create(&p).expect("create");
523 f.write_all(body.as_bytes()).expect("write");
524 p
525 }
526
527 fn absent(&self, name: &str) -> PathBuf {
529 self.0.join(name)
530 }
531 }
532
533 impl Drop for Fixtures {
534 fn drop(&mut self) {
535 let _ = std::fs::remove_dir_all(&self.0);
538 }
539 }
540
541 const MODULE: &[u8] = b"\0asm\x01\0\0\0 pretend this is a module";
542
543 fn doc(hash: &str, min: u64, sites: &str) -> String {
544 format!(
545 r#"{{ "schema": "scry/safe-accesses/v1", "scry_version": "3.2.4",
546 "module_sha256": "{hash}", "memory_min_bytes": {min},
547 "premises": {{ "bounded_memory": true }},
548 "counts": {{ "access_sites": 9, "proven_safe": 1 }},
549 "proven_safe": [{sites}] }}"#
550 )
551 }
552
553 #[test]
556 fn hash_mismatch_refuses_everything_901() {
557 let stale = "0".repeat(64);
558 let fx = Fixtures::new();
559 let p = fx.write(
560 "stale.json",
561 &doc(
562 &stale,
563 65536,
564 r#"{"func":0,"pc":1,"op":"i32.load","width":4}"#,
565 ),
566 );
567 let r = ingest(&p, MODULE, 65536);
568 assert!(!r.accepted, "a stale analysis must never be accepted");
569 assert!(
570 r.offered.is_empty(),
571 "a refused document is not trusted piecemeal"
572 );
573 let why = r.refusal.expect("a refusal names its reason");
574 assert!(why.contains("module_sha256 mismatch"), "{why}");
575 assert!(why.contains(&stale), "{why}");
577 assert!(why.contains(&hex_sha256(MODULE)), "{why}");
578 }
579
580 #[test]
581 fn matching_hash_is_accepted_and_case_insensitive_901() {
582 let h = hex_sha256(MODULE).to_uppercase();
583 let fx = Fixtures::new();
584 let p = fx.write(
585 "good.json",
586 &doc(&h, 65536, r#"{"func":0,"pc":1,"op":"i32.load","width":4}"#),
587 );
588 let r = ingest(&p, MODULE, 65536);
589 assert!(r.accepted, "{:?}", r.refusal);
590 assert_eq!(r.offered.len(), 1);
591 assert_eq!(r.scry_version, "3.2.4");
592 }
593
594 #[test]
597 fn one_flipped_module_byte_refuses_901() {
598 let fx = Fixtures::new();
599 let p = fx.write(
600 "flip.json",
601 &doc(
602 &hex_sha256(MODULE),
603 65536,
604 r#"{"func":0,"pc":1,"op":"i32.load","width":4}"#,
605 ),
606 );
607 let mut rewritten = MODULE.to_vec();
608 rewritten.push(0x00);
609 let r = ingest(&p, &rewritten, 65536);
610 assert!(!r.accepted);
611 assert!(r.refusal.unwrap().contains("module_sha256 mismatch"));
612 }
613
614 #[test]
617 fn memory_min_bytes_disagreement_refuses_901() {
618 let fx = Fixtures::new();
619 let p = fx.write(
620 "floor.json",
621 &doc(
622 &hex_sha256(MODULE),
623 131072,
624 r#"{"func":0,"pc":1,"op":"i32.load","width":4}"#,
625 ),
626 );
627 let r = ingest(&p, MODULE, 65536);
628 assert!(!r.accepted);
629 let why = r.refusal.unwrap();
630 assert!(why.contains("memory_min_bytes disagreement"), "{why}");
631 assert!(why.contains("131072") && why.contains("65536"), "{why}");
632 }
633
634 #[test]
637 fn malformed_missing_and_wrong_schema_all_refuse_without_erroring_901() {
638 let h = hex_sha256(MODULE);
639 let cases = vec![
640 ("missing", None),
641 ("garbage.json", Some("this is not json {{{".to_string())),
642 ("empty.json", Some(String::new())),
643 (
644 "wrongschema.json",
645 Some(doc(&h, 65536, "").replace(SAFE_ACCESSES_SCHEMA, "scry/safe-accesses/v2")),
646 ),
647 (
648 "nohash.json",
649 Some(r#"{"schema":"scry/safe-accesses/v1","memory_min_bytes":65536}"#.to_string()),
650 ),
651 (
652 "sitegarbage.json",
653 Some(doc(&h, 65536, r#"{"func":"four","pc":1,"width":4}"#)),
654 ),
655 ];
656 let fx = Fixtures::new();
657 for (name, body) in cases {
658 let p = match body {
659 Some(b) => fx.write(name, &b),
660 None => fx.absent("definitely-absent.json"),
663 };
664 let r = ingest(&p, MODULE, 65536);
665 assert!(!r.accepted, "'{name}' must not be accepted");
666 assert!(r.offered.is_empty(), "'{name}' offered sites");
667 assert!(r.refusal.is_some(), "'{name}' refused without a reason");
668 assert!(!r.diagnostics.is_empty(), "'{name}' refused silently");
669 }
670 }
671
672 #[test]
673 fn unknown_fields_are_tolerated_901() {
674 let fx = Fixtures::new();
676 let p = fx.write(
677 "future.json",
678 &format!(
679 r#"{{ "schema": "scry/safe-accesses/v1", "scry_version": "9.9.9",
680 "module_sha256": "{}", "memory_min_bytes": 65536,
681 "brand_new_field": {{ "nested": [1,2,3] }},
682 "proven_safe": [{{"func":0,"pc":1,"op":"i32.load","width":4,
683 "confidence":"high"}}] }}"#,
684 hex_sha256(MODULE)
685 ),
686 );
687 let r = ingest(&p, MODULE, 65536);
688 assert!(r.accepted, "{:?}", r.refusal);
689 assert_eq!(r.offered.len(), 1);
690 }
691
692 #[test]
693 fn accepted_but_empty_is_diagnosed_901() {
694 let fx = Fixtures::new();
695 let p = fx.write("none.json", &doc(&hex_sha256(MODULE), 65536, ""));
696 let r = ingest(&p, MODULE, 65536);
697 assert!(r.accepted);
698 assert!(r.offered.is_empty());
699 assert!(
700 r.diagnostics
701 .iter()
702 .any(|d| d.contains("ZERO access sites")),
703 "an accepted-but-vacuous document must say so: {:?}",
704 r.diagnostics
705 );
706 }
707
708 fn ops() -> Vec<WasmOp> {
711 vec![
712 WasmOp::LocalGet(0), WasmOp::I32Load {
714 offset: 0,
715 align: 2,
716 }, WasmOp::LocalGet(0), WasmOp::I32Load8U {
719 offset: 1,
720 align: 0,
721 }, WasmOp::I32Add, WasmOp::I64Store {
724 offset: 8,
725 align: 3,
726 }, ]
728 }
729
730 #[test]
731 fn valid_sites_become_marks_901() {
732 let fx = Fixtures::new();
733 let p = fx.write(
734 "marks.json",
735 &doc(
736 &hex_sha256(MODULE),
737 65536,
738 r#"{"func":0,"pc":5,"op":"i64.store","width":8},
739 {"func":0,"pc":1,"op":"i32.load","width":4},
740 {"func":0,"pc":3,"op":"i32.load8_u","width":1}"#,
741 ),
742 );
743 let r = ingest(&p, MODULE, 65536);
744 let mut notes = Vec::new();
745 assert_eq!(r.validate_function(0, &ops(), &mut notes), vec![1, 3, 5]);
746 assert!(notes.is_empty(), "{notes:?}");
747 }
748
749 #[test]
753 fn byte_offsets_instead_of_op_indices_elide_nothing_loudly_901() {
754 let fx = Fixtures::new();
755 let p = fx.write(
756 "byteoffsets.json",
757 &doc(
758 &hex_sha256(MODULE),
759 65536,
760 r#"{"func":0,"pc":41,"op":"i32.load","width":4},
761 {"func":0,"pc":137,"op":"i32.load8_u","width":1}"#,
762 ),
763 );
764 let r = ingest(&p, MODULE, 65536);
765 assert!(
766 r.accepted,
767 "the FILE is well formed — only the keys are wrong"
768 );
769 let mut notes = Vec::new();
770 assert_eq!(
771 r.validate_function(0, &ops(), &mut notes),
772 Vec::<usize>::new()
773 );
774 assert_eq!(notes.len(), 2);
775 assert!(
776 notes.iter().all(|n| n.contains("out of range")),
777 "{notes:?}"
778 );
779 assert!(notes[0].contains("OPERATOR index"), "{notes:?}");
780 }
781
782 #[test]
783 fn non_access_operator_is_dropped_901() {
784 let fx = Fixtures::new();
785 let p = fx.write(
786 "nonaccess.json",
787 &doc(
788 &hex_sha256(MODULE),
789 65536,
790 r#"{"func":0,"pc":4,"op":"i32.load","width":4}"#,
791 ),
792 );
793 let mut notes = Vec::new();
794 let marks = ingest(&p, MODULE, 65536).validate_function(0, &ops(), &mut notes);
795 assert_eq!(marks, Vec::<usize>::new());
796 assert!(notes[0].contains("not a linear-memory access"), "{notes:?}");
797 }
798
799 #[test]
800 fn width_disagreement_is_dropped_901() {
801 let fx = Fixtures::new();
804 let p = fx.write(
805 "width.json",
806 &doc(
807 &hex_sha256(MODULE),
808 65536,
809 r#"{"func":0,"pc":3,"op":"i32.load","width":4},
810 {"func":0,"pc":1,"op":"i32.load","width":4}"#,
811 ),
812 );
813 let mut notes = Vec::new();
814 let marks = ingest(&p, MODULE, 65536).validate_function(0, &ops(), &mut notes);
815 assert_eq!(
816 marks,
817 vec![1],
818 "the sound entry survives, the skewed one does not"
819 );
820 assert_eq!(notes.len(), 1);
821 assert!(
822 notes[0].contains("disagrees with the decoded operator"),
823 "{notes:?}"
824 );
825 }
826
827 #[test]
828 fn sites_are_keyed_per_function_901() {
829 let fx = Fixtures::new();
830 let p = fx.write(
831 "perfunc.json",
832 &doc(
833 &hex_sha256(MODULE),
834 65536,
835 r#"{"func":7,"pc":1,"op":"i32.load","width":4}"#,
836 ),
837 );
838 let r = ingest(&p, MODULE, 65536);
839 let mut notes = Vec::new();
840 assert_eq!(
842 r.validate_function(0, &ops(), &mut notes),
843 Vec::<usize>::new()
844 );
845 assert!(notes.is_empty());
846 assert_eq!(r.validate_function(7, &ops(), &mut notes), vec![1]);
847 }
848
849 #[test]
852 fn access_width_covers_the_bytes_touched_not_the_value_width_901() {
853 assert_eq!(
854 access_width(&WasmOp::I64Load32U {
855 offset: 0,
856 align: 2
857 }),
858 Some(4)
859 );
860 assert_eq!(
861 access_width(&WasmOp::I64Store8 {
862 offset: 0,
863 align: 0
864 }),
865 Some(1)
866 );
867 assert_eq!(
868 access_width(&WasmOp::I32Load16S {
869 offset: 0,
870 align: 1
871 }),
872 Some(2)
873 );
874 assert_eq!(
875 access_width(&WasmOp::F64Load {
876 offset: 0,
877 align: 3
878 }),
879 Some(8)
880 );
881 assert_eq!(access_width(&WasmOp::I32Add), None);
882 assert_eq!(access_width(&WasmOp::LocalGet(0)), None);
883 }
884
885 #[test]
892 fn fixture_dirs_are_unique_per_run_1309() {
893 let a = Fixtures::new();
894 let b = Fixtures::new();
895 assert_ne!(
896 a.0, b.0,
897 "two runs must not share a fixture directory — that is #1309"
898 );
899
900 let pid = std::process::id().to_string();
904 for d in [&a.0, &b.0] {
905 let name = d.file_name().unwrap().to_string_lossy().to_string();
906 assert!(
907 name.contains(&pid),
908 "fixture dir {name} does not carry the pid, so another process \
909 can collide with it"
910 );
911 }
912
913 let pa = a.write("same-name.json", "a");
915 let pb = b.write("same-name.json", "b");
916 assert_ne!(pa, pb);
917 assert_eq!(std::fs::read_to_string(&pa).unwrap(), "a");
918 assert_eq!(std::fs::read_to_string(&pb).unwrap(), "b");
919 }
920
921 #[test]
922 fn fixture_dir_is_removed_on_drop_1309() {
923 let path = {
924 let fx = Fixtures::new();
925 fx.write("leftover.json", "{}");
926 fx.0.clone()
927 };
928 assert!(
929 !path.exists(),
930 "the fixture directory must clean up after itself: {} survived",
931 path.display()
932 );
933 }
934
935 #[test]
938 fn attestation_sidecar_path_mirrors_the_safety_manifest_901() {
939 assert_eq!(
940 ElisionAttestation::sidecar_path(Path::new("/tmp/foo.elf")),
941 PathBuf::from("/tmp/foo.proven-safe-elisions.json")
942 );
943 assert_eq!(
944 ElisionAttestation::sidecar_path(Path::new("out")),
945 PathBuf::from("out.proven-safe-elisions.json")
946 );
947 }
948
949 #[test]
950 fn refusal_is_attested_not_hidden_901() {
951 let a = ElisionAttestation {
952 schema: ELISION_ATTESTATION_SCHEMA.to_string(),
953 synth_version: "0.55.0".to_string(),
954 scry_version: "3.2.4".to_string(),
955 module_sha256: "aa".repeat(32),
956 declared_module_sha256: "bb".repeat(32),
957 memory_min_bytes: Some(65536),
958 declared_memory_min_bytes: 65536,
959 safety_bounds: "software".to_string(),
960 accepted: false,
961 refusal: Some("module_sha256 mismatch".to_string()),
962 sites_offered: 8,
963 sites_elided: 0,
964 sites_not_elided: 8,
965 elisions: Vec::new(),
966 diagnostics: vec!["refused".to_string()],
967 };
968 let json = a.to_json();
969 assert!(json.contains("\"accepted\": false"));
971 assert!(json.contains("module_sha256 mismatch"));
972 assert!(json.contains("\"sites_offered\": 8"));
973 assert!(json.contains("\"sites_elided\": 0"));
974 let back: ElisionAttestation = serde_json::from_str(&json).expect("round-trips");
975 assert_eq!(back, a);
976 }
977
978 #[test]
985 fn no_floor_attests_null_not_zero_rq57() {
986 let a = ElisionAttestation {
987 schema: ELISION_ATTESTATION_SCHEMA.to_string(),
988 synth_version: "0.56.1".to_string(),
989 scry_version: "3.2.4".to_string(),
990 module_sha256: "aa".repeat(32),
991 declared_module_sha256: "aa".repeat(32),
992 memory_min_bytes: None,
993 declared_memory_min_bytes: 65536,
994 safety_bounds: "software".to_string(),
995 accepted: false,
996 refusal: Some("no memory floor can be established".to_string()),
997 sites_offered: 1,
998 sites_elided: 0,
999 sites_not_elided: 1,
1000 elisions: Vec::new(),
1001 diagnostics: Vec::new(),
1002 };
1003 let json = a.to_json();
1004 assert!(
1005 json.contains("\"memory_min_bytes\": null"),
1006 "absence must be an explicit null, got:\n{json}"
1007 );
1008 assert!(
1009 !json.contains("\"memory_min_bytes\": 0"),
1010 "the invented-0 floor must be unrepresentable, got:\n{json}"
1011 );
1012 let back: ElisionAttestation = serde_json::from_str(&json).expect("round-trips");
1013 assert_eq!(back, a);
1014 }
1015}