1use 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
13fn is_identity_override(ov: &Overrides) -> bool {
18 ov.kibana_url.is_some() || ov.es_url.is_some() || ov.api_key.is_some()
19}
20
21fn 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 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 pub fn host(&self) -> String {
86 let url = strip_userinfo(&self.kibana_url);
88 if let Some(pos) = url.rfind("://") {
90 let after_scheme = &url[pos + 3..];
91 let host_part = after_scheme.split('/').next().unwrap_or("");
93 if !host_part.is_empty() {
95 return host_part.to_string();
96 }
97 }
98 url
100 }
101
102 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
113fn 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 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 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 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 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 #[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 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 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 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 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 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 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 #[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 #[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 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 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 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(); 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 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 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 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 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}