1use serde::{Deserialize, Serialize};
20use wm_core::Galaxy;
21
22use crate::memory::MemoryId;
23
24#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
26pub struct MemoryRevision {
27 pub seq: u32,
29 pub timestamp: u64,
31 pub old_hash: String,
33 pub new_hash: String,
35 #[serde(default)]
38 pub actor_session: Option<String>,
39 #[serde(default)]
41 pub actor_user: Option<String>,
42 #[serde(default)]
46 pub actor_compartment: Option<String>,
47}
48
49#[derive(Debug, Clone, Default, PartialEq, Eq)]
51pub struct RevisionActor {
52 pub session: Option<String>,
53 pub user: Option<String>,
54 pub compartment: Option<String>,
55}
56
57#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
59pub struct RevisionChainReport {
60 pub entries: usize,
61 pub valid: bool,
64 pub breaks: Vec<String>,
66 pub matches_head: bool,
69}
70
71#[must_use]
73pub fn verify_chain(entries: &[MemoryRevision], current_hash: &str) -> RevisionChainReport {
74 let mut breaks = Vec::new();
75 for (i, entry) in entries.iter().enumerate() {
76 if entry.seq != i as u32 {
77 breaks.push(format!(
78 "seq discontinuity at position {i}: expected {i}, found {}",
79 entry.seq
80 ));
81 }
82 if i > 0 {
83 let prev = &entries[i - 1];
84 if prev.new_hash != entry.old_hash {
85 breaks.push(format!(
86 "hash-linkage break at seq {}: prev new_hash != entry old_hash",
87 entry.seq
88 ));
89 }
90 }
91 }
92 let matches_head = match entries.last() {
93 None => true,
94 Some(last) => last.new_hash == current_hash,
95 };
96 if !matches_head {
97 breaks.push(
98 "head mismatch: last revision new_hash != current memory content_hash".to_string(),
99 );
100 }
101 RevisionChainReport {
102 entries: entries.len(),
103 valid: breaks.is_empty(),
104 breaks,
105 matches_head,
106 }
107}
108
109#[must_use]
112pub fn revision_key(galaxy: Galaxy, id: MemoryId, seq: u32) -> Vec<u8> {
113 format!("rev:{}:{}:{seq:010}", galaxy.db_name(), id).into_bytes()
114}
115
116#[must_use]
118pub fn revision_prefix(galaxy: Galaxy, id: MemoryId) -> Vec<u8> {
119 format!("rev:{}:{}:", galaxy.db_name(), id).into_bytes()
120}
121
122#[cfg(test)]
123mod tests {
124 use super::*;
125
126 fn rev(seq: u32, old_hash: &str, new_hash: &str) -> MemoryRevision {
127 MemoryRevision {
128 seq,
129 timestamp: 1_700_000_000,
130 old_hash: old_hash.to_string(),
131 new_hash: new_hash.to_string(),
132 actor_session: Some("ses-1".to_string()),
133 actor_user: None,
134 actor_compartment: None,
135 }
136 }
137
138 #[test]
139 fn intact_chain_is_valid() {
140 let entries = vec![rev(0, "h0", "h1"), rev(1, "h1", "h2"), rev(2, "h2", "h3")];
141 let report = verify_chain(&entries, "h3");
142 assert!(report.valid, "{:?}", report.breaks);
143 assert!(report.matches_head);
144 assert_eq!(report.entries, 3);
145 }
146
147 #[test]
148 fn deleted_entry_breaks_seq_continuity() {
149 let entries = vec![rev(0, "h0", "h1"), rev(2, "h1", "h3")];
150 let report = verify_chain(&entries, "h3");
151 assert!(!report.valid);
152 assert!(
153 report
154 .breaks
155 .iter()
156 .any(|b| b.contains("seq discontinuity"))
157 );
158 }
159
160 #[test]
161 fn spliced_entry_breaks_hash_linkage() {
162 let mut entries = vec![rev(0, "h0", "h1"), rev(1, "h1", "h2")];
166 entries[0].new_hash = "forged".to_string();
167 let report = verify_chain(&entries, "h2");
168 assert!(!report.valid);
169 assert!(report.breaks.iter().any(|b| b.contains("hash-linkage")));
170 }
171
172 #[test]
173 fn out_of_band_rewrite_breaks_head_match() {
174 let entries = vec![rev(0, "h0", "h1")];
175 let report = verify_chain(&entries, "h_undisclosed_edit");
176 assert!(!report.valid);
177 assert!(!report.matches_head);
178 assert!(report.breaks.iter().any(|b| b.contains("head mismatch")));
179 }
180
181 #[test]
182 fn empty_chain_is_vacuously_valid() {
183 let report = verify_chain(&[], "anything");
184 assert!(report.valid);
185 assert!(report.matches_head);
186 assert_eq!(report.entries, 0);
187 }
188
189 #[test]
190 fn keys_sort_numerically() {
191 let id = MemoryId::nil();
192 let mut keys: Vec<Vec<u8>> = (0..12u32)
193 .map(|s| revision_key(Galaxy::Codex, id, s))
194 .collect();
195 keys.sort();
196 for (i, key) in keys.iter().enumerate() {
197 assert!(String::from_utf8_lossy(key).ends_with(&format!("{i:010}")));
198 }
199 assert!(revision_prefix(Galaxy::Codex, id).starts_with(b"rev:"));
200 }
201}