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: u64,
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-E4981",
66 Self::Json(_) => "AUTHS-E4982",
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: u64,
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::Prefix;
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!["DKey1".to_string()],
374 vec!["ENext1".to_string()],
375 1,
376 1,
377 Said::new_unchecked("ESaid123".to_string()),
378 )
379 }
380
381 #[test]
382 fn test_cache_path_uses_hash() {
383 let dir = TempDir::new().unwrap();
384 let path = cache_path_for_did(dir.path(), "did:keri:ETestPrefix");
385 let filename = path.file_name().unwrap().to_string_lossy();
386 assert!(filename.ends_with(".json"));
388 assert_eq!(filename.len(), 64 + 5); }
390
391 #[test]
392 fn test_different_dids_get_different_paths() {
393 let dir = TempDir::new().unwrap();
394 let path1 = cache_path_for_did(dir.path(), "did:keri:ETest1");
395 let path2 = cache_path_for_did(dir.path(), "did:keri:ETest2");
396 assert_ne!(path1, path2);
397 }
398
399 #[test]
400 fn test_cache_write_and_read() {
401 let dir = TempDir::new().unwrap();
402 let did = "did:keri:ETest123";
403 let state = create_test_state();
404 let tip_said = "ELatestSaid";
405
406 write_kel_cache(dir.path(), did, &state, tip_said, VALID_OID, Utc::now()).unwrap();
408
409 let loaded = try_load_cached_state(dir.path(), did, tip_said);
411 assert!(loaded.is_some());
412 let loaded = loaded.unwrap();
413 assert_eq!(loaded.prefix, state.prefix);
414 assert_eq!(loaded.sequence, state.sequence);
415 }
416
417 #[test]
418 fn test_cache_invalidation_on_said_mismatch() {
419 let dir = TempDir::new().unwrap();
420 let did = "did:keri:EMismatch";
421 let state = create_test_state();
422
423 write_kel_cache(dir.path(), did, &state, "EOldSaid", VALID_OID, Utc::now()).unwrap();
425
426 let result = try_load_cached_state(dir.path(), did, "ENewSaid");
428 assert!(result.is_none());
429
430 let result = try_load_cached_state(dir.path(), did, "EOldSaid");
432 assert!(result.is_some());
433 }
434
435 #[test]
436 fn test_cache_invalidation_on_did_mismatch() {
437 let dir = TempDir::new().unwrap();
438 let did = "did:keri:EOriginal";
439 let state = create_test_state();
440 let tip_said = "ESaid";
441
442 write_kel_cache(dir.path(), did, &state, tip_said, VALID_OID, Utc::now()).unwrap();
444
445 let path = cache_path_for_did(dir.path(), did);
447 let mut cache: CachedKelState =
448 serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
449 cache.did = IdentityDID::new_unchecked("did:keri:EWrongDid");
450 fs::write(&path, serde_json::to_vec_pretty(&cache).unwrap()).unwrap();
451
452 let result = try_load_cached_state(dir.path(), did, tip_said);
454 assert!(result.is_none());
455 }
456
457 #[test]
458 fn test_cache_handles_missing_file() {
459 let dir = TempDir::new().unwrap();
460 let result = try_load_cached_state(dir.path(), "did:keri:ENonexistent", "ESomeSaid");
461 assert!(result.is_none());
462 }
463
464 #[test]
465 fn test_cache_handles_corrupt_file() {
466 let dir = TempDir::new().unwrap();
467 let did = "did:keri:ECorrupt";
468 let path = cache_path_for_did(dir.path(), did);
469
470 fs::create_dir_all(path.parent().unwrap()).unwrap();
472 fs::write(&path, "{ invalid json }").unwrap();
473
474 let result = try_load_cached_state(dir.path(), did, "ESomeSaid");
476 assert!(result.is_none());
477 }
478
479 #[test]
480 fn test_cache_version_mismatch() {
481 let dir = TempDir::new().unwrap();
482 let did = "did:keri:EVersionTest";
483 let state = create_test_state();
484 let tip_said = "ESaid";
485
486 write_kel_cache(dir.path(), did, &state, tip_said, VALID_OID, Utc::now()).unwrap();
488
489 let path = cache_path_for_did(dir.path(), did);
491 let mut cache: CachedKelState =
492 serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
493 cache.version = CACHE_VERSION + 1;
494 fs::write(&path, serde_json::to_vec_pretty(&cache).unwrap()).unwrap();
495
496 let result = try_load_cached_state(dir.path(), did, tip_said);
498 assert!(result.is_none());
499 }
500
501 #[test]
502 fn test_invalidate_cache() {
503 let dir = TempDir::new().unwrap();
504 let did = "did:keri:EToInvalidate";
505 let state = create_test_state();
506
507 write_kel_cache(dir.path(), did, &state, "ESaid", VALID_OID, Utc::now()).unwrap();
509 assert!(try_load_cached_state(dir.path(), did, "ESaid").is_some());
510
511 invalidate_cache(dir.path(), did).unwrap();
513
514 assert!(try_load_cached_state(dir.path(), did, "ESaid").is_none());
516 }
517
518 #[test]
519 fn test_invalidate_nonexistent_cache() {
520 let dir = TempDir::new().unwrap();
521 let result = invalidate_cache(dir.path(), "did:keri:ENeverExisted");
523 assert!(result.is_ok());
524 }
525
526 #[test]
527 fn test_list_cached_entries() {
528 let dir = TempDir::new().unwrap();
529 let state = create_test_state();
530
531 write_kel_cache(
533 dir.path(),
534 "did:keri:ETest1",
535 &state,
536 "ESaid1",
537 VALID_OID,
538 Utc::now(),
539 )
540 .unwrap();
541 write_kel_cache(
542 dir.path(),
543 "did:keri:ETest2",
544 &state,
545 "ESaid2",
546 VALID_OID,
547 Utc::now(),
548 )
549 .unwrap();
550
551 let entries = list_cached_entries(dir.path()).unwrap();
552 assert_eq!(entries.len(), 2);
553
554 let dids: Vec<_> = entries.iter().map(|e| e.did.as_str()).collect();
555 assert!(dids.contains(&"did:keri:ETest1"));
556 assert!(dids.contains(&"did:keri:ETest2"));
557 }
558
559 #[test]
560 fn test_clear_all_caches() {
561 let dir = TempDir::new().unwrap();
562 let state = create_test_state();
563
564 write_kel_cache(
566 dir.path(),
567 "did:keri:EClear1",
568 &state,
569 "ESaid",
570 VALID_OID,
571 Utc::now(),
572 )
573 .unwrap();
574 write_kel_cache(
575 dir.path(),
576 "did:keri:EClear2",
577 &state,
578 "ESaid",
579 VALID_OID,
580 Utc::now(),
581 )
582 .unwrap();
583
584 let count = clear_all_caches(dir.path()).unwrap();
586 assert_eq!(count, 2);
587
588 let entries = list_cached_entries(dir.path()).unwrap();
590 assert!(entries.is_empty());
591 }
592
593 #[test]
594 fn test_inspect_cache() {
595 let dir = TempDir::new().unwrap();
596 let did = "did:keri:EInspect";
597 let state = create_test_state();
598 let tip_said = "ESaid";
599
600 write_kel_cache(dir.path(), did, &state, tip_said, VALID_OID, Utc::now()).unwrap();
601
602 let inspected = inspect_cache(dir.path(), did).unwrap();
603 assert!(inspected.is_some());
604 let inspected = inspected.unwrap();
605 assert_eq!(inspected.did, did);
606 assert_eq!(inspected.validated_against_tip_said, tip_said);
607 }
608}