Skip to main content

ignition_core/config/
mod.rs

1//! Config discovery, load/save, selection, and the env overlay (research
2//! Pattern 2).
3//!
4//! Discovery: `IGNITION_CLI_CONFIG` (explicit path — scripts and tests)
5//! FIRST, the platform path second. macOS gotcha: `directories` ignores
6//! `XDG_CONFIG_HOME`, which is exactly why every test drives the env
7//! override.
8//!
9//! Precedence (LOCKED): CLI flag > `IGNITION_*` env > profile value >
10//! default. Each env concern has exactly one home: profile selection env
11//! (`IGNITION_PROFILE`) is folded into `--profile` by the bin's
12//! `apply_env_defaults`; the URL env overlay lives here ([`apply_env_overlay`]);
13//! auth env resolution lives in [`secret`].
14
15pub mod profile;
16pub mod secret;
17
18pub use profile::{AuthRef, Config, Profile, RigConfig, RigEntry, UiConfig};
19pub use secret::{
20    BasicEnvStore, Credential, EnvStore, KeyringStore, Secret, SecretStore, resolve_secret,
21};
22
23use std::path::{Path, PathBuf};
24
25use crate::error::CoreError;
26
27/// Serializes env-var mutation across this crate's unit tests: env is
28/// process-global and lib tests run in parallel threads (edition 2024 makes
29/// `set_var` unsafe for exactly this reason — under this lock it is sound).
30#[cfg(test)]
31pub(crate) static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
32
33/// Config file location: `IGNITION_CLI_CONFIG` env override first, the
34/// platform config dir second.
35pub fn config_path() -> PathBuf {
36    std::env::var_os("IGNITION_CLI_CONFIG")
37        .map(PathBuf::from)
38        .unwrap_or_else(|| {
39            let dirs = directories::ProjectDirs::from("", "", "ignition-cli")
40                .expect("no home directory discoverable");
41            dirs.config_dir().join("config.toml")
42        })
43}
44
45/// Load config from `path`. A missing file is a fresh install, NOT an error
46/// (`version` must work day one). Unreadable or invalid TOML is
47/// [`CoreError::ConfigInvalid`] (exit 3) naming the path. Unknown keys WARN
48/// (tracing) and are otherwise tolerated — no `deny_unknown_fields`, ever
49/// (Pitfall 7). Sub-second `poll_interval_secs` is REFUSED (exit 3,
50/// `poll_interval_too_small`) — the strict path.
51pub fn load(path: &Path) -> Result<Config, CoreError> {
52    load_inner(path, true)
53}
54
55/// The TUI's load entry point (08-01, TUIX-05): identical parse and
56/// lenient extraction to [`load`], but a clamp violation confined to the
57/// NEW schema surface does NOT error — it warns carrying the same
58/// `poll_interval_too_small` slug/message, substitutes the default
59/// cadence, and returns Ok.
60///
61/// Contract: schema-surface failures degrade with a warning; resolution
62/// failures are fatal — the TUI is an authed cockpit. Raw TOML parse
63/// failures, profile deserialize failures (e.g. a broken profile URL),
64/// and selection failures still hard-error through the frozen
65/// `config_invalid` exit-3 taxonomy: a config that cannot name a
66/// reachable profile cannot start the cockpit.
67pub fn load_for_tui(path: &Path) -> Result<Config, CoreError> {
68    load_inner(path, false)
69}
70
71/// Shared body of [`load`] / [`load_for_tui`]: everything is common
72/// except the clamp's strictness.
73fn load_inner(path: &Path, strict_clamp: bool) -> Result<Config, CoreError> {
74    let raw = match std::fs::read_to_string(path) {
75        Ok(raw) => raw,
76        Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
77            return Ok(Config::default());
78        }
79        Err(err) => {
80            return Err(CoreError::ConfigInvalid {
81                reason: format!("cannot read {}: {err}", path.display()),
82            });
83        }
84    };
85    if raw.trim().is_empty() {
86        return Ok(Config::default());
87    }
88    warn_unknown_keys(&raw);
89    let mut config: Config = toml::from_str(&raw).map_err(|err| CoreError::ConfigInvalid {
90        reason: format!("{}: {err}", path.display()),
91    })?;
92    if strict_clamp {
93        validate(&config)?;
94    } else {
95        degrade_clamp_violations(&mut config);
96    }
97    Ok(config)
98}
99
100/// Post-deserialize validation — the sub-second clamp (08-01, TUIX-05):
101/// `poll_interval_secs = 0` is REFUSED (exit 3, `poll_interval_too_small`)
102/// rather than silently honoring a cadence that hammers the gateway. The
103/// FIRST offending profile in BTreeMap order is named (deterministic).
104/// Everything else — including the lenient-degraded defaults — passes.
105fn validate(config: &Config) -> Result<(), CoreError> {
106    for (name, profile) in &config.profiles {
107        if profile.poll_interval_secs == Some(0) {
108            return Err(CoreError::PollIntervalTooSmall {
109                profile: name.clone(),
110            });
111        }
112    }
113    Ok(())
114}
115
116/// The [`load_for_tui`] half of the clamp: substitute the default cadence
117/// for every sub-second value, warning with the SAME
118/// `poll_interval_too_small` slug/message the strict path refuses with.
119/// Only the NEW schema surface degrades; nothing else is touched.
120fn degrade_clamp_violations(config: &mut Config) {
121    for (name, profile) in &mut config.profiles {
122        if profile.poll_interval_secs == Some(0) {
123            profile.poll_interval_secs = None;
124            tracing::warn!(
125                slug = "poll_interval_too_small",
126                profile = %name,
127                "poll_interval_secs must be >= 1 (sub-second polling refused) — using the default cadence"
128            );
129        }
130    }
131}
132
133const KNOWN_TOP_LEVEL: &[&str] = &["active", "profiles", "rig", "rigs", "ui"];
134const KNOWN_PROFILE_KEYS: &[&str] = &[
135    "url",
136    "label",
137    "ssl_verify",
138    "auth",
139    "webdev_secret",
140    "poll_interval_secs",
141];
142const KNOWN_AUTH_KEYS: &[&str] = &["token_env", "keyring", "user_env", "password_env"];
143
144/// Warn (never fail) about config keys a future CLI version might
145/// understand. Invalid TOML is skipped here — [`load`] reports it properly.
146fn warn_unknown_keys(raw: &str) {
147    let Ok(table) = raw.parse::<toml::Table>() else {
148        return;
149    };
150    for (key, value) in &table {
151        if !KNOWN_TOP_LEVEL.contains(&key.as_str()) {
152            tracing::warn!(key = %key, "unknown config key (ignored)");
153        }
154        if key != "profiles" {
155            continue;
156        }
157        let Some(profiles) = value.as_table() else {
158            continue;
159        };
160        for (name, profile_value) in profiles {
161            let Some(profile_table) = profile_value.as_table() else {
162                continue;
163            };
164            for (profile_key, auth_value) in profile_table {
165                if !KNOWN_PROFILE_KEYS.contains(&profile_key.as_str()) {
166                    tracing::warn!(profile = %name, key = %profile_key, "unknown profile key (ignored)");
167                }
168                if profile_key == "auth"
169                    && let Some(auth_table) = auth_value.as_table()
170                {
171                    for auth_key in auth_table.keys() {
172                        if !KNOWN_AUTH_KEYS.contains(&auth_key.as_str()) {
173                            tracing::warn!(profile = %name, key = auth_key, "unknown auth key (ignored)");
174                        }
175                    }
176                }
177            }
178        }
179    }
180}
181
182/// Persist config to `path`, creating parent dirs. The file is written —
183/// and re-asserted — with 0600 permissions on unix (Pitfall 3.6
184/// prevention, verified by test).
185pub fn save(path: &Path, config: &Config) -> Result<(), CoreError> {
186    if let Some(parent) = path.parent() {
187        std::fs::create_dir_all(parent).map_err(|err| CoreError::ConfigInvalid {
188            reason: format!("cannot create {}: {err}", parent.display()),
189        })?;
190    }
191    let contents = toml::to_string_pretty(config).map_err(|err| CoreError::ConfigInvalid {
192        reason: format!("cannot serialize config: {err}"),
193    })?;
194    std::fs::write(path, contents).map_err(|err| CoreError::ConfigInvalid {
195        reason: format!("cannot write {}: {err}", path.display()),
196    })?;
197    enforce_0600(path)
198}
199
200/// Re-assert 0600 even when the file already existed with looser perms
201/// (`OpenOptions::mode` only applies at creation).
202#[cfg(unix)]
203fn enforce_0600(path: &Path) -> Result<(), CoreError> {
204    use std::os::unix::fs::PermissionsExt;
205
206    std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)).map_err(|err| {
207        CoreError::ConfigInvalid {
208            reason: format!("cannot set 0600 on {}: {err}", path.display()),
209        }
210    })
211}
212
213#[cfg(not(unix))]
214fn enforce_0600(_path: &Path) -> Result<(), CoreError> {
215    Ok(())
216}
217
218/// Env overlay (the `IGNITION_*` half of the LOCKED precedence): when
219/// `IGNITION_URL` is set it overrides the selected profile's URL. Profile
220/// selection env (`IGNITION_PROFILE`) is folded into the `--profile` flag by
221/// the bin; auth env resolution lives in [`secret`] — one concern per home.
222pub fn apply_env_overlay(config: &mut Config, selected_profile: Option<&str>) {
223    let Some(name) = selected_profile else { return };
224    let Ok(url_string) = std::env::var("IGNITION_URL") else {
225        return;
226    };
227    if url_string.is_empty() {
228        return;
229    }
230    let Ok(url) = url::Url::parse(&url_string) else {
231        tracing::warn!(url = %url_string, "IGNITION_URL is not a valid URL; ignoring");
232        return;
233    };
234    if let Some(profile) = config.profiles.get_mut(name) {
235        profile.url = url;
236    }
237}
238
239/// Resolve which profile a command targets: flag (which already contains
240/// `IGNITION_PROFILE` via the bin's env-defaults step) > `config.active`.
241///
242/// Unknown name → [`CoreError::ProfileNotFound`] (exit 3). Nothing found →
243/// `Ok(None)` — callers decide whether `None` is an error (gateway
244/// commands: yes, via [`CoreError::NoActiveProfile`]; `version` and
245/// `profile list` tolerate it).
246pub fn resolve_selection(
247    config: &Config,
248    flag: Option<&str>,
249) -> Result<Option<(String, Profile)>, CoreError> {
250    let name = match flag.map(str::to_owned).or_else(|| config.active.clone()) {
251        Some(name) => name,
252        None => return Ok(None),
253    };
254    match config.profiles.get(&name) {
255        Some(profile) => Ok(Some((name, profile.clone()))),
256        None => Err(CoreError::ProfileNotFound {
257            name,
258            known: config.profiles.keys().cloned().collect(),
259        }),
260    }
261}
262
263#[cfg(test)]
264mod tests {
265    use super::{
266        Config, Profile, apply_env_overlay, config_path, load, load_for_tui, resolve_selection,
267        save,
268    };
269    use crate::config::AuthRef;
270    use crate::config::ENV_LOCK;
271    use crate::error::CoreError;
272
273    use std::path::PathBuf;
274
275    fn temp_config_path() -> (tempfile::TempDir, PathBuf) {
276        let dir = tempfile::tempdir().expect("tempdir");
277        let path = dir.path().join("config.toml");
278        (dir, path)
279    }
280
281    fn sample_config() -> Config {
282        let mut config = Config {
283            active: Some("dev".into()),
284            ..Config::default()
285        };
286        config.profiles.insert(
287            "dev".into(),
288            Profile {
289                url: "http://localhost:9088/".parse().expect("url"),
290                label: Some("Dev rig".into()),
291                ssl_verify: true,
292                auth: AuthRef::TokenEnv {
293                    token_env: "IGNITION_TOKEN".into(),
294                },
295                webdev_secret: None,
296                poll_interval_secs: None,
297            },
298        );
299        config.profiles.insert(
300            "prod".into(),
301            Profile {
302                url: "https://gw.example.com:8443/".parse().expect("url"),
303                label: None,
304                ssl_verify: true,
305                auth: AuthRef::Keyring {
306                    keyring: "profile:prod".into(),
307                },
308                webdev_secret: None,
309                poll_interval_secs: None,
310            },
311        );
312        config
313    }
314
315    /// Save → load round-trips exactly, and the on-disk TOML omits `label`
316    /// when unset (`skip_serializing_if` proven at the file level).
317    #[test]
318    fn round_trip_save_load() {
319        let (_dir, path) = temp_config_path();
320        let config = sample_config();
321
322        save(&path, &config).expect("save");
323        let reloaded = load(&path).expect("load");
324        assert_eq!(reloaded, config, "round trip must be lossless");
325
326        let raw = std::fs::read_to_string(&path).expect("read raw");
327        assert!(raw.contains("label = \"Dev rig\""));
328        let prod_section = raw
329            .split("[profiles.prod]")
330            .nth(1)
331            .expect("prod section serialized");
332        assert!(
333            !prod_section.contains("label"),
334            "unset label must not be serialized: {prod_section}",
335        );
336    }
337
338    /// Config is written with 0600 perms — and re-asserted on overwrite
339    /// even if something loosened them (Pitfall 3.6 prevention).
340    #[test]
341    #[cfg(unix)]
342    fn save_enforces_0600_and_creates_parents() {
343        use std::os::unix::fs::PermissionsExt;
344
345        let (_dir, path) = temp_config_path();
346        let nested = path.parent().unwrap().join("nested/deeper/config.toml");
347
348        save(&nested, &sample_config()).expect("save creates parent dirs");
349        let mode = std::fs::metadata(&nested)
350            .expect("metadata")
351            .permissions()
352            .mode();
353        assert_eq!(mode & 0o777, 0o600, "fresh config must be 0600");
354
355        // Loosen, save again → still 0600 afterwards.
356        std::fs::set_permissions(&nested, std::fs::Permissions::from_mode(0o644)).expect("loosen");
357        save(&nested, &sample_config()).expect("save again");
358        let mode = std::fs::metadata(&nested)
359            .expect("metadata")
360            .permissions()
361            .mode();
362        assert_eq!(mode & 0o777, 0o600, "overwrite must re-assert 0600");
363    }
364
365    /// Unknown TOML keys warn but do NOT fail the load (Pitfall 7).
366    #[test]
367    fn unknown_keys_warn_but_do_not_fail() {
368        let (_dir, path) = temp_config_path();
369        std::fs::write(
370            &path,
371            r#"
372future_top_level = "whatever"
373active = "dev"
374
375[profiles.dev]
376url = "http://localhost:9088/"
377future_profile_key = 42
378
379[profiles.dev.auth]
380token_env = "IGNITION_TOKEN"
381future_auth_key = "x"
382"#,
383        )
384        .expect("write");
385
386        let config = load(&path).expect("unknown keys must not fail the load");
387        assert_eq!(config.active.as_deref(), Some("dev"));
388        assert!(config.profiles.contains_key("dev"));
389    }
390
391    /// Warn-silent pin (TUIX-05): the NEW schema keys (`ui`, `poll_interval_secs`)
392    /// must be on the warn-lists so loading a config that carries them emits
393    /// NO unknown-key warning. Membership asserted against the private lists —
394    /// the lists ARE the warning behavior.
395    #[test]
396    fn new_schema_keys_are_warn_silent() {
397        assert!(
398            super::KNOWN_TOP_LEVEL.contains(&"ui"),
399            "KNOWN_TOP_LEVEL must carry \"ui\""
400        );
401        assert!(
402            super::KNOWN_PROFILE_KEYS.contains(&"poll_interval_secs"),
403            "KNOWN_PROFILE_KEYS must carry \"poll_interval_secs\""
404        );
405
406        // And a config carrying both loads cleanly (behavioral half).
407        let (_dir, path) = temp_config_path();
408        std::fs::write(
409            &path,
410            r#"
411[ui]
412theme = "dark"
413
414[profiles.dev]
415url = "http://localhost:9088/"
416poll_interval_secs = 10
417"#,
418        )
419        .expect("write");
420        let config = load(&path).expect("new keys must not fail the load");
421        assert_eq!(config.ui.theme.as_deref(), Some("dark"));
422        assert_eq!(config.profiles["dev"].poll_interval_secs, Some(10));
423    }
424
425    /// The sub-second clamp (08-01): `poll_interval_secs = 0` is refused
426    /// with the additive slug on the config class — exit 3, never a new
427    /// exit code.
428    #[test]
429    fn poll_interval_zero_is_refused() {
430        let (_dir, path) = temp_config_path();
431        std::fs::write(
432            &path,
433            "[profiles.dev]\nurl = \"http://localhost:9088/\"\npoll_interval_secs = 0\n",
434        )
435        .expect("write");
436
437        let err = load(&path).expect_err("0 must be refused");
438        assert_eq!(err.code(), "poll_interval_too_small");
439        assert_eq!(err.exit_code(), 3, "config class — no new exit code");
440        let message = err.to_string();
441        assert!(
442            message.contains("dev") && message.contains("sub-second"),
443            "refusal must name the profile + the rule: {message}"
444        );
445        let hint = err.hint().expect("hint required");
446        assert!(
447            hint.contains("[profiles.dev]") && hint.contains("poll_interval_secs"),
448            "hint must point at the profile key: {hint}"
449        );
450    }
451
452    /// The floor is 1: `Some(1)` passes the clamp.
453    #[test]
454    fn poll_interval_one_is_the_floor() {
455        let (_dir, path) = temp_config_path();
456        std::fs::write(
457            &path,
458            "[profiles.dev]\nurl = \"http://localhost:9088/\"\npoll_interval_secs = 1\n",
459        )
460        .expect("write");
461
462        let config = load(&path).expect("1 is the floor — must load");
463        assert_eq!(config.profiles["dev"].poll_interval_secs, Some(1));
464    }
465
466    /// `load_for_tui` (08-01): a clamp violation on the NEW schema surface
467    /// degrades to the default cadence with a warning — Ok, field None.
468    #[test]
469    fn load_for_tui_degrades_clamp_violation() {
470        let (_dir, path) = temp_config_path();
471        std::fs::write(
472            &path,
473            "[profiles.dev]\nurl = \"http://localhost:9088/\"\npoll_interval_secs = 0\n",
474        )
475        .expect("write");
476
477        let config = load_for_tui(&path).expect("the TUI degrades the clamp instead of refusing");
478        assert_eq!(
479            config.profiles["dev"].poll_interval_secs, None,
480            "sub-second value substituted with the default cadence"
481        );
482    }
483
484    /// `load_for_tui` degrades ONLY the new-surface clamp: a broken
485    /// profile URL is a RESOLUTION failure and stays fatal (the TUI is
486    /// an authed cockpit — a config that cannot name a reachable profile
487    /// cannot start it).
488    #[test]
489    fn load_for_tui_still_refuses_broken_profile_url() {
490        let (_dir, path) = temp_config_path();
491        std::fs::write(
492            &path,
493            "[profiles.dev]\nurl = \"not a url at all\"\npoll_interval_secs = 5\n",
494        )
495        .expect("write");
496
497        let err = load_for_tui(&path).expect_err("broken profile url is fatal");
498        assert_eq!(err.exit_code(), 3, "config_invalid class");
499        assert_eq!(err.code(), "config_invalid");
500    }
501
502    /// `load_for_tui` on garbage TOML: raw parse failure stays fatal.
503    #[test]
504    fn load_for_tui_still_refuses_garbage_toml() {
505        let (_dir, path) = temp_config_path();
506        std::fs::write(&path, "this is ][ not toml\n").expect("write");
507
508        let err = load_for_tui(&path).expect_err("garbage toml is fatal");
509        assert_eq!(err.exit_code(), 3);
510        assert_eq!(err.code(), "config_invalid");
511    }
512
513    /// Missing file is a fresh install, not an error; with no flag and no
514    /// active profile, selection is `Ok(None)` (version/profile-list
515    /// tolerate it).
516    #[test]
517    fn missing_file_and_no_selection_resolve_none() {
518        let (_dir, path) = temp_config_path();
519        assert!(!path.exists(), "fixture sanity");
520
521        let config = load(&path).expect("missing file is not an error");
522        assert_eq!(config, Config::default());
523
524        let selection =
525            resolve_selection(&config, None).expect("no active + no flag is not an error");
526        assert!(selection.is_none());
527    }
528
529    /// Unknown profile → `ProfileNotFound` (exit 3) carrying the known
530    /// profile list for the hint.
531    #[test]
532    fn unknown_profile_lists_known() {
533        let config = sample_config();
534        let err = resolve_selection(&config, Some("nope")).expect_err("unknown profile errors");
535        match err {
536            CoreError::ProfileNotFound {
537                ref name,
538                ref known,
539            } => {
540                assert_eq!(name, "nope");
541                assert_eq!(known, &vec!["dev".to_string(), "prod".to_string()]);
542            }
543            other => panic!("wrong error class: {other}"),
544        }
545        assert_eq!(err.exit_code(), 3);
546        let hint = err.hint().expect("hint");
547        assert!(
548            hint.contains("dev") && hint.contains("prod"),
549            "hint names knowns: {hint}"
550        );
551    }
552
553    /// Flag > active: an explicit flag selects a non-active profile.
554    #[test]
555    fn selection_flag_beats_active() {
556        let config = sample_config(); // active = dev
557        let (name, profile) = resolve_selection(&config, Some("prod"))
558            .expect("flag selects prod")
559            .expect("some");
560        assert_eq!(name, "prod");
561        assert_eq!(
562            profile.auth,
563            AuthRef::Keyring {
564                keyring: "profile:prod".into()
565            }
566        );
567    }
568
569    /// `IGNITION_URL` overrides the SELECTED profile only (env overlay
570    /// scoped, per the LOCKED precedence).
571    #[test]
572    fn env_overlay_overrides_selected_profile_url() {
573        let _lock = ENV_LOCK.lock().expect("env lock");
574        // SAFETY: single-threaded under ENV_LOCK; restored before return.
575        unsafe { std::env::set_var("IGNITION_URL", "http://override.example:7000") };
576
577        let mut config = sample_config();
578        apply_env_overlay(&mut config, Some("dev"));
579        assert_eq!(
580            config.profiles["dev"].url.as_str(),
581            "http://override.example:7000/",
582            "selected profile URL overridden",
583        );
584        assert_eq!(
585            config.profiles["prod"].url.as_str(),
586            "https://gw.example.com:8443/",
587            "other profiles untouched",
588        );
589
590        // No selection → no-op even with the env set.
591        let mut config = sample_config();
592        apply_env_overlay(&mut config, None);
593        assert_eq!(
594            config.profiles["dev"].url.as_str(),
595            "http://localhost:9088/",
596            "no selected profile → overlay is a no-op",
597        );
598
599        // SAFETY: single-threaded under ENV_LOCK.
600        unsafe { std::env::remove_var("IGNITION_URL") };
601    }
602
603    /// `IGNITION_CLI_CONFIG` wins over the platform path; without it, the
604    /// platform path ends in `config.toml`.
605    #[test]
606    fn config_path_env_override_first() {
607        let _lock = ENV_LOCK.lock().expect("env lock");
608        let dir = tempfile::tempdir().expect("tempdir");
609        let override_path = dir.path().join("my-config.toml");
610
611        // SAFETY: single-threaded under ENV_LOCK; removed before return.
612        unsafe { std::env::set_var("IGNITION_CLI_CONFIG", &override_path) };
613        assert_eq!(config_path(), override_path, "env override wins");
614        // SAFETY: single-threaded under ENV_LOCK.
615        unsafe { std::env::remove_var("IGNITION_CLI_CONFIG") };
616
617        assert!(
618            config_path().ends_with("config.toml"),
619            "platform fallback lands on config.toml: {}",
620            config_path().display(),
621        );
622    }
623}