use crate::config::ConfigManager;
use crate::error::{MinoError, MinoResult};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
use tokio::fs;
use tracing::debug;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CachedCredential {
pub value: String,
pub expires_at: DateTime<Utc>,
pub provider: String,
}
impl CachedCredential {
pub fn new(provider: &str, value: String, expires_at: DateTime<Utc>) -> Self {
Self {
value,
expires_at,
provider: provider.to_string(),
}
}
pub fn is_expired(&self) -> bool {
Utc::now() >= self.expires_at - chrono::Duration::seconds(60)
}
}
pub struct CredentialCache {
cache_dir: PathBuf,
}
impl CredentialCache {
pub async fn new() -> MinoResult<Self> {
let cache_dir = ConfigManager::credentials_dir();
fs::create_dir_all(&cache_dir)
.await
.map_err(|e| MinoError::io("creating credentials cache dir", e))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let perms = std::fs::Permissions::from_mode(0o700);
std::fs::set_permissions(&cache_dir, perms)
.map_err(|e| MinoError::io("setting credentials dir permissions", e))?;
}
Ok(Self { cache_dir })
}
pub async fn get(&self, key: &str) -> MinoResult<Option<CachedCredential>> {
let path = self.cache_path(key);
if !path.exists() {
return Ok(None);
}
let content = fs::read_to_string(&path)
.await
.map_err(|e| MinoError::io(format!("reading cache file {}", path.display()), e))?;
let cred: CachedCredential = serde_json::from_str(&content)?;
if cred.is_expired() {
debug!("Cached credential {} is expired", key);
self.remove(key).await?;
return Ok(None);
}
debug!("Using cached credential {}", key);
Ok(Some(cred))
}
pub async fn set(&self, key: &str, cred: &CachedCredential) -> MinoResult<()> {
let path = self.cache_path(key);
let content = serde_json::to_string_pretty(cred)?;
fs::write(&path, content)
.await
.map_err(|e| MinoError::io(format!("writing cache file {}", path.display()), e))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let perms = std::fs::Permissions::from_mode(0o600);
std::fs::set_permissions(&path, perms)
.map_err(|e| MinoError::io("setting cache file permissions", e))?;
}
debug!("Cached credential {} until {}", key, cred.expires_at);
Ok(())
}
pub async fn remove(&self, key: &str) -> MinoResult<()> {
let path = self.cache_path(key);
if path.exists() {
fs::remove_file(&path)
.await
.map_err(|e| MinoError::io(format!("removing cache file {}", path.display()), e))?;
}
Ok(())
}
pub async fn clear(&self) -> MinoResult<()> {
let mut entries = fs::read_dir(&self.cache_dir)
.await
.map_err(|e| MinoError::io("reading cache directory", e))?;
while let Some(entry) = entries
.next_entry()
.await
.map_err(|e| MinoError::io("reading cache entry", e))?
{
if entry.path().extension().is_some_and(|ext| ext == "json") {
fs::remove_file(entry.path())
.await
.map_err(|e| MinoError::io("removing cache file", e))?;
}
}
Ok(())
}
fn cache_path(&self, key: &str) -> PathBuf {
self.cache_dir.join(format!("{}.json", key))
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
async fn test_cache() -> (CredentialCache, TempDir) {
let temp = TempDir::new().unwrap();
let cache = CredentialCache {
cache_dir: temp.path().to_path_buf(),
};
(cache, temp)
}
#[tokio::test]
async fn cache_set_and_get() {
let (cache, _temp) = test_cache().await;
let cred = CachedCredential::new(
"test",
"secret123".to_string(),
Utc::now() + chrono::Duration::hours(1),
);
cache.set("test-key", &cred).await.unwrap();
let retrieved = cache.get("test-key").await.unwrap().unwrap();
assert_eq!(retrieved.value, "secret123");
assert_eq!(retrieved.provider, "test");
}
#[tokio::test]
async fn cache_expired_returns_none() {
let (cache, _temp) = test_cache().await;
let cred = CachedCredential::new(
"test",
"secret123".to_string(),
Utc::now() - chrono::Duration::hours(1), );
cache.set("test-key", &cred).await.unwrap();
let retrieved = cache.get("test-key").await.unwrap();
assert!(retrieved.is_none());
}
#[tokio::test]
async fn cache_missing_returns_none() {
let (cache, _temp) = test_cache().await;
let retrieved = cache.get("nonexistent").await.unwrap();
assert!(retrieved.is_none());
}
}