1use std::collections::HashMap;
11use std::path::PathBuf;
12use std::sync::Arc;
13
14use dashmap::DashMap;
15
16use crate::error::CredentialError;
17use crate::handle::{Channel, CredentialHandle, Fingerprint, GOOGLE};
18use crate::store::{CredentialStore, ValidationReport};
19
20#[derive(Debug, Clone)]
21pub struct GoogleAccount {
22 pub id: String,
23 pub agent_id: String,
24 pub client_id_path: PathBuf,
25 pub client_secret_path: PathBuf,
26 pub token_path: PathBuf,
27 pub scopes: Vec<String>,
28}
29
30pub struct GoogleCredentialStore {
31 accounts: Arc<HashMap<String, GoogleAccount>>,
32 refresh_locks: DashMap<Fingerprint, Arc<tokio::sync::Mutex<()>>>,
35}
36
37impl GoogleCredentialStore {
38 pub fn new(accounts: Vec<GoogleAccount>) -> Self {
39 let mut map = HashMap::with_capacity(accounts.len());
40 for a in accounts {
41 map.insert(a.id.clone(), a);
42 }
43 Self {
44 accounts: Arc::new(map),
45 refresh_locks: DashMap::new(),
46 }
47 }
48
49 pub fn empty() -> Self {
50 Self {
51 accounts: Arc::new(HashMap::new()),
52 refresh_locks: DashMap::new(),
53 }
54 }
55
56 pub fn account(&self, id: &str) -> Option<&GoogleAccount> {
57 self.accounts.get(id)
58 }
59
60 pub fn account_for_agent(&self, agent_id: &str) -> Option<&GoogleAccount> {
61 self.accounts.values().find(|a| a.agent_id == agent_id)
62 }
63
64 pub fn refresh_lock(&self, handle: &CredentialHandle) -> Option<Arc<tokio::sync::Mutex<()>>> {
70 let fp = handle.fingerprint();
71 let entry = self
72 .refresh_locks
73 .entry(fp)
74 .or_insert_with(|| Arc::new(tokio::sync::Mutex::new(())));
75 Some(entry.clone())
76 }
77}
78
79impl std::fmt::Debug for GoogleCredentialStore {
80 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
81 f.debug_struct("GoogleCredentialStore")
82 .field("account_count", &self.accounts.len())
83 .field("active_refresh_locks", &self.refresh_locks.len())
84 .finish()
85 }
86}
87
88impl CredentialStore for GoogleCredentialStore {
89 type Account = GoogleAccount;
90
91 fn channel(&self) -> Channel {
92 GOOGLE
93 }
94
95 fn get(&self, handle: &CredentialHandle) -> Result<Self::Account, CredentialError> {
96 let id = handle.account_id_raw();
97 self.accounts
98 .get(id)
99 .cloned()
100 .ok_or_else(|| CredentialError::NotFound {
101 channel: GOOGLE,
102 account: id.to_string(),
103 })
104 }
105
106 fn issue(&self, account_id: &str, agent_id: &str) -> Result<CredentialHandle, CredentialError> {
107 let account = self
108 .accounts
109 .get(account_id)
110 .ok_or_else(|| CredentialError::NotFound {
111 channel: GOOGLE,
112 account: account_id.to_string(),
113 })?;
114 if account.agent_id != agent_id {
116 let handle = CredentialHandle::new(GOOGLE, account_id, agent_id);
117 return Err(CredentialError::NotPermitted {
118 channel: GOOGLE,
119 agent: agent_id.to_string(),
120 fp: handle.fingerprint(),
121 });
122 }
123 Ok(CredentialHandle::new(GOOGLE, account_id, agent_id))
124 }
125
126 fn list(&self) -> Vec<String> {
127 let mut ids: Vec<_> = self.accounts.keys().cloned().collect();
128 ids.sort();
129 ids
130 }
131
132 fn allow_agents(&self, account_id: &str) -> Vec<String> {
133 self.accounts
134 .get(account_id)
135 .map(|a| vec![a.agent_id.clone()])
136 .unwrap_or_default()
137 }
138
139 fn validate(&self) -> ValidationReport {
140 let mut report = ValidationReport::default();
141 for (id, a) in self.accounts.iter() {
142 if a.scopes.is_empty() {
143 report
144 .warnings
145 .push(format!("google account '{id}' has no scopes declared"));
146 }
147 let is_inline = |p: &std::path::Path| p.to_string_lossy().starts_with("inline:");
151 if !is_inline(&a.client_id_path) && !a.client_id_path.exists() {
152 report.errors.push(crate::error::BuildError::Credential {
153 channel: GOOGLE,
154 instance: id.clone(),
155 source: CredentialError::FileMissing {
156 path: a.client_id_path.clone(),
157 },
158 });
159 }
160 if !is_inline(&a.client_secret_path) && !a.client_secret_path.exists() {
161 report.errors.push(crate::error::BuildError::Credential {
162 channel: GOOGLE,
163 instance: id.clone(),
164 source: CredentialError::FileMissing {
165 path: a.client_secret_path.clone(),
166 },
167 });
168 }
169 report.accounts_ok += 1;
172 }
173 report
174 }
175}
176
177#[cfg(test)]
178mod tests {
179 use super::*;
180
181 fn mk(id: &str, agent: &str) -> GoogleAccount {
182 GoogleAccount {
183 id: id.into(),
184 agent_id: agent.into(),
185 client_id_path: PathBuf::from("/nonexistent/cid"),
186 client_secret_path: PathBuf::from("/nonexistent/csec"),
187 token_path: PathBuf::from("/nonexistent/tok"),
188 scopes: vec!["https://www.googleapis.com/auth/gmail.readonly".into()],
189 }
190 }
191
192 #[test]
193 fn issue_rejects_mismatched_agent() {
194 let store = GoogleCredentialStore::new(vec![mk("ana@x.com", "ana")]);
195 assert!(store.issue("ana@x.com", "ana").is_ok());
196 let err = store.issue("ana@x.com", "kate").unwrap_err();
197 assert!(matches!(err, CredentialError::NotPermitted { .. }));
198 }
199
200 #[test]
201 fn account_for_agent_lookup() {
202 let store =
203 GoogleCredentialStore::new(vec![mk("ana@x.com", "ana"), mk("kate@x.com", "kate")]);
204 assert_eq!(store.account_for_agent("ana").unwrap().id, "ana@x.com");
205 assert_eq!(store.account_for_agent("kate").unwrap().id, "kate@x.com");
206 assert!(store.account_for_agent("nobody").is_none());
207 }
208
209 #[tokio::test]
210 async fn refresh_lock_serialises_same_account() {
211 let store = GoogleCredentialStore::new(vec![mk("ana@x.com", "ana")]);
212 let h = store.issue("ana@x.com", "ana").unwrap();
213 let l1 = store.refresh_lock(&h).unwrap();
214 let l2 = store.refresh_lock(&h).unwrap();
215 assert!(Arc::ptr_eq(&l1, &l2));
217 let _guard = l1.lock().await;
218 let try_second =
220 tokio::time::timeout(std::time::Duration::from_millis(50), l2.lock()).await;
221 assert!(try_second.is_err(), "second lock should block");
222 }
223
224 #[tokio::test]
225 async fn refresh_lock_distinct_for_different_accounts() {
226 let store = GoogleCredentialStore::new(vec![mk("a@x.com", "ana"), mk("k@x.com", "kate")]);
227 let ha = store.issue("a@x.com", "ana").unwrap();
228 let hk = store.issue("k@x.com", "kate").unwrap();
229 let la = store.refresh_lock(&ha).unwrap();
230 let lk = store.refresh_lock(&hk).unwrap();
231 assert!(!Arc::ptr_eq(&la, &lk));
232 }
233
234 #[test]
235 fn validate_flags_missing_files() {
236 let store = GoogleCredentialStore::new(vec![mk("ana@x.com", "ana")]);
237 let report = store.validate();
238 assert!(report.errors.len() >= 2, "missing files should surface");
239 }
240}