use serde::{Deserialize, Serialize};
use wm_core::Galaxy;
use crate::memory::MemoryId;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct MemoryRevision {
pub seq: u32,
pub timestamp: u64,
pub old_hash: String,
pub new_hash: String,
#[serde(default)]
pub actor_session: Option<String>,
#[serde(default)]
pub actor_user: Option<String>,
#[serde(default)]
pub actor_compartment: Option<String>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct RevisionActor {
pub session: Option<String>,
pub user: Option<String>,
pub compartment: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct RevisionChainReport {
pub entries: usize,
pub valid: bool,
pub breaks: Vec<String>,
pub matches_head: bool,
}
#[must_use]
pub fn verify_chain(entries: &[MemoryRevision], current_hash: &str) -> RevisionChainReport {
let mut breaks = Vec::new();
for (i, entry) in entries.iter().enumerate() {
if entry.seq != i as u32 {
breaks.push(format!(
"seq discontinuity at position {i}: expected {i}, found {}",
entry.seq
));
}
if i > 0 {
let prev = &entries[i - 1];
if prev.new_hash != entry.old_hash {
breaks.push(format!(
"hash-linkage break at seq {}: prev new_hash != entry old_hash",
entry.seq
));
}
}
}
let matches_head = match entries.last() {
None => true,
Some(last) => last.new_hash == current_hash,
};
if !matches_head {
breaks.push(
"head mismatch: last revision new_hash != current memory content_hash".to_string(),
);
}
RevisionChainReport {
entries: entries.len(),
valid: breaks.is_empty(),
breaks,
matches_head,
}
}
#[must_use]
pub fn revision_key(galaxy: Galaxy, id: MemoryId, seq: u32) -> Vec<u8> {
format!("rev:{}:{}:{seq:010}", galaxy.db_name(), id).into_bytes()
}
#[must_use]
pub fn revision_prefix(galaxy: Galaxy, id: MemoryId) -> Vec<u8> {
format!("rev:{}:{}:", galaxy.db_name(), id).into_bytes()
}
#[cfg(test)]
mod tests {
use super::*;
fn rev(seq: u32, old_hash: &str, new_hash: &str) -> MemoryRevision {
MemoryRevision {
seq,
timestamp: 1_700_000_000,
old_hash: old_hash.to_string(),
new_hash: new_hash.to_string(),
actor_session: Some("ses-1".to_string()),
actor_user: None,
actor_compartment: None,
}
}
#[test]
fn intact_chain_is_valid() {
let entries = vec![rev(0, "h0", "h1"), rev(1, "h1", "h2"), rev(2, "h2", "h3")];
let report = verify_chain(&entries, "h3");
assert!(report.valid, "{:?}", report.breaks);
assert!(report.matches_head);
assert_eq!(report.entries, 3);
}
#[test]
fn deleted_entry_breaks_seq_continuity() {
let entries = vec![rev(0, "h0", "h1"), rev(2, "h1", "h3")];
let report = verify_chain(&entries, "h3");
assert!(!report.valid);
assert!(
report
.breaks
.iter()
.any(|b| b.contains("seq discontinuity"))
);
}
#[test]
fn spliced_entry_breaks_hash_linkage() {
let mut entries = vec![rev(0, "h0", "h1"), rev(1, "h1", "h2")];
entries[0].new_hash = "forged".to_string();
let report = verify_chain(&entries, "h2");
assert!(!report.valid);
assert!(report.breaks.iter().any(|b| b.contains("hash-linkage")));
}
#[test]
fn out_of_band_rewrite_breaks_head_match() {
let entries = vec![rev(0, "h0", "h1")];
let report = verify_chain(&entries, "h_undisclosed_edit");
assert!(!report.valid);
assert!(!report.matches_head);
assert!(report.breaks.iter().any(|b| b.contains("head mismatch")));
}
#[test]
fn empty_chain_is_vacuously_valid() {
let report = verify_chain(&[], "anything");
assert!(report.valid);
assert!(report.matches_head);
assert_eq!(report.entries, 0);
}
#[test]
fn keys_sort_numerically() {
let id = MemoryId::nil();
let mut keys: Vec<Vec<u8>> = (0..12u32)
.map(|s| revision_key(Galaxy::Codex, id, s))
.collect();
keys.sort();
for (i, key) in keys.iter().enumerate() {
assert!(String::from_utf8_lossy(key).ends_with(&format!("{i:010}")));
}
assert!(revision_prefix(Galaxy::Codex, id).starts_with(b"rev:"));
}
}