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_config`] to read 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//!
16//! ## `load_config` vs `config.json`
17//!
18//! [`load_config`] merges sources **per field**: process environment wins over values from
19//! `config.json` for `API_BASE_URL`, `ROMM_DOWNLOAD_DIR`, `API_USE_HTTPS`, and auth-related
20//! variables. The keyring is used only to fill missing or placeholder secrets after that merge.
21
22use std::path::PathBuf;
23
24use anyhow::{anyhow, Context, Result};
25
26use serde::{Deserialize, Serialize};
27
28// ---------------------------------------------------------------------------
29// Types
30// ---------------------------------------------------------------------------
31
32#[derive(Debug, Clone, Serialize, Deserialize)]
33pub enum AuthConfig {
34    Basic { username: String, password: String },
35    Bearer { token: String },
36    ApiKey { header: String, key: String },
37}
38
39#[derive(Debug, Clone, Serialize, Deserialize)]
40pub struct Config {
41    pub base_url: String,
42    pub download_dir: String,
43    pub use_https: bool,
44    pub auth: Option<AuthConfig>,
45}
46
47fn is_placeholder(value: &str) -> bool {
48    value.contains("your-") || value.contains("placeholder") || value.trim().is_empty()
49}
50
51/// Written to `config.json` when the real secret is stored in the OS keyring (`persist_user_config`).
52pub const KEYRING_SECRET_PLACEHOLDER: &str = "<stored-in-keyring>";
53
54/// True if `s` is the sentinel written to disk when the secret lives in the keyring.
55pub fn is_keyring_placeholder(s: &str) -> bool {
56    s == KEYRING_SECRET_PLACEHOLDER
57}
58
59/// RomM site URL: the same origin you use in the browser (scheme, host, optional port).
60///
61/// Trims whitespace and trailing `/`, and removes a trailing `/api` segment if present. HTTP
62/// calls use paths such as `/api/platforms`; they must not double up with `.../api/api/...`.
63pub fn normalize_romm_origin(url: &str) -> String {
64    let mut s = url.trim().trim_end_matches('/').to_string();
65    if s.ends_with("/api") {
66        s.truncate(s.len() - 4);
67    }
68    s.trim_end_matches('/').to_string()
69}
70
71// ---------------------------------------------------------------------------
72// Keyring helpers
73// ---------------------------------------------------------------------------
74
75const KEYRING_SERVICE: &str = "romm-cli";
76
77/// Store a secret in the OS keyring under the `romm-cli` service name.
78pub fn keyring_store(key: &str, value: &str) -> Result<()> {
79    let entry = keyring::Entry::new(KEYRING_SERVICE, key)
80        .map_err(|e| anyhow!("keyring entry error: {e}"))?;
81    entry
82        .set_password(value)
83        .map_err(|e| anyhow!("keyring set error: {e}"))
84}
85
86/// Retrieve a secret from the OS keyring, returning `None` if not found.
87pub(crate) fn keyring_get(key: &str) -> Option<String> {
88    let entry = keyring::Entry::new(KEYRING_SERVICE, key).ok()?;
89    entry.get_password().ok()
90}
91
92// ---------------------------------------------------------------------------
93// Paths
94// ---------------------------------------------------------------------------
95
96/// Directory for user-level config (`romm-cli` under the OS config dir).
97pub fn user_config_dir() -> Option<PathBuf> {
98    if let Ok(dir) = std::env::var("ROMM_TEST_CONFIG_DIR") {
99        return Some(PathBuf::from(dir));
100    }
101    dirs::config_dir().map(|d| d.join("romm-cli"))
102}
103
104/// Path to the user-level `config.json` file (`.../romm-cli/config.json`).
105pub fn user_config_json_path() -> Option<PathBuf> {
106    user_config_dir().map(|d| d.join("config.json"))
107}
108
109/// Reads `config.json` from disk only (no env merge, no keyring resolution).
110/// Used by the TUI setup wizard to detect `<stored-in-keyring>` placeholders.
111pub fn read_user_config_json_from_disk() -> Option<Config> {
112    let path = user_config_json_path()?;
113    let content = std::fs::read_to_string(path).ok()?;
114    serde_json::from_str(&content).ok()
115}
116
117/// Where the OpenAPI spec is cached (`.../romm-cli/openapi.json`).
118///
119/// Override with `ROMM_OPENAPI_PATH` (absolute or relative path).
120pub fn openapi_cache_path() -> Result<PathBuf> {
121    if let Ok(p) = std::env::var("ROMM_OPENAPI_PATH") {
122        return Ok(PathBuf::from(p));
123    }
124    let dir = user_config_dir().ok_or_else(|| {
125        anyhow!("Could not resolve config directory. Set ROMM_OPENAPI_PATH to store openapi.json.")
126    })?;
127    Ok(dir.join("openapi.json"))
128}
129
130// ---------------------------------------------------------------------------
131// Loading
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    // 1. Load from JSON first (if it exists)
140    let mut json_config = None;
141    if let Some(path) = user_config_json_path() {
142        if path.is_file() {
143            if let Ok(content) = std::fs::read_to_string(&path) {
144                if let Ok(config) = serde_json::from_str::<Config>(&content) {
145                    json_config = Some(config);
146                }
147            }
148        }
149    }
150
151    // 2. Resolve base_url
152    let base_raw = env_nonempty("API_BASE_URL")
153        .or_else(|| json_config.as_ref().map(|c| c.base_url.clone()))
154        .ok_or_else(|| {
155            anyhow!(
156                "API_BASE_URL is not set. Set it in the environment, a config.json file, or run: romm-cli init"
157            )
158        })?;
159    let mut base_url = normalize_romm_origin(&base_raw);
160
161    // 3. Resolve download_dir
162    let download_dir = env_nonempty("ROMM_DOWNLOAD_DIR")
163        .or_else(|| json_config.as_ref().map(|c| c.download_dir.clone()))
164        .unwrap_or_else(|| {
165            dirs::download_dir()
166                .unwrap_or_else(|| dirs::home_dir().unwrap_or_default().join("Downloads"))
167                .join("romm-cli")
168                .display()
169                .to_string()
170        });
171
172    // 4. Resolve use_https
173    let use_https = if let Ok(s) = std::env::var("API_USE_HTTPS") {
174        s.to_lowercase() == "true"
175    } else if let Some(c) = &json_config {
176        c.use_https
177    } else {
178        true
179    };
180
181    if use_https && base_url.starts_with("http://") {
182        base_url = base_url.replace("http://", "https://");
183    }
184
185    // 5. Resolve Auth
186    let mut username = env_nonempty("API_USERNAME");
187    let mut password = env_nonempty("API_PASSWORD");
188    let mut token = env_nonempty("API_TOKEN");
189    let mut api_key = env_nonempty("API_KEY");
190    let mut api_key_header = env_nonempty("API_KEY_HEADER");
191
192    if let Some(c) = &json_config {
193        if let Some(auth) = &c.auth {
194            match auth {
195                AuthConfig::Basic {
196                    username: u,
197                    password: p,
198                } => {
199                    if username.is_none() {
200                        username = Some(u.clone());
201                    }
202                    if password.is_none() {
203                        password = Some(p.clone());
204                    }
205                }
206                AuthConfig::Bearer { token: t } => {
207                    if token.is_none() {
208                        token = Some(t.clone());
209                    }
210                }
211                AuthConfig::ApiKey { header: h, key: k } => {
212                    if api_key_header.is_none() {
213                        api_key_header = Some(h.clone());
214                    }
215                    if api_key.is_none() {
216                        api_key = Some(k.clone());
217                    }
218                }
219            }
220        }
221    }
222
223    // Resolve placeholders from keyring (including disk sentinel `<stored-in-keyring>`).
224    if let Some(p) = &password {
225        if is_placeholder(p) || is_keyring_placeholder(p) {
226            if let Some(k) = keyring_get("API_PASSWORD") {
227                password = Some(k);
228            }
229        }
230    } else {
231        password = keyring_get("API_PASSWORD");
232    }
233
234    if let Some(t) = &token {
235        if is_placeholder(t) || is_keyring_placeholder(t) {
236            if let Some(k) = keyring_get("API_TOKEN") {
237                token = Some(k);
238            }
239        }
240    } else {
241        token = keyring_get("API_TOKEN");
242    }
243
244    if let Some(k) = &api_key {
245        if is_placeholder(k) || is_keyring_placeholder(k) {
246            if let Some(kr) = keyring_get("API_KEY") {
247                api_key = Some(kr);
248            }
249        }
250    } else {
251        api_key = keyring_get("API_KEY");
252    }
253
254    let auth = if let (Some(user), Some(pass)) = (username, password) {
255        if !is_placeholder(&pass) && !is_keyring_placeholder(&pass) {
256            Some(AuthConfig::Basic {
257                username: user,
258                password: pass,
259            })
260        } else {
261            None
262        }
263    } else if let (Some(key), Some(header)) = (api_key, api_key_header) {
264        if !is_placeholder(&key) && !is_keyring_placeholder(&key) {
265            Some(AuthConfig::ApiKey { header, key })
266        } else {
267            None
268        }
269    } else if let Some(tok) = token {
270        if !is_placeholder(&tok) && !is_keyring_placeholder(&tok) {
271            Some(AuthConfig::Bearer { token: tok })
272        } else {
273            None
274        }
275    } else {
276        None
277    };
278
279    Ok(Config {
280        base_url,
281        download_dir,
282        use_https,
283        auth,
284    })
285}
286
287/// Write user-level `romm-cli/config.json` and store secrets in the OS keyring when possible
288/// (same layout as interactive `romm-cli init`).
289pub fn persist_user_config(
290    base_url: &str,
291    download_dir: &str,
292    use_https: bool,
293    auth: Option<AuthConfig>,
294) -> Result<()> {
295    let Some(path) = user_config_json_path() else {
296        return Err(anyhow!(
297            "Could not determine config directory (no HOME / APPDATA?)."
298        ));
299    };
300    let dir = path
301        .parent()
302        .ok_or_else(|| anyhow!("invalid config path"))?;
303    std::fs::create_dir_all(dir).with_context(|| format!("create {}", dir.display()))?;
304
305    let mut config_to_save = Config {
306        base_url: base_url.to_string(),
307        download_dir: download_dir.to_string(),
308        use_https,
309        auth: auth.clone(),
310    };
311
312    match &mut config_to_save.auth {
313        None => {}
314        Some(AuthConfig::Basic { password, .. }) => {
315            if let Err(e) = keyring_store("API_PASSWORD", password) {
316                tracing::warn!("keyring store API_PASSWORD: {e}; writing plaintext to config.json");
317            } else {
318                *password = KEYRING_SECRET_PLACEHOLDER.to_string();
319            }
320        }
321        Some(AuthConfig::Bearer { token }) => {
322            if let Err(e) = keyring_store("API_TOKEN", token) {
323                tracing::warn!("keyring store API_TOKEN: {e}; writing plaintext to config.json");
324            } else {
325                *token = KEYRING_SECRET_PLACEHOLDER.to_string();
326            }
327        }
328        Some(AuthConfig::ApiKey { key, .. }) => {
329            if let Err(e) = keyring_store("API_KEY", key) {
330                tracing::warn!("keyring store API_KEY: {e}; writing plaintext to config.json");
331            } else {
332                *key = KEYRING_SECRET_PLACEHOLDER.to_string();
333            }
334        }
335    }
336
337    let content = serde_json::to_string_pretty(&config_to_save)?;
338    {
339        use std::io::Write;
340        let mut f =
341            std::fs::File::create(&path).with_context(|| format!("write {}", path.display()))?;
342        f.write_all(content.as_bytes())?;
343    }
344
345    #[cfg(unix)]
346    {
347        use std::os::unix::fs::PermissionsExt;
348        let mut perms = std::fs::metadata(&path)?.permissions();
349        perms.set_mode(0o600);
350        std::fs::set_permissions(&path, perms)?;
351    }
352
353    Ok(())
354}
355
356#[cfg(test)]
357mod tests {
358    use super::*;
359    use std::sync::{Mutex, MutexGuard, OnceLock};
360
361    fn env_lock() -> &'static Mutex<()> {
362        static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
363        LOCK.get_or_init(|| Mutex::new(()))
364    }
365
366    struct TestEnv {
367        _guard: MutexGuard<'static, ()>,
368        config_dir: PathBuf,
369    }
370
371    impl TestEnv {
372        fn new() -> Self {
373            let guard = env_lock().lock().expect("env lock");
374            clear_auth_env();
375
376            let ts = std::time::SystemTime::now()
377                .duration_since(std::time::UNIX_EPOCH)
378                .unwrap()
379                .as_nanos();
380            let config_dir = std::env::temp_dir().join(format!("romm-config-test-{ts}"));
381            std::fs::create_dir_all(&config_dir).unwrap();
382            std::env::set_var("ROMM_TEST_CONFIG_DIR", &config_dir);
383
384            Self {
385                _guard: guard,
386                config_dir,
387            }
388        }
389    }
390
391    impl Drop for TestEnv {
392        fn drop(&mut self) {
393            clear_auth_env();
394            std::env::remove_var("ROMM_TEST_CONFIG_DIR");
395            let _ = std::fs::remove_dir_all(&self.config_dir);
396        }
397    }
398
399    fn clear_auth_env() {
400        for key in [
401            "API_BASE_URL",
402            "API_USERNAME",
403            "API_PASSWORD",
404            "API_TOKEN",
405            "API_KEY",
406            "API_KEY_HEADER",
407            "API_USE_HTTPS",
408            "ROMM_TEST_CONFIG_DIR",
409        ] {
410            std::env::remove_var(key);
411        }
412    }
413
414    #[test]
415    fn prefers_basic_auth_over_other_modes() {
416        let _env = TestEnv::new();
417        std::env::set_var("API_BASE_URL", "http://example.test");
418        std::env::set_var("API_USERNAME", "user");
419        std::env::set_var("API_PASSWORD", "pass");
420        std::env::set_var("API_TOKEN", "token");
421        std::env::set_var("API_KEY", "apikey");
422        std::env::set_var("API_KEY_HEADER", "X-Api-Key");
423
424        let cfg = load_config().expect("config should load");
425        match cfg.auth {
426            Some(AuthConfig::Basic { username, password }) => {
427                assert_eq!(username, "user");
428                assert_eq!(password, "pass");
429            }
430            _ => panic!("expected basic auth"),
431        }
432    }
433
434    #[test]
435    fn uses_api_key_header_when_token_missing() {
436        let _env = TestEnv::new();
437        std::env::set_var("API_BASE_URL", "http://example.test");
438        std::env::set_var("API_KEY", "real-key");
439        std::env::set_var("API_KEY_HEADER", "X-Api-Key");
440
441        let cfg = load_config().expect("config should load");
442        match cfg.auth {
443            Some(AuthConfig::ApiKey { header, key }) => {
444                assert_eq!(header, "X-Api-Key");
445                assert_eq!(key, "real-key");
446            }
447            _ => panic!("expected api key auth"),
448        }
449    }
450
451    #[test]
452    fn normalizes_api_base_url_and_enforces_https_by_default() {
453        let _env = TestEnv::new();
454        std::env::set_var("API_BASE_URL", "http://romm.example/api/");
455        let cfg = load_config().expect("config");
456        // Upgraded to https by default
457        assert_eq!(cfg.base_url, "https://romm.example");
458    }
459
460    #[test]
461    fn does_not_enforce_https_if_toggle_is_false() {
462        let _env = TestEnv::new();
463        std::env::set_var("API_BASE_URL", "http://romm.example/api/");
464        std::env::set_var("API_USE_HTTPS", "false");
465        let cfg = load_config().expect("config");
466        assert_eq!(cfg.base_url, "http://romm.example");
467    }
468
469    #[test]
470    fn normalize_romm_origin_trims_and_strips_api_suffix() {
471        assert_eq!(
472            normalize_romm_origin("http://localhost:8080/api/"),
473            "http://localhost:8080"
474        );
475        assert_eq!(
476            normalize_romm_origin("https://x.example"),
477            "https://x.example"
478        );
479    }
480
481    #[test]
482    fn empty_api_username_does_not_enable_basic() {
483        let _env = TestEnv::new();
484        std::env::set_var("API_BASE_URL", "http://example.test");
485        std::env::set_var("API_USERNAME", "");
486        std::env::set_var("API_PASSWORD", "secret");
487
488        let cfg = load_config().expect("config should load");
489        assert!(
490            cfg.auth.is_none(),
491            "empty API_USERNAME should not pair with password for Basic"
492        );
493    }
494
495    #[test]
496    fn ignores_placeholder_bearer_token() {
497        let _env = TestEnv::new();
498        std::env::set_var("API_BASE_URL", "http://example.test");
499        std::env::set_var("API_TOKEN", "your-bearer-token-here");
500
501        let cfg = load_config().expect("config should load");
502        assert!(cfg.auth.is_none(), "placeholder token should be ignored");
503    }
504
505    #[test]
506    fn loads_from_user_json_file() {
507        let env = TestEnv::new();
508        let config_json = r#"{
509            "base_url": "http://from-json-file.test",
510            "download_dir": "/tmp/downloads",
511            "use_https": false,
512            "auth": null
513        }"#;
514
515        std::fs::write(env.config_dir.join("config.json"), config_json).unwrap();
516
517        let cfg = load_config().expect("load from user config.json");
518        assert_eq!(cfg.base_url, "http://from-json-file.test");
519        assert_eq!(cfg.download_dir, "/tmp/downloads");
520        assert!(!cfg.use_https);
521    }
522}