Skip to main content

elasticctl_core/
config.rs

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