Skip to main content

elasticctl_core/
config.rs

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