Skip to main content

rite_runtime/
transcript_sink.rs

1//! Transcript sink, the durable consumer of [`StepFact`]s.
2//!
3//! The sink is an inline observer in the executor thread. It records each
4//! [`StepFact`] synchronously before the executor proceeds to its next action,
5//! and the on-disk implementation `fsync`s the line to the storage device
6//! before returning. This invariant, *the transcript is durable before the
7//! UI sees the fact* , is what makes the on-disk record the authoritative
8//! source of evidence and the live UI a tee of the same stream.
9//!
10//! # On-disk format (`transcript.jsonl`)
11//!
12//! Each line is a JSON object with three fields:
13//!
14//! ```jsonc
15//! {"prev_hash": "sha256:…", "at": "2026-06-01T20:34:51Z", "fact": { "type": "step_started", … }}
16//! ```
17//!
18//! `at` is the event's wall-clock time, supplied by the executor's clock when
19//! it emits the fact (the sink records it rather than choosing it, so the time
20//! is independent of the storage backend). It is the single uniform timestamp
21//! for every event, and part of the hashed line, so it is tamper-evident like
22//! the rest of the envelope.
23//!
24//! The chain is verified by recomputing each line's SHA-256, accumulating it
25//! as the expected `prev_hash` of the next line, starting from
26//! [`GENESIS_HASH`]. The final transcript fingerprint is the hash of the
27//! last line; the JSONL is self-identifying and no sidecar file is written.
28//!
29//! # Implementations
30//!
31//! - [`JsonlFileSink`], writes to disk, flushes after every line.
32//! - [`InMemorySink`], collects facts in a `Vec` for tests and tooling.
33
34use 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/// SHA-256 fingerprint produced by a [`TranscriptSink::finalize`] call.
47///
48/// Encoded as `sha256:<lowercase-hex>`, matching the convention used
49/// throughout the runtime for artifact and transcript fingerprints.
50#[derive(Debug, Clone, PartialEq, Eq)]
51pub struct TranscriptFingerprint(String);
52
53impl TranscriptFingerprint {
54    /// Build a fingerprint from an already-formatted `sha256:<hex>` string.
55    #[must_use]
56    pub fn from_string(s: String) -> Self {
57        Self(s)
58    }
59
60    /// `sha256:<hex>` representation.
61    #[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
73/// Genesis `prev_hash` value used on the very first line of every transcript.
74///
75/// `sha256:` followed by 64 zero hex digits.
76pub const GENESIS_HASH: &str =
77    "sha256:0000000000000000000000000000000000000000000000000000000000000000";
78
79/// Synchronous, durable observer of [`StepFact`]s.
80///
81/// Implementations must record each fact, including syncing it to the
82/// underlying storage where applicable, before returning. The executor
83/// relies on this to maintain the invariant that the UI never sees a fact
84/// that has not been durably persisted.
85pub trait TranscriptSink: Send {
86    /// Record a single fact, stamped with the caller-supplied event time `at`.
87    /// Must persist before returning, the file-backed implementation calls
88    /// `sync_data` so a power loss after `record` returns cannot drop the fact.
89    ///
90    /// `at` is supplied by the executor (the clock owner), not read here, so the
91    /// recorded time is independent of the persistence implementation and the
92    /// sink stays a deterministic serializer.
93    ///
94    /// # Errors
95    ///
96    /// Returns the underlying I/O error if the sink cannot persist the fact.
97    fn record(&mut self, at: DateTime<Utc>, fact: &StepFact) -> io::Result<()>;
98
99    /// Finalize the transcript and return its fingerprint.
100    ///
101    /// Calling `finalize` more than once is implementation-defined; the
102    /// default expectation is that the second call returns the cached
103    /// fingerprint.
104    ///
105    /// # Errors
106    ///
107    /// Returns the underlying I/O error if any pending state cannot be
108    /// persisted.
109    fn finalize(&mut self) -> io::Result<TranscriptFingerprint>;
110}
111
112/// JSONL file sink with SHA-256 chain integrity.
113///
114/// Writes `transcript.jsonl` to a target directory. Every `record` call
115/// serializes the fact, prepends the current chain head as `prev_hash`,
116/// writes one line, flushes, and advances the chain head. The transcript
117/// is self-identifying: `SHA-256` of the last line *is* the cryptographic
118/// fingerprint; no sidecar file is written.
119#[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    /// Create a new sink in `dir`. Writes `transcript.jsonl` next to it.
129    ///
130    /// # Errors
131    ///
132    /// Returns an I/O error if the JSONL file cannot be created.
133    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    /// Path to the JSONL file this sink is writing to.
148    #[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        // `flush` drains the BufWriter into the File; `sync_data` then
169        // forces the kernel to push the page-cache pages to the storage
170        // device before we return. Without the sync, a power loss between
171        // `record` and the OS's next writeback would drop already-reported
172        // facts even though the executor moved on. Cost at ceremony pace
173        // (one record every few seconds of human pace) is negligible.
174        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/// In-memory sink that collects every recorded [`StepFact`].
190///
191/// Useful for tests and for tooling that wants to inspect the fact stream
192/// without going through disk.
193#[derive(Debug, Default)]
194pub struct InMemorySink {
195    facts: Vec<StepFact>,
196    current_hash: String,
197    finalized: Option<TranscriptFingerprint>,
198}
199
200impl InMemorySink {
201    /// Create an empty sink.
202    #[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    /// Recorded facts in the order they arrived.
212    #[must_use]
213    pub fn facts(&self) -> &[StepFact] {
214        &self.facts
215    }
216
217    /// Number of facts recorded so far.
218    #[must_use]
219    pub fn len(&self) -> usize {
220        self.facts.len()
221    }
222
223    /// Whether any fact has been recorded.
224    #[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/// A recorded fact paired with its envelope timestamp: the uniform event time,
271/// which lives on the chain envelope rather than on individual facts. Returned
272/// by [`read_verified_transcript`] so consumers read event time uniformly.
273#[derive(Debug, Clone)]
274pub struct TimedFact {
275    /// Wall-clock time the fact was recorded.
276    pub at: DateTime<Utc>,
277    /// The recorded fact.
278    pub fact: StepFact,
279}
280
281/// Outcome of verifying a transcript on disk.
282#[derive(Debug, Clone)]
283pub struct TranscriptVerified {
284    /// Number of facts read and verified.
285    pub fact_count: usize,
286    /// Final transcript fingerprint (hash of the last line).
287    pub fingerprint: TranscriptFingerprint,
288    /// `true` if the last fact is a terminal one (`CeremonyCompleted` or
289    /// `CeremonyFailed`). `false` means the transcript was cut off
290    /// before the executor reached its finalize step, a truncated run.
291    pub terminated: bool,
292}
293
294/// Verification failure modes.
295#[derive(Debug, Error)]
296pub enum VerifyError {
297    /// Underlying I/O error reading the transcript.
298    #[error("io error: {0}")]
299    Io(#[from] io::Error),
300    /// A line did not parse as a [`ChainedFact`].
301    #[error("line {line} is not a valid chained fact: {reason}")]
302    InvalidLine {
303        /// 1-indexed line number.
304        line: usize,
305        /// Parse error.
306        reason: String,
307    },
308    /// The chain is broken: a line's `prev_hash` did not match the
309    /// expected value computed from the previous line.
310    #[error("broken chain at line {line}: expected prev_hash {expected}, got {actual}")]
311    BrokenChain {
312        /// 1-indexed line number where the break was detected.
313        line: usize,
314        /// The expected `prev_hash` (= hash of the previous line).
315        expected: String,
316        /// The `prev_hash` actually recorded on the line.
317        actual: String,
318    },
319    /// The transcript file has zero lines.
320    #[error("transcript is empty")]
321    Empty,
322    /// A value was drawn (or a contribution folded) before any
323    /// `EntropySeeded` fact established the source.
324    #[error("entropy source used before it was seeded")]
325    SeedMissing,
326    /// A second `EntropySeeded` fact appeared. The source is seeded exactly
327    /// once at ceremony start; accepting a re-seed would let a crafted
328    /// transcript swap the seed mid-stream and have later draws "verify"
329    /// against a seed of the forger's choosing.
330    #[error("entropy source seeded more than once")]
331    DuplicateSeed,
332    /// The seed fact declares a derivation scheme the verifier does not
333    /// recognise. Never trust an unknown scheme (the JWT-`alg` lesson).
334    #[error("unknown entropy derivation scheme: {0}")]
335    UnknownDerivation(String),
336    /// A recorded hex value (the seed `m` or a drawn value) did not decode.
337    #[error("malformed entropy value: {0}")]
338    MalformedEntropy(String),
339    /// A drawn value does not match what re-derivation from the recorded
340    /// seed and path produces: the source was tampered with.
341    #[error("entropy draw at path '{path}' does not match re-derived value")]
342    DrawMismatch {
343        /// Derivation path of the mismatched draw.
344        path: String,
345    },
346    /// A recorded draw is longer than `HKDF-Expand` can produce
347    /// ([`MAX_DRAW_LEN`](crate::entropy::MAX_DRAW_LEN) bytes), so it cannot
348    /// have come from the real source. Rejected up front: attempting the
349    /// re-derivation would abort the verifier instead of failing the check.
350    #[error(
351        "entropy draw at path '{path}' records {len} bytes, beyond the {max}-byte HKDF-Expand limit"
352    )]
353    DrawTooLong {
354        /// Derivation path of the oversized draw.
355        path: String,
356        /// Recorded value length in bytes.
357        len: usize,
358        /// The `HKDF-Expand` output limit.
359        max: usize,
360    },
361    /// A draw's recorded path does not start with the
362    /// `<epoch>/<step>/` prefix the verifier rebuilds from its own fold
363    /// count and the step named on the fact, so the draw is attributed to
364    /// the wrong epoch or step.
365    #[error("entropy draw path '{path}' does not match expected '{expected_prefix}<purpose>'")]
366    PathMismatch {
367        /// Recorded derivation path.
368        path: String,
369        /// The `<epoch>/<step>/` prefix the verifier expected.
370        expected_prefix: String,
371    },
372    /// A contribution's recorded epoch does not match its position in the
373    /// fold sequence (first contribution is epoch 1, and so on).
374    #[error("entropy contribution at step '{step}' records epoch {recorded}, expected {expected}")]
375    EpochMismatch {
376        /// Step that recorded the contribution.
377        step: String,
378        /// Epoch index recorded on the fact.
379        recorded: u32,
380        /// Epoch index implied by the fold count.
381        expected: u32,
382    },
383    /// The same derivation path was drawn twice. The runtime never reuses a
384    /// path, so a duplicate means the record was edited or replayed.
385    #[error("entropy draw path '{path}' recorded more than once")]
386    DuplicateDrawPath {
387        /// The repeated derivation path.
388        path: String,
389    },
390}
391
392/// Verify a JSONL transcript file produced by [`JsonlFileSink`].
393///
394/// Reads the file line-by-line, recomputes each line's SHA-256, and
395/// checks that `prev_hash` matches the previous line's hash.
396///
397/// # Errors
398///
399/// Returns [`VerifyError`] for any I/O failure, malformed line, or
400/// chain break.
401pub 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/// Verified transcript contents, chain-walked facts plus the final
411/// fingerprint and a flag for whether the run reached a terminal fact.
412#[derive(Debug, Clone)]
413pub struct LoadedTranscript {
414    /// Facts in the order they were recorded, each paired with the envelope
415    /// timestamp the sink stamped when it wrote the line.
416    pub facts: Vec<TimedFact>,
417    /// Final transcript fingerprint (hash of the last line).
418    pub fingerprint: TranscriptFingerprint,
419    /// `true` if the last fact is `CeremonyCompleted` or `CeremonyFailed`.
420    pub terminated: bool,
421}
422
423/// Verify a JSONL transcript and return its facts.
424///
425/// Same chain check as [`verify_transcript`], plus returns the
426/// deserialized [`StepFact`] stream and whether the last line is a
427/// terminal fact.
428///
429/// # Errors
430///
431/// Same as [`verify_transcript`].
432pub 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/// Outcome of re-deriving a transcript's entropy source.
480#[derive(Debug, Clone, Default)]
481pub struct EntropyVerified {
482    /// Derivation-scheme tag declared by the seed fact, if the ceremony used
483    /// the source at all.
484    pub derivation: Option<String>,
485    /// Provenance of the machine seed as recorded by the seed fact (e.g.
486    /// `os`, or the `dry-run` sentinel). Consumers surface this so a
487    /// dry-run transcript, which re-derives just as cleanly as a real one,
488    /// is never presented as real evidence.
489    pub source: Option<String>,
490    /// Number of human contributions folded into the ratchet.
491    pub contributions: usize,
492    /// Number of drawn values that re-derived to their recorded value.
493    pub values_verified: usize,
494}
495
496/// Re-derive a transcript's entropy source and confirm every drawn value.
497///
498/// Replays the `rite-kdf/v1` ratchet over the recorded facts: it rebuilds
499/// `seed_0` from the recorded machine entropy, folds each human contribution
500/// in chain order, and for every [`StepFact::EntropyDrawn`] re-derives the
501/// value from the recorded path and checks it against the recorded value. A
502/// tampered seed, contribution, path, or value fails the check.
503///
504/// Beyond the value re-derivation, the replay enforces the source's own
505/// invariants, so a transcript cannot record a fact stream the runtime could
506/// never have produced:
507///
508/// - the source is seeded exactly once;
509/// - each contribution's recorded `epoch` matches its position in the fold
510///   sequence;
511/// - each draw's recorded path carries the `<epoch>/<step>/` prefix rebuilt
512///   from the verifier's own fold count and the step named on the fact;
513/// - no derivation path is drawn twice;
514/// - no recorded value exceeds what `HKDF-Expand` can produce
515///   ([`MAX_DRAW_LEN`](crate::entropy::MAX_DRAW_LEN) bytes), which would
516///   otherwise abort the verifier instead of failing the check.
517///
518/// The caller is expected to have chain-verified the facts first (via
519/// [`read_verified_transcript`]); this function only re-derives.
520///
521/// # Errors
522///
523/// Returns a [`VerifyError`] variant describing the first inconsistency:
524/// [`VerifyError::SeedMissing`], [`VerifyError::DuplicateSeed`],
525/// [`VerifyError::UnknownDerivation`], [`VerifyError::MalformedEntropy`],
526/// [`VerifyError::EpochMismatch`], [`VerifyError::PathMismatch`],
527/// [`VerifyError::DuplicateDrawPath`], [`VerifyError::DrawTooLong`], or
528/// [`VerifyError::DrawMismatch`].
529pub 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                // Rebuild the path prefix from the verifier's own fold count
581                // and the step named on the fact; only the purpose segment is
582                // taken from the record. A draw attributed to the wrong epoch
583                // or step fails here even if its value re-derives.
584                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                // The recorded value's own length fixes how many bytes to
595                // re-derive; decoding it also rejects a malformed hex record.
596                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        // The sink records the `at` it is given (it does not read a clock), and
664        // the reader recovers exactly that instant from the envelope.
665        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        // Sample fact isn't a terminal one; the transcript is open-ended.
725        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 after the fold, so it must derive from the epoch-1 seed.
802            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        // A mid-stream re-seed would let a forged transcript swap the seed
851        // under later draws; the replay refuses rather than resetting.
852        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        // 8161 bytes is one past the HKDF-Expand limit (255 * 32). Re-deriving
860        // it would panic inside `expand`; the verifier must reject it as a
861        // malformed record instead of aborting.
862        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        // No contribution was folded, so the draw must come from epoch 0; a
879        // recorded epoch-1 path is a stream the runtime could not produce.
880        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            // Path names step `other` but the fact was recorded under `issue`.
897            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                // First fold must record epoch 1.
920                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        // The runtime never reuses a path; a repeat means the record was
938        // edited or replayed.
939        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}