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