Skip to main content

auths_id/keri/
kel.rs

1//! Git-backed Key Event Log (KEL) storage.
2//!
3//! The KEL is stored as a chain of Git commits where:
4//! - Each commit contains a single event as `event.json`
5//! - The commit chain mirrors the KERI event chain
6//! - The ref path follows standard conventions
7
8use chrono::{DateTime, Utc};
9use git2::{Commit, ErrorCode, Repository, Signature};
10
11use super::cache;
12use super::incremental::{self, IncrementalResult};
13use super::types::Prefix;
14use super::validate::validate_for_append;
15use super::{Event, IcpEvent, KeyState};
16use crate::domain::EventHash;
17use crate::witness::{event_hash_to_oid, oid_to_event_hash};
18
19/// Errors that can occur during KEL operations.
20#[derive(Debug, thiserror::Error)]
21#[non_exhaustive]
22pub enum KelError {
23    #[error("Git error: {0}")]
24    Git(#[from] git2::Error),
25
26    #[error("Serialization error: {0}")]
27    Serialization(String),
28
29    #[error("KEL not found for prefix: {0}")]
30    NotFound(String),
31
32    #[error("Invalid operation: {0}")]
33    InvalidOperation(String),
34
35    #[error("Invalid data: {0}")]
36    InvalidData(String),
37
38    #[error("Chain integrity error: {0}")]
39    ChainIntegrity(String),
40
41    #[error("Validation failed: {0}")]
42    ValidationFailed(#[from] super::ValidationError),
43}
44
45impl auths_core::error::AuthsErrorInfo for KelError {
46    fn error_code(&self) -> &'static str {
47        match self {
48            Self::Git(_) => "AUTHS-E4601",
49            Self::Serialization(_) => "AUTHS-E4602",
50            Self::NotFound(_) => "AUTHS-E4603",
51            Self::InvalidOperation(_) => "AUTHS-E4604",
52            Self::InvalidData(_) => "AUTHS-E4605",
53            Self::ChainIntegrity(_) => "AUTHS-E4606",
54            Self::ValidationFailed(_) => "AUTHS-E4607",
55        }
56    }
57
58    fn suggestion(&self) -> Option<&'static str> {
59        match self {
60            Self::Git(_) => Some("Check that the Git repository is accessible and not corrupted"),
61            Self::Serialization(_) => None,
62            Self::NotFound(_) => Some("Initialize the identity first with 'auths init'"),
63            Self::InvalidOperation(_) => None,
64            Self::InvalidData(_) => Some("The KEL data may be corrupted; try re-syncing"),
65            Self::ChainIntegrity(_) => {
66                Some("The KEL has non-linear history; this indicates tampering")
67            }
68            Self::ValidationFailed(_) => None,
69        }
70    }
71}
72
73/// Standard filename for storing KERI events in commits.
74const EVENT_BLOB_NAME: &str = "event.json";
75
76/// Construct the Git reference path for a KEL.
77fn kel_ref(prefix: &Prefix) -> String {
78    format!("refs/did/keri/{}/kel", prefix.as_str())
79}
80
81/// Git-backed Key Event Log.
82///
83/// Provides operations for creating, appending to, and reading KERI event logs
84/// stored in a Git repository.
85pub struct GitKel<'a> {
86    repo: &'a Repository,
87    prefix: Prefix,
88    ref_path: String,
89}
90
91impl<'a> GitKel<'a> {
92    /// Create a new GitKel instance for the given prefix using the default ref path.
93    pub fn new(repo: &'a Repository, prefix: impl Into<String>) -> Self {
94        let prefix = Prefix::new_unchecked(prefix.into());
95        let ref_path = kel_ref(&prefix);
96        Self {
97            repo,
98            prefix,
99            ref_path,
100        }
101    }
102
103    /// Create a GitKel instance with a custom ref path.
104    ///
105    /// This allows reading KELs stored at non-default locations.
106    ///
107    /// Args:
108    /// * `repo`: The Git repository containing the KEL.
109    /// * `prefix`: The KERI identifier prefix.
110    /// * `ref_path`: The Git ref path to read/write the KEL.
111    ///
112    /// Usage:
113    /// ```ignore
114    /// let kel = GitKel::with_ref(&repo, "EPrefix", "refs/keri/kel".into());
115    /// ```
116    pub fn with_ref(repo: &'a Repository, prefix: impl Into<String>, ref_path: String) -> Self {
117        Self {
118            repo,
119            prefix: Prefix::new_unchecked(prefix.into()),
120            ref_path,
121        }
122    }
123
124    /// Get the prefix for this KEL.
125    pub fn prefix(&self) -> &Prefix {
126        &self.prefix
127    }
128
129    /// Returns the working directory of the underlying Git repository.
130    ///
131    /// Used to derive the Auths home directory for cache operations without
132    /// reading environment variables. In production the repo workdir equals
133    /// `~/.auths`; in tests it equals the temporary directory created by the
134    /// test harness.
135    pub(crate) fn workdir(&self) -> &std::path::Path {
136        self.repo.workdir().unwrap_or_else(|| self.repo.path())
137    }
138
139    /// Check if a KEL exists for this prefix.
140    pub fn exists(&self) -> bool {
141        self.repo.find_reference(&self.ref_path).is_ok()
142    }
143
144    /// Create a new KEL with an inception event.
145    ///
146    /// This creates the initial commit with no parent.
147    ///
148    /// # Args
149    ///
150    /// * `event` - The inception event to store as the first commit
151    ///
152    /// # Usage
153    ///
154    /// ```rust,ignore
155    /// let hash = kel.create(&icp_event)?;
156    /// ```
157    pub fn create(
158        &self,
159        event: &IcpEvent,
160        now: chrono::DateTime<chrono::Utc>,
161    ) -> Result<EventHash, KelError> {
162        if self.exists() {
163            return Err(KelError::InvalidOperation(format!(
164                "KEL already exists for prefix: {}",
165                self.prefix.as_str()
166            )));
167        }
168
169        let wrapped = Event::Icp(event.clone());
170        let json = serde_json::to_vec_pretty(&wrapped)
171            .map_err(|e| KelError::Serialization(e.to_string()))?;
172
173        let blob_oid = self.repo.blob(&json)?;
174        let mut tree_builder = self.repo.treebuilder(None)?;
175        tree_builder.insert(EVENT_BLOB_NAME, blob_oid, 0o100644)?;
176        let tree_oid = tree_builder.write()?;
177        let tree = self.repo.find_tree(tree_oid)?;
178
179        let sig = self.signature(now)?;
180        let commit_oid = self.repo.commit(
181            Some(&self.ref_path),
182            &sig,
183            &sig,
184            &format!("KERI inception: {}", event.i.as_str()),
185            &tree,
186            &[],
187        )?;
188
189        Ok(oid_to_event_hash(commit_oid))
190    }
191
192    /// Append a rotation or interaction event to the KEL.
193    ///
194    /// The event must have a valid previous SAID that matches the current tip.
195    ///
196    /// # Args
197    ///
198    /// * `event` - The rotation or interaction event to append
199    ///
200    /// # Usage
201    ///
202    /// ```rust,ignore
203    /// let hash = kel.append(&rot_event)?;
204    /// ```
205    pub fn append(
206        &self,
207        event: &Event,
208        now: chrono::DateTime<chrono::Utc>,
209    ) -> Result<EventHash, KelError> {
210        let ref_name = &self.ref_path;
211
212        let reference = self.repo.find_reference(ref_name).map_err(|e| {
213            if e.code() == ErrorCode::NotFound {
214                KelError::NotFound(self.prefix.as_str().to_string())
215            } else {
216                KelError::Git(e)
217            }
218        })?;
219        let parent_commit = reference.peel_to_commit()?;
220
221        // Validate event cryptographically before persisting
222        let state = self.build_current_state()?;
223        validate_for_append(event, &state)?;
224
225        let json =
226            serde_json::to_vec_pretty(event).map_err(|e| KelError::Serialization(e.to_string()))?;
227
228        let blob_oid = self.repo.blob(&json)?;
229        let mut tree_builder = self.repo.treebuilder(None)?;
230        tree_builder.insert(EVENT_BLOB_NAME, blob_oid, 0o100644)?;
231        let tree_oid = tree_builder.write()?;
232        let tree = self.repo.find_tree(tree_oid)?;
233
234        let msg = match event {
235            Event::Rot(e) => format!("KERI rotation: s={}", e.s),
236            Event::Ixn(e) => format!("KERI interaction: s={}", e.s),
237            Event::Icp(_) => unreachable!(),
238        };
239
240        let sig = self.signature(now)?;
241        let commit_oid =
242            self.repo
243                .commit(Some(ref_name), &sig, &sig, &msg, &tree, &[&parent_commit])?;
244
245        Ok(oid_to_event_hash(commit_oid))
246    }
247
248    /// Read all events from the KEL (oldest to newest).
249    pub fn get_events(&self) -> Result<Vec<Event>, KelError> {
250        let ref_name = &self.ref_path;
251        let reference = self.repo.find_reference(ref_name).map_err(|e| {
252            if e.code() == ErrorCode::NotFound {
253                KelError::NotFound(self.prefix.as_str().to_string())
254            } else {
255                KelError::Git(e)
256            }
257        })?;
258
259        let mut events = Vec::new();
260        let mut commit = reference.peel_to_commit()?;
261
262        // Walk backwards from tip to inception
263        loop {
264            let event = self.read_event_from_commit(&commit)?;
265            events.push(event);
266
267            if commit.parent_count() == 0 {
268                break; // Reached inception
269            }
270            commit = commit.parent(0)?;
271        }
272
273        events.reverse(); // Oldest first
274        Ok(events)
275    }
276
277    /// Get the current key state with incremental validation.
278    ///
279    /// This is the primary method for getting key state. It uses a three-tier
280    /// approach for optimal performance:
281    ///
282    /// 1. **Cache hit**: If cached state matches current tip, return immediately (O(1))
283    /// 2. **Incremental**: If cache is behind, validate only new events (O(k))
284    /// 3. **Full replay**: If cache is missing/invalid, do full replay (O(n))
285    ///
286    /// All paths write an updated cache on success.
287    ///
288    /// # Errors
289    ///
290    /// Returns `KelError` if the KEL is corrupted (e.g., merge commits, broken chain).
291    /// Cache problems trigger fallback to full replay, not errors.
292    pub fn get_state(&self, now: DateTime<Utc>) -> Result<KeyState, KelError> {
293        let did = format!("did:keri:{}", self.prefix.as_str());
294
295        // Try incremental validation
296        match incremental::try_incremental_validation(self, &did, now) {
297            Ok(IncrementalResult::CacheHit(state)) => {
298                return Ok(state);
299            }
300            Ok(IncrementalResult::IncrementalSuccess {
301                state,
302                events_validated: _,
303            }) => {
304                return Ok(state);
305            }
306            Ok(IncrementalResult::NeedsFullReplay(reason)) => {
307                log::debug!("KEL full replay for {}: {:?}", did, reason);
308            }
309            Err(incremental::IncrementalError::NonLinearHistory {
310                commit,
311                parent_count,
312            }) => {
313                // Hard error - don't fall back, the KEL is corrupt
314                return Err(KelError::ChainIntegrity(format!(
315                    "KEL has non-linear history: commit {} has {} parents",
316                    commit, parent_count
317                )));
318            }
319            Err(e) => {
320                // Other incremental errors - log and fall back
321                log::warn!("Incremental validation failed for {}: {}", did, e);
322            }
323        }
324
325        // Fall back to full replay
326        self.get_state_full_replay(now)
327    }
328
329    /// Get the current key state by full O(n) replay of the KEL.
330    ///
331    /// This bypasses all caching and always replays the entire KEL.
332    /// Prefer `get_state()` which uses caching and incremental validation.
333    ///
334    /// After successful replay, writes the cache for future use.
335    pub fn get_state_full_replay(&self, now: DateTime<Utc>) -> Result<KeyState, KelError> {
336        let tip_hash = self.tip_commit_hash()?;
337        let latest = self.read_event_from_commit_hash(tip_hash)?;
338        let tip_said = latest.said();
339        let tip_oid_hex = tip_hash.to_hex();
340        let did = format!("did:keri:{}", self.prefix.as_str());
341
342        let events = self.get_events()?;
343
344        if events.is_empty() {
345            return Err(KelError::NotFound(self.prefix.as_str().to_string()));
346        }
347
348        // First event must be inception
349        let first = &events[0];
350        let Event::Icp(icp) = first else {
351            return Err(KelError::InvalidData(
352                "First event in KEL must be inception".into(),
353            ));
354        };
355
356        let threshold = icp.kt.parse::<u64>().map_err(|_| {
357            KelError::InvalidData(format!("Malformed sequence number: {:?}", icp.kt))
358        })?;
359        let next_threshold = icp.nt.parse::<u64>().map_err(|_| {
360            KelError::InvalidData(format!("Malformed sequence number: {:?}", icp.nt))
361        })?;
362
363        let mut state = KeyState::from_inception(
364            icp.i.clone(),
365            icp.k.clone(),
366            icp.n.clone(),
367            threshold,
368            next_threshold,
369            icp.d.clone(),
370        );
371
372        // Apply remaining events
373        for event in events.iter().skip(1) {
374            match event {
375                Event::Rot(rot) => {
376                    let seq = rot.s.value();
377                    let threshold = rot.kt.parse::<u64>().map_err(|_| {
378                        KelError::InvalidData(format!("Malformed sequence number: {:?}", rot.kt))
379                    })?;
380                    let next_threshold = rot.nt.parse::<u64>().map_err(|_| {
381                        KelError::InvalidData(format!("Malformed sequence number: {:?}", rot.nt))
382                    })?;
383
384                    state.apply_rotation(
385                        rot.k.clone(),
386                        rot.n.clone(),
387                        threshold,
388                        next_threshold,
389                        seq,
390                        rot.d.clone(),
391                    );
392                }
393                Event::Ixn(ixn) => {
394                    let seq = ixn.s.value();
395                    state.apply_interaction(seq, ixn.d.clone());
396                }
397                Event::Icp(_) => {
398                    return Err(KelError::InvalidData(
399                        "Multiple inception events in KEL".into(),
400                    ));
401                }
402            }
403        }
404
405        // Write cache (ignore errors - cache is optional)
406        let _ = cache::write_kel_cache(
407            self.workdir(),
408            &did,
409            &state,
410            tip_said.as_str(),
411            &tip_oid_hex,
412            now,
413        );
414
415        Ok(state)
416    }
417
418    /// Get the latest event from the KEL.
419    pub fn get_latest_event(&self) -> Result<Event, KelError> {
420        let ref_name = &self.ref_path;
421        let reference = self.repo.find_reference(ref_name).map_err(|e| {
422            if e.code() == ErrorCode::NotFound {
423                KelError::NotFound(self.prefix.as_str().to_string())
424            } else {
425                KelError::Git(e)
426            }
427        })?;
428
429        let commit = reference.peel_to_commit()?;
430        self.read_event_from_commit(&commit)
431    }
432
433    /// Build the current key state from events without caching.
434    ///
435    /// Used internally by `append()` to validate new events without
436    /// requiring a `now` parameter for cache writes.
437    fn build_current_state(&self) -> Result<KeyState, KelError> {
438        let events = self.get_events()?;
439        if events.is_empty() {
440            return Err(KelError::NotFound(self.prefix.as_str().to_string()));
441        }
442
443        let Event::Icp(icp) = &events[0] else {
444            return Err(KelError::InvalidData(
445                "First event in KEL must be inception".into(),
446            ));
447        };
448
449        let threshold = icp
450            .kt
451            .parse::<u64>()
452            .map_err(|_| KelError::InvalidData(format!("Malformed threshold: {:?}", icp.kt)))?;
453        let next_threshold = icp
454            .nt
455            .parse::<u64>()
456            .map_err(|_| KelError::InvalidData(format!("Malformed threshold: {:?}", icp.nt)))?;
457
458        let mut state = KeyState::from_inception(
459            icp.i.clone(),
460            icp.k.clone(),
461            icp.n.clone(),
462            threshold,
463            next_threshold,
464            icp.d.clone(),
465        );
466
467        for event in events.iter().skip(1) {
468            match event {
469                Event::Rot(rot) => {
470                    let seq = rot.s.value();
471                    let t = rot.kt.parse::<u64>().map_err(|_| {
472                        KelError::InvalidData(format!("Malformed threshold: {:?}", rot.kt))
473                    })?;
474                    let nt = rot.nt.parse::<u64>().map_err(|_| {
475                        KelError::InvalidData(format!("Malformed threshold: {:?}", rot.nt))
476                    })?;
477                    state.apply_rotation(rot.k.clone(), rot.n.clone(), t, nt, seq, rot.d.clone());
478                }
479                Event::Ixn(ixn) => {
480                    state.apply_interaction(ixn.s.value(), ixn.d.clone());
481                }
482                Event::Icp(_) => {
483                    return Err(KelError::InvalidData(
484                        "Multiple inception events in KEL".into(),
485                    ));
486                }
487            }
488        }
489
490        Ok(state)
491    }
492
493    /// Read an event from a commit.
494    fn read_event_from_commit(&self, commit: &Commit) -> Result<Event, KelError> {
495        let tree = commit.tree()?;
496        let entry = tree
497            .get_name(EVENT_BLOB_NAME)
498            .ok_or_else(|| KelError::InvalidData("Missing event.json in commit".into()))?;
499        let blob = self.repo.find_blob(entry.id())?;
500        let event: Event = serde_json::from_slice(blob.content())
501            .map_err(|e| KelError::Serialization(e.to_string()))?;
502        Ok(event)
503    }
504
505    /// Create a Git signature for commits using an injected timestamp.
506    fn signature(
507        &self,
508        now: chrono::DateTime<chrono::Utc>,
509    ) -> Result<Signature<'static>, KelError> {
510        self.repo
511            .signature()
512            .or_else(|_| {
513                Signature::new("auths", "auths@local", &git2::Time::new(now.timestamp(), 0))
514            })
515            .map_err(KelError::Git)
516    }
517
518    // --- Commit hash helpers for incremental validation ---
519
520    /// Get the hash of the tip commit for this KEL.
521    pub fn tip_commit_hash(&self) -> Result<EventHash, KelError> {
522        let ref_name = &self.ref_path;
523        let reference = self.repo.find_reference(ref_name).map_err(|e| {
524            if e.code() == ErrorCode::NotFound {
525                KelError::NotFound(self.prefix.as_str().to_string())
526            } else {
527                KelError::Git(e)
528            }
529        })?;
530        let commit = reference.peel_to_commit()?;
531        Ok(oid_to_event_hash(commit.id()))
532    }
533
534    /// Read an event from a commit by its hash.
535    pub fn read_event_from_commit_hash(&self, hash: EventHash) -> Result<Event, KelError> {
536        let commit = self.repo.find_commit(event_hash_to_oid(hash))?;
537        self.read_event_from_commit(&commit)
538    }
539
540    /// Get the parent commit hash, if any.
541    ///
542    /// Returns `None` for the inception commit (no parent).
543    pub fn parent_hash(&self, hash: EventHash) -> Result<Option<EventHash>, KelError> {
544        let commit = self.repo.find_commit(event_hash_to_oid(hash))?;
545        if commit.parent_count() == 0 {
546            Ok(None)
547        } else {
548            Ok(Some(oid_to_event_hash(commit.parent_id(0)?)))
549        }
550    }
551
552    /// Get the number of parents for a commit.
553    ///
554    /// KEL commits must have exactly 1 parent (except inception which has 0).
555    /// Any commit with >1 parent indicates a merge, which is invalid for KELs.
556    pub fn parent_count(&self, hash: EventHash) -> Result<usize, KelError> {
557        let commit = self.repo.find_commit(event_hash_to_oid(hash))?;
558        Ok(commit.parent_count())
559    }
560
561    /// Check if a commit exists in the repository.
562    pub fn commit_exists(&self, hash: EventHash) -> bool {
563        self.repo.find_commit(event_hash_to_oid(hash)).is_ok()
564    }
565
566    /// Parse a commit hash from a hex string.
567    pub fn parse_hash(hex: &str) -> Result<EventHash, KelError> {
568        hex.parse::<EventHash>()
569            .map_err(|e| KelError::InvalidData(format!("Invalid commit hash: {}", e)))
570    }
571}
572
573#[cfg(test)]
574#[allow(clippy::disallowed_methods)]
575mod tests {
576    use super::*;
577    use crate::keri::inception::create_keri_identity;
578    use crate::keri::rotation::rotate_keys;
579    use crate::keri::{KERI_VERSION, KeriSequence, Prefix, RotEvent, Said};
580    use tempfile::TempDir;
581
582    fn setup_repo() -> (TempDir, Repository) {
583        let dir = TempDir::new().unwrap();
584        let repo = Repository::init(dir.path()).unwrap();
585
586        let mut config = repo.config().unwrap();
587        config.set_str("user.name", "Test User").unwrap();
588        config.set_str("user.email", "test@example.com").unwrap();
589
590        (dir, repo)
591    }
592
593    fn with_temp_auths_home_and_repo<F>(f: F)
594    where
595        F: FnOnce(&TempDir, &Repository),
596    {
597        let (dir, repo) = setup_repo();
598        f(&dir, &repo);
599    }
600
601    fn make_icp_event(prefix: &str) -> IcpEvent {
602        IcpEvent {
603            v: KERI_VERSION.to_string(),
604            d: Said::new_unchecked(prefix.to_string()),
605            i: Prefix::new_unchecked(prefix.to_string()),
606            s: KeriSequence::new(0),
607            kt: "1".to_string(),
608            k: vec!["DKey1".to_string()],
609            nt: "1".to_string(),
610            n: vec!["ENext1".to_string()],
611            bt: "0".to_string(),
612            b: vec![],
613            a: vec![],
614            x: String::new(),
615        }
616    }
617
618    #[test]
619    fn create_and_read_kel() {
620        let (_dir, repo) = setup_repo();
621        let kel = GitKel::new(&repo, "ETest123");
622
623        let icp = make_icp_event("ETest123");
624        kel.create(&icp, chrono::Utc::now()).unwrap();
625
626        assert!(kel.exists());
627
628        let events = kel.get_events().unwrap();
629        assert_eq!(events.len(), 1);
630        assert!(events[0].is_inception());
631    }
632
633    #[test]
634    fn cannot_create_duplicate_kel() {
635        let (_dir, repo) = setup_repo();
636        let kel = GitKel::new(&repo, "ETest123");
637
638        let icp = make_icp_event("ETest123");
639        kel.create(&icp, chrono::Utc::now()).unwrap();
640
641        let result = kel.create(&icp, chrono::Utc::now());
642        assert!(result.is_err());
643    }
644
645    #[test]
646    fn append_rotation_event() {
647        let (_dir, repo) = setup_repo();
648        let init = create_keri_identity(&repo, None, chrono::Utc::now()).unwrap();
649        let _rot = rotate_keys(
650            &repo,
651            &init.prefix,
652            &init.next_keypair_pkcs8,
653            None,
654            chrono::Utc::now(),
655        )
656        .unwrap();
657
658        let kel = GitKel::new(&repo, init.prefix.as_str());
659        let events = kel.get_events().unwrap();
660        assert_eq!(events.len(), 2);
661        assert!(events[0].is_inception());
662        assert!(events[1].is_rotation());
663    }
664
665    #[test]
666    fn append_rejects_invalid_signature() {
667        let (_dir, repo) = setup_repo();
668        let init = create_keri_identity(&repo, None, chrono::Utc::now()).unwrap();
669        let kel = GitKel::new(&repo, init.prefix.as_str());
670
671        // Build a fake rotation event with invalid SAID
672        let rot = Event::Rot(RotEvent {
673            v: KERI_VERSION.to_string(),
674            d: Said::new_unchecked("EFakeSaid".to_string()),
675            i: init.prefix.clone(),
676            s: KeriSequence::new(1),
677            p: Said::new_unchecked(init.prefix.as_str().to_string()),
678            kt: "1".to_string(),
679            k: vec!["DFakeKey".to_string()],
680            nt: "1".to_string(),
681            n: vec!["EFakeNext".to_string()],
682            bt: "0".to_string(),
683            b: vec![],
684            a: vec![],
685            x: String::new(),
686        });
687
688        let result = kel.append(&rot, chrono::Utc::now());
689        assert!(result.is_err());
690        assert!(matches!(result, Err(KelError::ValidationFailed(_))));
691    }
692
693    #[test]
694    fn get_state_after_inception() {
695        let (_dir, repo) = setup_repo();
696        let init = create_keri_identity(&repo, None, chrono::Utc::now()).unwrap();
697        let kel = GitKel::new(&repo, init.prefix.as_str());
698
699        let state = kel.get_state(chrono::Utc::now()).unwrap();
700        assert_eq!(state.prefix.as_str(), init.prefix.as_str());
701        assert_eq!(state.sequence, 0);
702        assert!(state.can_rotate());
703    }
704
705    #[test]
706    fn get_state_after_rotation() {
707        let (_dir, repo) = setup_repo();
708        let init = create_keri_identity(&repo, None, chrono::Utc::now()).unwrap();
709        let rot = rotate_keys(
710            &repo,
711            &init.prefix,
712            &init.next_keypair_pkcs8,
713            None,
714            chrono::Utc::now(),
715        )
716        .unwrap();
717
718        let kel = GitKel::new(&repo, init.prefix.as_str());
719        let state = kel.get_state(chrono::Utc::now()).unwrap();
720        assert_eq!(state.sequence, 1);
721        assert_eq!(rot.sequence, 1);
722    }
723
724    #[test]
725    fn get_latest_event() {
726        let (_dir, repo) = setup_repo();
727        let init = create_keri_identity(&repo, None, chrono::Utc::now()).unwrap();
728        let kel = GitKel::new(&repo, init.prefix.as_str());
729
730        let latest = kel.get_latest_event().unwrap();
731        assert!(latest.is_inception());
732
733        let _rot = rotate_keys(
734            &repo,
735            &init.prefix,
736            &init.next_keypair_pkcs8,
737            None,
738            chrono::Utc::now(),
739        )
740        .unwrap();
741
742        let latest = kel.get_latest_event().unwrap();
743        assert!(latest.is_rotation());
744    }
745
746    #[test]
747    fn not_found_error_for_missing_kel() {
748        let (_dir, repo) = setup_repo();
749        let kel = GitKel::new(&repo, "ENotExist");
750
751        let result = kel.get_events();
752        assert!(matches!(result, Err(KelError::NotFound(_))));
753    }
754
755    #[test]
756    fn cannot_append_icp_event() {
757        let (_dir, repo) = setup_repo();
758        let init = create_keri_identity(&repo, None, chrono::Utc::now()).unwrap();
759        let kel = GitKel::new(&repo, init.prefix.as_str());
760
761        let icp2 = Event::Icp(make_icp_event("EFake"));
762        let result = kel.append(&icp2, chrono::Utc::now());
763        assert!(matches!(result, Err(KelError::ValidationFailed(_))));
764    }
765
766    // --- Incremental validation tests ---
767
768    fn make_rot_event(prefix: &str, seq: u64, prev_said: &str) -> RotEvent {
769        RotEvent {
770            v: KERI_VERSION.to_string(),
771            d: Said::new_unchecked(format!("ERot{}", seq)),
772            i: Prefix::new_unchecked(prefix.to_string()),
773            s: KeriSequence::new(seq),
774            p: Said::new_unchecked(prev_said.to_string()),
775            kt: "1".to_string(),
776            k: vec![format!("DKey{}", seq + 1)],
777            nt: "1".to_string(),
778            n: vec![format!("ENext{}", seq + 1)],
779            bt: "0".to_string(),
780            b: vec![],
781            a: vec![],
782            x: String::new(),
783        }
784    }
785
786    #[test]
787    fn test_cold_cache_full_replay() {
788        let (_dir, repo) = setup_repo();
789        let init = create_keri_identity(&repo, None, chrono::Utc::now()).unwrap();
790        let kel = GitKel::new(&repo, init.prefix.as_str());
791
792        let state = kel.get_state(chrono::Utc::now()).unwrap();
793        assert_eq!(state.prefix.as_str(), init.prefix.as_str());
794        assert_eq!(state.sequence, 0);
795
796        let did = format!("did:keri:{}", init.prefix.as_str());
797        let tip_said = kel.get_latest_event().unwrap().said().to_string();
798        let cached = cache::try_load_cached_state(_dir.path(), &did, &tip_said);
799        assert!(cached.is_some());
800    }
801
802    #[test]
803    fn test_warm_cache_hit() {
804        let (_dir, repo) = setup_repo();
805        let init = create_keri_identity(&repo, None, chrono::Utc::now()).unwrap();
806        let kel = GitKel::new(&repo, init.prefix.as_str());
807
808        let state1 = kel.get_state(chrono::Utc::now()).unwrap();
809        let state2 = kel.get_state(chrono::Utc::now()).unwrap();
810
811        assert_eq!(state1, state2);
812        assert_eq!(state2.sequence, 0);
813    }
814
815    #[test]
816    fn test_incremental_validation_after_rotation() {
817        let (_dir, repo) = setup_repo();
818        let init = create_keri_identity(&repo, None, chrono::Utc::now()).unwrap();
819        let kel = GitKel::new(&repo, init.prefix.as_str());
820
821        // Prime cache
822        let _ = kel.get_state(chrono::Utc::now()).unwrap();
823
824        // Rotate keys (appends validated event)
825        let rot1 = rotate_keys(
826            &repo,
827            &init.prefix,
828            &init.next_keypair_pkcs8,
829            None,
830            chrono::Utc::now(),
831        )
832        .unwrap();
833
834        let state = kel.get_state(chrono::Utc::now()).unwrap();
835        assert_eq!(state.sequence, 1);
836
837        // Rotate again
838        let _rot2 = rotate_keys(
839            &repo,
840            &init.prefix,
841            &rot1.new_next_keypair_pkcs8,
842            None,
843            chrono::Utc::now(),
844        )
845        .unwrap();
846
847        let state = kel.get_state(chrono::Utc::now()).unwrap();
848        assert_eq!(state.sequence, 2);
849    }
850
851    #[test]
852    fn test_cache_divergence_fallback() {
853        let (_dir, repo) = setup_repo();
854        let init = create_keri_identity(&repo, None, chrono::Utc::now()).unwrap();
855        let kel = GitKel::new(&repo, init.prefix.as_str());
856
857        let _ = kel.get_state(chrono::Utc::now()).unwrap();
858
859        let did = format!("did:keri:{}", init.prefix.as_str());
860        let cached_full = cache::try_load_cached_state_full(_dir.path(), &did).unwrap();
861        let _ = cache::write_kel_cache(
862            _dir.path(),
863            &did,
864            &cached_full.state,
865            cached_full.validated_against_tip_said.as_str(),
866            "0000000000000000000000000000000000000000",
867            chrono::Utc::now(),
868        );
869
870        let state = kel.get_state(chrono::Utc::now()).unwrap();
871        assert_eq!(state.prefix.as_str(), init.prefix.as_str());
872    }
873
874    #[test]
875    fn test_get_state_matches_full_replay() {
876        let (_dir, repo) = setup_repo();
877        let init = create_keri_identity(&repo, None, chrono::Utc::now()).unwrap();
878        let rot1 = rotate_keys(
879            &repo,
880            &init.prefix,
881            &init.next_keypair_pkcs8,
882            None,
883            chrono::Utc::now(),
884        )
885        .unwrap();
886        let _rot2 = rotate_keys(
887            &repo,
888            &init.prefix,
889            &rot1.new_next_keypair_pkcs8,
890            None,
891            chrono::Utc::now(),
892        )
893        .unwrap();
894
895        let kel = GitKel::new(&repo, init.prefix.as_str());
896
897        let state_incremental = kel.get_state(chrono::Utc::now()).unwrap();
898        let did = format!("did:keri:{}", init.prefix.as_str());
899        let _ = cache::invalidate_cache(_dir.path(), &did);
900        let state_full = kel.get_state_full_replay(chrono::Utc::now()).unwrap();
901
902        assert_eq!(state_incremental.prefix, state_full.prefix);
903        assert_eq!(state_incremental.sequence, state_full.sequence);
904        assert_eq!(state_incremental.current_keys, state_full.current_keys);
905        assert_eq!(
906            state_incremental.last_event_said,
907            state_full.last_event_said
908        );
909    }
910
911    #[test]
912    fn test_cache_said_mismatch_forces_replay() {
913        let (_dir, repo) = setup_repo();
914        let init = create_keri_identity(&repo, None, chrono::Utc::now()).unwrap();
915        let kel = GitKel::new(&repo, init.prefix.as_str());
916
917        let _ = kel.get_state(chrono::Utc::now()).unwrap();
918
919        let did = format!("did:keri:{}", init.prefix.as_str());
920        let cached_full = cache::try_load_cached_state_full(_dir.path(), &did).unwrap();
921
922        let _ = cache::write_kel_cache(
923            _dir.path(),
924            &did,
925            &cached_full.state,
926            "EFakeSaidThatDoesNotMatchCommit",
927            cached_full.last_commit_oid.as_str(),
928            chrono::Utc::now(),
929        );
930
931        let state = kel.get_state(chrono::Utc::now()).unwrap();
932        assert_eq!(state.prefix.as_str(), init.prefix.as_str());
933        assert_eq!(state.sequence, 0);
934
935        let new_cached = cache::try_load_cached_state_full(_dir.path(), &did).unwrap();
936        let tip_said = kel.get_latest_event().unwrap().said().to_string();
937        assert_eq!(
938            new_cached.validated_against_tip_said.as_str(),
939            tip_said.as_str()
940        );
941    }
942
943    #[test]
944    fn test_commit_hash_helpers() {
945        let (_dir, repo) = setup_repo();
946        let init = create_keri_identity(&repo, None, chrono::Utc::now()).unwrap();
947        let kel = GitKel::new(&repo, init.prefix.as_str());
948
949        let tip_hash = kel.tip_commit_hash().unwrap();
950        assert!(kel.commit_exists(tip_hash));
951
952        let event = kel.read_event_from_commit_hash(tip_hash).unwrap();
953        assert!(event.is_inception());
954
955        assert!(kel.parent_hash(tip_hash).unwrap().is_none());
956
957        // Rotate to add another event
958        let _rot = rotate_keys(
959            &repo,
960            &init.prefix,
961            &init.next_keypair_pkcs8,
962            None,
963            chrono::Utc::now(),
964        )
965        .unwrap();
966
967        let new_tip = kel.tip_commit_hash().unwrap();
968        assert_ne!(tip_hash, new_tip);
969
970        let parent = kel.parent_hash(new_tip).unwrap();
971        assert!(parent.is_some());
972        assert_eq!(parent.unwrap(), tip_hash);
973    }
974
975    #[test]
976    fn test_kel_merge_commit_rejected() {
977        with_temp_auths_home_and_repo(|_repo_dir, repo| {
978            let prefix = "EMergeReject";
979            let kel = GitKel::new(repo, prefix);
980            let icp = make_icp_event(prefix);
981            kel.create(&icp, chrono::Utc::now()).unwrap();
982
983            let _ = kel.get_state(chrono::Utc::now()).unwrap();
984
985            let inception_hash = kel.tip_commit_hash().unwrap();
986            let inception_oid = crate::witness::event_hash_to_oid(inception_hash);
987
988            let rot1 = Event::Rot(make_rot_event(prefix, 1, prefix));
989            let rot1_json = serde_json::to_vec_pretty(&rot1).unwrap();
990            let blob1_oid = repo.blob(&rot1_json).unwrap();
991            let mut tb1 = repo.treebuilder(None).unwrap();
992            tb1.insert("event.json", blob1_oid, 0o100644).unwrap();
993            let tree1_oid = tb1.write().unwrap();
994            let tree1 = repo.find_tree(tree1_oid).unwrap();
995            let inception_commit = repo.find_commit(inception_oid).unwrap();
996            let sig = repo.signature().unwrap();
997            let branch1_oid = repo
998                .commit(None, &sig, &sig, "Branch 1", &tree1, &[&inception_commit])
999                .unwrap();
1000
1001            // Create second branch: another rotation at s=1 (divergent)
1002            let rot2 = Event::Rot(make_rot_event(prefix, 1, prefix));
1003            let rot2_json = serde_json::to_vec_pretty(&rot2).unwrap();
1004            let blob2_oid = repo.blob(&rot2_json).unwrap();
1005            let mut tb2 = repo.treebuilder(None).unwrap();
1006            tb2.insert("event.json", blob2_oid, 0o100644).unwrap();
1007            let tree2_oid = tb2.write().unwrap();
1008            let tree2 = repo.find_tree(tree2_oid).unwrap();
1009            let branch2_oid = repo
1010                .commit(None, &sig, &sig, "Branch 2", &tree2, &[&inception_commit])
1011                .unwrap();
1012
1013            // Create a merge commit with two parents (INVALID for KEL!)
1014            let branch1_commit = repo.find_commit(branch1_oid).unwrap();
1015            let branch2_commit = repo.find_commit(branch2_oid).unwrap();
1016            let merge_oid = repo
1017                .commit(
1018                    None,
1019                    &sig,
1020                    &sig,
1021                    "Merge (invalid)",
1022                    &tree1,
1023                    &[&branch1_commit, &branch2_commit],
1024                )
1025                .unwrap();
1026
1027            // Point the KEL ref to the merge commit
1028            let ref_name = format!("refs/did/keri/{}/kel", prefix);
1029            repo.reference(&ref_name, merge_oid, true, "Force merge commit")
1030                .unwrap();
1031
1032            // Now get_state() should fail with ChainIntegrity error
1033            let result = kel.get_state(chrono::Utc::now());
1034            assert!(result.is_err());
1035            let err = result.unwrap_err();
1036            assert!(
1037                matches!(err, KelError::ChainIntegrity(_)),
1038                "Expected ChainIntegrity error, got: {:?}",
1039                err
1040            );
1041
1042            // Verify the error message mentions non-linear
1043            let msg = err.to_string();
1044            assert!(
1045                msg.contains("non-linear"),
1046                "Error message should mention non-linear: {}",
1047                msg
1048            );
1049        });
1050    }
1051}