Skip to main content

romm_cli/
config.rs

1//! Configuration and authentication for the ROMM client.
2//!
3//! This module is deliberately independent of any particular frontend:
4//! both the TUI and the command-line subcommands share the same `Config`
5//! and `AuthConfig` types.
6//!
7//! ## Configuration precedence
8//!
9//! Call [`load_config`] to read config:
10//!
11//! 1. Variables already set in the process environment (highest priority), including `API_TOKEN` and
12//!    paths `ROMM_TOKEN_FILE` / `API_TOKEN_FILE` (file contents used as bearer token when `API_TOKEN` is unset).
13//! 2. User `config.json` (see [`user_config_json_path`]) — fills any field **not** already set from the environment.
14//!
15//! There is **no** automatic loading of a `.env` file; set variables in your shell or process manager,
16//! or rely on `config.json` written by `romm-cli init` / the TUI setup wizard.
17//!
18//! After env + JSON merge, secrets that are still placeholders (including [`KEYRING_SECRET_PLACEHOLDER`])
19//! are resolved via the OS keyring (`keyring` crate, service name `romm-cli`). On Windows the stored
20//! credential target is typically `API_TOKEN.romm-cli`, `API_PASSWORD.romm-cli`, or `API_KEY.romm-cli`.
21//!
22//! ## `load_config` vs `config.json`
23//!
24//! [`load_config`] merges sources **per field**: process environment wins over values from
25//! `config.json` for `API_BASE_URL`, `ROMM_DOWNLOAD_DIR`, `API_USE_HTTPS`, and auth-related
26//! fields. The keyring is used only to replace placeholder or sentinel secret strings after that merge.
27
28use std::fs;
29use std::path::PathBuf;
30
31use anyhow::{anyhow, Context, Result};
32
33use serde::{Deserialize, Serialize};
34
35// ---------------------------------------------------------------------------
36// Types
37// ---------------------------------------------------------------------------
38
39#[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
58/// Written to `config.json` when the real secret is stored in the OS keyring (`persist_user_config`).
59pub const KEYRING_SECRET_PLACEHOLDER: &str = "<stored-in-keyring>";
60
61/// True if `s` is the sentinel written to disk when the secret lives in the keyring.
62pub fn is_keyring_placeholder(s: &str) -> bool {
63    s == KEYRING_SECRET_PLACEHOLDER
64}
65
66/// RomM site URL: the same origin you use in the browser (scheme, host, optional port).
67///
68/// Trims whitespace and trailing `/`, and removes a trailing `/api` segment if present. HTTP
69/// calls use paths such as `/api/platforms`; they must not double up with `.../api/api/...`.
70pub 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
78// ---------------------------------------------------------------------------
79// Keyring helpers
80// ---------------------------------------------------------------------------
81
82const KEYRING_SERVICE: &str = "romm-cli";
83
84/// Store a secret in the OS keyring under the `romm-cli` service name.
85pub 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
93/// Retrieve a secret from the OS keyring, returning `None` if not found.
94pub(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
99// ---------------------------------------------------------------------------
100// Paths
101// ---------------------------------------------------------------------------
102
103/// Directory for user-level config (`romm-cli` under the OS config dir).
104pub 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
111/// Path to the user-level `config.json` file (`.../romm-cli/config.json`).
112pub fn user_config_json_path() -> Option<PathBuf> {
113    user_config_dir().map(|d| d.join("config.json"))
114}
115
116/// Reads `config.json` from disk only (no env merge, no keyring resolution).
117/// Used by the TUI setup wizard to detect `<stored-in-keyring>` placeholders.
118pub 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
124/// Auth to pass to [`persist_user_config`] when saving non-auth fields (e.g. TUI Settings).
125///
126/// Prefer the in-memory [`Config::auth`]. If it is `None` (e.g. [`load_config`] could not read the
127/// token from the keyring), reuse `auth` from [`read_user_config_json_from_disk`] so we do not
128/// overwrite `config.json` with `"auth": null` while the file still held a bearer sentinel.
129pub 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
133/// Where the OpenAPI spec is cached (`.../romm-cli/openapi.json`).
134///
135/// Override with `ROMM_OPENAPI_PATH` (absolute or relative path).
136pub 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
146// ---------------------------------------------------------------------------
147// Loading
148// ---------------------------------------------------------------------------
149
150fn env_nonempty(key: &str) -> Option<String> {
151    std::env::var(key).ok().filter(|s| !s.trim().is_empty())
152}
153
154/// Max bytes read from bearer token files (`ROMM_TOKEN_FILE` / `API_TOKEN_FILE`).
155const MAX_TOKEN_FILE_BYTES: usize = 64 * 1024;
156
157/// Bearer token: `API_TOKEN` env, else UTF-8 file at `ROMM_TOKEN_FILE` or `API_TOKEN_FILE` path.
158fn 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
185/// Returns true when [`load_config`] has no resolved [`AuthConfig::Bearer`] (etc.) but `config.json`
186/// on disk still contains [`KEYRING_SECRET_PLACEHOLDER`] (OS keyring could not supply the secret).
187pub 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
202/// Loads merged config from env, optional `config.json`, and the OS keyring.
203///
204/// Bearer token resolution order: `API_TOKEN`, then UTF-8 file at `ROMM_TOKEN_FILE` or `API_TOKEN_FILE`
205/// (max 64 KiB, trimmed), then JSON. If a token file path is set but the file is missing, empty, or
206/// too large, returns an error.
207pub fn load_config() -> Result<Config> {
208    // 1. Load from JSON first (if it exists)
209    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    // 2. Resolve base_url
221    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    // 3. Resolve download_dir
231    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    // 4. Resolve use_https
242    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    // 5. Resolve Auth
255    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    // Resolve placeholders from keyring (including disk sentinel `<stored-in-keyring>`).
293    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
381/// Write user-level `romm-cli/config.json` and store secrets in the OS keyring when possible
382/// (same layout as interactive `romm-cli init`).
383pub 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        // Upgraded to https by default
553        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}