1use std::fs::{File, OpenOptions};
29use std::io::{self, BufRead, BufReader, BufWriter, Write};
30use std::path::{Path, PathBuf};
31
32use serde::{Deserialize, Serialize};
33use thiserror::Error;
34
35use crate::transcript::compute_fingerprint;
36use rite_model::StepFact;
37
38#[derive(Debug, Clone, PartialEq, Eq)]
43pub struct TranscriptFingerprint(String);
44
45impl TranscriptFingerprint {
46 #[must_use]
48 pub fn from_string(s: String) -> Self {
49 Self(s)
50 }
51
52 #[must_use]
54 pub fn as_str(&self) -> &str {
55 &self.0
56 }
57}
58
59impl std::fmt::Display for TranscriptFingerprint {
60 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
61 f.write_str(&self.0)
62 }
63}
64
65pub const GENESIS_HASH: &str =
69 "sha256:0000000000000000000000000000000000000000000000000000000000000000";
70
71pub trait TranscriptSink: Send {
78 fn record(&mut self, fact: &StepFact) -> io::Result<()>;
86
87 fn finalize(&mut self) -> io::Result<TranscriptFingerprint>;
98}
99
100#[derive(Debug)]
108pub struct JsonlFileSink {
109 jsonl_path: PathBuf,
110 writer: BufWriter<File>,
111 current_hash: String,
112 finalized: Option<TranscriptFingerprint>,
113}
114
115impl JsonlFileSink {
116 pub fn create(dir: &Path) -> io::Result<Self> {
122 let jsonl_path = dir.join("transcript.jsonl");
123 let file = OpenOptions::new()
124 .create_new(true)
125 .write(true)
126 .open(&jsonl_path)?;
127 Ok(Self {
128 jsonl_path,
129 writer: BufWriter::new(file),
130 current_hash: GENESIS_HASH.to_string(),
131 finalized: None,
132 })
133 }
134
135 #[must_use]
137 pub fn jsonl_path(&self) -> &Path {
138 &self.jsonl_path
139 }
140}
141
142impl TranscriptSink for JsonlFileSink {
143 fn record(&mut self, fact: &StepFact) -> io::Result<()> {
144 if self.finalized.is_some() {
145 return Err(io::Error::other("transcript already finalized"));
146 }
147 let line = ChainedFact {
148 prev_hash: &self.current_hash,
149 fact,
150 };
151 let bytes = serde_json::to_vec(&line).map_err(io::Error::other)?;
152 self.current_hash = compute_fingerprint(&bytes);
153 self.writer.write_all(&bytes)?;
154 self.writer.write_all(b"\n")?;
155 self.writer.flush()?;
162 self.writer.get_ref().sync_data()?;
163 Ok(())
164 }
165
166 fn finalize(&mut self) -> io::Result<TranscriptFingerprint> {
167 if let Some(existing) = &self.finalized {
168 return Ok(existing.clone());
169 }
170 let fingerprint = TranscriptFingerprint(self.current_hash.clone());
171 self.finalized = Some(fingerprint.clone());
172 Ok(fingerprint)
173 }
174}
175
176#[derive(Debug, Default)]
181pub struct InMemorySink {
182 facts: Vec<StepFact>,
183 current_hash: String,
184 finalized: Option<TranscriptFingerprint>,
185}
186
187impl InMemorySink {
188 #[must_use]
190 pub fn new() -> Self {
191 Self {
192 facts: Vec::new(),
193 current_hash: GENESIS_HASH.to_string(),
194 finalized: None,
195 }
196 }
197
198 #[must_use]
200 pub fn facts(&self) -> &[StepFact] {
201 &self.facts
202 }
203
204 #[must_use]
206 pub fn len(&self) -> usize {
207 self.facts.len()
208 }
209
210 #[must_use]
212 pub fn is_empty(&self) -> bool {
213 self.facts.is_empty()
214 }
215}
216
217impl TranscriptSink for InMemorySink {
218 fn record(&mut self, fact: &StepFact) -> io::Result<()> {
219 if self.finalized.is_some() {
220 return Err(io::Error::other("transcript already finalized"));
221 }
222 let line = ChainedFact {
223 prev_hash: &self.current_hash,
224 fact,
225 };
226 let bytes = serde_json::to_vec(&line).map_err(io::Error::other)?;
227 self.current_hash = compute_fingerprint(&bytes);
228 self.facts.push(fact.clone());
229 Ok(())
230 }
231
232 fn finalize(&mut self) -> io::Result<TranscriptFingerprint> {
233 if let Some(existing) = &self.finalized {
234 return Ok(existing.clone());
235 }
236 let fingerprint = TranscriptFingerprint(self.current_hash.clone());
237 self.finalized = Some(fingerprint.clone());
238 Ok(fingerprint)
239 }
240}
241
242#[derive(Serialize)]
243struct ChainedFact<'a> {
244 prev_hash: &'a str,
245 fact: &'a StepFact,
246}
247
248#[derive(Deserialize)]
249struct OwnedChainedFact {
250 prev_hash: String,
251 fact: StepFact,
252}
253
254#[derive(Debug, Clone)]
256pub struct TranscriptVerified {
257 pub fact_count: usize,
259 pub fingerprint: TranscriptFingerprint,
261 pub terminated: bool,
265}
266
267#[derive(Debug, Error)]
269pub enum VerifyError {
270 #[error("io error: {0}")]
272 Io(#[from] io::Error),
273 #[error("line {line} is not a valid chained fact: {reason}")]
275 InvalidLine {
276 line: usize,
278 reason: String,
280 },
281 #[error("broken chain at line {line}: expected prev_hash {expected}, got {actual}")]
284 BrokenChain {
285 line: usize,
287 expected: String,
289 actual: String,
291 },
292 #[error("transcript is empty")]
294 Empty,
295}
296
297pub fn verify_transcript(jsonl_path: &Path) -> Result<TranscriptVerified, VerifyError> {
307 let loaded = read_verified_transcript(jsonl_path)?;
308 Ok(TranscriptVerified {
309 fact_count: loaded.facts.len(),
310 fingerprint: loaded.fingerprint,
311 terminated: loaded.terminated,
312 })
313}
314
315#[derive(Debug, Clone)]
318pub struct LoadedTranscript {
319 pub facts: Vec<StepFact>,
321 pub fingerprint: TranscriptFingerprint,
323 pub terminated: bool,
325}
326
327pub fn read_verified_transcript(jsonl_path: &Path) -> Result<LoadedTranscript, VerifyError> {
337 let file = File::open(jsonl_path)?;
338 let reader = BufReader::new(file);
339
340 let mut expected_prev = GENESIS_HASH.to_string();
341 let mut last_hash = expected_prev.clone();
342 let mut facts: Vec<StepFact> = Vec::new();
343
344 for (idx, line) in reader.lines().enumerate() {
345 let line = line?;
346 let line_no = idx.saturating_add(1);
347 let parsed: OwnedChainedFact =
348 serde_json::from_str(&line).map_err(|e| VerifyError::InvalidLine {
349 line: line_no,
350 reason: e.to_string(),
351 })?;
352 if parsed.prev_hash != expected_prev {
353 return Err(VerifyError::BrokenChain {
354 line: line_no,
355 expected: expected_prev,
356 actual: parsed.prev_hash,
357 });
358 }
359 last_hash = compute_fingerprint(line.as_bytes());
360 expected_prev.clone_from(&last_hash);
361 facts.push(parsed.fact);
362 }
363
364 if facts.is_empty() {
365 return Err(VerifyError::Empty);
366 }
367
368 let terminated = matches!(
369 facts.last(),
370 Some(StepFact::CeremonyCompleted { .. } | StepFact::CeremonyFailed { .. }),
371 );
372
373 Ok(LoadedTranscript {
374 facts,
375 fingerprint: TranscriptFingerprint(last_hash),
376 terminated,
377 })
378}
379
380#[cfg(test)]
381mod tests {
382 use chrono::Utc;
383
384 use super::*;
385
386 fn sample_fact() -> StepFact {
387 StepFact::CeremonyStarted {
388 name: "Test".to_string(),
389 started_at: Utc::now(),
390 }
391 }
392
393 #[test]
394 fn in_memory_sink_records_and_finalizes() {
395 let mut sink = InMemorySink::new();
396 assert!(sink.is_empty());
397 sink.record(&sample_fact()).expect("record");
398 assert_eq!(sink.len(), 1);
399 let fp = sink.finalize().expect("finalize");
400 assert!(fp.as_str().starts_with("sha256:"));
401 }
402
403 #[test]
404 fn in_memory_sink_rejects_record_after_finalize() {
405 let mut sink = InMemorySink::new();
406 sink.record(&sample_fact()).expect("record");
407 sink.finalize().expect("finalize");
408 let err = sink.record(&sample_fact()).expect_err("should fail");
409 assert!(err.to_string().contains("already finalized"));
410 }
411
412 #[test]
413 fn in_memory_sink_chain_advances() {
414 let mut sink = InMemorySink::new();
415 let initial = sink.current_hash.clone();
416 sink.record(&sample_fact()).expect("record");
417 assert_ne!(sink.current_hash, initial);
418 let after_first = sink.current_hash.clone();
419 sink.record(&sample_fact()).expect("record");
420 assert_ne!(sink.current_hash, after_first);
421 }
422
423 #[test]
424 fn jsonl_file_sink_writes_and_chains() {
425 let tmp = tempfile::tempdir().expect("tempdir");
426 let mut sink = JsonlFileSink::create(tmp.path()).expect("create");
427 sink.record(&sample_fact()).expect("record");
428 sink.record(&sample_fact()).expect("record");
429 let fp = sink.finalize().expect("finalize");
430
431 let jsonl = std::fs::read_to_string(sink.jsonl_path()).expect("read jsonl");
432 let mut lines = jsonl.lines();
433 let raw_first = lines.next().expect("first line");
434 let raw_second = lines.next().expect("second line");
435 assert!(lines.next().is_none(), "expected exactly two lines");
436
437 let first: serde_json::Value = serde_json::from_str(raw_first).expect("parse first");
438 let second: serde_json::Value = serde_json::from_str(raw_second).expect("parse second");
439 assert_eq!(
440 first.get("prev_hash").and_then(serde_json::Value::as_str),
441 Some(GENESIS_HASH),
442 );
443 let first_hash = compute_fingerprint(raw_first.as_bytes());
444 assert_eq!(
445 second.get("prev_hash").and_then(serde_json::Value::as_str),
446 Some(first_hash.as_str()),
447 );
448 let second_hash = compute_fingerprint(raw_second.as_bytes());
449 assert_eq!(fp.as_str(), second_hash);
450 }
451
452 #[test]
453 fn jsonl_file_sink_refuses_existing_file() {
454 let tmp = tempfile::tempdir().expect("tempdir");
455 let _first = JsonlFileSink::create(tmp.path()).expect("first");
456 let err = JsonlFileSink::create(tmp.path()).expect_err("second should fail");
457 assert_eq!(err.kind(), io::ErrorKind::AlreadyExists);
458 }
459
460 #[test]
461 fn verify_round_trip_succeeds_on_well_formed_transcript() {
462 let tmp = tempfile::tempdir().expect("tempdir");
463 let mut sink = JsonlFileSink::create(tmp.path()).expect("create");
464 sink.record(&sample_fact()).expect("record");
465 sink.record(&sample_fact()).expect("record");
466 sink.record(&sample_fact()).expect("record");
467 let written = sink.finalize().expect("finalize");
468
469 let verified = verify_transcript(sink.jsonl_path()).expect("verify");
470 assert_eq!(verified.fact_count, 3);
471 assert_eq!(verified.fingerprint, written);
472 assert!(!verified.terminated);
474 }
475
476 #[test]
477 fn verify_detects_chain_break() {
478 let tmp = tempfile::tempdir().expect("tempdir");
479 let mut sink = JsonlFileSink::create(tmp.path()).expect("create");
480 sink.record(&sample_fact()).expect("record");
481 sink.record(&sample_fact()).expect("record");
482 let _ = sink.finalize().expect("finalize");
483
484 let path = sink.jsonl_path().to_path_buf();
485 let content = std::fs::read_to_string(&path).expect("read");
486 let mut lines: Vec<String> = content.lines().map(String::from).collect();
487 if let Some(second) = lines.get_mut(1) {
488 *second = second.replace("prev_hash\":\"sha256:", "prev_hash\":\"sha256:ff");
489 }
490 std::fs::write(&path, lines.join("\n") + "\n").expect("write");
491
492 let err = verify_transcript(&path).expect_err("should detect tamper");
493 assert!(matches!(err, VerifyError::BrokenChain { .. }));
494 }
495
496 #[test]
497 fn verify_empty_transcript_returns_empty_error() {
498 let tmp = tempfile::tempdir().expect("tempdir");
499 let path = tmp.path().join("transcript.jsonl");
500 std::fs::write(&path, "").expect("write empty");
501 let err = verify_transcript(&path).expect_err("empty");
502 assert!(matches!(err, VerifyError::Empty));
503 }
504}