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//! ## Environment file precedence
8//!
9//! Call [`load_layered_env`] before reading config:
10//!
11//! 1. Variables already set in the process environment (highest priority).
12//! 2. Project `.env` in the current working directory (via `dotenvy`).
13//! 3. User config: `{config_dir}/romm-cli/.env` — fills keys not already set (so a repo `.env` wins over user defaults).
14//! 4. OS keyring — secrets stored by `romm-cli init` (lowest priority fallback).
15
16use std::path::PathBuf;
17
18use anyhow::{anyhow, Context, Result};
19
20// ---------------------------------------------------------------------------
21// Types
22// ---------------------------------------------------------------------------
23
24#[derive(Debug, Clone)]
25pub enum AuthConfig {
26    Basic { username: String, password: String },
27    Bearer { token: String },
28    ApiKey { header: String, key: String },
29}
30
31#[derive(Debug, Clone)]
32pub struct Config {
33    pub base_url: String,
34    pub download_dir: String,
35    pub use_https: bool,
36    pub auth: Option<AuthConfig>,
37}
38
39fn is_placeholder(value: &str) -> bool {
40    value.contains("your-") || value.contains("placeholder") || value.trim().is_empty()
41}
42
43/// RomM site URL: the same origin you use in the browser (scheme, host, optional port).
44///
45/// Trims whitespace and trailing `/`, and removes a trailing `/api` segment if present. HTTP
46/// calls use paths such as `/api/platforms`; they must not double up with `.../api/api/...`.
47pub fn normalize_romm_origin(url: &str) -> String {
48    let mut s = url.trim().trim_end_matches('/').to_string();
49    if s.ends_with("/api") {
50        s.truncate(s.len() - 4);
51    }
52    s.trim_end_matches('/').to_string()
53}
54
55// ---------------------------------------------------------------------------
56// Keyring helpers
57// ---------------------------------------------------------------------------
58
59const KEYRING_SERVICE: &str = "romm-cli";
60
61/// Store a secret in the OS keyring under the `romm-cli` service name.
62pub fn keyring_store(key: &str, value: &str) -> Result<()> {
63    let entry = keyring::Entry::new(KEYRING_SERVICE, key)
64        .map_err(|e| anyhow!("keyring entry error: {e}"))?;
65    entry
66        .set_password(value)
67        .map_err(|e| anyhow!("keyring set error: {e}"))
68}
69
70/// Retrieve a secret from the OS keyring, returning `None` if not found.
71fn keyring_get(key: &str) -> Option<String> {
72    let entry = keyring::Entry::new(KEYRING_SERVICE, key).ok()?;
73    entry.get_password().ok()
74}
75
76// ---------------------------------------------------------------------------
77// Paths
78// ---------------------------------------------------------------------------
79
80/// Directory for user-level config (`romm-cli` under the OS config dir).
81pub fn user_config_dir() -> Option<PathBuf> {
82    #[cfg(test)]
83    if let Ok(dir) = std::env::var("ROMM_TEST_CONFIG_DIR") {
84        return Some(PathBuf::from(dir));
85    }
86    dirs::config_dir().map(|d| d.join("romm-cli"))
87}
88
89/// Path to the user-level `.env` file (`.../romm-cli/.env`).
90pub fn user_config_env_path() -> Option<PathBuf> {
91    user_config_dir().map(|d| d.join(".env"))
92}
93
94/// Where the OpenAPI spec is cached (`.../romm-cli/openapi.json`).
95///
96/// Override with `ROMM_OPENAPI_PATH` (absolute or relative path).
97pub fn openapi_cache_path() -> Result<PathBuf> {
98    if let Ok(p) = std::env::var("ROMM_OPENAPI_PATH") {
99        return Ok(PathBuf::from(p));
100    }
101    let dir = user_config_dir().ok_or_else(|| {
102        anyhow!("Could not resolve config directory. Set ROMM_OPENAPI_PATH to store openapi.json.")
103    })?;
104    Ok(dir.join("openapi.json"))
105}
106
107// ---------------------------------------------------------------------------
108// Loading
109// ---------------------------------------------------------------------------
110
111/// Load env vars from `./.env` in cwd, then from the user config file.
112/// Later files only set variables not already set (env or earlier file), so a project `.env` overrides the same keys in the user file.
113pub fn load_layered_env() {
114    let _ = dotenvy::dotenv();
115    if let Some(path) = user_config_env_path() {
116        if path.is_file() {
117            let _ = dotenvy::from_path(path);
118        }
119    }
120}
121
122/// Read an env var, falling back to the OS keyring if unset or empty.
123///
124/// A line like `API_PASSWORD=` in a project `.env` sets the variable to the empty string; we treat
125/// that as "not set" so the keyring (e.g. after `romm-cli init` / TUI setup) is still used.
126fn env_or_keyring(key: &str) -> Option<String> {
127    match std::env::var(key) {
128        Ok(s) if !s.trim().is_empty() => Some(s),
129        Ok(_) => keyring_get(key),
130        Err(_) => keyring_get(key),
131    }
132}
133
134fn env_nonempty(key: &str) -> Option<String> {
135    std::env::var(key).ok().filter(|s| !s.trim().is_empty())
136}
137
138pub fn load_config() -> Result<Config> {
139    let base_raw = std::env::var("API_BASE_URL").map_err(|_| {
140        anyhow!(
141            "API_BASE_URL is not set. Set it in the environment, a .env file, or run: romm-cli init"
142        )
143    })?;
144    let mut base_url = normalize_romm_origin(&base_raw);
145
146    let download_dir = env_nonempty("ROMM_DOWNLOAD_DIR").unwrap_or_else(|| {
147        dirs::download_dir()
148            .unwrap_or_else(|| dirs::home_dir().unwrap_or_default().join("Downloads"))
149            .join("romm-cli")
150            .display()
151            .to_string()
152    });
153
154    let use_https = std::env::var("API_USE_HTTPS")
155        .map(|s| s.to_lowercase() == "true")
156        .unwrap_or(true);
157
158    if use_https && base_url.starts_with("http://") {
159        base_url = base_url.replace("http://", "https://");
160    }
161
162    let username = env_nonempty("API_USERNAME");
163    let password = env_or_keyring("API_PASSWORD");
164    let token = env_or_keyring("API_TOKEN").or_else(|| env_or_keyring("API_KEY"));
165    let api_key = env_or_keyring("API_KEY");
166    let api_key_header = env_nonempty("API_KEY_HEADER");
167
168    let auth = if let (Some(user), Some(pass)) = (username, password) {
169        // Priority 1: Basic auth
170        Some(AuthConfig::Basic {
171            username: user,
172            password: pass,
173        })
174    } else if let (Some(key), Some(header)) = (api_key, api_key_header) {
175        // Priority 2: API key in custom header (when both set, prefer over bearer)
176        if !is_placeholder(&key) {
177            Some(AuthConfig::ApiKey { header, key })
178        } else {
179            None
180        }
181    } else if let Some(tok) = token {
182        // Priority 3: Bearer token (skip placeholders)
183        if !is_placeholder(&tok) {
184            Some(AuthConfig::Bearer { token: tok })
185        } else {
186            None
187        }
188    } else {
189        None
190    };
191
192    Ok(Config {
193        base_url,
194        download_dir,
195        use_https,
196        auth,
197    })
198}
199
200/// Escape a value for use in a `.env` file line (same rules as `romm-cli init`).
201pub(crate) fn escape_env_value(s: &str) -> String {
202    let needs_quote = s.is_empty()
203        || s.chars()
204            .any(|c| c.is_whitespace() || c == '#' || c == '"' || c == '\'');
205    if needs_quote {
206        let escaped = s.replace('\\', "\\\\").replace('"', "\\\"");
207        format!("\"{}\"", escaped)
208    } else {
209        s.to_string()
210    }
211}
212
213/// Write user-level `romm-cli/.env` and store secrets in the OS keyring when possible
214/// (same layout as interactive `romm-cli init`).
215pub fn persist_user_config(
216    base_url: &str,
217    download_dir: &str,
218    use_https: bool,
219    auth: Option<AuthConfig>,
220) -> Result<()> {
221    let Some(path) = user_config_env_path() else {
222        return Err(anyhow!(
223            "Could not determine config directory (no HOME / APPDATA?)."
224        ));
225    };
226    let dir = path
227        .parent()
228        .ok_or_else(|| anyhow!("invalid config path"))?;
229    std::fs::create_dir_all(dir).with_context(|| format!("create {}", dir.display()))?;
230
231    let mut lines: Vec<String> = vec![
232        "# romm-cli user configuration".to_string(),
233        "# Secrets are stored in the OS keyring when available.".to_string(),
234        "# Applied after project .env: only fills variables not already set.".to_string(),
235        String::new(),
236        format!("API_BASE_URL={}", escape_env_value(base_url)),
237        format!("ROMM_DOWNLOAD_DIR={}", escape_env_value(download_dir)),
238        format!("API_USE_HTTPS={}", if use_https { "true" } else { "false" }),
239        String::new(),
240    ];
241
242    match &auth {
243        None => {
244            lines.push("# No auth variables set.".to_string());
245        }
246        Some(AuthConfig::Basic { username, password }) => {
247            lines.push("# Basic auth (password stored in OS keyring)".to_string());
248            lines.push(format!("API_USERNAME={}", escape_env_value(username)));
249            if let Err(e) = keyring_store("API_PASSWORD", password) {
250                tracing::warn!("keyring store API_PASSWORD: {e}; writing plaintext to .env");
251                lines.push(format!("API_PASSWORD={}", escape_env_value(password)));
252            }
253        }
254        Some(AuthConfig::Bearer { token }) => {
255            lines.push("# Bearer token (stored in OS keyring)".to_string());
256            if let Err(e) = keyring_store("API_TOKEN", token) {
257                tracing::warn!("keyring store API_TOKEN: {e}; writing plaintext to .env");
258                lines.push(format!("API_TOKEN={}", escape_env_value(token)));
259            }
260        }
261        Some(AuthConfig::ApiKey { header, key }) => {
262            lines.push("# Custom header API key (key stored in OS keyring)".to_string());
263            lines.push(format!("API_KEY_HEADER={}", escape_env_value(header)));
264            if let Err(e) = keyring_store("API_KEY", key) {
265                tracing::warn!("keyring store API_KEY: {e}; writing plaintext to .env");
266                lines.push(format!("API_KEY={}", escape_env_value(key)));
267            }
268        }
269    }
270
271    let content = lines.join("\n") + "\n";
272    {
273        use std::io::Write;
274        let mut f =
275            std::fs::File::create(&path).with_context(|| format!("write {}", path.display()))?;
276        f.write_all(content.as_bytes())?;
277    }
278
279    #[cfg(unix)]
280    {
281        use std::os::unix::fs::PermissionsExt;
282        let mut perms = std::fs::metadata(&path)?.permissions();
283        perms.set_mode(0o600);
284        std::fs::set_permissions(&path, perms)?;
285    }
286
287    Ok(())
288}
289
290#[cfg(test)]
291mod tests {
292    use super::*;
293    use std::sync::{Mutex, OnceLock};
294
295    fn env_lock() -> &'static Mutex<()> {
296        static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
297        LOCK.get_or_init(|| Mutex::new(()))
298    }
299
300    fn clear_auth_env() {
301        for key in [
302            "API_BASE_URL",
303            "API_USERNAME",
304            "API_PASSWORD",
305            "API_TOKEN",
306            "API_KEY",
307            "API_KEY_HEADER",
308            "API_USE_HTTPS",
309        ] {
310            std::env::remove_var(key);
311        }
312    }
313
314    #[test]
315    fn prefers_basic_auth_over_other_modes() {
316        let _guard = env_lock().lock().expect("env lock");
317        clear_auth_env();
318        std::env::set_var("API_BASE_URL", "http://example.test");
319        std::env::set_var("API_USERNAME", "user");
320        std::env::set_var("API_PASSWORD", "pass");
321        std::env::set_var("API_TOKEN", "token");
322        std::env::set_var("API_KEY", "apikey");
323        std::env::set_var("API_KEY_HEADER", "X-Api-Key");
324
325        let cfg = load_config().expect("config should load");
326        match cfg.auth {
327            Some(AuthConfig::Basic { username, password }) => {
328                assert_eq!(username, "user");
329                assert_eq!(password, "pass");
330            }
331            _ => panic!("expected basic auth"),
332        }
333    }
334
335    #[test]
336    fn uses_api_key_header_when_token_missing() {
337        let _guard = env_lock().lock().expect("env lock");
338        clear_auth_env();
339        std::env::set_var("API_BASE_URL", "http://example.test");
340        std::env::set_var("API_KEY", "real-key");
341        std::env::set_var("API_KEY_HEADER", "X-Api-Key");
342
343        let cfg = load_config().expect("config should load");
344        match cfg.auth {
345            Some(AuthConfig::ApiKey { header, key }) => {
346                assert_eq!(header, "X-Api-Key");
347                assert_eq!(key, "real-key");
348            }
349            _ => panic!("expected api key auth"),
350        }
351    }
352
353    #[test]
354    fn normalizes_api_base_url_and_enforces_https_by_default() {
355        let _guard = env_lock().lock().expect("env lock");
356        clear_auth_env();
357        std::env::set_var("API_BASE_URL", "http://romm.example/api/");
358        let cfg = load_config().expect("config");
359        // Upgraded to https by default
360        assert_eq!(cfg.base_url, "https://romm.example");
361    }
362
363    #[test]
364    fn does_not_enforce_https_if_toggle_is_false() {
365        let _guard = env_lock().lock().expect("env lock");
366        clear_auth_env();
367        std::env::set_var("API_BASE_URL", "http://romm.example/api/");
368        std::env::set_var("API_USE_HTTPS", "false");
369        let cfg = load_config().expect("config");
370        assert_eq!(cfg.base_url, "http://romm.example");
371    }
372
373    #[test]
374    fn normalize_romm_origin_trims_and_strips_api_suffix() {
375        assert_eq!(
376            normalize_romm_origin("http://localhost:8080/api/"),
377            "http://localhost:8080"
378        );
379        assert_eq!(
380            normalize_romm_origin("https://x.example"),
381            "https://x.example"
382        );
383    }
384
385    #[test]
386    fn empty_api_username_does_not_enable_basic() {
387        let _guard = env_lock().lock().expect("env lock");
388        clear_auth_env();
389        std::env::set_var("API_BASE_URL", "http://example.test");
390        std::env::set_var("API_USERNAME", "");
391        std::env::set_var("API_PASSWORD", "secret");
392
393        let cfg = load_config().expect("config should load");
394        assert!(
395            cfg.auth.is_none(),
396            "empty API_USERNAME should not pair with password for Basic"
397        );
398    }
399
400    #[test]
401    fn ignores_placeholder_bearer_token() {
402        let _guard = env_lock().lock().expect("env lock");
403        clear_auth_env();
404        std::env::set_var("API_BASE_URL", "http://example.test");
405        std::env::set_var("API_TOKEN", "your-bearer-token-here");
406
407        let cfg = load_config().expect("config should load");
408        assert!(cfg.auth.is_none(), "placeholder token should be ignored");
409    }
410
411    #[test]
412    fn layered_env_applies_user_file_for_unset_keys() {
413        let _guard = env_lock().lock().expect("env lock");
414        clear_auth_env();
415        std::env::remove_var("API_BASE_URL");
416
417        let ts = std::time::SystemTime::now()
418            .duration_since(std::time::UNIX_EPOCH)
419            .unwrap()
420            .as_nanos();
421        let base = std::env::temp_dir().join(format!("romm-layered-{ts}"));
422        std::fs::create_dir_all(&base).unwrap();
423        let work = base.join("work");
424        std::fs::create_dir_all(&work).unwrap();
425        std::fs::write(
426            base.join(".env"),
427            "API_BASE_URL=http://from-user-file.test\n",
428        )
429        .unwrap();
430
431        std::env::set_var("ROMM_TEST_CONFIG_DIR", base.as_os_str());
432        let old_cwd = std::env::current_dir().unwrap();
433        std::env::set_current_dir(&work).unwrap();
434
435        load_layered_env();
436        // Force use_https=false so the http assertion works
437        std::env::set_var("API_USE_HTTPS", "false");
438        let cfg = load_config().expect("load from user .env");
439        assert_eq!(cfg.base_url, "http://from-user-file.test");
440
441        std::env::set_current_dir(old_cwd).unwrap();
442        std::env::remove_var("ROMM_TEST_CONFIG_DIR");
443        std::env::remove_var("API_BASE_URL");
444        let _ = std::fs::remove_dir_all(&base);
445    }
446}