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