use crate::sha::sha256_hex;
use crate::state::{Durable, Kind};
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
use std::time::{SystemTime, UNIX_EPOCH};
pub fn now_ms() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_millis() as u64)
.unwrap_or(0)
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct CachedCred {
#[serde(default)]
pub access_token: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub refresh_token: Option<String>,
#[serde(default)]
pub expires_at_ms: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub token_type: Option<String>,
#[serde(default, skip_serializing_if = "serde_json::Map::is_empty")]
pub extra: serde_json::Map<String, serde_json::Value>,
}
impl CachedCred {
pub fn valid_at(&self, now_ms: u64, skew_ms: u64) -> bool {
self.expires_at_ms == 0 || now_ms.saturating_add(skew_ms) < self.expires_at_ms
}
}
pub fn cred_id(target: &str) -> String {
sha256_hex(target.as_bytes())
}
pub fn load(durable: &Durable, target: &str) -> Option<CachedCred> {
let env = durable.get(Kind::Cred, &cred_id(target)).ok()??;
serde_json::from_value(env.state).ok()
}
pub fn store(durable: &Durable, target: &str, cred: &CachedCred) -> Result<(), String> {
let value = serde_json::to_value(cred).map_err(|e| e.to_string())?;
durable
.put(Kind::Cred, &cred_id(target), value, None)
.map(|_| ())
.map_err(|e| format!("{e}"))
}
pub fn evict(durable: &Durable, target: &str) -> Result<(), String> {
durable
.delete(Kind::Cred, &cred_id(target))
.map_err(|e| format!("{e}"))
}
pub fn default_dir() -> PathBuf {
if let Some(d) = std::env::var_os("AGENTD_CRED_DIR") {
return PathBuf::from(d);
}
if let Some(d) = std::env::var_os("XDG_STATE_HOME") {
return PathBuf::from(d).join("agentd").join("creds");
}
if let Some(h) = std::env::var_os("HOME") {
return PathBuf::from(h)
.join(".local")
.join("state")
.join("agentd")
.join("creds");
}
std::env::temp_dir().join("agentd").join("creds")
}
fn file_path(dir: &std::path::Path, target: &str) -> PathBuf {
dir.join(format!("{}.json", cred_id(target)))
}
pub fn load_file(dir: &std::path::Path, target: &str) -> Option<CachedCred> {
let bytes = std::fs::read(file_path(dir, target)).ok()?;
serde_json::from_slice(&bytes).ok()
}
pub fn store_file(dir: &std::path::Path, target: &str, cred: &CachedCred) -> Result<(), String> {
std::fs::create_dir_all(dir).map_err(|e| format!("cred dir {}: {e}", dir.display()))?;
let path = file_path(dir, target);
let json = serde_json::to_vec_pretty(cred).map_err(|e| e.to_string())?;
std::fs::write(&path, &json).map_err(|e| format!("write {}: {e}", path.display()))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ = std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600));
}
Ok(())
}
pub fn evict_file(dir: &std::path::Path, target: &str) -> Result<(), String> {
match std::fs::remove_file(file_path(dir, target)) {
Ok(()) => Ok(()),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(e) => Err(format!("{e}")),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn cred_id_is_stable_and_distinct() {
assert_eq!(cred_id("mcp:github"), cred_id("mcp:github"));
assert_ne!(cred_id("mcp:github"), cred_id("intelligence"));
assert_eq!(cred_id("x").len(), 64);
}
#[test]
fn valid_at_honours_expiry_and_skew() {
let c = CachedCred {
access_token: "t".into(),
expires_at_ms: 1_000,
..Default::default()
};
assert!(c.valid_at(0, 0));
assert!(c.valid_at(900, 50));
assert!(!c.valid_at(960, 50)); assert!(!c.valid_at(1_000, 0));
let never = CachedCred {
access_token: "t".into(),
expires_at_ms: 0,
..Default::default()
};
assert!(never.valid_at(u64::MAX - 1, 10));
}
}