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