1use std::fs;
29use std::path::PathBuf;
30
31use anyhow::{anyhow, Context, Result};
32
33use serde::{Deserialize, Serialize};
34
35#[derive(Debug, Clone, Serialize, Deserialize)]
40pub enum AuthConfig {
41 Basic { username: String, password: String },
42 Bearer { token: String },
43 ApiKey { header: String, key: String },
44}
45
46#[derive(Debug, Clone, Serialize, Deserialize)]
47pub struct Config {
48 pub base_url: String,
49 pub download_dir: String,
50 pub use_https: bool,
51 pub auth: Option<AuthConfig>,
52}
53
54fn is_placeholder(value: &str) -> bool {
55 value.contains("your-") || value.contains("placeholder") || value.trim().is_empty()
56}
57
58pub const KEYRING_SECRET_PLACEHOLDER: &str = "<stored-in-keyring>";
60
61pub fn is_keyring_placeholder(s: &str) -> bool {
63 s == KEYRING_SECRET_PLACEHOLDER
64}
65
66pub fn normalize_romm_origin(url: &str) -> String {
71 let mut s = url.trim().trim_end_matches('/').to_string();
72 if s.ends_with("/api") {
73 s.truncate(s.len() - 4);
74 }
75 s.trim_end_matches('/').to_string()
76}
77
78const KEYRING_SERVICE: &str = "romm-cli";
83
84pub fn keyring_store(key: &str, value: &str) -> Result<()> {
86 let entry = keyring::Entry::new(KEYRING_SERVICE, key)
87 .map_err(|e| anyhow!("keyring entry error: {e}"))?;
88 entry
89 .set_password(value)
90 .map_err(|e| anyhow!("keyring set error: {e}"))
91}
92
93pub(crate) fn keyring_get(key: &str) -> Option<String> {
95 let entry = keyring::Entry::new(KEYRING_SERVICE, key).ok()?;
96 entry.get_password().ok()
97}
98
99pub fn user_config_dir() -> Option<PathBuf> {
105 if let Ok(dir) = std::env::var("ROMM_TEST_CONFIG_DIR") {
106 return Some(PathBuf::from(dir));
107 }
108 dirs::config_dir().map(|d| d.join("romm-cli"))
109}
110
111pub fn user_config_json_path() -> Option<PathBuf> {
113 user_config_dir().map(|d| d.join("config.json"))
114}
115
116pub fn read_user_config_json_from_disk() -> Option<Config> {
119 let path = user_config_json_path()?;
120 let content = std::fs::read_to_string(path).ok()?;
121 serde_json::from_str(&content).ok()
122}
123
124pub fn auth_for_persist_merge(in_memory: Option<AuthConfig>) -> Option<AuthConfig> {
130 in_memory.or_else(|| read_user_config_json_from_disk().and_then(|c| c.auth))
131}
132
133pub fn openapi_cache_path() -> Result<PathBuf> {
137 if let Ok(p) = std::env::var("ROMM_OPENAPI_PATH") {
138 return Ok(PathBuf::from(p));
139 }
140 let dir = user_config_dir().ok_or_else(|| {
141 anyhow!("Could not resolve config directory. Set ROMM_OPENAPI_PATH to store openapi.json.")
142 })?;
143 Ok(dir.join("openapi.json"))
144}
145
146fn env_nonempty(key: &str) -> Option<String> {
151 std::env::var(key).ok().filter(|s| !s.trim().is_empty())
152}
153
154const MAX_TOKEN_FILE_BYTES: usize = 64 * 1024;
156
157fn token_from_env_or_file() -> Result<Option<String>> {
159 if let Some(t) = env_nonempty("API_TOKEN") {
160 return Ok(Some(t));
161 }
162 let path = env_nonempty("ROMM_TOKEN_FILE").or_else(|| env_nonempty("API_TOKEN_FILE"));
163 let Some(path) = path else {
164 return Ok(None);
165 };
166 let path = path.trim();
167 let bytes = fs::read(path).with_context(|| format!("read bearer token file {path}"))?;
168 if bytes.len() > MAX_TOKEN_FILE_BYTES {
169 return Err(anyhow!(
170 "bearer token file exceeds max size of {} bytes",
171 MAX_TOKEN_FILE_BYTES
172 ));
173 }
174 let s = String::from_utf8(bytes)
175 .map_err(|e| anyhow!("bearer token file must be valid UTF-8: {e}"))?;
176 let t = s.trim();
177 if t.is_empty() {
178 return Err(anyhow!(
179 "bearer token file is empty after trimming whitespace"
180 ));
181 }
182 Ok(Some(t.to_string()))
183}
184
185pub fn disk_has_unresolved_keyring_sentinel(config: &Config) -> bool {
188 if config.auth.is_some() {
189 return false;
190 }
191 let Some(disk) = read_user_config_json_from_disk() else {
192 return false;
193 };
194 match &disk.auth {
195 Some(AuthConfig::Bearer { token }) => is_keyring_placeholder(token),
196 Some(AuthConfig::Basic { password, .. }) => is_keyring_placeholder(password),
197 Some(AuthConfig::ApiKey { key, .. }) => is_keyring_placeholder(key),
198 None => false,
199 }
200}
201
202pub fn load_config() -> Result<Config> {
208 let mut json_config = None;
210 if let Some(path) = user_config_json_path() {
211 if path.is_file() {
212 if let Ok(content) = std::fs::read_to_string(&path) {
213 if let Ok(config) = serde_json::from_str::<Config>(&content) {
214 json_config = Some(config);
215 }
216 }
217 }
218 }
219
220 let base_raw = env_nonempty("API_BASE_URL")
222 .or_else(|| json_config.as_ref().map(|c| c.base_url.clone()))
223 .ok_or_else(|| {
224 anyhow!(
225 "API_BASE_URL is not set. Set it in the environment, a config.json file, or run: romm-cli init"
226 )
227 })?;
228 let mut base_url = normalize_romm_origin(&base_raw);
229
230 let download_dir = env_nonempty("ROMM_DOWNLOAD_DIR")
232 .or_else(|| json_config.as_ref().map(|c| c.download_dir.clone()))
233 .unwrap_or_else(|| {
234 dirs::download_dir()
235 .unwrap_or_else(|| dirs::home_dir().unwrap_or_default().join("Downloads"))
236 .join("romm-cli")
237 .display()
238 .to_string()
239 });
240
241 let use_https = if let Ok(s) = std::env::var("API_USE_HTTPS") {
243 s.to_lowercase() == "true"
244 } else if let Some(c) = &json_config {
245 c.use_https
246 } else {
247 true
248 };
249
250 if use_https && base_url.starts_with("http://") {
251 base_url = base_url.replace("http://", "https://");
252 }
253
254 let mut username = env_nonempty("API_USERNAME");
256 let mut password = env_nonempty("API_PASSWORD");
257 let mut token = token_from_env_or_file()?;
258 let mut api_key = env_nonempty("API_KEY");
259 let mut api_key_header = env_nonempty("API_KEY_HEADER");
260
261 if let Some(c) = &json_config {
262 if let Some(auth) = &c.auth {
263 match auth {
264 AuthConfig::Basic {
265 username: u,
266 password: p,
267 } => {
268 if username.is_none() {
269 username = Some(u.clone());
270 }
271 if password.is_none() {
272 password = Some(p.clone());
273 }
274 }
275 AuthConfig::Bearer { token: t } => {
276 if token.is_none() {
277 token = Some(t.clone());
278 }
279 }
280 AuthConfig::ApiKey { header: h, key: k } => {
281 if api_key_header.is_none() {
282 api_key_header = Some(h.clone());
283 }
284 if api_key.is_none() {
285 api_key = Some(k.clone());
286 }
287 }
288 }
289 }
290 }
291
292 if let Some(p) = &password {
294 if is_placeholder(p) || is_keyring_placeholder(p) {
295 if let Some(k) = keyring_get("API_PASSWORD") {
296 password = Some(k);
297 }
298 }
299 } else {
300 password = keyring_get("API_PASSWORD");
301 }
302
303 if let Some(t) = &token {
304 if is_placeholder(t) || is_keyring_placeholder(t) {
305 if let Some(k) = keyring_get("API_TOKEN") {
306 token = Some(k);
307 }
308 }
309 } else {
310 token = keyring_get("API_TOKEN");
311 }
312
313 if let Some(k) = &api_key {
314 if is_placeholder(k) || is_keyring_placeholder(k) {
315 if let Some(kr) = keyring_get("API_KEY") {
316 api_key = Some(kr);
317 }
318 }
319 } else {
320 api_key = keyring_get("API_KEY");
321 }
322
323 if let Some(ref p) = password {
324 if is_keyring_placeholder(p) {
325 tracing::warn!(
326 "Could not read API_PASSWORD from the OS keyring; value is still <stored-in-keyring>. \
327 On Windows, look for a Generic credential with target API_PASSWORD.romm-cli."
328 );
329 }
330 }
331 if let Some(ref t) = token {
332 if is_keyring_placeholder(t) {
333 tracing::warn!(
334 "Could not read API_TOKEN from the OS keyring; value is still <stored-in-keyring>. \
335 On Windows, look for a Generic credential with target API_TOKEN.romm-cli."
336 );
337 }
338 }
339 if let Some(ref k) = api_key {
340 if is_keyring_placeholder(k) {
341 tracing::warn!(
342 "Could not read API_KEY from the OS keyring; value is still <stored-in-keyring>. \
343 On Windows, look for a Generic credential with target API_KEY.romm-cli."
344 );
345 }
346 }
347
348 let auth = if let (Some(user), Some(pass)) = (username, password) {
349 if !is_placeholder(&pass) && !is_keyring_placeholder(&pass) {
350 Some(AuthConfig::Basic {
351 username: user,
352 password: pass,
353 })
354 } else {
355 None
356 }
357 } else if let (Some(key), Some(header)) = (api_key, api_key_header) {
358 if !is_placeholder(&key) && !is_keyring_placeholder(&key) {
359 Some(AuthConfig::ApiKey { header, key })
360 } else {
361 None
362 }
363 } else if let Some(tok) = token {
364 if !is_placeholder(&tok) && !is_keyring_placeholder(&tok) {
365 Some(AuthConfig::Bearer { token: tok })
366 } else {
367 None
368 }
369 } else {
370 None
371 };
372
373 Ok(Config {
374 base_url,
375 download_dir,
376 use_https,
377 auth,
378 })
379}
380
381pub fn persist_user_config(
384 base_url: &str,
385 download_dir: &str,
386 use_https: bool,
387 auth: Option<AuthConfig>,
388) -> Result<()> {
389 let Some(path) = user_config_json_path() else {
390 return Err(anyhow!(
391 "Could not determine config directory (no HOME / APPDATA?)."
392 ));
393 };
394 let dir = path
395 .parent()
396 .ok_or_else(|| anyhow!("invalid config path"))?;
397 std::fs::create_dir_all(dir).with_context(|| format!("create {}", dir.display()))?;
398
399 let mut config_to_save = Config {
400 base_url: base_url.to_string(),
401 download_dir: download_dir.to_string(),
402 use_https,
403 auth: auth.clone(),
404 };
405
406 match &mut config_to_save.auth {
407 None => {}
408 Some(AuthConfig::Basic { password, .. }) => {
409 if let Err(e) = keyring_store("API_PASSWORD", password) {
410 tracing::warn!("keyring store API_PASSWORD: {e}; writing plaintext to config.json");
411 } else {
412 *password = KEYRING_SECRET_PLACEHOLDER.to_string();
413 }
414 }
415 Some(AuthConfig::Bearer { token }) => {
416 if let Err(e) = keyring_store("API_TOKEN", token) {
417 tracing::warn!("keyring store API_TOKEN: {e}; writing plaintext to config.json");
418 } else {
419 *token = KEYRING_SECRET_PLACEHOLDER.to_string();
420 }
421 }
422 Some(AuthConfig::ApiKey { key, .. }) => {
423 if let Err(e) = keyring_store("API_KEY", key) {
424 tracing::warn!("keyring store API_KEY: {e}; writing plaintext to config.json");
425 } else {
426 *key = KEYRING_SECRET_PLACEHOLDER.to_string();
427 }
428 }
429 }
430
431 let content = serde_json::to_string_pretty(&config_to_save)?;
432 {
433 use std::io::Write;
434 let mut f =
435 std::fs::File::create(&path).with_context(|| format!("write {}", path.display()))?;
436 f.write_all(content.as_bytes())?;
437 }
438
439 #[cfg(unix)]
440 {
441 use std::os::unix::fs::PermissionsExt;
442 let mut perms = std::fs::metadata(&path)?.permissions();
443 perms.set_mode(0o600);
444 std::fs::set_permissions(&path, perms)?;
445 }
446
447 Ok(())
448}
449
450#[cfg(test)]
451mod tests {
452 use super::*;
453 use std::sync::{Mutex, MutexGuard, OnceLock};
454
455 fn env_lock() -> &'static Mutex<()> {
456 static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
457 LOCK.get_or_init(|| Mutex::new(()))
458 }
459
460 struct TestEnv {
461 _guard: MutexGuard<'static, ()>,
462 config_dir: PathBuf,
463 }
464
465 impl TestEnv {
466 fn new() -> Self {
467 let guard = env_lock().lock().expect("env lock");
468 clear_auth_env();
469
470 let ts = std::time::SystemTime::now()
471 .duration_since(std::time::UNIX_EPOCH)
472 .unwrap()
473 .as_nanos();
474 let config_dir = std::env::temp_dir().join(format!("romm-config-test-{ts}"));
475 std::fs::create_dir_all(&config_dir).unwrap();
476 std::env::set_var("ROMM_TEST_CONFIG_DIR", &config_dir);
477
478 Self {
479 _guard: guard,
480 config_dir,
481 }
482 }
483 }
484
485 impl Drop for TestEnv {
486 fn drop(&mut self) {
487 clear_auth_env();
488 std::env::remove_var("ROMM_TEST_CONFIG_DIR");
489 let _ = std::fs::remove_dir_all(&self.config_dir);
490 }
491 }
492
493 fn clear_auth_env() {
494 for key in [
495 "API_BASE_URL",
496 "API_USERNAME",
497 "API_PASSWORD",
498 "API_TOKEN",
499 "ROMM_TOKEN_FILE",
500 "API_TOKEN_FILE",
501 "API_KEY",
502 "API_KEY_HEADER",
503 "API_USE_HTTPS",
504 "ROMM_TEST_CONFIG_DIR",
505 ] {
506 std::env::remove_var(key);
507 }
508 }
509
510 #[test]
511 fn prefers_basic_auth_over_other_modes() {
512 let _env = TestEnv::new();
513 std::env::set_var("API_BASE_URL", "http://example.test");
514 std::env::set_var("API_USERNAME", "user");
515 std::env::set_var("API_PASSWORD", "pass");
516 std::env::set_var("API_TOKEN", "token");
517 std::env::set_var("API_KEY", "apikey");
518 std::env::set_var("API_KEY_HEADER", "X-Api-Key");
519
520 let cfg = load_config().expect("config should load");
521 match cfg.auth {
522 Some(AuthConfig::Basic { username, password }) => {
523 assert_eq!(username, "user");
524 assert_eq!(password, "pass");
525 }
526 _ => panic!("expected basic auth"),
527 }
528 }
529
530 #[test]
531 fn uses_api_key_header_when_token_missing() {
532 let _env = TestEnv::new();
533 std::env::set_var("API_BASE_URL", "http://example.test");
534 std::env::set_var("API_KEY", "real-key");
535 std::env::set_var("API_KEY_HEADER", "X-Api-Key");
536
537 let cfg = load_config().expect("config should load");
538 match cfg.auth {
539 Some(AuthConfig::ApiKey { header, key }) => {
540 assert_eq!(header, "X-Api-Key");
541 assert_eq!(key, "real-key");
542 }
543 _ => panic!("expected api key auth"),
544 }
545 }
546
547 #[test]
548 fn normalizes_api_base_url_and_enforces_https_by_default() {
549 let _env = TestEnv::new();
550 std::env::set_var("API_BASE_URL", "http://romm.example/api/");
551 let cfg = load_config().expect("config");
552 assert_eq!(cfg.base_url, "https://romm.example");
554 }
555
556 #[test]
557 fn does_not_enforce_https_if_toggle_is_false() {
558 let _env = TestEnv::new();
559 std::env::set_var("API_BASE_URL", "http://romm.example/api/");
560 std::env::set_var("API_USE_HTTPS", "false");
561 let cfg = load_config().expect("config");
562 assert_eq!(cfg.base_url, "http://romm.example");
563 }
564
565 #[test]
566 fn normalize_romm_origin_trims_and_strips_api_suffix() {
567 assert_eq!(
568 normalize_romm_origin("http://localhost:8080/api/"),
569 "http://localhost:8080"
570 );
571 assert_eq!(
572 normalize_romm_origin("https://x.example"),
573 "https://x.example"
574 );
575 }
576
577 #[test]
578 fn empty_api_username_does_not_enable_basic() {
579 let _env = TestEnv::new();
580 std::env::set_var("API_BASE_URL", "http://example.test");
581 std::env::set_var("API_USERNAME", "");
582 std::env::set_var("API_PASSWORD", "secret");
583
584 let cfg = load_config().expect("config should load");
585 assert!(
586 cfg.auth.is_none(),
587 "empty API_USERNAME should not pair with password for Basic"
588 );
589 }
590
591 #[test]
592 fn ignores_placeholder_bearer_token() {
593 let _env = TestEnv::new();
594 std::env::set_var("API_BASE_URL", "http://example.test");
595 std::env::set_var("API_TOKEN", "your-bearer-token-here");
596
597 let cfg = load_config().expect("config should load");
598 assert!(cfg.auth.is_none(), "placeholder token should be ignored");
599 }
600
601 #[test]
602 fn loads_from_user_json_file() {
603 let env = TestEnv::new();
604 let config_json = r#"{
605 "base_url": "http://from-json-file.test",
606 "download_dir": "/tmp/downloads",
607 "use_https": false,
608 "auth": null
609 }"#;
610
611 std::fs::write(env.config_dir.join("config.json"), config_json).unwrap();
612
613 let cfg = load_config().expect("load from user config.json");
614 assert_eq!(cfg.base_url, "http://from-json-file.test");
615 assert_eq!(cfg.download_dir, "/tmp/downloads");
616 assert!(!cfg.use_https);
617 }
618
619 #[test]
620 fn auth_for_persist_merge_prefers_in_memory() {
621 let env = TestEnv::new();
622 let on_disk = r#"{
623 "base_url": "http://disk.test",
624 "download_dir": "/tmp",
625 "use_https": false,
626 "auth": { "Bearer": { "token": "from-disk" } }
627 }"#;
628 std::fs::write(env.config_dir.join("config.json"), on_disk).unwrap();
629
630 let mem = Some(AuthConfig::Bearer {
631 token: "from-memory".into(),
632 });
633 let merged = auth_for_persist_merge(mem.clone());
634 assert_eq!(format!("{:?}", merged), format!("{:?}", mem));
635 }
636
637 #[test]
638 fn auth_for_persist_merge_falls_back_to_disk_when_memory_empty() {
639 let env = TestEnv::new();
640 let on_disk = r#"{
641 "base_url": "http://disk.test",
642 "download_dir": "/tmp",
643 "use_https": false,
644 "auth": { "Bearer": { "token": "<stored-in-keyring>" } }
645 }"#;
646 std::fs::write(env.config_dir.join("config.json"), on_disk).unwrap();
647
648 let merged = auth_for_persist_merge(None);
649 match merged {
650 Some(AuthConfig::Bearer { token }) => {
651 assert_eq!(token, KEYRING_SECRET_PLACEHOLDER);
652 }
653 _ => panic!("expected bearer auth from disk"),
654 }
655 }
656
657 #[test]
658 fn bearer_keyring_sentinel_without_keyring_entry_yields_no_auth() {
659 let env = TestEnv::new();
660 std::env::set_var("API_BASE_URL", "http://example.test");
661 let config_json = r#"{
662 "base_url": "http://example.test",
663 "download_dir": "/tmp",
664 "use_https": false,
665 "auth": { "Bearer": { "token": "<stored-in-keyring>" } }
666 }"#;
667 std::fs::write(env.config_dir.join("config.json"), config_json).unwrap();
668
669 let cfg = load_config().expect("load");
670 assert!(
671 cfg.auth.is_none(),
672 "unresolved keyring sentinel must not become Bearer auth in Config"
673 );
674 assert!(disk_has_unresolved_keyring_sentinel(&cfg));
675 }
676
677 #[test]
678 fn bearer_token_from_romm_token_file() {
679 let env = TestEnv::new();
680 let token_path = env.config_dir.join("secret.token");
681 std::fs::write(&token_path, " tok-from-file\n").unwrap();
682 std::env::set_var("API_BASE_URL", "http://example.test");
683 std::env::set_var("ROMM_TOKEN_FILE", token_path.to_str().unwrap());
684
685 let cfg = load_config().expect("load");
686 match cfg.auth {
687 Some(AuthConfig::Bearer { token }) => assert_eq!(token, "tok-from-file"),
688 _ => panic!("expected bearer from token file"),
689 }
690 }
691
692 #[test]
693 fn api_token_env_wins_over_token_file() {
694 let env = TestEnv::new();
695 let token_path = env.config_dir.join("secret.token");
696 std::fs::write(&token_path, "from-file").unwrap();
697 std::env::set_var("API_BASE_URL", "http://example.test");
698 std::env::set_var("ROMM_TOKEN_FILE", token_path.to_str().unwrap());
699 std::env::set_var("API_TOKEN", "from-env");
700
701 let cfg = load_config().expect("load");
702 match cfg.auth {
703 Some(AuthConfig::Bearer { token }) => assert_eq!(token, "from-env"),
704 _ => panic!("expected env API_TOKEN to win"),
705 }
706 }
707
708 #[test]
709 fn romm_token_file_overrides_json_bearer() {
710 let env = TestEnv::new();
711 let token_path = env.config_dir.join("secret.token");
712 std::fs::write(&token_path, "from-file").unwrap();
713 std::env::set_var("API_BASE_URL", "http://example.test");
714 std::env::set_var("ROMM_TOKEN_FILE", token_path.to_str().unwrap());
715 let config_json = r#"{
716 "base_url": "http://example.test",
717 "download_dir": "/tmp",
718 "use_https": false,
719 "auth": { "Bearer": { "token": "from-json" } }
720 }"#;
721 std::fs::write(env.config_dir.join("config.json"), config_json).unwrap();
722
723 let cfg = load_config().expect("load");
724 match cfg.auth {
725 Some(AuthConfig::Bearer { token }) => assert_eq!(token, "from-file"),
726 _ => panic!("expected token file to override json"),
727 }
728 }
729
730 #[test]
731 fn romm_token_file_missing_errors() {
732 let env = TestEnv::new();
733 let missing = env.config_dir.join("this-token-file-does-not-exist");
734 std::env::set_var("API_BASE_URL", "http://example.test");
735 std::env::set_var("ROMM_TOKEN_FILE", missing.to_str().unwrap());
736
737 let err = load_config().expect_err("missing token file should error");
738 let msg = format!("{err:#}");
739 assert!(
740 msg.contains("read bearer token file"),
741 "unexpected error: {msg}"
742 );
743 }
744
745 #[test]
746 fn romm_token_file_empty_errors() {
747 let env = TestEnv::new();
748 let token_path = env.config_dir.join("empty.token");
749 std::fs::write(&token_path, " \n\t ").unwrap();
750 std::env::set_var("API_BASE_URL", "http://example.test");
751 std::env::set_var("ROMM_TOKEN_FILE", token_path.to_str().unwrap());
752
753 let err = load_config().expect_err("empty token file should error");
754 assert!(
755 format!("{err:#}").contains("empty"),
756 "unexpected error: {err:#}"
757 );
758 }
759
760 #[test]
761 fn romm_token_file_too_large_errors() {
762 let env = TestEnv::new();
763 let token_path = env.config_dir.join("huge.token");
764 std::fs::write(&token_path, vec![b'a'; MAX_TOKEN_FILE_BYTES + 1]).unwrap();
765 std::env::set_var("API_BASE_URL", "http://example.test");
766 std::env::set_var("ROMM_TOKEN_FILE", token_path.to_str().unwrap());
767
768 let err = load_config().expect_err("oversized token file should error");
769 assert!(
770 format!("{err:#}").contains("max size"),
771 "unexpected error: {err:#}"
772 );
773 }
774}