Skip to main content

elasticctl_core/
config.rs

1//! Profiles, their on-disk form, and resolution order.
2//!
3//! Flags override environment variables, which override profiles and defaults.
4
5use crate::error::{Error, ErrorKind, Result};
6use serde::{Deserialize, Serialize};
7use std::collections::BTreeMap;
8use std::fs;
9use std::path::{Path, PathBuf};
10
11/// A Kibana space id is one URL path segment. An empty space means the
12/// default space, as it always has.
13pub(crate) fn validate_space(space: &str) -> Result<()> {
14    if space.is_empty() {
15        return Ok(());
16    }
17    if !space
18        .bytes()
19        .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || matches!(b, b'_' | b'-'))
20    {
21        return Err(Error::new(ErrorKind::Error, "invalid space id"));
22    }
23    Ok(())
24}
25
26const REDACTED: &str = "***";
27
28/// Whether overrides change the target deployment or credential.
29///
30/// Only these overrides change the guard banner's reported source. Timeout and
31/// space overrides do not.
32fn is_identity_override(ov: &Overrides) -> bool {
33    ov.kibana_url.is_some() || ov.es_url.is_some() || ov.api_key.is_some()
34}
35
36/// Read an environment variable, distinguishing absence from invalid Unicode.
37///
38/// The error names the variable but never includes its raw bytes, so a binary
39/// value cannot leak into a message or log.
40fn checked_env(name: &str) -> Result<Option<String>> {
41    match std::env::var_os(name) {
42        None => Ok(None),
43        Some(value) => value.into_string().map(Some).map_err(|_| {
44            Error::new(
45                ErrorKind::Error,
46                format!("{name} contains invalid Unicode; set it to valid UTF-8"),
47            )
48        }),
49    }
50}
51
52#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
53pub struct Profile {
54    pub kibana_url: String,
55    #[serde(default, skip_serializing_if = "Option::is_none")]
56    pub es_url: Option<String>,
57    #[serde(default, skip_serializing_if = "Option::is_none")]
58    pub api_key: Option<String>,
59    #[serde(default, skip_serializing_if = "Option::is_none")]
60    pub username: Option<String>,
61    #[serde(default, skip_serializing_if = "Option::is_none")]
62    pub password: Option<String>,
63    #[serde(default = "default_space")]
64    pub space: String,
65    #[serde(default = "default_verify")]
66    pub verify: bool,
67    #[serde(default = "default_timeout")]
68    pub timeout_secs: u64,
69}
70
71fn default_space() -> String {
72    "default".to_string()
73}
74fn default_verify() -> bool {
75    true
76}
77fn default_timeout() -> u64 {
78    30
79}
80
81impl std::fmt::Debug for Profile {
82    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
83        f.debug_struct("Profile")
84            .field("kibana_url", &strip_userinfo(&self.kibana_url))
85            .field("es_url", &self.es_url.as_deref().map(strip_userinfo))
86            .field("api_key", &self.api_key.as_ref().map(|_| REDACTED))
87            .field("username", &self.username)
88            .field("password", &self.password.as_ref().map(|_| REDACTED))
89            .field("space", &self.space)
90            .field("verify", &self.verify)
91            .field("timeout_secs", &self.timeout_secs)
92            .finish()
93    }
94}
95
96impl Profile {
97    /// Return a copy safe to print.
98    ///
99    /// Secrets become `***`; absent values remain absent. URL userinfo is
100    /// stripped too, so a credential embedded in a URL never leaks.
101    pub fn redacted(&self) -> Profile {
102        let mut scrubbed = self.clone();
103        scrubbed.strip_userinfo();
104        Profile {
105            api_key: self.api_key.as_ref().map(|_| REDACTED.to_string()),
106            password: self.password.as_ref().map(|_| REDACTED.to_string()),
107            ..scrubbed
108        }
109    }
110
111    /// Return the Kibana URL host for banners.
112    ///
113    /// Userinfo is stripped first, then the host is parsed. Return the scrubbed
114    /// URL when no host can be parsed.
115    pub fn host(&self) -> String {
116        // Strip userinfo so a credential in the URL never reaches a banner.
117        let url = strip_userinfo(&self.kibana_url);
118        // Parse the authority host. `scheme_anchor` handles a doubled scheme;
119        // the split drops the path, query, and fragment.
120        if let Some(pos) = scheme_anchor(&url) {
121            let after_scheme = &url[pos..];
122            let host_part = after_scheme.split(['/', '?', '#']).next().unwrap_or("");
123            // Use the parsed host when present.
124            if !host_part.is_empty() {
125                return host_part.to_string();
126            }
127        }
128        // Preserve the scrubbed URL when parsing finds no host.
129        url
130    }
131
132    /// Remove userinfo from `kibana_url` and `es_url`.
133    ///
134    /// Credentials use `api_key` or `username` and `password`; the transport
135    /// never reads URL userinfo. Removing it prevents credentials appearing in
136    /// the guard banner, `config show`, or `--debug` output.
137    pub fn strip_userinfo(&mut self) {
138        self.kibana_url = strip_userinfo(&self.kibana_url);
139        self.es_url = self.es_url.as_deref().map(strip_userinfo);
140    }
141}
142
143/// Return the byte index just past the scheme's `://`.
144///
145/// Shared with flavor detection's `host_of`.
146///
147/// The scheme is normally the first `://`. A doubled scheme
148/// (`https://https://host`) puts a second `://` inside the authority, so the
149/// first `/` after the first `://` is itself part of that `://`; anchor on that
150/// second `://` then so the authority after it parses correctly. A `://` in the
151/// path, query, or fragment never becomes the anchor.
152pub(crate) fn scheme_anchor(url: &str) -> Option<usize> {
153    let first = url.find("://")?;
154    let after = &url[first + 3..];
155    let Some(delim) = after.find(['/', '?', '#']) else {
156        return Some(first + 3);
157    };
158    // A doubled scheme is the only case where the first delimiter is itself the
159    // `/` of a second `://`. An empty port (`https://host:/path`) also ends the
160    // authority in `:`, but the bytes after it are not `//`, so it is not
161    // doubled and must anchor on the first scheme.
162    if delim >= 1
163        && after.as_bytes()[delim - 1] == b':'
164        && after.get(delim..delim + 2) == Some("//")
165    {
166        // Anchor just past the second `://`, never on a later `://` in the
167        // path, query, or fragment.
168        Some(first + 3 + delim + 2)
169    } else {
170        Some(first + 3)
171    }
172}
173
174/// Remove userinfo from a URL authority without changing other bytes.
175///
176/// Userinfo is the `user:password@` in the authority, delimited by the scheme's
177/// `://` and the first `/`, `?`, or `#` after it. Paths and queries may
178/// legitimately contain `@`; only an `@` inside the authority is userinfo. A
179/// `://` in the path, query, or fragment is not the scheme and must not defeat
180/// the strip, while a doubled scheme still strips its userinfo.
181fn strip_userinfo(url: &str) -> String {
182    let Some(scheme_end) = scheme_anchor(url) else {
183        return url.to_string();
184    };
185    let (scheme, rest) = url.split_at(scheme_end);
186    let authority_end = rest.find(['/', '?', '#']).unwrap_or(rest.len());
187    let (authority, tail) = rest.split_at(authority_end);
188    match authority.rfind('@') {
189        Some(i) => format!("{scheme}{}{tail}", &authority[i + 1..]),
190        None => url.to_string(),
191    }
192}
193
194#[derive(Debug, Clone, Default, Serialize, Deserialize)]
195pub struct Config {
196    #[serde(default)]
197    pub current: String,
198    #[serde(default)]
199    pub profiles: BTreeMap<String, Profile>,
200}
201
202#[derive(Debug, Clone, Copy, PartialEq, Eq)]
203pub enum Source {
204    Profile,
205    Env,
206    Flags,
207}
208
209#[derive(Debug, Clone)]
210pub struct Resolved {
211    pub profile: Profile,
212    pub name: String,
213    pub source: Source,
214}
215
216impl Resolved {
217    pub fn banner(&self) -> String {
218        format!(
219            "profile: {} @ {}, space: {}",
220            self.name,
221            self.profile.host(),
222            self.profile.space
223        )
224    }
225}
226
227#[derive(Debug, Clone, Default)]
228pub struct Overrides {
229    pub kibana_url: Option<String>,
230    pub es_url: Option<String>,
231    pub api_key: Option<String>,
232    pub space: Option<String>,
233    pub timeout_secs: Option<u64>,
234}
235
236impl Overrides {
237    /// Read `ELASTICCTL_*` environment variables as overrides.
238    ///
239    /// `es_url` and `kibana_url` are identity overrides. Cloud deployments use
240    /// distinct endpoints; inheriting one from a saved profile could target two
241    /// deployments and send the overridden credential to the wrong host.
242    pub fn from_env() -> Overrides {
243        Overrides {
244            kibana_url: std::env::var("ELASTICCTL_KIBANA_URL").ok(),
245            es_url: std::env::var("ELASTICCTL_ES_URL").ok(),
246            api_key: std::env::var("ELASTICCTL_API_KEY").ok(),
247            space: std::env::var("ELASTICCTL_SPACE").ok(),
248            timeout_secs: std::env::var("ELASTICCTL_TIMEOUT")
249                .ok()
250                .and_then(|v| v.parse().ok()),
251        }
252    }
253
254    /// Read `ELASTICCTL_*` environment variables as overrides, failing on
255    /// invalid input.
256    ///
257    /// Unlike `from_env`, this distinguishes an absent variable from invalid
258    /// Unicode and rejects a non-integer timeout instead of silently dropping
259    /// it.
260    pub fn try_from_env() -> Result<Overrides> {
261        Self::try_from_env_with_flags(&Overrides::default())
262    }
263
264    /// Read `ELASTICCTL_*` as overrides, failing on invalid input for every
265    /// field the given flags do not already override.
266    ///
267    /// A flag overrides its environment variable, so a stale invalid value in
268    /// an overridden field must not fail the command. `Context::build` passes
269    /// the CLI flags here; `config init --from-env` uses the strict
270    /// `try_from_env` because the environment is its source of truth.
271    pub fn try_from_env_with_flags(flags: &Overrides) -> Result<Overrides> {
272        let timeout = if flags.timeout_secs.is_some() {
273            std::env::var("ELASTICCTL_TIMEOUT")
274                .ok()
275                .and_then(|v| v.parse().ok())
276        } else {
277            match checked_env("ELASTICCTL_TIMEOUT")? {
278                None => None,
279                Some(value) => Some(value.parse::<u64>().map_err(|error| {
280                    Error::new(
281                        ErrorKind::Error,
282                        format!("ELASTICCTL_TIMEOUT must be an unsigned integer: {error}"),
283                    )
284                })?),
285            }
286        };
287        let space = if flags.space.is_some() {
288            std::env::var("ELASTICCTL_SPACE").ok()
289        } else {
290            checked_env("ELASTICCTL_SPACE")?
291        };
292        Ok(Overrides {
293            kibana_url: checked_env("ELASTICCTL_KIBANA_URL")?,
294            es_url: checked_env("ELASTICCTL_ES_URL")?,
295            api_key: checked_env("ELASTICCTL_API_KEY")?,
296            space,
297            timeout_secs: timeout,
298        })
299    }
300
301    /// Merge overrides, preferring `self` over `lower`.
302    pub fn merge_over(self, lower: Overrides) -> Overrides {
303        Overrides {
304            kibana_url: self.kibana_url.or(lower.kibana_url),
305            es_url: self.es_url.or(lower.es_url),
306            api_key: self.api_key.or(lower.api_key),
307            space: self.space.or(lower.space),
308            timeout_secs: self.timeout_secs.or(lower.timeout_secs),
309        }
310    }
311}
312
313impl Config {
314    pub fn default_path() -> PathBuf {
315        directories::UserDirs::new()
316            .map(|d| d.home_dir().to_path_buf())
317            .unwrap_or_else(|| PathBuf::from("."))
318            .join(".elasticctl")
319            .join("config.toml")
320    }
321
322    /// Treat a missing file as an empty config so `config init` works on a new
323    /// machine.
324    pub fn load(path: &Path) -> Result<Config> {
325        if !path.exists() {
326            return Ok(Config::default());
327        }
328        let body = fs::read_to_string(path).map_err(|e| {
329            Error::new(ErrorKind::Error, format!("reading {}: {e}", path.display()))
330        })?;
331        toml::from_str(&body)
332            .map_err(|e| Error::new(ErrorKind::Error, format!("parsing {}: {e}", path.display())))
333    }
334
335    /// Describe insecure file permissions, or return `None` for absent or
336    /// owner-only files.
337    ///
338    /// The caller decides whether and how to display this warning, including
339    /// for CLI `--json` output.
340    #[cfg(unix)]
341    pub fn permission_warning(path: &Path) -> Option<String> {
342        use std::os::unix::fs::PermissionsExt;
343        let metadata = fs::metadata(path).ok()?;
344        let mode = metadata.permissions().mode();
345        // Group or other permission bits are set.
346        if mode & 0o077 != 0 {
347            Some(format!(
348                "config file {} is readable by group or other (mode {:o}); should be 0600",
349                path.display(),
350                mode & 0o777
351            ))
352        } else {
353            None
354        }
355    }
356
357    #[cfg(not(unix))]
358    pub fn permission_warning(_path: &Path) -> Option<String> {
359        None
360    }
361
362    pub fn save(&self, path: &Path) -> Result<()> {
363        let parent = path
364            .parent()
365            .filter(|parent| !parent.as_os_str().is_empty())
366            .unwrap_or_else(|| Path::new("."));
367        fs::create_dir_all(parent).map_err(|e| {
368            Error::new(
369                ErrorKind::Error,
370                format!("creating {}: {e}", parent.display()),
371            )
372        })?;
373        // Strip userinfo from every profile before serializing, so a credential
374        // embedded in a URL is never written to disk by a direct library caller.
375        let mut scrubbed = self.clone();
376        for profile in scrubbed.profiles.values_mut() {
377            profile.strip_userinfo();
378        }
379        let body = toml::to_string_pretty(&scrubbed)
380            .map_err(|e| Error::new(ErrorKind::Error, format!("serializing config: {e}")))?;
381
382        // Write to a same-directory temporary file, then rename it over the
383        // destination. This replaces the file atomically instead of truncating
384        // it in place, so an existing loose-permission file or a symlink is
385        // never written through.
386        let mut pending = tempfile::Builder::new()
387            .prefix(".elasticctl-config-")
388            .tempfile_in(parent)
389            .map_err(|e| {
390                Error::new(ErrorKind::Error, format!("writing {}: {e}", path.display()))
391            })?;
392        #[cfg(unix)]
393        {
394            use std::os::unix::fs::PermissionsExt;
395            pending
396                .as_file()
397                .set_permissions(fs::Permissions::from_mode(0o600))
398                .map_err(|e| {
399                    Error::new(ErrorKind::Error, format!("writing {}: {e}", path.display()))
400                })?;
401        }
402        use std::io::Write;
403        pending.write_all(body.as_bytes()).map_err(|e| {
404            Error::new(ErrorKind::Error, format!("writing {}: {e}", path.display()))
405        })?;
406        pending.as_file().sync_all().map_err(|e| {
407            Error::new(ErrorKind::Error, format!("writing {}: {e}", path.display()))
408        })?;
409        pending.persist(path).map_err(|e| {
410            Error::new(ErrorKind::Error, format!("writing {}: {e}", path.display()))
411        })?;
412        // Sync the directory so the rename is durable. Only Linux supports
413        // directory fsync; macOS and BSD return EINVAL on a directory fd.
414        #[cfg(target_os = "linux")]
415        fs::File::open(parent)
416            .and_then(|f| f.sync_all())
417            .map_err(|e| {
418                Error::new(ErrorKind::Error, format!("writing {}: {e}", path.display()))
419            })?;
420        Ok(())
421    }
422
423    /// Resolve the effective profile and its source.
424    pub fn resolve(&self, name: Option<&str>, ov: &Overrides) -> Result<Resolved> {
425        let wanted = name.unwrap_or(if self.current.is_empty() {
426            "default"
427        } else {
428            &self.current
429        });
430        let mut profile = self.profiles.get(wanted).cloned().ok_or_else(|| {
431            Error::new(ErrorKind::NotFound, format!("Profile '{wanted}' not found"))
432        })?;
433
434        if let Some(v) = &ov.kibana_url {
435            profile.kibana_url = v.clone();
436        }
437        // Do not combine an overridden Kibana URL with a profile Elasticsearch
438        // URL: they can target separate deployments. Without an `es_url`
439        // override, fall back to the Kibana host instead of sending credentials
440        // to the profile's Elasticsearch host.
441        if let Some(v) = &ov.es_url {
442            profile.es_url = Some(v.clone());
443        } else if ov.kibana_url.is_some() {
444            profile.es_url = None;
445        }
446        if let Some(v) = &ov.api_key {
447            profile.api_key = Some(v.clone());
448        }
449        if let Some(v) = &ov.space {
450            profile.space = v.clone();
451        }
452        if let Some(v) = ov.timeout_secs {
453            profile.timeout_secs = v;
454        }
455
456        validate_space(&profile.space)?;
457
458        let source = if is_identity_override(ov) {
459            Source::Flags
460        } else {
461            Source::Profile
462        };
463
464        // Strip userinfo after applying file, environment, and flag values.
465        profile.strip_userinfo();
466
467        Ok(Resolved {
468            profile,
469            name: wanted.to_string(),
470            source,
471        })
472    }
473}
474
475#[cfg(test)]
476mod tests {
477    use super::*;
478    use std::fs;
479
480    #[test]
481    fn invalid_spaces_are_refused_during_resolution() {
482        for space in [
483            ".", "..", "a/b", "a?b", "%2e%2e", "UPPER", "a b", "é", "a\\b",
484        ] {
485            let mut config = sample();
486            config.profiles.get_mut("default").unwrap().space = space.into();
487            for overrides in [
488                Overrides::default(),
489                Overrides {
490                    space: Some(space.into()),
491                    ..Default::default()
492                },
493            ] {
494                let error = config.resolve(None, &overrides).unwrap_err();
495                assert_eq!(error.kind, ErrorKind::Error);
496                assert_eq!(error.message, "invalid space id");
497            }
498        }
499    }
500
501    fn sample() -> Config {
502        let mut profiles = BTreeMap::new();
503        profiles.insert(
504            "default".to_string(),
505            Profile {
506                kibana_url: "https://kb.example.com".into(),
507                es_url: Some("https://es.example.com".into()),
508                api_key: Some("essu_SECRET".into()),
509                username: Some("user".into()),
510                password: Some("pass_SECRET".into()),
511                space: "default".into(),
512                verify: true,
513                timeout_secs: 30,
514            },
515        );
516        profiles.insert(
517            "prod".to_string(),
518            Profile {
519                kibana_url: "https://prod.example.com".into(),
520                ..profiles["default"].clone()
521            },
522        );
523        Config {
524            current: "default".into(),
525            profiles,
526        }
527    }
528
529    #[test]
530    fn round_trips_through_toml() {
531        let dir = tempfile::tempdir().unwrap();
532        let path = dir.path().join("config.toml");
533        sample().save(&path).unwrap();
534        let loaded = Config::load(&path).unwrap();
535        assert_eq!(loaded.current, "default");
536        assert_eq!(loaded.profiles.len(), 2);
537        assert_eq!(
538            loaded.profiles["prod"].kibana_url,
539            "https://prod.example.com"
540        );
541    }
542
543    #[test]
544    fn save_enforces_owner_only_permissions() {
545        use std::os::unix::fs::PermissionsExt;
546        let dir = tempfile::tempdir().unwrap();
547        let path = dir.path().join("config.toml");
548        sample().save(&path).unwrap();
549        let mode = fs::metadata(&path).unwrap().permissions().mode() & 0o777;
550        assert_eq!(mode, 0o600, "config must not be readable by group or other");
551    }
552
553    #[cfg(unix)]
554    #[test]
555    fn save_atomically_replaces_a_permissive_existing_file() {
556        use std::os::unix::fs::{MetadataExt, PermissionsExt};
557        let dir = tempfile::tempdir().unwrap();
558        let path = dir.path().join("config.toml");
559        fs::write(&path, "old\n").unwrap();
560        fs::set_permissions(&path, fs::Permissions::from_mode(0o644)).unwrap();
561        let old_inode = fs::metadata(&path).unwrap().ino();
562
563        sample().save(&path).unwrap();
564
565        let metadata = fs::metadata(&path).unwrap();
566        assert_ne!(metadata.ino(), old_inode, "save must replace, not truncate");
567        assert_eq!(metadata.permissions().mode() & 0o777, 0o600);
568        assert_eq!(Config::load(&path).unwrap().current, "default");
569    }
570
571    #[cfg(unix)]
572    #[test]
573    fn save_replaces_a_symlink_without_writing_its_target() {
574        use std::os::unix::fs::symlink;
575        let dir = tempfile::tempdir().unwrap();
576        let target = dir.path().join("target.toml");
577        let path = dir.path().join("config.toml");
578        fs::write(&target, "do not replace\n").unwrap();
579        symlink(&target, &path).unwrap();
580
581        sample().save(&path).unwrap();
582
583        assert_eq!(fs::read_to_string(&target).unwrap(), "do not replace\n");
584        assert!(
585            !fs::symlink_metadata(&path)
586                .unwrap()
587                .file_type()
588                .is_symlink()
589        );
590    }
591
592    #[test]
593    fn save_never_writes_userinfo_to_disk() {
594        let dir = tempfile::tempdir().unwrap();
595        let path = dir.path().join("config.toml");
596        let cfg = with_urls(
597            "https://user:pass@kb.example.com",
598            Some("https://user:pass@es.example.com"),
599        );
600        cfg.save(&path).unwrap();
601        let body = fs::read_to_string(&path).unwrap();
602        assert!(!body.contains("user:pass"), "{body}");
603        assert!(!body.contains("user:"), "{body}");
604        assert!(body.contains("https://kb.example.com"), "{body}");
605        assert!(body.contains("https://es.example.com"), "{body}");
606    }
607
608    #[test]
609    fn load_of_a_missing_file_is_an_empty_config_not_an_error() {
610        let dir = tempfile::tempdir().unwrap();
611        let cfg = Config::load(&dir.path().join("absent.toml")).unwrap();
612        assert!(cfg.profiles.is_empty());
613    }
614
615    #[test]
616    fn resolving_an_unknown_profile_is_a_not_found_error() {
617        let err = sample()
618            .resolve(Some("nope"), &Overrides::default())
619            .unwrap_err();
620        assert_eq!(err.kind, ErrorKind::NotFound);
621        assert!(err.message.contains("nope"));
622    }
623
624    /// Verify that `ELASTICCTL_ES_URL` overrides the profile. This prevents a
625    /// Kibana override and inherited `es_url` from targeting two deployments.
626    #[test]
627    fn an_es_url_override_is_applied() {
628        let r = sample()
629            .resolve(
630                None,
631                &Overrides {
632                    es_url: Some("https://other-es.example.com".into()),
633                    ..Default::default()
634                },
635            )
636            .unwrap();
637        assert_eq!(
638            r.profile.es_url.as_deref(),
639            Some("https://other-es.example.com")
640        );
641    }
642
643    /// A Kibana-only override must clear the profile's Elasticsearch host.
644    /// Otherwise credentials could be sent to an unselected deployment.
645    #[test]
646    fn overriding_only_the_kibana_url_clears_the_profiles_es_url() {
647        let r = sample()
648            .resolve(
649                None,
650                &Overrides {
651                    kibana_url: Some("https://other-kb.example.com".into()),
652                    ..Default::default()
653                },
654            )
655            .unwrap();
656        assert_eq!(
657            r.profile.es_url, None,
658            "an inherited es_url would point at the profile's stack, not the overridden one"
659        );
660    }
661
662    #[test]
663    fn an_es_url_override_counts_as_an_identity_override() {
664        let r = sample()
665            .resolve(
666                None,
667                &Overrides {
668                    es_url: Some("https://other-es.example.com".into()),
669                    ..Default::default()
670                },
671            )
672            .unwrap();
673        assert_eq!(
674            r.source,
675            Source::Flags,
676            "changing which stack is addressed is an identity change"
677        );
678    }
679
680    #[test]
681    fn resolve_defaults_to_the_current_profile() {
682        let r = sample().resolve(None, &Overrides::default()).unwrap();
683        assert_eq!(r.name, "default");
684        assert_eq!(r.source, Source::Profile);
685        assert_eq!(r.profile.kibana_url, "https://kb.example.com");
686    }
687
688    #[test]
689    fn flags_override_the_profile_and_change_the_reported_source() {
690        let ov = Overrides {
691            kibana_url: Some("https://override.example.com".into()),
692            ..Default::default()
693        };
694        let r = sample().resolve(None, &ov).unwrap();
695        assert_eq!(r.profile.kibana_url, "https://override.example.com");
696        assert_eq!(
697            r.source,
698            Source::Flags,
699            "an identity override changes provenance"
700        );
701    }
702
703    #[test]
704    fn a_non_identity_override_does_not_change_the_source() {
705        let ov = Overrides {
706            timeout_secs: Some(90),
707            ..Default::default()
708        };
709        let r = sample().resolve(None, &ov).unwrap();
710        assert_eq!(r.profile.timeout_secs, 90);
711        assert_eq!(
712            r.source,
713            Source::Profile,
714            "timeout is not an identity field"
715        );
716    }
717
718    #[test]
719    fn redacted_hides_every_secret_field() {
720        let p = sample().profiles["default"].redacted();
721        assert_eq!(
722            p.api_key.as_deref(),
723            Some("***"),
724            "api_key must be redacted"
725        );
726        assert_eq!(
727            p.password.as_deref(),
728            Some("***"),
729            "password must be redacted"
730        );
731        assert_eq!(
732            p.kibana_url, "https://kb.example.com",
733            "non-secrets stay visible"
734        );
735    }
736
737    #[test]
738    fn redacted_leaves_absent_secrets_absent() {
739        let mut p = sample().profiles["default"].clone();
740        p.api_key = None;
741        p.password = None;
742        let redacted = p.redacted();
743        assert_eq!(redacted.api_key, None, "absent api_key stays absent");
744        assert_eq!(redacted.password, None, "absent password stays absent");
745    }
746
747    #[test]
748    fn redacted_strips_userinfo_from_both_urls() {
749        let mut p = sample().profiles["default"].clone();
750        p.kibana_url = "https://user:pass@kb.example.com".into();
751        p.es_url = Some("https://user:pass@es.example.com".into());
752        let r = p.redacted();
753        assert_eq!(r.kibana_url, "https://kb.example.com");
754        assert_eq!(r.es_url.as_deref(), Some("https://es.example.com"));
755        assert_eq!(r.api_key.as_deref(), Some("***"), "secrets still redact");
756    }
757
758    #[test]
759    fn debug_never_prints_a_secret_or_userinfo() {
760        let profile = with_urls(
761            "https://user:pass@kb.example.com",
762            Some("https://user:pass@es.example.com"),
763        )
764        .profiles["default"]
765            .clone();
766        let out = format!("{profile:?}");
767        assert!(!out.contains("essu_SECRET"), "api key leaked: {out}");
768        // `user:pass` and `pass@` name the userinfo; the field name `password`
769        // itself legitimately contains `pass`, so a bare `pass` would false-fail.
770        assert!(!out.contains("user:pass"), "userinfo leaked: {out}");
771        assert!(!out.contains("pass@"), "userinfo leaked: {out}");
772        assert!(out.contains("kb.example.com"), "host missing: {out}");
773        assert!(out.contains("es.example.com"), "host missing: {out}");
774    }
775
776    #[test]
777    fn debug_marks_the_api_key_present_but_redacted() {
778        let profile = with_urls("https://kb.example.com", None).profiles["default"].clone();
779        let out = format!("{profile:?}");
780        assert!(
781            out.contains("Some(\"***\")"),
782            "api key must show present-but-redacted: {out}"
783        );
784        assert!(!out.contains("essu_SECRET"), "api key leaked: {out}");
785    }
786
787    #[test]
788    fn banner_names_profile_host_and_space() {
789        let r = sample()
790            .resolve(Some("prod"), &Overrides::default())
791            .unwrap();
792        let b = r.banner();
793        assert!(b.contains("prod"), "banner must name the profile: {b}");
794        assert!(
795            b.contains("prod.example.com"),
796            "banner must name the host: {b}"
797        );
798        assert!(b.contains("default"), "banner must name the space: {b}");
799    }
800
801    #[test]
802    fn a_saved_config_never_contains_a_plaintext_key_in_a_world_readable_file() {
803        // Check key storage and file permissions together.
804        use std::os::unix::fs::PermissionsExt;
805        let dir = tempfile::tempdir().unwrap();
806        let path = dir.path().join("config.toml");
807        sample().save(&path).unwrap();
808        let body = fs::read_to_string(&path).unwrap();
809        assert!(
810            body.contains("essu_SECRET"),
811            "the real key is stored, not redacted on disk"
812        );
813        assert_eq!(
814            fs::metadata(&path).unwrap().permissions().mode() & 0o777,
815            0o600
816        );
817    }
818
819    #[test]
820    fn newly_created_file_is_mode_0600_not_umask_default() {
821        use std::os::unix::fs::PermissionsExt;
822        let dir = tempfile::tempdir().unwrap();
823        let path = dir.path().join("new_config.toml");
824        // Verify the file is newly created.
825        assert!(!path.exists());
826        sample().save(&path).unwrap();
827        let mode = fs::metadata(&path).unwrap().permissions().mode() & 0o777;
828        assert_eq!(
829            mode, 0o600,
830            "newly created config must be 0600 immediately, not umask default"
831        );
832    }
833
834    #[test]
835    fn host_parses_normal_https_url() {
836        let p = Profile {
837            kibana_url: "https://kb.example.com".into(),
838            ..Profile {
839                kibana_url: "".into(),
840                es_url: None,
841                api_key: None,
842                username: None,
843                password: None,
844                space: "default".into(),
845                verify: true,
846                timeout_secs: 30,
847            }
848        };
849        assert_eq!(p.host(), "kb.example.com");
850    }
851
852    #[test]
853    fn host_strips_path_from_url() {
854        let p = Profile {
855            kibana_url: "https://kb.example.com/api/spaces".into(),
856            ..Profile {
857                kibana_url: "".into(),
858                es_url: None,
859                api_key: None,
860                username: None,
861                password: None,
862                space: "default".into(),
863                verify: true,
864                timeout_secs: 30,
865            }
866        };
867        assert_eq!(p.host(), "kb.example.com");
868    }
869
870    #[test]
871    fn host_handles_doubled_scheme() {
872        let p = Profile {
873            kibana_url: "https://https://kb.example.com".into(),
874            ..Profile {
875                kibana_url: "".into(),
876                es_url: None,
877                api_key: None,
878                username: None,
879                password: None,
880                space: "default".into(),
881                verify: true,
882                timeout_secs: 30,
883            }
884        };
885        // A doubled scheme anchors on the second `://`, so the host is the
886        // authority after it.
887        assert_eq!(p.host(), "kb.example.com");
888    }
889
890    #[test]
891    fn host_handles_bare_hostname() {
892        let p = Profile {
893            kibana_url: "kb.example.com".into(),
894            ..Profile {
895                kibana_url: "".into(),
896                es_url: None,
897                api_key: None,
898                username: None,
899                password: None,
900                space: "default".into(),
901                verify: true,
902                timeout_secs: 30,
903            }
904        };
905        assert_eq!(p.host(), "kb.example.com", "must fall back to original");
906    }
907
908    #[test]
909    fn host_handles_empty_string() {
910        let p = Profile {
911            kibana_url: "".into(),
912            es_url: None,
913            api_key: None,
914            username: None,
915            password: None,
916            space: "default".into(),
917            verify: true,
918            timeout_secs: 30,
919        };
920        assert_eq!(p.host(), "", "empty falls back to original");
921    }
922
923    #[test]
924    fn host_never_shows_userinfo() {
925        let p = with_urls("https://user:pass@kb.example.com", None).profiles["default"].clone();
926        assert_eq!(p.host(), "kb.example.com");
927    }
928
929    #[test]
930    fn load_succeeds_on_a_permissive_file_and_prints_nothing() {
931        use std::os::unix::fs::PermissionsExt;
932        let dir = tempfile::tempdir().unwrap();
933        let path = dir.path().join("config.toml");
934        sample().save(&path).unwrap();
935        fs::set_permissions(&path, fs::Permissions::from_mode(0o644)).unwrap();
936        // `load` does not print; callers use `permission_warning` instead.
937        let cfg = Config::load(&path).unwrap();
938        assert!(
939            !cfg.profiles.is_empty(),
940            "load must succeed regardless of file permissions"
941        );
942    }
943
944    #[test]
945    fn permission_warning_flags_a_group_or_other_readable_file() {
946        use std::os::unix::fs::PermissionsExt;
947        let dir = tempfile::tempdir().unwrap();
948        let path = dir.path().join("config.toml");
949        sample().save(&path).unwrap();
950        fs::set_permissions(&path, fs::Permissions::from_mode(0o644)).unwrap();
951        let warning = Config::permission_warning(&path).expect("0644 must warn");
952        assert!(warning.contains("644"), "{warning}");
953    }
954
955    #[test]
956    fn permission_warning_is_none_for_an_owner_only_file() {
957        let dir = tempfile::tempdir().unwrap();
958        let path = dir.path().join("config.toml");
959        sample().save(&path).unwrap(); // `Config::save` already enforces 0600.
960        assert!(Config::permission_warning(&path).is_none());
961    }
962
963    #[test]
964    fn permission_warning_is_none_for_a_missing_file() {
965        let dir = tempfile::tempdir().unwrap();
966        assert!(Config::permission_warning(&dir.path().join("absent.toml")).is_none());
967    }
968
969    fn with_urls(kibana: &str, es: Option<&str>) -> Config {
970        let mut profiles = BTreeMap::new();
971        profiles.insert(
972            "default".to_string(),
973            Profile {
974                kibana_url: kibana.into(),
975                es_url: es.map(String::from),
976                api_key: Some("essu_SECRET".into()),
977                username: None,
978                password: None,
979                space: "default".into(),
980                verify: true,
981                timeout_secs: 30,
982            },
983        );
984        Config {
985            current: "default".into(),
986            profiles,
987        }
988    }
989
990    #[test]
991    fn resolve_strips_userinfo_from_both_urls() {
992        let r = with_urls(
993            "https://user:pass@kb.example.com",
994            Some("https://user:pass@es.example.com:9243/"),
995        )
996        .resolve(None, &Overrides::default())
997        .unwrap();
998        assert_eq!(r.profile.kibana_url, "https://kb.example.com");
999        assert_eq!(
1000            r.profile.es_url.as_deref(),
1001            Some("https://es.example.com:9243/")
1002        );
1003    }
1004
1005    #[test]
1006    fn resolve_strips_userinfo_supplied_by_an_override() {
1007        // Strip userinfo after flags and environment overrides are applied.
1008        let ov = Overrides {
1009            kibana_url: Some("https://user:pass@override.example.com".into()),
1010            ..Default::default()
1011        };
1012        let r = with_urls("https://kb.example.com", None)
1013            .resolve(None, &ov)
1014            .unwrap();
1015        assert_eq!(r.profile.kibana_url, "https://override.example.com");
1016    }
1017
1018    #[test]
1019    fn the_banner_never_shows_userinfo() {
1020        // The approval banner must not expose a password.
1021        let r = with_urls("https://user:hunter2@prod.example.com", None)
1022            .resolve(None, &Overrides::default())
1023            .unwrap();
1024        let b = r.banner();
1025        assert!(!b.contains("hunter2"), "{b}");
1026        // The banner uses one `@` between profile name and host.
1027        assert_eq!(b.matches('@').count(), 1, "{b}");
1028        assert!(b.contains("prod.example.com"), "{b}");
1029    }
1030
1031    #[test]
1032    fn a_url_without_userinfo_is_left_exactly_as_written() {
1033        for url in [
1034            "https://kb.example.com",
1035            "https://kb.example.com:5601/base/path?q=1",
1036            "http://localhost:5601",
1037            "kb.example.com",
1038            "",
1039        ] {
1040            let mut p = with_urls(url, None).profiles["default"].clone();
1041            p.strip_userinfo();
1042            assert_eq!(
1043                p.kibana_url, url,
1044                "unchanged input must stay byte-identical"
1045            );
1046        }
1047    }
1048
1049    #[test]
1050    fn an_at_sign_outside_the_authority_is_not_treated_as_userinfo() {
1051        // Only the authority carries userinfo; paths and queries may contain `@`.
1052        let mut p =
1053            with_urls("https://kb.example.com/a@b?user=x@y", None).profiles["default"].clone();
1054        p.strip_userinfo();
1055        assert_eq!(p.kibana_url, "https://kb.example.com/a@b?user=x@y");
1056    }
1057
1058    #[test]
1059    fn a_query_string_scheme_without_userinfo_is_left_exactly_as_written() {
1060        // A later `://` in the query is not the scheme, so it must not disturb
1061        // a URL that carries no userinfo at all.
1062        let mut p =
1063            with_urls("https://kb.example.com/?next=https://idp", None).profiles["default"].clone();
1064        p.strip_userinfo();
1065        assert_eq!(p.kibana_url, "https://kb.example.com/?next=https://idp");
1066    }
1067
1068    #[test]
1069    fn userinfo_is_stripped_when_a_query_contains_a_scheme() {
1070        // The first `://` delimits the scheme; the `://` inside the query must
1071        // not defeat the strip of the authority's userinfo.
1072        let mut p = with_urls("https://user:pass@kb.example.com/?next=https://idp", None).profiles
1073            ["default"]
1074            .clone();
1075        p.strip_userinfo();
1076        assert_eq!(p.kibana_url, "https://kb.example.com/?next=https://idp");
1077    }
1078
1079    #[test]
1080    fn host_uses_the_authority_when_a_query_contains_a_scheme() {
1081        let p = with_urls("https://user:pass@kb.example.com/?next=https://idp", None)
1082            .profiles["default"]
1083            .clone();
1084        assert_eq!(p.host(), "kb.example.com");
1085    }
1086
1087    #[test]
1088    fn a_doubled_scheme_with_userinfo_is_stripped() {
1089        // A doubled scheme anchors on the second `://`, so the `user:pass@` in
1090        // the authority after it is still stripped.
1091        let mut p =
1092            with_urls("https://https://user:pass@kb.example.com", None).profiles["default"].clone();
1093        p.strip_userinfo();
1094        assert_eq!(p.kibana_url, "https://https://kb.example.com");
1095        assert_eq!(p.host(), "kb.example.com");
1096    }
1097
1098    #[test]
1099    fn a_doubled_scheme_with_userinfo_and_a_query_scheme_strips_the_userinfo() {
1100        // A doubled scheme plus a later `://` in the query must anchor on the
1101        // second scheme, not the query's `://`, or `user:pass@` leaks.
1102        let mut p = with_urls(
1103            "https://https://user:pass@kb.example.com/?next=https://idp",
1104            None,
1105        )
1106        .profiles["default"]
1107            .clone();
1108        p.strip_userinfo();
1109        assert_eq!(
1110            p.kibana_url,
1111            "https://https://kb.example.com/?next=https://idp"
1112        );
1113    }
1114
1115    #[test]
1116    fn an_empty_port_does_not_defeat_userinfo_stripping() {
1117        // `host:` is an empty port, not a doubled scheme; the userinfo before it
1118        // must still be stripped even when the query carries a later `://`.
1119        let mut p =
1120            with_urls("https://user:pass@host:/path?next=https://idp", None).profiles["default"]
1121                .clone();
1122        p.strip_userinfo();
1123        assert_eq!(p.kibana_url, "https://host:/path?next=https://idp");
1124    }
1125
1126    #[test]
1127    fn percent_encoded_userinfo_scrubs_to_the_host() {
1128        // The raw `@` is the delimiter; `%40` (a percent-encoded `@`) is not
1129        // userinfo and must not be mistaken for one.
1130        assert_eq!(
1131            strip_userinfo("https://user%40x:pass@kb.example.com"),
1132            "https://kb.example.com"
1133        );
1134    }
1135
1136    #[test]
1137    fn percent_encoded_path_and_query_without_userinfo_are_left_byte_identical() {
1138        // `%2F` and `%3F` are percent-encoded `/` and `?`, not delimiters, so a
1139        // URL with no userinfo must be returned exactly as written.
1140        let url = "https://kb.example.com/%2Fpath?next=%3F";
1141        assert_eq!(strip_userinfo(url), url);
1142    }
1143
1144    // `try_from_env_with_flags` and `try_from_env` read the process
1145    // environment, which cannot be mutated here because this crate forbids
1146    // `unsafe` blocks. Their flag-override behavior is exercised end-to-end by
1147    // the `config_cmd` integration test
1148    // `a_timeout_flag_supersedes_an_invalid_environment_timeout`.
1149}