1use serde::Deserialize;
4use serde::Serialize;
5
6#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
12#[serde(rename_all = "snake_case")]
13pub enum Verdict {
14 Matched,
16 Diverged,
18 NoResult,
20 InfrastructureError,
24}
25
26impl std::fmt::Display for Verdict {
27 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
28 formatter.write_str(match self {
29 Self::Matched => "matched",
30 Self::Diverged => "diverged",
31 Self::NoResult => "no_result",
32 Self::InfrastructureError => "infrastructure_error",
33 })
34 }
35}
36
37#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
48#[serde(rename_all = "snake_case", tag = "kind")]
49pub enum NoResultReason {
50 NotRun,
53 FirstRunRejected {
55 exit_code: Option<i32>,
56 signal: Option<i32>,
57 stdout_bytes: u64,
58 stderr_bytes: u64,
59 },
60 ComparisonRefused { detail: String },
62 ContainerFailed(ContainerFailure),
65 RunFailed {
68 run: VerificationRun,
69 failure: RunFailure,
70 detail: String,
71 },
72}
73
74#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
81#[serde(rename_all = "snake_case")]
82pub enum RunFailure {
83 BackendFailure,
86 DeterminismLoss,
88 PolicyRefusal,
91 Deadlock,
94}
95
96impl RunFailure {
97 pub fn as_str(self) -> &'static str {
98 match self {
99 Self::BackendFailure => "backend_failure",
100 Self::DeterminismLoss => "determinism_loss",
101 Self::PolicyRefusal => "policy_refusal",
102 Self::Deadlock => "deadlock",
103 }
104 }
105}
106
107#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
108#[serde(rename_all = "snake_case")]
109pub enum VerificationRun {
110 Run1,
111 Run2,
112}
113
114#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
115#[serde(rename_all = "snake_case", tag = "kind", deny_unknown_fields)]
116pub enum ContainerDisposition {
117 Exited {
119 code: i32,
120 },
121 Signaled {
122 signal: i32,
123 core_dumped: bool,
124 },
125}
126
127#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
128#[serde(deny_unknown_fields)]
129pub struct ContainerFailure {
130 pub run: VerificationRun,
131 pub disposition: ContainerDisposition,
132}
133
134impl ContainerFailure {
135 pub fn require_reporter_exit(
138 &self,
139 status: Option<i32>,
140 signal: Option<i32>,
141 timed_out: bool,
142 ) -> Result<(), String> {
143 if status == Some(125) && signal.is_none() && !timed_out {
144 Ok(())
145 } else {
146 Err(format!(
147 "container_failed contradicts Hermit process disposition: status={status:?} signal={signal:?} timed_out={timed_out} (expected exit 125 without timeout)"
148 ))
149 }
150 }
151}
152
153#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
155#[serde(rename_all = "snake_case", tag = "kind")]
156pub enum InfrastructureError {
157 SkidOvershoot { count: u64 },
161 HostInputChanged {
169 run: VerificationRun,
170 path: String,
171 before: detcore_model::host_input::HostFileIdentity,
172 after: detcore_model::host_input::HostFileIdentity,
173 },
174}
175
176impl std::fmt::Display for InfrastructureError {
177 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
178 match self {
179 Self::SkidOvershoot { count } => {
180 write!(f, "{count} precise-timer skid overshoot(s)")
181 }
182 Self::HostInputChanged {
183 run,
184 path,
185 before,
186 after,
187 } => write!(
188 f,
189 "host input changed during run {}: {path} ({before} -> {after})",
190 match run {
191 VerificationRun::Run1 => 1,
192 VerificationRun::Run2 => 2,
193 }
194 ),
195 }
196 }
197}
198
199#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
200pub struct RuntimeStats {
201 pub scheduler_turns: u64,
202 pub virtual_nanoseconds: u64,
203 #[serde(default, skip_serializing_if = "Option::is_none")]
204 pub syscalls: Option<u64>,
205 #[serde(default, skip_serializing_if = "Option::is_none")]
208 pub dispatch: Option<detcore_model::summary::DispatchStats>,
209 #[serde(default, skip_serializing_if = "Option::is_none")]
214 pub syscall_histogram: Option<detcore_model::syscall_histogram::CompactSyscallHistogram>,
215}
216
217#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
218pub struct VerificationRuntime {
219 #[serde(default, skip_serializing_if = "Option::is_none")]
220 pub run1: Option<RuntimeStats>,
221 #[serde(default, skip_serializing_if = "Option::is_none")]
222 pub run2: Option<RuntimeStats>,
223}
224
225#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
226pub struct DbtCountedBranchComparison {
227 pub left: u64,
228 pub right: u64,
229}
230
231impl DbtCountedBranchComparison {
232 #[allow(dead_code)] pub fn matched(self) -> bool {
234 self.left == self.right
235 }
236}
237
238#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
239#[serde(rename_all = "snake_case")]
240pub enum LogCompareStrictness {
241 Stripped,
242 Canonical,
243}
244
245impl std::fmt::Display for LogCompareStrictness {
246 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
247 formatter.write_str(match self {
248 Self::Stripped => "stripped",
249 Self::Canonical => "canonical",
250 })
251 }
252}
253
254#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
255#[serde(rename_all = "snake_case")]
256pub enum ComparedLogScope {
257 Deterministic,
258 Info,
259 FullTrace,
260}
261
262#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
263#[serde(rename_all = "snake_case")]
264pub enum RecordEnvelopeReport {
265 AllRecordsV1,
266 DbtEvidenceTransportV1,
267 CrossBackendDetcoreV1,
268 CallerDefined,
269}
270
271impl RecordEnvelopeReport {
272 pub fn is_canonical(self) -> bool {
273 matches!(self, Self::AllRecordsV1 | Self::DbtEvidenceTransportV1)
274 }
275
276 pub fn as_str(self) -> &'static str {
277 match self {
278 Self::AllRecordsV1 => "all_records_v1",
279 Self::DbtEvidenceTransportV1 => "dbt_evidence_transport_v1",
280 Self::CrossBackendDetcoreV1 => "cross_backend_detcore_v1",
281 Self::CallerDefined => "caller_defined",
282 }
283 }
284}
285
286#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
292pub struct ComparisonReport {
293 pub strictness: LogCompareStrictness,
294 #[serde(default, skip_serializing_if = "Option::is_none")]
295 pub display_name: Option<String>,
296 pub compare_logs: bool,
297 #[serde(default, skip_serializing_if = "Option::is_none")]
298 pub compare_io_buffers: Option<bool>,
299 #[serde(default, skip_serializing_if = "Option::is_none")]
300 pub log_scope: Option<ComparedLogScope>,
301 pub record_envelope: RecordEnvelopeReport,
302 #[serde(default, skip_serializing_if = "Option::is_none")]
303 pub virtualize_time: Option<bool>,
304 #[serde(default, skip_serializing_if = "Option::is_none")]
305 pub strip_lines: Option<bool>,
306 #[serde(default, skip_serializing_if = "Option::is_none")]
307 pub canonicalize_addresses: Option<bool>,
308 #[serde(default, skip_serializing_if = "Option::is_none")]
309 pub full_trace: Option<bool>,
310 #[serde(default, skip_serializing_if = "Option::is_none")]
311 pub exact_remainder: Option<bool>,
312 #[serde(default, skip_serializing_if = "Option::is_none")]
313 pub stripped_prefixes: Option<Vec<String>>,
314 #[serde(default, skip_serializing_if = "Option::is_none")]
315 pub canonicalizations: Option<Vec<String>>,
316 #[serde(default, skip_serializing_if = "Option::is_none")]
317 pub ignore_lines: Option<bool>,
318 #[serde(default, skip_serializing_if = "Option::is_none")]
319 pub skip_commit: Option<bool>,
320 #[serde(default, skip_serializing_if = "Option::is_none")]
321 pub skip_detlog: Option<bool>,
322}
323
324#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
325pub struct ComparedLogMessages {
326 pub left: u64,
327 pub right: u64,
328}
329
330#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
336#[serde(deny_unknown_fields)]
337pub struct ComparedOutput {
338 pub exit_code: Option<i32>,
339 pub signal: Option<i32>,
340 pub stdout_sha256: String,
341 pub stdout_bytes: u64,
342 pub stderr_sha256: String,
343 pub stderr_bytes: u64,
344}
345
346#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
347#[serde(deny_unknown_fields)]
348pub struct ComparedOutputs {
349 pub left: ComparedOutput,
350 pub right: ComparedOutput,
351}
352
353impl ComparedOutputs {
354 pub fn require_exact_match(&self) -> Result<(), String> {
355 if self.left == self.right {
356 Ok(())
357 } else {
358 Err("verification operands differ in status, stdout, or stderr".into())
359 }
360 }
361}
362
363#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
369pub struct VerificationReport {
370 pub verified: bool,
371 pub bitwise_parity: bool,
372 pub verdict: Verdict,
373 #[serde(default)]
374 pub no_result_reason: Option<NoResultReason>,
375 #[serde(default)]
376 pub infrastructure_error: Option<InfrastructureError>,
377 #[serde(deserialize_with = "present_but_nullable_comparison")]
384 pub comparison: Option<ComparisonReport>,
385 pub compared_log_messages: Option<ComparedLogMessages>,
386 #[serde(default, skip_serializing_if = "Option::is_none")]
389 pub compared_outputs: Option<ComparedOutputs>,
390 #[serde(default, skip_serializing_if = "Option::is_none")]
391 pub dbt_counted_branches: Option<DbtCountedBranchComparison>,
392 #[serde(default, skip_serializing_if = "Option::is_none")]
395 pub runtime: Option<VerificationRuntime>,
396 #[serde(default)]
401 pub guest_exit_code: Option<i32>,
402 #[serde(default)]
405 pub guest_signal: Option<i32>,
406 #[serde(default)]
419 pub first_divergent_scheduler_turn: Option<u64>,
420 #[serde(default)]
428 pub first_divergent_virtual_nanoseconds: Option<u64>,
429 #[serde(default)]
439 pub first_divergent_record: Option<u64>,
440 #[serde(default)]
449 pub first_divergent_syscall: Option<u64>,
450 #[serde(default)]
454 pub first_divergent_left_message: Option<String>,
455 #[serde(default)]
457 pub first_divergent_right_message: Option<String>,
458 #[serde(default, skip_serializing_if = "Option::is_none")]
466 pub exact_branch_counter: Option<BranchCounterVerdict>,
467}
468
469#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
472#[serde(deny_unknown_fields)]
473pub struct BranchCounterVerdict {
474 pub present: bool,
475 pub evidence: String,
476}
477
478fn present_but_nullable_comparison<'de, D>(
483 deserializer: D,
484) -> Result<Option<ComparisonReport>, D::Error>
485where
486 D: serde::Deserializer<'de>,
487{
488 Option::<ComparisonReport>::deserialize(deserializer)
489}
490
491impl VerificationReport {
492 #[allow(dead_code)] pub fn no_result() -> Self {
494 Self {
495 verified: false,
496 bitwise_parity: false,
497 verdict: Verdict::NoResult,
498 no_result_reason: Some(NoResultReason::NotRun),
499 infrastructure_error: None,
500 comparison: None,
501 compared_log_messages: None,
502 compared_outputs: None,
503 dbt_counted_branches: None,
504 runtime: None,
505 guest_exit_code: None,
506 guest_signal: None,
507 first_divergent_scheduler_turn: None,
508 first_divergent_virtual_nanoseconds: None,
509 first_divergent_record: None,
510 first_divergent_syscall: None,
511 first_divergent_left_message: None,
512 first_divergent_right_message: None,
513 exact_branch_counter: None,
514 }
515 }
516
517 fn require_consistent_outcome_fields(self) -> Result<Self, String> {
522 if let Some(NoResultReason::RunFailed { detail, .. }) = &self.no_result_reason {
523 if detail.trim().is_empty() {
524 return Err("incomplete verification report: run_failed omitted its detail".into());
525 }
526 if self.verdict != Verdict::NoResult
529 || self.verified
530 || self.bitwise_parity
531 || self.infrastructure_error.is_some()
532 || self.comparison.is_some()
533 || self.compared_log_messages.is_some()
534 || self.compared_outputs.is_some()
535 || self.dbt_counted_branches.is_some()
536 || self.runtime.is_some()
537 || self.guest_exit_code.is_some()
538 || self.guest_signal.is_some()
539 || self.first_divergent_scheduler_turn.is_some()
540 || self.first_divergent_virtual_nanoseconds.is_some()
541 || self.first_divergent_record.is_some()
542 || self.first_divergent_syscall.is_some()
543 || self.first_divergent_left_message.is_some()
544 || self.first_divergent_right_message.is_some()
545 {
546 return Err(
547 "run_failed contradicts its no-comparison, unknown-guest outcome".into(),
548 );
549 }
550 }
551 if let Some(NoResultReason::ContainerFailed(failure)) = &self.no_result_reason {
552 let valid_status = match failure.disposition {
553 ContainerDisposition::Exited { code } => {
557 const DEADLINE_EXIT: i32 = 124;
560 (0..=255).contains(&code)
561 && code != detcore_model::HERMIT_POLICY_REFUSAL_EXIT
562 && code != detcore_model::HERMIT_GUEST_DEADLOCK_EXIT
563 && code != DEADLINE_EXIT
564 && detcore_model::signal_from_exit_status(code).is_none()
565 }
566 ContainerDisposition::Signaled { signal, .. } => (1..=64).contains(&signal),
567 };
568 if !valid_status {
569 return Err("invalid container_failed disposition".into());
570 }
571 if self.verdict != Verdict::NoResult
574 || self.verified
575 || self.bitwise_parity
576 || self.infrastructure_error.is_some()
577 || self.comparison.is_some()
578 || self.compared_log_messages.is_some()
579 || self.compared_outputs.is_some()
580 || self.dbt_counted_branches.is_some()
581 || self.runtime.is_some()
582 || self.guest_exit_code.is_some()
583 || self.guest_signal.is_some()
584 || self.first_divergent_scheduler_turn.is_some()
585 || self.first_divergent_virtual_nanoseconds.is_some()
586 || self.first_divergent_record.is_some()
587 || self.first_divergent_syscall.is_some()
588 || self.first_divergent_left_message.is_some()
589 || self.first_divergent_right_message.is_some()
590 {
591 return Err(
592 "container_failed contradicts its no-comparison, unknown-guest outcome".into(),
593 );
594 }
595 }
596 if self.comparison.is_none()
597 && !matches!(
598 self.verdict,
599 Verdict::NoResult | Verdict::InfrastructureError
600 )
601 {
602 return Err(format!(
603 "incomplete verification report: comparison is null but verdict is {}; null is legal only for no_result or infrastructure_error",
604 self.verdict
605 ));
606 }
607 match (&self.verdict, &self.infrastructure_error) {
608 (Verdict::InfrastructureError, Some(InfrastructureError::SkidOvershoot { count }))
609 if *count > 0 => {}
610 (Verdict::InfrastructureError, Some(InfrastructureError::SkidOvershoot { .. })) => {
611 return Err(
612 "incomplete verification report: skid_overshoot count must be positive".into(),
613 );
614 }
615 (Verdict::InfrastructureError, Some(InfrastructureError::HostInputChanged { .. }))
618 if self.comparison.is_some() => {}
619 (Verdict::InfrastructureError, Some(InfrastructureError::HostInputChanged { .. })) => {
620 return Err(
621 "incomplete verification report: host_input_changed requires the comparison that found the divergence"
622 .into(),
623 );
624 }
625 (Verdict::InfrastructureError, None) => {
626 return Err(
627 "incomplete verification report: infrastructure_error verdict omitted infrastructure_error"
628 .into(),
629 );
630 }
631 (_, Some(_)) => {
632 return Err(format!(
633 "inconsistent verification report: verdict={} carries infrastructure_error",
634 self.verdict
635 ));
636 }
637 (_, None) => {}
638 }
639 Ok(self)
640 }
641
642 #[allow(dead_code)] pub fn from_json_slice(bytes: &[u8]) -> Result<Self, String> {
646 serde_json::from_slice::<Self>(bytes)
647 .map_err(|error| format!("incomplete verification report: {error}"))?
648 .require_consistent_outcome_fields()
649 }
650
651 #[allow(dead_code)] pub fn from_json_value(value: serde_json::Value) -> Result<Self, String> {
653 serde_json::from_value::<Self>(value)
654 .map_err(|error| format!("incomplete verification report: {error}"))?
655 .require_consistent_outcome_fields()
656 }
657
658 #[allow(dead_code)] pub fn from_current_json_slice(bytes: &[u8]) -> Result<Self, String> {
663 let report = Self::from_json_slice(bytes)?;
664 let value = serde_json::from_slice(bytes)
665 .map_err(|error| format!("incomplete verification report: {error}"))?;
666 Self::from_current_json_value(value)?;
667 Ok(report)
668 }
669
670 #[allow(dead_code)] pub fn from_current_json_value(value: serde_json::Value) -> Result<Self, String> {
676 Self::from_producer_json_value(value, true)
677 }
678
679 #[allow(dead_code)] pub fn from_retained_cell_json_slice(bytes: &[u8]) -> Result<Self, String> {
685 let report = Self::from_json_slice(bytes)?;
686 let value = serde_json::from_slice(bytes)
687 .map_err(|error| format!("incomplete verification report: {error}"))?;
688 Self::from_producer_json_value(value, false)?;
689 Ok(report)
690 }
691
692 fn from_producer_json_value(
693 value: serde_json::Value,
694 require_outputs: bool,
695 ) -> Result<Self, String> {
696 let object = value
697 .as_object()
698 .ok_or_else(|| "incomplete verification report: expected an object".to_string())?;
699 for field in [
700 "verified",
701 "bitwise_parity",
702 "verdict",
703 "infrastructure_error",
704 "comparison",
705 "no_result_reason",
706 "compared_log_messages",
707 "guest_exit_code",
708 "guest_signal",
709 "first_divergent_scheduler_turn",
710 "first_divergent_virtual_nanoseconds",
711 "first_divergent_record",
712 "first_divergent_syscall",
713 "first_divergent_left_message",
714 "first_divergent_right_message",
715 ] {
716 if !object.contains_key(field) {
717 return Err(format!(
718 "incomplete verification report: missing current producer field `{field}`"
719 ));
720 }
721 }
722 let verdict = object.get("verdict").and_then(serde_json::Value::as_str);
723 if require_outputs
724 && matches!(verdict, Some("matched" | "diverged"))
725 && object
726 .get("compared_outputs")
727 .is_none_or(serde_json::Value::is_null)
728 {
729 return Err(
730 "incomplete verification report: missing current producer field `compared_outputs`"
731 .into(),
732 );
733 }
734 if let Some(comparison) = object
735 .get("comparison")
736 .and_then(serde_json::Value::as_object)
737 {
738 for field in [
739 "strictness",
740 "display_name",
741 "compare_logs",
742 "compare_io_buffers",
743 "log_scope",
744 "record_envelope",
745 "virtualize_time",
746 "strip_lines",
747 "canonicalize_addresses",
748 "full_trace",
749 "exact_remainder",
750 "stripped_prefixes",
751 "canonicalizations",
752 "ignore_lines",
753 "skip_commit",
754 "skip_detlog",
755 ] {
756 if !comparison.contains_key(field) {
757 return Err(format!(
758 "incomplete verification report: missing current comparison field `{field}`"
759 ));
760 }
761 }
762 }
763 let report = Self::from_json_value(value)?;
764 if report.verdict == Verdict::Matched && report.compared_outputs.is_some() {
768 report.require_exact_output_match()?;
769 }
770 if report.verdict == Verdict::NoResult && report.compared_outputs.is_some() {
771 return Err(
772 "inconsistent verification report: no_result carries compared_outputs".into(),
773 );
774 }
775 match (&report.verdict, &report.no_result_reason) {
776 (Verdict::NoResult, Some(NoResultReason::ComparisonRefused { detail }))
777 if detail.trim().is_empty() =>
778 {
779 Err("incomplete verification report: comparison_refused omitted its detail".into())
780 }
781 (Verdict::NoResult, Some(_)) => Ok(report),
782 (Verdict::NoResult, None) => Ok(report),
783 (_, Some(_)) => Err(format!(
784 "inconsistent verification report: verdict={} carries no_result_reason",
785 report.verdict
786 )),
787 (_, None) => Ok(report),
788 }
789 }
790
791 pub fn require_canonical_comparison(&self) -> Result<(), String> {
796 let counts = self.compared_log_messages.as_ref();
797 let Some(comparison) = self.comparison.as_ref() else {
802 return Err(format!(
803 "verification recorded no comparison at all (verdict={}), so there is no canonical INFO evidence to admit",
804 self.verdict
805 ));
806 };
807 if comparison.strictness == LogCompareStrictness::Canonical
808 && comparison.compare_logs
809 && comparison.record_envelope.is_canonical()
810 && counts.is_some_and(|counts| counts.left > 0 && counts.right > 0)
811 {
812 Ok(())
813 } else {
814 Err(format!(
815 "verification did not compare canonical non-vacuous INFO evidence: strictness={} compare_logs={} record_envelope={} messages={}/{}",
816 comparison.strictness,
817 comparison.compare_logs,
818 comparison.record_envelope.as_str(),
819 counts.map_or(0, |counts| counts.left),
820 counts.map_or(0, |counts| counts.right),
821 ))
822 }
823 }
824
825 pub fn require_exact_output_match(&self) -> Result<(), String> {
828 let outputs = self
829 .compared_outputs
830 .as_ref()
831 .ok_or("same-backend verification omitted exact output evidence")?;
832 outputs.require_exact_match()?;
833 let output = &outputs.left;
834 for (field, digest) in [
835 ("stdout_sha256", output.stdout_sha256.as_str()),
836 ("stderr_sha256", output.stderr_sha256.as_str()),
837 ] {
838 if digest.len() != 64
839 || !digest
840 .bytes()
841 .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
842 {
843 return Err(format!("{field} is not a SHA-256 digest"));
844 }
845 }
846 if output.exit_code.is_some() == output.signal.is_some() {
847 return Err(
848 "output must record exactly one guest disposition (exit code or signal)".into(),
849 );
850 }
851 if self.guest_exit_code != output.exit_code || self.guest_signal != output.signal {
852 return Err("output disposition contradicts its verification result".into());
853 }
854 Ok(())
855 }
856
857 pub fn require_canonical_match(&self) -> Result<(), String> {
861 self.require_canonical_comparison()?;
862 if self.compared_outputs.is_some() {
863 self.require_exact_output_match()?;
864 }
865 if self.verified && self.verdict == Verdict::Matched && self.bitwise_parity {
866 Ok(())
867 } else {
868 Err(format!(
869 "canonical verification did not match: verified={} verdict={} bitwise_parity={}",
870 self.verified, self.verdict, self.bitwise_parity
871 ))
872 }
873 }
874}
875
876#[cfg(test)]
877mod tests {
878 use super::*;
879
880 #[test]
881 fn the_exact_branch_counter_verdict_is_optional_and_strictly_typed() {
882 let report = VerificationReport::no_result();
885 let bytes = serde_json::to_vec(&report).unwrap();
886 let value: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
887 assert!(value.get("exact_branch_counter").is_none(), "{value}");
888 assert_eq!(
889 VerificationReport::from_current_json_slice(&bytes)
890 .unwrap()
891 .exact_branch_counter,
892 None
893 );
894 let mut report = VerificationReport::no_result();
896 report.exact_branch_counter = Some(BranchCounterVerdict {
897 present: false,
898 evidence: "fixture CPU: Reverie performance-counter validation failed \
899 (AmdSpecLockMapShouldBeDisabled)"
900 .into(),
901 });
902 let bytes = serde_json::to_vec(&report).unwrap();
903 assert_eq!(
904 VerificationReport::from_current_json_slice(&bytes)
905 .unwrap()
906 .exact_branch_counter,
907 report.exact_branch_counter
908 );
909 let mut value: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
911 value["exact_branch_counter"]["measured_counts"] = serde_json::json!(91);
912 assert!(VerificationReport::from_json_slice(value.to_string().as_bytes()).is_err());
913 }
914
915 fn failed_container_report(
916 run: VerificationRun,
917 disposition: ContainerDisposition,
918 ) -> VerificationReport {
919 let mut report = VerificationReport::no_result();
920 report.no_result_reason = Some(NoResultReason::ContainerFailed(ContainerFailure {
921 run,
922 disposition,
923 }));
924 report
925 }
926
927 #[test]
928 fn run_failure_round_trips_and_names_no_guest_disposition() {
929 for run in [VerificationRun::Run1, VerificationRun::Run2] {
930 for failure in [
931 RunFailure::BackendFailure,
932 RunFailure::DeterminismLoss,
933 RunFailure::PolicyRefusal,
934 RunFailure::Deadlock,
935 ] {
936 let mut report = VerificationReport::no_result();
937 report.no_result_reason = Some(NoResultReason::RunFailed {
938 run,
939 failure,
940 detail: "drrun started and the DBT run then failed".into(),
941 });
942 let bytes = serde_json::to_vec(&report).unwrap();
943 let value: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
944 assert_eq!(value["no_result_reason"]["kind"], "run_failed");
945 assert_eq!(value["no_result_reason"]["failure"], failure.as_str());
946 assert_eq!(
947 VerificationReport::from_current_json_slice(&bytes).unwrap(),
948 report
949 );
950 assert!(report.require_canonical_comparison().is_err());
951
952 let mut guest = report.clone();
953 guest.guest_exit_code = Some(0);
954 let bytes = serde_json::to_vec(&guest).unwrap();
955 assert!(VerificationReport::from_current_json_slice(&bytes).is_err());
956
957 let mut blank = report.clone();
958 blank.no_result_reason = Some(NoResultReason::RunFailed {
959 run,
960 failure,
961 detail: " ".into(),
962 });
963 let bytes = serde_json::to_vec(&blank).unwrap();
964 assert!(VerificationReport::from_current_json_slice(&bytes).is_err());
965 }
966 }
967 }
968
969 #[test]
970 fn container_failure_requires_exact_actor_and_absent_comparison_fields() {
971 for run in [VerificationRun::Run1, VerificationRun::Run2] {
972 for disposition in [
973 ContainerDisposition::Exited { code: 0 },
974 ContainerDisposition::Exited { code: 101 },
975 ContainerDisposition::Signaled {
976 signal: 14,
977 core_dumped: false,
978 },
979 ContainerDisposition::Signaled {
980 signal: 11,
981 core_dumped: true,
982 },
983 ] {
984 let report = failed_container_report(run, disposition);
985 let bytes = serde_json::to_vec(&report).unwrap();
986 assert_eq!(
987 VerificationReport::from_current_json_slice(&bytes).unwrap(),
988 report
989 );
990 assert!(report.require_canonical_comparison().is_err());
991 assert!(report.require_canonical_match().is_err());
992 let NoResultReason::ContainerFailed(failure) =
993 report.no_result_reason.as_ref().unwrap()
994 else {
995 unreachable!()
996 };
997 failure
998 .require_reporter_exit(Some(125), None, false)
999 .unwrap();
1000 for (status, signal, timeout) in [
1001 (Some(0), None, false),
1002 (Some(122), None, false),
1003 (None, Some(14), false),
1004 (Some(125), None, true),
1005 ] {
1006 assert!(
1007 failure
1008 .require_reporter_exit(status, signal, timeout)
1009 .is_err()
1010 );
1011 }
1012 let value = serde_json::to_value(&report).unwrap();
1013 for (field, invented) in [
1014 ("verified", serde_json::json!(true)),
1015 ("bitwise_parity", serde_json::json!(true)),
1016 ("verdict", serde_json::json!("matched")),
1017 ("guest_exit_code", serde_json::json!(0)),
1018 ("guest_signal", serde_json::json!(14)),
1019 (
1020 "runtime",
1021 serde_json::json!({"run1":{"scheduler_turns":1,"virtual_nanoseconds":2}}),
1022 ),
1023 (
1024 "compared_log_messages",
1025 serde_json::json!({"left":1,"right":1}),
1026 ),
1027 ("first_divergent_record", serde_json::json!(1)),
1028 ("first_divergent_syscall", serde_json::json!(1)),
1029 ("first_divergent_scheduler_turn", serde_json::json!(1)),
1030 ("first_divergent_virtual_nanoseconds", serde_json::json!(1)),
1031 (
1032 "first_divergent_left_message",
1033 serde_json::json!("invented"),
1034 ),
1035 (
1036 "first_divergent_right_message",
1037 serde_json::json!("invented"),
1038 ),
1039 (
1040 "dbt_counted_branches",
1041 serde_json::json!({"left":1,"right":1}),
1042 ),
1043 (
1044 "infrastructure_error",
1045 serde_json::json!({"kind":"skid_overshoot","count":1}),
1046 ),
1047 ] {
1048 let mut bad = value.clone();
1049 bad[field] = invented;
1050 let bytes = serde_json::to_vec(&bad).unwrap();
1051 assert!(
1052 VerificationReport::from_current_json_slice(&bytes).is_err(),
1053 "{field}"
1054 );
1055 assert!(
1056 VerificationReport::from_retained_cell_json_slice(&bytes).is_err(),
1057 "{field}"
1058 );
1059 }
1060 }
1061 }
1062 }
1063
1064 #[test]
1065 fn container_failure_rejects_malformed_duplicate_and_reserved_dispositions() {
1066 let report = failed_container_report(
1067 VerificationRun::Run1,
1068 ContainerDisposition::Exited { code: 0 },
1069 );
1070 let value = serde_json::to_value(report).unwrap();
1071 for code in [-1, 121, 122, 124, 129, 142, 192, 256] {
1072 let mut bad = value.clone();
1073 bad["no_result_reason"]["disposition"]["code"] = code.into();
1074 assert!(
1075 VerificationReport::from_current_json_value(bad).is_err(),
1076 "{code}"
1077 );
1078 }
1079 for replacement in [
1080 serde_json::json!({"kind":"container_failed","disposition":{"kind":"exited","code":1}}),
1081 serde_json::json!({"kind":"container_failed","run":"run3","disposition":{"kind":"exited","code":1}}),
1082 serde_json::json!({"kind":"container_failed","run":"run1","disposition":{"kind":"exited","code":1,"signal":14}}),
1083 serde_json::json!({"kind":"container_failed","run":"run1","disposition":{"kind":"signaled","signal":14}}),
1084 serde_json::json!({"kind":"container_failed","run":"run1","disposition":{"kind":"signaled","signal":0,"core_dumped":false}}),
1085 serde_json::json!({"kind":"container_failed","run":"run1","disposition":{"kind":"signaled","signal":65,"core_dumped":false}}),
1086 serde_json::json!({"kind":"container_failed","run":"run1","extra":true,"disposition":{"kind":"exited","code":1}}),
1087 ] {
1088 let mut bad = value.clone();
1089 bad["no_result_reason"] = replacement;
1090 assert!(VerificationReport::from_current_json_value(bad).is_err());
1091 }
1092 let raw = serde_json::to_string(&value).unwrap();
1093 for (needle, replacement) in [
1094 (r#""run":"run1""#, r#""run":"run1","run":"run2""#),
1095 (r#""code":0"#, r#""code":0,"code":0"#),
1096 ] {
1097 assert!(raw.contains(needle));
1098 let bad = raw.replacen(needle, replacement, 1);
1099 assert!(
1100 VerificationReport::from_current_json_slice(bad.as_bytes())
1101 .unwrap_err()
1102 .contains("duplicate field")
1103 );
1104 }
1105 }
1106
1107 #[test]
1112 fn divergence_position_survives_the_parse() {
1113 let json = br#"{"verified":false,"bitwise_parity":false,"verdict":"diverged",
1114 "comparison":{"strictness":"canonical","compare_logs":true,"record_envelope":"all_records_v1"},"compared_log_messages":{"left":180,"right":180},
1115 "first_divergent_scheduler_turn":4,
1116 "first_divergent_virtual_nanoseconds":1767225600002825515,
1117 "first_divergent_record":108,
1118 "first_divergent_syscall":37,
1119 "first_divergent_left_message":"INFO detcore: left event",
1120 "first_divergent_right_message":"INFO detcore: right event"}"#;
1121 let report = VerificationReport::from_json_slice(json).expect("diverged report parses");
1122 assert_eq!(report.first_divergent_scheduler_turn, Some(4));
1123 assert_eq!(
1124 report.first_divergent_virtual_nanoseconds,
1125 Some(1_767_225_600_002_825_515)
1126 );
1127 assert_eq!(report.first_divergent_record, Some(108));
1128 assert_eq!(report.first_divergent_syscall, Some(37));
1134 assert_eq!(
1135 report.first_divergent_left_message.as_deref(),
1136 Some("INFO detcore: left event")
1137 );
1138 assert_eq!(
1139 report.first_divergent_right_message.as_deref(),
1140 Some("INFO detcore: right event")
1141 );
1142 }
1143
1144 #[test]
1149 fn a_located_record_does_not_require_its_bounding_siblings() {
1150 let json = br#"{"verified":false,"bitwise_parity":false,"verdict":"diverged",
1151 "comparison":{"strictness":"canonical","compare_logs":true,"record_envelope":"all_records_v1"},
1152 "compared_log_messages":{"left":180,"right":180},
1153 "first_divergent_record":3}"#;
1154 let report = VerificationReport::from_json_slice(json).expect("report parses");
1155 assert_eq!(report.first_divergent_record, Some(3));
1156 assert_eq!(report.first_divergent_scheduler_turn, None);
1157 assert_eq!(report.first_divergent_virtual_nanoseconds, None);
1158 assert_eq!(report.first_divergent_left_message, None);
1159 assert_eq!(report.first_divergent_right_message, None);
1160 assert_eq!(report.first_divergent_syscall, None);
1161 }
1162
1163 #[test]
1170 fn an_earlier_divergence_reports_a_smaller_position() {
1171 let parse = |turn: u64, nanos: u64| {
1172 let json = format!(
1173 r#"{{"verified":false,"bitwise_parity":false,"verdict":"diverged",
1174 "comparison":{{"strictness":"canonical","compare_logs":true,"record_envelope":"all_records_v1"}},"compared_log_messages":{{"left":180,"right":180}},
1175 "first_divergent_scheduler_turn":{turn},
1176 "first_divergent_virtual_nanoseconds":{nanos}}}"#
1177 );
1178 VerificationReport::from_json_slice(json.as_bytes()).expect("report parses")
1179 };
1180 let late = parse(4, 1_767_225_600_002_825_515);
1181 let early = parse(1, 1_767_225_600_000_500_000);
1182 assert!(
1183 early.first_divergent_scheduler_turn < late.first_divergent_scheduler_turn,
1184 "an earlier divergence must order before a later one in scheduler turns"
1185 );
1186 assert!(
1187 early.first_divergent_virtual_nanoseconds < late.first_divergent_virtual_nanoseconds,
1188 "and in virtual nanoseconds"
1189 );
1190 }
1191
1192 #[test]
1196 fn a_report_without_the_divergence_position_still_parses() {
1197 let json = br#"{"verified":true,"bitwise_parity":true,"verdict":"matched",
1198 "comparison":{"strictness":"canonical","compare_logs":true,"record_envelope":"all_records_v1"},"compared_log_messages":{"left":180,"right":180}}"#;
1199 let report = VerificationReport::from_json_slice(json);
1200 let report = report.expect("a pre-field report is not malformed");
1201 assert_eq!(report.first_divergent_scheduler_turn, None);
1202 assert_eq!(report.first_divergent_virtual_nanoseconds, None);
1203 }
1204
1205 #[test]
1206 fn runtime_totals_survive_the_typed_report_parse() {
1207 let json = br#"{"verified":true,"bitwise_parity":true,"verdict":"matched",
1208 "comparison":{"strictness":"canonical","compare_logs":true,"record_envelope":"all_records_v1"},"compared_log_messages":{"left":180,"right":180},
1209 "runtime":{"run1":{"scheduler_turns":12,"virtual_nanoseconds":34,"syscalls":5},
1210 "run2":{"scheduler_turns":13,"virtual_nanoseconds":35,"syscalls":6}}}"#;
1211 let report = VerificationReport::from_json_slice(json).expect("runtime report parses");
1212 let runtime = report.runtime.expect("runtime retained");
1213 assert_eq!(runtime.run1.expect("run1").syscalls, Some(5));
1214 assert_eq!(runtime.run2.expect("run2").scheduler_turns, 13);
1215 }
1216
1217 fn report(
1218 strictness: LogCompareStrictness,
1219 compare_logs: bool,
1220 left: u64,
1221 right: u64,
1222 ) -> VerificationReport {
1223 VerificationReport {
1224 verified: true,
1225 bitwise_parity: true,
1226 verdict: Verdict::Matched,
1227 no_result_reason: None,
1228 infrastructure_error: None,
1229 comparison: Some(ComparisonReport {
1230 strictness,
1231 display_name: None,
1232 compare_logs,
1233 compare_io_buffers: None,
1234 log_scope: None,
1235 record_envelope: RecordEnvelopeReport::AllRecordsV1,
1236 virtualize_time: None,
1237 strip_lines: None,
1238 canonicalize_addresses: None,
1239 full_trace: None,
1240 exact_remainder: None,
1241 stripped_prefixes: None,
1242 canonicalizations: None,
1243 ignore_lines: None,
1244 skip_commit: None,
1245 skip_detlog: None,
1246 }),
1247 compared_log_messages: Some(ComparedLogMessages { left, right }),
1248 compared_outputs: None,
1249 dbt_counted_branches: None,
1250 runtime: None,
1251 guest_exit_code: None,
1252 guest_signal: None,
1253 first_divergent_scheduler_turn: None,
1254 first_divergent_virtual_nanoseconds: None,
1255 first_divergent_record: None,
1256 first_divergent_syscall: None,
1257 first_divergent_left_message: None,
1258 first_divergent_right_message: None,
1259 exact_branch_counter: None,
1260 }
1261 }
1262
1263 #[test]
1264 fn brackets_every_canonical_match_requirement() {
1265 assert!(
1266 report(LogCompareStrictness::Canonical, true, 1, 1)
1267 .require_canonical_match()
1268 .is_ok()
1269 );
1270 let mut weak = report(LogCompareStrictness::Canonical, true, 1, 1);
1271 weak.verified = false;
1272 assert!(weak.require_canonical_match().is_err());
1273 let mut weak = report(LogCompareStrictness::Canonical, true, 1, 1);
1274 weak.verdict = Verdict::Diverged;
1275 assert!(weak.require_canonical_match().is_err());
1276 let mut weak = report(LogCompareStrictness::Canonical, true, 1, 1);
1277 weak.bitwise_parity = false;
1278 assert!(weak.require_canonical_match().is_err());
1279 assert!(
1280 report(LogCompareStrictness::Stripped, true, 1, 1)
1281 .require_canonical_match()
1282 .is_err()
1283 );
1284 assert!(
1285 report(LogCompareStrictness::Canonical, false, 1, 1)
1286 .require_canonical_match()
1287 .is_err()
1288 );
1289 assert!(
1290 report(LogCompareStrictness::Canonical, true, 0, 1)
1291 .require_canonical_match()
1292 .is_err()
1293 );
1294 let mut weak = report(LogCompareStrictness::Canonical, true, 1, 1);
1295 weak.comparison.as_mut().unwrap().record_envelope = RecordEnvelopeReport::CallerDefined;
1296 assert!(weak.require_canonical_match().is_err());
1297 }
1298
1299 const REAL_NO_RESULT_REPORT: &str = concat!(
1306 r#"{"verified":false,"bitwise_parity":false,"verdict":"no_result","#,
1307 r#""comparison":null,"compared_log_messages":null,"guest_exit_code":null,"#,
1308 r#""guest_signal":null,"first_divergent_scheduler_turn":null,"#,
1309 r#""first_divergent_virtual_nanoseconds":null,"first_divergent_record":null,"#,
1310 r#""first_divergent_syscall":null}"#,
1311 );
1312
1313 #[test]
1314 fn a_real_no_result_report_parses_instead_of_reading_as_unreadable() {
1315 let parsed = VerificationReport::from_json_slice(REAL_NO_RESULT_REPORT.as_bytes())
1316 .expect("a documented no-result report must parse");
1317 assert_eq!(parsed.verdict, Verdict::NoResult);
1318 assert!(parsed.comparison.is_none());
1319 }
1320
1321 #[test]
1322 fn a_no_result_report_is_still_inadmissible_and_names_the_real_reason() {
1323 let parsed = VerificationReport::from_json_slice(REAL_NO_RESULT_REPORT.as_bytes())
1327 .expect("a documented no-result report must parse");
1328 let refusal = parsed
1329 .require_canonical_comparison()
1330 .expect_err("a report with no comparison must never be admitted");
1331 assert!(
1332 refusal.contains("recorded no comparison at all"),
1333 "refusal must name the missing comparison, got: {refusal}"
1334 );
1335 assert!(
1336 refusal.contains("no_result"),
1337 "refusal must carry the verdict, got: {refusal}"
1338 );
1339 assert!(parsed.require_canonical_match().is_err());
1340 }
1341
1342 #[test]
1343 fn a_missing_comparison_field_is_refused_rather_than_defaulting_to_none() {
1344 let absent = r#"{"verified":false,"bitwise_parity":false,"verdict":"no_result","compared_log_messages":null}"#;
1348 assert!(VerificationReport::from_json_slice(absent.as_bytes()).is_err());
1349 }
1350
1351 #[test]
1352 fn a_malformed_comparison_object_is_still_refused_at_parse() {
1353 let malformed = REAL_NO_RESULT_REPORT.replace(r#""comparison":null"#, r#""comparison":7"#);
1356 assert!(VerificationReport::from_json_slice(malformed.as_bytes()).is_err());
1357 let missing_field = REAL_NO_RESULT_REPORT.replace(
1358 r#""comparison":null"#,
1359 r#""comparison":{"compare_logs":true,"record_envelope":"all_records_v1"}"#,
1360 );
1361 assert!(VerificationReport::from_json_slice(missing_field.as_bytes()).is_err());
1362 }
1363
1364 #[test]
1365 fn comparison_record_envelope_is_required_and_unknown_values_are_refused() {
1366 let missing = serde_json::json!({
1367 "verified": true,
1368 "bitwise_parity": true,
1369 "verdict": "matched",
1370 "infrastructure_error": null,
1371 "comparison": {"strictness": "canonical", "compare_logs": true},
1372 "compared_log_messages": {"left": 1, "right": 1}
1373 });
1374 let error = VerificationReport::from_json_value(missing)
1375 .expect_err("a comparison that never states its record envelope is not admissible")
1376 .to_string();
1377 assert!(
1383 error.contains("record_envelope"),
1384 "the refusal must name the missing field so an operator can tell a producer that \
1385 predates the envelope from a corrupt report; got: {error}"
1386 );
1387
1388 let unknown = serde_json::json!({
1389 "verified": true,
1390 "bitwise_parity": true,
1391 "verdict": "matched",
1392 "infrastructure_error": null,
1393 "comparison": {
1394 "strictness": "canonical",
1395 "compare_logs": true,
1396 "record_envelope": "unknown_v9"
1397 },
1398 "compared_log_messages": {"left": 1, "right": 1}
1399 });
1400 assert!(VerificationReport::from_json_value(unknown).is_err());
1401 }
1402
1403 #[test]
1404 fn readable_object_with_unreadable_nested_predicate_is_refused() {
1405 let malformed = serde_json::json!({
1406 "verified": true,
1407 "bitwise_parity": true,
1408 "verdict": "matched",
1409 "comparison": null,
1410 "compared_log_messages": {"left": 1, "right": 1}
1411 });
1412 assert!(VerificationReport::from_json_value(malformed).is_err());
1413 }
1414
1415 #[test]
1416 fn typed_no_result_reason_round_trips_and_unknown_values_are_refused() {
1417 let current = serde_json::to_value(VerificationReport::no_result()).unwrap();
1418 let parsed = VerificationReport::from_current_json_value(current.clone())
1419 .expect("the typed not-run reason must round-trip");
1420 assert_eq!(parsed.no_result_reason, Some(NoResultReason::NotRun));
1421
1422 let mut rejected = VerificationReport::no_result();
1423 rejected.no_result_reason = Some(NoResultReason::FirstRunRejected {
1424 exit_code: Some(9),
1425 signal: None,
1426 stdout_bytes: 0,
1427 stderr_bytes: 0,
1428 });
1429 rejected.guest_exit_code = Some(9);
1430 let mut rejected_value = serde_json::to_value(&rejected).unwrap();
1431 for include_null in [false, true] {
1432 if include_null {
1433 rejected_value["compared_outputs"] = serde_json::Value::Null;
1434 } else {
1435 rejected_value
1436 .as_object_mut()
1437 .unwrap()
1438 .remove("compared_outputs");
1439 }
1440 let parsed = VerificationReport::from_current_json_slice(
1441 &serde_json::to_vec(&rejected_value).unwrap(),
1442 )
1443 .expect("a first-run rejection has no comparison output evidence");
1444 assert_eq!(parsed, rejected);
1445 }
1446 let output = ComparedOutput {
1447 exit_code: Some(9),
1448 signal: None,
1449 stdout_sha256: "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
1450 .into(),
1451 stdout_bytes: 0,
1452 stderr_sha256: "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
1453 .into(),
1454 stderr_bytes: 0,
1455 };
1456 rejected.compared_outputs = Some(ComparedOutputs {
1457 left: output.clone(),
1458 right: output,
1459 });
1460 let error =
1461 VerificationReport::from_current_json_slice(&serde_json::to_vec(&rejected).unwrap())
1462 .expect_err("a first-run rejection cannot carry comparison output evidence");
1463 assert!(
1464 error.contains("no_result") && error.contains("compared_outputs"),
1465 "{error}"
1466 );
1467
1468 let mut missing = current.clone();
1469 missing.as_object_mut().unwrap().remove("no_result_reason");
1470 let error = VerificationReport::from_current_json_value(missing)
1471 .expect_err("a current no-result must state the nullable reason field");
1472 assert!(error.contains("no_result_reason"), "{error}");
1473
1474 let mut absent = current.clone();
1475 absent["no_result_reason"] = serde_json::Value::Null;
1476 let parsed = VerificationReport::from_current_json_value(absent)
1477 .expect("explicit null records that the producer has no exact cause");
1478 assert_eq!(parsed.no_result_reason, None);
1479
1480 let mut comparator_refusal = current.clone();
1481 comparator_refusal["no_result_reason"] = serde_json::json!({
1482 "kind": "comparison_refused",
1483 "detail": "the first log was truncated"
1484 });
1485 let parsed = VerificationReport::from_current_json_value(comparator_refusal)
1486 .expect("a comparator refusal carries the producer's exact cause");
1487 assert_eq!(
1488 parsed.no_result_reason,
1489 Some(NoResultReason::ComparisonRefused {
1490 detail: "the first log was truncated".into()
1491 })
1492 );
1493
1494 let mut unknown = current;
1495 unknown["no_result_reason"] = serde_json::json!({"kind": "never_ran"});
1496 let error = VerificationReport::from_current_json_value(unknown)
1497 .expect_err("an unknown no-result reason must be refused");
1498 assert!(
1499 error.contains("unknown variant"),
1500 "refusal must identify the unknown typed reason: {error}"
1501 );
1502 }
1503
1504 #[test]
1505 fn current_comparison_requires_every_producer_field_by_name() {
1506 let mut current = serde_json::json!({
1507 "verified": true,
1508 "bitwise_parity": true,
1509 "verdict": "matched",
1510 "no_result_reason": null,
1511 "infrastructure_error": null,
1512 "comparison": {
1513 "strictness": "canonical",
1514 "display_name": "BitwiseInfoV1",
1515 "compare_logs": true,
1516 "compare_io_buffers": true,
1517 "log_scope": "info",
1518 "record_envelope": "all_records_v1",
1519 "virtualize_time": true,
1520 "strip_lines": false,
1521 "canonicalize_addresses": true,
1522 "full_trace": true,
1523 "exact_remainder": true,
1524 "stripped_prefixes": ["real-wall-clock-prefix/v1"],
1525 "canonicalizations": ["host-address-to-first-appearance-ordinal/v1"],
1526 "ignore_lines": false,
1527 "skip_commit": false,
1528 "skip_detlog": false
1529 },
1530 "compared_log_messages": {"left": 1, "right": 1},
1531 "compared_outputs": {
1532 "left": {
1533 "exit_code": 0,
1534 "signal": null,
1535 "stdout_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
1536 "stdout_bytes": 4,
1537 "stderr_sha256": "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd",
1538 "stderr_bytes": 0
1539 },
1540 "right": {
1541 "exit_code": 0,
1542 "signal": null,
1543 "stdout_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
1544 "stdout_bytes": 4,
1545 "stderr_sha256": "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd",
1546 "stderr_bytes": 0
1547 }
1548 },
1549 "guest_exit_code": 0,
1550 "guest_signal": null,
1551 "first_divergent_scheduler_turn": null,
1552 "first_divergent_virtual_nanoseconds": null,
1553 "first_divergent_record": null,
1554 "first_divergent_syscall": null,
1555 "first_divergent_left_message": null,
1556 "first_divergent_right_message": null
1557 });
1558 let parsed = VerificationReport::from_current_json_value(current.clone())
1559 .expect("the complete current producer shape must parse");
1560 let comparison = parsed.comparison.expect("matched report has comparison");
1561 assert_eq!(comparison.compare_io_buffers, Some(true));
1562 assert_eq!(comparison.log_scope, Some(ComparedLogScope::Info));
1563 assert_eq!(parsed.guest_exit_code, Some(0));
1564 let mut missing_outputs = current.clone();
1565 missing_outputs
1566 .as_object_mut()
1567 .unwrap()
1568 .remove("compared_outputs");
1569 let error = VerificationReport::from_current_json_value(missing_outputs)
1570 .expect_err("a completed current report must carry exact output evidence");
1571 assert!(error.contains("compared_outputs"), "{error}");
1572 let mut null_outputs = current.clone();
1573 null_outputs["compared_outputs"] = serde_json::Value::Null;
1574 let error = VerificationReport::from_current_json_value(null_outputs)
1575 .expect_err("null is not output evidence for a completed current report");
1576 assert!(error.contains("compared_outputs"), "{error}");
1577 current["comparison"]
1578 .as_object_mut()
1579 .unwrap()
1580 .remove("compare_io_buffers");
1581 let error = VerificationReport::from_current_json_value(current)
1582 .expect_err("current comparison missing a producer field must be refused");
1583 assert!(
1584 error.contains("compare_io_buffers"),
1585 "refusal must name the missing comparison field: {error}"
1586 );
1587 }
1588
1589 #[test]
1590 fn current_raw_reports_reject_duplicates_and_preserve_required_fields() {
1591 let mut current = serde_json::json!({
1592 "verified": true,
1593 "bitwise_parity": true,
1594 "verdict": "matched",
1595 "no_result_reason": null,
1596 "infrastructure_error": null,
1597 "comparison": {
1598 "strictness": "canonical",
1599 "display_name": "BitwiseInfoV1",
1600 "compare_logs": true,
1601 "compare_io_buffers": true,
1602 "log_scope": "info",
1603 "record_envelope": "all_records_v1",
1604 "virtualize_time": true,
1605 "strip_lines": false,
1606 "canonicalize_addresses": true,
1607 "full_trace": true,
1608 "exact_remainder": true,
1609 "stripped_prefixes": ["real-wall-clock-prefix/v1"],
1610 "canonicalizations": ["host-address-to-first-appearance-ordinal/v1"],
1611 "ignore_lines": false,
1612 "skip_commit": false,
1613 "skip_detlog": false
1614 },
1615 "compared_log_messages": {"left": 1, "right": 1},
1616 "guest_exit_code": 0,
1617 "guest_signal": null,
1618 "first_divergent_scheduler_turn": null,
1619 "first_divergent_virtual_nanoseconds": null,
1620 "first_divergent_record": null,
1621 "first_divergent_syscall": null,
1622 "first_divergent_left_message": null,
1623 "first_divergent_right_message": null
1624 });
1625 let output = serde_json::json!({
1626 "exit_code": 0, "signal": null,
1627 "stdout_sha256": "a".repeat(64), "stdout_bytes": 0,
1628 "stderr_sha256": "b".repeat(64), "stderr_bytes": 0
1629 });
1630 current["compared_outputs"] = serde_json::json!({"left": output, "right": output});
1631 for mutation in [
1632 "unequal",
1633 "bad-digest",
1634 "two-dispositions",
1635 "wrong-disposition",
1636 ] {
1637 let mut bad = current.clone();
1638 match mutation {
1639 "unequal" => {
1640 bad["compared_outputs"]["right"]["stdout_sha256"] = "c".repeat(64).into()
1641 }
1642 "bad-digest" => {
1643 for side in ["left", "right"] {
1644 bad["compared_outputs"][side]["stdout_sha256"] = "invalid".into();
1645 }
1646 }
1647 "two-dispositions" => {
1648 for side in ["left", "right"] {
1649 bad["compared_outputs"][side]["signal"] = 9.into();
1650 }
1651 }
1652 "wrong-disposition" => bad["guest_exit_code"] = 7.into(),
1653 _ => unreachable!(),
1654 }
1655 let bytes = serde_json::to_vec(&bad).unwrap();
1656 assert!(
1657 VerificationReport::from_current_json_slice(&bytes).is_err(),
1658 "{mutation}"
1659 );
1660 assert!(
1661 VerificationReport::from_retained_cell_json_slice(&bytes).is_err(),
1662 "{mutation}"
1663 );
1664 assert!(
1665 VerificationReport::from_json_slice(&bytes)
1666 .unwrap()
1667 .require_canonical_match()
1668 .is_err(),
1669 "{mutation}"
1670 );
1671 }
1672 let mut historical = current.clone();
1673 historical
1674 .as_object_mut()
1675 .unwrap()
1676 .remove("compared_outputs");
1677 let bytes = serde_json::to_vec(&historical).unwrap();
1678 VerificationReport::from_retained_cell_json_slice(&bytes)
1679 .unwrap()
1680 .require_canonical_match()
1681 .unwrap();
1682 assert!(VerificationReport::from_current_json_slice(&bytes).is_err());
1683 let mut diverged = current.clone();
1684 diverged["verdict"] = "diverged".into();
1685 diverged["verified"] = false.into();
1686 diverged["bitwise_parity"] = false.into();
1687 diverged["compared_outputs"]["right"]["stdout_sha256"] = "c".repeat(64).into();
1688 VerificationReport::from_current_json_value(diverged).unwrap();
1689 let mut relaxed = current.clone();
1690 relaxed["comparison"]["strictness"] = "stripped".into();
1691 relaxed["bitwise_parity"] = false.into();
1692 VerificationReport::from_current_json_value(relaxed).unwrap();
1693 let raw = serde_json::to_string(¤t).unwrap();
1694 VerificationReport::from_current_json_slice(raw.as_bytes())
1695 .unwrap()
1696 .require_canonical_match()
1697 .unwrap();
1698 for (needle, replacement) in [
1699 (r#""verified":true"#, r#""verified":false,"verified":true"#),
1700 (r#""verified":true"#, r#""verified":true,"verified":true"#),
1701 (
1702 r#""no_result_reason":null"#,
1703 r#""no_result_reason":{"kind":"not_run"},"no_result_reason":null"#,
1704 ),
1705 (
1706 r#""no_result_reason":null"#,
1707 r#""no_result_reason":null,"no_result_reason":null"#,
1708 ),
1709 (r#""left":1"#, r#""left":0,"left":1"#),
1710 (r#""left":1"#, r#""left":1,"left":1"#),
1711 ] {
1712 assert_eq!(raw.matches(needle).count(), 1);
1713 let duplicated = raw.replacen(needle, replacement, 1);
1714 let collapsed: serde_json::Value = serde_json::from_str(&duplicated).unwrap();
1715 assert_eq!(
1716 collapsed, current,
1717 "the last-value map loses this contradiction"
1718 );
1719 let error =
1720 VerificationReport::from_current_json_slice(duplicated.as_bytes()).unwrap_err();
1721 assert!(error.contains("duplicate field"), "{replacement}: {error}");
1722 }
1723 for field in ["no_result_reason", "guest_signal"] {
1724 let mut missing = current.clone();
1725 missing.as_object_mut().unwrap().remove(field);
1726 let error =
1727 VerificationReport::from_current_json_slice(&serde_json::to_vec(&missing).unwrap())
1728 .unwrap_err();
1729 assert!(error.contains(field), "{error}");
1730 }
1731 let mut missing = current;
1732 missing["comparison"]
1733 .as_object_mut()
1734 .unwrap()
1735 .remove("compare_io_buffers");
1736 assert!(
1737 VerificationReport::from_current_json_slice(&serde_json::to_vec(&missing).unwrap())
1738 .unwrap_err()
1739 .contains("compare_io_buffers")
1740 );
1741 let mut refused = serde_json::to_value(VerificationReport::no_result()).unwrap();
1742 refused["no_result_reason"] = serde_json::json!({"kind":"comparison_refused","detail":"the second log was truncated"});
1743 VerificationReport::from_current_json_slice(&serde_json::to_vec(&refused).unwrap())
1744 .unwrap();
1745 refused["no_result_reason"]["detail"] = serde_json::json!("");
1746 assert!(
1747 VerificationReport::from_current_json_slice(&serde_json::to_vec(&refused).unwrap())
1748 .unwrap_err()
1749 .contains("detail")
1750 );
1751 }
1752
1753 #[test]
1754 fn unknown_verdict_is_refused_by_the_shared_type() {
1755 let unknown = serde_json::json!({
1756 "verified": false,
1757 "bitwise_parity": false,
1758 "verdict": "partly_matched",
1759 "comparison": null,
1760 "compared_log_messages": null
1761 });
1762 let error = VerificationReport::from_json_value(unknown)
1763 .expect_err("an unknown verdict must not become a product failure");
1764 assert!(
1765 error.contains("unknown variant"),
1766 "unexpected error: {error}"
1767 );
1768 }
1769
1770 #[test]
1771 fn infrastructure_error_is_distinct_from_no_result_and_requires_a_positive_count() {
1772 let mut report =
1773 serde_json::to_value(report(LogCompareStrictness::Canonical, true, 1, 1)).unwrap();
1774 report["verified"] = serde_json::json!(false);
1775 report["bitwise_parity"] = serde_json::json!(false);
1776 report["verdict"] = serde_json::json!("infrastructure_error");
1777 report["infrastructure_error"] = serde_json::json!({"kind": "skid_overshoot", "count": 2});
1778
1779 let parsed = VerificationReport::from_json_value(report.clone()).unwrap();
1780 assert_eq!(parsed.verdict, Verdict::InfrastructureError);
1781 assert_eq!(
1782 parsed.infrastructure_error,
1783 Some(InfrastructureError::SkidOvershoot { count: 2 })
1784 );
1785 assert!(parsed.comparison.is_some());
1786
1787 report["infrastructure_error"]["count"] = serde_json::json!(0);
1788 let error = VerificationReport::from_json_value(report)
1789 .expect_err("zero cannot describe an observed skid overshoot");
1790 assert!(error.contains("count must be positive"), "{error}");
1791 }
1792}