use std::fs;
use std::path::Path;
use crate::types::{canonical_bytes, Blake3Hash, OperationEvent, Receipt};
pub const FORMAT_VERSION: &str = "core/v1";
const GENESIS_SEED_STR: &str =
concat!("affidavit-v", env!("CARGO_PKG_VERSION"), "-genesis");
pub const GENESIS_SEED: &[u8] = GENESIS_SEED_STR.as_bytes();
pub const WORKING_PATH: &str = ".affi/working.json";
#[derive(Debug, thiserror::Error)]
pub enum ChainError {
#[error("canonical encoding failed: {0}")]
Encode(#[source] serde_json::Error),
#[error("receipt decode failed: {0}")]
Decode(#[source] serde_json::Error),
#[error("io error at {path}: {source}")]
Io {
path: String,
#[source]
source: std::io::Error,
},
}
fn genesis_hash() -> Blake3Hash {
Blake3Hash::from_bytes(GENESIS_SEED)
}
fn fold_event(prev: &Blake3Hash, event: &OperationEvent) -> Result<Blake3Hash, ChainError> {
let event_bytes = canonical_bytes(event).map_err(ChainError::Encode)?;
let mut buf = Vec::with_capacity(prev.as_hex().len() + event_bytes.len());
buf.extend_from_slice(prev.as_hex().as_bytes());
buf.extend_from_slice(&event_bytes);
Ok(Blake3Hash::from_bytes(&buf))
}
pub fn recompute_chain(events: &[OperationEvent]) -> Result<Blake3Hash, ChainError> {
let mut acc = genesis_hash();
for event in events {
acc = fold_event(&acc, event)?;
}
Ok(acc)
}
#[derive(Debug, Clone)]
pub struct ChainAssembler {
events: Vec<OperationEvent>,
running: Blake3Hash,
}
impl Default for ChainAssembler {
fn default() -> Self {
Self::new()
}
}
impl ChainAssembler {
pub fn new() -> Self {
ChainAssembler {
events: Vec::new(),
running: genesis_hash(),
}
}
pub fn from_events(events: Vec<OperationEvent>) -> Result<Self, ChainError> {
let running = recompute_chain(&events)?;
Ok(ChainAssembler { events, running })
}
pub fn append(&mut self, event: OperationEvent) -> Result<(), ChainError> {
self.running = fold_event(&self.running, &event)?;
self.events.push(event);
Ok(())
}
pub fn events(&self) -> &[OperationEvent] {
&self.events
}
pub fn len(&self) -> usize {
self.events.len()
}
pub fn is_empty(&self) -> bool {
self.events.is_empty()
}
pub fn finalize(self) -> Receipt {
Receipt::sealed(FORMAT_VERSION.to_string(), self.events, self.running)
}
}
pub fn content_address(receipt: &Receipt) -> Result<Blake3Hash, ChainError> {
let bytes = canonical_bytes(receipt).map_err(ChainError::Encode)?;
Ok(Blake3Hash::from_bytes(&bytes))
}
pub fn serialize_receipt(receipt: &Receipt) -> Result<Vec<u8>, ChainError> {
canonical_bytes(receipt).map_err(ChainError::Encode)
}
pub fn deserialize_receipt(bytes: &[u8]) -> Result<Receipt, ChainError> {
serde_json::from_slice(bytes).map_err(ChainError::Decode)
}
pub fn save_working(events: &[OperationEvent]) -> Result<(), ChainError> {
let bytes = canonical_bytes(&events.to_vec()).map_err(ChainError::Encode)?;
write_file(Path::new(WORKING_PATH), &bytes)
}
pub fn load_working() -> Result<Vec<OperationEvent>, ChainError> {
let path = Path::new(WORKING_PATH);
if !path.exists() {
return Ok(Vec::new());
}
let bytes = fs::read(path).map_err(|source| ChainError::Io {
path: WORKING_PATH.to_string(),
source,
})?;
serde_json::from_slice(&bytes).map_err(ChainError::Decode)
}
pub fn save_receipt(receipt: &Receipt, path: &Path) -> Result<(), ChainError> {
let bytes = serialize_receipt(receipt)?;
write_file(path, &bytes)
}
fn write_file(path: &Path, bytes: &[u8]) -> Result<(), ChainError> {
if let Some(parent) = path.parent() {
if !parent.as_os_str().is_empty() {
fs::create_dir_all(parent).map_err(|source| ChainError::Io {
path: parent.display().to_string(),
source,
})?;
}
}
fs::write(path, bytes).map_err(|source| ChainError::Io {
path: path.display().to_string(),
source,
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::types::{ObjectRef, OperationEvent};
fn event(seq: u64, payload: &[u8]) -> OperationEvent {
OperationEvent {
id: format!("e{seq}"),
seq,
event_type: "test.op".to_string(),
objects: vec![ObjectRef {
id: format!("obj{seq}"),
obj_type: "artifact".to_string(),
qualifier: Some("input".to_string()),
}],
payload_commitment: Blake3Hash::from_bytes(payload),
}
}
#[test]
fn chain_empty_equals_genesis() {
assert_eq!(recompute_chain(&[]).unwrap(), genesis_hash());
}
#[test]
fn chain_append_matches_recompute() {
let mut asm = ChainAssembler::new();
let evs = vec![event(0, b"a"), event(1, b"b"), event(2, b"c")];
for e in &evs {
asm.append(e.clone()).unwrap();
}
let recomputed = recompute_chain(&evs).unwrap();
let receipt = asm.finalize();
assert_eq!(receipt.chain_hash, recomputed);
}
#[test]
fn chain_is_deterministic() {
let evs = vec![event(0, b"x"), event(1, b"y")];
assert_eq!(
recompute_chain(&evs).unwrap(),
recompute_chain(&evs).unwrap()
);
}
#[test]
fn chain_tamper_changes_hash() {
let evs = vec![event(0, b"a"), event(1, b"b")];
let honest = recompute_chain(&evs).unwrap();
let mut tampered = evs.clone();
tampered[0].payload_commitment = Blake3Hash::from_bytes(b"forged");
let forged = recompute_chain(&tampered).unwrap();
assert_ne!(
honest, forged,
"tampering a commitment must break the chain"
);
}
#[test]
fn chain_order_matters() {
let a = vec![event(0, b"a"), event(1, b"b")];
let b = vec![event(1, b"b"), event(0, b"a")];
assert_ne!(recompute_chain(&a).unwrap(), recompute_chain(&b).unwrap());
}
#[test]
fn receipt_round_trips() {
let mut asm = ChainAssembler::new();
asm.append(event(0, b"a")).unwrap();
let receipt = asm.finalize();
let bytes = serialize_receipt(&receipt).unwrap();
let back = deserialize_receipt(&bytes).unwrap();
assert_eq!(receipt, back);
}
#[test]
fn content_address_is_stable() {
let mut asm = ChainAssembler::new();
asm.append(event(0, b"a")).unwrap();
let receipt = asm.finalize();
assert_eq!(
content_address(&receipt).unwrap(),
content_address(&receipt).unwrap()
);
}
#[test]
fn from_events_reconstructs_running_hash() {
let evs = vec![event(0, b"a"), event(1, b"b")];
let asm = ChainAssembler::from_events(evs.clone()).unwrap();
assert_eq!(asm.finalize().chain_hash, recompute_chain(&evs).unwrap());
}
}