1use std::path::PathBuf;
23
24use anyhow::{anyhow, Context, Result};
25
26use serde::{Deserialize, Serialize};
27
28#[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
51pub const KEYRING_SECRET_PLACEHOLDER: &str = "<stored-in-keyring>";
53
54pub fn is_keyring_placeholder(s: &str) -> bool {
56 s == KEYRING_SECRET_PLACEHOLDER
57}
58
59pub 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
71const KEYRING_SERVICE: &str = "romm-cli";
76
77pub 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
86pub(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
92pub 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
104pub fn user_config_json_path() -> Option<PathBuf> {
106 user_config_dir().map(|d| d.join("config.json"))
107}
108
109pub 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
117pub 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
130fn 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 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 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 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 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 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 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
287pub 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 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}