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