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