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//! Missing entries are silent; other keyring errors are logged at warn (never with secret values).
22//! On save, a successful store is followed by read-back verification before writing the sentinel to JSON.
23//!
24//! ## `load_config` vs `config.json`
25//!
26//! [`load_config`] merges sources **per field**: process environment wins over values from
27//! `config.json` for `API_BASE_URL`, `ROMM_ROMS_DIR`/`ROMM_DOWNLOAD_DIR`, `API_USE_HTTPS`, and auth-related
28//! fields. The keyring is used only to replace placeholder or sentinel secret strings after that merge.
29
30use std::fs;
31use std::path::PathBuf;
32
33use anyhow::{anyhow, Context, Result};
34
35use serde::{Deserialize, Serialize};
36
37// ---------------------------------------------------------------------------
38// Types
39// ---------------------------------------------------------------------------
40
41#[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
60/// Written to `config.json` when the real secret is stored in the OS keyring (`persist_user_config`).
61pub const KEYRING_SECRET_PLACEHOLDER: &str = "<stored-in-keyring>";
62
63/// True if `s` is the sentinel written to disk when the secret lives in the keyring.
64pub fn is_keyring_placeholder(s: &str) -> bool {
65    s == KEYRING_SECRET_PLACEHOLDER
66}
67
68/// RomM site URL: the same origin you use in the browser (scheme, host, optional port).
69///
70/// Trims whitespace and trailing `/`, and removes a trailing `/api` segment if present. HTTP
71/// calls use paths such as `/api/platforms`; they must not double up with `.../api/api/...`.
72pub 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
80// ---------------------------------------------------------------------------
81// Keyring helpers
82// ---------------------------------------------------------------------------
83
84const KEYRING_SERVICE: &str = "romm-cli";
85
86/// Store a secret in the OS keyring under the `romm-cli` service name.
87pub 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
95/// Map `get_password` result: [`keyring::Error::NoEntry`] is normal when no credential exists (no log).
96/// Other errors are logged (never logs secret bytes).
97fn 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
108/// Retrieve a secret from the OS keyring, returning `None` if not found or on error (after logging unexpected errors).
109pub(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
120/// After a successful `set_password`, confirm read-back matches `expected`. If not, caller should keep plaintext in JSON.
121fn 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
148// ---------------------------------------------------------------------------
149// Paths
150// ---------------------------------------------------------------------------
151
152/// Directory for user-level config (`romm-cli` under the OS config dir).
153pub 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
160/// Path to the user-level `config.json` file (`.../romm-cli/config.json`).
161pub fn user_config_json_path() -> Option<PathBuf> {
162    user_config_dir().map(|d| d.join("config.json"))
163}
164
165/// Reads `config.json` from disk only (no env merge, no keyring resolution).
166/// Used by the TUI setup wizard to detect `<stored-in-keyring>` placeholders.
167pub 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
173/// Auth to pass to [`persist_user_config`] when saving non-auth fields (e.g. TUI Settings).
174///
175/// Prefer the in-memory [`Config::auth`]. If it is `None` (e.g. [`load_config`] could not read the
176/// token from the keyring), reuse `auth` from [`read_user_config_json_from_disk`] so we do not
177/// overwrite `config.json` with `"auth": null` while the file still held a bearer sentinel.
178pub 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
182/// Where the OpenAPI spec is cached (`.../romm-cli/openapi.json`).
183///
184/// Override with `ROMM_OPENAPI_PATH` (absolute or relative path).
185pub 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
195// ---------------------------------------------------------------------------
196// Loading
197// ---------------------------------------------------------------------------
198
199fn 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
213/// Max bytes read from bearer token files (`ROMM_TOKEN_FILE` / `API_TOKEN_FILE`).
214const MAX_TOKEN_FILE_BYTES: usize = 64 * 1024;
215
216/// Bearer token: `API_TOKEN` env, else UTF-8 file at `ROMM_TOKEN_FILE` or `API_TOKEN_FILE` path.
217fn 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
244/// Returns true when [`load_config`] has no resolved [`AuthConfig::Bearer`] (etc.) but `config.json`
245/// on disk still contains [`KEYRING_SECRET_PLACEHOLDER`] (OS keyring could not supply the secret).
246pub 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
261/// Loads merged config from env, optional `config.json`, and the OS keyring.
262///
263/// Bearer token resolution order: `API_TOKEN`, then UTF-8 file at `ROMM_TOKEN_FILE` or `API_TOKEN_FILE`
264/// (max 64 KiB, trimmed), then JSON. If a token file path is set but the file is missing, empty, or
265/// too large, returns an error.
266pub fn load_config() -> Result<Config> {
267    // 1. Load from JSON first (if it exists)
268    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    // 2. Resolve base_url
280    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    // 3. Resolve ROM storage directory
290    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    // 4. Resolve use_https
302    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    // 5. Resolve Auth
315    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    // Resolve placeholders from keyring (including disk sentinel `<stored-in-keyring>`).
353    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
441/// Write user-level `romm-cli/config.json` and store secrets in the OS keyring when possible
442/// (same layout as interactive `romm-cli init`).
443///
444/// If a secret field is already [`KEYRING_SECRET_PLACEHOLDER`], it is written to JSON as-is and
445/// the keyring is not updated (avoids overwriting the vault with the literal sentinel string).
446///
447/// After a successful [`keyring_store`], the secret is read back from the keyring; only if it
448/// matches the stored value is JSON updated to the sentinel (otherwise plaintext is kept and a
449/// warning is logged).
450pub 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        // Upgraded to https by default
653        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    /// When auth is merged from disk as [`KEYRING_SECRET_PLACEHOLDER`], persist must not call
887    /// `keyring_store` with that literal (would overwrite the real vault entry). JSON should still
888    /// contain the sentinel and updated non-auth fields.
889    #[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}