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