1use crate::sha::sha256_hex;
19use crate::state::{Durable, Kind};
20use serde::{Deserialize, Serialize};
21use std::path::PathBuf;
22use std::time::{SystemTime, UNIX_EPOCH};
23
24pub fn now_ms() -> u64 {
26 SystemTime::now()
27 .duration_since(UNIX_EPOCH)
28 .map(|d| d.as_millis() as u64)
29 .unwrap_or(0)
30}
31
32#[derive(Debug, Clone, Serialize, Deserialize, Default)]
34pub struct CachedCred {
35 #[serde(default)]
36 pub access_token: String,
37 #[serde(default, skip_serializing_if = "Option::is_none")]
38 pub refresh_token: Option<String>,
39 #[serde(default)]
41 pub expires_at_ms: u64,
42 #[serde(default, skip_serializing_if = "Option::is_none")]
43 pub token_type: Option<String>,
44 #[serde(default, skip_serializing_if = "serde_json::Map::is_empty")]
47 pub extra: serde_json::Map<String, serde_json::Value>,
48}
49
50impl CachedCred {
51 pub fn valid_at(&self, now_ms: u64, skew_ms: u64) -> bool {
54 self.expires_at_ms == 0 || now_ms.saturating_add(skew_ms) < self.expires_at_ms
55 }
56}
57
58pub fn cred_id(target: &str) -> String {
60 sha256_hex(target.as_bytes())
61}
62
63pub fn load(durable: &Durable, target: &str) -> Option<CachedCred> {
65 let env = durable.get(Kind::Cred, &cred_id(target)).ok()??;
66 serde_json::from_value(env.state).ok()
67}
68
69pub fn store(durable: &Durable, target: &str, cred: &CachedCred) -> Result<(), String> {
71 let value = serde_json::to_value(cred).map_err(|e| e.to_string())?;
72 durable
73 .put(Kind::Cred, &cred_id(target), value, None)
74 .map(|_| ())
75 .map_err(|e| format!("{e}"))
76}
77
78pub fn evict(durable: &Durable, target: &str) -> Result<(), String> {
80 durable
81 .delete(Kind::Cred, &cred_id(target))
82 .map_err(|e| format!("{e}"))
83}
84
85pub fn default_dir() -> PathBuf {
99 if let Some(d) = std::env::var_os("AGENTD_CRED_DIR") {
100 return PathBuf::from(d);
101 }
102 if let Some(d) = std::env::var_os("XDG_STATE_HOME") {
103 return PathBuf::from(d).join("agentd").join("creds");
104 }
105 if let Some(h) = std::env::var_os("HOME") {
106 return PathBuf::from(h)
107 .join(".local")
108 .join("state")
109 .join("agentd")
110 .join("creds");
111 }
112 std::env::temp_dir().join("agentd").join("creds")
113}
114
115fn file_path(dir: &std::path::Path, target: &str) -> PathBuf {
116 dir.join(format!("{}.json", cred_id(target)))
117}
118
119pub fn load_file(dir: &std::path::Path, target: &str) -> Option<CachedCred> {
121 let bytes = std::fs::read(file_path(dir, target)).ok()?;
122 serde_json::from_slice(&bytes).ok()
123}
124
125pub fn store_file(dir: &std::path::Path, target: &str, cred: &CachedCred) -> Result<(), String> {
127 std::fs::create_dir_all(dir).map_err(|e| format!("cred dir {}: {e}", dir.display()))?;
128 let path = file_path(dir, target);
129 let json = serde_json::to_vec_pretty(cred).map_err(|e| e.to_string())?;
130 std::fs::write(&path, &json).map_err(|e| format!("write {}: {e}", path.display()))?;
131 #[cfg(unix)]
134 {
135 use std::os::unix::fs::PermissionsExt;
136 let _ = std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600));
137 }
138 Ok(())
139}
140
141pub fn evict_file(dir: &std::path::Path, target: &str) -> Result<(), String> {
143 match std::fs::remove_file(file_path(dir, target)) {
144 Ok(()) => Ok(()),
145 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
146 Err(e) => Err(format!("{e}")),
147 }
148}
149
150#[cfg(test)]
151mod tests {
152 use super::*;
153
154 #[test]
155 fn cred_id_is_stable_and_distinct() {
156 assert_eq!(cred_id("mcp:github"), cred_id("mcp:github"));
157 assert_ne!(cred_id("mcp:github"), cred_id("intelligence"));
158 assert_eq!(cred_id("x").len(), 64);
160 }
161
162 #[test]
163 fn valid_at_honours_expiry_and_skew() {
164 let c = CachedCred {
165 access_token: "t".into(),
166 expires_at_ms: 1_000,
167 ..Default::default()
168 };
169 assert!(c.valid_at(0, 0));
170 assert!(c.valid_at(900, 50));
171 assert!(!c.valid_at(960, 50)); assert!(!c.valid_at(1_000, 0));
173 let never = CachedCred {
175 access_token: "t".into(),
176 expires_at_ms: 0,
177 ..Default::default()
178 };
179 assert!(never.valid_at(u64::MAX - 1, 10));
180 }
181}