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