Skip to main content

libgrite_core/
integrity.rs

1//! Integrity checking for events and projections
2//!
3//! Provides verification of event hashes, signatures, and projection consistency.
4
5use crate::hash::compute_event_id;
6use crate::signing::verify_signature;
7use crate::store::GritStore;
8use crate::types::event::Event;
9use crate::types::ids::{EventId, id_to_hex};
10use crate::GriteError;
11
12/// Result of an integrity check
13#[derive(Debug, Default)]
14pub struct IntegrityReport {
15    /// Total events checked
16    pub events_checked: usize,
17    /// Events that passed all checks
18    pub events_valid: usize,
19    /// Events with corruption issues
20    pub corrupt_events: Vec<CorruptEvent>,
21    /// Signature verification results (if signatures were checked)
22    pub signatures_checked: usize,
23    /// Valid signatures
24    pub signatures_valid: usize,
25    /// Invalid or missing signatures
26    pub signature_errors: Vec<SignatureError>,
27}
28
29/// A corrupt event with details
30#[derive(Debug)]
31pub struct CorruptEvent {
32    pub event_id: EventId,
33    pub issue_id: String,
34    pub kind: CorruptionKind,
35}
36
37/// Types of event corruption
38#[derive(Debug)]
39pub enum CorruptionKind {
40    /// Event ID doesn't match computed hash
41    HashMismatch {
42        expected: EventId,
43        computed: EventId,
44    },
45    /// Event references a parent that doesn't exist
46    MissingParent {
47        parent_id: EventId,
48    },
49}
50
51/// Signature verification error
52#[derive(Debug)]
53pub struct SignatureError {
54    pub event_id: EventId,
55    pub actor_id: String,
56    pub error: String,
57}
58
59impl IntegrityReport {
60    /// Check if the report indicates all is well
61    pub fn is_healthy(&self) -> bool {
62        self.corrupt_events.is_empty() && self.signature_errors.is_empty()
63    }
64
65    /// Get the number of corrupt events
66    pub fn corruption_count(&self) -> usize {
67        self.corrupt_events.len()
68    }
69
70    /// Get the number of signature errors
71    pub fn signature_error_count(&self) -> usize {
72        self.signature_errors.len()
73    }
74}
75
76/// Verify that an event's ID matches its content hash
77pub fn verify_event_hash(event: &Event) -> Result<(), CorruptionKind> {
78    let computed = compute_event_id(
79        &event.issue_id,
80        &event.actor,
81        event.ts_unix_ms,
82        event.parent.as_ref(),
83        &event.kind,
84    );
85
86    if computed != event.event_id {
87        return Err(CorruptionKind::HashMismatch {
88            expected: event.event_id,
89            computed,
90        });
91    }
92
93    Ok(())
94}
95
96/// Check integrity of all events in the store
97///
98/// This verifies:
99/// - Event IDs match computed hashes
100/// - Parent references point to existing events (optional)
101pub fn check_store_integrity(
102    store: &GritStore,
103    verify_parents: bool,
104) -> Result<IntegrityReport, GriteError> {
105    let mut report = IntegrityReport::default();
106
107    // Get all events from all issues
108    let issues = store.list_issues(&Default::default())?;
109
110    for issue_summary in &issues {
111        let events = store.get_issue_events(&issue_summary.issue_id)?;
112        let event_ids: std::collections::HashSet<EventId> =
113            events.iter().map(|e| e.event_id).collect();
114
115        for event in &events {
116            report.events_checked += 1;
117
118            // Verify hash
119            match verify_event_hash(event) {
120                Ok(()) => {
121                    report.events_valid += 1;
122                }
123                Err(kind) => {
124                    report.corrupt_events.push(CorruptEvent {
125                        event_id: event.event_id,
126                        issue_id: id_to_hex(&event.issue_id),
127                        kind,
128                    });
129                    continue;
130                }
131            }
132
133            // Verify parent exists (if requested)
134            if verify_parents {
135                if let Some(parent_id) = &event.parent {
136                    if !event_ids.contains(parent_id) {
137                        report.corrupt_events.push(CorruptEvent {
138                            event_id: event.event_id,
139                            issue_id: id_to_hex(&event.issue_id),
140                            kind: CorruptionKind::MissingParent {
141                                parent_id: *parent_id,
142                            },
143                        });
144                    }
145                }
146            }
147        }
148    }
149
150    Ok(report)
151}
152
153/// Verify signatures on all events in the store
154///
155/// Requires a function to look up public keys by actor ID.
156pub fn verify_store_signatures<F>(
157    store: &GritStore,
158    get_public_key: F,
159) -> Result<IntegrityReport, GriteError>
160where
161    F: Fn(&str) -> Option<String>,
162{
163    let mut report = IntegrityReport::default();
164
165    let issues = store.list_issues(&Default::default())?;
166
167    for issue_summary in &issues {
168        let events = store.get_issue_events(&issue_summary.issue_id)?;
169
170        for event in &events {
171            report.events_checked += 1;
172
173            // Skip events without signatures
174            if event.sig.is_none() {
175                report.signature_errors.push(SignatureError {
176                    event_id: event.event_id,
177                    actor_id: id_to_hex(&event.actor),
178                    error: "signature missing".to_string(),
179                });
180                continue;
181            }
182
183            report.signatures_checked += 1;
184
185            // Look up public key
186            let actor_hex = id_to_hex(&event.actor);
187            let public_key = match get_public_key(&actor_hex) {
188                Some(pk) => pk,
189                None => {
190                    report.signature_errors.push(SignatureError {
191                        event_id: event.event_id,
192                        actor_id: actor_hex,
193                        error: "public key not found".to_string(),
194                    });
195                    continue;
196                }
197            };
198
199            // Verify signature
200            match verify_signature(event, &public_key) {
201                Ok(()) => {
202                    report.signatures_valid += 1;
203                    report.events_valid += 1;
204                }
205                Err(e) => {
206                    report.signature_errors.push(SignatureError {
207                        event_id: event.event_id,
208                        actor_id: actor_hex,
209                        error: e.to_string(),
210                    });
211                }
212            }
213        }
214    }
215
216    Ok(report)
217}
218
219#[cfg(test)]
220mod tests {
221    use super::*;
222    use crate::types::event::EventKind;
223
224    #[test]
225    fn test_verify_event_hash_valid() {
226        let issue_id = [1u8; 16];
227        let actor = [2u8; 16];
228        let ts = 1700000000000u64;
229        let kind = EventKind::IssueCreated {
230            title: "Test".to_string(),
231            body: "Body".to_string(),
232            labels: vec![],
233        };
234
235        let event_id = compute_event_id(&issue_id, &actor, ts, None, &kind);
236        let event = Event::new(event_id, issue_id, actor, ts, None, kind);
237
238        assert!(verify_event_hash(&event).is_ok());
239    }
240
241    #[test]
242    fn test_verify_event_hash_invalid() {
243        let issue_id = [1u8; 16];
244        let actor = [2u8; 16];
245        let ts = 1700000000000u64;
246        let kind = EventKind::IssueCreated {
247            title: "Test".to_string(),
248            body: "Body".to_string(),
249            labels: vec![],
250        };
251
252        // Create event with wrong event_id
253        let event = Event::new([0u8; 32], issue_id, actor, ts, None, kind);
254
255        let result = verify_event_hash(&event);
256        assert!(matches!(result, Err(CorruptionKind::HashMismatch { .. })));
257    }
258
259    #[test]
260    fn test_integrity_report_is_healthy() {
261        let report = IntegrityReport::default();
262        assert!(report.is_healthy());
263
264        let mut report_with_error = IntegrityReport::default();
265        report_with_error.corrupt_events.push(CorruptEvent {
266            event_id: [0u8; 32],
267            issue_id: "test".to_string(),
268            kind: CorruptionKind::HashMismatch {
269                expected: [0u8; 32],
270                computed: [1u8; 32],
271            },
272        });
273        assert!(!report_with_error.is_healthy());
274    }
275}