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