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