1use std::fs;
31use std::path::PathBuf;
32
33use anyhow::{anyhow, Context, Result};
34
35use serde::{Deserialize, Serialize};
36
37#[derive(Debug, Clone, Serialize, Deserialize)]
43pub enum AuthConfig {
44 Basic {
46 username: String,
48 password: String,
50 },
51 Bearer {
53 token: String,
55 },
56 ApiKey {
58 header: String,
60 key: String,
62 },
63}
64
65#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
67pub struct ExtrasDefaults {
68 pub include_related_roms: bool,
70 pub include_cover: bool,
72 pub include_manual: bool,
74}
75
76impl Default for ExtrasDefaults {
77 fn default() -> Self {
78 Self {
79 include_related_roms: true,
80 include_cover: true,
81 include_manual: true,
82 }
83 }
84}
85
86#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
88pub struct SaveSyncConfig {
89 #[serde(default)]
91 pub save_dir: Option<String>,
92 #[serde(default)]
94 pub device_id: Option<String>,
95}
96
97#[derive(Debug, Clone, Serialize, Deserialize)]
102pub struct Config {
103 pub base_url: String,
105 pub download_dir: String,
107 pub use_https: bool,
109 pub auth: Option<AuthConfig>,
111 #[serde(default)]
113 pub extras_defaults: ExtrasDefaults,
114 #[serde(default)]
116 pub save_sync: SaveSyncConfig,
117}
118
119pub fn resolved_save_dir(config: &Config) -> PathBuf {
120 config
121 .save_sync
122 .save_dir
123 .as_deref()
124 .map(str::trim)
125 .filter(|s| !s.is_empty())
126 .map(PathBuf::from)
127 .unwrap_or_else(|| PathBuf::from(&config.download_dir).join("saves"))
128}
129
130fn is_placeholder(value: &str) -> bool {
131 value.contains("your-") || value.contains("placeholder") || value.trim().is_empty()
132}
133
134pub const KEYRING_SECRET_PLACEHOLDER: &str = "<stored-in-keyring>";
136
137pub fn is_keyring_placeholder(s: &str) -> bool {
139 s == KEYRING_SECRET_PLACEHOLDER
140}
141
142pub fn normalize_romm_origin(url: &str) -> String {
152 let mut s = url.trim().trim_end_matches('/').to_string();
153 if s.ends_with("/api") {
154 s.truncate(s.len() - 4);
155 }
156 s.trim_end_matches('/').to_string()
157}
158
159const KEYRING_SERVICE: &str = "romm-cli";
164
165pub fn keyring_store(key: &str, value: &str) -> Result<()> {
170 let entry = keyring::Entry::new(KEYRING_SERVICE, key)
171 .map_err(|e| anyhow!("keyring entry error: {e}"))?;
172 entry
173 .set_password(value)
174 .map_err(|e| anyhow!("keyring set error: {e}"))
175}
176
177fn keyring_get_password_result(key: &str, result: keyring::Result<String>) -> Option<String> {
180 match result {
181 Ok(s) => Some(s),
182 Err(keyring::Error::NoEntry) => None,
183 Err(e) => {
184 tracing::warn!("keyring get_password for key {key}: {e}");
185 None
186 }
187 }
188}
189
190pub(crate) fn keyring_get(key: &str) -> Option<String> {
194 let entry = match keyring::Entry::new(KEYRING_SERVICE, key) {
195 Ok(e) => e,
196 Err(e) => {
197 tracing::warn!("keyring Entry::new for key {key}: {e}");
198 return None;
199 }
200 };
201 keyring_get_password_result(key, entry.get_password())
202}
203
204fn keyring_verify_read_back_matches(key: &str, expected: &str) -> bool {
207 let entry = match keyring::Entry::new(KEYRING_SERVICE, key) {
208 Ok(e) => e,
209 Err(e) => {
210 tracing::warn!(
211 "keyring verify: Entry::new for key {key} after successful store: {e}; writing plaintext to config.json"
212 );
213 return false;
214 }
215 };
216 match entry.get_password() {
217 Ok(read) if read == expected => true,
218 Ok(_) => {
219 tracing::warn!(
220 "keyring verify: read-back for key {key} did not match; writing plaintext to config.json"
221 );
222 false
223 }
224 Err(e) => {
225 tracing::warn!(
226 "keyring verify: get_password for key {key} after successful store: {e}; writing plaintext to config.json"
227 );
228 false
229 }
230 }
231}
232
233pub fn user_config_dir() -> Option<PathBuf> {
239 if let Ok(dir) = std::env::var("ROMM_TEST_CONFIG_DIR") {
240 return Some(PathBuf::from(dir));
241 }
242 dirs::config_dir().map(|d| d.join("romm-cli"))
243}
244
245pub fn user_config_json_path() -> Option<PathBuf> {
247 user_config_dir().map(|d| d.join("config.json"))
248}
249
250pub fn read_user_config_json_from_disk() -> Option<Config> {
253 let path = user_config_json_path()?;
254 let content = std::fs::read_to_string(path).ok()?;
255 serde_json::from_str(&content).ok()
256}
257
258pub fn auth_for_persist_merge(in_memory: Option<AuthConfig>) -> Option<AuthConfig> {
264 in_memory.or_else(|| read_user_config_json_from_disk().and_then(|c| c.auth))
265}
266
267pub fn openapi_cache_path() -> Result<PathBuf> {
271 if let Ok(p) = std::env::var("ROMM_OPENAPI_PATH") {
272 return Ok(PathBuf::from(p));
273 }
274 let dir = user_config_dir().ok_or_else(|| {
275 anyhow!("Could not resolve config directory. Set ROMM_OPENAPI_PATH to store openapi.json.")
276 })?;
277 Ok(dir.join("openapi.json"))
278}
279
280fn env_nonempty(key: &str) -> Option<String> {
285 std::env::var(key).ok().filter(|s| !s.trim().is_empty())
286}
287
288pub fn should_check_updates() -> bool {
293 match std::env::var("ROMM_CHECK_UPDATES") {
294 Ok(value) => {
295 let normalized = value.trim().to_ascii_lowercase();
296 !matches!(normalized.as_str(), "0" | "false" | "no" | "off")
297 }
298 Err(_) => true,
299 }
300}
301
302const MAX_TOKEN_FILE_BYTES: usize = 64 * 1024;
304
305fn token_from_env_or_file() -> Result<Option<String>> {
307 if let Some(t) = env_nonempty("API_TOKEN") {
308 return Ok(Some(t));
309 }
310 let path = env_nonempty("ROMM_TOKEN_FILE").or_else(|| env_nonempty("API_TOKEN_FILE"));
311 let Some(path) = path else {
312 return Ok(None);
313 };
314 let path = path.trim();
315 let bytes = fs::read(path).with_context(|| format!("read bearer token file {path}"))?;
316 if bytes.len() > MAX_TOKEN_FILE_BYTES {
317 return Err(anyhow!(
318 "bearer token file exceeds max size of {} bytes",
319 MAX_TOKEN_FILE_BYTES
320 ));
321 }
322 let s = String::from_utf8(bytes)
323 .map_err(|e| anyhow!("bearer token file must be valid UTF-8: {e}"))?;
324 let t = s.trim();
325 if t.is_empty() {
326 return Err(anyhow!(
327 "bearer token file is empty after trimming whitespace"
328 ));
329 }
330 Ok(Some(t.to_string()))
331}
332
333pub fn disk_has_unresolved_keyring_sentinel(config: &Config) -> bool {
336 if config.auth.is_some() {
337 return false;
338 }
339 let Some(disk) = read_user_config_json_from_disk() else {
340 return false;
341 };
342 match &disk.auth {
343 Some(AuthConfig::Bearer { token }) => is_keyring_placeholder(token),
344 Some(AuthConfig::Basic { password, .. }) => is_keyring_placeholder(password),
345 Some(AuthConfig::ApiKey { key, .. }) => is_keyring_placeholder(key),
346 None => false,
347 }
348}
349
350pub fn load_config() -> Result<Config> {
361 let mut json_config = None;
363 if let Some(path) = user_config_json_path() {
364 if path.is_file() {
365 if let Ok(content) = std::fs::read_to_string(&path) {
366 if let Ok(config) = serde_json::from_str::<Config>(&content) {
367 json_config = Some(config);
368 }
369 }
370 }
371 }
372
373 let base_raw = env_nonempty("API_BASE_URL")
375 .or_else(|| json_config.as_ref().map(|c| c.base_url.clone()))
376 .ok_or_else(|| {
377 anyhow!(
378 "API_BASE_URL is not set. Set it in the environment, a config.json file, or run: romm-cli init"
379 )
380 })?;
381 let mut base_url = normalize_romm_origin(&base_raw);
382
383 let download_dir = env_nonempty("ROMM_ROMS_DIR")
385 .or_else(|| env_nonempty("ROMM_DOWNLOAD_DIR"))
386 .or_else(|| json_config.as_ref().map(|c| c.download_dir.clone()))
387 .unwrap_or_else(|| {
388 dirs::download_dir()
389 .unwrap_or_else(|| dirs::home_dir().unwrap_or_default().join("Downloads"))
390 .join("romm-cli")
391 .display()
392 .to_string()
393 });
394
395 let use_https = if let Ok(s) = std::env::var("API_USE_HTTPS") {
397 s.to_lowercase() == "true"
398 } else if let Some(c) = &json_config {
399 c.use_https
400 } else {
401 true
402 };
403
404 if use_https && base_url.starts_with("http://") {
405 base_url = base_url.replace("http://", "https://");
406 }
407
408 let mut username = env_nonempty("API_USERNAME");
410 let mut password = env_nonempty("API_PASSWORD");
411 let mut token = token_from_env_or_file()?;
412 let mut api_key = env_nonempty("API_KEY");
413 let mut api_key_header = env_nonempty("API_KEY_HEADER");
414
415 if let Some(c) = &json_config {
416 if let Some(auth) = &c.auth {
417 match auth {
418 AuthConfig::Basic {
419 username: u,
420 password: p,
421 } => {
422 if username.is_none() {
423 username = Some(u.clone());
424 }
425 if password.is_none() {
426 password = Some(p.clone());
427 }
428 }
429 AuthConfig::Bearer { token: t } => {
430 if token.is_none() {
431 token = Some(t.clone());
432 }
433 }
434 AuthConfig::ApiKey { header: h, key: k } => {
435 if api_key_header.is_none() {
436 api_key_header = Some(h.clone());
437 }
438 if api_key.is_none() {
439 api_key = Some(k.clone());
440 }
441 }
442 }
443 }
444 }
445
446 if let Some(p) = &password {
448 if is_placeholder(p) || is_keyring_placeholder(p) {
449 if let Some(k) = keyring_get("API_PASSWORD") {
450 password = Some(k);
451 }
452 }
453 } else {
454 password = keyring_get("API_PASSWORD");
455 }
456
457 if let Some(t) = &token {
458 if is_placeholder(t) || is_keyring_placeholder(t) {
459 if let Some(k) = keyring_get("API_TOKEN") {
460 token = Some(k);
461 }
462 }
463 } else {
464 token = keyring_get("API_TOKEN");
465 }
466
467 if let Some(k) = &api_key {
468 if is_placeholder(k) || is_keyring_placeholder(k) {
469 if let Some(kr) = keyring_get("API_KEY") {
470 api_key = Some(kr);
471 }
472 }
473 } else {
474 api_key = keyring_get("API_KEY");
475 }
476
477 if let Some(ref p) = password {
478 if is_keyring_placeholder(p) {
479 tracing::warn!(
480 "Could not read API_PASSWORD from the OS keyring; value is still <stored-in-keyring>. \
481 On Windows, look for a Generic credential with target API_PASSWORD.romm-cli."
482 );
483 }
484 }
485 if let Some(ref t) = token {
486 if is_keyring_placeholder(t) {
487 tracing::warn!(
488 "Could not read API_TOKEN from the OS keyring; value is still <stored-in-keyring>. \
489 On Windows, look for a Generic credential with target API_TOKEN.romm-cli."
490 );
491 }
492 }
493 if let Some(ref k) = api_key {
494 if is_keyring_placeholder(k) {
495 tracing::warn!(
496 "Could not read API_KEY from the OS keyring; value is still <stored-in-keyring>. \
497 On Windows, look for a Generic credential with target API_KEY.romm-cli."
498 );
499 }
500 }
501
502 let auth = if let (Some(user), Some(pass)) = (username, password) {
503 if !is_placeholder(&pass) && !is_keyring_placeholder(&pass) {
504 Some(AuthConfig::Basic {
505 username: user,
506 password: pass,
507 })
508 } else {
509 None
510 }
511 } else if let (Some(key), Some(header)) = (api_key, api_key_header) {
512 if !is_placeholder(&key) && !is_keyring_placeholder(&key) {
513 Some(AuthConfig::ApiKey { header, key })
514 } else {
515 None
516 }
517 } else if let Some(tok) = token {
518 if !is_placeholder(&tok) && !is_keyring_placeholder(&tok) {
519 Some(AuthConfig::Bearer { token: tok })
520 } else {
521 None
522 }
523 } else {
524 None
525 };
526
527 let extras_defaults = json_config
528 .as_ref()
529 .map(|c| c.extras_defaults.clone())
530 .unwrap_or_default();
531 let save_sync = json_config
532 .as_ref()
533 .map(|c| c.save_sync.clone())
534 .unwrap_or_default();
535
536 Ok(Config {
537 base_url,
538 download_dir,
539 use_https,
540 auth,
541 extras_defaults,
542 save_sync,
543 })
544}
545
546pub fn persist_user_config(config: &Config) -> Result<()> {
557 let Some(path) = user_config_json_path() else {
558 return Err(anyhow!(
559 "Could not determine config directory (no HOME / APPDATA?)."
560 ));
561 };
562 let dir = path
563 .parent()
564 .ok_or_else(|| anyhow!("invalid config path"))?;
565 std::fs::create_dir_all(dir).with_context(|| format!("create {}", dir.display()))?;
566
567 let mut config_to_save = config.clone();
568
569 match &mut config_to_save.auth {
570 None => {}
571 Some(AuthConfig::Basic { password, .. }) => {
572 if is_keyring_placeholder(password) {
573 tracing::debug!(
574 "skip keyring store for API_PASSWORD: value is keyring sentinel; leaving disk sentinel unchanged"
575 );
576 } else if let Err(e) = keyring_store("API_PASSWORD", password) {
577 tracing::warn!("keyring store API_PASSWORD: {e}; writing plaintext to config.json");
578 } else if keyring_verify_read_back_matches("API_PASSWORD", password.as_str()) {
579 *password = KEYRING_SECRET_PLACEHOLDER.to_string();
580 }
581 }
582 Some(AuthConfig::Bearer { token }) => {
583 if is_keyring_placeholder(token) {
584 tracing::debug!(
585 "skip keyring store for API_TOKEN: value is keyring sentinel; leaving disk sentinel unchanged"
586 );
587 } else if let Err(e) = keyring_store("API_TOKEN", token) {
588 tracing::warn!("keyring store API_TOKEN: {e}; writing plaintext to config.json");
589 } else if keyring_verify_read_back_matches("API_TOKEN", token.as_str()) {
590 *token = KEYRING_SECRET_PLACEHOLDER.to_string();
591 }
592 }
593 Some(AuthConfig::ApiKey { key, .. }) => {
594 if is_keyring_placeholder(key) {
595 tracing::debug!(
596 "skip keyring store for API_KEY: value is keyring sentinel; leaving disk sentinel unchanged"
597 );
598 } else if let Err(e) = keyring_store("API_KEY", key) {
599 tracing::warn!("keyring store API_KEY: {e}; writing plaintext to config.json");
600 } else if keyring_verify_read_back_matches("API_KEY", key.as_str()) {
601 *key = KEYRING_SECRET_PLACEHOLDER.to_string();
602 }
603 }
604 }
605
606 let content = serde_json::to_string_pretty(&config_to_save)?;
607 {
608 use std::io::Write;
609 let mut f =
610 std::fs::File::create(&path).with_context(|| format!("write {}", path.display()))?;
611 f.write_all(content.as_bytes())?;
612 }
613
614 #[cfg(unix)]
615 {
616 use std::os::unix::fs::PermissionsExt;
617 let mut perms = std::fs::metadata(&path)?.permissions();
618 perms.set_mode(0o600);
619 std::fs::set_permissions(&path, perms)?;
620 }
621
622 Ok(())
623}
624
625pub fn reset_all_settings() -> Result<()> {
627 if let Some(path) = user_config_json_path() {
628 if path.exists() {
629 let _ = std::fs::remove_file(&path);
630 }
631 }
632 for key in ["API_PASSWORD", "API_TOKEN", "API_KEY"] {
633 if let Ok(entry) = keyring::Entry::new(KEYRING_SERVICE, key) {
634 let _ = entry.delete_credential();
635 }
636 }
637 Ok(())
638}
639
640#[cfg(test)]
641pub(crate) fn test_env_lock() -> &'static std::sync::Mutex<()> {
642 use std::sync::{Mutex, OnceLock};
643 static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
644 LOCK.get_or_init(|| Mutex::new(()))
645}
646
647#[cfg(test)]
648mod tests {
649 use super::*;
650 use std::sync::MutexGuard;
651
652 #[test]
653 fn keyring_get_password_result_ok() {
654 assert_eq!(
655 super::keyring_get_password_result("API_TOKEN", Ok("secret".into())),
656 Some("secret".into())
657 );
658 }
659
660 #[test]
661 fn keyring_get_password_result_no_entry_is_none() {
662 assert_eq!(
663 super::keyring_get_password_result("API_TOKEN", Err(keyring::Error::NoEntry)),
664 None
665 );
666 }
667
668 struct TestEnv {
669 _guard: MutexGuard<'static, ()>,
670 config_dir: PathBuf,
671 }
672
673 impl TestEnv {
674 fn new() -> Self {
675 let guard = super::test_env_lock()
676 .lock()
677 .unwrap_or_else(|e| e.into_inner());
678 clear_auth_env();
679
680 let ts = std::time::SystemTime::now()
681 .duration_since(std::time::UNIX_EPOCH)
682 .unwrap()
683 .as_nanos();
684 let config_dir = std::env::temp_dir().join(format!("romm-config-test-{ts}"));
685 std::fs::create_dir_all(&config_dir).unwrap();
686 std::env::set_var("ROMM_TEST_CONFIG_DIR", &config_dir);
687
688 Self {
689 _guard: guard,
690 config_dir,
691 }
692 }
693 }
694
695 impl Drop for TestEnv {
696 fn drop(&mut self) {
697 clear_auth_env();
698 std::env::remove_var("ROMM_TEST_CONFIG_DIR");
699 let _ = std::fs::remove_dir_all(&self.config_dir);
700 }
701 }
702
703 fn clear_auth_env() {
704 for key in [
705 "API_BASE_URL",
706 "ROMM_ROMS_DIR",
707 "API_USERNAME",
708 "API_PASSWORD",
709 "API_TOKEN",
710 "ROMM_TOKEN_FILE",
711 "API_TOKEN_FILE",
712 "API_KEY",
713 "API_KEY_HEADER",
714 "API_USE_HTTPS",
715 "ROMM_TEST_CONFIG_DIR",
716 ] {
717 std::env::remove_var(key);
718 }
719 }
720
721 #[test]
722 fn prefers_basic_auth_over_other_modes() {
723 let _env = TestEnv::new();
724 std::env::set_var("API_BASE_URL", "http://example.test");
725 std::env::set_var("API_USERNAME", "user");
726 std::env::set_var("API_PASSWORD", "pass");
727 std::env::set_var("API_TOKEN", "token");
728 std::env::set_var("API_KEY", "apikey");
729 std::env::set_var("API_KEY_HEADER", "X-Api-Key");
730
731 let cfg = load_config().expect("config should load");
732 match cfg.auth {
733 Some(AuthConfig::Basic { username, password }) => {
734 assert_eq!(username, "user");
735 assert_eq!(password, "pass");
736 }
737 _ => panic!("expected basic auth"),
738 }
739 }
740
741 #[test]
742 fn uses_api_key_header_when_token_missing() {
743 let _env = TestEnv::new();
744 std::env::set_var("API_BASE_URL", "http://example.test");
745 std::env::set_var("API_KEY", "real-key");
746 std::env::set_var("API_KEY_HEADER", "X-Api-Key");
747
748 let cfg = load_config().expect("config should load");
749 match cfg.auth {
750 Some(AuthConfig::ApiKey { header, key }) => {
751 assert_eq!(header, "X-Api-Key");
752 assert_eq!(key, "real-key");
753 }
754 _ => panic!("expected api key auth"),
755 }
756 }
757
758 #[test]
759 fn normalizes_api_base_url_and_enforces_https_by_default() {
760 let _env = TestEnv::new();
761 std::env::set_var("API_BASE_URL", "http://romm.example/api/");
762 let cfg = load_config().expect("config");
763 assert_eq!(cfg.base_url, "https://romm.example");
765 }
766
767 #[test]
768 fn does_not_enforce_https_if_toggle_is_false() {
769 let _env = TestEnv::new();
770 std::env::set_var("API_BASE_URL", "http://romm.example/api/");
771 std::env::set_var("API_USE_HTTPS", "false");
772 let cfg = load_config().expect("config");
773 assert_eq!(cfg.base_url, "http://romm.example");
774 }
775
776 #[test]
777 fn normalize_romm_origin_trims_and_strips_api_suffix() {
778 assert_eq!(
779 normalize_romm_origin("http://localhost:8080/api/"),
780 "http://localhost:8080"
781 );
782 assert_eq!(
783 normalize_romm_origin("https://x.example"),
784 "https://x.example"
785 );
786 }
787
788 #[test]
789 fn empty_api_username_does_not_enable_basic() {
790 let _env = TestEnv::new();
791 std::env::set_var("API_BASE_URL", "http://example.test");
792 std::env::set_var("API_USERNAME", "");
793 std::env::set_var("API_PASSWORD", "secret");
794
795 let cfg = load_config().expect("config should load");
796 assert!(
797 cfg.auth.is_none(),
798 "empty API_USERNAME should not pair with password for Basic"
799 );
800 }
801
802 #[test]
803 fn ignores_placeholder_bearer_token() {
804 let _env = TestEnv::new();
805 std::env::set_var("API_BASE_URL", "http://example.test");
806 std::env::set_var("API_TOKEN", "your-bearer-token-here");
807
808 let cfg = load_config().expect("config should load");
809 assert!(cfg.auth.is_none(), "placeholder token should be ignored");
810 }
811
812 #[test]
813 fn loads_from_user_json_file() {
814 let env = TestEnv::new();
815 let config_json = r#"{
816 "base_url": "http://from-json-file.test",
817 "download_dir": "/tmp/downloads",
818 "use_https": false,
819 "auth": null
820 }"#;
821
822 std::fs::write(env.config_dir.join("config.json"), config_json).unwrap();
823
824 let cfg = load_config().expect("load from user config.json");
825 assert_eq!(cfg.base_url, "http://from-json-file.test");
826 assert_eq!(cfg.download_dir, "/tmp/downloads");
827 assert!(!cfg.use_https);
828 }
829
830 #[test]
831 fn extras_defaults_default_to_all_true_when_missing_from_json() {
832 let config_json = r#"{
833 "base_url": "http://from-json-file.test",
834 "download_dir": "/tmp/downloads",
835 "use_https": false,
836 "auth": null
837 }"#;
838 let cfg: Config = serde_json::from_str(config_json).expect("deserialize legacy config");
839 assert!(cfg.extras_defaults.include_related_roms);
840 assert!(cfg.extras_defaults.include_cover);
841 assert!(cfg.extras_defaults.include_manual);
842 }
843
844 #[test]
845 fn save_sync_defaults_when_missing_from_legacy_json() {
846 let config_json = r#"{
847 "base_url": "http://from-json-file.test",
848 "download_dir": "/tmp/downloads",
849 "use_https": false,
850 "auth": null
851 }"#;
852 let cfg: Config = serde_json::from_str(config_json).expect("deserialize legacy config");
853 assert_eq!(cfg.save_sync, SaveSyncConfig::default());
854 }
855
856 #[test]
857 fn resolved_save_dir_falls_back_to_download_dir_saves() {
858 let cfg = Config {
859 base_url: "http://example.test".into(),
860 download_dir: "/roms".into(),
861 use_https: false,
862 auth: None,
863 extras_defaults: ExtrasDefaults::default(),
864 save_sync: SaveSyncConfig::default(),
865 };
866 assert_eq!(
867 resolved_save_dir(&cfg),
868 PathBuf::from("/roms").join("saves")
869 );
870 }
871
872 #[test]
873 fn roms_dir_env_takes_precedence_over_legacy_download_dir_env() {
874 let _env = TestEnv::new();
875 std::env::set_var("API_BASE_URL", "http://example.test");
876 std::env::set_var("ROMM_ROMS_DIR", "/preferred-roms");
877 std::env::set_var("ROMM_DOWNLOAD_DIR", "/legacy-downloads");
878
879 let cfg = load_config().expect("config should load");
880 assert_eq!(cfg.download_dir, "/preferred-roms");
881 }
882
883 #[test]
884 fn auth_for_persist_merge_prefers_in_memory() {
885 let env = TestEnv::new();
886 let on_disk = r#"{
887 "base_url": "http://disk.test",
888 "download_dir": "/tmp",
889 "use_https": false,
890 "auth": { "Bearer": { "token": "from-disk" } }
891 }"#;
892 std::fs::write(env.config_dir.join("config.json"), on_disk).unwrap();
893
894 let mem = Some(AuthConfig::Bearer {
895 token: "from-memory".into(),
896 });
897 let merged = auth_for_persist_merge(mem.clone());
898 assert_eq!(format!("{:?}", merged), format!("{:?}", mem));
899 }
900
901 #[test]
902 fn auth_for_persist_merge_falls_back_to_disk_when_memory_empty() {
903 let env = TestEnv::new();
904 let on_disk = r#"{
905 "base_url": "http://disk.test",
906 "download_dir": "/tmp",
907 "use_https": false,
908 "auth": { "Bearer": { "token": "<stored-in-keyring>" } }
909 }"#;
910 std::fs::write(env.config_dir.join("config.json"), on_disk).unwrap();
911
912 let merged = auth_for_persist_merge(None);
913 match merged {
914 Some(AuthConfig::Bearer { token }) => {
915 assert_eq!(token, KEYRING_SECRET_PLACEHOLDER);
916 }
917 _ => panic!("expected bearer auth from disk"),
918 }
919 }
920
921 #[test]
922 fn bearer_keyring_sentinel_without_keyring_entry_yields_no_auth() {
923 let env = TestEnv::new();
924 std::env::set_var("API_BASE_URL", "http://example.test");
925 let config_json = r#"{
926 "base_url": "http://example.test",
927 "download_dir": "/tmp",
928 "use_https": false,
929 "auth": { "Bearer": { "token": "<stored-in-keyring>" } }
930 }"#;
931 std::fs::write(env.config_dir.join("config.json"), config_json).unwrap();
932
933 let cfg = load_config().expect("load");
934 assert!(
935 cfg.auth.is_none(),
936 "unresolved keyring sentinel must not become Bearer auth in Config"
937 );
938 assert!(disk_has_unresolved_keyring_sentinel(&cfg));
939 }
940
941 #[test]
942 fn bearer_token_from_romm_token_file() {
943 let env = TestEnv::new();
944 let token_path = env.config_dir.join("secret.token");
945 std::fs::write(&token_path, " tok-from-file\n").unwrap();
946 std::env::set_var("API_BASE_URL", "http://example.test");
947 std::env::set_var("ROMM_TOKEN_FILE", token_path.to_str().unwrap());
948
949 let cfg = load_config().expect("load");
950 match cfg.auth {
951 Some(AuthConfig::Bearer { token }) => assert_eq!(token, "tok-from-file"),
952 _ => panic!("expected bearer from token file"),
953 }
954 }
955
956 #[test]
957 fn api_token_env_wins_over_token_file() {
958 let env = TestEnv::new();
959 let token_path = env.config_dir.join("secret.token");
960 std::fs::write(&token_path, "from-file").unwrap();
961 std::env::set_var("API_BASE_URL", "http://example.test");
962 std::env::set_var("ROMM_TOKEN_FILE", token_path.to_str().unwrap());
963 std::env::set_var("API_TOKEN", "from-env");
964
965 let cfg = load_config().expect("load");
966 match cfg.auth {
967 Some(AuthConfig::Bearer { token }) => assert_eq!(token, "from-env"),
968 _ => panic!("expected env API_TOKEN to win"),
969 }
970 }
971
972 #[test]
973 fn romm_token_file_overrides_json_bearer() {
974 let env = TestEnv::new();
975 let token_path = env.config_dir.join("secret.token");
976 std::fs::write(&token_path, "from-file").unwrap();
977 std::env::set_var("API_BASE_URL", "http://example.test");
978 std::env::set_var("ROMM_TOKEN_FILE", token_path.to_str().unwrap());
979 let config_json = r#"{
980 "base_url": "http://example.test",
981 "download_dir": "/tmp",
982 "use_https": false,
983 "auth": { "Bearer": { "token": "from-json" } }
984 }"#;
985 std::fs::write(env.config_dir.join("config.json"), config_json).unwrap();
986
987 let cfg = load_config().expect("load");
988 match cfg.auth {
989 Some(AuthConfig::Bearer { token }) => assert_eq!(token, "from-file"),
990 _ => panic!("expected token file to override json"),
991 }
992 }
993
994 #[test]
995 fn romm_token_file_missing_errors() {
996 let env = TestEnv::new();
997 let missing = env.config_dir.join("this-token-file-does-not-exist");
998 std::env::set_var("API_BASE_URL", "http://example.test");
999 std::env::set_var("ROMM_TOKEN_FILE", missing.to_str().unwrap());
1000
1001 let err = load_config().expect_err("missing token file should error");
1002 let msg = format!("{err:#}");
1003 assert!(
1004 msg.contains("read bearer token file"),
1005 "unexpected error: {msg}"
1006 );
1007 }
1008
1009 #[test]
1010 fn romm_token_file_empty_errors() {
1011 let env = TestEnv::new();
1012 let token_path = env.config_dir.join("empty.token");
1013 std::fs::write(&token_path, " \n\t ").unwrap();
1014 std::env::set_var("API_BASE_URL", "http://example.test");
1015 std::env::set_var("ROMM_TOKEN_FILE", token_path.to_str().unwrap());
1016
1017 let err = load_config().expect_err("empty token file should error");
1018 assert!(
1019 format!("{err:#}").contains("empty"),
1020 "unexpected error: {err:#}"
1021 );
1022 }
1023
1024 #[test]
1025 fn romm_token_file_too_large_errors() {
1026 let env = TestEnv::new();
1027 let token_path = env.config_dir.join("huge.token");
1028 std::fs::write(&token_path, vec![b'a'; MAX_TOKEN_FILE_BYTES + 1]).unwrap();
1029 std::env::set_var("API_BASE_URL", "http://example.test");
1030 std::env::set_var("ROMM_TOKEN_FILE", token_path.to_str().unwrap());
1031
1032 let err = load_config().expect_err("oversized token file should error");
1033 assert!(
1034 format!("{err:#}").contains("max size"),
1035 "unexpected error: {err:#}"
1036 );
1037 }
1038
1039 #[test]
1043 fn persist_user_config_preserves_sentinel_secrets_in_json() {
1044 let env = TestEnv::new();
1045 let path = env.config_dir.join("config.json");
1046
1047 persist_user_config(&Config {
1048 base_url: "https://updated.example".into(),
1049 download_dir: "/var/romm-dl".into(),
1050 use_https: true,
1051 auth: Some(AuthConfig::Bearer {
1052 token: KEYRING_SECRET_PLACEHOLDER.to_string(),
1053 }),
1054 extras_defaults: ExtrasDefaults::default(),
1055 save_sync: SaveSyncConfig::default(),
1056 })
1057 .expect("persist bearer sentinel");
1058
1059 let cfg: Config = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
1060 assert_eq!(cfg.base_url, "https://updated.example");
1061 assert_eq!(cfg.download_dir, "/var/romm-dl");
1062 assert!(cfg.use_https);
1063 match cfg.auth {
1064 Some(AuthConfig::Bearer { token }) => {
1065 assert_eq!(token, KEYRING_SECRET_PLACEHOLDER);
1066 }
1067 _ => panic!("expected bearer sentinel preserved in config.json"),
1068 }
1069
1070 persist_user_config(&Config {
1071 base_url: "https://apikey.example".into(),
1072 download_dir: "/dl".into(),
1073 use_https: false,
1074 auth: Some(AuthConfig::ApiKey {
1075 header: "X-Api-Key".into(),
1076 key: KEYRING_SECRET_PLACEHOLDER.to_string(),
1077 }),
1078 extras_defaults: ExtrasDefaults::default(),
1079 save_sync: SaveSyncConfig::default(),
1080 })
1081 .expect("persist api key sentinel");
1082
1083 let cfg: Config = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
1084 assert_eq!(cfg.base_url, "https://apikey.example");
1085 match cfg.auth {
1086 Some(AuthConfig::ApiKey { header, key }) => {
1087 assert_eq!(header, "X-Api-Key");
1088 assert_eq!(key, KEYRING_SECRET_PLACEHOLDER);
1089 }
1090 _ => panic!("expected api key sentinel preserved"),
1091 }
1092
1093 persist_user_config(&Config {
1094 base_url: "https://basic.example".into(),
1095 download_dir: "/dl".into(),
1096 use_https: true,
1097 auth: Some(AuthConfig::Basic {
1098 username: "alice".into(),
1099 password: KEYRING_SECRET_PLACEHOLDER.to_string(),
1100 }),
1101 extras_defaults: ExtrasDefaults::default(),
1102 save_sync: SaveSyncConfig::default(),
1103 })
1104 .expect("persist basic password sentinel");
1105
1106 let cfg: Config = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
1107 assert_eq!(cfg.base_url, "https://basic.example");
1108 match cfg.auth {
1109 Some(AuthConfig::Basic { username, password }) => {
1110 assert_eq!(username, "alice");
1111 assert_eq!(password, KEYRING_SECRET_PLACEHOLDER);
1112 }
1113 _ => panic!("expected basic password sentinel preserved"),
1114 }
1115 }
1116
1117 #[test]
1118 fn should_check_updates_defaults_true_and_honors_false_values() {
1119 let _env = TestEnv::new();
1120 std::env::remove_var("ROMM_CHECK_UPDATES");
1121 assert!(should_check_updates());
1122
1123 for value in ["false", "FALSE", "0", "no", "off"] {
1124 std::env::set_var("ROMM_CHECK_UPDATES", value);
1125 assert!(
1126 !should_check_updates(),
1127 "expected ROMM_CHECK_UPDATES={value} to disable checks"
1128 );
1129 }
1130
1131 std::env::set_var("ROMM_CHECK_UPDATES", "true");
1132 assert!(should_check_updates());
1133 }
1134}