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