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 {
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 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 pub fn try_from_env() -> Result<Overrides> {
198 Self::try_from_env_with_flags(&Overrides::default())
199 }
200
201 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 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 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 #[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 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 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 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 #[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 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 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 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 #[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 #[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 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 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 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(); 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 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 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 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 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 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 }