1use secrecy::SecretString;
16use thiserror::Error;
17use zeroize::Zeroizing;
18
19#[cfg(feature = "native-keychain")]
21use colored::Colorize;
22
23#[cfg(feature = "native-keychain")]
25use crate::core::provider_to_env_var;
26
27#[cfg(feature = "native-keychain")]
29const SERVICE_NAME: &str = "nika";
30
31#[derive(Debug, Error)]
33pub enum KeyringError {
34 #[error("Failed to access keyring: {0}")]
35 AccessError(String),
36 #[error("Key not found for provider: {0}")]
37 NotFound(String),
38 #[error("Failed to store key: {0}")]
39 StoreError(String),
40 #[error("Failed to delete key: {0}")]
41 DeleteError(String),
42}
43
44#[cfg(feature = "native-keychain")]
49mod native {
50 use super::*;
51 use keyring::Entry;
52
53 pub struct NikaKeyring;
55
56 impl NikaKeyring {
57 pub fn get(provider: &str) -> Result<Zeroizing<String>, KeyringError> {
63 if super::should_skip_keychain() {
65 return Err(KeyringError::NotFound(format!(
66 "{} (keychain skipped via NIKA_SKIP_KEYCHAIN)",
67 provider
68 )));
69 }
70
71 let entry = Entry::new(SERVICE_NAME, provider)
72 .map_err(|e| KeyringError::AccessError(e.to_string()))?;
73
74 let password = entry.get_password().map_err(|e| match e {
75 keyring::Error::NoEntry => KeyringError::NotFound(provider.to_string()),
76 _ => KeyringError::AccessError(e.to_string()),
77 })?;
78
79 Ok(Zeroizing::new(password))
80 }
81
82 pub fn get_secret(provider: &str) -> Result<SecretString, KeyringError> {
84 let key = Self::get(provider)?;
85 Ok(SecretString::from((*key).clone()))
86 }
87
88 pub fn set(provider: &str, key: &str) -> Result<(), KeyringError> {
92 if cfg!(test) || super::should_skip_keychain() {
93 return Err(KeyringError::StoreError(format!(
94 "{} (keychain skipped)",
95 provider
96 )));
97 }
98
99 let entry = Entry::new(SERVICE_NAME, provider)
100 .map_err(|e| KeyringError::AccessError(e.to_string()))?;
101
102 entry
103 .set_password(key)
104 .map_err(|e| KeyringError::StoreError(e.to_string()))
105 }
106
107 pub fn delete(provider: &str) -> Result<(), KeyringError> {
111 if cfg!(test) || super::should_skip_keychain() {
112 return Err(KeyringError::DeleteError(format!(
113 "{} (keychain skipped)",
114 provider
115 )));
116 }
117
118 let entry = Entry::new(SERVICE_NAME, provider)
119 .map_err(|e| KeyringError::AccessError(e.to_string()))?;
120
121 entry
122 .delete_credential()
123 .map_err(|e| KeyringError::DeleteError(e.to_string()))
124 }
125
126 pub fn exists(provider: &str) -> bool {
128 Self::get(provider).is_ok()
129 }
130
131 pub fn get_masked(provider: &str) -> Option<String> {
135 if super::should_skip_keychain() {
137 return None;
138 }
139 Self::get(provider).ok().map(|k| super::mask_api_key(&k))
140 }
141 }
142}
143
144pub fn should_skip_keychain() -> bool {
154 cfg!(test)
155 || std::env::var("NIKA_SKIP_KEYCHAIN")
156 .map(|v| matches!(v.as_str(), "1" | "true" | "yes"))
157 .unwrap_or(false)
158}
159
160#[cfg(not(feature = "native-keychain"))]
166mod stub {
167 use super::*;
168
169 pub struct NikaKeyring;
172
173 impl NikaKeyring {
174 pub fn get(_provider: &str) -> Result<Zeroizing<String>, KeyringError> {
176 Err(KeyringError::AccessError(
177 "Keychain not available (native-keychain feature disabled)".into(),
178 ))
179 }
180
181 pub fn get_secret(_provider: &str) -> Result<SecretString, KeyringError> {
183 Err(KeyringError::AccessError(
184 "Keychain not available (native-keychain feature disabled)".into(),
185 ))
186 }
187
188 pub fn set(_provider: &str, _key: &str) -> Result<(), KeyringError> {
190 Err(KeyringError::StoreError(
191 "Keychain not available (native-keychain feature disabled)".into(),
192 ))
193 }
194
195 pub fn delete(_provider: &str) -> Result<(), KeyringError> {
197 Err(KeyringError::DeleteError(
198 "Keychain not available (native-keychain feature disabled)".into(),
199 ))
200 }
201
202 pub fn exists(_provider: &str) -> bool {
204 false
205 }
206
207 pub fn get_masked(_provider: &str) -> Option<String> {
209 None
210 }
211 }
212}
213
214#[cfg(feature = "native-keychain")]
216pub use native::NikaKeyring;
217
218#[cfg(not(feature = "native-keychain"))]
219pub use stub::NikaKeyring;
220
221pub fn mask_api_key(key: &str) -> String {
229 if key.len() > 8 {
230 format!("{}...", &key[..8])
231 } else {
232 "***".to_string()
233 }
234}
235
236pub fn validate_key_format(provider: &str, key: &str) -> Result<(), String> {
240 use crate::core::{find_provider, validate_key_format as core_validate};
241
242 if key.is_empty() {
244 return Err("API key cannot be empty".to_string());
245 }
246
247 let Some(prov) = find_provider(provider) else {
249 return Ok(());
251 };
252
253 if core_validate(prov, key) {
255 Ok(())
256 } else {
257 Err(format!(
258 "Invalid API key format for {}. Expected prefix: {}",
259 provider,
260 prov.key_prefix.unwrap_or("(any)")
261 ))
262 }
263}
264
265const MIGRATEABLE_PROVIDERS: &[&str] = &[
270 "anthropic",
271 "openai",
272 "mistral",
273 "groq",
274 "deepseek",
275 "gemini",
276 "xai",
277];
278
279#[derive(Debug, Default)]
280pub struct MigrationReport {
281 pub migrated: usize,
282 pub skipped: usize,
283 pub not_found: Vec<String>,
284 pub errors: Vec<(String, String)>,
285}
286
287impl MigrationReport {
288 pub fn summary(&self) -> String {
289 format!(
290 "Migration complete: {} migrated, {} skipped, {} not found",
291 self.migrated,
292 self.skipped,
293 self.not_found.len()
294 )
295 }
296}
297
298#[cfg(feature = "native-keychain")]
303pub fn migrate_env_to_keyring() -> MigrationReport {
304 let mut report = MigrationReport::default();
305
306 for provider in MIGRATEABLE_PROVIDERS {
307 let env_var = provider_to_env_var(provider).unwrap_or("UNKNOWN_API_KEY");
309
310 match std::env::var(env_var) {
311 Ok(key) if !key.is_empty() => {
312 if NikaKeyring::exists(provider) {
313 println!(
314 " ├── {}: Found → {}",
315 env_var,
316 "Already in keychain".yellow()
317 );
318 report.skipped += 1;
319 continue;
320 }
321
322 print!(" ├── {}: Found → Migrating... ", env_var);
323 match NikaKeyring::set(provider, &key) {
324 Ok(()) => {
325 println!("{}", "✓".green());
326 report.migrated += 1;
327 }
328 Err(e) => {
329 println!("{} ({})", "✗".red(), e);
330 report.errors.push((provider.to_string(), e.to_string()));
331 }
332 }
333 }
334 _ => {
335 println!(" ├── {}: {}", env_var, "Not found".dimmed());
336 report.not_found.push(provider.to_string());
337 }
338 }
339 }
340
341 report
342}
343
344#[cfg(not(feature = "native-keychain"))]
345pub fn migrate_env_to_keyring() -> MigrationReport {
346 println!(" ⚠ Migration not available (native-keychain feature disabled)");
347 println!(" ⚠ Use environment variables instead in Docker/container environments");
348 MigrationReport {
349 not_found: MIGRATEABLE_PROVIDERS
350 .iter()
351 .map(|s| s.to_string())
352 .collect(),
353 ..Default::default()
354 }
355}
356
357#[cfg(test)]
358mod tests {
359 use super::*;
360
361 #[test]
362 #[cfg(feature = "native-keychain")]
363 fn test_service_name_is_nika() {
364 assert_eq!(SERVICE_NAME, "nika");
365 }
366
367 #[test]
368 fn test_mask_api_key_standard() {
369 let key = "sk-ant-api03-abc123xyz789def456ghi";
370 assert_eq!(mask_api_key(key), "sk-ant-a...");
371 }
372
373 #[test]
374 fn test_mask_api_key_short() {
375 assert_eq!(mask_api_key("short"), "***");
376 assert_eq!(mask_api_key("12345678"), "***");
377 }
378
379 #[test]
380 fn test_mask_api_key_boundary() {
381 assert_eq!(mask_api_key("123456789"), "12345678...");
382 }
383
384 #[test]
385 fn test_mask_api_key_empty() {
386 assert_eq!(mask_api_key(""), "***");
387 }
388
389 #[test]
390 fn test_validate_anthropic_key_valid() {
391 let result =
392 validate_key_format("anthropic", "sk-ant-api03-abcdefghijklmnopqrstuvwxyz123456");
393 assert!(result.is_ok());
394 }
395
396 #[test]
397 fn test_validate_anthropic_key_wrong_prefix() {
398 let result = validate_key_format("anthropic", "sk-wrong-prefix");
399 assert!(result.is_err());
400 }
401
402 #[test]
403 fn test_validate_openai_key_valid() {
404 let result = validate_key_format("openai", "sk-proj-abcdefghijklmnop");
405 assert!(result.is_ok());
406 }
407
408 #[test]
409 fn test_validate_empty_key_rejected() {
410 let result = validate_key_format("anthropic", "");
411 assert!(result.is_err());
412 }
413
414 #[test]
415 fn test_keyring_error_display() {
416 let err = KeyringError::NotFound("anthropic".to_string());
417 assert!(err.to_string().contains("anthropic"));
418 }
419
420 #[test]
421 fn test_migration_report_summary() {
422 let report = MigrationReport {
423 migrated: 2,
424 skipped: 1,
425 not_found: vec!["groq".into()],
426 errors: vec![],
427 };
428 let summary = report.summary();
429 assert!(summary.contains("2 migrated"));
430 assert!(summary.contains("1 skipped"));
431 }
432
433 #[test]
436 fn test_keyring_set_guarded_in_test_mode() {
437 let result = NikaKeyring::set("test_provider", "test-key-value");
440 assert!(
441 result.is_err(),
442 "NikaKeyring::set() must be guarded in test mode"
443 );
444 }
445
446 #[test]
447 fn test_keyring_delete_guarded_in_test_mode() {
448 let result = NikaKeyring::delete("test_provider");
450 assert!(
451 result.is_err(),
452 "NikaKeyring::delete() must be guarded in test mode"
453 );
454 }
455
456 #[test]
459 fn test_migrateable_providers_includes_xai() {
460 assert!(
461 MIGRATEABLE_PROVIDERS.contains(&"xai"),
462 "MIGRATEABLE_PROVIDERS must include xai, got: {:?}",
463 MIGRATEABLE_PROVIDERS
464 );
465 }
466
467 #[test]
468 fn test_migrateable_providers_count() {
469 assert_eq!(
470 MIGRATEABLE_PROVIDERS.len(),
471 7,
472 "Expected 7 migrateable providers (all LLM), got: {:?}",
473 MIGRATEABLE_PROVIDERS
474 );
475 }
476
477 #[cfg(feature = "native-keychain")]
481 mod native_tests {
482 use super::*;
483
484 #[test]
485 #[ignore = "Requires real OS keychain access — causes macOS popup"]
486 fn test_nika_keyring_not_found() {
487 let result = NikaKeyring::get("nonexistent_provider_test_xyz");
489 assert!(matches!(
490 result,
491 Err(KeyringError::NotFound(_)) | Err(KeyringError::AccessError(_))
492 ));
493 }
494 }
495
496 #[cfg(not(feature = "native-keychain"))]
498 mod stub_tests {
499 use super::*;
500
501 #[test]
502 fn test_stub_get_returns_error() {
503 let result = NikaKeyring::get("anthropic");
504 assert!(result.is_err());
505 }
506
507 #[test]
508 fn test_stub_exists_returns_false() {
509 assert!(!NikaKeyring::exists("anthropic"));
510 }
511
512 #[test]
513 fn test_stub_set_returns_error() {
514 let result = NikaKeyring::set("anthropic", "test-key");
515 assert!(result.is_err());
516 }
517 }
518}