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