1pub use gestura_core_config::*;
54
55use std::fs;
56use std::path::Path;
57
58#[allow(hidden_glob_reexports)]
62use crate::error::{AppError, Result};
63
64#[cfg(feature = "security")]
65use crate::security::{create_secure_storage, keychain_access_disabled};
66
67#[cfg(all(feature = "security", not(test)))]
68const KEYCHAIN_SERVICE: &str = "gestura";
69
70#[cfg(all(feature = "security", test))]
71fn test_keychain_store() -> &'static std::sync::Mutex<std::collections::HashMap<String, String>> {
72 use std::collections::HashMap;
73 use std::sync::{Mutex, OnceLock};
74
75 static STORE: OnceLock<Mutex<HashMap<String, String>>> = OnceLock::new();
76 STORE.get_or_init(|| Mutex::new(HashMap::new()))
77}
78
79#[cfg(all(feature = "security", test))]
80fn clear_test_keychain_store() {
81 if let Ok(mut m) = test_keychain_store().lock() {
82 m.clear();
83 }
84}
85
86#[cfg(all(feature = "security", test))]
88fn get_keychain_secret(key: &str) -> Option<String> {
89 test_keychain_store()
90 .lock()
91 .ok()
92 .and_then(|m| m.get(key).cloned())
93}
94
95#[cfg(all(feature = "security", not(test)))]
96fn get_keychain_secret(key: &str) -> Option<String> {
97 use keyring::Entry;
98 let entry = match Entry::new(KEYCHAIN_SERVICE, key) {
99 Ok(e) => e,
100 Err(e) => {
101 tracing::warn!(
102 key,
103 error = %e,
104 "Failed to create keyring entry — keychain may be inaccessible"
105 );
106 return None;
107 }
108 };
109 match entry.get_password() {
110 Ok(pw) => Some(pw),
111 Err(keyring::Error::NoEntry) => None,
112 Err(e) => {
113 tracing::warn!(
114 key,
115 error = %e,
116 "Failed to read secret from keychain — \
117 if another app stored this key you may need to grant access \
118 via the macOS Keychain Access prompt"
119 );
120 None
121 }
122 }
123}
124
125#[cfg(all(feature = "security", test))]
127fn set_keychain_secret(key: &str, value: &str) -> Result<()> {
128 test_keychain_store()
129 .lock()
130 .map_err(|_| AppError::Internal("Test keychain poisoned".to_string()))?
131 .insert(key.to_string(), value.to_string());
132 Ok(())
133}
134
135#[cfg(feature = "security")]
140fn get_keychain_secret_with_legacy_fallback(
141 canonical_key: &str,
142 legacy_key: &str,
143) -> Option<String> {
144 if let Some(v) = get_keychain_secret(canonical_key) {
145 return (!v.is_empty()).then_some(v);
146 }
147
148 let legacy = get_keychain_secret(legacy_key)?;
149 if legacy.is_empty() {
150 return None;
151 }
152
153 if let Err(e) = set_keychain_secret(canonical_key, &legacy) {
154 tracing::warn!(
155 canonical_key,
156 legacy_key,
157 error = %e,
158 "Failed to self-heal secret from legacy key to canonical key"
159 );
160 }
161
162 Some(legacy)
163}
164
165#[cfg(feature = "security")]
170async fn get_secret_with_legacy(
171 storage: &dyn crate::security::SecureStorage,
172 canonical_key: &str,
173 legacy_key: &str,
174) -> Option<String> {
175 if let Ok(Some(v)) = storage.get_secret(canonical_key).await
176 && !v.is_empty()
177 {
178 return Some(v);
179 }
180
181 if let Ok(Some(v)) = storage.get_secret(legacy_key).await
182 && !v.is_empty()
183 {
184 let _ = storage.store_secret(canonical_key, &v).await;
186 return Some(v);
187 }
188
189 None
190}
191
192#[cfg(feature = "security")]
199fn autoselect_primary_llm_provider_from_hydrated_secrets(config: &mut AppConfig) {
200 fn provider_is_configured(llm: &LlmSettings, provider: &str) -> bool {
201 if provider.eq_ignore_ascii_case("openai") {
202 llm.openai
203 .as_ref()
204 .is_some_and(|c| !c.api_key.trim().is_empty())
205 } else if provider.eq_ignore_ascii_case("anthropic") {
206 llm.anthropic
207 .as_ref()
208 .is_some_and(|c| !c.api_key.trim().is_empty())
209 } else if provider.eq_ignore_ascii_case("gemini") {
210 llm.gemini
211 .as_ref()
212 .is_some_and(|c| !c.api_key.trim().is_empty())
213 } else if provider.eq_ignore_ascii_case("grok") {
214 llm.grok
215 .as_ref()
216 .is_some_and(|c| !c.api_key.trim().is_empty())
217 } else if provider.eq_ignore_ascii_case("ollama") {
218 true
220 } else {
221 false
222 }
223 }
224
225 if provider_is_configured(&config.llm, &config.llm.primary) {
227 return;
228 }
229
230 for candidate in ["anthropic", "openai", "gemini", "grok"] {
232 if provider_is_configured(&config.llm, candidate) {
233 config.llm.primary = candidate.to_string();
234 return;
235 }
236 }
237
238 config.llm.primary = "ollama".to_string();
239}
240
241#[cfg(feature = "security")]
246fn keychain_has_secret(canonical_key: &str, legacy_key: &str) -> bool {
247 get_keychain_secret_with_legacy_fallback(canonical_key, legacy_key).is_some()
248}
249
250#[cfg(all(feature = "security", not(test)))]
251fn set_keychain_secret(key: &str, value: &str) -> Result<()> {
252 use keyring::Entry;
253 Entry::new(KEYCHAIN_SERVICE, key)
254 .map_err(|e| AppError::Internal(format!("Keyring error: {}", e)))?
255 .set_password(value)
256 .map_err(|e| AppError::Internal(format!("Keyring error: {}", e)))?;
257 Ok(())
258}
259pub trait AppConfigSecurityExt: Sized {
268 fn load() -> Self;
270 fn load_async() -> impl std::future::Future<Output = Self> + Send;
272 fn save_to_path(&self, path: impl AsRef<Path>) -> Result<()>;
274 fn save(&self) -> Result<()>;
276 fn save_async(&self) -> impl std::future::Future<Output = Result<()>> + Send;
278 fn save_to_path_async(
280 &self,
281 path: impl AsRef<Path> + Send,
282 ) -> impl std::future::Future<Output = Result<()>> + Send;
283 fn load_with_env() -> Self;
285 fn load_with_env_async() -> impl std::future::Future<Output = Self> + Send;
287 #[cfg(feature = "security")]
289 fn sanitize_secrets(&mut self);
290 fn api_key_keychain_status() -> Vec<(&'static str, bool)>;
292 #[cfg(feature = "security")]
294 fn has_plaintext_secrets(&self) -> bool;
295 #[cfg(feature = "security")]
297 fn hydrate_secrets_sync(&mut self) -> Result<()>;
298 #[cfg(feature = "security")]
300 fn hydrate_secrets(&mut self) -> impl std::future::Future<Output = Result<()>> + Send;
301 #[cfg(feature = "security")]
303 fn migrate_secrets_sync(&self) -> Result<bool>;
304 #[cfg(feature = "security")]
306 fn migrate_secrets(&self) -> impl std::future::Future<Output = Result<bool>> + Send;
307}
308
309impl AppConfigSecurityExt for AppConfig {
310 fn load() -> Self {
317 let yaml_path = Self::default_path();
318 let json_path = Self::legacy_json_path();
319 let backup_path = Self::legacy_json_backup_path();
320 let had_yaml = yaml_path.exists();
321 let had_json = json_path.exists();
322 #[cfg_attr(not(feature = "security"), allow(unused_variables))]
323 let used_default_config = !had_yaml && !had_json;
324 let needs_format_migration = !had_yaml && had_json;
327 #[allow(unused_mut)] let mut config = if had_yaml {
329 Self::load_from_path(&yaml_path)
330 } else {
331 if had_json {
333 if let Ok(s) = fs::read_to_string(&json_path) {
334 serde_json::from_str::<Self>(&s).unwrap_or_default()
337 } else {
338 Self::default()
339 }
340 } else {
341 Self::default()
342 }
343 };
344 config.normalize_derived_defaults();
345
346 #[cfg(feature = "security")]
348 {
349 if keychain_access_disabled() {
350 tracing::info!(
354 "Keychain access disabled; skipping secret hydration/migration on config load"
355 );
356 } else {
357 let had_plaintext_secrets = config.has_plaintext_secrets();
360
361 let _ = config.hydrate_secrets_sync();
363
364 if used_default_config {
369 autoselect_primary_llm_provider_from_hydrated_secrets(&mut config);
370 }
371
372 let migrated = config.migrate_secrets_sync().unwrap_or(false);
374
375 if had_plaintext_secrets || migrated {
378 let _ = config.save();
379 }
380 }
381 }
382
383 if needs_format_migration {
387 let _ = config.save();
388 if yaml_path.exists() && json_path.exists() && !backup_path.exists() {
390 let _ = fs::rename(&json_path, &backup_path);
391 }
392 }
393
394 let mcp_json_servers = crate::mcp::config::McpJsonFile::load_aggregated();
396 for server in mcp_json_servers {
397 if let Some(existing) = config
399 .mcp_servers
400 .iter_mut()
401 .find(|s| s.name == server.name)
402 {
403 *existing = server;
404 } else {
405 config.mcp_servers.push(server);
406 }
407 }
408
409 config
410 }
411
412 async fn load_async() -> Self {
416 let yaml_path = Self::default_path();
417 let json_path = Self::legacy_json_path();
418 let backup_path = Self::legacy_json_backup_path();
419 let had_yaml = tokio::fs::try_exists(&yaml_path).await.unwrap_or(false);
420 let had_json = tokio::fs::try_exists(&json_path).await.unwrap_or(false);
421 #[cfg_attr(not(feature = "security"), allow(unused_variables))]
422 let used_default_config = !had_yaml && !had_json;
423 let needs_format_migration = !had_yaml && had_json;
424 #[allow(unused_mut)] let mut config = if had_yaml {
426 Self::load_from_path_async(&yaml_path).await
427 } else if had_json {
428 if let Ok(s) = tokio::fs::read_to_string(&json_path).await {
429 serde_json::from_str::<Self>(&s).unwrap_or_default()
430 } else {
431 Self::default()
432 }
433 } else {
434 Self::default()
435 };
436 config.normalize_derived_defaults();
437
438 #[cfg(feature = "security")]
440 {
441 if keychain_access_disabled() {
442 tracing::info!(
443 "Keychain access disabled; skipping secret hydration/migration on async config load"
444 );
445 } else {
446 let had_plaintext_secrets = config.has_plaintext_secrets();
447
448 let _ = config.hydrate_secrets().await;
450
451 if used_default_config {
452 autoselect_primary_llm_provider_from_hydrated_secrets(&mut config);
453 }
454
455 let migrated = config.migrate_secrets().await.unwrap_or(false);
457
458 if had_plaintext_secrets || migrated {
459 let _ = config.save_async().await;
460 }
461 }
462 }
463
464 if needs_format_migration {
468 let _ = config.save_async().await;
469
470 let yaml_exists = tokio::fs::try_exists(&yaml_path).await.unwrap_or(false);
471 let json_exists = tokio::fs::try_exists(&json_path).await.unwrap_or(false);
472 let backup_exists = tokio::fs::try_exists(&backup_path).await.unwrap_or(false);
473
474 if yaml_exists && json_exists && !backup_exists {
475 let _ = tokio::fs::rename(&json_path, &backup_path).await;
476 }
477 }
478
479 let mcp_json_servers = crate::mcp::config::McpJsonFile::load_aggregated_async().await;
481 for server in mcp_json_servers {
482 if let Some(existing) = config
484 .mcp_servers
485 .iter_mut()
486 .find(|s| s.name == server.name)
487 {
488 *existing = server;
489 } else {
490 config.mcp_servers.push(server);
491 }
492 }
493
494 config
495 }
496
497 fn save_to_path(&self, path: impl AsRef<Path>) -> Result<()> {
501 let path = path.as_ref();
502 if let Some(parent) = path.parent() {
503 fs::create_dir_all(parent)?;
504 }
505
506 #[cfg(feature = "security")]
507 {
508 if keychain_access_disabled() && self.has_plaintext_secrets() {
509 return Err(AppError::Config(
510 "Cannot persist plaintext secrets while keychain access is disabled. \
511Set secrets via environment variables or re-enable keychain access (unset GESTURA_DISABLE_KEYCHAIN)."
512 .to_string(),
513 ));
514 }
515
516 let _ = self.migrate_secrets_sync();
518
519 let mut clean_config = self.clone();
520 clean_config.sanitize_secrets();
521 let data = serde_yaml::to_string(&clean_config)
522 .map_err(|e| AppError::Config(format!("Failed to serialize config: {}", e)))?;
523 fs::write(path, data)?;
524 }
525
526 #[cfg(not(feature = "security"))]
527 {
528 let data = serde_yaml::to_string(self)
529 .map_err(|e| AppError::Config(format!("Failed to serialize config: {}", e)))?;
530 fs::write(path, data)?;
531 }
532
533 Ok(())
534 }
535
536 fn save(&self) -> Result<()> {
538 self.save_to_path(Self::default_path())
539 }
540
541 async fn save_async(&self) -> Result<()> {
543 self.save_to_path_async(Self::default_path()).await
544 }
545
546 async fn save_to_path_async(&self, path: impl AsRef<Path>) -> Result<()> {
550 let path = path.as_ref().to_path_buf();
551 if let Some(parent) = path.parent() {
552 tokio::fs::create_dir_all(parent).await?;
553 }
554
555 #[cfg(feature = "security")]
556 {
557 if keychain_access_disabled() && self.has_plaintext_secrets() {
558 return Err(AppError::Config(
559 "Cannot persist plaintext secrets while keychain access is disabled. \
560Set secrets via environment variables or re-enable keychain access (unset GESTURA_DISABLE_KEYCHAIN)."
561 .to_string(),
562 ));
563 }
564
565 let _ = self.migrate_secrets().await;
568
569 let mut clean_config = self.clone();
570 clean_config.sanitize_secrets();
571 let data = serde_yaml::to_string(&clean_config)
572 .map_err(|e| AppError::Config(format!("Failed to serialize config: {}", e)))?;
573 tokio::fs::write(path, data).await?;
574 }
575
576 #[cfg(not(feature = "security"))]
577 {
578 let data = serde_yaml::to_string(self)
579 .map_err(|e| AppError::Config(format!("Failed to serialize config: {}", e)))?;
580 tokio::fs::write(path, data).await?;
581 }
582
583 Ok(())
584 }
585
586 #[cfg(feature = "security")]
592 fn sanitize_secrets(&mut self) {
593 if let Some(c) = &mut self.llm.openai {
594 c.api_key.clear();
595 }
596 if let Some(c) = &mut self.llm.anthropic {
597 c.api_key.clear();
598 }
599 if let Some(c) = &mut self.llm.grok {
600 c.api_key.clear();
601 }
602 if let Some(c) = &mut self.llm.gemini {
603 c.api_key.clear();
604 }
605
606 self.voice.openai_api_key = None;
607 self.web_search.serpapi_key = None;
608 self.web_search.brave_key = None;
609 }
610
611 #[cfg(feature = "security")]
620 fn api_key_keychain_status() -> Vec<(&'static str, bool)> {
621 if keychain_access_disabled() {
622 return vec![
623 ("openai", false),
624 ("anthropic", false),
625 ("gemini", false),
626 ("grok", false),
627 ("voice_openai", false),
628 ("serpapi", false),
629 ("brave", false),
630 ];
631 }
632
633 vec![
634 (
635 "openai",
636 keychain_has_secret("gestura_llm_openai_api_key", "gestura_api_key_openai"),
637 ),
638 (
639 "anthropic",
640 keychain_has_secret("gestura_llm_anthropic_api_key", "gestura_api_key_anthropic"),
641 ),
642 (
643 "gemini",
644 keychain_has_secret("gestura_llm_gemini_api_key", "gestura_api_key_gemini"),
645 ),
646 (
647 "grok",
648 keychain_has_secret("gestura_llm_grok_api_key", "gestura_api_key_grok"),
649 ),
650 (
651 "voice_openai",
652 keychain_has_secret(
653 "gestura_voice_openai_api_key",
654 "gestura_api_key_voice_openai",
655 ),
656 ),
657 (
658 "serpapi",
659 keychain_has_secret("gestura_web_search_serpapi_key", "gestura_api_key_serpapi"),
660 ),
661 (
662 "brave",
663 keychain_has_secret("gestura_web_search_brave_key", "gestura_api_key_brave"),
664 ),
665 ]
666 }
667
668 #[cfg(not(feature = "security"))]
671 fn api_key_keychain_status() -> Vec<(&'static str, bool)> {
672 vec![
673 ("openai", false),
674 ("anthropic", false),
675 ("gemini", false),
676 ("grok", false),
677 ("voice_openai", false),
678 ("serpapi", false),
679 ("brave", false),
680 ]
681 }
682
683 #[cfg(feature = "security")]
686 fn has_plaintext_secrets(&self) -> bool {
687 self.llm
688 .openai
689 .as_ref()
690 .is_some_and(|c| !c.api_key.is_empty())
691 || self
692 .llm
693 .anthropic
694 .as_ref()
695 .is_some_and(|c| !c.api_key.is_empty())
696 || self
697 .llm
698 .grok
699 .as_ref()
700 .is_some_and(|c| !c.api_key.is_empty())
701 || self
702 .llm
703 .gemini
704 .as_ref()
705 .is_some_and(|c| !c.api_key.is_empty())
706 || self
707 .voice
708 .openai_api_key
709 .as_deref()
710 .is_some_and(|k| !k.is_empty())
711 || self
712 .web_search
713 .serpapi_key
714 .as_deref()
715 .is_some_and(|k| !k.is_empty())
716 || self
717 .web_search
718 .brave_key
719 .as_deref()
720 .is_some_and(|k| !k.is_empty())
721 }
722
723 #[cfg(feature = "security")]
725 fn hydrate_secrets_sync(&mut self) -> Result<()> {
726 if keychain_access_disabled() {
727 return Ok(());
728 }
729
730 if let Some(secret) = get_keychain_secret_with_legacy_fallback(
732 "gestura_llm_openai_api_key",
733 "gestura_api_key_openai",
734 ) {
735 let c = self.llm.openai.get_or_insert_with(Default::default);
737 c.api_key = secret;
738 }
739 if let Some(c) = self.llm.openai.as_mut()
741 && c.model.is_empty()
742 {
743 c.model = crate::default_models::DEFAULT_OPENAI_MODEL.to_string();
744 }
745
746 if let Some(secret) = get_keychain_secret_with_legacy_fallback(
748 "gestura_llm_anthropic_api_key",
749 "gestura_api_key_anthropic",
750 ) {
751 let c = self.llm.anthropic.get_or_insert_with(Default::default);
752 c.api_key = secret;
753 }
754 if let Some(c) = self.llm.anthropic.as_mut()
755 && c.model.is_empty()
756 {
757 c.model = crate::default_models::DEFAULT_ANTHROPIC_MODEL.to_string();
758 }
759
760 if let Some(secret) = get_keychain_secret_with_legacy_fallback(
762 "gestura_llm_grok_api_key",
763 "gestura_api_key_grok",
764 ) {
765 let c = self.llm.grok.get_or_insert_with(Default::default);
766 c.api_key = secret;
767 }
768 if let Some(c) = self.llm.grok.as_mut()
769 && c.model.is_empty()
770 {
771 c.model = crate::default_models::DEFAULT_GROK_MODEL.to_string();
772 }
773
774 if let Some(secret) = get_keychain_secret_with_legacy_fallback(
776 "gestura_llm_gemini_api_key",
777 "gestura_api_key_gemini",
778 ) {
779 let c = self.llm.gemini.get_or_insert_with(Default::default);
780 c.api_key = secret;
781 }
782 if let Some(c) = self.llm.gemini.as_mut()
783 && c.model.is_empty()
784 {
785 c.model = crate::default_models::DEFAULT_GEMINI_MODEL.to_string();
786 }
787
788 if let Some(secret) = get_keychain_secret_with_legacy_fallback(
790 "gestura_voice_openai_api_key",
791 "gestura_api_key_voice_openai",
792 ) {
793 self.voice.openai_api_key = Some(secret);
794 }
795
796 if let Some(secret) = get_keychain_secret_with_legacy_fallback(
798 "gestura_web_search_serpapi_key",
799 "gestura_api_key_serpapi",
800 ) {
801 self.web_search.serpapi_key = Some(secret);
802 }
803
804 if let Some(secret) = get_keychain_secret_with_legacy_fallback(
806 "gestura_web_search_brave_key",
807 "gestura_api_key_brave",
808 ) {
809 self.web_search.brave_key = Some(secret);
810 }
811
812 Ok(())
813 }
814
815 #[cfg(feature = "security")]
817 async fn hydrate_secrets(&mut self) -> Result<()> {
818 if keychain_access_disabled() {
819 return Ok(());
820 }
821
822 let storage = create_secure_storage();
823
824 macro_rules! hydrate_with_legacy {
828 ($field:expr, $canonical:expr, $legacy:expr) => {
829 let mut found: Option<String> = None;
831 if let Ok(Some(secret)) = storage.get_secret($canonical).await {
832 if !secret.is_empty() {
833 found = Some(secret);
834 }
835 }
836 if found.is_none() {
837 if let Ok(Some(secret)) = storage.get_secret($legacy).await {
838 if !secret.is_empty() {
839 let _ = storage.store_secret($canonical, &secret).await;
840 found = Some(secret);
841 }
842 }
843 }
844 if let Some(secret) = found {
845 *$field = secret;
846 }
847 };
848 ($field:expr, $canonical:expr, $legacy:expr, option) => {
849 let mut found: Option<String> = None;
850 if let Ok(Some(secret)) = storage.get_secret($canonical).await {
851 if !secret.is_empty() {
852 found = Some(secret);
853 }
854 }
855 if found.is_none() {
856 if let Ok(Some(secret)) = storage.get_secret($legacy).await {
857 if !secret.is_empty() {
858 let _ = storage.store_secret($canonical, &secret).await;
859 found = Some(secret);
860 }
861 }
862 }
863 if let Some(secret) = found {
864 *$field = Some(secret);
865 }
866 };
867 }
868
869 if let Some(secret) = get_secret_with_legacy(
872 storage.as_ref(),
873 "gestura_llm_openai_api_key",
874 "gestura_api_key_openai",
875 )
876 .await
877 {
878 let c = self.llm.openai.get_or_insert_with(Default::default);
879 c.api_key = secret;
880 }
881 if let Some(c) = self.llm.openai.as_mut()
883 && c.model.is_empty()
884 {
885 c.model = crate::default_models::DEFAULT_OPENAI_MODEL.to_string();
886 }
887 if let Some(secret) = get_secret_with_legacy(
888 storage.as_ref(),
889 "gestura_llm_anthropic_api_key",
890 "gestura_api_key_anthropic",
891 )
892 .await
893 {
894 let c = self.llm.anthropic.get_or_insert_with(Default::default);
895 c.api_key = secret;
896 }
897 if let Some(c) = self.llm.anthropic.as_mut()
898 && c.model.is_empty()
899 {
900 c.model = crate::default_models::DEFAULT_ANTHROPIC_MODEL.to_string();
901 }
902 if let Some(secret) = get_secret_with_legacy(
903 storage.as_ref(),
904 "gestura_llm_grok_api_key",
905 "gestura_api_key_grok",
906 )
907 .await
908 {
909 let c = self.llm.grok.get_or_insert_with(Default::default);
910 c.api_key = secret;
911 }
912 if let Some(c) = self.llm.grok.as_mut()
913 && c.model.is_empty()
914 {
915 c.model = crate::default_models::DEFAULT_GROK_MODEL.to_string();
916 }
917 if let Some(secret) = get_secret_with_legacy(
918 storage.as_ref(),
919 "gestura_llm_gemini_api_key",
920 "gestura_api_key_gemini",
921 )
922 .await
923 {
924 let c = self.llm.gemini.get_or_insert_with(Default::default);
925 c.api_key = secret;
926 }
927 if let Some(c) = self.llm.gemini.as_mut()
928 && c.model.is_empty()
929 {
930 c.model = crate::default_models::DEFAULT_GEMINI_MODEL.to_string();
931 }
932
933 hydrate_with_legacy!(
934 &mut self.voice.openai_api_key,
935 "gestura_voice_openai_api_key",
936 "gestura_api_key_voice_openai",
937 option
938 );
939 hydrate_with_legacy!(
940 &mut self.web_search.serpapi_key,
941 "gestura_web_search_serpapi_key",
942 "gestura_api_key_serpapi",
943 option
944 );
945 hydrate_with_legacy!(
946 &mut self.web_search.brave_key,
947 "gestura_web_search_brave_key",
948 "gestura_api_key_brave",
949 option
950 );
951
952 Ok(())
953 }
954
955 #[cfg(feature = "security")]
958 fn migrate_secrets_sync(&self) -> Result<bool> {
959 if keychain_access_disabled() {
960 return Ok(false);
961 }
962
963 let mut changed = false;
964
965 if let Some(c) = &self.llm.openai
967 && !c.api_key.is_empty()
968 {
969 if !keychain_has_secret("gestura_llm_openai_api_key", "gestura_api_key_openai") {
971 set_keychain_secret("gestura_llm_openai_api_key", &c.api_key)?;
972 changed = true;
973 }
974 }
977
978 if let Some(c) = &self.llm.anthropic
980 && !c.api_key.is_empty()
981 && !keychain_has_secret("gestura_llm_anthropic_api_key", "gestura_api_key_anthropic")
982 {
983 set_keychain_secret("gestura_llm_anthropic_api_key", &c.api_key)?;
984 changed = true;
985 }
986
987 if let Some(c) = &self.llm.grok
989 && !c.api_key.is_empty()
990 && !keychain_has_secret("gestura_llm_grok_api_key", "gestura_api_key_grok")
991 {
992 set_keychain_secret("gestura_llm_grok_api_key", &c.api_key)?;
993 changed = true;
994 }
995
996 if let Some(c) = &self.llm.gemini
998 && !c.api_key.is_empty()
999 && !keychain_has_secret("gestura_llm_gemini_api_key", "gestura_api_key_gemini")
1000 {
1001 set_keychain_secret("gestura_llm_gemini_api_key", &c.api_key)?;
1002 changed = true;
1003 }
1004
1005 if let Some(key) = self.voice.openai_api_key.as_deref()
1007 && !key.is_empty()
1008 && !keychain_has_secret(
1009 "gestura_voice_openai_api_key",
1010 "gestura_api_key_voice_openai",
1011 )
1012 {
1013 set_keychain_secret("gestura_voice_openai_api_key", key)?;
1014 changed = true;
1015 }
1016
1017 if let Some(key) = self.web_search.serpapi_key.as_deref()
1019 && !key.is_empty()
1020 && !keychain_has_secret("gestura_web_search_serpapi_key", "gestura_api_key_serpapi")
1021 {
1022 set_keychain_secret("gestura_web_search_serpapi_key", key)?;
1023 changed = true;
1024 }
1025
1026 if let Some(key) = self.web_search.brave_key.as_deref()
1028 && !key.is_empty()
1029 && !keychain_has_secret("gestura_web_search_brave_key", "gestura_api_key_brave")
1030 {
1031 set_keychain_secret("gestura_web_search_brave_key", key)?;
1032 changed = true;
1033 }
1034
1035 Ok(changed)
1036 }
1037
1038 #[cfg(feature = "security")]
1040 async fn migrate_secrets(&self) -> Result<bool> {
1041 if keychain_access_disabled() {
1042 return Ok(false);
1043 }
1044
1045 let storage = create_secure_storage();
1046 let mut changed = false;
1047
1048 async fn storage_has_secret(
1049 storage: &dyn crate::security::SecureStorage,
1050 canonical: &str,
1051 legacy: &str,
1052 ) -> bool {
1053 if let Ok(Some(v)) = storage.get_secret(canonical).await
1055 && !v.is_empty()
1056 {
1057 return true;
1058 }
1059 if let Ok(Some(v)) = storage.get_secret(legacy).await
1061 && !v.is_empty()
1062 {
1063 let _ = storage.store_secret(canonical, &v).await;
1064 return true;
1065 }
1066 false
1067 }
1068
1069 macro_rules! migrate {
1070 ($field:expr, $canonical:expr, $legacy:expr) => {
1071 if !$field.is_empty() {
1072 if !storage_has_secret(storage.as_ref(), $canonical, $legacy).await {
1073 let _ = storage.store_secret($canonical, $field).await;
1074 changed = true;
1075 }
1076 }
1077 };
1078 ($field:expr, $canonical:expr, $legacy:expr, option) => {
1079 if let Some(val) = $field {
1080 if !val.is_empty() {
1081 if !storage_has_secret(storage.as_ref(), $canonical, $legacy).await {
1082 let _ = storage.store_secret($canonical, val).await;
1083 changed = true;
1084 }
1085 }
1086 }
1087 };
1088 }
1089
1090 if let Some(c) = &self.llm.openai {
1091 migrate!(
1092 &c.api_key,
1093 "gestura_llm_openai_api_key",
1094 "gestura_api_key_openai"
1095 );
1096 }
1097 if let Some(c) = &self.llm.anthropic {
1098 migrate!(
1099 &c.api_key,
1100 "gestura_llm_anthropic_api_key",
1101 "gestura_api_key_anthropic"
1102 );
1103 }
1104 if let Some(c) = &self.llm.grok {
1105 migrate!(
1106 &c.api_key,
1107 "gestura_llm_grok_api_key",
1108 "gestura_api_key_grok"
1109 );
1110 }
1111 if let Some(c) = &self.llm.gemini {
1112 migrate!(
1113 &c.api_key,
1114 "gestura_llm_gemini_api_key",
1115 "gestura_api_key_gemini"
1116 );
1117 }
1118
1119 migrate!(
1120 &self.voice.openai_api_key,
1121 "gestura_voice_openai_api_key",
1122 "gestura_api_key_voice_openai",
1123 option
1124 );
1125 migrate!(
1126 &self.web_search.serpapi_key,
1127 "gestura_web_search_serpapi_key",
1128 "gestura_api_key_serpapi",
1129 option
1130 );
1131 migrate!(
1132 &self.web_search.brave_key,
1133 "gestura_web_search_brave_key",
1134 "gestura_api_key_brave",
1135 option
1136 );
1137
1138 Ok(changed)
1139 }
1140
1141 fn load_with_env() -> Self {
1146 Self::load().apply_env_overrides()
1147 }
1148
1149 async fn load_with_env_async() -> Self {
1151 Self::load_async().await.apply_env_overrides()
1152 }
1153}
1154
1155#[cfg(test)]
1156mod tests {
1157 use super::*;
1158 use crate::pipeline::CompactionStrategy;
1159 use std::sync::{Mutex, OnceLock};
1160
1161 #[test]
1162 fn default_config_has_expected_values() {
1163 let c = AppConfig::default();
1164 assert_eq!(c.hotkey_listen, "Ctrl+Space");
1165 assert_eq!(c.grace_period_secs, 30);
1166 assert_eq!(c.llm.primary, "anthropic");
1167 assert!(!c.privacy.data_collection);
1168 assert!(c.privacy.crash_reports);
1169 assert!(c.privacy.voice_data_local);
1170 assert!(!c.privacy.require_auth);
1171 assert_eq!(c.privacy.auth_timeout, 15);
1172 assert_eq!(c.llm.fallback, None);
1175 assert!(c.llm.ollama.is_none());
1176 assert!(c.llm.openai.is_none());
1177 assert!(c.llm.anthropic.is_none());
1178 let ollama_defaults = OllamaConfig::default();
1180 assert_eq!(ollama_defaults.base_url, "http://localhost:11434");
1181 assert_eq!(ollama_defaults.model, "llama3.2");
1182 }
1183
1184 #[test]
1185 fn test_config_get() {
1186 let c = AppConfig::default();
1187 assert_eq!(c.get("llm.primary"), Some("anthropic".to_string()));
1188 assert_eq!(
1189 c.get("pipeline.agent_telemetry.enabled"),
1190 Some("false".to_string())
1191 );
1192 assert_eq!(
1193 c.get("pipeline.agent_telemetry.trace_export.enabled"),
1194 Some("false".to_string())
1195 );
1196 assert_eq!(
1197 c.get("pipeline.agent_telemetry.trace_export.protocol"),
1198 Some("grpc".to_string())
1199 );
1200 assert_eq!(c.get("privacy.crash_reports"), Some("true".to_string()));
1201 assert_eq!(c.get("unknown.key"), None);
1202 }
1203
1204 #[test]
1205 fn test_whisper_model_info() {
1206 let models = WhisperModelInfo::available_models();
1207 assert!(!models.is_empty());
1208 let recommended: Vec<_> = models.iter().filter(|m| m.recommended).collect();
1209 assert_eq!(recommended.len(), 1);
1210 }
1211
1212 #[test]
1213 fn test_backward_compatibility_without_pipeline_settings() {
1214 let default_config = AppConfig::default();
1216 let mut json_value: serde_json::Value = serde_json::to_value(&default_config).unwrap();
1217
1218 json_value.as_object_mut().unwrap().remove("pipeline");
1220
1221 let config: AppConfig = serde_json::from_value(json_value).unwrap();
1223
1224 assert_eq!(config.pipeline.max_history_messages, 10);
1226 assert_eq!(config.pipeline.auto_compact_threshold_percent, 80);
1227 assert_eq!(
1228 config.pipeline.compaction_strategy,
1229 CompactionStrategy::Summarize
1230 );
1231 assert_eq!(config.pipeline.max_context_tokens, 0);
1232 assert!(config.pipeline.log_token_usage);
1233 assert!(!config.pipeline.agent_telemetry.enabled);
1234 assert!(!config.pipeline.agent_telemetry.trace_export.enabled);
1235 assert_eq!(
1236 config.pipeline.agent_telemetry.trace_export.protocol,
1237 AgentTelemetryTraceExportProtocol::Grpc
1238 );
1239 }
1240
1241 #[test]
1242 fn test_backward_compatibility_without_privacy_settings() {
1243 let default_config = AppConfig::default();
1244 let mut json_value: serde_json::Value = serde_json::to_value(&default_config).unwrap();
1245
1246 json_value.as_object_mut().unwrap().remove("privacy");
1247
1248 let config: AppConfig = serde_json::from_value(json_value).unwrap();
1249
1250 assert!(!config.privacy.data_collection);
1251 assert!(config.privacy.crash_reports);
1252 assert!(config.privacy.voice_data_local);
1253 assert!(!config.privacy.require_auth);
1254 assert_eq!(config.privacy.auth_timeout, 15);
1255 }
1256
1257 #[test]
1258 fn test_backward_compatibility_with_partial_pipeline_settings() {
1259 let default_config = AppConfig::default();
1261 let mut json_value: serde_json::Value = serde_json::to_value(&default_config).unwrap();
1262
1263 let pipeline_obj = serde_json::json!({
1265 "max_history_messages": 20
1266 });
1267 json_value
1268 .as_object_mut()
1269 .unwrap()
1270 .insert("pipeline".to_string(), pipeline_obj);
1271
1272 let config: AppConfig = serde_json::from_value(json_value).unwrap();
1274
1275 assert_eq!(config.pipeline.max_history_messages, 20);
1277
1278 assert_eq!(config.pipeline.auto_compact_threshold_percent, 80);
1280 assert_eq!(
1281 config.pipeline.compaction_strategy,
1282 CompactionStrategy::Summarize
1283 );
1284 assert_eq!(config.pipeline.max_context_tokens, 0);
1285 assert!(config.pipeline.log_token_usage);
1286 assert!(!config.pipeline.agent_telemetry.enabled);
1287 assert_eq!(
1288 config.pipeline.agent_telemetry.trace_export.protocol,
1289 AgentTelemetryTraceExportProtocol::Grpc
1290 );
1291 assert_eq!(
1292 config.pipeline.agent_telemetry.trace_export.endpoint,
1293 gestura_core_foundation::telemetry::DEFAULT_OTLP_TRACE_ENDPOINT
1294 );
1295 }
1296
1297 #[test]
1298 fn test_pipeline_settings_serialization_roundtrip() {
1299 let mut config = AppConfig::default();
1301 config.pipeline.max_history_messages = 15;
1302 config.pipeline.auto_compact_threshold_percent = 75;
1303 config.pipeline.compaction_strategy = CompactionStrategy::MemoryBank;
1304 config.pipeline.max_context_tokens = 50000;
1305 config.pipeline.log_token_usage = false;
1306 config.pipeline.agent_telemetry.enabled = true;
1307 config.pipeline.agent_telemetry.trace_export.enabled = true;
1308 config.pipeline.agent_telemetry.trace_export.protocol =
1309 AgentTelemetryTraceExportProtocol::Grpc;
1310 config.pipeline.agent_telemetry.trace_export.endpoint = "http://localhost:4317".to_string();
1311
1312 let yaml = serde_yaml::to_string(&config).unwrap();
1314
1315 let deserialized: AppConfig = serde_yaml::from_str(&yaml).unwrap();
1317
1318 assert_eq!(deserialized.pipeline.max_history_messages, 15);
1320 assert_eq!(deserialized.pipeline.auto_compact_threshold_percent, 75);
1321 assert_eq!(
1322 deserialized.pipeline.compaction_strategy,
1323 CompactionStrategy::MemoryBank
1324 );
1325 assert_eq!(deserialized.pipeline.max_context_tokens, 50000);
1326 assert!(!deserialized.pipeline.log_token_usage);
1327 assert!(deserialized.pipeline.agent_telemetry.enabled);
1328 assert!(deserialized.pipeline.agent_telemetry.trace_export.enabled);
1329 assert_eq!(
1330 deserialized.pipeline.agent_telemetry.trace_export.protocol,
1331 AgentTelemetryTraceExportProtocol::Grpc
1332 );
1333 assert_eq!(
1334 deserialized.pipeline.agent_telemetry.trace_export.endpoint,
1335 "http://localhost:4317"
1336 );
1337 }
1338
1339 fn env_lock() -> &'static Mutex<()> {
1341 static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
1342 LOCK.get_or_init(|| Mutex::new(()))
1343 }
1344
1345 #[cfg(feature = "security")]
1350 fn keychain_lock() -> &'static Mutex<()> {
1351 static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
1352 LOCK.get_or_init(|| Mutex::new(()))
1353 }
1354
1355 struct ScopedEnvVar {
1361 key: &'static str,
1362 old: Option<String>,
1363 }
1364
1365 impl ScopedEnvVar {
1366 fn set(key: &'static str, value: String) -> Self {
1367 let old = std::env::var(key).ok();
1368 unsafe {
1370 std::env::set_var(key, value);
1371 }
1372 Self { key, old }
1373 }
1374
1375 #[allow(dead_code)]
1376 fn unset(key: &'static str) -> Self {
1377 let old = std::env::var(key).ok();
1378 unsafe {
1380 std::env::remove_var(key);
1381 }
1382 Self { key, old }
1383 }
1384 }
1385
1386 impl Drop for ScopedEnvVar {
1387 fn drop(&mut self) {
1388 match &self.old {
1389 Some(v) => unsafe {
1390 std::env::set_var(self.key, v);
1391 },
1392 None => unsafe {
1393 std::env::remove_var(self.key);
1394 },
1395 }
1396 }
1397 }
1398
1399 #[test]
1400 #[cfg_attr(
1401 target_os = "windows",
1402 ignore = "dirs::home_dir() bypasses env var overrides on Windows"
1403 )]
1404 fn migrates_legacy_json_config_to_yaml_on_load() {
1405 let _guard = env_lock().lock().unwrap_or_else(|e| e.into_inner());
1407
1408 #[cfg(feature = "security")]
1412 {
1413 let _keychain_guard = keychain_lock().lock().unwrap_or_else(|e| e.into_inner());
1414 clear_test_keychain_store();
1415 }
1416
1417 let dir = tempfile::tempdir().unwrap();
1418 let home = dir.path().to_path_buf();
1419 let _home = ScopedEnvVar::set("HOME", home.to_string_lossy().to_string());
1420 let _userprofile = ScopedEnvVar::set("USERPROFILE", home.to_string_lossy().to_string());
1421 let _homedrive = ScopedEnvVar::set("HOMEDRIVE", "C:".to_string());
1422 let _homepath = ScopedEnvVar::set("HOMEPATH", "\\".to_string());
1423
1424 let gestura_dir = home.join(".gestura");
1425 fs::create_dir_all(&gestura_dir).unwrap();
1426
1427 let json_path = gestura_dir.join("config.json");
1428 let yaml_path = gestura_dir.join("config.yaml");
1429 let backup_path = gestura_dir.join("config.json.backup");
1430
1431 let cfg = AppConfig::default();
1433 let json = serde_json::to_string_pretty(&cfg).unwrap();
1434 fs::write(&json_path, json).unwrap();
1435 assert!(!yaml_path.exists());
1436
1437 let loaded = AppConfig::load();
1439 assert_eq!(loaded, cfg);
1440
1441 assert!(yaml_path.exists());
1443
1444 assert!(!json_path.exists() || backup_path.exists());
1446 }
1447
1448 #[test]
1449 #[cfg(feature = "security")]
1450 #[cfg_attr(
1451 target_os = "windows",
1452 ignore = "dirs::home_dir() bypasses env var overrides on Windows"
1453 )]
1454 fn migrates_plaintext_openai_key_to_keystore_and_sanitizes_yaml_on_load() {
1455 let _guard = env_lock().lock().unwrap_or_else(|e| e.into_inner());
1457
1458 let _keychain_guard = keychain_lock().lock().unwrap_or_else(|e| e.into_inner());
1460
1461 let _ci = ScopedEnvVar::unset("CI");
1463
1464 clear_test_keychain_store();
1465
1466 let dir = tempfile::tempdir().unwrap();
1467 let home = dir.path().to_path_buf();
1468 let _home = ScopedEnvVar::set("HOME", home.to_string_lossy().to_string());
1469 let _userprofile = ScopedEnvVar::set("USERPROFILE", home.to_string_lossy().to_string());
1470 let _homedrive = ScopedEnvVar::set("HOMEDRIVE", "C:".to_string());
1471 let _homepath = ScopedEnvVar::set("HOMEPATH", "\\".to_string());
1472
1473 let gestura_dir = home.join(".gestura");
1474 fs::create_dir_all(&gestura_dir).unwrap();
1475
1476 let yaml_path = gestura_dir.join("config.yaml");
1477
1478 let secret = "sk-test-openai-1234567890".to_string();
1479 let mut cfg = AppConfig::default();
1480 cfg.llm.openai = Some(OpenAiConfig {
1481 api_key: secret.clone(),
1482 ..Default::default()
1483 });
1484
1485 let yaml = serde_yaml::to_string(&cfg).unwrap();
1486 assert!(yaml.contains(&secret));
1487 fs::write(&yaml_path, yaml).unwrap();
1488
1489 let loaded = AppConfig::load();
1491 assert_eq!(
1492 loaded.llm.openai.as_ref().unwrap().api_key,
1493 secret,
1494 "loaded config should retain the secret in-memory"
1495 );
1496
1497 let persisted = fs::read_to_string(&yaml_path).unwrap();
1498 assert!(
1499 !persisted.contains(&secret),
1500 "persisted YAML must not contain plaintext secrets"
1501 );
1502
1503 let persisted_cfg: AppConfig = serde_yaml::from_str(&persisted).unwrap();
1504 assert!(
1505 persisted_cfg
1506 .llm
1507 .openai
1508 .as_ref()
1509 .is_some_and(|c| c.api_key.is_empty()),
1510 "persisted config should have openai.api_key cleared"
1511 );
1512
1513 let loaded_again = AppConfig::load();
1515 assert_eq!(
1516 loaded_again.llm.openai.as_ref().unwrap().api_key,
1517 secret,
1518 "second load should hydrate secret from secure storage"
1519 );
1520
1521 let persisted2 = fs::read_to_string(&yaml_path).unwrap();
1522 assert!(!persisted2.contains(&secret));
1523 }
1524
1525 #[test]
1526 #[cfg(feature = "security")]
1527 #[cfg_attr(
1528 target_os = "windows",
1529 ignore = "dirs::home_dir() bypasses env var overrides on Windows"
1530 )]
1531 fn keychain_secret_overrides_yaml_and_plaintext_is_sanitized_on_load() {
1532 let _guard = env_lock().lock().unwrap_or_else(|e| e.into_inner());
1534
1535 let _keychain_guard = keychain_lock().lock().unwrap_or_else(|e| e.into_inner());
1537
1538 let _ci = ScopedEnvVar::unset("CI");
1540
1541 clear_test_keychain_store();
1542
1543 let keychain_secret = "sk-keychain-openai-A";
1545 set_keychain_secret("gestura_llm_openai_api_key", keychain_secret).unwrap();
1546
1547 let dir = tempfile::tempdir().unwrap();
1548 let home = dir.path().to_path_buf();
1549 let _home = ScopedEnvVar::set("HOME", home.to_string_lossy().to_string());
1550 let _userprofile = ScopedEnvVar::set("USERPROFILE", home.to_string_lossy().to_string());
1551 let _homedrive = ScopedEnvVar::set("HOMEDRIVE", "C:".to_string());
1552 let _homepath = ScopedEnvVar::set("HOMEPATH", "\\".to_string());
1553
1554 let gestura_dir = home.join(".gestura");
1555 fs::create_dir_all(&gestura_dir).unwrap();
1556 let yaml_path = gestura_dir.join("config.yaml");
1557
1558 let yaml_secret = "sk-yaml-openai-B".to_string();
1560 let mut cfg = AppConfig::default();
1561 cfg.llm.openai = Some(OpenAiConfig {
1562 api_key: yaml_secret.clone(),
1563 ..Default::default()
1564 });
1565 let yaml = serde_yaml::to_string(&cfg).unwrap();
1566 assert!(yaml.contains(&yaml_secret));
1567 fs::write(&yaml_path, yaml).unwrap();
1568
1569 let loaded = AppConfig::load();
1571 assert_eq!(
1572 loaded.llm.openai.as_ref().unwrap().api_key,
1573 keychain_secret,
1574 "keychain secret must override plaintext YAML value"
1575 );
1576
1577 let persisted = fs::read_to_string(&yaml_path).unwrap();
1578 assert!(
1579 !persisted.contains(&yaml_secret),
1580 "persisted YAML must not contain plaintext secrets"
1581 );
1582 assert!(
1583 !persisted.contains(keychain_secret),
1584 "persisted YAML must not contain hydrated keychain secrets"
1585 );
1586
1587 let keychain_after = get_keychain_secret("gestura_llm_openai_api_key");
1589 assert_eq!(keychain_after.as_deref(), Some(keychain_secret));
1590 }
1591
1592 #[test]
1593 #[cfg(feature = "security")]
1594 #[cfg_attr(
1595 target_os = "windows",
1596 ignore = "dirs::home_dir() bypasses env var overrides on Windows"
1597 )]
1598 fn fresh_install_with_keychain_openai_secret_bootstraps_provider_and_autoselects_primary() {
1599 let _guard = env_lock().lock().unwrap_or_else(|e| e.into_inner());
1600 let _keychain_guard = keychain_lock().lock().unwrap_or_else(|e| e.into_inner());
1601
1602 let _ci = ScopedEnvVar::unset("CI");
1604
1605 clear_test_keychain_store();
1606 let secret = "sk-keychain-openai-fresh-install";
1607 set_keychain_secret("gestura_llm_openai_api_key", secret).unwrap();
1608
1609 let dir = tempfile::tempdir().unwrap();
1611 let home = dir.path().to_path_buf();
1612 let _home = ScopedEnvVar::set("HOME", home.to_string_lossy().to_string());
1613 let _userprofile = ScopedEnvVar::set("USERPROFILE", home.to_string_lossy().to_string());
1614 let _homedrive = ScopedEnvVar::set("HOMEDRIVE", "C:".to_string());
1615 let _homepath = ScopedEnvVar::set("HOMEPATH", "\\".to_string());
1616
1617 let loaded = AppConfig::load();
1618
1619 assert_eq!(
1620 loaded.llm.openai.as_ref().unwrap().api_key,
1621 secret,
1622 "fresh install should hydrate OpenAI secret from keychain"
1623 );
1624 assert_eq!(
1625 loaded.llm.primary, "openai",
1626 "fresh install should autoselect a configured provider instead of staying on an unconfigured default"
1627 );
1628 }
1629
1630 #[test]
1631 #[cfg(feature = "security")]
1632 #[cfg_attr(
1633 target_os = "windows",
1634 ignore = "dirs::home_dir() bypasses env var overrides on Windows"
1635 )]
1636 fn fresh_install_with_legacy_openai_secret_autoselects_and_self_heals() {
1637 let _guard = env_lock().lock().unwrap_or_else(|e| e.into_inner());
1638 let _keychain_guard = keychain_lock().lock().unwrap_or_else(|e| e.into_inner());
1639 let _ci = ScopedEnvVar::unset("CI");
1640
1641 clear_test_keychain_store();
1642 let legacy_secret = "sk-legacy-openai-fresh-install";
1643 set_keychain_secret("gestura_api_key_openai", legacy_secret).unwrap();
1644
1645 let dir = tempfile::tempdir().unwrap();
1646 let home = dir.path().to_path_buf();
1647 let _home = ScopedEnvVar::set("HOME", home.to_string_lossy().to_string());
1648 let _userprofile = ScopedEnvVar::set("USERPROFILE", home.to_string_lossy().to_string());
1649 let _homedrive = ScopedEnvVar::set("HOMEDRIVE", "C:".to_string());
1650 let _homepath = ScopedEnvVar::set("HOMEPATH", "\\".to_string());
1651
1652 let loaded = AppConfig::load();
1653
1654 assert_eq!(loaded.llm.primary, "openai");
1655 assert_eq!(loaded.llm.openai.as_ref().unwrap().api_key, legacy_secret);
1656
1657 let canonical = get_keychain_secret("gestura_llm_openai_api_key");
1659 assert_eq!(canonical.as_deref(), Some(legacy_secret));
1660 }
1661
1662 #[test]
1663 #[cfg(feature = "security")]
1664 #[cfg_attr(
1665 target_os = "windows",
1666 ignore = "dirs::home_dir() bypasses env var overrides on Windows"
1667 )]
1668 fn fresh_install_with_only_gemini_keychain_secret_autoselects_gemini() {
1669 let _guard = env_lock().lock().unwrap_or_else(|e| e.into_inner());
1670 let _keychain_guard = keychain_lock().lock().unwrap_or_else(|e| e.into_inner());
1671 let _ci = ScopedEnvVar::unset("CI");
1672
1673 clear_test_keychain_store();
1674 let secret = "AIza-fresh-install-gemini";
1675 set_keychain_secret("gestura_llm_gemini_api_key", secret).unwrap();
1676
1677 let dir = tempfile::tempdir().unwrap();
1678 let home = dir.path().to_path_buf();
1679 let _home = ScopedEnvVar::set("HOME", home.to_string_lossy().to_string());
1680 let _userprofile = ScopedEnvVar::set("USERPROFILE", home.to_string_lossy().to_string());
1681 let _homedrive = ScopedEnvVar::set("HOMEDRIVE", "C:".to_string());
1682 let _homepath = ScopedEnvVar::set("HOMEPATH", "\\".to_string());
1683
1684 let loaded = AppConfig::load();
1685 assert_eq!(loaded.llm.primary, "gemini");
1686 assert_eq!(loaded.llm.gemini.as_ref().unwrap().api_key, secret);
1687 }
1688
1689 #[tokio::test]
1690 #[cfg(feature = "security")]
1691 async fn get_secret_with_legacy_prefers_canonical() {
1692 use crate::security::SecureStorage as _;
1693
1694 let storage = crate::security::MockSecureStorage::new();
1695 storage
1696 .store_secret("canonical_key", "canonical")
1697 .await
1698 .unwrap();
1699 storage.store_secret("legacy_key", "legacy").await.unwrap();
1700
1701 let v = get_secret_with_legacy(&storage, "canonical_key", "legacy_key").await;
1702 assert_eq!(v.as_deref(), Some("canonical"));
1703 }
1704
1705 #[tokio::test]
1706 #[cfg(feature = "security")]
1707 async fn get_secret_with_legacy_falls_back_and_self_heals() {
1708 use crate::security::SecureStorage as _;
1709
1710 let storage = crate::security::MockSecureStorage::new();
1711 storage.store_secret("legacy_key", "legacy").await.unwrap();
1712
1713 let v = get_secret_with_legacy(&storage, "canonical_key", "legacy_key").await;
1714 assert_eq!(v.as_deref(), Some("legacy"));
1715
1716 let canonical = storage.get_secret("canonical_key").await.unwrap();
1717 assert_eq!(canonical.as_deref(), Some("legacy"));
1718 }
1719
1720 #[test]
1721 #[cfg(feature = "security")]
1722 fn hydrate_secrets_sync_falls_back_to_legacy_keys_and_self_heals() {
1723 let _guard = env_lock().lock().unwrap_or_else(|e| e.into_inner());
1724 let _keychain_guard = keychain_lock().lock().unwrap_or_else(|e| e.into_inner());
1725
1726 let _ci = ScopedEnvVar::unset("CI");
1728
1729 clear_test_keychain_store();
1730
1731 let legacy_secret = "sk-legacy-openai";
1733 set_keychain_secret("gestura_api_key_openai", legacy_secret).unwrap();
1734
1735 let mut cfg = AppConfig::default();
1736 cfg.llm.openai = Some(OpenAiConfig {
1737 api_key: String::new(),
1738 ..Default::default()
1739 });
1740
1741 cfg.hydrate_secrets_sync().unwrap();
1742 assert_eq!(cfg.llm.openai.as_ref().unwrap().api_key, legacy_secret);
1743
1744 let canonical = get_keychain_secret("gestura_llm_openai_api_key");
1746 assert_eq!(canonical.as_deref(), Some(legacy_secret));
1747 }
1748}