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