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