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