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