use std::fs::{File, OpenOptions};
use std::io::{self, BufRead, BufReader, BufWriter, Write};
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use thiserror::Error;
use crate::transcript::compute_fingerprint;
use rite_model::StepFact;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TranscriptFingerprint(String);
impl TranscriptFingerprint {
#[must_use]
pub fn from_string(s: String) -> Self {
Self(s)
}
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
}
impl std::fmt::Display for TranscriptFingerprint {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
pub const GENESIS_HASH: &str =
"sha256:0000000000000000000000000000000000000000000000000000000000000000";
pub trait TranscriptSink: Send {
fn record(&mut self, fact: &StepFact) -> io::Result<()>;
fn finalize(&mut self) -> io::Result<TranscriptFingerprint>;
}
#[derive(Debug)]
pub struct JsonlFileSink {
jsonl_path: PathBuf,
writer: BufWriter<File>,
current_hash: String,
finalized: Option<TranscriptFingerprint>,
}
impl JsonlFileSink {
pub fn create(dir: &Path) -> io::Result<Self> {
let jsonl_path = dir.join("transcript.jsonl");
let file = OpenOptions::new()
.create_new(true)
.write(true)
.open(&jsonl_path)?;
Ok(Self {
jsonl_path,
writer: BufWriter::new(file),
current_hash: GENESIS_HASH.to_string(),
finalized: None,
})
}
#[must_use]
pub fn jsonl_path(&self) -> &Path {
&self.jsonl_path
}
}
impl TranscriptSink for JsonlFileSink {
fn record(&mut self, fact: &StepFact) -> io::Result<()> {
if self.finalized.is_some() {
return Err(io::Error::other("transcript already finalized"));
}
let line = ChainedFact {
prev_hash: &self.current_hash,
fact,
};
let bytes = serde_json::to_vec(&line).map_err(io::Error::other)?;
self.current_hash = compute_fingerprint(&bytes);
self.writer.write_all(&bytes)?;
self.writer.write_all(b"\n")?;
self.writer.flush()?;
self.writer.get_ref().sync_data()?;
Ok(())
}
fn finalize(&mut self) -> io::Result<TranscriptFingerprint> {
if let Some(existing) = &self.finalized {
return Ok(existing.clone());
}
let fingerprint = TranscriptFingerprint(self.current_hash.clone());
self.finalized = Some(fingerprint.clone());
Ok(fingerprint)
}
}
#[derive(Debug, Default)]
pub struct InMemorySink {
facts: Vec<StepFact>,
current_hash: String,
finalized: Option<TranscriptFingerprint>,
}
impl InMemorySink {
#[must_use]
pub fn new() -> Self {
Self {
facts: Vec::new(),
current_hash: GENESIS_HASH.to_string(),
finalized: None,
}
}
#[must_use]
pub fn facts(&self) -> &[StepFact] {
&self.facts
}
#[must_use]
pub fn len(&self) -> usize {
self.facts.len()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.facts.is_empty()
}
}
impl TranscriptSink for InMemorySink {
fn record(&mut self, fact: &StepFact) -> io::Result<()> {
if self.finalized.is_some() {
return Err(io::Error::other("transcript already finalized"));
}
let line = ChainedFact {
prev_hash: &self.current_hash,
fact,
};
let bytes = serde_json::to_vec(&line).map_err(io::Error::other)?;
self.current_hash = compute_fingerprint(&bytes);
self.facts.push(fact.clone());
Ok(())
}
fn finalize(&mut self) -> io::Result<TranscriptFingerprint> {
if let Some(existing) = &self.finalized {
return Ok(existing.clone());
}
let fingerprint = TranscriptFingerprint(self.current_hash.clone());
self.finalized = Some(fingerprint.clone());
Ok(fingerprint)
}
}
#[derive(Serialize)]
struct ChainedFact<'a> {
prev_hash: &'a str,
fact: &'a StepFact,
}
#[derive(Deserialize)]
struct OwnedChainedFact {
prev_hash: String,
fact: StepFact,
}
#[derive(Debug, Clone)]
pub struct TranscriptVerified {
pub fact_count: usize,
pub fingerprint: TranscriptFingerprint,
pub terminated: bool,
}
#[derive(Debug, Error)]
pub enum VerifyError {
#[error("io error: {0}")]
Io(#[from] io::Error),
#[error("line {line} is not a valid chained fact: {reason}")]
InvalidLine {
line: usize,
reason: String,
},
#[error("broken chain at line {line}: expected prev_hash {expected}, got {actual}")]
BrokenChain {
line: usize,
expected: String,
actual: String,
},
#[error("transcript is empty")]
Empty,
}
pub fn verify_transcript(jsonl_path: &Path) -> Result<TranscriptVerified, VerifyError> {
let loaded = read_verified_transcript(jsonl_path)?;
Ok(TranscriptVerified {
fact_count: loaded.facts.len(),
fingerprint: loaded.fingerprint,
terminated: loaded.terminated,
})
}
#[derive(Debug, Clone)]
pub struct LoadedTranscript {
pub facts: Vec<StepFact>,
pub fingerprint: TranscriptFingerprint,
pub terminated: bool,
}
pub fn read_verified_transcript(jsonl_path: &Path) -> Result<LoadedTranscript, VerifyError> {
let file = File::open(jsonl_path)?;
let reader = BufReader::new(file);
let mut expected_prev = GENESIS_HASH.to_string();
let mut last_hash = expected_prev.clone();
let mut facts: Vec<StepFact> = Vec::new();
for (idx, line) in reader.lines().enumerate() {
let line = line?;
let line_no = idx.saturating_add(1);
let parsed: OwnedChainedFact =
serde_json::from_str(&line).map_err(|e| VerifyError::InvalidLine {
line: line_no,
reason: e.to_string(),
})?;
if parsed.prev_hash != expected_prev {
return Err(VerifyError::BrokenChain {
line: line_no,
expected: expected_prev,
actual: parsed.prev_hash,
});
}
last_hash = compute_fingerprint(line.as_bytes());
expected_prev.clone_from(&last_hash);
facts.push(parsed.fact);
}
if facts.is_empty() {
return Err(VerifyError::Empty);
}
let terminated = matches!(
facts.last(),
Some(StepFact::CeremonyCompleted { .. } | StepFact::CeremonyFailed { .. }),
);
Ok(LoadedTranscript {
facts,
fingerprint: TranscriptFingerprint(last_hash),
terminated,
})
}
#[cfg(test)]
mod tests {
use chrono::Utc;
use super::*;
fn sample_fact() -> StepFact {
StepFact::CeremonyStarted {
name: "Test".to_string(),
started_at: Utc::now(),
}
}
#[test]
fn in_memory_sink_records_and_finalizes() {
let mut sink = InMemorySink::new();
assert!(sink.is_empty());
sink.record(&sample_fact()).expect("record");
assert_eq!(sink.len(), 1);
let fp = sink.finalize().expect("finalize");
assert!(fp.as_str().starts_with("sha256:"));
}
#[test]
fn in_memory_sink_rejects_record_after_finalize() {
let mut sink = InMemorySink::new();
sink.record(&sample_fact()).expect("record");
sink.finalize().expect("finalize");
let err = sink.record(&sample_fact()).expect_err("should fail");
assert!(err.to_string().contains("already finalized"));
}
#[test]
fn in_memory_sink_chain_advances() {
let mut sink = InMemorySink::new();
let initial = sink.current_hash.clone();
sink.record(&sample_fact()).expect("record");
assert_ne!(sink.current_hash, initial);
let after_first = sink.current_hash.clone();
sink.record(&sample_fact()).expect("record");
assert_ne!(sink.current_hash, after_first);
}
#[test]
fn jsonl_file_sink_writes_and_chains() {
let tmp = tempfile::tempdir().expect("tempdir");
let mut sink = JsonlFileSink::create(tmp.path()).expect("create");
sink.record(&sample_fact()).expect("record");
sink.record(&sample_fact()).expect("record");
let fp = sink.finalize().expect("finalize");
let jsonl = std::fs::read_to_string(sink.jsonl_path()).expect("read jsonl");
let mut lines = jsonl.lines();
let raw_first = lines.next().expect("first line");
let raw_second = lines.next().expect("second line");
assert!(lines.next().is_none(), "expected exactly two lines");
let first: serde_json::Value = serde_json::from_str(raw_first).expect("parse first");
let second: serde_json::Value = serde_json::from_str(raw_second).expect("parse second");
assert_eq!(
first.get("prev_hash").and_then(serde_json::Value::as_str),
Some(GENESIS_HASH),
);
let first_hash = compute_fingerprint(raw_first.as_bytes());
assert_eq!(
second.get("prev_hash").and_then(serde_json::Value::as_str),
Some(first_hash.as_str()),
);
let second_hash = compute_fingerprint(raw_second.as_bytes());
assert_eq!(fp.as_str(), second_hash);
}
#[test]
fn jsonl_file_sink_refuses_existing_file() {
let tmp = tempfile::tempdir().expect("tempdir");
let _first = JsonlFileSink::create(tmp.path()).expect("first");
let err = JsonlFileSink::create(tmp.path()).expect_err("second should fail");
assert_eq!(err.kind(), io::ErrorKind::AlreadyExists);
}
#[test]
fn verify_round_trip_succeeds_on_well_formed_transcript() {
let tmp = tempfile::tempdir().expect("tempdir");
let mut sink = JsonlFileSink::create(tmp.path()).expect("create");
sink.record(&sample_fact()).expect("record");
sink.record(&sample_fact()).expect("record");
sink.record(&sample_fact()).expect("record");
let written = sink.finalize().expect("finalize");
let verified = verify_transcript(sink.jsonl_path()).expect("verify");
assert_eq!(verified.fact_count, 3);
assert_eq!(verified.fingerprint, written);
assert!(!verified.terminated);
}
#[test]
fn verify_detects_chain_break() {
let tmp = tempfile::tempdir().expect("tempdir");
let mut sink = JsonlFileSink::create(tmp.path()).expect("create");
sink.record(&sample_fact()).expect("record");
sink.record(&sample_fact()).expect("record");
let _ = sink.finalize().expect("finalize");
let path = sink.jsonl_path().to_path_buf();
let content = std::fs::read_to_string(&path).expect("read");
let mut lines: Vec<String> = content.lines().map(String::from).collect();
if let Some(second) = lines.get_mut(1) {
*second = second.replace("prev_hash\":\"sha256:", "prev_hash\":\"sha256:ff");
}
std::fs::write(&path, lines.join("\n") + "\n").expect("write");
let err = verify_transcript(&path).expect_err("should detect tamper");
assert!(matches!(err, VerifyError::BrokenChain { .. }));
}
#[test]
fn verify_empty_transcript_returns_empty_error() {
let tmp = tempfile::tempdir().expect("tempdir");
let path = tmp.path().join("transcript.jsonl");
std::fs::write(&path, "").expect("write empty");
let err = verify_transcript(&path).expect_err("empty");
assert!(matches!(err, VerifyError::Empty));
}
}