1use std::path::{Path, PathBuf};
15use std::sync::Arc;
16
17use crate::foundation::FoundationError;
18#[cfg(test)]
19use crate::foundation::profiles::CredentialLocator;
20use crate::foundation::profiles::Profile;
21pub enum Credential {
25 Keychain(String),
27 Environment(String),
29 Unavailable(CredentialError),
32}
33
34impl std::fmt::Debug for Credential {
35 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
36 match self {
37 Credential::Keychain(_) => f.write_str("Credential::Keychain(***)"),
38 Credential::Environment(_) => f.write_str("Credential::Environment(***)"),
39 Credential::Unavailable(e) => {
40 f.debug_tuple("Credential::Unavailable").field(e).finish()
41 }
42 }
43 }
44}
45#[derive(Debug, Clone, PartialEq, Eq)]
48pub enum CredentialError {
49 Unavailable(String),
50 Locked(String),
51 NotFound { service: String, account: String },
52}
53
54impl std::fmt::Display for CredentialError {
55 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
56 match self {
57 Self::Unavailable(s) => write!(f, "keychain unavailable: {s}"),
58 Self::Locked(s) => write!(f, "keychain locked: {s}"),
59 Self::NotFound { service, account } => {
60 write!(f, "keychain entry not found for {service}:{account}")
61 }
62 }
63 }
64}
65
66pub trait KeychainBackend: Send + Sync + std::fmt::Debug {
70 fn get(&self, service: &str, account: &str) -> Result<String, CredentialError>;
71 fn set(&self, service: &str, account: &str, value: &str) -> Result<(), CredentialError>;
72 fn delete(&self, service: &str, account: &str) -> Result<(), CredentialError>;
73}
74#[derive(Debug, Default, Clone)]
77pub struct SystemKeychain;
78
79impl KeychainBackend for SystemKeychain {
80 fn get(&self, service: &str, account: &str) -> Result<String, CredentialError> {
81 let entry = keyring::Entry::new(service, account)
82 .map_err(|e| CredentialError::Unavailable(e.to_string()))?;
83 entry.get_password().map_err(|e| match e {
84 keyring::Error::NoEntry => CredentialError::NotFound {
85 service: service.to_string(),
86 account: account.to_string(),
87 },
88 keyring::Error::PlatformFailure(_) => CredentialError::Unavailable(e.to_string()),
89 _ => CredentialError::Locked(e.to_string()),
90 })
91 }
92
93 fn set(&self, service: &str, account: &str, value: &str) -> Result<(), CredentialError> {
94 let entry = keyring::Entry::new(service, account)
95 .map_err(|e| CredentialError::Unavailable(e.to_string()))?;
96 entry
97 .set_password(value)
98 .map_err(|e| CredentialError::Unavailable(e.to_string()))
99 }
100
101 fn delete(&self, service: &str, account: &str) -> Result<(), CredentialError> {
102 let entry = keyring::Entry::new(service, account)
103 .map_err(|e| CredentialError::Unavailable(e.to_string()))?;
104 entry.delete_credential().map_err(|e| match e {
105 keyring::Error::NoEntry => CredentialError::NotFound {
106 service: service.to_string(),
107 account: account.to_string(),
108 },
109 _ => CredentialError::Unavailable(e.to_string()),
110 })
111 }
112}
113
114#[derive(Debug, Clone)]
117pub struct KeychainCredentialResolver<B: KeychainBackend + Clone> {
118 backend: B,
119}
120
121impl<B: KeychainBackend + Clone> KeychainCredentialResolver<B> {
122 pub fn new(backend: B) -> Self {
123 Self { backend }
124 }
125
126 pub fn resolve(&self, profile: &Profile) -> Credential {
130 let loc = &profile.credential;
131 if loc.service == "__env__" && loc.account == "__env__" {
132 let env_var = std::env::var("OXICODE_API_KEY").ok();
135 if let Some(value) = env_var
136 && !value.is_empty()
137 {
138 return Credential::Environment(value);
139 }
140 return Credential::Unavailable(CredentialError::NotFound {
141 service: loc.service.clone(),
142 account: loc.account.clone(),
143 });
144 }
145 match self.backend.get(&loc.service, &loc.account) {
146 Ok(value) => Credential::Keychain(value),
147 Err(e) => Credential::Unavailable(e),
148 }
149 }
150}
151
152impl Default for KeychainCredentialResolver<SystemKeychain> {
153 fn default() -> Self {
154 Self::new(SystemKeychain)
155 }
156}
157
158pub struct LegacyImporter<B: KeychainBackend + Clone> {
163 backend: B,
164}
165
166impl<B: KeychainBackend + Clone> LegacyImporter<B> {
167 pub fn new(backend: B) -> Self {
168 Self { backend }
169 }
170
171 pub fn run(
174 &self,
175 auth_json_path: &Path,
176 profile_id: &str,
177 provider: &str,
178 acknowledge: bool,
179 archive: bool,
180 ) -> Result<LegacyImportOutcome, FoundationError> {
181 if !acknowledge {
182 return Err(FoundationError::Parse(
183 "legacy import requires explicit acknowledgement".to_string(),
184 ));
185 }
186 if !auth_json_path.is_file() {
187 return Err(FoundationError::Parse(format!(
188 "legacy auth file not found: {}",
189 auth_json_path.display()
190 )));
191 }
192 let raw = std::fs::read_to_string(auth_json_path)?;
193 let parsed: serde_json::Value = serde_json::from_str(&raw)?;
194 let key = parsed
195 .get("providers")
196 .and_then(|p| p.get(provider))
197 .and_then(|p| p.get("api_key"))
198 .and_then(|p| p.as_str())
199 .ok_or_else(|| {
200 FoundationError::Parse(format!(
201 "legacy auth file does not contain an API key for {provider}"
202 ))
203 })?;
204 let service = "dev.oxi.foundation".to_string();
205 let account = profile_id.to_string();
206 self.backend
207 .set(&service, &account, key)
208 .map_err(|e| FoundationError::KeychainUnavailable(e.to_string()))?;
209 let archive_path = if archive {
210 Some(self.archive_legacy(auth_json_path)?)
211 } else {
212 None
213 };
214 Ok(LegacyImportOutcome {
215 service,
216 account,
217 archive_path,
218 })
219 }
220
221 fn archive_legacy(&self, path: &Path) -> Result<PathBuf, FoundationError> {
222 let parent = path.parent().unwrap_or_else(|| Path::new("."));
223 let archive_dir = parent.join("archive");
224 std::fs::create_dir_all(&archive_dir)?;
225 let ts = chrono::Utc::now().format("%Y%m%dT%H%M%S").to_string();
226 let target = archive_dir.join(format!(
227 "auth-{}-{}.json",
228 ts,
229 path.file_name()
230 .and_then(|s| s.to_str())
231 .unwrap_or("auth.json"),
232 ));
233 if target.exists() {
235 return Err(FoundationError::Parse(format!(
236 "archive target already exists: {}",
237 target.display()
238 )));
239 }
240 std::fs::rename(path, &target)?;
241 Ok(target)
242 }
243}
244
245#[derive(Debug, Clone)]
247pub struct LegacyImportOutcome {
248 pub service: String,
249 pub account: String,
250 pub archive_path: Option<PathBuf>,
251}
252
253impl<B: KeychainBackend + Clone> std::fmt::Debug for LegacyImporter<B> {
254 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
255 f.debug_struct("LegacyImporter")
256 .field("backend", &"<dyn KeychainBackend>")
257 .finish()
258 }
259}
260
261#[derive(Debug, Clone, Default)]
264pub struct InMemoryKeychain {
265 inner: std::collections::HashMap<(String, String), String>,
266}
267
268impl InMemoryKeychain {
269 pub fn new() -> Self {
270 Self::default()
271 }
272
273 pub fn seed(&mut self, service: &str, account: &str, value: &str) {
274 self.inner.insert(
275 (service.to_string(), account.to_string()),
276 value.to_string(),
277 );
278 }
279}
280
281impl KeychainBackend for InMemoryKeychain {
282 fn get(&self, service: &str, account: &str) -> Result<String, CredentialError> {
283 self.inner
284 .get(&(service.to_string(), account.to_string()))
285 .cloned()
286 .ok_or_else(|| CredentialError::NotFound {
287 service: service.to_string(),
288 account: account.to_string(),
289 })
290 }
291
292 fn set(&self, service: &str, account: &str, value: &str) -> Result<(), CredentialError> {
293 let _ = (service, account, value);
299 Err(CredentialError::Unavailable(
300 "InMemoryKeychain::set requires the Mutex variant".to_string(),
301 ))
302 }
303
304 fn delete(&self, _service: &str, _account: &str) -> Result<(), CredentialError> {
305 Err(CredentialError::Unavailable(
306 "InMemoryKeychain::delete requires the Mutex variant".to_string(),
307 ))
308 }
309}
310
311#[derive(Debug, Default, Clone)]
315pub struct MutexKeychain {
316 inner: Arc<parking_lot::Mutex<std::collections::HashMap<(String, String), String>>>,
317}
318
319impl MutexKeychain {
320 pub fn new() -> Self {
321 Self::default()
322 }
323
324 pub fn seed(&self, service: &str, account: &str, value: &str) {
325 self.inner.lock().insert(
326 (service.to_string(), account.to_string()),
327 value.to_string(),
328 );
329 }
330
331 pub fn snapshot(&self) -> Vec<((String, String), String)> {
332 self.inner
333 .lock()
334 .iter()
335 .map(|(k, v)| (k.clone(), v.clone()))
336 .collect()
337 }
338}
339
340impl KeychainBackend for MutexKeychain {
341 fn get(&self, service: &str, account: &str) -> Result<String, CredentialError> {
342 self.inner
343 .lock()
344 .get(&(service.to_string(), account.to_string()))
345 .cloned()
346 .ok_or_else(|| CredentialError::NotFound {
347 service: service.to_string(),
348 account: account.to_string(),
349 })
350 }
351
352 fn set(&self, service: &str, account: &str, value: &str) -> Result<(), CredentialError> {
353 self.inner.lock().insert(
354 (service.to_string(), account.to_string()),
355 value.to_string(),
356 );
357 Ok(())
358 }
359
360 fn delete(&self, service: &str, account: &str) -> Result<(), CredentialError> {
361 self.inner
362 .lock()
363 .remove(&(service.to_string(), account.to_string()));
364 Ok(())
365 }
366}
367pub fn source_class(profile: &Profile) -> &'static str {
368 let loc = &profile.credential;
369 if loc.service == "__env__" && loc.account == "__env__" {
370 "environment"
371 } else {
372 "keychain"
373 }
374}
375
376#[cfg(test)]
377mod tests {
378 use super::*;
379
380 fn profile_with_locator(service: &str, account: &str) -> Profile {
381 Profile {
382 id: "x".to_string(),
383 provider: "anthropic".to_string(),
384 model: "claude-sonnet".to_string(),
385 roles: vec!["coding.primary".to_string()],
386 credential: CredentialLocator {
387 service: service.to_string(),
388 account: account.to_string(),
389 },
390 }
391 }
392
393 #[test]
394 fn resolve_missing_keychain_is_unavailable() {
395 let r = KeychainCredentialResolver::new(MutexKeychain::new());
396 let p = profile_with_locator("dev.oxi.foundation", "missing");
397 match r.resolve(&p) {
398 Credential::Unavailable(CredentialError::NotFound { service, account }) => {
399 assert_eq!(service, "dev.oxi.foundation");
400 assert_eq!(account, "missing");
401 }
402 other => panic!("expected Unavailable, got {other:?}"),
403 }
404 }
405
406 #[test]
407 fn resolve_seeded_keychain_is_keychain() {
408 let k = MutexKeychain::new();
409 k.seed("dev.oxi.foundation", "p1", "sk-xxx");
410 let r = KeychainCredentialResolver::new(k);
411 let p = profile_with_locator("dev.oxi.foundation", "p1");
412 match r.resolve(&p) {
413 Credential::Keychain(v) => assert_eq!(v, "sk-xxx"),
414 other => panic!("expected Keychain, got {other:?}"),
415 }
416 }
417
418 #[test]
419 fn resolve_env_sentinel_uses_env_var() {
420 let original = std::env::var("OXICODE_API_KEY").ok();
421 unsafe {
422 std::env::set_var("OXICODE_API_KEY", "env-xxx");
423 }
424 let r = KeychainCredentialResolver::new(MutexKeychain::new());
425 let p = profile_with_locator("__env__", "__env__");
426 match r.resolve(&p) {
427 Credential::Environment(v) => assert_eq!(v, "env-xxx"),
428 other => panic!("expected Environment, got {other:?}"),
429 }
430 unsafe {
431 std::env::remove_var("OXICODE_API_KEY");
432 }
433 if let Some(value) = original {
434 unsafe {
435 std::env::set_var("OXICODE_API_KEY", value);
436 }
437 }
438 }
439
440 #[test]
441 fn legacy_import_requires_acknowledge() {
442 let tmp = tempfile::tempdir().unwrap();
443 let auth = tmp.path().join("auth.json");
444 std::fs::write(&auth, r#"{"providers":{"anthropic":{"api_key":"sk-x"}}}"#).unwrap();
445 let importer = LegacyImporter::new(MutexKeychain::new());
446 let err = importer
447 .run(&auth, "p1", "anthropic", false, false)
448 .unwrap_err();
449 assert!(matches!(err, FoundationError::Parse(_)));
450 }
451
452 #[test]
453 fn legacy_import_writes_keychain_and_archives() {
454 let tmp = tempfile::tempdir().unwrap();
455 let auth = tmp.path().join("auth.json");
456 std::fs::write(&auth, r#"{"providers":{"anthropic":{"api_key":"sk-x"}}}"#).unwrap();
457 let k = MutexKeychain::new();
458 let importer = LegacyImporter::new(k.clone());
459 let out = importer.run(&auth, "p1", "anthropic", true, true).unwrap();
460 assert_eq!(out.service, "dev.oxi.foundation");
461 assert_eq!(out.account, "p1");
462 assert!(out.archive_path.is_some());
463 assert!(!auth.is_file(), "archive should have moved the file");
464 let snap = k.snapshot();
465 assert_eq!(snap.len(), 1);
466 assert_eq!(
467 snap[0].0,
468 ("dev.oxi.foundation".to_string(), "p1".to_string())
469 );
470 }
471
472 #[test]
473 fn legacy_import_rejects_missing_key() {
474 let tmp = tempfile::tempdir().unwrap();
475 let auth = tmp.path().join("auth.json");
476 std::fs::write(&auth, r#"{"providers":{"anthropic":{}}}"#).unwrap();
477 let importer = LegacyImporter::new(MutexKeychain::new());
478 let err = importer
479 .run(&auth, "p1", "anthropic", true, false)
480 .unwrap_err();
481 assert!(matches!(err, FoundationError::Parse(_)));
482 }
483
484 #[test]
485 fn legacy_import_does_not_archive_when_asked() {
486 let tmp = tempfile::tempdir().unwrap();
487 let auth = tmp.path().join("auth.json");
488 std::fs::write(&auth, r#"{"providers":{"anthropic":{"api_key":"sk-x"}}}"#).unwrap();
489 let k = MutexKeychain::new();
490 let importer = LegacyImporter::new(k.clone());
491 let out = importer.run(&auth, "p1", "anthropic", true, false).unwrap();
492 assert!(out.archive_path.is_none());
493 assert!(
494 auth.is_file(),
495 "file should remain when archive is not requested"
496 );
497 }
498
499 #[test]
500 fn source_class_reports_environment_or_keychain() {
501 let p = profile_with_locator("__env__", "__env__");
502 assert_eq!(source_class(&p), "environment");
503 let p = profile_with_locator("dev.oxi.foundation", "p1");
504 assert_eq!(source_class(&p), "keychain");
505 }
506
507 #[test]
508 fn credential_error_display_redacts_value() {
509 let err = CredentialError::NotFound {
510 service: "dev.oxi.foundation".to_string(),
511 account: "p1".to_string(),
512 };
513 let s = err.to_string();
514 assert!(!s.contains("sk-"));
515 assert!(s.contains("dev.oxi.foundation"));
516 assert!(s.contains("p1"));
517 }
518}