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