1use std::cell::Cell;
22use std::path::{Path, PathBuf};
23use std::str::FromStr;
24
25use serde::de::DeserializeOwned;
26use serde::{Deserialize, Deserializer};
27use toml_edit::DocumentMut;
28
29use crate::error::CliCoreError;
30
31#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
39#[non_exhaustive]
40pub enum CredentialStore {
41 Auto,
44 #[default]
47 Keyring,
48 File,
51}
52
53impl CredentialStore {
54 #[must_use]
56 pub fn as_str(self) -> &'static str {
57 match self {
58 CredentialStore::Auto => "auto",
59 CredentialStore::Keyring => "keyring",
60 CredentialStore::File => "file",
61 }
62 }
63}
64
65impl std::fmt::Display for CredentialStore {
66 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
67 f.write_str(self.as_str())
68 }
69}
70
71#[derive(Debug, Clone, PartialEq, Eq)]
73pub struct ParseCredentialStoreError(String);
74
75impl std::fmt::Display for ParseCredentialStoreError {
76 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
77 write!(
78 f,
79 "invalid credential store {:?} (expected one of: auto, keyring, file)",
80 self.0
81 )
82 }
83}
84
85impl std::error::Error for ParseCredentialStoreError {}
86
87impl FromStr for CredentialStore {
88 type Err = ParseCredentialStoreError;
89
90 fn from_str(s: &str) -> Result<Self, Self::Err> {
91 match s.trim().to_ascii_lowercase().as_str() {
92 "auto" => Ok(CredentialStore::Auto),
93 "keyring" | "keychain" => Ok(CredentialStore::Keyring),
95 "file" => Ok(CredentialStore::File),
96 _ => Err(ParseCredentialStoreError(s.to_owned())),
97 }
98 }
99}
100
101impl<'de> Deserialize<'de> for CredentialStore {
102 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
103 where
104 D: Deserializer<'de>,
105 {
106 let raw = String::deserialize(deserializer)?;
107 raw.parse().map_err(serde::de::Error::custom)
108 }
109}
110
111#[derive(Clone, Debug, Default, Deserialize)]
116#[serde(default)]
117pub struct EngineConfig {
118 pub credentials: CredentialsConfig,
120}
121
122#[derive(Clone, Debug, Default, Deserialize)]
124#[serde(default)]
125pub struct CredentialsConfig {
126 pub store: Option<CredentialStore>,
128}
129
130thread_local! {
142 static CREDENTIAL_STORE_FLAG: Cell<u8> = const { Cell::new(0) };
143}
144
145fn encode_store(store: Option<CredentialStore>) -> u8 {
146 match store {
147 None => 0,
148 Some(CredentialStore::Auto) => 1,
149 Some(CredentialStore::Keyring) => 2,
150 Some(CredentialStore::File) => 3,
151 }
152}
153
154fn decode_store(byte: u8) -> Option<CredentialStore> {
155 match byte {
156 1 => Some(CredentialStore::Auto),
157 2 => Some(CredentialStore::Keyring),
158 3 => Some(CredentialStore::File),
159 _ => None,
160 }
161}
162
163pub(crate) fn set_credential_store_flag(store: Option<CredentialStore>) {
169 CREDENTIAL_STORE_FLAG.with(|f| f.set(encode_store(store)));
170}
171
172pub(crate) fn clear_credential_store_flag() {
177 CREDENTIAL_STORE_FLAG.with(|f| f.set(0));
178}
179
180#[must_use]
183pub(crate) fn credential_store_flag() -> Option<CredentialStore> {
184 CREDENTIAL_STORE_FLAG.with(|f| decode_store(f.get()))
185}
186
187#[must_use]
190pub fn credential_store_env_var(app_id: &str) -> String {
191 format!(
192 "{}_CREDENTIAL_STORE",
193 crate::flags::app_id_env_prefix(app_id)
194 )
195}
196
197#[must_use]
200pub fn config_file_path(app_id: &str) -> Option<PathBuf> {
201 if !crate::fs::is_safe_path_component(app_id) {
202 tracing::warn!(app_id, "refusing config path with unsafe app id");
203 return None;
204 }
205 crate::fs::config_base_dir().map(|base| base.join(app_id).join("config.toml"))
206}
207
208#[must_use]
214pub fn load(app_id: &str) -> EngineConfig {
215 ConfigFile::load(app_id).engine()
216}
217
218#[derive(Clone, Debug)]
237pub struct ConfigFile {
238 path: Option<PathBuf>,
239 doc: DocumentMut,
240}
241
242impl Default for ConfigFile {
243 fn default() -> Self {
244 Self::from_doc(None, DocumentMut::new())
245 }
246}
247
248impl ConfigFile {
249 fn from_doc(path: Option<PathBuf>, doc: DocumentMut) -> Self {
250 Self { path, doc }
251 }
252
253 #[must_use]
265 pub fn load(app_id: &str) -> Self {
266 let path = config_file_path(app_id);
267 let doc = match &path {
268 None => DocumentMut::new(),
269 Some(p) => match std::fs::read_to_string(p) {
270 Ok(contents) => contents.parse::<DocumentMut>().unwrap_or_else(|e| {
271 tracing::warn!(path = %p.display(), error = %e, "ignoring malformed config file");
272 DocumentMut::new()
273 }),
274 Err(e) if e.kind() == std::io::ErrorKind::NotFound => DocumentMut::new(),
275 Err(e) => {
276 tracing::warn!(path = %p.display(), error = %e, "could not read config file");
277 DocumentMut::new()
278 }
279 },
280 };
281 Self::from_doc(path, doc)
282 }
283
284 #[must_use]
288 pub fn path(&self) -> Option<&Path> {
289 self.path.as_deref()
290 }
291
292 #[must_use]
297 pub fn engine(&self) -> EngineConfig {
298 self.deserialize().unwrap_or_default()
299 }
300
301 pub fn section<T: DeserializeOwned>(&self, name: &str) -> crate::Result<Option<T>> {
311 let item = match self.doc.get(name) {
312 None => return Ok(None),
313 Some(item) => item,
314 };
315 let mut tmp = DocumentMut::new();
318 if let Some(tbl) = item.as_table_like() {
319 for (k, v) in tbl.iter() {
320 tmp[k] = v.clone();
321 }
322 }
323 toml_edit::de::from_document::<T>(tmp)
324 .map(Some)
325 .map_err(|e| CliCoreError::message(format!("config section {name:?}: {e}")))
326 }
327
328 pub fn deserialize<T: DeserializeOwned>(&self) -> crate::Result<T> {
336 toml_edit::de::from_document::<T>(self.doc.clone())
337 .map_err(|e| CliCoreError::message(format!("config deserialize error: {e}")))
338 }
339
340 #[must_use]
345 pub fn get(&self, dotted_key: &str) -> Option<String> {
346 let mut item = self.doc.as_item();
347 for segment in dotted_key.split('.') {
348 item = item.as_table_like()?.get(segment)?;
349 }
350 match item.as_value() {
351 Some(toml_edit::Value::String(s)) => Some(s.value().clone()),
352 Some(other) => Some(other.to_string().trim().to_owned()),
353 None => Some(item.to_string()),
354 }
355 }
356
357 pub fn set(&mut self, dotted_key: &str, value: &str) -> crate::Result<()> {
380 const ENGINE_RESERVED_TABLES: &[&str] = &["credentials"];
385 let first_segment = dotted_key.split('.').next().unwrap_or("");
386 if ENGINE_RESERVED_TABLES.contains(&first_segment) {
387 match dotted_key {
388 "credentials.store" => {
389 value
390 .parse::<CredentialStore>()
391 .map_err(|e| CliCoreError::message(e.to_string()))?;
392 }
393 other => {
394 return Err(CliCoreError::message(format!(
395 "unknown engine-reserved key {other:?}; \
396 the only supported key in [credentials] is \"credentials.store\""
397 )));
398 }
399 }
400 }
401 let segments: Vec<&str> = dotted_key.split('.').collect();
402 if segments.iter().any(|s| s.is_empty()) {
403 return Err(CliCoreError::message(format!(
404 "invalid config key {dotted_key:?}"
405 )));
406 }
407 let Some((last, parents)) = segments.split_last() else {
408 return Err(CliCoreError::message("empty config key"));
409 };
410 let mut table = self.doc.as_table_mut();
411 for segment in parents {
412 let entry = table
413 .entry(segment)
414 .or_insert(toml_edit::Item::Table(toml_edit::Table::new()));
415 table = entry.as_table_mut().ok_or_else(|| {
416 CliCoreError::message(format!("config key {segment:?} is not a table"))
417 })?;
418 }
419 table[last] = toml_edit::Item::Value(infer_toml_value(value));
420 Ok(())
421 }
422
423 #[must_use]
426 pub fn to_toml_string(&self) -> String {
427 self.doc.to_string()
428 }
429
430 pub fn save(&self) -> crate::Result<()> {
436 let path = self.path.as_ref().ok_or_else(|| {
437 CliCoreError::message(
438 "no config path available (set XDG_CONFIG_HOME, HOME, or %APPDATA% \
439 to a directory)",
440 )
441 })?;
442 crate::fs::write_string_atomic(path, &self.doc.to_string())
443 }
444}
445
446fn infer_toml_value(value: &str) -> toml_edit::Value {
448 if let Ok(b) = value.parse::<bool>() {
449 return b.into();
450 }
451 if let Ok(i) = value.parse::<i64>() {
452 return i.into();
453 }
454 if let Ok(f) = value.parse::<f64>() {
455 return f.into();
456 }
457 value.into()
458}
459
460#[must_use]
467pub fn resolve_credential_store_with(
468 flag: Option<CredentialStore>,
469 env: Option<&str>,
470 file: &EngineConfig,
471) -> CredentialStore {
472 if let Some(store) = flag {
473 return store;
474 }
475 if let Some(raw) = env {
476 match raw.parse::<CredentialStore>() {
477 Ok(store) => return store,
478 Err(e) => tracing::warn!(error = %e, "ignoring invalid credential-store env var"),
479 }
480 }
481 if let Some(store) = file.credentials.store {
482 return store;
483 }
484 CredentialStore::default()
485}
486
487pub fn resolve_credential_store(
495 app_id: &str,
496 var: impl Fn(&str) -> Option<String>,
497) -> CredentialStore {
498 let env = var(&credential_store_env_var(app_id));
499 let file = load(app_id);
500 resolve_credential_store_with(credential_store_flag(), env.as_deref(), &file)
501}
502
503#[cfg(test)]
510#[allow(unsafe_code, dead_code)]
511pub(crate) mod test_env {
512 use std::path::Path;
513 use std::sync::{Mutex, MutexGuard};
514
515 pub(crate) static XDG_TEST_MUTEX: Mutex<()> = Mutex::new(());
517
518 pub(crate) fn lock() -> MutexGuard<'static, ()> {
523 XDG_TEST_MUTEX.lock().unwrap_or_else(|e| e.into_inner())
524 }
525
526 pub(crate) struct EnvVarGuard {
529 key: &'static str,
530 prev: Option<String>,
531 }
532
533 impl EnvVarGuard {
534 pub(crate) fn set(key: &'static str, value: Option<&Path>) -> Self {
537 let prev = std::env::var(key).ok();
538 unsafe {
540 match value {
541 Some(v) => std::env::set_var(key, v),
542 None => std::env::remove_var(key),
543 }
544 }
545 Self { key, prev }
546 }
547 }
548
549 impl Drop for EnvVarGuard {
550 fn drop(&mut self) {
551 unsafe {
553 match self.prev.take() {
554 Some(v) => std::env::set_var(self.key, v),
555 None => std::env::remove_var(self.key),
556 }
557 }
558 }
559 }
560
561 pub(crate) fn with_xdg_config_home<F: FnOnce() -> R, R>(value: &Path, f: F) -> R {
564 let _lock = lock();
565 let _restore = EnvVarGuard::set("XDG_CONFIG_HOME", Some(value));
566 f()
567 }
568}
569
570#[cfg(test)]
571mod tests {
572 use super::*;
573
574 #[test]
575 fn parses_known_variants_case_insensitively() {
576 assert_eq!("auto".parse(), Ok(CredentialStore::Auto));
577 assert_eq!("Keyring".parse(), Ok(CredentialStore::Keyring));
578 assert_eq!("KEYCHAIN".parse(), Ok(CredentialStore::Keyring));
579 assert_eq!(" file ".parse(), Ok(CredentialStore::File));
580 }
581
582 #[test]
583 fn rejects_unknown_variant() {
584 let err = "vault"
585 .parse::<CredentialStore>()
586 .expect_err("should reject");
587 assert!(err.to_string().contains("vault"));
588 }
589
590 #[test]
591 fn display_round_trips_through_from_str() {
592 for store in [
593 CredentialStore::Auto,
594 CredentialStore::Keyring,
595 CredentialStore::File,
596 ] {
597 assert_eq!(store.to_string().parse(), Ok(store));
598 }
599 }
600
601 #[test]
602 fn env_var_name_is_derived_from_app_id() {
603 assert_eq!(
604 credential_store_env_var("godaddy"),
605 "GODADDY_CREDENTIAL_STORE"
606 );
607 assert_eq!(
608 credential_store_env_var("my-cli"),
609 "MY_CLI_CREDENTIAL_STORE"
610 );
611 }
612
613 #[test]
614 fn deserializes_store_from_toml() {
615 let config: EngineConfig =
616 toml_edit::de::from_str("[credentials]\nstore = \"file\"\n").expect("valid toml");
617 assert_eq!(config.credentials.store, Some(CredentialStore::File));
618 }
619
620 #[test]
621 fn deserialize_rejects_bad_store_value() {
622 let result = toml_edit::de::from_str::<EngineConfig>("[credentials]\nstore = \"nope\"\n");
623 assert!(result.is_err(), "bad store value should fail to parse");
624 }
625
626 #[test]
627 fn unknown_keys_are_ignored() {
628 let config: EngineConfig =
629 toml_edit::de::from_str("future_section = true\n[credentials]\nstore = \"auto\"\n")
630 .expect("unknown keys tolerated");
631 assert_eq!(config.credentials.store, Some(CredentialStore::Auto));
632 }
633
634 #[test]
635 fn resolution_precedence_flag_beats_env_beats_file() {
636 let file = EngineConfig {
637 credentials: CredentialsConfig {
638 store: Some(CredentialStore::Keyring),
639 },
640 };
641 assert_eq!(
643 resolve_credential_store_with(Some(CredentialStore::Auto), Some("file"), &file),
644 CredentialStore::Auto
645 );
646 assert_eq!(
648 resolve_credential_store_with(None, Some("file"), &file),
649 CredentialStore::File
650 );
651 assert_eq!(
653 resolve_credential_store_with(None, None, &file),
654 CredentialStore::Keyring
655 );
656 }
657
658 #[test]
659 fn resolution_defaults_to_keyring() {
660 assert_eq!(
661 resolve_credential_store_with(None, None, &EngineConfig::default()),
662 CredentialStore::Keyring
663 );
664 }
665
666 #[test]
667 fn resolution_ignores_invalid_env_and_falls_through() {
668 let file = EngineConfig {
669 credentials: CredentialsConfig {
670 store: Some(CredentialStore::File),
671 },
672 };
673 assert_eq!(
675 resolve_credential_store_with(None, Some("garbage"), &file),
676 CredentialStore::File
677 );
678 assert_eq!(
680 resolve_credential_store_with(None, Some("garbage"), &EngineConfig::default()),
681 CredentialStore::Keyring
682 );
683 }
684
685 #[test]
686 fn config_file_path_rejects_unsafe_app_id() {
687 assert_eq!(config_file_path("../evil"), None);
688 assert_eq!(config_file_path("a/b"), None);
689 }
690
691 #[test]
692 fn credential_store_flag_encodes_round_trips() {
693 for store in [
694 None,
695 Some(CredentialStore::Auto),
696 Some(CredentialStore::Keyring),
697 Some(CredentialStore::File),
698 ] {
699 assert_eq!(decode_store(encode_store(store)), store);
700 }
701 }
702
703 #[test]
704 fn config_file_path_uses_xdg_config_home() {
705 let dir = std::env::temp_dir().join("cli-engine-config-path-test");
706 test_env::with_xdg_config_home(&dir, || {
707 assert_eq!(
708 config_file_path("myapp"),
709 Some(dir.join("myapp").join("config.toml"))
710 );
711 });
712 }
713
714 #[derive(Debug, Deserialize, PartialEq)]
715 struct Deploy {
716 region: String,
717 replicas: u32,
718 }
719
720 fn doc_config(toml: &str) -> ConfigFile {
721 ConfigFile::from_doc(None, toml.parse().expect("valid toml"))
722 }
723
724 #[test]
725 fn section_reads_consumer_table() {
726 let cfg = doc_config("[deploy]\nregion = \"us-west\"\nreplicas = 3\n");
727 let deploy: Deploy = cfg.section("deploy").expect("ok").expect("present");
728 assert_eq!(
729 deploy,
730 Deploy {
731 region: "us-west".to_owned(),
732 replicas: 3
733 }
734 );
735 assert!(cfg.section::<Deploy>("absent").expect("ok").is_none());
736 }
737
738 #[test]
739 fn engine_and_consumer_sections_coexist() {
740 let cfg = doc_config(
741 "[credentials]\nstore = \"file\"\n[deploy]\nregion = \"eu\"\nreplicas = 1\n",
742 );
743 assert_eq!(cfg.engine().credentials.store, Some(CredentialStore::File));
744 assert_eq!(
745 cfg.section::<Deploy>("deploy")
746 .expect("ok")
747 .expect("present")
748 .region,
749 "eu"
750 );
751 }
752
753 #[test]
754 fn get_reads_dotted_scalar() {
755 let cfg = doc_config("[credentials]\nstore = \"file\"\n[deploy]\nreplicas = 3\n");
756 assert_eq!(cfg.get("credentials.store").as_deref(), Some("file"));
757 assert_eq!(cfg.get("deploy.replicas").as_deref(), Some("3"));
758 assert_eq!(cfg.get("deploy.missing"), None);
759 assert_eq!(cfg.get("nope.at.all"), None);
760 }
761
762 #[test]
763 fn set_infers_scalar_types() {
764 let mut cfg = ConfigFile::default();
765 cfg.set("telemetry.enabled", "true").expect("set bool");
766 cfg.set("deploy.replicas", "5").expect("set int");
767 cfg.set("deploy.region", "us-west").expect("set str");
768 assert_eq!(cfg.get("telemetry.enabled").as_deref(), Some("true"));
769 assert_eq!(cfg.get("deploy.replicas").as_deref(), Some("5"));
770 assert_eq!(cfg.get("deploy.region").as_deref(), Some("us-west"));
771 assert!(cfg.doc.to_string().contains("enabled = true"));
773 assert!(cfg.doc.to_string().contains("replicas = 5"));
774 }
775
776 #[test]
777 fn set_validates_engine_store_key() {
778 let mut cfg = ConfigFile::default();
779 assert!(cfg.set("credentials.store", "bogus").is_err());
780 assert!(cfg.set("credentials.store", "file").is_ok());
781 assert_eq!(cfg.engine().credentials.store, Some(CredentialStore::File));
782 }
783
784 #[test]
785 fn set_rejects_unknown_engine_reserved_keys() {
786 let mut cfg = ConfigFile::default();
787 assert!(
789 cfg.set("credentials.unknown_future_key", "foo").is_err(),
790 "unknown credentials key should be rejected"
791 );
792 assert!(
793 cfg.set("credentials.timeout", "30").is_err(),
794 "unknown credentials.timeout should be rejected"
795 );
796 assert!(
798 cfg.set("deploy.region", "us-west").is_ok(),
799 "consumer-owned keys should be accepted"
800 );
801 }
802
803 #[test]
804 fn set_rejects_empty_key_segments() {
805 let mut cfg = ConfigFile::default();
806 assert!(cfg.set("a..b", "x").is_err());
807 assert!(cfg.set("", "x").is_err());
808 }
809
810 #[test]
811 fn set_preserves_comments_and_other_tables() {
812 let mut cfg =
813 doc_config("# keep me\n[credentials]\nstore = \"file\"\n\n[deploy]\nregion = \"us\"\n");
814 cfg.set("deploy.region", "eu").expect("set");
815 let rendered = cfg.doc.to_string();
816 assert!(
817 rendered.contains("# keep me"),
818 "comment preserved: {rendered}"
819 );
820 assert!(
821 rendered.contains("store = \"file\""),
822 "other table preserved"
823 );
824 assert!(rendered.contains("region = \"eu\""), "value updated");
825 }
826
827 #[test]
828 fn load_and_save_round_trip() {
829 let dir = tempfile::tempdir().expect("tempdir");
830 test_env::with_xdg_config_home(dir.path(), || {
831 let mut cfg = ConfigFile::load("roundtrip");
832 assert!(cfg.path().is_some());
833 cfg.set("deploy.region", "us-west").expect("set");
834 cfg.save().expect("save");
835 let reloaded = ConfigFile::load("roundtrip");
837 assert_eq!(reloaded.get("deploy.region").as_deref(), Some("us-west"));
838 });
839 }
840
841 #[test]
842 fn malformed_file_loads_as_empty() {
843 let dir = tempfile::tempdir().expect("tempdir");
844 test_env::with_xdg_config_home(dir.path(), || {
845 let path = config_file_path("broken").expect("path");
846 std::fs::create_dir_all(path.parent().expect("parent")).expect("mkdir");
847 std::fs::write(&path, "not = valid = toml").expect("write");
848 let cfg = ConfigFile::load("broken");
849 assert_eq!(cfg.engine().credentials.store, None);
850 assert_eq!(cfg.get("anything"), None);
851 });
852 }
853
854 #[test]
855 fn default_config_has_no_path_and_save_errors() {
856 let cfg = ConfigFile::default();
857 assert!(cfg.path().is_none());
858 assert!(cfg.save().is_err(), "save without a path should error");
859 }
860}