1use crate::error::{Error, ErrorKind, Result};
6use serde::{Deserialize, Serialize};
7use std::collections::BTreeMap;
8use std::fs;
9use std::io::Write;
10use std::path::{Path, PathBuf};
11
12const REDACTED: &str = "***";
13
14fn is_identity_override(ov: &Overrides) -> bool {
19 ov.kibana_url.is_some() || ov.es_url.is_some() || ov.api_key.is_some()
20}
21
22#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
23pub struct Profile {
24 pub kibana_url: String,
25 #[serde(default, skip_serializing_if = "Option::is_none")]
26 pub es_url: Option<String>,
27 #[serde(default, skip_serializing_if = "Option::is_none")]
28 pub api_key: Option<String>,
29 #[serde(default, skip_serializing_if = "Option::is_none")]
30 pub username: Option<String>,
31 #[serde(default, skip_serializing_if = "Option::is_none")]
32 pub password: Option<String>,
33 #[serde(default = "default_space")]
34 pub space: String,
35 #[serde(default = "default_verify")]
36 pub verify: bool,
37 #[serde(default = "default_timeout")]
38 pub timeout_secs: u64,
39}
40
41fn default_space() -> String {
42 "default".to_string()
43}
44fn default_verify() -> bool {
45 true
46}
47fn default_timeout() -> u64 {
48 30
49}
50
51impl Profile {
52 pub fn redacted(&self) -> Profile {
56 Profile {
57 api_key: self.api_key.as_ref().map(|_| REDACTED.to_string()),
58 password: self.password.as_ref().map(|_| REDACTED.to_string()),
59 ..self.clone()
60 }
61 }
62
63 pub fn host(&self) -> String {
67 if let Some(pos) = self.kibana_url.rfind("://") {
69 let after_scheme = &self.kibana_url[pos + 3..];
70 let host_part = after_scheme.split('/').next().unwrap_or("");
72 if !host_part.is_empty() {
74 return host_part.to_string();
75 }
76 }
77 self.kibana_url.clone()
79 }
80
81 pub fn strip_userinfo(&mut self) {
87 self.kibana_url = strip_userinfo(&self.kibana_url);
88 self.es_url = self.es_url.as_deref().map(strip_userinfo);
89 }
90}
91
92fn strip_userinfo(url: &str) -> String {
96 let (scheme, rest) = match url.find("://") {
97 Some(i) => url.split_at(i + 3),
98 None => ("", url),
99 };
100 let authority_end = rest.find(['/', '?', '#']).unwrap_or(rest.len());
101 let (authority, tail) = rest.split_at(authority_end);
102 match authority.rfind('@') {
103 Some(i) => format!("{scheme}{}{tail}", &authority[i + 1..]),
104 None => url.to_string(),
105 }
106}
107
108#[derive(Debug, Clone, Default, Serialize, Deserialize)]
109pub struct Config {
110 #[serde(default)]
111 pub current: String,
112 #[serde(default)]
113 pub profiles: BTreeMap<String, Profile>,
114}
115
116#[derive(Debug, Clone, Copy, PartialEq, Eq)]
117pub enum Source {
118 Profile,
119 Env,
120 Flags,
121}
122
123#[derive(Debug, Clone)]
124pub struct Resolved {
125 pub profile: Profile,
126 pub name: String,
127 pub source: Source,
128}
129
130impl Resolved {
131 pub fn banner(&self) -> String {
132 format!(
133 "profile: {} @ {}, space: {}",
134 self.name,
135 self.profile.host(),
136 self.profile.space
137 )
138 }
139}
140
141#[derive(Debug, Clone, Default)]
142pub struct Overrides {
143 pub kibana_url: Option<String>,
144 pub es_url: Option<String>,
145 pub api_key: Option<String>,
146 pub space: Option<String>,
147 pub timeout_secs: Option<u64>,
148}
149
150impl Overrides {
151 pub fn from_env() -> Overrides {
157 Overrides {
158 kibana_url: std::env::var("ELASTICCTL_KIBANA_URL").ok(),
159 es_url: std::env::var("ELASTICCTL_ES_URL").ok(),
160 api_key: std::env::var("ELASTICCTL_API_KEY").ok(),
161 space: std::env::var("ELASTICCTL_SPACE").ok(),
162 timeout_secs: std::env::var("ELASTICCTL_TIMEOUT")
163 .ok()
164 .and_then(|v| v.parse().ok()),
165 }
166 }
167
168 pub fn merge_over(self, lower: Overrides) -> Overrides {
170 Overrides {
171 kibana_url: self.kibana_url.or(lower.kibana_url),
172 es_url: self.es_url.or(lower.es_url),
173 api_key: self.api_key.or(lower.api_key),
174 space: self.space.or(lower.space),
175 timeout_secs: self.timeout_secs.or(lower.timeout_secs),
176 }
177 }
178}
179
180impl Config {
181 pub fn default_path() -> PathBuf {
182 directories::UserDirs::new()
183 .map(|d| d.home_dir().to_path_buf())
184 .unwrap_or_else(|| PathBuf::from("."))
185 .join(".elasticctl")
186 .join("config.toml")
187 }
188
189 pub fn load(path: &Path) -> Result<Config> {
192 if !path.exists() {
193 return Ok(Config::default());
194 }
195 let body = fs::read_to_string(path).map_err(|e| {
196 Error::new(ErrorKind::Error, format!("reading {}: {e}", path.display()))
197 })?;
198 toml::from_str(&body)
199 .map_err(|e| Error::new(ErrorKind::Error, format!("parsing {}: {e}", path.display())))
200 }
201
202 #[cfg(unix)]
208 pub fn permission_warning(path: &Path) -> Option<String> {
209 use std::os::unix::fs::PermissionsExt;
210 let metadata = fs::metadata(path).ok()?;
211 let mode = metadata.permissions().mode();
212 if mode & 0o077 != 0 {
214 Some(format!(
215 "config file {} is readable by group or other (mode {:o}); should be 0600",
216 path.display(),
217 mode & 0o777
218 ))
219 } else {
220 None
221 }
222 }
223
224 #[cfg(not(unix))]
225 pub fn permission_warning(_path: &Path) -> Option<String> {
226 None
227 }
228
229 pub fn save(&self, path: &Path) -> Result<()> {
230 if let Some(parent) = path.parent() {
231 fs::create_dir_all(parent).map_err(|e| {
232 Error::new(
233 ErrorKind::Error,
234 format!("creating {}: {e}", parent.display()),
235 )
236 })?;
237 }
238 let body = toml::to_string_pretty(self)
239 .map_err(|e| Error::new(ErrorKind::Error, format!("serializing config: {e}")))?;
240 Self::write_config_file(path, &body)?;
241 Self::restrict_permissions(path)
243 }
244
245 #[cfg(unix)]
246 fn write_config_file(path: &Path, body: &str) -> Result<()> {
247 use std::os::unix::fs::OpenOptionsExt;
248 let mut f = std::fs::OpenOptions::new()
249 .write(true)
250 .create(true)
251 .truncate(true)
252 .mode(0o600)
253 .open(path)
254 .map_err(|e| {
255 Error::new(ErrorKind::Error, format!("writing {}: {e}", path.display()))
256 })?;
257 f.write_all(body.as_bytes())
258 .map_err(|e| Error::new(ErrorKind::Error, format!("writing {}: {e}", path.display())))
259 }
260
261 #[cfg(not(unix))]
262 fn write_config_file(path: &Path, body: &str) -> Result<()> {
263 fs::write(path, body)
264 .map_err(|e| Error::new(ErrorKind::Error, format!("writing {}: {e}", path.display())))
265 }
266
267 #[cfg(unix)]
268 fn restrict_permissions(path: &Path) -> Result<()> {
269 use std::os::unix::fs::PermissionsExt;
270 fs::set_permissions(path, fs::Permissions::from_mode(0o600))
271 .map_err(|e| Error::new(ErrorKind::Error, format!("chmod {}: {e}", path.display())))
272 }
273
274 #[cfg(not(unix))]
275 fn restrict_permissions(_path: &Path) -> Result<()> {
276 Ok(())
277 }
278
279 pub fn resolve(&self, name: Option<&str>, ov: &Overrides) -> Result<Resolved> {
281 let wanted = name.unwrap_or(if self.current.is_empty() {
282 "default"
283 } else {
284 &self.current
285 });
286 let mut profile = self.profiles.get(wanted).cloned().ok_or_else(|| {
287 Error::new(ErrorKind::NotFound, format!("Profile '{wanted}' not found"))
288 })?;
289
290 if let Some(v) = &ov.kibana_url {
291 profile.kibana_url = v.clone();
292 }
293 if let Some(v) = &ov.es_url {
298 profile.es_url = Some(v.clone());
299 } else if ov.kibana_url.is_some() {
300 profile.es_url = None;
301 }
302 if let Some(v) = &ov.api_key {
303 profile.api_key = Some(v.clone());
304 }
305 if let Some(v) = &ov.space {
306 profile.space = v.clone();
307 }
308 if let Some(v) = ov.timeout_secs {
309 profile.timeout_secs = v;
310 }
311
312 let source = if is_identity_override(ov) {
313 Source::Flags
314 } else {
315 Source::Profile
316 };
317
318 profile.strip_userinfo();
320
321 Ok(Resolved {
322 profile,
323 name: wanted.to_string(),
324 source,
325 })
326 }
327}
328
329#[cfg(test)]
330mod tests {
331 use super::*;
332 use std::fs;
333
334 fn sample() -> Config {
335 let mut profiles = BTreeMap::new();
336 profiles.insert(
337 "default".to_string(),
338 Profile {
339 kibana_url: "https://kb.example.com".into(),
340 es_url: Some("https://es.example.com".into()),
341 api_key: Some("essu_SECRET".into()),
342 username: Some("user".into()),
343 password: Some("pass_SECRET".into()),
344 space: "default".into(),
345 verify: true,
346 timeout_secs: 30,
347 },
348 );
349 profiles.insert(
350 "prod".to_string(),
351 Profile {
352 kibana_url: "https://prod.example.com".into(),
353 ..profiles["default"].clone()
354 },
355 );
356 Config {
357 current: "default".into(),
358 profiles,
359 }
360 }
361
362 #[test]
363 fn round_trips_through_toml() {
364 let dir = tempfile::tempdir().unwrap();
365 let path = dir.path().join("config.toml");
366 sample().save(&path).unwrap();
367 let loaded = Config::load(&path).unwrap();
368 assert_eq!(loaded.current, "default");
369 assert_eq!(loaded.profiles.len(), 2);
370 assert_eq!(
371 loaded.profiles["prod"].kibana_url,
372 "https://prod.example.com"
373 );
374 }
375
376 #[test]
377 fn save_enforces_owner_only_permissions() {
378 use std::os::unix::fs::PermissionsExt;
379 let dir = tempfile::tempdir().unwrap();
380 let path = dir.path().join("config.toml");
381 sample().save(&path).unwrap();
382 let mode = fs::metadata(&path).unwrap().permissions().mode() & 0o777;
383 assert_eq!(mode, 0o600, "config must not be readable by group or other");
384 }
385
386 #[test]
387 fn load_of_a_missing_file_is_an_empty_config_not_an_error() {
388 let dir = tempfile::tempdir().unwrap();
389 let cfg = Config::load(&dir.path().join("absent.toml")).unwrap();
390 assert!(cfg.profiles.is_empty());
391 }
392
393 #[test]
394 fn resolving_an_unknown_profile_is_a_not_found_error() {
395 let err = sample()
396 .resolve(Some("nope"), &Overrides::default())
397 .unwrap_err();
398 assert_eq!(err.kind, ErrorKind::NotFound);
399 assert!(err.message.contains("nope"));
400 }
401
402 #[test]
405 fn an_es_url_override_is_applied() {
406 let r = sample()
407 .resolve(
408 None,
409 &Overrides {
410 es_url: Some("https://other-es.example.com".into()),
411 ..Default::default()
412 },
413 )
414 .unwrap();
415 assert_eq!(
416 r.profile.es_url.as_deref(),
417 Some("https://other-es.example.com")
418 );
419 }
420
421 #[test]
424 fn overriding_only_the_kibana_url_clears_the_profiles_es_url() {
425 let r = sample()
426 .resolve(
427 None,
428 &Overrides {
429 kibana_url: Some("https://other-kb.example.com".into()),
430 ..Default::default()
431 },
432 )
433 .unwrap();
434 assert_eq!(
435 r.profile.es_url, None,
436 "an inherited es_url would point at the profile's stack, not the overridden one"
437 );
438 }
439
440 #[test]
441 fn an_es_url_override_counts_as_an_identity_override() {
442 let r = sample()
443 .resolve(
444 None,
445 &Overrides {
446 es_url: Some("https://other-es.example.com".into()),
447 ..Default::default()
448 },
449 )
450 .unwrap();
451 assert_eq!(
452 r.source,
453 Source::Flags,
454 "changing which stack is addressed is an identity change"
455 );
456 }
457
458 #[test]
459 fn resolve_defaults_to_the_current_profile() {
460 let r = sample().resolve(None, &Overrides::default()).unwrap();
461 assert_eq!(r.name, "default");
462 assert_eq!(r.source, Source::Profile);
463 assert_eq!(r.profile.kibana_url, "https://kb.example.com");
464 }
465
466 #[test]
467 fn flags_override_the_profile_and_change_the_reported_source() {
468 let ov = Overrides {
469 kibana_url: Some("https://override.example.com".into()),
470 ..Default::default()
471 };
472 let r = sample().resolve(None, &ov).unwrap();
473 assert_eq!(r.profile.kibana_url, "https://override.example.com");
474 assert_eq!(
475 r.source,
476 Source::Flags,
477 "an identity override changes provenance"
478 );
479 }
480
481 #[test]
482 fn a_non_identity_override_does_not_change_the_source() {
483 let ov = Overrides {
484 timeout_secs: Some(90),
485 ..Default::default()
486 };
487 let r = sample().resolve(None, &ov).unwrap();
488 assert_eq!(r.profile.timeout_secs, 90);
489 assert_eq!(
490 r.source,
491 Source::Profile,
492 "timeout is not an identity field"
493 );
494 }
495
496 #[test]
497 fn redacted_hides_every_secret_field() {
498 let p = sample().profiles["default"].redacted();
499 assert_eq!(
500 p.api_key.as_deref(),
501 Some("***"),
502 "api_key must be redacted"
503 );
504 assert_eq!(
505 p.password.as_deref(),
506 Some("***"),
507 "password must be redacted"
508 );
509 assert_eq!(
510 p.kibana_url, "https://kb.example.com",
511 "non-secrets stay visible"
512 );
513 }
514
515 #[test]
516 fn redacted_leaves_absent_secrets_absent() {
517 let mut p = sample().profiles["default"].clone();
518 p.api_key = None;
519 p.password = None;
520 let redacted = p.redacted();
521 assert_eq!(redacted.api_key, None, "absent api_key stays absent");
522 assert_eq!(redacted.password, None, "absent password stays absent");
523 }
524
525 #[test]
526 fn banner_names_profile_host_and_space() {
527 let r = sample()
528 .resolve(Some("prod"), &Overrides::default())
529 .unwrap();
530 let b = r.banner();
531 assert!(b.contains("prod"), "banner must name the profile: {b}");
532 assert!(
533 b.contains("prod.example.com"),
534 "banner must name the host: {b}"
535 );
536 assert!(b.contains("default"), "banner must name the space: {b}");
537 }
538
539 #[test]
540 fn a_saved_config_never_contains_a_plaintext_key_in_a_world_readable_file() {
541 use std::os::unix::fs::PermissionsExt;
543 let dir = tempfile::tempdir().unwrap();
544 let path = dir.path().join("config.toml");
545 sample().save(&path).unwrap();
546 let body = fs::read_to_string(&path).unwrap();
547 assert!(
548 body.contains("essu_SECRET"),
549 "the real key is stored, not redacted on disk"
550 );
551 assert_eq!(
552 fs::metadata(&path).unwrap().permissions().mode() & 0o777,
553 0o600
554 );
555 }
556
557 #[test]
558 fn newly_created_file_is_mode_0600_not_umask_default() {
559 use std::os::unix::fs::PermissionsExt;
560 let dir = tempfile::tempdir().unwrap();
561 let path = dir.path().join("new_config.toml");
562 assert!(!path.exists());
564 sample().save(&path).unwrap();
565 let mode = fs::metadata(&path).unwrap().permissions().mode() & 0o777;
566 assert_eq!(
567 mode, 0o600,
568 "newly created config must be 0600 immediately, not umask default"
569 );
570 }
571
572 #[test]
573 fn host_parses_normal_https_url() {
574 let p = Profile {
575 kibana_url: "https://kb.example.com".into(),
576 ..Profile {
577 kibana_url: "".into(),
578 es_url: None,
579 api_key: None,
580 username: None,
581 password: None,
582 space: "default".into(),
583 verify: true,
584 timeout_secs: 30,
585 }
586 };
587 assert_eq!(p.host(), "kb.example.com");
588 }
589
590 #[test]
591 fn host_strips_path_from_url() {
592 let p = Profile {
593 kibana_url: "https://kb.example.com/api/spaces".into(),
594 ..Profile {
595 kibana_url: "".into(),
596 es_url: None,
597 api_key: None,
598 username: None,
599 password: None,
600 space: "default".into(),
601 verify: true,
602 timeout_secs: 30,
603 }
604 };
605 assert_eq!(p.host(), "kb.example.com");
606 }
607
608 #[test]
609 fn host_handles_doubled_scheme() {
610 let p = Profile {
611 kibana_url: "https://https://kb.example.com".into(),
612 ..Profile {
613 kibana_url: "".into(),
614 es_url: None,
615 api_key: None,
616 username: None,
617 password: None,
618 space: "default".into(),
619 verify: true,
620 timeout_secs: 30,
621 }
622 };
623 assert_eq!(
624 p.host(),
625 "kb.example.com",
626 "must extract host after last :// to avoid reporting wrong scheme as host"
627 );
628 }
629
630 #[test]
631 fn host_handles_bare_hostname() {
632 let p = Profile {
633 kibana_url: "kb.example.com".into(),
634 ..Profile {
635 kibana_url: "".into(),
636 es_url: None,
637 api_key: None,
638 username: None,
639 password: None,
640 space: "default".into(),
641 verify: true,
642 timeout_secs: 30,
643 }
644 };
645 assert_eq!(p.host(), "kb.example.com", "must fall back to original");
646 }
647
648 #[test]
649 fn host_handles_empty_string() {
650 let p = Profile {
651 kibana_url: "".into(),
652 es_url: None,
653 api_key: None,
654 username: None,
655 password: None,
656 space: "default".into(),
657 verify: true,
658 timeout_secs: 30,
659 };
660 assert_eq!(p.host(), "", "empty falls back to original");
661 }
662
663 #[test]
664 fn load_succeeds_on_a_permissive_file_and_prints_nothing() {
665 use std::os::unix::fs::PermissionsExt;
666 let dir = tempfile::tempdir().unwrap();
667 let path = dir.path().join("config.toml");
668 sample().save(&path).unwrap();
669 fs::set_permissions(&path, fs::Permissions::from_mode(0o644)).unwrap();
670 let cfg = Config::load(&path).unwrap();
672 assert!(
673 !cfg.profiles.is_empty(),
674 "load must succeed regardless of file permissions"
675 );
676 }
677
678 #[test]
679 fn permission_warning_flags_a_group_or_other_readable_file() {
680 use std::os::unix::fs::PermissionsExt;
681 let dir = tempfile::tempdir().unwrap();
682 let path = dir.path().join("config.toml");
683 sample().save(&path).unwrap();
684 fs::set_permissions(&path, fs::Permissions::from_mode(0o644)).unwrap();
685 let warning = Config::permission_warning(&path).expect("0644 must warn");
686 assert!(warning.contains("644"), "{warning}");
687 }
688
689 #[test]
690 fn permission_warning_is_none_for_an_owner_only_file() {
691 let dir = tempfile::tempdir().unwrap();
692 let path = dir.path().join("config.toml");
693 sample().save(&path).unwrap(); assert!(Config::permission_warning(&path).is_none());
695 }
696
697 #[test]
698 fn permission_warning_is_none_for_a_missing_file() {
699 let dir = tempfile::tempdir().unwrap();
700 assert!(Config::permission_warning(&dir.path().join("absent.toml")).is_none());
701 }
702
703 fn with_urls(kibana: &str, es: Option<&str>) -> Config {
704 let mut profiles = BTreeMap::new();
705 profiles.insert(
706 "default".to_string(),
707 Profile {
708 kibana_url: kibana.into(),
709 es_url: es.map(String::from),
710 api_key: Some("essu_SECRET".into()),
711 username: None,
712 password: None,
713 space: "default".into(),
714 verify: true,
715 timeout_secs: 30,
716 },
717 );
718 Config {
719 current: "default".into(),
720 profiles,
721 }
722 }
723
724 #[test]
725 fn resolve_strips_userinfo_from_both_urls() {
726 let r = with_urls(
727 "https://user:pass@kb.example.com",
728 Some("https://user:pass@es.example.com:9243/"),
729 )
730 .resolve(None, &Overrides::default())
731 .unwrap();
732 assert_eq!(r.profile.kibana_url, "https://kb.example.com");
733 assert_eq!(
734 r.profile.es_url.as_deref(),
735 Some("https://es.example.com:9243/")
736 );
737 }
738
739 #[test]
740 fn resolve_strips_userinfo_supplied_by_an_override() {
741 let ov = Overrides {
743 kibana_url: Some("https://user:pass@override.example.com".into()),
744 ..Default::default()
745 };
746 let r = with_urls("https://kb.example.com", None)
747 .resolve(None, &ov)
748 .unwrap();
749 assert_eq!(r.profile.kibana_url, "https://override.example.com");
750 }
751
752 #[test]
753 fn the_banner_never_shows_userinfo() {
754 let r = with_urls("https://user:hunter2@prod.example.com", None)
756 .resolve(None, &Overrides::default())
757 .unwrap();
758 let b = r.banner();
759 assert!(!b.contains("hunter2"), "{b}");
760 assert_eq!(b.matches('@').count(), 1, "{b}");
762 assert!(b.contains("prod.example.com"), "{b}");
763 }
764
765 #[test]
766 fn a_url_without_userinfo_is_left_exactly_as_written() {
767 for url in [
768 "https://kb.example.com",
769 "https://kb.example.com:5601/base/path?q=1",
770 "http://localhost:5601",
771 "kb.example.com",
772 "",
773 ] {
774 let mut p = with_urls(url, None).profiles["default"].clone();
775 p.strip_userinfo();
776 assert_eq!(
777 p.kibana_url, url,
778 "unchanged input must stay byte-identical"
779 );
780 }
781 }
782
783 #[test]
784 fn an_at_sign_outside_the_authority_is_not_treated_as_userinfo() {
785 let mut p =
787 with_urls("https://kb.example.com/a@b?user=x@y", None).profiles["default"].clone();
788 p.strip_userinfo();
789 assert_eq!(p.kibana_url, "https://kb.example.com/a@b?user=x@y");
790 }
791}