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