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