use serde::{Deserialize, Deserializer, Serialize};
use super::{TranscriptRewriteCommit, TranscriptRewritePrefixAccumulator};
use crate::session::TranscriptEditError;
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[serde(rename_all = "snake_case")]
pub struct TranscriptRewriteAuditReceiptBatch {
start_prefix: TranscriptRewritePrefixAccumulator,
commits: Vec<TranscriptRewriteCommit>,
end_prefix: TranscriptRewritePrefixAccumulator,
}
impl TranscriptRewriteAuditReceiptBatch {
pub fn new(
start_prefix: TranscriptRewritePrefixAccumulator,
commits: Vec<TranscriptRewriteCommit>,
end_prefix: TranscriptRewritePrefixAccumulator,
) -> Result<Self, TranscriptEditError> {
if commits.is_empty() {
return Err(TranscriptEditError::HistoryStateMalformed(
"rewrite audit receipt batch cannot be empty".to_string(),
));
}
let mut rebuilt = start_prefix.clone();
for commit in &commits {
rebuilt = rebuilt
.extend(commit)
.map_err(|error| TranscriptEditError::HistoryStateMalformed(error.to_string()))?;
}
if rebuilt != end_prefix {
return Err(TranscriptEditError::HistoryStateMalformed(format!(
"rewrite audit receipt end prefix does not bind {} ordered occurrences",
commits.len()
)));
}
Ok(Self {
start_prefix,
commits,
end_prefix,
})
}
#[must_use]
pub fn start_prefix(&self) -> &TranscriptRewritePrefixAccumulator {
&self.start_prefix
}
#[must_use]
pub fn commits(&self) -> &[TranscriptRewriteCommit] {
&self.commits
}
#[must_use]
pub fn end_prefix(&self) -> &TranscriptRewritePrefixAccumulator {
&self.end_prefix
}
#[must_use]
pub fn into_commits(self) -> Vec<TranscriptRewriteCommit> {
self.commits
}
}
impl<'de> Deserialize<'de> for TranscriptRewriteAuditReceiptBatch {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
#[derive(Deserialize)]
#[serde(deny_unknown_fields, rename_all = "snake_case")]
struct Wire {
start_prefix: TranscriptRewritePrefixAccumulator,
commits: Vec<TranscriptRewriteCommit>,
end_prefix: TranscriptRewritePrefixAccumulator,
}
let wire = Wire::deserialize(deserializer)?;
Self::new(wire.start_prefix, wire.commits, wire.end_prefix)
.map_err(serde::de::Error::custom)
}
}
#[cfg(test)]
#[allow(clippy::expect_used)]
mod tests {
use super::*;
use crate::session::{TranscriptRewriteReason, TranscriptRewriteSelection};
use crate::time_compat::SystemTime;
fn commit(generation: u64) -> TranscriptRewriteCommit {
TranscriptRewriteCommit {
rewrite_generation: generation,
parent_revision: format!("parent-{generation}"),
revision: format!("revision-{generation}"),
selection: TranscriptRewriteSelection::MessageRange { start: 0, end: 1 },
original_span_digest: format!("original-{generation}"),
replacement_digest: format!("replacement-{generation}"),
messages_before: 1,
messages_after: 1,
reason: TranscriptRewriteReason::new("test"),
actor: None,
committed_at: SystemTime::UNIX_EPOCH,
}
}
#[test]
fn generation_eighty_receipt_binds_only_its_one_commit() {
let start: TranscriptRewritePrefixAccumulator = serde_json::from_value(serde_json::json!({
"occurrence_count": 79,
"digest": format!("sha256:{}", "0".repeat(64)),
}))
.expect("synthetic proved prefix is canonical");
let commit = commit(80);
let end = start.extend(&commit).expect("generation 80 extends 79");
let receipt = TranscriptRewriteAuditReceiptBatch::new(start, vec![commit], end)
.expect("one-commit receipt validates");
assert_eq!(receipt.commits().len(), 1);
assert_eq!(receipt.start_prefix().occurrence_count(), 79);
assert_eq!(receipt.end_prefix().occurrence_count(), 80);
}
#[test]
fn receipt_wire_rejects_unknown_identity_fields() {
let start = TranscriptRewritePrefixAccumulator::empty();
let commit = commit(1);
let end = start.extend(&commit).expect("first commit extends empty");
let mut wire = serde_json::to_value(
TranscriptRewriteAuditReceiptBatch::new(start, vec![commit], end)
.expect("receipt validates"),
)
.expect("receipt serializes");
wire.as_object_mut().expect("receipt is an object").insert(
"candidate_identity".to_string(),
serde_json::json!("ignored"),
);
assert!(
serde_json::from_value::<TranscriptRewriteAuditReceiptBatch>(wire).is_err(),
"identity-bearing receipt wire must reject unknown fields"
);
}
}