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
62impl auths_core::error::AuthsErrorInfo for CacheError {
63 fn error_code(&self) -> &'static str {
64 match self {
65 Self::Io(_) => "AUTHS-E4986",
66 Self::Json(_) => "AUTHS-E4987",
67 }
68 }
69
70 fn suggestion(&self) -> Option<&'static str> {
71 match self {
72 Self::Io(_) => {
73 Some("Check cache directory permissions; the cache is optional and can be cleared")
74 }
75 Self::Json(_) => {
76 Some("The cache file may be corrupted; try clearing it with 'auths cache clear'")
77 }
78 }
79 }
80}
81
82pub fn cache_path_for_did(auths_home: &Path, did: &str) -> PathBuf {
92 let mut hasher = Sha256::new();
93 hasher.update(did.as_bytes());
94 let hash = hasher.finalize();
95 let hex = hex::encode(hash);
96
97 auths_home
98 .join("cache")
99 .join("kel")
100 .join(format!("{}.json", hex))
101}
102
103pub fn write_kel_cache(
120 auths_home: &Path,
121 did: &str,
122 state: &KeyState,
123 tip_said: &str,
124 commit_oid: &str,
125 now: DateTime<Utc>,
126) -> Result<(), CacheError> {
127 let cache = CachedKelState {
128 version: CACHE_VERSION,
129 #[allow(clippy::disallowed_methods)] did: IdentityDID::new_unchecked(did),
131 sequence: state.sequence,
132 validated_against_tip_said: Said::new_unchecked(tip_said.to_string()),
133 #[allow(clippy::disallowed_methods)] last_commit_oid: CommitOid::new_unchecked(commit_oid),
135 state: state.clone(),
136 cached_at: now,
137 };
138
139 let path = cache_path_for_did(auths_home, did);
140
141 if let Some(parent) = path.parent() {
143 fs::create_dir_all(parent)?;
144 }
145
146 let temp_path = path.with_extension("tmp");
148 {
149 let mut file = OpenOptions::new()
150 .write(true)
151 .create(true)
152 .truncate(true)
153 .open(&temp_path)?;
154
155 #[cfg(unix)]
157 {
158 use std::os::unix::fs::PermissionsExt;
159 file.set_permissions(fs::Permissions::from_mode(0o600))?;
160 }
161
162 let json = serde_json::to_vec_pretty(&cache)?;
163 file.write_all(&json)?;
164 file.sync_all()?;
165 }
166
167 fs::rename(&temp_path, &path)?;
168
169 #[cfg(unix)]
171 {
172 use std::os::unix::fs::PermissionsExt;
173 fs::set_permissions(&path, fs::Permissions::from_mode(0o600))?;
174 }
175
176 Ok(())
177}
178
179pub fn try_load_cached_state(
196 auths_home: &Path,
197 did: &str,
198 expected_tip_said: &str,
199) -> Option<KeyState> {
200 let cache = try_load_cached_state_full(auths_home, did)?;
201
202 if cache.validated_against_tip_said != expected_tip_said {
204 return None;
205 }
206
207 Some(cache.state)
208}
209
210pub fn try_load_cached_state_full(auths_home: &Path, did: &str) -> Option<CachedKelState> {
223 let path = cache_path_for_did(auths_home, did);
224
225 let contents = fs::read_to_string(&path).ok()?;
227 let cache: CachedKelState = serde_json::from_str(&contents).ok()?;
228
229 if cache.version != CACHE_VERSION {
231 return None;
232 }
233
234 if cache.did.as_str() != did {
236 return None;
237 }
238
239 Some(cache)
240}
241
242pub fn invalidate_cache(auths_home: &Path, did: &str) -> Result<(), io::Error> {
254 let path = cache_path_for_did(auths_home, did);
255 if path.exists() {
256 fs::remove_file(&path)?;
257 }
258 Ok(())
259}
260
261#[derive(Debug, Clone)]
263pub struct CacheEntry {
264 pub did: IdentityDID,
266 pub sequence: u128,
268 pub validated_against_tip_said: Said,
270 pub last_commit_oid: CommitOid,
272 pub cached_at: DateTime<Utc>,
274 pub path: PathBuf,
276}
277
278pub fn list_cached_entries(auths_home: &Path) -> Result<Vec<CacheEntry>, io::Error> {
285 let cache_dir = auths_home.join("cache").join("kel");
286
287 if !cache_dir.exists() {
288 return Ok(Vec::new());
289 }
290
291 let mut entries = Vec::new();
292 for entry in fs::read_dir(&cache_dir)? {
293 let entry = entry?;
294 let path = entry.path();
295 if path.extension().is_some_and(|ext| ext == "json")
296 && let Ok(contents) = fs::read_to_string(&path)
297 && let Ok(cache) = serde_json::from_str::<CachedKelState>(&contents)
298 {
299 entries.push(CacheEntry {
300 did: cache.did,
301 sequence: cache.sequence,
302 validated_against_tip_said: cache.validated_against_tip_said,
303 last_commit_oid: cache.last_commit_oid,
304 cached_at: cache.cached_at,
305 path: path.clone(),
306 });
307 }
308 }
309
310 Ok(entries)
311}
312
313pub fn clear_all_caches(auths_home: &Path) -> Result<usize, io::Error> {
321 let cache_dir = auths_home.join("cache").join("kel");
322
323 if !cache_dir.exists() {
324 return Ok(0);
325 }
326
327 let mut count = 0;
328 for entry in fs::read_dir(&cache_dir)? {
329 let entry = entry?;
330 let path = entry.path();
331 if path.extension().is_some_and(|ext| ext == "json") {
332 fs::remove_file(&path)?;
333 count += 1;
334 }
335 }
336
337 Ok(count)
338}
339
340pub fn inspect_cache(auths_home: &Path, did: &str) -> Result<Option<CachedKelState>, io::Error> {
348 let path = cache_path_for_did(auths_home, did);
349
350 if !path.exists() {
351 return Ok(None);
352 }
353
354 let contents = fs::read_to_string(&path)?;
355 match serde_json::from_str::<CachedKelState>(&contents) {
356 Ok(cache) => Ok(Some(cache)),
357 Err(_) => Ok(None), }
359}
360
361#[cfg(test)]
362#[allow(clippy::disallowed_methods)]
363mod tests {
364 use super::*;
365 use crate::keri::{CesrKey, Prefix, Threshold};
366 use tempfile::TempDir;
367
368 const VALID_OID: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
369
370 fn create_test_state() -> KeyState {
371 KeyState::from_inception(
372 Prefix::new_unchecked("ETestPrefix".to_string()),
373 vec![CesrKey::new_unchecked("DKey1".to_string())],
374 vec![Said::new_unchecked("ENext1".to_string())],
375 Threshold::Simple(1),
376 Threshold::Simple(1),
377 Said::new_unchecked("ESaid123".to_string()),
378 vec![],
379 Threshold::Simple(0),
380 vec![],
381 )
382 }
383
384 #[test]
385 fn test_cache_path_uses_hash() {
386 let dir = TempDir::new().unwrap();
387 let path = cache_path_for_did(dir.path(), "did:keri:ETestPrefix");
388 let filename = path.file_name().unwrap().to_string_lossy();
389 assert!(filename.ends_with(".json"));
391 assert_eq!(filename.len(), 64 + 5); }
393
394 #[test]
395 fn test_different_dids_get_different_paths() {
396 let dir = TempDir::new().unwrap();
397 let path1 = cache_path_for_did(dir.path(), "did:keri:ETest1");
398 let path2 = cache_path_for_did(dir.path(), "did:keri:ETest2");
399 assert_ne!(path1, path2);
400 }
401
402 #[test]
403 fn test_cache_write_and_read() {
404 let dir = TempDir::new().unwrap();
405 let did = "did:keri:ETest123";
406 let state = create_test_state();
407 let tip_said = "ELatestSaid";
408
409 write_kel_cache(dir.path(), did, &state, tip_said, VALID_OID, Utc::now()).unwrap();
411
412 let loaded = try_load_cached_state(dir.path(), did, tip_said);
414 assert!(loaded.is_some());
415 let loaded = loaded.unwrap();
416 assert_eq!(loaded.prefix, state.prefix);
417 assert_eq!(loaded.sequence, state.sequence);
418 }
419
420 #[test]
421 fn test_cache_invalidation_on_said_mismatch() {
422 let dir = TempDir::new().unwrap();
423 let did = "did:keri:EMismatch";
424 let state = create_test_state();
425
426 write_kel_cache(dir.path(), did, &state, "EOldSaid", VALID_OID, Utc::now()).unwrap();
428
429 let result = try_load_cached_state(dir.path(), did, "ENewSaid");
431 assert!(result.is_none());
432
433 let result = try_load_cached_state(dir.path(), did, "EOldSaid");
435 assert!(result.is_some());
436 }
437
438 #[test]
439 fn test_cache_invalidation_on_did_mismatch() {
440 let dir = TempDir::new().unwrap();
441 let did = "did:keri:EOriginal";
442 let state = create_test_state();
443 let tip_said = "ESaid";
444
445 write_kel_cache(dir.path(), did, &state, tip_said, VALID_OID, Utc::now()).unwrap();
447
448 let path = cache_path_for_did(dir.path(), did);
450 let mut cache: CachedKelState =
451 serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
452 cache.did = IdentityDID::new_unchecked("did:keri:EWrongDid");
453 fs::write(&path, serde_json::to_vec_pretty(&cache).unwrap()).unwrap();
454
455 let result = try_load_cached_state(dir.path(), did, tip_said);
457 assert!(result.is_none());
458 }
459
460 #[test]
461 fn test_cache_handles_missing_file() {
462 let dir = TempDir::new().unwrap();
463 let result = try_load_cached_state(dir.path(), "did:keri:ENonexistent", "ESomeSaid");
464 assert!(result.is_none());
465 }
466
467 #[test]
468 fn test_cache_handles_corrupt_file() {
469 let dir = TempDir::new().unwrap();
470 let did = "did:keri:ECorrupt";
471 let path = cache_path_for_did(dir.path(), did);
472
473 fs::create_dir_all(path.parent().unwrap()).unwrap();
475 fs::write(&path, "{ invalid json }").unwrap();
476
477 let result = try_load_cached_state(dir.path(), did, "ESomeSaid");
479 assert!(result.is_none());
480 }
481
482 #[test]
483 fn test_cache_version_mismatch() {
484 let dir = TempDir::new().unwrap();
485 let did = "did:keri:EVersionTest";
486 let state = create_test_state();
487 let tip_said = "ESaid";
488
489 write_kel_cache(dir.path(), did, &state, tip_said, VALID_OID, Utc::now()).unwrap();
491
492 let path = cache_path_for_did(dir.path(), did);
494 let mut cache: CachedKelState =
495 serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
496 cache.version = CACHE_VERSION + 1;
497 fs::write(&path, serde_json::to_vec_pretty(&cache).unwrap()).unwrap();
498
499 let result = try_load_cached_state(dir.path(), did, tip_said);
501 assert!(result.is_none());
502 }
503
504 #[test]
505 fn test_invalidate_cache() {
506 let dir = TempDir::new().unwrap();
507 let did = "did:keri:EToInvalidate";
508 let state = create_test_state();
509
510 write_kel_cache(dir.path(), did, &state, "ESaid", VALID_OID, Utc::now()).unwrap();
512 assert!(try_load_cached_state(dir.path(), did, "ESaid").is_some());
513
514 invalidate_cache(dir.path(), did).unwrap();
516
517 assert!(try_load_cached_state(dir.path(), did, "ESaid").is_none());
519 }
520
521 #[test]
522 fn test_invalidate_nonexistent_cache() {
523 let dir = TempDir::new().unwrap();
524 let result = invalidate_cache(dir.path(), "did:keri:ENeverExisted");
526 assert!(result.is_ok());
527 }
528
529 #[test]
530 fn test_list_cached_entries() {
531 let dir = TempDir::new().unwrap();
532 let state = create_test_state();
533
534 write_kel_cache(
536 dir.path(),
537 "did:keri:ETest1",
538 &state,
539 "ESaid1",
540 VALID_OID,
541 Utc::now(),
542 )
543 .unwrap();
544 write_kel_cache(
545 dir.path(),
546 "did:keri:ETest2",
547 &state,
548 "ESaid2",
549 VALID_OID,
550 Utc::now(),
551 )
552 .unwrap();
553
554 let entries = list_cached_entries(dir.path()).unwrap();
555 assert_eq!(entries.len(), 2);
556
557 let dids: Vec<_> = entries.iter().map(|e| e.did.as_str()).collect();
558 assert!(dids.contains(&"did:keri:ETest1"));
559 assert!(dids.contains(&"did:keri:ETest2"));
560 }
561
562 #[test]
563 fn test_clear_all_caches() {
564 let dir = TempDir::new().unwrap();
565 let state = create_test_state();
566
567 write_kel_cache(
569 dir.path(),
570 "did:keri:EClear1",
571 &state,
572 "ESaid",
573 VALID_OID,
574 Utc::now(),
575 )
576 .unwrap();
577 write_kel_cache(
578 dir.path(),
579 "did:keri:EClear2",
580 &state,
581 "ESaid",
582 VALID_OID,
583 Utc::now(),
584 )
585 .unwrap();
586
587 let count = clear_all_caches(dir.path()).unwrap();
589 assert_eq!(count, 2);
590
591 let entries = list_cached_entries(dir.path()).unwrap();
593 assert!(entries.is_empty());
594 }
595
596 #[test]
597 fn test_inspect_cache() {
598 let dir = TempDir::new().unwrap();
599 let did = "did:keri:EInspect";
600 let state = create_test_state();
601 let tip_said = "ESaid";
602
603 write_kel_cache(dir.path(), did, &state, tip_said, VALID_OID, Utc::now()).unwrap();
604
605 let inspected = inspect_cache(dir.path(), did).unwrap();
606 assert!(inspected.is_some());
607 let inspected = inspected.unwrap();
608 assert_eq!(inspected.did, did);
609 assert_eq!(inspected.validated_against_tip_said, tip_said);
610 }
611}