1use std::collections::HashSet;
35use std::fs::{File, OpenOptions};
36use std::io::{self, BufRead, BufReader, BufWriter, Write};
37use std::path::{Path, PathBuf};
38
39use chrono::{DateTime, Utc};
40use serde::{Deserialize, Serialize};
41use thiserror::Error;
42
43use crate::transcript::compute_fingerprint;
44use rite_model::StepFact;
45
46#[derive(Debug, Clone, PartialEq, Eq)]
51pub struct TranscriptFingerprint(String);
52
53impl TranscriptFingerprint {
54 #[must_use]
56 pub fn from_string(s: String) -> Self {
57 Self(s)
58 }
59
60 #[must_use]
62 pub fn as_str(&self) -> &str {
63 &self.0
64 }
65}
66
67impl std::fmt::Display for TranscriptFingerprint {
68 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
69 f.write_str(&self.0)
70 }
71}
72
73pub const GENESIS_HASH: &str =
77 "sha256:0000000000000000000000000000000000000000000000000000000000000000";
78
79pub trait TranscriptSink: Send {
86 fn record(&mut self, at: DateTime<Utc>, fact: &StepFact) -> io::Result<()>;
98
99 fn finalize(&mut self) -> io::Result<TranscriptFingerprint>;
110}
111
112#[derive(Debug)]
120pub struct JsonlFileSink {
121 jsonl_path: PathBuf,
122 writer: BufWriter<File>,
123 current_hash: String,
124 finalized: Option<TranscriptFingerprint>,
125}
126
127impl JsonlFileSink {
128 pub fn create(dir: &Path) -> io::Result<Self> {
134 let jsonl_path = dir.join("transcript.jsonl");
135 let file = OpenOptions::new()
136 .create_new(true)
137 .write(true)
138 .open(&jsonl_path)?;
139 Ok(Self {
140 jsonl_path,
141 writer: BufWriter::new(file),
142 current_hash: GENESIS_HASH.to_string(),
143 finalized: None,
144 })
145 }
146
147 #[must_use]
149 pub fn jsonl_path(&self) -> &Path {
150 &self.jsonl_path
151 }
152}
153
154impl TranscriptSink for JsonlFileSink {
155 fn record(&mut self, at: DateTime<Utc>, fact: &StepFact) -> io::Result<()> {
156 if self.finalized.is_some() {
157 return Err(io::Error::other("transcript already finalized"));
158 }
159 let line = ChainedFact {
160 prev_hash: &self.current_hash,
161 at,
162 fact,
163 };
164 let bytes = serde_json::to_vec(&line).map_err(io::Error::other)?;
165 self.current_hash = compute_fingerprint(&bytes);
166 self.writer.write_all(&bytes)?;
167 self.writer.write_all(b"\n")?;
168 self.writer.flush()?;
175 self.writer.get_ref().sync_data()?;
176 Ok(())
177 }
178
179 fn finalize(&mut self) -> io::Result<TranscriptFingerprint> {
180 if let Some(existing) = &self.finalized {
181 return Ok(existing.clone());
182 }
183 let fingerprint = TranscriptFingerprint(self.current_hash.clone());
184 self.finalized = Some(fingerprint.clone());
185 Ok(fingerprint)
186 }
187}
188
189#[derive(Debug, Default)]
194pub struct InMemorySink {
195 facts: Vec<StepFact>,
196 current_hash: String,
197 finalized: Option<TranscriptFingerprint>,
198}
199
200impl InMemorySink {
201 #[must_use]
203 pub fn new() -> Self {
204 Self {
205 facts: Vec::new(),
206 current_hash: GENESIS_HASH.to_string(),
207 finalized: None,
208 }
209 }
210
211 #[must_use]
213 pub fn facts(&self) -> &[StepFact] {
214 &self.facts
215 }
216
217 #[must_use]
219 pub fn len(&self) -> usize {
220 self.facts.len()
221 }
222
223 #[must_use]
225 pub fn is_empty(&self) -> bool {
226 self.facts.is_empty()
227 }
228}
229
230impl TranscriptSink for InMemorySink {
231 fn record(&mut self, at: DateTime<Utc>, fact: &StepFact) -> io::Result<()> {
232 if self.finalized.is_some() {
233 return Err(io::Error::other("transcript already finalized"));
234 }
235 let line = ChainedFact {
236 prev_hash: &self.current_hash,
237 at,
238 fact,
239 };
240 let bytes = serde_json::to_vec(&line).map_err(io::Error::other)?;
241 self.current_hash = compute_fingerprint(&bytes);
242 self.facts.push(fact.clone());
243 Ok(())
244 }
245
246 fn finalize(&mut self) -> io::Result<TranscriptFingerprint> {
247 if let Some(existing) = &self.finalized {
248 return Ok(existing.clone());
249 }
250 let fingerprint = TranscriptFingerprint(self.current_hash.clone());
251 self.finalized = Some(fingerprint.clone());
252 Ok(fingerprint)
253 }
254}
255
256#[derive(Serialize)]
257struct ChainedFact<'a> {
258 prev_hash: &'a str,
259 at: DateTime<Utc>,
260 fact: &'a StepFact,
261}
262
263#[derive(Deserialize)]
264struct OwnedChainedFact {
265 prev_hash: String,
266 at: DateTime<Utc>,
267 fact: StepFact,
268}
269
270#[derive(Debug, Clone)]
274pub struct TimedFact {
275 pub at: DateTime<Utc>,
277 pub fact: StepFact,
279}
280
281#[derive(Debug, Clone)]
283pub struct TranscriptVerified {
284 pub fact_count: usize,
286 pub fingerprint: TranscriptFingerprint,
288 pub terminated: bool,
292}
293
294#[derive(Debug, Error)]
296pub enum VerifyError {
297 #[error("io error: {0}")]
299 Io(#[from] io::Error),
300 #[error("line {line} is not a valid chained fact: {reason}")]
302 InvalidLine {
303 line: usize,
305 reason: String,
307 },
308 #[error("broken chain at line {line}: expected prev_hash {expected}, got {actual}")]
311 BrokenChain {
312 line: usize,
314 expected: String,
316 actual: String,
318 },
319 #[error("transcript is empty")]
321 Empty,
322 #[error("entropy source used before it was seeded")]
325 SeedMissing,
326 #[error("entropy source seeded more than once")]
331 DuplicateSeed,
332 #[error("unknown entropy derivation scheme: {0}")]
335 UnknownDerivation(String),
336 #[error("malformed entropy value: {0}")]
338 MalformedEntropy(String),
339 #[error("entropy draw at path '{path}' does not match re-derived value")]
342 DrawMismatch {
343 path: String,
345 },
346 #[error(
351 "entropy draw at path '{path}' records {len} bytes, beyond the {max}-byte HKDF-Expand limit"
352 )]
353 DrawTooLong {
354 path: String,
356 len: usize,
358 max: usize,
360 },
361 #[error("entropy draw path '{path}' does not match expected '{expected_prefix}<purpose>'")]
366 PathMismatch {
367 path: String,
369 expected_prefix: String,
371 },
372 #[error("entropy contribution at step '{step}' records epoch {recorded}, expected {expected}")]
375 EpochMismatch {
376 step: String,
378 recorded: u32,
380 expected: u32,
382 },
383 #[error("entropy draw path '{path}' recorded more than once")]
386 DuplicateDrawPath {
387 path: String,
389 },
390}
391
392pub fn verify_transcript(jsonl_path: &Path) -> Result<TranscriptVerified, VerifyError> {
402 let loaded = read_verified_transcript(jsonl_path)?;
403 Ok(TranscriptVerified {
404 fact_count: loaded.facts.len(),
405 fingerprint: loaded.fingerprint,
406 terminated: loaded.terminated,
407 })
408}
409
410#[derive(Debug, Clone)]
413pub struct LoadedTranscript {
414 pub facts: Vec<TimedFact>,
417 pub fingerprint: TranscriptFingerprint,
419 pub terminated: bool,
421}
422
423pub fn read_verified_transcript(jsonl_path: &Path) -> Result<LoadedTranscript, VerifyError> {
433 let file = File::open(jsonl_path)?;
434 let reader = BufReader::new(file);
435
436 let mut expected_prev = GENESIS_HASH.to_string();
437 let mut last_hash = expected_prev.clone();
438 let mut facts: Vec<TimedFact> = Vec::new();
439
440 for (idx, line) in reader.lines().enumerate() {
441 let line = line?;
442 let line_no = idx.saturating_add(1);
443 let parsed: OwnedChainedFact =
444 serde_json::from_str(&line).map_err(|e| VerifyError::InvalidLine {
445 line: line_no,
446 reason: e.to_string(),
447 })?;
448 if parsed.prev_hash != expected_prev {
449 return Err(VerifyError::BrokenChain {
450 line: line_no,
451 expected: expected_prev,
452 actual: parsed.prev_hash,
453 });
454 }
455 last_hash = compute_fingerprint(line.as_bytes());
456 expected_prev.clone_from(&last_hash);
457 facts.push(TimedFact {
458 at: parsed.at,
459 fact: parsed.fact,
460 });
461 }
462
463 if facts.is_empty() {
464 return Err(VerifyError::Empty);
465 }
466
467 let terminated = matches!(
468 facts.last().map(|t| &t.fact),
469 Some(StepFact::CeremonyCompleted { .. } | StepFact::CeremonyFailed { .. }),
470 );
471
472 Ok(LoadedTranscript {
473 facts,
474 fingerprint: TranscriptFingerprint(last_hash),
475 terminated,
476 })
477}
478
479#[derive(Debug, Clone, Default)]
481pub struct EntropyVerified {
482 pub derivation: Option<String>,
485 pub source: Option<String>,
490 pub contributions: usize,
492 pub values_verified: usize,
494}
495
496pub fn verify_entropy<'a>(
530 facts: impl IntoIterator<Item = &'a StepFact>,
531) -> Result<EntropyVerified, VerifyError> {
532 let mut seed: Option<[u8; 32]> = None;
533 let mut epoch: u32 = 0;
534 let mut drawn_paths: HashSet<String> = HashSet::new();
535 let mut result = EntropyVerified::default();
536
537 for fact in facts {
538 match fact {
539 StepFact::EntropySeeded {
540 m,
541 source,
542 derivation,
543 ..
544 } => {
545 if seed.is_some() {
546 return Err(VerifyError::DuplicateSeed);
547 }
548 if derivation != crate::entropy::DERIVATION_V1 {
549 return Err(VerifyError::UnknownDerivation(derivation.clone()));
550 }
551 let m_bytes = base16ct::lower::decode_vec(m)
552 .map_err(|e| VerifyError::MalformedEntropy(format!("seed m: {e}")))?;
553 seed = Some(crate::entropy::initial_seed(&m_bytes));
554 result.derivation = Some(derivation.clone());
555 result.source = Some(source.clone());
556 }
557 StepFact::EntropyContributed {
558 step,
559 epoch: recorded_epoch,
560 contribution,
561 ..
562 } => {
563 let current = seed.as_ref().ok_or(VerifyError::SeedMissing)?;
564 let expected = epoch.saturating_add(1);
565 if *recorded_epoch != expected {
566 return Err(VerifyError::EpochMismatch {
567 step: step.as_str().to_string(),
568 recorded: *recorded_epoch,
569 expected,
570 });
571 }
572 seed = Some(crate::entropy::fold_seed(current, contribution.as_bytes()));
573 epoch = expected;
574 result.contributions = result.contributions.saturating_add(1);
575 }
576 StepFact::EntropyDrawn {
577 step, path, value, ..
578 } => {
579 let current = seed.as_ref().ok_or(VerifyError::SeedMissing)?;
580 let expected_prefix = crate::entropy::build_path(epoch, step, "");
585 if !path.starts_with(&expected_prefix) || path.len() == expected_prefix.len() {
586 return Err(VerifyError::PathMismatch {
587 path: path.clone(),
588 expected_prefix,
589 });
590 }
591 if !drawn_paths.insert(path.clone()) {
592 return Err(VerifyError::DuplicateDrawPath { path: path.clone() });
593 }
594 let recorded = base16ct::lower::decode_vec(value)
597 .map_err(|e| VerifyError::MalformedEntropy(format!("draw {path}: {e}")))?;
598 if recorded.len() > crate::entropy::MAX_DRAW_LEN {
599 return Err(VerifyError::DrawTooLong {
600 path: path.clone(),
601 len: recorded.len(),
602 max: crate::entropy::MAX_DRAW_LEN,
603 });
604 }
605 let expected = crate::entropy::derive_value(current, path, recorded.len());
606 if expected != recorded {
607 return Err(VerifyError::DrawMismatch { path: path.clone() });
608 }
609 result.values_verified = result.values_verified.saturating_add(1);
610 }
611 _ => {}
612 }
613 }
614
615 Ok(result)
616}
617
618#[cfg(test)]
619mod tests {
620 use super::*;
621 use crate::test_support::fixed_test_time as test_at;
622
623 fn sample_fact() -> StepFact {
624 StepFact::CeremonyStarted {
625 name: "Test".to_string(),
626 }
627 }
628
629 #[test]
630 fn in_memory_sink_records_and_finalizes() {
631 let mut sink = InMemorySink::new();
632 assert!(sink.is_empty());
633 sink.record(test_at(), &sample_fact()).expect("record");
634 assert_eq!(sink.len(), 1);
635 let fp = sink.finalize().expect("finalize");
636 assert!(fp.as_str().starts_with("sha256:"));
637 }
638
639 #[test]
640 fn in_memory_sink_rejects_record_after_finalize() {
641 let mut sink = InMemorySink::new();
642 sink.record(test_at(), &sample_fact()).expect("record");
643 sink.finalize().expect("finalize");
644 let err = sink
645 .record(test_at(), &sample_fact())
646 .expect_err("should fail");
647 assert!(err.to_string().contains("already finalized"));
648 }
649
650 #[test]
651 fn in_memory_sink_chain_advances() {
652 let mut sink = InMemorySink::new();
653 let initial = sink.current_hash.clone();
654 sink.record(test_at(), &sample_fact()).expect("record");
655 assert_ne!(sink.current_hash, initial);
656 let after_first = sink.current_hash.clone();
657 sink.record(test_at(), &sample_fact()).expect("record");
658 assert_ne!(sink.current_hash, after_first);
659 }
660
661 #[test]
662 fn record_persists_the_supplied_time_and_reader_recovers_it() {
663 let tmp = tempfile::tempdir().expect("tempdir");
666 let mut sink = JsonlFileSink::create(tmp.path()).expect("create");
667 let at = test_at();
668 sink.record(at, &sample_fact()).expect("record");
669 sink.finalize().expect("finalize");
670
671 let loaded = read_verified_transcript(sink.jsonl_path()).expect("read");
672 assert_eq!(loaded.facts.first().expect("one fact").at, at);
673 }
674
675 #[test]
676 fn jsonl_file_sink_writes_and_chains() {
677 let tmp = tempfile::tempdir().expect("tempdir");
678 let mut sink = JsonlFileSink::create(tmp.path()).expect("create");
679 sink.record(test_at(), &sample_fact()).expect("record");
680 sink.record(test_at(), &sample_fact()).expect("record");
681 let fp = sink.finalize().expect("finalize");
682
683 let jsonl = std::fs::read_to_string(sink.jsonl_path()).expect("read jsonl");
684 let mut lines = jsonl.lines();
685 let raw_first = lines.next().expect("first line");
686 let raw_second = lines.next().expect("second line");
687 assert!(lines.next().is_none(), "expected exactly two lines");
688
689 let first: serde_json::Value = serde_json::from_str(raw_first).expect("parse first");
690 let second: serde_json::Value = serde_json::from_str(raw_second).expect("parse second");
691 assert_eq!(
692 first.get("prev_hash").and_then(serde_json::Value::as_str),
693 Some(GENESIS_HASH),
694 );
695 let first_hash = compute_fingerprint(raw_first.as_bytes());
696 assert_eq!(
697 second.get("prev_hash").and_then(serde_json::Value::as_str),
698 Some(first_hash.as_str()),
699 );
700 let second_hash = compute_fingerprint(raw_second.as_bytes());
701 assert_eq!(fp.as_str(), second_hash);
702 }
703
704 #[test]
705 fn jsonl_file_sink_refuses_existing_file() {
706 let tmp = tempfile::tempdir().expect("tempdir");
707 let _first = JsonlFileSink::create(tmp.path()).expect("first");
708 let err = JsonlFileSink::create(tmp.path()).expect_err("second should fail");
709 assert_eq!(err.kind(), io::ErrorKind::AlreadyExists);
710 }
711
712 #[test]
713 fn verify_round_trip_succeeds_on_well_formed_transcript() {
714 let tmp = tempfile::tempdir().expect("tempdir");
715 let mut sink = JsonlFileSink::create(tmp.path()).expect("create");
716 sink.record(test_at(), &sample_fact()).expect("record");
717 sink.record(test_at(), &sample_fact()).expect("record");
718 sink.record(test_at(), &sample_fact()).expect("record");
719 let written = sink.finalize().expect("finalize");
720
721 let verified = verify_transcript(sink.jsonl_path()).expect("verify");
722 assert_eq!(verified.fact_count, 3);
723 assert_eq!(verified.fingerprint, written);
724 assert!(!verified.terminated);
726 }
727
728 #[test]
729 fn verify_detects_chain_break() {
730 let tmp = tempfile::tempdir().expect("tempdir");
731 let mut sink = JsonlFileSink::create(tmp.path()).expect("create");
732 sink.record(test_at(), &sample_fact()).expect("record");
733 sink.record(test_at(), &sample_fact()).expect("record");
734 let _ = sink.finalize().expect("finalize");
735
736 let path = sink.jsonl_path().to_path_buf();
737 let content = std::fs::read_to_string(&path).expect("read");
738 let mut lines: Vec<String> = content.lines().map(String::from).collect();
739 if let Some(second) = lines.get_mut(1) {
740 *second = second.replace("prev_hash\":\"sha256:", "prev_hash\":\"sha256:ff");
741 }
742 std::fs::write(&path, lines.join("\n") + "\n").expect("write");
743
744 let err = verify_transcript(&path).expect_err("should detect tamper");
745 assert!(matches!(err, VerifyError::BrokenChain { .. }));
746 }
747
748 #[test]
749 fn verify_empty_transcript_returns_empty_error() {
750 let tmp = tempfile::tempdir().expect("tempdir");
751 let path = tmp.path().join("transcript.jsonl");
752 std::fs::write(&path, "").expect("write empty");
753 let err = verify_transcript(&path).expect_err("empty");
754 assert!(matches!(err, VerifyError::Empty));
755 }
756
757 use rite_model::StepId;
758
759 fn seeded_fact(m: &[u8]) -> StepFact {
760 StepFact::EntropySeeded {
761 m: base16ct::lower::encode_string(m),
762 source: "os".to_string(),
763 derivation: crate::entropy::DERIVATION_V1.to_string(),
764 }
765 }
766
767 fn drawn_fact(seed: &[u8; 32], step: &str, path: &str, len: usize) -> StepFact {
768 StepFact::EntropyDrawn {
769 step: StepId::new(step),
770 path: path.to_string(),
771 value: base16ct::lower::encode_string(&crate::entropy::derive_value(seed, path, len)),
772 }
773 }
774
775 #[test]
776 fn verify_entropy_re_derives_a_clean_draw() {
777 let m = b"machine entropy bytes";
778 let seed = crate::entropy::initial_seed(m);
779 let facts = [
780 seeded_fact(m),
781 drawn_fact(&seed, "issue", "0/issue/cert-serial", 9),
782 ];
783 let v = verify_entropy(facts.iter()).expect("verify");
784 assert_eq!(v.values_verified, 1);
785 assert_eq!(v.contributions, 0);
786 assert_eq!(v.derivation.as_deref(), Some("rite-kdf/v1"));
787 }
788
789 #[test]
790 fn verify_entropy_follows_the_ratchet_through_a_contribution() {
791 let m = b"machine entropy bytes";
792 let seed0 = crate::entropy::initial_seed(m);
793 let seed1 = crate::entropy::fold_seed(&seed0, b"3 1 6 4 2 5");
794 let facts = [
795 seeded_fact(m),
796 StepFact::EntropyContributed {
797 step: StepId::new("roll"),
798 epoch: 1,
799 contribution: "3 1 6 4 2 5".to_string(),
800 },
801 drawn_fact(&seed1, "issue", "1/issue/cert-serial", 9),
803 ];
804 let v = verify_entropy(facts.iter()).expect("verify");
805 assert_eq!(v.contributions, 1);
806 assert_eq!(v.values_verified, 1);
807 }
808
809 #[test]
810 fn verify_entropy_detects_a_tampered_value() {
811 let m = b"machine entropy bytes";
812 let seed = crate::entropy::initial_seed(m);
813 let mut drawn = drawn_fact(&seed, "issue", "0/issue/cert-serial", 9);
814 if let StepFact::EntropyDrawn { value, .. } = &mut drawn {
815 *value = "deadbeefdeadbeefdead".to_string();
816 }
817 let facts = [seeded_fact(m), drawn];
818 let err = verify_entropy(facts.iter()).expect_err("tamper");
819 assert!(matches!(err, VerifyError::DrawMismatch { .. }));
820 }
821
822 #[test]
823 fn verify_entropy_rejects_unknown_derivation() {
824 let facts = [StepFact::EntropySeeded {
825 m: "00".to_string(),
826 source: "os".to_string(),
827 derivation: "rite-kdf/v99".to_string(),
828 }];
829 let err = verify_entropy(facts.iter()).expect_err("unknown scheme");
830 assert!(matches!(err, VerifyError::UnknownDerivation(_)));
831 }
832
833 #[test]
834 fn verify_entropy_rejects_draw_before_seed() {
835 let seed = crate::entropy::initial_seed(b"x");
836 let facts = [drawn_fact(&seed, "issue", "0/issue/cert-serial", 9)];
837 let err = verify_entropy(facts.iter()).expect_err("unseeded");
838 assert!(matches!(err, VerifyError::SeedMissing));
839 }
840
841 #[test]
842 fn verify_entropy_surfaces_the_seed_source() {
843 let m = b"machine entropy bytes";
844 let v = verify_entropy([seeded_fact(m)].iter()).expect("verify");
845 assert_eq!(v.source.as_deref(), Some("os"));
846 }
847
848 #[test]
849 fn verify_entropy_rejects_a_second_seed() {
850 let facts = [seeded_fact(b"first"), seeded_fact(b"second")];
853 let err = verify_entropy(facts.iter()).expect_err("re-seed");
854 assert!(matches!(err, VerifyError::DuplicateSeed));
855 }
856
857 #[test]
858 fn verify_entropy_rejects_an_oversized_draw_without_panicking() {
859 let m = b"machine entropy bytes";
863 let oversized = "ab".repeat(crate::entropy::MAX_DRAW_LEN.saturating_add(1));
864 let facts = [
865 seeded_fact(m),
866 StepFact::EntropyDrawn {
867 step: StepId::new("issue"),
868 path: "0/issue/cert-serial".to_string(),
869 value: oversized,
870 },
871 ];
872 let err = verify_entropy(facts.iter()).expect_err("oversized draw");
873 assert!(matches!(err, VerifyError::DrawTooLong { len, .. } if len == 8161));
874 }
875
876 #[test]
877 fn verify_entropy_rejects_a_draw_path_with_the_wrong_epoch() {
878 let m = b"machine entropy bytes";
881 let seed = crate::entropy::initial_seed(m);
882 let facts = [
883 seeded_fact(m),
884 drawn_fact(&seed, "issue", "1/issue/cert-serial", 9),
885 ];
886 let err = verify_entropy(facts.iter()).expect_err("wrong epoch");
887 assert!(matches!(err, VerifyError::PathMismatch { .. }));
888 }
889
890 #[test]
891 fn verify_entropy_rejects_a_draw_path_with_the_wrong_step() {
892 let m = b"machine entropy bytes";
893 let seed = crate::entropy::initial_seed(m);
894 let facts = [
895 seeded_fact(m),
896 drawn_fact(&seed, "issue", "0/other/cert-serial", 9),
898 ];
899 let err = verify_entropy(facts.iter()).expect_err("wrong step");
900 assert!(matches!(err, VerifyError::PathMismatch { .. }));
901 }
902
903 #[test]
904 fn verify_entropy_rejects_an_empty_purpose() {
905 let m = b"machine entropy bytes";
906 let seed = crate::entropy::initial_seed(m);
907 let facts = [seeded_fact(m), drawn_fact(&seed, "issue", "0/issue/", 9)];
908 let err = verify_entropy(facts.iter()).expect_err("empty purpose");
909 assert!(matches!(err, VerifyError::PathMismatch { .. }));
910 }
911
912 #[test]
913 fn verify_entropy_rejects_a_contribution_epoch_out_of_sequence() {
914 let m = b"machine entropy bytes";
915 let facts = [
916 seeded_fact(m),
917 StepFact::EntropyContributed {
918 step: StepId::new("roll"),
919 epoch: 2,
921 contribution: "3 1 6 4 2 5".to_string(),
922 },
923 ];
924 let err = verify_entropy(facts.iter()).expect_err("epoch skip");
925 assert!(matches!(
926 err,
927 VerifyError::EpochMismatch {
928 recorded: 2,
929 expected: 1,
930 ..
931 }
932 ));
933 }
934
935 #[test]
936 fn verify_entropy_rejects_a_duplicate_draw_path() {
937 let m = b"machine entropy bytes";
940 let seed = crate::entropy::initial_seed(m);
941 let facts = [
942 seeded_fact(m),
943 drawn_fact(&seed, "issue", "0/issue/cert-serial", 9),
944 drawn_fact(&seed, "issue", "0/issue/cert-serial", 9),
945 ];
946 let err = verify_entropy(facts.iter()).expect_err("duplicate path");
947 assert!(matches!(err, VerifyError::DuplicateDrawPath { .. }));
948 }
949}