use auths_verifier::CommitOid;
use auths_verifier::types::IdentityDID;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
#[allow(clippy::disallowed_types)]
use std::fs::{self, OpenOptions};
use std::io::{self, Write};
use std::path::{Path, PathBuf};
use super::state::KeyState;
use super::types::Said;
pub const CACHE_VERSION: u32 = 2;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CachedKelState {
pub version: u32,
pub did: IdentityDID,
pub sequence: u128,
pub validated_against_tip_said: Said,
pub last_commit_oid: CommitOid,
pub state: KeyState,
pub cached_at: DateTime<Utc>,
}
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum CacheError {
#[error("I/O error: {0}")]
Io(#[from] io::Error),
#[error("JSON serialization error: {0}")]
Json(#[from] serde_json::Error),
}
impl auths_core::error::AuthsErrorInfo for CacheError {
fn error_code(&self) -> &'static str {
match self {
Self::Io(_) => "AUTHS-E4981",
Self::Json(_) => "AUTHS-E4982",
}
}
fn suggestion(&self) -> Option<&'static str> {
match self {
Self::Io(_) => {
Some("Check cache directory permissions; the cache is optional and can be cleared")
}
Self::Json(_) => {
Some("The cache file may be corrupted; try clearing it with 'auths cache clear'")
}
}
}
}
pub fn cache_path_for_did(auths_home: &Path, did: &str) -> PathBuf {
let mut hasher = Sha256::new();
hasher.update(did.as_bytes());
let hash = hasher.finalize();
let hex = hex::encode(hash);
auths_home
.join("cache")
.join("kel")
.join(format!("{}.json", hex))
}
pub fn write_kel_cache(
auths_home: &Path,
did: &str,
state: &KeyState,
tip_said: &str,
commit_oid: &str,
now: DateTime<Utc>,
) -> Result<(), CacheError> {
let cache = CachedKelState {
version: CACHE_VERSION,
#[allow(clippy::disallowed_methods)] did: IdentityDID::new_unchecked(did),
sequence: state.sequence,
validated_against_tip_said: Said::new_unchecked(tip_said.to_string()),
#[allow(clippy::disallowed_methods)] last_commit_oid: CommitOid::new_unchecked(commit_oid),
state: state.clone(),
cached_at: now,
};
let path = cache_path_for_did(auths_home, did);
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
let temp_path = path.with_extension("tmp");
{
let mut file = OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.open(&temp_path)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
file.set_permissions(fs::Permissions::from_mode(0o600))?;
}
let json = serde_json::to_vec_pretty(&cache)?;
file.write_all(&json)?;
file.sync_all()?;
}
fs::rename(&temp_path, &path)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(&path, fs::Permissions::from_mode(0o600))?;
}
Ok(())
}
pub fn try_load_cached_state(
auths_home: &Path,
did: &str,
expected_tip_said: &str,
) -> Option<KeyState> {
let cache = try_load_cached_state_full(auths_home, did)?;
if cache.validated_against_tip_said != expected_tip_said {
return None;
}
Some(cache.state)
}
pub fn try_load_cached_state_full(auths_home: &Path, did: &str) -> Option<CachedKelState> {
let path = cache_path_for_did(auths_home, did);
let contents = fs::read_to_string(&path).ok()?;
let cache: CachedKelState = serde_json::from_str(&contents).ok()?;
if cache.version != CACHE_VERSION {
return None;
}
if cache.did.as_str() != did {
return None;
}
Some(cache)
}
pub fn invalidate_cache(auths_home: &Path, did: &str) -> Result<(), io::Error> {
let path = cache_path_for_did(auths_home, did);
if path.exists() {
fs::remove_file(&path)?;
}
Ok(())
}
#[derive(Debug, Clone)]
pub struct CacheEntry {
pub did: IdentityDID,
pub sequence: u128,
pub validated_against_tip_said: Said,
pub last_commit_oid: CommitOid,
pub cached_at: DateTime<Utc>,
pub path: PathBuf,
}
pub fn list_cached_entries(auths_home: &Path) -> Result<Vec<CacheEntry>, io::Error> {
let cache_dir = auths_home.join("cache").join("kel");
if !cache_dir.exists() {
return Ok(Vec::new());
}
let mut entries = Vec::new();
for entry in fs::read_dir(&cache_dir)? {
let entry = entry?;
let path = entry.path();
if path.extension().is_some_and(|ext| ext == "json")
&& let Ok(contents) = fs::read_to_string(&path)
&& let Ok(cache) = serde_json::from_str::<CachedKelState>(&contents)
{
entries.push(CacheEntry {
did: cache.did,
sequence: cache.sequence,
validated_against_tip_said: cache.validated_against_tip_said,
last_commit_oid: cache.last_commit_oid,
cached_at: cache.cached_at,
path: path.clone(),
});
}
}
Ok(entries)
}
pub fn clear_all_caches(auths_home: &Path) -> Result<usize, io::Error> {
let cache_dir = auths_home.join("cache").join("kel");
if !cache_dir.exists() {
return Ok(0);
}
let mut count = 0;
for entry in fs::read_dir(&cache_dir)? {
let entry = entry?;
let path = entry.path();
if path.extension().is_some_and(|ext| ext == "json") {
fs::remove_file(&path)?;
count += 1;
}
}
Ok(count)
}
pub fn inspect_cache(auths_home: &Path, did: &str) -> Result<Option<CachedKelState>, io::Error> {
let path = cache_path_for_did(auths_home, did);
if !path.exists() {
return Ok(None);
}
let contents = fs::read_to_string(&path)?;
match serde_json::from_str::<CachedKelState>(&contents) {
Ok(cache) => Ok(Some(cache)),
Err(_) => Ok(None), }
}
#[cfg(test)]
#[allow(clippy::disallowed_methods)]
mod tests {
use super::*;
use crate::keri::{CesrKey, Prefix, Threshold};
use tempfile::TempDir;
const VALID_OID: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
fn create_test_state() -> KeyState {
KeyState::from_inception(
Prefix::new_unchecked("ETestPrefix".to_string()),
vec![CesrKey::new_unchecked("DKey1".to_string())],
vec![Said::new_unchecked("ENext1".to_string())],
Threshold::Simple(1),
Threshold::Simple(1),
Said::new_unchecked("ESaid123".to_string()),
vec![],
Threshold::Simple(0),
vec![],
)
}
#[test]
fn test_cache_path_uses_hash() {
let dir = TempDir::new().unwrap();
let path = cache_path_for_did(dir.path(), "did:keri:ETestPrefix");
let filename = path.file_name().unwrap().to_string_lossy();
assert!(filename.ends_with(".json"));
assert_eq!(filename.len(), 64 + 5); }
#[test]
fn test_different_dids_get_different_paths() {
let dir = TempDir::new().unwrap();
let path1 = cache_path_for_did(dir.path(), "did:keri:ETest1");
let path2 = cache_path_for_did(dir.path(), "did:keri:ETest2");
assert_ne!(path1, path2);
}
#[test]
fn test_cache_write_and_read() {
let dir = TempDir::new().unwrap();
let did = "did:keri:ETest123";
let state = create_test_state();
let tip_said = "ELatestSaid";
write_kel_cache(dir.path(), did, &state, tip_said, VALID_OID, Utc::now()).unwrap();
let loaded = try_load_cached_state(dir.path(), did, tip_said);
assert!(loaded.is_some());
let loaded = loaded.unwrap();
assert_eq!(loaded.prefix, state.prefix);
assert_eq!(loaded.sequence, state.sequence);
}
#[test]
fn test_cache_invalidation_on_said_mismatch() {
let dir = TempDir::new().unwrap();
let did = "did:keri:EMismatch";
let state = create_test_state();
write_kel_cache(dir.path(), did, &state, "EOldSaid", VALID_OID, Utc::now()).unwrap();
let result = try_load_cached_state(dir.path(), did, "ENewSaid");
assert!(result.is_none());
let result = try_load_cached_state(dir.path(), did, "EOldSaid");
assert!(result.is_some());
}
#[test]
fn test_cache_invalidation_on_did_mismatch() {
let dir = TempDir::new().unwrap();
let did = "did:keri:EOriginal";
let state = create_test_state();
let tip_said = "ESaid";
write_kel_cache(dir.path(), did, &state, tip_said, VALID_OID, Utc::now()).unwrap();
let path = cache_path_for_did(dir.path(), did);
let mut cache: CachedKelState =
serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
cache.did = IdentityDID::new_unchecked("did:keri:EWrongDid");
fs::write(&path, serde_json::to_vec_pretty(&cache).unwrap()).unwrap();
let result = try_load_cached_state(dir.path(), did, tip_said);
assert!(result.is_none());
}
#[test]
fn test_cache_handles_missing_file() {
let dir = TempDir::new().unwrap();
let result = try_load_cached_state(dir.path(), "did:keri:ENonexistent", "ESomeSaid");
assert!(result.is_none());
}
#[test]
fn test_cache_handles_corrupt_file() {
let dir = TempDir::new().unwrap();
let did = "did:keri:ECorrupt";
let path = cache_path_for_did(dir.path(), did);
fs::create_dir_all(path.parent().unwrap()).unwrap();
fs::write(&path, "{ invalid json }").unwrap();
let result = try_load_cached_state(dir.path(), did, "ESomeSaid");
assert!(result.is_none());
}
#[test]
fn test_cache_version_mismatch() {
let dir = TempDir::new().unwrap();
let did = "did:keri:EVersionTest";
let state = create_test_state();
let tip_said = "ESaid";
write_kel_cache(dir.path(), did, &state, tip_said, VALID_OID, Utc::now()).unwrap();
let path = cache_path_for_did(dir.path(), did);
let mut cache: CachedKelState =
serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
cache.version = CACHE_VERSION + 1;
fs::write(&path, serde_json::to_vec_pretty(&cache).unwrap()).unwrap();
let result = try_load_cached_state(dir.path(), did, tip_said);
assert!(result.is_none());
}
#[test]
fn test_invalidate_cache() {
let dir = TempDir::new().unwrap();
let did = "did:keri:EToInvalidate";
let state = create_test_state();
write_kel_cache(dir.path(), did, &state, "ESaid", VALID_OID, Utc::now()).unwrap();
assert!(try_load_cached_state(dir.path(), did, "ESaid").is_some());
invalidate_cache(dir.path(), did).unwrap();
assert!(try_load_cached_state(dir.path(), did, "ESaid").is_none());
}
#[test]
fn test_invalidate_nonexistent_cache() {
let dir = TempDir::new().unwrap();
let result = invalidate_cache(dir.path(), "did:keri:ENeverExisted");
assert!(result.is_ok());
}
#[test]
fn test_list_cached_entries() {
let dir = TempDir::new().unwrap();
let state = create_test_state();
write_kel_cache(
dir.path(),
"did:keri:ETest1",
&state,
"ESaid1",
VALID_OID,
Utc::now(),
)
.unwrap();
write_kel_cache(
dir.path(),
"did:keri:ETest2",
&state,
"ESaid2",
VALID_OID,
Utc::now(),
)
.unwrap();
let entries = list_cached_entries(dir.path()).unwrap();
assert_eq!(entries.len(), 2);
let dids: Vec<_> = entries.iter().map(|e| e.did.as_str()).collect();
assert!(dids.contains(&"did:keri:ETest1"));
assert!(dids.contains(&"did:keri:ETest2"));
}
#[test]
fn test_clear_all_caches() {
let dir = TempDir::new().unwrap();
let state = create_test_state();
write_kel_cache(
dir.path(),
"did:keri:EClear1",
&state,
"ESaid",
VALID_OID,
Utc::now(),
)
.unwrap();
write_kel_cache(
dir.path(),
"did:keri:EClear2",
&state,
"ESaid",
VALID_OID,
Utc::now(),
)
.unwrap();
let count = clear_all_caches(dir.path()).unwrap();
assert_eq!(count, 2);
let entries = list_cached_entries(dir.path()).unwrap();
assert!(entries.is_empty());
}
#[test]
fn test_inspect_cache() {
let dir = TempDir::new().unwrap();
let did = "did:keri:EInspect";
let state = create_test_state();
let tip_said = "ESaid";
write_kel_cache(dir.path(), did, &state, tip_said, VALID_OID, Utc::now()).unwrap();
let inspected = inspect_cache(dir.path(), did).unwrap();
assert!(inspected.is_some());
let inspected = inspected.unwrap();
assert_eq!(inspected.did, did);
assert_eq!(inspected.validated_against_tip_said, tip_said);
}
}