1use auths_verifier::CommitOid;
16use auths_verifier::types::IdentityDID;
17use chrono::{DateTime, Utc};
18use serde::{Deserialize, Serialize};
19use sha2::{Digest, Sha256};
20#[allow(clippy::disallowed_types)]
21use std::fs::{self, OpenOptions};
23use std::io::{self, Write};
24use std::path::{Path, PathBuf};
25
26use super::state::KeyState;
27use super::types::Said;
28
29pub const CACHE_VERSION: u32 = 2;
31
32#[derive(Debug, Clone, Serialize, Deserialize)]
34pub struct CachedKelState {
35 pub version: u32,
37 pub did: IdentityDID,
39 pub sequence: u128,
41 pub validated_against_tip_said: Said,
43 pub last_commit_oid: CommitOid,
45 pub state: KeyState,
47 pub cached_at: DateTime<Utc>,
49}
50
51#[derive(Debug, thiserror::Error)]
53#[non_exhaustive]
54pub enum CacheError {
55 #[error("I/O error: {0}")]
56 Io(#[from] io::Error),
57
58 #[error("JSON serialization error: {0}")]
59 Json(#[from] serde_json::Error),
60
61 #[error("invalid DID: {0}")]
62 InvalidDid(String),
63}
64
65impl auths_core::error::AuthsErrorInfo for CacheError {
66 fn error_code(&self) -> &'static str {
67 match self {
68 Self::Io(_) => "AUTHS-E4986",
69 Self::Json(_) => "AUTHS-E4987",
70 Self::InvalidDid(_) => "AUTHS-E4988",
71 }
72 }
73
74 fn suggestion(&self) -> Option<&'static str> {
75 match self {
76 Self::Io(_) => {
77 Some("Check cache directory permissions; the cache is optional and can be cleared")
78 }
79 Self::Json(_) => {
80 Some("The cache file may be corrupted; try clearing it with 'auths cache clear'")
81 }
82 Self::InvalidDid(_) => Some("The DID must be a 'did:keri:' identity"),
83 }
84 }
85}
86
87pub fn cache_path_for_did(auths_home: &Path, did: &str) -> PathBuf {
97 let mut hasher = Sha256::new();
98 hasher.update(did.as_bytes());
99 let hash = hasher.finalize();
100 let hex = hex::encode(hash);
101
102 auths_home
103 .join("cache")
104 .join("kel")
105 .join(format!("{}.json", hex))
106}
107
108pub fn write_kel_cache(
125 auths_home: &Path,
126 did: &str,
127 state: &KeyState,
128 tip_said: &str,
129 commit_oid: &str,
130 now: DateTime<Utc>,
131) -> Result<(), CacheError> {
132 let did_parsed = IdentityDID::parse(did).map_err(|e| CacheError::InvalidDid(e.to_string()))?;
133 let cache = CachedKelState {
134 version: CACHE_VERSION,
135 did: did_parsed,
136 sequence: state.sequence,
137 validated_against_tip_said: Said::new_unchecked(tip_said.to_string()),
138 #[allow(clippy::disallowed_methods)] last_commit_oid: CommitOid::new_unchecked(commit_oid),
140 state: state.clone(),
141 cached_at: now,
142 };
143
144 let path = cache_path_for_did(auths_home, did);
145
146 if let Some(parent) = path.parent() {
148 fs::create_dir_all(parent)?;
149 }
150
151 let temp_path = path.with_extension("tmp");
153 {
154 let mut file = OpenOptions::new()
155 .write(true)
156 .create(true)
157 .truncate(true)
158 .open(&temp_path)?;
159
160 #[cfg(unix)]
162 {
163 use std::os::unix::fs::PermissionsExt;
164 file.set_permissions(fs::Permissions::from_mode(0o600))?;
165 }
166
167 let json = serde_json::to_vec_pretty(&cache)?;
168 file.write_all(&json)?;
169 file.sync_all()?;
170 }
171
172 fs::rename(&temp_path, &path)?;
173
174 #[cfg(unix)]
176 {
177 use std::os::unix::fs::PermissionsExt;
178 fs::set_permissions(&path, fs::Permissions::from_mode(0o600))?;
179 }
180
181 Ok(())
182}
183
184pub fn try_load_cached_state(
201 auths_home: &Path,
202 did: &str,
203 expected_tip_said: &str,
204) -> Option<KeyState> {
205 let cache = try_load_cached_state_full(auths_home, did)?;
206
207 if cache.validated_against_tip_said != expected_tip_said {
209 return None;
210 }
211
212 Some(cache.state)
213}
214
215pub fn try_load_cached_state_full(auths_home: &Path, did: &str) -> Option<CachedKelState> {
228 let path = cache_path_for_did(auths_home, did);
229
230 let contents = fs::read_to_string(&path).ok()?;
232 let cache: CachedKelState = serde_json::from_str(&contents).ok()?;
233
234 if cache.version != CACHE_VERSION {
236 return None;
237 }
238
239 if cache.did.as_str() != did {
241 return None;
242 }
243
244 Some(cache)
245}
246
247pub fn invalidate_cache(auths_home: &Path, did: &str) -> Result<(), io::Error> {
259 let path = cache_path_for_did(auths_home, did);
260 if path.exists() {
261 fs::remove_file(&path)?;
262 }
263 Ok(())
264}
265
266#[derive(Debug, Clone)]
268pub struct CacheEntry {
269 pub did: IdentityDID,
271 pub sequence: u128,
273 pub validated_against_tip_said: Said,
275 pub last_commit_oid: CommitOid,
277 pub cached_at: DateTime<Utc>,
279 pub path: PathBuf,
281}
282
283pub fn list_cached_entries(auths_home: &Path) -> Result<Vec<CacheEntry>, io::Error> {
290 let cache_dir = auths_home.join("cache").join("kel");
291
292 if !cache_dir.exists() {
293 return Ok(Vec::new());
294 }
295
296 let mut entries = Vec::new();
297 for entry in fs::read_dir(&cache_dir)? {
298 let entry = entry?;
299 let path = entry.path();
300 if path.extension().is_some_and(|ext| ext == "json")
301 && let Ok(contents) = fs::read_to_string(&path)
302 && let Ok(cache) = serde_json::from_str::<CachedKelState>(&contents)
303 {
304 entries.push(CacheEntry {
305 did: cache.did,
306 sequence: cache.sequence,
307 validated_against_tip_said: cache.validated_against_tip_said,
308 last_commit_oid: cache.last_commit_oid,
309 cached_at: cache.cached_at,
310 path: path.clone(),
311 });
312 }
313 }
314
315 Ok(entries)
316}
317
318pub fn clear_all_caches(auths_home: &Path) -> Result<usize, io::Error> {
326 let cache_dir = auths_home.join("cache").join("kel");
327
328 if !cache_dir.exists() {
329 return Ok(0);
330 }
331
332 let mut count = 0;
333 for entry in fs::read_dir(&cache_dir)? {
334 let entry = entry?;
335 let path = entry.path();
336 if path.extension().is_some_and(|ext| ext == "json") {
337 fs::remove_file(&path)?;
338 count += 1;
339 }
340 }
341
342 Ok(count)
343}
344
345pub fn inspect_cache(auths_home: &Path, did: &str) -> Result<Option<CachedKelState>, io::Error> {
353 let path = cache_path_for_did(auths_home, did);
354
355 if !path.exists() {
356 return Ok(None);
357 }
358
359 let contents = fs::read_to_string(&path)?;
360 match serde_json::from_str::<CachedKelState>(&contents) {
361 Ok(cache) => Ok(Some(cache)),
362 Err(_) => Ok(None), }
364}
365
366#[cfg(test)]
367#[allow(clippy::disallowed_methods)]
368mod tests {
369 use super::*;
370 use crate::keri::{CesrKey, Prefix, Threshold};
371 use tempfile::TempDir;
372
373 const VALID_OID: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
374
375 fn create_test_state() -> KeyState {
376 KeyState::from_inception(
377 Prefix::new_unchecked("ETestPrefix".to_string()),
378 vec![CesrKey::new_unchecked("DKey1".to_string())],
379 vec![Said::new_unchecked("ENext1".to_string())],
380 Threshold::Simple(1),
381 Threshold::Simple(1),
382 Said::new_unchecked("ESaid123".to_string()),
383 vec![],
384 Threshold::Simple(0),
385 vec![],
386 )
387 }
388
389 #[test]
390 fn test_cache_path_uses_hash() {
391 let dir = TempDir::new().unwrap();
392 let path = cache_path_for_did(dir.path(), "did:keri:ETestPrefix");
393 let filename = path.file_name().unwrap().to_string_lossy();
394 assert!(filename.ends_with(".json"));
396 assert_eq!(filename.len(), 64 + 5); }
398
399 #[test]
400 fn test_different_dids_get_different_paths() {
401 let dir = TempDir::new().unwrap();
402 let path1 = cache_path_for_did(dir.path(), "did:keri:ETest1");
403 let path2 = cache_path_for_did(dir.path(), "did:keri:ETest2");
404 assert_ne!(path1, path2);
405 }
406
407 #[test]
408 fn test_cache_write_and_read() {
409 let dir = TempDir::new().unwrap();
410 let did = "did:keri:ETest123";
411 let state = create_test_state();
412 let tip_said = "ELatestSaid";
413
414 write_kel_cache(dir.path(), did, &state, tip_said, VALID_OID, Utc::now()).unwrap();
416
417 let loaded = try_load_cached_state(dir.path(), did, tip_said);
419 assert!(loaded.is_some());
420 let loaded = loaded.unwrap();
421 assert_eq!(loaded.prefix, state.prefix);
422 assert_eq!(loaded.sequence, state.sequence);
423 }
424
425 #[test]
426 fn write_kel_cache_refuses_non_keri_did() {
427 let dir = TempDir::new().unwrap();
428 let state = create_test_state();
429
430 let result = write_kel_cache(
431 dir.path(),
432 "did:key:z6MkNotAKeriIdentity",
433 &state,
434 "ELatestSaid",
435 VALID_OID,
436 Utc::now(),
437 );
438
439 assert!(matches!(result, Err(CacheError::InvalidDid(_))));
440 }
441
442 #[test]
443 fn test_cache_invalidation_on_said_mismatch() {
444 let dir = TempDir::new().unwrap();
445 let did = "did:keri:EMismatch";
446 let state = create_test_state();
447
448 write_kel_cache(dir.path(), did, &state, "EOldSaid", VALID_OID, Utc::now()).unwrap();
450
451 let result = try_load_cached_state(dir.path(), did, "ENewSaid");
453 assert!(result.is_none());
454
455 let result = try_load_cached_state(dir.path(), did, "EOldSaid");
457 assert!(result.is_some());
458 }
459
460 #[test]
461 fn test_cache_invalidation_on_did_mismatch() {
462 let dir = TempDir::new().unwrap();
463 let did = "did:keri:EOriginal";
464 let state = create_test_state();
465 let tip_said = "ESaid";
466
467 write_kel_cache(dir.path(), did, &state, tip_said, VALID_OID, Utc::now()).unwrap();
469
470 let path = cache_path_for_did(dir.path(), did);
472 let mut cache: CachedKelState =
473 serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
474 cache.did = IdentityDID::parse("did:keri:EWrongDid").unwrap();
475 fs::write(&path, serde_json::to_vec_pretty(&cache).unwrap()).unwrap();
476
477 let result = try_load_cached_state(dir.path(), did, tip_said);
479 assert!(result.is_none());
480 }
481
482 #[test]
483 fn test_cache_handles_missing_file() {
484 let dir = TempDir::new().unwrap();
485 let result = try_load_cached_state(dir.path(), "did:keri:ENonexistent", "ESomeSaid");
486 assert!(result.is_none());
487 }
488
489 #[test]
490 fn test_cache_handles_corrupt_file() {
491 let dir = TempDir::new().unwrap();
492 let did = "did:keri:ECorrupt";
493 let path = cache_path_for_did(dir.path(), did);
494
495 fs::create_dir_all(path.parent().unwrap()).unwrap();
497 fs::write(&path, "{ invalid json }").unwrap();
498
499 let result = try_load_cached_state(dir.path(), did, "ESomeSaid");
501 assert!(result.is_none());
502 }
503
504 #[test]
505 fn test_cache_version_mismatch() {
506 let dir = TempDir::new().unwrap();
507 let did = "did:keri:EVersionTest";
508 let state = create_test_state();
509 let tip_said = "ESaid";
510
511 write_kel_cache(dir.path(), did, &state, tip_said, VALID_OID, Utc::now()).unwrap();
513
514 let path = cache_path_for_did(dir.path(), did);
516 let mut cache: CachedKelState =
517 serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
518 cache.version = CACHE_VERSION + 1;
519 fs::write(&path, serde_json::to_vec_pretty(&cache).unwrap()).unwrap();
520
521 let result = try_load_cached_state(dir.path(), did, tip_said);
523 assert!(result.is_none());
524 }
525
526 #[test]
527 fn test_invalidate_cache() {
528 let dir = TempDir::new().unwrap();
529 let did = "did:keri:EToInvalidate";
530 let state = create_test_state();
531
532 write_kel_cache(dir.path(), did, &state, "ESaid", VALID_OID, Utc::now()).unwrap();
534 assert!(try_load_cached_state(dir.path(), did, "ESaid").is_some());
535
536 invalidate_cache(dir.path(), did).unwrap();
538
539 assert!(try_load_cached_state(dir.path(), did, "ESaid").is_none());
541 }
542
543 #[test]
544 fn test_invalidate_nonexistent_cache() {
545 let dir = TempDir::new().unwrap();
546 let result = invalidate_cache(dir.path(), "did:keri:ENeverExisted");
548 assert!(result.is_ok());
549 }
550
551 #[test]
552 fn test_list_cached_entries() {
553 let dir = TempDir::new().unwrap();
554 let state = create_test_state();
555
556 write_kel_cache(
558 dir.path(),
559 "did:keri:ETest1",
560 &state,
561 "ESaid1",
562 VALID_OID,
563 Utc::now(),
564 )
565 .unwrap();
566 write_kel_cache(
567 dir.path(),
568 "did:keri:ETest2",
569 &state,
570 "ESaid2",
571 VALID_OID,
572 Utc::now(),
573 )
574 .unwrap();
575
576 let entries = list_cached_entries(dir.path()).unwrap();
577 assert_eq!(entries.len(), 2);
578
579 let dids: Vec<_> = entries.iter().map(|e| e.did.as_str()).collect();
580 assert!(dids.contains(&"did:keri:ETest1"));
581 assert!(dids.contains(&"did:keri:ETest2"));
582 }
583
584 #[test]
585 fn test_clear_all_caches() {
586 let dir = TempDir::new().unwrap();
587 let state = create_test_state();
588
589 write_kel_cache(
591 dir.path(),
592 "did:keri:EClear1",
593 &state,
594 "ESaid",
595 VALID_OID,
596 Utc::now(),
597 )
598 .unwrap();
599 write_kel_cache(
600 dir.path(),
601 "did:keri:EClear2",
602 &state,
603 "ESaid",
604 VALID_OID,
605 Utc::now(),
606 )
607 .unwrap();
608
609 let count = clear_all_caches(dir.path()).unwrap();
611 assert_eq!(count, 2);
612
613 let entries = list_cached_entries(dir.path()).unwrap();
615 assert!(entries.is_empty());
616 }
617
618 #[test]
619 fn test_inspect_cache() {
620 let dir = TempDir::new().unwrap();
621 let did = "did:keri:EInspect";
622 let state = create_test_state();
623 let tip_said = "ESaid";
624
625 write_kel_cache(dir.path(), did, &state, tip_said, VALID_OID, Utc::now()).unwrap();
626
627 let inspected = inspect_cache(dir.path(), did).unwrap();
628 assert!(inspected.is_some());
629 let inspected = inspected.unwrap();
630 assert_eq!(inspected.did, did);
631 assert_eq!(inspected.validated_against_tip_said, tip_said);
632 }
633}