clay-borg/crates/cb-events/src/lib.rs

169 lines
4.9 KiB
Rust
Raw Normal View History

//! cb-events — event envelope, append-only log, snapshots, canonical
//! serialization, and state hashing (GameKernel §2.4, K4, K7, K9K11).
use cb_kernel::{EventSeq, GameId};
use serde::{de::DeserializeOwned, Deserialize, Serialize};
use sha2::{Digest, Sha256};
/// Versioned event envelope (GameKernel K4).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Envelope<E> {
pub seq: EventSeq,
pub game_id: GameId,
pub round: u8,
pub schema_ver: u16,
pub payload: E,
}
/// In-memory append-only event log. File-backed storage arrives in a later
/// loop pass behind the same interface (AM-11 pairs this with storage
/// impls under one conformance suite).
#[derive(Debug, Default)]
pub struct EventLog<E> {
events: Vec<Envelope<E>>,
}
impl<E> EventLog<E> {
pub fn new() -> Self {
Self { events: Vec::new() }
}
/// Append with sequence enforcement: envelopes must arrive in strictly
/// increasing `seq` order (GameKernel K11).
pub fn append(&mut self, envelope: Envelope<E>) -> Result<(), LogError> {
if let Some(last) = self.events.last() {
if envelope.seq.0 != last.seq.0 + 1 {
return Err(LogError::NonMonotonicSeq {
expected: last.seq.0 + 1,
got: envelope.seq.0,
});
}
}
self.events.push(envelope);
Ok(())
}
pub fn len(&self) -> usize {
self.events.len()
}
pub fn is_empty(&self) -> bool {
self.events.is_empty()
}
pub fn iter(&self) -> impl Iterator<Item = &Envelope<E>> {
self.events.iter()
}
}
#[derive(Debug, PartialEq, Eq)]
pub enum LogError {
NonMonotonicSeq { expected: u64, got: u64 },
}
impl core::fmt::Display for LogError {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
match self {
LogError::NonMonotonicSeq { expected, got } => {
write!(f, "non-monotonic event seq: expected {expected}, got {got}")
}
}
}
}
/// Canonical serialization: serde_json with struct-declaration field order
/// and ordered maps (kernel state uses BTreeMap per GameKernel K6), so the
/// same state always yields the same bytes.
pub fn canonical_bytes<T: Serialize>(value: &T) -> Vec<u8> {
serde_json::to_vec(value).expect("canonical serialization must not fail")
}
/// SHA-256 state hash over the canonical serialization (GameKernel K7).
pub fn state_hash<T: Serialize>(value: &T) -> [u8; 32] {
let mut hasher = Sha256::new();
hasher.update(canonical_bytes(value));
hasher.finalize().into()
}
/// Hex form of [`state_hash`], for scenario `expect.state_hash` fields.
pub fn state_hash_hex<T: Serialize>(value: &T) -> String {
let hash = state_hash(value);
let mut out = String::with_capacity(64);
for byte in hash {
use core::fmt::Write;
write!(out, "{byte:02x}").expect("writing to String cannot fail");
}
out
}
/// A snapshot pairs the canonical state bytes with the last included
/// event (GameKernel K9).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Snapshot {
pub through: EventSeq,
pub state: Vec<u8>,
}
impl Snapshot {
pub fn take<T: Serialize>(state: &T, through: EventSeq) -> Self {
Self {
through,
state: canonical_bytes(state),
}
}
pub fn restore<T: DeserializeOwned>(&self) -> Result<T, serde_json::Error> {
serde_json::from_slice(&self.state)
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::BTreeMap;
#[test]
fn log_rejects_seq_gaps() {
let mut log: EventLog<u8> = EventLog::new();
let env = |seq| Envelope {
seq: EventSeq(seq),
game_id: GameId(1),
round: 1,
schema_ver: 1,
payload: 0u8,
};
log.append(env(0)).unwrap();
log.append(env(1)).unwrap();
assert_eq!(
log.append(env(3)),
Err(LogError::NonMonotonicSeq {
expected: 2,
got: 3
})
);
}
/// K7: identical state → identical hash; any change → different hash.
#[test]
fn state_hash_is_stable_and_sensitive() {
let mut a = BTreeMap::new();
a.insert("stress", 2u8);
let mut b = BTreeMap::new();
b.insert("stress", 2u8);
assert_eq!(state_hash_hex(&a), state_hash_hex(&b));
b.insert("stress", 3u8);
assert_ne!(state_hash_hex(&a), state_hash_hex(&b));
}
/// K9: snapshot → restore is identity on the canonical form.
#[test]
fn snapshot_roundtrip() {
let mut state = BTreeMap::new();
state.insert("round".to_string(), 3u8);
let snap = Snapshot::take(&state, EventSeq(17));
let restored: BTreeMap<String, u8> = snap.restore().unwrap();
assert_eq!(state, restored);
assert_eq!(state_hash_hex(&state), state_hash_hex(&restored));
}
}