1#![forbid(unsafe_code)]
2
3use crate::borders::{BorderSet, BorderType};
25use crate::{Widget, apply_style, clear_text_area, draw_text_span};
26use ftui_core::geometry::Rect;
27use ftui_render::cell::{Cell, PackedRgba};
28use ftui_render::frame::Frame;
29use ftui_style::Style;
30
31const PASS_FG: PackedRgba = PackedRgba::rgb(0, 200, 0);
36const PASS_BG: PackedRgba = PackedRgba::rgb(0, 60, 0);
37const FAIL_FG: PackedRgba = PackedRgba::rgb(220, 50, 50);
38const FAIL_BG: PackedRgba = PackedRgba::rgb(60, 10, 10);
39const WARN_FG: PackedRgba = PackedRgba::rgb(220, 200, 0);
40const HEADER_FG: PackedRgba = PackedRgba::rgb(140, 160, 180);
41const DIM_FG: PackedRgba = PackedRgba::rgb(120, 120, 120);
42const DETAIL_FG: PackedRgba = PackedRgba::rgb(160, 180, 200);
43
44#[derive(Debug, Clone, Copy, PartialEq, Eq)]
50pub enum CheckOutcome {
51 Pass,
52 Fail,
53 Warn,
54}
55
56impl CheckOutcome {
57 #[must_use]
60 pub fn parse(s: &str) -> Self {
61 match s {
62 "pass" | "PASS" => Self::Pass,
63 "fail" | "FAIL" => Self::Fail,
64 _ => Self::Warn,
65 }
66 }
67
68 #[must_use]
70 pub fn label(self) -> &'static str {
71 match self {
72 Self::Pass => "PASS",
73 Self::Fail => "FAIL",
74 Self::Warn => "WARN",
75 }
76 }
77}
78
79#[derive(Debug, Clone, PartialEq, Eq)]
81pub struct CheckEntry {
82 pub check: String,
83 pub outcome: CheckOutcome,
84 pub error_code: Option<String>,
85 pub detail: String,
86}
87
88#[derive(Debug, Clone, PartialEq, Eq, Default)]
90pub struct LayerVerdict {
91 pub passed: bool,
92 pub error_code: Option<String>,
93 pub checks: Vec<CheckEntry>,
94}
95
96#[derive(Debug, Clone, Copy, PartialEq, Eq)]
101pub enum FailureClass {
102 Signature,
103 Transparency,
104 Attestation,
105 StaleData,
106}
107
108impl FailureClass {
109 #[must_use]
111 pub fn parse(s: &str) -> Option<Self> {
112 match s {
113 "Signature" => Some(Self::Signature),
114 "Transparency" => Some(Self::Transparency),
115 "Attestation" => Some(Self::Attestation),
116 "StaleData" => Some(Self::StaleData),
117 _ => None,
118 }
119 }
120
121 #[must_use]
122 pub fn label(self) -> &'static str {
123 match self {
124 Self::Signature => "Signature",
125 Self::Transparency => "Transparency",
126 Self::Attestation => "Attestation",
127 Self::StaleData => "StaleData",
128 }
129 }
130}
131
132#[derive(Debug, Clone, PartialEq, Default)]
134pub struct PosteriorSnapshot {
135 pub point_estimate: Option<f64>,
136 pub ci_lower: Option<f64>,
137 pub ci_upper: Option<f64>,
138}
139
140#[derive(Debug, Clone, PartialEq)]
145pub struct ReceiptVerdict {
146 pub receipt_id: String,
147 pub trace_id: String,
148 pub decision_id: String,
149 pub policy_id: String,
150 pub verification_timestamp_ns: u64,
151 pub passed: bool,
152 pub failure_class: Option<FailureClass>,
153 pub exit_code: i32,
154 pub signature: LayerVerdict,
155 pub transparency: LayerVerdict,
156 pub attestation: LayerVerdict,
157 pub warnings: Vec<String>,
158 pub posterior_snapshot: Option<PosteriorSnapshot>,
160}
161
162impl ReceiptVerdict {
163 #[must_use]
165 pub fn skeleton(receipt_id: impl Into<String>, passed: bool) -> Self {
166 Self {
167 receipt_id: receipt_id.into(),
168 trace_id: String::new(),
169 decision_id: String::new(),
170 policy_id: String::new(),
171 verification_timestamp_ns: 0,
172 passed,
173 failure_class: None,
174 exit_code: if passed { 0 } else { 2 },
175 signature: LayerVerdict::default(),
176 transparency: LayerVerdict::default(),
177 attestation: LayerVerdict::default(),
178 warnings: Vec::new(),
179 posterior_snapshot: None,
180 }
181 }
182}
183
184#[derive(Debug, Clone)]
203pub struct ReceiptVerifierPanel<'a> {
204 verdict: &'a ReceiptVerdict,
205 border_type: BorderType,
206 style: Style,
207 title_style: Style,
208 show_posterior_path: bool,
209 show_evidence_chain: bool,
210 show_triage: bool,
211}
212
213impl<'a> ReceiptVerifierPanel<'a> {
214 #[must_use]
219 pub fn new(verdict: &'a ReceiptVerdict) -> Self {
220 Self {
221 verdict,
222 border_type: BorderType::Rounded,
223 style: Style::default(),
224 title_style: Style::default().bold(),
225 show_posterior_path: false,
226 show_evidence_chain: false,
227 show_triage: true,
228 }
229 }
230
231 #[must_use]
232 pub fn border_type(mut self, border_type: BorderType) -> Self {
233 self.border_type = border_type;
234 self
235 }
236
237 #[must_use]
238 pub fn style(mut self, style: Style) -> Self {
239 self.style = style;
240 self
241 }
242
243 #[must_use]
244 pub fn title_style(mut self, style: Style) -> Self {
245 self.title_style = style;
246 self
247 }
248
249 #[must_use]
252 pub fn show_posterior_path(mut self, show: bool) -> Self {
253 self.show_posterior_path = show;
254 self
255 }
256
257 #[must_use]
259 pub fn show_evidence_chain(mut self, show: bool) -> Self {
260 self.show_evidence_chain = show;
261 self
262 }
263
264 #[must_use]
266 pub fn show_triage(mut self, show: bool) -> Self {
267 self.show_triage = show;
268 self
269 }
270
271 #[must_use]
273 pub fn min_height(&self) -> u16 {
274 let mut h: u16 = 7;
276 if self.show_posterior_path
277 && let Some(snapshot) = self.verdict.posterior_snapshot.as_ref()
278 {
279 h += 1 + posterior_visible_rows(snapshot);
281 }
282 if self.show_evidence_chain {
283 h += 1 + 2; h += self.verdict.transparency.checks.len() as u16;
286 h += self.verdict.signature.checks.len() as u16;
287 }
288 if !self.verdict.warnings.is_empty() {
289 h += 1 + self.verdict.warnings.len() as u16; }
291 if self.show_triage
292 && let Some(fc) = self.verdict.failure_class
293 {
294 h += triage_height(fc);
295 }
296 h
297 }
298
299 fn border_style_for_status(passed: bool) -> Style {
300 if passed {
301 Style::new().fg(PASS_FG)
302 } else {
303 Style::new().fg(FAIL_FG)
304 }
305 }
306
307 fn render_border(&self, area: Rect, frame: &mut Frame, border_style: Style) {
308 let deg = frame.buffer.degradation;
309 let set = if deg.use_unicode_borders() {
310 self.border_type.to_border_set()
311 } else {
312 BorderSet::ASCII
313 };
314
315 let border_cell = |c: char| -> Cell {
316 let mut cell = Cell::from_char(c);
317 apply_style(&mut cell, border_style);
318 cell
319 };
320
321 for x in area.x..area.right() {
322 frame
323 .buffer
324 .set_fast(x, area.y, border_cell(set.horizontal));
325 }
326 let bottom_y = area.bottom().saturating_sub(1);
327 for x in area.x..area.right() {
328 frame
329 .buffer
330 .set_fast(x, bottom_y, border_cell(set.horizontal));
331 }
332 for y in area.y..area.bottom() {
333 frame.buffer.set_fast(area.x, y, border_cell(set.vertical));
334 }
335 let right_x = area.right().saturating_sub(1);
336 for y in area.y..area.bottom() {
337 frame.buffer.set_fast(right_x, y, border_cell(set.vertical));
338 }
339 frame
340 .buffer
341 .set_fast(area.x, area.y, border_cell(set.top_left));
342 frame
343 .buffer
344 .set_fast(right_x, area.y, border_cell(set.top_right));
345 frame
346 .buffer
347 .set_fast(area.x, bottom_y, border_cell(set.bottom_left));
348 frame
349 .buffer
350 .set_fast(right_x, bottom_y, border_cell(set.bottom_right));
351 }
352
353 fn verdict_badge_style(passed: bool, apply_styling: bool) -> Style {
354 if !apply_styling {
355 return Style::default();
356 }
357 if passed {
358 Style::new().fg(PASS_FG).bg(PASS_BG).bold()
359 } else {
360 Style::new().fg(FAIL_FG).bg(FAIL_BG).bold()
361 }
362 }
363
364 fn render_verdict_row(&self, x: u16, y: u16, max_x: u16, frame: &mut Frame) {
365 let apply_styling = frame.buffer.degradation.apply_styling();
366 let badge_text = if self.verdict.passed {
367 " VERIFIED "
368 } else {
369 " FAILED "
370 };
371 let badge_style = Self::verdict_badge_style(self.verdict.passed, apply_styling);
372 let title_style = if apply_styling {
373 self.title_style
374 } else {
375 Style::default()
376 };
377
378 let mut cx = draw_text_span(frame, x, y, badge_text, badge_style, max_x);
379 if cx < max_x {
380 cx = draw_text_span(frame, cx, y, " ", Style::default(), max_x);
381 }
382 let id_text = format!("receipt {}", self.verdict.receipt_id);
383 cx = draw_text_span(frame, cx, y, &id_text, title_style, max_x);
384
385 if !self.verdict.passed
386 && let Some(fc) = self.verdict.failure_class
387 {
388 let detail_style = if apply_styling {
389 Style::new().fg(FAIL_FG)
390 } else {
391 Style::default()
392 };
393 let trail = format!(
394 " failure_class={} exit_code={}",
395 fc.label(),
396 self.verdict.exit_code
397 );
398 let _ = draw_text_span(frame, cx, y, &trail, detail_style, max_x);
399 }
400 }
401
402 fn render_provenance_row(&self, x: u16, y: u16, max_x: u16, frame: &mut Frame) {
403 let style = if frame.buffer.degradation.apply_styling() {
404 Style::new().fg(DETAIL_FG)
405 } else {
406 Style::default()
407 };
408 let line = format!(
409 "trace={} decision={} policy={}",
410 display_or_dash(&self.verdict.trace_id),
411 display_or_dash(&self.verdict.decision_id),
412 display_or_dash(&self.verdict.policy_id),
413 );
414 draw_text_span(frame, x, y, &line, style, max_x);
415 }
416
417 fn render_layer_row(
418 x: u16,
419 y: u16,
420 max_x: u16,
421 frame: &mut Frame,
422 name: &str,
423 layer: &LayerVerdict,
424 ) {
425 let apply_styling = frame.buffer.degradation.apply_styling();
426 let label = format!(" {name:<13} ");
427 let label_style = if apply_styling {
428 Style::new().fg(HEADER_FG)
429 } else {
430 Style::default()
431 };
432 let mut cx = draw_text_span(frame, x, y, &label, label_style, max_x);
433
434 let outcome_label = if layer.passed { "PASS" } else { "FAIL" };
435 let outcome_style = if !apply_styling {
436 Style::default()
437 } else if layer.passed {
438 Style::new().fg(PASS_FG).bold()
439 } else {
440 Style::new().fg(FAIL_FG).bold()
441 };
442 cx = draw_text_span(frame, cx, y, outcome_label, outcome_style, max_x);
443
444 let trail_style = if apply_styling {
445 Style::new().fg(DIM_FG)
446 } else {
447 Style::default()
448 };
449 let trail = format!(
450 " error_code={}",
451 layer.error_code.as_deref().unwrap_or("-")
452 );
453 let _ = draw_text_span(frame, cx, y, &trail, trail_style, max_x);
454 }
455
456 fn render_posterior_path(&self, x: u16, mut y: u16, max_x: u16, frame: &mut Frame) -> u16 {
457 let Some(snapshot) = self.verdict.posterior_snapshot.as_ref() else {
458 return y;
459 };
460 let apply_styling = frame.buffer.degradation.apply_styling();
461 let header_style = if apply_styling {
462 Style::new().fg(HEADER_FG).bold()
463 } else {
464 Style::default()
465 };
466 let detail_style = if apply_styling {
467 Style::new().fg(DETAIL_FG)
468 } else {
469 Style::default()
470 };
471
472 draw_text_span(frame, x, y, "posterior path:", header_style, max_x);
473 y += 1;
474 if let Some(p) = snapshot.point_estimate {
475 draw_text_span(
476 frame,
477 x,
478 y,
479 &format!(" point_estimate = {p}"),
480 detail_style,
481 max_x,
482 );
483 y += 1;
484 }
485 if let Some(lo) = snapshot.ci_lower {
486 draw_text_span(
487 frame,
488 x,
489 y,
490 &format!(" confidence_interval_95_lower = {lo}"),
491 detail_style,
492 max_x,
493 );
494 y += 1;
495 }
496 if let Some(hi) = snapshot.ci_upper {
497 draw_text_span(
498 frame,
499 x,
500 y,
501 &format!(" confidence_interval_95_upper = {hi}"),
502 detail_style,
503 max_x,
504 );
505 y += 1;
506 }
507 y
508 }
509
510 fn render_evidence_chain(&self, x: u16, mut y: u16, max_x: u16, frame: &mut Frame) -> u16 {
511 let apply_styling = frame.buffer.degradation.apply_styling();
512 let header_style = if apply_styling {
513 Style::new().fg(HEADER_FG).bold()
514 } else {
515 Style::default()
516 };
517
518 draw_text_span(frame, x, y, "evidence chain:", header_style, max_x);
519 y += 1;
520 y = render_layer_checks(
521 x,
522 y,
523 max_x,
524 frame,
525 "transparency",
526 &self.verdict.transparency,
527 );
528 y = render_layer_checks(x, y, max_x, frame, "signature", &self.verdict.signature);
529 y
530 }
531
532 fn render_warnings(&self, x: u16, mut y: u16, max_x: u16, frame: &mut Frame) -> u16 {
533 if self.verdict.warnings.is_empty() {
534 return y;
535 }
536 let apply_styling = frame.buffer.degradation.apply_styling();
537 let header_style = if apply_styling {
538 Style::new().fg(WARN_FG).bold()
539 } else {
540 Style::default()
541 };
542 let line_style = if apply_styling {
543 Style::new().fg(WARN_FG)
544 } else {
545 Style::default()
546 };
547 draw_text_span(frame, x, y, "warnings:", header_style, max_x);
548 y += 1;
549 for w in &self.verdict.warnings {
550 draw_text_span(frame, x, y, &format!(" ! {w}"), line_style, max_x);
551 y += 1;
552 }
553 y
554 }
555
556 fn render_triage(&self, x: u16, mut y: u16, max_x: u16, frame: &mut Frame) -> u16 {
557 if !self.show_triage {
558 return y;
559 }
560 let Some(fc) = self.verdict.failure_class else {
561 return y;
562 };
563 let apply_styling = frame.buffer.degradation.apply_styling();
564 let header_style = if apply_styling {
565 Style::new().fg(FAIL_FG).bold()
566 } else {
567 Style::default()
568 };
569 let body_style = if apply_styling {
570 Style::new().fg(DETAIL_FG)
571 } else {
572 Style::default()
573 };
574
575 draw_text_span(
576 frame,
577 x,
578 y,
579 &format!("incident triage (failure_class={}):", fc.label()),
580 header_style,
581 max_x,
582 );
583 y += 1;
584 for line in triage_lines(fc) {
585 draw_text_span(frame, x, y, line, body_style, max_x);
586 y += 1;
587 }
588 y
589 }
590}
591
592impl Widget for ReceiptVerifierPanel<'_> {
593 fn render(&self, area: Rect, frame: &mut Frame) {
594 if area.is_empty() {
595 return;
596 }
597 if area.width < 4 || area.height < 3 {
598 clear_text_area(frame, area, Style::default());
599 return;
600 }
601
602 let deg = frame.buffer.degradation;
603 if !deg.render_content() {
604 clear_text_area(frame, area, Style::default());
605 return;
606 }
607
608 let base_style = if deg.apply_styling() {
609 self.style
610 } else {
611 Style::default()
612 };
613 clear_text_area(frame, area, base_style);
614
615 let border_style = Self::border_style_for_status(self.verdict.passed);
616 let border_style = if deg.apply_styling() {
617 border_style
618 } else {
619 Style::default()
620 };
621 if deg.render_decorative() {
622 self.render_border(area, frame, border_style);
623 }
624
625 let inner_x = area.x.saturating_add(1);
626 let inner_max_x = area.right().saturating_sub(1);
627 let mut y = area.y.saturating_add(1);
628 let max_y = area.bottom().saturating_sub(1);
629
630 if y >= max_y || inner_x >= inner_max_x {
631 return;
632 }
633
634 self.render_verdict_row(inner_x, y, inner_max_x, frame);
636 y += 1;
637 if y >= max_y {
638 return;
639 }
640
641 self.render_provenance_row(inner_x, y, inner_max_x, frame);
643 y += 1;
644 if y >= max_y {
645 return;
646 }
647
648 for (name, layer) in [
650 ("signature", &self.verdict.signature),
651 ("transparency", &self.verdict.transparency),
652 ("attestation", &self.verdict.attestation),
653 ] {
654 if y >= max_y {
655 return;
656 }
657 Self::render_layer_row(inner_x, y, inner_max_x, frame, name, layer);
658 y += 1;
659 }
660
661 if self.show_posterior_path && self.verdict.posterior_snapshot.is_some() && y < max_y {
663 y = self.render_posterior_path(inner_x, y, inner_max_x, frame);
664 }
665
666 if self.show_evidence_chain && y < max_y {
668 y = self.render_evidence_chain(inner_x, y, inner_max_x, frame);
669 }
670
671 if y < max_y {
673 y = self.render_warnings(inner_x, y, inner_max_x, frame);
674 }
675
676 if y < max_y {
678 let _ = self.render_triage(inner_x, y, inner_max_x, frame);
679 }
680 }
681
682 fn is_essential(&self) -> bool {
683 false
684 }
685}
686
687fn display_or_dash(s: &str) -> &str {
692 if s.is_empty() { "-" } else { s }
693}
694
695fn render_layer_checks(
696 x: u16,
697 mut y: u16,
698 max_x: u16,
699 frame: &mut Frame,
700 name: &str,
701 layer: &LayerVerdict,
702) -> u16 {
703 let apply_styling = frame.buffer.degradation.apply_styling();
704 let header_style = if apply_styling {
705 Style::new().fg(HEADER_FG)
706 } else {
707 Style::default()
708 };
709 let verdict = if layer.passed { "PASS" } else { "FAIL" };
710 draw_text_span(
711 frame,
712 x,
713 y,
714 &format!(" {name} layer ({verdict}):"),
715 header_style,
716 max_x,
717 );
718 y += 1;
719 for check in &layer.checks {
720 let outcome_style = if !apply_styling {
721 Style::default()
722 } else {
723 match check.outcome {
724 CheckOutcome::Pass => Style::new().fg(PASS_FG),
725 CheckOutcome::Fail => Style::new().fg(FAIL_FG),
726 CheckOutcome::Warn => Style::new().fg(WARN_FG),
727 }
728 };
729 let line = format!(
730 " [{}] {} — {}",
731 check.outcome.label(),
732 check.check,
733 check.detail
734 );
735 draw_text_span(frame, x, y, &line, outcome_style, max_x);
736 y += 1;
737 }
738 y
739}
740
741fn posterior_visible_rows(snapshot: &PosteriorSnapshot) -> u16 {
742 let mut n: u16 = 0;
743 if snapshot.point_estimate.is_some() {
744 n += 1;
745 }
746 if snapshot.ci_lower.is_some() {
747 n += 1;
748 }
749 if snapshot.ci_upper.is_some() {
750 n += 1;
751 }
752 n
753}
754
755fn triage_lines(fc: FailureClass) -> &'static [&'static str] {
758 match fc {
759 FailureClass::Signature => &[
760 " The receipt's threshold signature did not validate.",
761 " -> Confirm the verifier has the correct signer verification keys.",
762 " -> A genuine mismatch means the receipt was not produced by the",
763 " attested signing quorum: treat the decision as UNTRUSTED.",
764 ],
765 FailureClass::Transparency => &[
766 " The transparency-log inclusion and/or consistency proof failed.",
767 " -> --show-evidence-chain shows which MMR proof check failed.",
768 " -> Inclusion fail: the receipt is not in the published log",
769 " (possible equivocation/omission). Consistency fail: the log was",
770 " forked or rewritten between checkpoints. Escalate to log-operator.",
771 ],
772 FailureClass::Attestation => &[
773 " The TEE attestation quote could not be validated.",
774 " -> Runtime has degraded to SAFE-MODE: it continues only under the",
775 " restricted capability posture (no attested-only operations),",
776 " because it can no longer prove it runs in a trusted enclave.",
777 " -> Check tee_attestation_policy freshness/measurement; a stale or",
778 " mismatched measurement is the usual cause. Re-attest before",
779 " promoting any decision that requires a trusted enclave.",
780 ],
781 FailureClass::StaleData => &[
782 " Verifier input is stale (timestamp/epoch outside accepted window).",
783 " -> Re-export a fresh verifier_input.json for this receipt and re-run.",
784 ],
785 }
786}
787
788fn triage_height(fc: FailureClass) -> u16 {
789 1 + triage_lines(fc).len() as u16
790}
791
792#[cfg(test)]
797mod tests {
798 use super::*;
799 use ftui_render::frame::Frame;
800 use ftui_render::grapheme_pool::GraphemePool;
801
802 fn make_pass() -> ReceiptVerdict {
803 let mut v = ReceiptVerdict::skeleton("rcpt-001", true);
804 v.trace_id = "trace-aaa".into();
805 v.decision_id = "dec-bbb".into();
806 v.policy_id = "pol-ccc".into();
807 v.signature = LayerVerdict {
808 passed: true,
809 error_code: None,
810 checks: vec![CheckEntry {
811 check: "threshold_signature".into(),
812 outcome: CheckOutcome::Pass,
813 error_code: None,
814 detail: "3/3 signers".into(),
815 }],
816 };
817 v.transparency = LayerVerdict {
818 passed: true,
819 error_code: None,
820 checks: vec![
821 CheckEntry {
822 check: "mmr_inclusion".into(),
823 outcome: CheckOutcome::Pass,
824 error_code: None,
825 detail: "leaf 41 under root r9".into(),
826 },
827 CheckEntry {
828 check: "mmr_consistency".into(),
829 outcome: CheckOutcome::Pass,
830 error_code: None,
831 detail: "c0->c1 consistent".into(),
832 },
833 ],
834 };
835 v.attestation = LayerVerdict::default();
836 v.attestation.passed = true;
837 v.posterior_snapshot = Some(PosteriorSnapshot {
838 point_estimate: Some(0.873),
839 ci_lower: Some(0.81),
840 ci_upper: Some(0.92),
841 });
842 v
843 }
844
845 fn make_attestation_fail() -> ReceiptVerdict {
846 let mut v = ReceiptVerdict::skeleton("rcpt-001", false);
847 v.trace_id = "trace-aaa".into();
848 v.decision_id = "dec-bbb".into();
849 v.policy_id = "pol-ccc".into();
850 v.failure_class = Some(FailureClass::Attestation);
851 v.exit_code = 2;
852 v.signature = LayerVerdict {
853 passed: true,
854 error_code: None,
855 checks: vec![],
856 };
857 v.transparency = LayerVerdict {
858 passed: true,
859 error_code: None,
860 checks: vec![],
861 };
862 v.attestation = LayerVerdict {
863 passed: false,
864 error_code: Some("ATTEST_STALE".into()),
865 checks: vec![CheckEntry {
866 check: "quote_freshness".into(),
867 outcome: CheckOutcome::Fail,
868 error_code: Some("ATTEST_STALE".into()),
869 detail: "quote older than max age".into(),
870 }],
871 };
872 v.warnings = vec!["attestation degraded; running in safe-mode".into()];
873 v
874 }
875
876 fn extract_row(frame: &Frame, y: u16, width: u16) -> String {
877 let mut row = String::new();
878 for x in 0..width {
879 if let Some(cell) = frame.buffer.get(x, y)
880 && let Some(ch) = cell.content.as_char()
881 {
882 row.push(ch);
883 } else {
884 row.push(' ');
885 }
886 }
887 row
888 }
889
890 fn frame_has_text(frame: &Frame, width: u16, height: u16, needle: &str) -> bool {
891 for y in 0..height {
892 if extract_row(frame, y, width).contains(needle) {
893 return true;
894 }
895 }
896 false
897 }
898
899 #[test]
900 fn check_outcome_parse_round_trip() {
901 assert_eq!(CheckOutcome::parse("pass"), CheckOutcome::Pass);
902 assert_eq!(CheckOutcome::parse("PASS"), CheckOutcome::Pass);
903 assert_eq!(CheckOutcome::parse("fail"), CheckOutcome::Fail);
904 assert_eq!(CheckOutcome::parse("FAIL"), CheckOutcome::Fail);
905 assert_eq!(CheckOutcome::parse("anything-else"), CheckOutcome::Warn);
906 assert_eq!(CheckOutcome::Pass.label(), "PASS");
907 assert_eq!(CheckOutcome::Fail.label(), "FAIL");
908 assert_eq!(CheckOutcome::Warn.label(), "WARN");
909 }
910
911 #[test]
912 fn failure_class_parse_maps_all_known() {
913 assert_eq!(
914 FailureClass::parse("Signature"),
915 Some(FailureClass::Signature)
916 );
917 assert_eq!(
918 FailureClass::parse("Transparency"),
919 Some(FailureClass::Transparency)
920 );
921 assert_eq!(
922 FailureClass::parse("Attestation"),
923 Some(FailureClass::Attestation)
924 );
925 assert_eq!(
926 FailureClass::parse("StaleData"),
927 Some(FailureClass::StaleData)
928 );
929 assert_eq!(FailureClass::parse("null"), None);
930 assert_eq!(FailureClass::parse(""), None);
931 }
932
933 #[test]
934 fn pass_verdict_renders_verified_and_provenance() {
935 let v = make_pass();
936 let mut pool = GraphemePool::new();
937 let mut frame = Frame::new(80, 12, &mut pool);
938 ReceiptVerifierPanel::new(&v).render(Rect::new(0, 0, 80, 12), &mut frame);
939 assert!(frame_has_text(&frame, 80, 12, "VERIFIED"));
940 assert!(frame_has_text(&frame, 80, 12, "receipt rcpt-001"));
941 assert!(frame_has_text(&frame, 80, 12, "trace=trace-aaa"));
942 assert!(frame_has_text(&frame, 80, 12, "signature"));
943 assert!(frame_has_text(&frame, 80, 12, "transparency"));
944 assert!(frame_has_text(&frame, 80, 12, "attestation"));
945 }
946
947 #[test]
948 fn fail_verdict_surfaces_failure_class_in_verdict_row() {
949 let v = make_attestation_fail();
950 let mut pool = GraphemePool::new();
951 let mut frame = Frame::new(80, 18, &mut pool);
952 ReceiptVerifierPanel::new(&v).render(Rect::new(0, 0, 80, 18), &mut frame);
953 assert!(frame_has_text(&frame, 80, 18, "FAILED"));
954 assert!(frame_has_text(&frame, 80, 18, "failure_class=Attestation"));
955 assert!(frame_has_text(&frame, 80, 18, "error_code=ATTEST_STALE"));
957 }
958
959 #[test]
960 fn show_posterior_path_renders_point_estimate() {
961 let v = make_pass();
962 let mut pool = GraphemePool::new();
963 let mut frame = Frame::new(80, 18, &mut pool);
964 ReceiptVerifierPanel::new(&v)
965 .show_posterior_path(true)
966 .render(Rect::new(0, 0, 80, 18), &mut frame);
967 assert!(frame_has_text(&frame, 80, 18, "posterior path:"));
968 assert!(frame_has_text(&frame, 80, 18, "point_estimate = 0.873"));
969 assert!(frame_has_text(
970 &frame,
971 80,
972 18,
973 "confidence_interval_95_lower = 0.81"
974 ));
975 }
976
977 #[test]
978 fn show_evidence_chain_renders_layer_checks() {
979 let v = make_pass();
980 let mut pool = GraphemePool::new();
981 let mut frame = Frame::new(80, 18, &mut pool);
982 ReceiptVerifierPanel::new(&v)
983 .show_evidence_chain(true)
984 .render(Rect::new(0, 0, 80, 18), &mut frame);
985 assert!(frame_has_text(&frame, 80, 18, "evidence chain:"));
986 assert!(frame_has_text(&frame, 80, 18, "mmr_inclusion"));
987 assert!(frame_has_text(&frame, 80, 18, "mmr_consistency"));
988 assert!(frame_has_text(&frame, 80, 18, "threshold_signature"));
989 }
990
991 #[test]
992 fn attestation_failure_triage_mentions_safe_mode() {
993 let v = make_attestation_fail();
994 let mut pool = GraphemePool::new();
995 let mut frame = Frame::new(80, 24, &mut pool);
996 ReceiptVerifierPanel::new(&v).render(Rect::new(0, 0, 80, 24), &mut frame);
997 assert!(frame_has_text(&frame, 80, 24, "incident triage"));
998 assert!(frame_has_text(&frame, 80, 24, "SAFE-MODE"));
999 }
1000
1001 #[test]
1002 fn warnings_block_renders_when_present() {
1003 let v = make_attestation_fail();
1004 let mut pool = GraphemePool::new();
1005 let mut frame = Frame::new(80, 24, &mut pool);
1006 ReceiptVerifierPanel::new(&v).render(Rect::new(0, 0, 80, 24), &mut frame);
1007 assert!(frame_has_text(&frame, 80, 24, "warnings:"));
1008 assert!(frame_has_text(&frame, 80, 24, "running in safe-mode"));
1009 }
1010
1011 #[test]
1012 fn empty_receipt_id_still_renders_provenance_dash() {
1013 let v = ReceiptVerdict::skeleton("rcpt-empty", true);
1014 let mut pool = GraphemePool::new();
1015 let mut frame = Frame::new(80, 10, &mut pool);
1016 ReceiptVerifierPanel::new(&v).render(Rect::new(0, 0, 80, 10), &mut frame);
1017 assert!(frame_has_text(&frame, 80, 10, "trace=-"));
1019 assert!(frame_has_text(&frame, 80, 10, "decision=-"));
1020 assert!(frame_has_text(&frame, 80, 10, "policy=-"));
1021 }
1022
1023 #[test]
1024 fn tiny_area_does_not_panic() {
1025 let v = make_pass();
1026 let mut pool = GraphemePool::new();
1027 let mut frame = Frame::new(3, 2, &mut pool);
1028 ReceiptVerifierPanel::new(&v).render(Rect::new(0, 0, 3, 2), &mut frame);
1029 let mut frame = Frame::new(1, 1, &mut pool);
1030 ReceiptVerifierPanel::new(&v).render(Rect::new(0, 0, 1, 1), &mut frame);
1031 }
1032
1033 #[test]
1034 fn min_height_grows_with_options() {
1035 let v = make_pass();
1036 let base = ReceiptVerifierPanel::new(&v).min_height();
1037 let with_post = ReceiptVerifierPanel::new(&v)
1038 .show_posterior_path(true)
1039 .min_height();
1040 assert!(
1041 with_post > base,
1042 "posterior path should increase min_height"
1043 );
1044 let with_chain = ReceiptVerifierPanel::new(&v)
1045 .show_evidence_chain(true)
1046 .min_height();
1047 assert!(
1048 with_chain > base,
1049 "evidence chain should increase min_height"
1050 );
1051 }
1052
1053 #[test]
1057 fn canonical_verifier_pass_json_round_trips_through_data_model() {
1058 let pass_json = r#"{
1059 "receipt_id":"rcpt-001","trace_id":"trace-aaa","decision_id":"dec-bbb",
1060 "policy_id":"pol-ccc","verification_timestamp_ns":1716000000000000000,
1061 "passed":true,"failure_class":null,"exit_code":0,
1062 "signature":{"passed":true,"error_code":null,"checks":[
1063 {"check":"threshold_signature","outcome":"pass","error_code":null,"detail":"3/3 signers"}
1064 ]},
1065 "transparency":{"passed":true,"error_code":null,"checks":[
1066 {"check":"mmr_inclusion","outcome":"pass","error_code":null,"detail":"leaf 41 under root r9"},
1067 {"check":"mmr_consistency","outcome":"pass","error_code":null,"detail":"c0->c1 consistent"}
1068 ]},
1069 "attestation":{"passed":true,"error_code":null,"checks":[]},
1070 "warnings":[],"logs":[]
1071 }"#;
1072 let v: serde_json::Value = serde_json::from_str(pass_json).unwrap();
1073 let verdict = verdict_from_json(&v).expect("canonical JSON should parse");
1076 assert_eq!(verdict.receipt_id, "rcpt-001");
1077 assert!(verdict.passed);
1078 assert_eq!(verdict.failure_class, None);
1079 assert_eq!(verdict.signature.checks.len(), 1);
1080 assert_eq!(verdict.transparency.checks.len(), 2);
1081 assert_eq!(verdict.transparency.checks[1].check, "mmr_consistency");
1082 }
1083
1084 fn verdict_from_json(v: &serde_json::Value) -> Option<ReceiptVerdict> {
1088 fn layer(v: &serde_json::Value) -> LayerVerdict {
1089 let passed = v.get("passed").and_then(|x| x.as_bool()).unwrap_or(false);
1090 let error_code = v
1091 .get("error_code")
1092 .and_then(|x| x.as_str())
1093 .map(String::from);
1094 let checks = v
1095 .get("checks")
1096 .and_then(|x| x.as_array())
1097 .map(|arr| {
1098 arr.iter()
1099 .map(|c| CheckEntry {
1100 check: c
1101 .get("check")
1102 .and_then(|x| x.as_str())
1103 .unwrap_or("")
1104 .to_string(),
1105 outcome: CheckOutcome::parse(
1106 c.get("outcome").and_then(|x| x.as_str()).unwrap_or(""),
1107 ),
1108 error_code: c
1109 .get("error_code")
1110 .and_then(|x| x.as_str())
1111 .map(String::from),
1112 detail: c
1113 .get("detail")
1114 .and_then(|x| x.as_str())
1115 .unwrap_or("")
1116 .to_string(),
1117 })
1118 .collect()
1119 })
1120 .unwrap_or_default();
1121 LayerVerdict {
1122 passed,
1123 error_code,
1124 checks,
1125 }
1126 }
1127 Some(ReceiptVerdict {
1128 receipt_id: v.get("receipt_id")?.as_str()?.to_string(),
1129 trace_id: v
1130 .get("trace_id")
1131 .and_then(|x| x.as_str())
1132 .unwrap_or("")
1133 .to_string(),
1134 decision_id: v
1135 .get("decision_id")
1136 .and_then(|x| x.as_str())
1137 .unwrap_or("")
1138 .to_string(),
1139 policy_id: v
1140 .get("policy_id")
1141 .and_then(|x| x.as_str())
1142 .unwrap_or("")
1143 .to_string(),
1144 verification_timestamp_ns: v
1145 .get("verification_timestamp_ns")
1146 .and_then(|x| x.as_u64())
1147 .unwrap_or(0),
1148 passed: v.get("passed").and_then(|x| x.as_bool()).unwrap_or(false),
1149 failure_class: v
1150 .get("failure_class")
1151 .and_then(|x| x.as_str())
1152 .and_then(FailureClass::parse),
1153 exit_code: v.get("exit_code").and_then(|x| x.as_i64()).unwrap_or(0) as i32,
1154 signature: v.get("signature").map(layer).unwrap_or_default(),
1155 transparency: v.get("transparency").map(layer).unwrap_or_default(),
1156 attestation: v.get("attestation").map(layer).unwrap_or_default(),
1157 warnings: v
1158 .get("warnings")
1159 .and_then(|x| x.as_array())
1160 .map(|arr| {
1161 arr.iter()
1162 .filter_map(|w| w.as_str().map(String::from))
1163 .collect()
1164 })
1165 .unwrap_or_default(),
1166 posterior_snapshot: None,
1167 })
1168 }
1169}