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::io::Write;
10use std::path::{Path, PathBuf};
11
12const REDACTED: &str = "***";
13
14/// Whether overrides change the target deployment or credential.
15///
16/// Only these overrides change the guard banner's reported source. Timeout and
17/// space overrides do not.
18fn is_identity_override(ov: &Overrides) -> bool {
19    ov.kibana_url.is_some() || ov.es_url.is_some() || ov.api_key.is_some()
20}
21
22#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
23pub struct Profile {
24    pub kibana_url: String,
25    #[serde(default, skip_serializing_if = "Option::is_none")]
26    pub es_url: Option<String>,
27    #[serde(default, skip_serializing_if = "Option::is_none")]
28    pub api_key: Option<String>,
29    #[serde(default, skip_serializing_if = "Option::is_none")]
30    pub username: Option<String>,
31    #[serde(default, skip_serializing_if = "Option::is_none")]
32    pub password: Option<String>,
33    #[serde(default = "default_space")]
34    pub space: String,
35    #[serde(default = "default_verify")]
36    pub verify: bool,
37    #[serde(default = "default_timeout")]
38    pub timeout_secs: u64,
39}
40
41fn default_space() -> String {
42    "default".to_string()
43}
44fn default_verify() -> bool {
45    true
46}
47fn default_timeout() -> u64 {
48    30
49}
50
51impl Profile {
52    /// Return a copy safe to print.
53    ///
54    /// Secrets become `***`; absent values remain absent.
55    pub fn redacted(&self) -> Profile {
56        Profile {
57            api_key: self.api_key.as_ref().map(|_| REDACTED.to_string()),
58            password: self.password.as_ref().map(|_| REDACTED.to_string()),
59            ..self.clone()
60        }
61    }
62
63    /// Return the Kibana URL host for banners.
64    ///
65    /// Return the original URL when no host can be parsed.
66    pub fn host(&self) -> String {
67        // Use the last scheme separator to handle doubled schemes.
68        if let Some(pos) = self.kibana_url.rfind("://") {
69            let after_scheme = &self.kibana_url[pos + 3..];
70            // Drop the path and query after the first slash.
71            let host_part = after_scheme.split('/').next().unwrap_or("");
72            // Use the parsed host when present.
73            if !host_part.is_empty() {
74                return host_part.to_string();
75            }
76        }
77        // Preserve the original URL when parsing finds no host.
78        self.kibana_url.clone()
79    }
80
81    /// Remove userinfo from `kibana_url` and `es_url`.
82    ///
83    /// Credentials use `api_key` or `username` and `password`; the transport
84    /// never reads URL userinfo. Removing it prevents credentials appearing in
85    /// the guard banner, `config show`, or `--debug` output.
86    pub fn strip_userinfo(&mut self) {
87        self.kibana_url = strip_userinfo(&self.kibana_url);
88        self.es_url = self.es_url.as_deref().map(strip_userinfo);
89    }
90}
91
92/// Remove userinfo from a URL authority without changing other bytes.
93///
94/// Paths and queries may legitimately contain `@`.
95fn strip_userinfo(url: &str) -> String {
96    let (scheme, rest) = match url.find("://") {
97        Some(i) => url.split_at(i + 3),
98        None => ("", url),
99    };
100    let authority_end = rest.find(['/', '?', '#']).unwrap_or(rest.len());
101    let (authority, tail) = rest.split_at(authority_end);
102    match authority.rfind('@') {
103        Some(i) => format!("{scheme}{}{tail}", &authority[i + 1..]),
104        None => url.to_string(),
105    }
106}
107
108#[derive(Debug, Clone, Default, Serialize, Deserialize)]
109pub struct Config {
110    #[serde(default)]
111    pub current: String,
112    #[serde(default)]
113    pub profiles: BTreeMap<String, Profile>,
114}
115
116#[derive(Debug, Clone, Copy, PartialEq, Eq)]
117pub enum Source {
118    Profile,
119    Env,
120    Flags,
121}
122
123#[derive(Debug, Clone)]
124pub struct Resolved {
125    pub profile: Profile,
126    pub name: String,
127    pub source: Source,
128}
129
130impl Resolved {
131    pub fn banner(&self) -> String {
132        format!(
133            "profile: {} @ {}, space: {}",
134            self.name,
135            self.profile.host(),
136            self.profile.space
137        )
138    }
139}
140
141#[derive(Debug, Clone, Default)]
142pub struct Overrides {
143    pub kibana_url: Option<String>,
144    pub es_url: Option<String>,
145    pub api_key: Option<String>,
146    pub space: Option<String>,
147    pub timeout_secs: Option<u64>,
148}
149
150impl Overrides {
151    /// Read `ELASTICCTL_*` environment variables as overrides.
152    ///
153    /// `es_url` and `kibana_url` are identity overrides. Cloud deployments use
154    /// distinct endpoints; inheriting one from a saved profile could target two
155    /// deployments and send the overridden credential to the wrong host.
156    pub fn from_env() -> Overrides {
157        Overrides {
158            kibana_url: std::env::var("ELASTICCTL_KIBANA_URL").ok(),
159            es_url: std::env::var("ELASTICCTL_ES_URL").ok(),
160            api_key: std::env::var("ELASTICCTL_API_KEY").ok(),
161            space: std::env::var("ELASTICCTL_SPACE").ok(),
162            timeout_secs: std::env::var("ELASTICCTL_TIMEOUT")
163                .ok()
164                .and_then(|v| v.parse().ok()),
165        }
166    }
167
168    /// Merge overrides, preferring `self` over `lower`.
169    pub fn merge_over(self, lower: Overrides) -> Overrides {
170        Overrides {
171            kibana_url: self.kibana_url.or(lower.kibana_url),
172            es_url: self.es_url.or(lower.es_url),
173            api_key: self.api_key.or(lower.api_key),
174            space: self.space.or(lower.space),
175            timeout_secs: self.timeout_secs.or(lower.timeout_secs),
176        }
177    }
178}
179
180impl Config {
181    pub fn default_path() -> PathBuf {
182        directories::UserDirs::new()
183            .map(|d| d.home_dir().to_path_buf())
184            .unwrap_or_else(|| PathBuf::from("."))
185            .join(".elasticctl")
186            .join("config.toml")
187    }
188
189    /// Treat a missing file as an empty config so `config init` works on a new
190    /// machine.
191    pub fn load(path: &Path) -> Result<Config> {
192        if !path.exists() {
193            return Ok(Config::default());
194        }
195        let body = fs::read_to_string(path).map_err(|e| {
196            Error::new(ErrorKind::Error, format!("reading {}: {e}", path.display()))
197        })?;
198        toml::from_str(&body)
199            .map_err(|e| Error::new(ErrorKind::Error, format!("parsing {}: {e}", path.display())))
200    }
201
202    /// Describe insecure file permissions, or return `None` for absent or
203    /// owner-only files.
204    ///
205    /// The caller decides whether and how to display this warning, including
206    /// for CLI `--json` output.
207    #[cfg(unix)]
208    pub fn permission_warning(path: &Path) -> Option<String> {
209        use std::os::unix::fs::PermissionsExt;
210        let metadata = fs::metadata(path).ok()?;
211        let mode = metadata.permissions().mode();
212        // Group or other permission bits are set.
213        if mode & 0o077 != 0 {
214            Some(format!(
215                "config file {} is readable by group or other (mode {:o}); should be 0600",
216                path.display(),
217                mode & 0o777
218            ))
219        } else {
220            None
221        }
222    }
223
224    #[cfg(not(unix))]
225    pub fn permission_warning(_path: &Path) -> Option<String> {
226        None
227    }
228
229    pub fn save(&self, path: &Path) -> Result<()> {
230        if let Some(parent) = path.parent() {
231            fs::create_dir_all(parent).map_err(|e| {
232                Error::new(
233                    ErrorKind::Error,
234                    format!("creating {}: {e}", parent.display()),
235                )
236            })?;
237        }
238        let body = toml::to_string_pretty(self)
239            .map_err(|e| Error::new(ErrorKind::Error, format!("serializing config: {e}")))?;
240        Self::write_config_file(path, &body)?;
241        // Restrict an existing file that had looser permissions.
242        Self::restrict_permissions(path)
243    }
244
245    #[cfg(unix)]
246    fn write_config_file(path: &Path, body: &str) -> Result<()> {
247        use std::os::unix::fs::OpenOptionsExt;
248        let mut f = std::fs::OpenOptions::new()
249            .write(true)
250            .create(true)
251            .truncate(true)
252            .mode(0o600)
253            .open(path)
254            .map_err(|e| {
255                Error::new(ErrorKind::Error, format!("writing {}: {e}", path.display()))
256            })?;
257        f.write_all(body.as_bytes())
258            .map_err(|e| Error::new(ErrorKind::Error, format!("writing {}: {e}", path.display())))
259    }
260
261    #[cfg(not(unix))]
262    fn write_config_file(path: &Path, body: &str) -> Result<()> {
263        fs::write(path, body)
264            .map_err(|e| Error::new(ErrorKind::Error, format!("writing {}: {e}", path.display())))
265    }
266
267    #[cfg(unix)]
268    fn restrict_permissions(path: &Path) -> Result<()> {
269        use std::os::unix::fs::PermissionsExt;
270        fs::set_permissions(path, fs::Permissions::from_mode(0o600))
271            .map_err(|e| Error::new(ErrorKind::Error, format!("chmod {}: {e}", path.display())))
272    }
273
274    #[cfg(not(unix))]
275    fn restrict_permissions(_path: &Path) -> Result<()> {
276        Ok(())
277    }
278
279    /// Resolve the effective profile and its source.
280    pub fn resolve(&self, name: Option<&str>, ov: &Overrides) -> Result<Resolved> {
281        let wanted = name.unwrap_or(if self.current.is_empty() {
282            "default"
283        } else {
284            &self.current
285        });
286        let mut profile = self.profiles.get(wanted).cloned().ok_or_else(|| {
287            Error::new(ErrorKind::NotFound, format!("Profile '{wanted}' not found"))
288        })?;
289
290        if let Some(v) = &ov.kibana_url {
291            profile.kibana_url = v.clone();
292        }
293        // Do not combine an overridden Kibana URL with a profile Elasticsearch
294        // URL: they can target separate deployments. Without an `es_url`
295        // override, fall back to the Kibana host instead of sending credentials
296        // to the profile's Elasticsearch host.
297        if let Some(v) = &ov.es_url {
298            profile.es_url = Some(v.clone());
299        } else if ov.kibana_url.is_some() {
300            profile.es_url = None;
301        }
302        if let Some(v) = &ov.api_key {
303            profile.api_key = Some(v.clone());
304        }
305        if let Some(v) = &ov.space {
306            profile.space = v.clone();
307        }
308        if let Some(v) = ov.timeout_secs {
309            profile.timeout_secs = v;
310        }
311
312        let source = if is_identity_override(ov) {
313            Source::Flags
314        } else {
315            Source::Profile
316        };
317
318        // Strip userinfo after applying file, environment, and flag values.
319        profile.strip_userinfo();
320
321        Ok(Resolved {
322            profile,
323            name: wanted.to_string(),
324            source,
325        })
326    }
327}
328
329#[cfg(test)]
330mod tests {
331    use super::*;
332    use std::fs;
333
334    fn sample() -> Config {
335        let mut profiles = BTreeMap::new();
336        profiles.insert(
337            "default".to_string(),
338            Profile {
339                kibana_url: "https://kb.example.com".into(),
340                es_url: Some("https://es.example.com".into()),
341                api_key: Some("essu_SECRET".into()),
342                username: Some("user".into()),
343                password: Some("pass_SECRET".into()),
344                space: "default".into(),
345                verify: true,
346                timeout_secs: 30,
347            },
348        );
349        profiles.insert(
350            "prod".to_string(),
351            Profile {
352                kibana_url: "https://prod.example.com".into(),
353                ..profiles["default"].clone()
354            },
355        );
356        Config {
357            current: "default".into(),
358            profiles,
359        }
360    }
361
362    #[test]
363    fn round_trips_through_toml() {
364        let dir = tempfile::tempdir().unwrap();
365        let path = dir.path().join("config.toml");
366        sample().save(&path).unwrap();
367        let loaded = Config::load(&path).unwrap();
368        assert_eq!(loaded.current, "default");
369        assert_eq!(loaded.profiles.len(), 2);
370        assert_eq!(
371            loaded.profiles["prod"].kibana_url,
372            "https://prod.example.com"
373        );
374    }
375
376    #[test]
377    fn save_enforces_owner_only_permissions() {
378        use std::os::unix::fs::PermissionsExt;
379        let dir = tempfile::tempdir().unwrap();
380        let path = dir.path().join("config.toml");
381        sample().save(&path).unwrap();
382        let mode = fs::metadata(&path).unwrap().permissions().mode() & 0o777;
383        assert_eq!(mode, 0o600, "config must not be readable by group or other");
384    }
385
386    #[test]
387    fn load_of_a_missing_file_is_an_empty_config_not_an_error() {
388        let dir = tempfile::tempdir().unwrap();
389        let cfg = Config::load(&dir.path().join("absent.toml")).unwrap();
390        assert!(cfg.profiles.is_empty());
391    }
392
393    #[test]
394    fn resolving_an_unknown_profile_is_a_not_found_error() {
395        let err = sample()
396            .resolve(Some("nope"), &Overrides::default())
397            .unwrap_err();
398        assert_eq!(err.kind, ErrorKind::NotFound);
399        assert!(err.message.contains("nope"));
400    }
401
402    /// Verify that `ELASTICCTL_ES_URL` overrides the profile. This prevents a
403    /// Kibana override and inherited `es_url` from targeting two deployments.
404    #[test]
405    fn an_es_url_override_is_applied() {
406        let r = sample()
407            .resolve(
408                None,
409                &Overrides {
410                    es_url: Some("https://other-es.example.com".into()),
411                    ..Default::default()
412                },
413            )
414            .unwrap();
415        assert_eq!(
416            r.profile.es_url.as_deref(),
417            Some("https://other-es.example.com")
418        );
419    }
420
421    /// A Kibana-only override must clear the profile's Elasticsearch host.
422    /// Otherwise credentials could be sent to an unselected deployment.
423    #[test]
424    fn overriding_only_the_kibana_url_clears_the_profiles_es_url() {
425        let r = sample()
426            .resolve(
427                None,
428                &Overrides {
429                    kibana_url: Some("https://other-kb.example.com".into()),
430                    ..Default::default()
431                },
432            )
433            .unwrap();
434        assert_eq!(
435            r.profile.es_url, None,
436            "an inherited es_url would point at the profile's stack, not the overridden one"
437        );
438    }
439
440    #[test]
441    fn an_es_url_override_counts_as_an_identity_override() {
442        let r = sample()
443            .resolve(
444                None,
445                &Overrides {
446                    es_url: Some("https://other-es.example.com".into()),
447                    ..Default::default()
448                },
449            )
450            .unwrap();
451        assert_eq!(
452            r.source,
453            Source::Flags,
454            "changing which stack is addressed is an identity change"
455        );
456    }
457
458    #[test]
459    fn resolve_defaults_to_the_current_profile() {
460        let r = sample().resolve(None, &Overrides::default()).unwrap();
461        assert_eq!(r.name, "default");
462        assert_eq!(r.source, Source::Profile);
463        assert_eq!(r.profile.kibana_url, "https://kb.example.com");
464    }
465
466    #[test]
467    fn flags_override_the_profile_and_change_the_reported_source() {
468        let ov = Overrides {
469            kibana_url: Some("https://override.example.com".into()),
470            ..Default::default()
471        };
472        let r = sample().resolve(None, &ov).unwrap();
473        assert_eq!(r.profile.kibana_url, "https://override.example.com");
474        assert_eq!(
475            r.source,
476            Source::Flags,
477            "an identity override changes provenance"
478        );
479    }
480
481    #[test]
482    fn a_non_identity_override_does_not_change_the_source() {
483        let ov = Overrides {
484            timeout_secs: Some(90),
485            ..Default::default()
486        };
487        let r = sample().resolve(None, &ov).unwrap();
488        assert_eq!(r.profile.timeout_secs, 90);
489        assert_eq!(
490            r.source,
491            Source::Profile,
492            "timeout is not an identity field"
493        );
494    }
495
496    #[test]
497    fn redacted_hides_every_secret_field() {
498        let p = sample().profiles["default"].redacted();
499        assert_eq!(
500            p.api_key.as_deref(),
501            Some("***"),
502            "api_key must be redacted"
503        );
504        assert_eq!(
505            p.password.as_deref(),
506            Some("***"),
507            "password must be redacted"
508        );
509        assert_eq!(
510            p.kibana_url, "https://kb.example.com",
511            "non-secrets stay visible"
512        );
513    }
514
515    #[test]
516    fn redacted_leaves_absent_secrets_absent() {
517        let mut p = sample().profiles["default"].clone();
518        p.api_key = None;
519        p.password = None;
520        let redacted = p.redacted();
521        assert_eq!(redacted.api_key, None, "absent api_key stays absent");
522        assert_eq!(redacted.password, None, "absent password stays absent");
523    }
524
525    #[test]
526    fn banner_names_profile_host_and_space() {
527        let r = sample()
528            .resolve(Some("prod"), &Overrides::default())
529            .unwrap();
530        let b = r.banner();
531        assert!(b.contains("prod"), "banner must name the profile: {b}");
532        assert!(
533            b.contains("prod.example.com"),
534            "banner must name the host: {b}"
535        );
536        assert!(b.contains("default"), "banner must name the space: {b}");
537    }
538
539    #[test]
540    fn a_saved_config_never_contains_a_plaintext_key_in_a_world_readable_file() {
541        // Check key storage and file permissions together.
542        use std::os::unix::fs::PermissionsExt;
543        let dir = tempfile::tempdir().unwrap();
544        let path = dir.path().join("config.toml");
545        sample().save(&path).unwrap();
546        let body = fs::read_to_string(&path).unwrap();
547        assert!(
548            body.contains("essu_SECRET"),
549            "the real key is stored, not redacted on disk"
550        );
551        assert_eq!(
552            fs::metadata(&path).unwrap().permissions().mode() & 0o777,
553            0o600
554        );
555    }
556
557    #[test]
558    fn newly_created_file_is_mode_0600_not_umask_default() {
559        use std::os::unix::fs::PermissionsExt;
560        let dir = tempfile::tempdir().unwrap();
561        let path = dir.path().join("new_config.toml");
562        // Verify the file is newly created.
563        assert!(!path.exists());
564        sample().save(&path).unwrap();
565        let mode = fs::metadata(&path).unwrap().permissions().mode() & 0o777;
566        assert_eq!(
567            mode, 0o600,
568            "newly created config must be 0600 immediately, not umask default"
569        );
570    }
571
572    #[test]
573    fn host_parses_normal_https_url() {
574        let p = Profile {
575            kibana_url: "https://kb.example.com".into(),
576            ..Profile {
577                kibana_url: "".into(),
578                es_url: None,
579                api_key: None,
580                username: None,
581                password: None,
582                space: "default".into(),
583                verify: true,
584                timeout_secs: 30,
585            }
586        };
587        assert_eq!(p.host(), "kb.example.com");
588    }
589
590    #[test]
591    fn host_strips_path_from_url() {
592        let p = Profile {
593            kibana_url: "https://kb.example.com/api/spaces".into(),
594            ..Profile {
595                kibana_url: "".into(),
596                es_url: None,
597                api_key: None,
598                username: None,
599                password: None,
600                space: "default".into(),
601                verify: true,
602                timeout_secs: 30,
603            }
604        };
605        assert_eq!(p.host(), "kb.example.com");
606    }
607
608    #[test]
609    fn host_handles_doubled_scheme() {
610        let p = Profile {
611            kibana_url: "https://https://kb.example.com".into(),
612            ..Profile {
613                kibana_url: "".into(),
614                es_url: None,
615                api_key: None,
616                username: None,
617                password: None,
618                space: "default".into(),
619                verify: true,
620                timeout_secs: 30,
621            }
622        };
623        assert_eq!(
624            p.host(),
625            "kb.example.com",
626            "must extract host after last :// to avoid reporting wrong scheme as host"
627        );
628    }
629
630    #[test]
631    fn host_handles_bare_hostname() {
632        let p = Profile {
633            kibana_url: "kb.example.com".into(),
634            ..Profile {
635                kibana_url: "".into(),
636                es_url: None,
637                api_key: None,
638                username: None,
639                password: None,
640                space: "default".into(),
641                verify: true,
642                timeout_secs: 30,
643            }
644        };
645        assert_eq!(p.host(), "kb.example.com", "must fall back to original");
646    }
647
648    #[test]
649    fn host_handles_empty_string() {
650        let p = Profile {
651            kibana_url: "".into(),
652            es_url: None,
653            api_key: None,
654            username: None,
655            password: None,
656            space: "default".into(),
657            verify: true,
658            timeout_secs: 30,
659        };
660        assert_eq!(p.host(), "", "empty falls back to original");
661    }
662
663    #[test]
664    fn load_succeeds_on_a_permissive_file_and_prints_nothing() {
665        use std::os::unix::fs::PermissionsExt;
666        let dir = tempfile::tempdir().unwrap();
667        let path = dir.path().join("config.toml");
668        sample().save(&path).unwrap();
669        fs::set_permissions(&path, fs::Permissions::from_mode(0o644)).unwrap();
670        // `load` does not print; callers use `permission_warning` instead.
671        let cfg = Config::load(&path).unwrap();
672        assert!(
673            !cfg.profiles.is_empty(),
674            "load must succeed regardless of file permissions"
675        );
676    }
677
678    #[test]
679    fn permission_warning_flags_a_group_or_other_readable_file() {
680        use std::os::unix::fs::PermissionsExt;
681        let dir = tempfile::tempdir().unwrap();
682        let path = dir.path().join("config.toml");
683        sample().save(&path).unwrap();
684        fs::set_permissions(&path, fs::Permissions::from_mode(0o644)).unwrap();
685        let warning = Config::permission_warning(&path).expect("0644 must warn");
686        assert!(warning.contains("644"), "{warning}");
687    }
688
689    #[test]
690    fn permission_warning_is_none_for_an_owner_only_file() {
691        let dir = tempfile::tempdir().unwrap();
692        let path = dir.path().join("config.toml");
693        sample().save(&path).unwrap(); // `Config::save` already enforces 0600.
694        assert!(Config::permission_warning(&path).is_none());
695    }
696
697    #[test]
698    fn permission_warning_is_none_for_a_missing_file() {
699        let dir = tempfile::tempdir().unwrap();
700        assert!(Config::permission_warning(&dir.path().join("absent.toml")).is_none());
701    }
702
703    fn with_urls(kibana: &str, es: Option<&str>) -> Config {
704        let mut profiles = BTreeMap::new();
705        profiles.insert(
706            "default".to_string(),
707            Profile {
708                kibana_url: kibana.into(),
709                es_url: es.map(String::from),
710                api_key: Some("essu_SECRET".into()),
711                username: None,
712                password: None,
713                space: "default".into(),
714                verify: true,
715                timeout_secs: 30,
716            },
717        );
718        Config {
719            current: "default".into(),
720            profiles,
721        }
722    }
723
724    #[test]
725    fn resolve_strips_userinfo_from_both_urls() {
726        let r = with_urls(
727            "https://user:pass@kb.example.com",
728            Some("https://user:pass@es.example.com:9243/"),
729        )
730        .resolve(None, &Overrides::default())
731        .unwrap();
732        assert_eq!(r.profile.kibana_url, "https://kb.example.com");
733        assert_eq!(
734            r.profile.es_url.as_deref(),
735            Some("https://es.example.com:9243/")
736        );
737    }
738
739    #[test]
740    fn resolve_strips_userinfo_supplied_by_an_override() {
741        // Strip userinfo after flags and environment overrides are applied.
742        let ov = Overrides {
743            kibana_url: Some("https://user:pass@override.example.com".into()),
744            ..Default::default()
745        };
746        let r = with_urls("https://kb.example.com", None)
747            .resolve(None, &ov)
748            .unwrap();
749        assert_eq!(r.profile.kibana_url, "https://override.example.com");
750    }
751
752    #[test]
753    fn the_banner_never_shows_userinfo() {
754        // The approval banner must not expose a password.
755        let r = with_urls("https://user:hunter2@prod.example.com", None)
756            .resolve(None, &Overrides::default())
757            .unwrap();
758        let b = r.banner();
759        assert!(!b.contains("hunter2"), "{b}");
760        // The banner uses one `@` between profile name and host.
761        assert_eq!(b.matches('@').count(), 1, "{b}");
762        assert!(b.contains("prod.example.com"), "{b}");
763    }
764
765    #[test]
766    fn a_url_without_userinfo_is_left_exactly_as_written() {
767        for url in [
768            "https://kb.example.com",
769            "https://kb.example.com:5601/base/path?q=1",
770            "http://localhost:5601",
771            "kb.example.com",
772            "",
773        ] {
774            let mut p = with_urls(url, None).profiles["default"].clone();
775            p.strip_userinfo();
776            assert_eq!(
777                p.kibana_url, url,
778                "unchanged input must stay byte-identical"
779            );
780        }
781    }
782
783    #[test]
784    fn an_at_sign_outside_the_authority_is_not_treated_as_userinfo() {
785        // Only the authority carries userinfo; paths and queries may contain `@`.
786        let mut p =
787            with_urls("https://kb.example.com/a@b?user=x@y", None).profiles["default"].clone();
788        p.strip_userinfo();
789        assert_eq!(p.kibana_url, "https://kb.example.com/a@b?user=x@y");
790    }
791}