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