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