shep_core/config/probe.rs
1//! `ProbeTarget`: parsing a probe's `target` once, at config time.
2//!
3//! `ProbeConfig::target` is free-form text whose grammar depends on
4//! `ProbeConfig::kind`: an `http://` URL, a `host:port` pair, or a shell
5//! command line. Parsing at Flockfile-normalize time means a malformed
6//! target fails `shep start` naming the Flockfile field, rather than
7//! surfacing at the daemon's first poll after the sheep comes online.
8//!
9//! No URL crate: the grammar needs no userinfo, query, fragment, IDN, or
10//! percent-decoding, so a hand-rolled split covers it without pulling in
11//! `url` and the `idna`/Unicode tables it drags along.
12
13use core::fmt;
14
15use crate::config::app::{ProbeConfig, ProbeKind};
16
17/// A probe's `target` after validation: the form the prober consumes.
18///
19/// Parsing here rather than in the daemon means a malformed target fails the
20/// Flockfile, not the first poll ten seconds after the sheep is online.
21#[derive(Debug, Clone, PartialEq, Eq)]
22pub enum ProbeTarget {
23 /// `http://host[:port]/path`: port defaults to 80, path to `/`.
24 Http {
25 /// The host or IP literal. A bracketed IPv6 literal (`[::1]`) has
26 /// its brackets stripped.
27 ///
28 /// Carried for the prober: connect with
29 /// `(host.as_str(), port)`, not by formatting `"{host}:{port}"`
30 /// into a `SocketAddr` parse: a stripped IPv6 literal has no
31 /// brackets to make that string parseable. For the RFC 7230
32 /// `Host:` header, re-bracket an IPv6 host (`format!("[{host}]")`)
33 /// before writing it.
34 host: String,
35 /// The port; defaults to 80 when the authority carries none.
36 port: u16,
37 /// The path; defaults to `/` when the URL carries none.
38 ///
39 /// Free of whitespace and control characters whenever it came from
40 /// [`ProbeTarget::parse`]: see [`ProbeTargetError::InvalidPath`].
41 /// The prober writes this verbatim into a request line, so that is a
42 /// security property, not a tidiness one; a caller that builds this
43 /// variant by hand rather than parsing takes it on itself.
44 path: String,
45 },
46 /// `host:port`.
47 Tcp {
48 /// The host or IP literal. A bracketed IPv6 literal (`[::1]`) has
49 /// its brackets stripped.
50 ///
51 /// Carried for the prober: connect with
52 /// `(host.as_str(), port)`, not by formatting `"{host}:{port}"`
53 /// into a `SocketAddr` parse: a stripped IPv6 literal has no
54 /// brackets to make that string parseable.
55 host: String,
56 /// The port.
57 port: u16,
58 },
59 /// A command line, run through the platform shell.
60 Exec {
61 /// The command line exactly as written in the Flockfile.
62 command: String,
63 },
64}
65
66impl ProbeTarget {
67 /// Parses `config.target` according to `config.kind`.
68 ///
69 /// # Errors
70 ///
71 /// - [`ProbeTargetError::Empty`]: the target is empty or all whitespace.
72 /// - [`ProbeTargetError::HttpsUnsupported`]: an `https://` URL.
73 /// - [`ProbeTargetError::NotHttpUrl`]: no `http://` scheme.
74 /// - [`ProbeTargetError::MissingHost`]: the authority has no host.
75 /// - [`ProbeTargetError::InvalidHost`]: the host contains `@`, whitespace, or an embedded `:`.
76 /// - [`ProbeTargetError::InvalidPath`]: the path contains whitespace or a control character.
77 /// - [`ProbeTargetError::MissingPort`]: a TCP target with no `:port`.
78 /// - [`ProbeTargetError::BadPort`]: the port is not a `u16`.
79 pub fn parse(config: &ProbeConfig) -> Result<Self, ProbeTargetError> {
80 if config.target.trim().is_empty() {
81 return Err(ProbeTargetError::Empty);
82 }
83 match config.kind {
84 ProbeKind::Http => parse_http(&config.target),
85 ProbeKind::Tcp => parse_tcp(&config.target),
86 ProbeKind::Exec => Ok(Self::Exec {
87 command: config.target.clone(),
88 }),
89 }
90 }
91}
92
93/// Why a probe target was rejected.
94///
95/// `#[non_exhaustive]`: growth is expected as probe kinds change.
96#[non_exhaustive]
97#[derive(Debug, Clone, PartialEq, Eq)]
98pub enum ProbeTargetError {
99 /// The target is empty or all whitespace.
100 Empty,
101 /// An `https://` URL. TLS probe targets are not supported.
102 HttpsUnsupported {
103 /// The target as written in the Flockfile.
104 target: String,
105 },
106 /// An HTTP probe target with no `http://` scheme.
107 NotHttpUrl {
108 /// The target as written in the Flockfile.
109 target: String,
110 },
111 /// The authority is empty: `http:///path`.
112 MissingHost {
113 /// The target as written in the Flockfile.
114 target: String,
115 },
116 /// The host contains a character the grammar has no field for: `@`
117 /// (userinfo), whitespace, or an embedded `:` (outside a bracketed IPv6
118 /// literal, a sign the authority carried more than one `host:port`
119 /// pair).
120 InvalidHost {
121 /// The target as written in the Flockfile.
122 target: String,
123 },
124 /// The path contains whitespace or a control character. Both break the
125 /// request line the prober builds around it: a `\r\n` appends
126 /// Flockfile-chosen headers, or an entire second request, to what goes
127 /// on the socket, and a space ends the path field early.
128 InvalidPath {
129 /// The target as written in the Flockfile.
130 target: String,
131 },
132 /// A TCP target with no `:port`.
133 MissingPort {
134 /// The target as written in the Flockfile.
135 target: String,
136 },
137 /// The port is not a `u16`.
138 BadPort {
139 /// The target as written in the Flockfile.
140 target: String,
141 },
142}
143
144impl fmt::Display for ProbeTargetError {
145 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
146 match self {
147 Self::Empty => f.write_str("probe target is empty"),
148 Self::HttpsUnsupported { target } => write!(
149 f,
150 "probe target `{target}` uses https://, which shep's probe client does not \
151 support (no TLS)"
152 ),
153 Self::NotHttpUrl { target } => {
154 write!(f, "probe target `{target}` is not an http:// URL")
155 }
156 Self::MissingHost { target } => write!(f, "probe target `{target}` has no host"),
157 Self::InvalidHost { target } => write!(
158 f,
159 "probe target `{target}` has a host containing `@`, whitespace, or an embedded \
160 `:`"
161 ),
162 Self::InvalidPath { target } => write!(
163 f,
164 "probe target `{target}` has a path containing whitespace or a control character"
165 ),
166 Self::MissingPort { target } => write!(f, "probe target `{target}` has no port"),
167 Self::BadPort { target } => {
168 write!(
169 f,
170 "probe target `{target}` has a port that is not a valid u16"
171 )
172 }
173 }
174 }
175}
176
177impl core::error::Error for ProbeTargetError {}
178
179/// Parses an `http://` target into host, port and path.
180fn parse_http(target: &str) -> Result<ProbeTarget, ProbeTargetError> {
181 // The empty check in `ProbeTarget::parse` trims before deciding whether
182 // there's anything here at all; the scheme match trims too, so
183 // `" http://host/ "` is accepted the same as `"http://host/"` instead
184 // of falling through to `NotHttpUrl`.
185 let trimmed = target.trim();
186 let Some(rest) = strip_prefix_ignore_ascii_case(trimmed, "http://") else {
187 if strip_prefix_ignore_ascii_case(trimmed, "https://").is_some() {
188 return Err(ProbeTargetError::HttpsUnsupported {
189 target: target.to_string(),
190 });
191 }
192 return Err(ProbeTargetError::NotHttpUrl {
193 target: target.to_string(),
194 });
195 };
196
197 let (authority, path) = match rest.find('/') {
198 Some(idx) => (&rest[..idx], &rest[idx..]),
199 None => (rest, "/"),
200 };
201 if authority.is_empty() {
202 return Err(ProbeTargetError::MissingHost {
203 target: target.to_string(),
204 });
205 }
206
207 let (host, port_str) = split_authority(authority, target)?;
208 if host.is_empty() {
209 return Err(ProbeTargetError::MissingHost {
210 target: target.to_string(),
211 });
212 }
213 validate_host(host, authority, target)?;
214 validate_path(path, target)?;
215 let port = parse_port(port_str.unwrap_or("80"), target)?;
216
217 Ok(ProbeTarget::Http {
218 host: host.to_string(),
219 port,
220 path: path.to_string(),
221 })
222}
223
224/// Splits an authority into `(host, port)`. The port is `None` when the
225/// authority carries none, leaving "default it" (HTTP, to 80) versus
226/// "require it" (TCP, [`ProbeTargetError::MissingPort`]) to the caller.
227/// Shared by [`parse_http`] and [`parse_tcp`] so both schemes agree on what
228/// a bracketed IPv6 host looks like.
229///
230/// Bracketed IPv6 (`[::1]:8080`) is matched before the general "split on the
231/// last colon" rule runs: splitting on the last colon alone puts `1]` in
232/// the host, and on the first puts an empty host and `:1]:8080` in the
233/// port.
234fn split_authority<'a>(
235 authority: &'a str,
236 target: &str,
237) -> Result<(&'a str, Option<&'a str>), ProbeTargetError> {
238 if let Some(inner) = authority.strip_prefix('[') {
239 // A missing closing bracket leaves no host to extract; report it the
240 // same way an empty authority is reported.
241 let close = inner
242 .find(']')
243 .ok_or_else(|| ProbeTargetError::MissingHost {
244 target: target.to_string(),
245 })?;
246 let host = &inner[..close];
247 let after = &inner[close + 1..];
248 let port_str = match after.strip_prefix(':') {
249 Some(p) => Some(p),
250 None if after.is_empty() => None,
251 // Trailing characters after `]` that are neither `:port` nor
252 // nothing (e.g. `[::1]x`) have no valid port to report.
253 None => {
254 return Err(ProbeTargetError::BadPort {
255 target: target.to_string(),
256 });
257 }
258 };
259 return Ok((host, port_str));
260 }
261 match authority.rsplit_once(':') {
262 Some((host, port_str)) => Ok((host, Some(port_str))),
263 None => Ok((authority, None)),
264 }
265}
266
267/// Rejects the host grammars [`ProbeTargetError::InvalidHost`] documents.
268/// All three parse as a syntactically fine host and never resolve at poll
269/// time.
270fn validate_host(host: &str, authority: &str, target: &str) -> Result<(), ProbeTargetError> {
271 let bracketed = authority.starts_with('[');
272 let invalid = host.contains('@')
273 || host.chars().any(char::is_whitespace)
274 || (!bracketed && host.contains(':'));
275 if invalid {
276 return Err(ProbeTargetError::InvalidHost {
277 target: target.to_string(),
278 });
279 }
280 Ok(())
281}
282
283/// Rejects a path containing whitespace or a control character.
284///
285/// The prober writes this path verbatim into `GET {path} HTTP/1.1\r\n…`, so
286/// a `\r\n` inside it is header injection, and a space ends the request
287/// line's path early, handing the server whatever follows as the HTTP
288/// version. RFC 3986 has no spelling for either character inside a path: a
289/// space is written `%20`, and a control character is percent-encoded too.
290fn validate_path(path: &str, target: &str) -> Result<(), ProbeTargetError> {
291 if path.chars().any(|c| c.is_whitespace() || c.is_control()) {
292 return Err(ProbeTargetError::InvalidPath {
293 target: target.to_string(),
294 });
295 }
296 Ok(())
297}
298
299/// Case-insensitively strips `prefix` from the start of `s`, so `HTTPS://`
300/// is recognized the same as `https://` (schemes are case-insensitive per
301/// RFC 3986 §3.1). `s.get(..prefix.len())` rather than a byte-index slice:
302/// a multi-byte character straddling that boundary would otherwise panic.
303fn strip_prefix_ignore_ascii_case<'a>(s: &'a str, prefix: &str) -> Option<&'a str> {
304 let head = s.get(..prefix.len())?;
305 head.eq_ignore_ascii_case(prefix)
306 .then_some(&s[prefix.len()..])
307}
308
309/// Parses a TCP `host:port` target: the whole target is the authority,
310/// since a TCP target carries no scheme or path.
311fn parse_tcp(target: &str) -> Result<ProbeTarget, ProbeTargetError> {
312 let (host, port_str) = split_authority(target, target)?;
313 if host.is_empty() {
314 return Err(ProbeTargetError::MissingHost {
315 target: target.to_string(),
316 });
317 }
318 validate_host(host, target, target)?;
319 let Some(port_str) = port_str else {
320 return Err(ProbeTargetError::MissingPort {
321 target: target.to_string(),
322 });
323 };
324 if port_str.is_empty() {
325 return Err(ProbeTargetError::MissingPort {
326 target: target.to_string(),
327 });
328 }
329 let port = parse_port(port_str, target)?;
330 Ok(ProbeTarget::Tcp {
331 host: host.to_string(),
332 port,
333 })
334}
335
336/// Parses a port string into a `u16`, reporting the original target (not
337/// just the port substring) so the error names the line the user has to edit.
338fn parse_port(port_str: &str, target: &str) -> Result<u16, ProbeTargetError> {
339 port_str
340 .parse::<u16>()
341 .map_err(|_| ProbeTargetError::BadPort {
342 target: target.to_string(),
343 })
344}
345
346#[cfg(test)]
347mod tests {
348 use super::*;
349 use crate::values::UpDuration;
350
351 fn probe_config(kind: ProbeKind, target: &str) -> ProbeConfig {
352 ProbeConfig {
353 kind,
354 target: target.to_string(),
355 interval: UpDuration::from_millis(10_000),
356 timeout: UpDuration::from_millis(5_000),
357 failure_threshold: 3,
358 }
359 }
360
361 #[test]
362 fn empty_target_rejected_for_every_kind() {
363 // fails if the empty check lives inside one kind's parser instead of
364 // running before the kind dispatch, so Exec's "only emptiness is
365 // rejected" carve-out skips the check entirely rather than skipping
366 // every other check
367 for kind in [ProbeKind::Http, ProbeKind::Tcp, ProbeKind::Exec] {
368 assert_eq!(
369 ProbeTarget::parse(&probe_config(kind, "")).unwrap_err(),
370 ProbeTargetError::Empty
371 );
372 assert_eq!(
373 ProbeTarget::parse(&probe_config(kind, " ")).unwrap_err(),
374 ProbeTargetError::Empty
375 );
376 }
377 }
378
379 #[test]
380 fn http_full_url_with_port_and_path_accepted() {
381 // fails if authority/path splitting is off by one around the first `/`
382 let target = ProbeTarget::parse(&probe_config(
383 ProbeKind::Http,
384 "http://127.0.0.1:8080/healthz",
385 ))
386 .unwrap();
387 assert_eq!(
388 target,
389 ProbeTarget::Http {
390 host: "127.0.0.1".to_string(),
391 port: 8080,
392 path: "/healthz".to_string(),
393 }
394 );
395 }
396
397 #[test]
398 fn http_missing_port_defaults_to_80_and_path_defaults_to_root() {
399 // fails if the no-colon authority branch forgets to default the port
400 let target =
401 ProbeTarget::parse(&probe_config(ProbeKind::Http, "http://localhost/")).unwrap();
402 assert_eq!(
403 target,
404 ProbeTarget::Http {
405 host: "localhost".to_string(),
406 port: 80,
407 path: "/".to_string(),
408 }
409 );
410 }
411
412 #[test]
413 fn http_missing_path_defaults_to_root() {
414 // fails if the no-slash case (rest.find('/') == None) is not handled,
415 // e.g. by treating the whole remainder as the authority and leaving
416 // the path empty instead of "/"
417 let target =
418 ProbeTarget::parse(&probe_config(ProbeKind::Http, "http://localhost:3000")).unwrap();
419 assert_eq!(
420 target,
421 ProbeTarget::Http {
422 host: "localhost".to_string(),
423 port: 3000,
424 path: "/".to_string(),
425 }
426 );
427 }
428
429 #[test]
430 fn http_bracketed_ipv6_with_port_and_path_accepted() {
431 // fails if the host/port split uses a plain rsplit_once(':') without
432 // checking for the bracket first, which would split "[::1]:8080"
433 // into host "1]" (wrong) or worse
434 let target =
435 ProbeTarget::parse(&probe_config(ProbeKind::Http, "http://[::1]:8080/x")).unwrap();
436 assert_eq!(
437 target,
438 ProbeTarget::Http {
439 host: "::1".to_string(),
440 port: 8080,
441 path: "/x".to_string(),
442 }
443 );
444 }
445
446 #[test]
447 fn http_bracketed_ipv6_without_port_defaults_to_80() {
448 // fails if the bracket branch requires a following `:port` instead of
449 // treating "nothing after the bracket" as "default port"
450 let target = ProbeTarget::parse(&probe_config(ProbeKind::Http, "http://[::1]/x")).unwrap();
451 assert_eq!(
452 target,
453 ProbeTarget::Http {
454 host: "::1".to_string(),
455 port: 80,
456 path: "/x".to_string(),
457 }
458 );
459 }
460
461 #[test]
462 fn https_scheme_rejected_as_unsupported() {
463 // fails if https:// is treated as a generic "not http" failure
464 // instead of its own named variant
465 let err = ProbeTarget::parse(&probe_config(ProbeKind::Http, "https://x/")).unwrap_err();
466 assert_eq!(
467 err,
468 ProbeTargetError::HttpsUnsupported {
469 target: "https://x/".to_string()
470 }
471 );
472 // fails if the error message regresses to something generic:
473 // variant identity alone doesn't guard the text.
474 assert!(err.to_string().contains("no TLS"), "{err}");
475 }
476
477 #[test]
478 fn https_scheme_matched_case_insensitively() {
479 // fails if scheme matching is case-sensitive: `HTTPS://` would then
480 // fall through to the generic NotHttpUrl, losing the TLS-specific
481 // explanation
482 assert_eq!(
483 ProbeTarget::parse(&probe_config(ProbeKind::Http, "HTTPS://x/")).unwrap_err(),
484 ProbeTargetError::HttpsUnsupported {
485 target: "HTTPS://x/".to_string()
486 }
487 );
488 }
489
490 #[test]
491 fn http_scheme_matched_case_insensitively() {
492 // fails if scheme matching is case-sensitive: `HTTP://` would then
493 // be rejected as NotHttpUrl instead of parsed
494 let target = ProbeTarget::parse(&probe_config(ProbeKind::Http, "HTTP://host/x")).unwrap();
495 assert_eq!(
496 target,
497 ProbeTarget::Http {
498 host: "host".to_string(),
499 port: 80,
500 path: "/x".to_string(),
501 }
502 );
503 }
504
505 #[test]
506 fn surrounding_whitespace_trimmed_before_scheme_match() {
507 // fails if only the empty-check trims (config.target.trim().is_empty())
508 // while the scheme matcher runs on the untrimmed target: that
509 // combination accepts "" but rejects " http://host/ " as
510 // NotHttpUrl, even though both are just whitespace-padded
511 let target =
512 ProbeTarget::parse(&probe_config(ProbeKind::Http, " http://host/ ")).unwrap();
513 assert_eq!(
514 target,
515 ProbeTarget::Http {
516 host: "host".to_string(),
517 port: 80,
518 path: "/".to_string(),
519 }
520 );
521 }
522
523 #[test]
524 fn scheme_missing_rejected_as_not_http_url() {
525 // fails if a target with no recognized scheme at all is mishandled
526 // (e.g. parsed as if "http://" were implied)
527 assert_eq!(
528 ProbeTarget::parse(&probe_config(ProbeKind::Http, "x/")).unwrap_err(),
529 ProbeTargetError::NotHttpUrl {
530 target: "x/".to_string()
531 }
532 );
533 }
534
535 #[test]
536 fn non_http_scheme_rejected_as_not_http_url() {
537 // fails if scheme detection only checks for the ABSENCE of "http://"
538 // rather than also rejecting a different, unrelated scheme
539 assert_eq!(
540 ProbeTarget::parse(&probe_config(ProbeKind::Http, "ftp://x/")).unwrap_err(),
541 ProbeTargetError::NotHttpUrl {
542 target: "ftp://x/".to_string()
543 }
544 );
545 }
546
547 #[test]
548 fn empty_authority_rejected_as_missing_host() {
549 // fails if an empty authority ("http:///path") is handed to the
550 // colon-splitting logic instead of being caught first, which would
551 // report a confusing BadPort or silently produce an empty host
552 assert_eq!(
553 ProbeTarget::parse(&probe_config(ProbeKind::Http, "http:///path")).unwrap_err(),
554 ProbeTargetError::MissingHost {
555 target: "http:///path".to_string()
556 }
557 );
558 }
559
560 #[test]
561 fn http_empty_host_before_port_rejected_as_missing_host() {
562 // fails if the empty-host guard that runs after the authority split
563 // is dropped: ":8080" is a non-empty authority, so the pre-split
564 // guard passes it through, and `http://:8080/` would parse to an
565 // empty host that never resolves at poll time.
566 assert_eq!(
567 ProbeTarget::parse(&probe_config(ProbeKind::Http, "http://:8080/")).unwrap_err(),
568 ProbeTargetError::MissingHost {
569 target: "http://:8080/".to_string()
570 }
571 );
572 }
573
574 #[test]
575 fn http_unclosed_bracket_rejected_as_missing_host() {
576 // fails if the bracket branch reports a missing `]` as anything but
577 // MissingHost: there is no host to extract from "[::1", so a BadPort
578 // would point the user at a port the target never carried.
579 assert_eq!(
580 ProbeTarget::parse(&probe_config(ProbeKind::Http, "http://[::1/")).unwrap_err(),
581 ProbeTargetError::MissingHost {
582 target: "http://[::1/".to_string()
583 }
584 );
585 }
586
587 #[test]
588 fn http_trailing_characters_after_bracket_rejected_as_bad_port() {
589 // fails if characters after `]` that are neither `:port` nor nothing
590 // are read as "no port": `http://[::1]x` would then quietly become
591 // `::1` on port 80, dropping the `x` the user wrote instead of saying
592 // the port is unreadable.
593 assert_eq!(
594 ProbeTarget::parse(&probe_config(ProbeKind::Http, "http://[::1]x")).unwrap_err(),
595 ProbeTargetError::BadPort {
596 target: "http://[::1]x".to_string()
597 }
598 );
599 }
600
601 #[test]
602 fn http_userinfo_in_host_rejected_as_invalid_host() {
603 // fails if the host/port split's "last colon" rule is trusted at
604 // face value: "user:pass@host:8080" would otherwise parse to host
605 // "user:pass@host", a userinfo prefix the grammar has no field for
606 // and that will never resolve at poll time
607 assert_eq!(
608 ProbeTarget::parse(&probe_config(
609 ProbeKind::Http,
610 "http://user:pass@host:8080/"
611 ))
612 .unwrap_err(),
613 ProbeTargetError::InvalidHost {
614 target: "http://user:pass@host:8080/".to_string()
615 }
616 );
617 }
618
619 #[test]
620 fn http_whitespace_in_host_rejected_as_invalid_host() {
621 // fails if a host containing a literal space is accepted outright
622 assert_eq!(
623 ProbeTarget::parse(&probe_config(ProbeKind::Http, "http://my host/")).unwrap_err(),
624 ProbeTargetError::InvalidHost {
625 target: "http://my host/".to_string()
626 }
627 );
628 }
629
630 #[test]
631 fn http_second_colon_in_host_rejected_as_invalid_host() {
632 // fails if "host:8080:9090" is trusted after a single rsplit_once:
633 // that puts "9090" in the port and leaves "host:8080" as the host,
634 // silently dropping the middle port instead of rejecting the target
635 assert_eq!(
636 ProbeTarget::parse(&probe_config(ProbeKind::Http, "http://host:8080:9090/"))
637 .unwrap_err(),
638 ProbeTargetError::InvalidHost {
639 target: "http://host:8080:9090/".to_string()
640 }
641 );
642 }
643
644 #[test]
645 fn http_crlf_in_path_rejected_as_invalid_path() {
646 // fails if the path is carried through unvalidated: this target puts
647 // `X-Injected: yes` on the wire as a real header while the probe
648 // still reports success. The trailing `yes` matters, since a
649 // payload ending in `\r\n` itself would be trimmed and prove nothing.
650 let target = "http://host:8080/health\r\nX-Injected: yes";
651 assert_eq!(
652 ProbeTarget::parse(&probe_config(ProbeKind::Http, target)).unwrap_err(),
653 ProbeTargetError::InvalidPath {
654 target: target.to_string()
655 }
656 );
657 }
658
659 #[test]
660 fn http_space_in_path_rejected_as_invalid_path() {
661 // fails if the check looks for `\r` and `\n` alone: a space is the
662 // same defect one field earlier, ending the request line's path and
663 // handing the server `b` where the HTTP version belongs.
664 assert_eq!(
665 ProbeTarget::parse(&probe_config(ProbeKind::Http, "http://host/a b")).unwrap_err(),
666 ProbeTargetError::InvalidPath {
667 target: "http://host/a b".to_string()
668 }
669 );
670 }
671
672 #[test]
673 fn http_control_character_in_path_rejected_as_invalid_path() {
674 // fails if the check tests `is_whitespace` alone: a NUL is neither
675 // whitespace nor printable, and no RFC 3986 path may carry one
676 // unencoded.
677 assert_eq!(
678 ProbeTarget::parse(&probe_config(ProbeKind::Http, "http://host/a\u{0}b")).unwrap_err(),
679 ProbeTargetError::InvalidPath {
680 target: "http://host/a\u{0}b".to_string()
681 }
682 );
683 }
684
685 #[test]
686 fn http_query_and_percent_encoded_path_still_accepted() {
687 // fails if the path check is widened into full URI validation: `?`,
688 // `&`, `=` and `%20` are all ordinary text in the tail of a target,
689 // and `%20` is precisely how the space rejected above is meant to be
690 // written.
691 let target = ProbeTarget::parse(&probe_config(
692 ProbeKind::Http,
693 "http://host/health?a=1&b=%20x",
694 ))
695 .unwrap();
696 assert_eq!(
697 target,
698 ProbeTarget::Http {
699 host: "host".to_string(),
700 port: 80,
701 path: "/health?a=1&b=%20x".to_string(),
702 }
703 );
704 }
705
706 #[test]
707 fn tcp_userinfo_in_host_rejected_as_invalid_host() {
708 // fails if validate_host is wired only into parse_http and not
709 // parse_tcp, which shares the same split_authority call
710 assert_eq!(
711 ProbeTarget::parse(&probe_config(ProbeKind::Tcp, "user:pass@host:5432")).unwrap_err(),
712 ProbeTargetError::InvalidHost {
713 target: "user:pass@host:5432".to_string()
714 }
715 );
716 }
717
718 #[test]
719 fn non_numeric_port_rejected_as_bad_port() {
720 // fails if the port substring is stored as-is without ever being
721 // parsed into a u16
722 assert_eq!(
723 ProbeTarget::parse(&probe_config(ProbeKind::Http, "http://host:notaport/"))
724 .unwrap_err(),
725 ProbeTargetError::BadPort {
726 target: "http://host:notaport/".to_string()
727 }
728 );
729 }
730
731 #[test]
732 fn port_out_of_u16_range_rejected_as_bad_port() {
733 // fails if the port is parsed as a wider integer type (e.g. u32) and
734 // then truncated/cast into u16 instead of rejected
735 assert_eq!(
736 ProbeTarget::parse(&probe_config(ProbeKind::Http, "http://host:99999/")).unwrap_err(),
737 ProbeTargetError::BadPort {
738 target: "http://host:99999/".to_string()
739 }
740 );
741 }
742
743 #[test]
744 fn tcp_host_and_port_accepted() {
745 let target = ProbeTarget::parse(&probe_config(ProbeKind::Tcp, "db.internal:5432")).unwrap();
746 assert_eq!(
747 target,
748 ProbeTarget::Tcp {
749 host: "db.internal".to_string(),
750 port: 5432,
751 }
752 );
753 }
754
755 #[test]
756 fn tcp_no_colon_rejected_as_missing_port() {
757 // fails if a TCP target with no colon at all is mistaken for a bare
758 // hostname with a default port: TCP has no default port
759 assert_eq!(
760 ProbeTarget::parse(&probe_config(ProbeKind::Tcp, "host")).unwrap_err(),
761 ProbeTargetError::MissingPort {
762 target: "host".to_string()
763 }
764 );
765 }
766
767 #[test]
768 fn tcp_trailing_colon_rejected_as_missing_port() {
769 // fails if an empty port substring after the colon is parsed as "0"
770 // or otherwise accepted instead of rejected
771 assert_eq!(
772 ProbeTarget::parse(&probe_config(ProbeKind::Tcp, "host:")).unwrap_err(),
773 ProbeTargetError::MissingPort {
774 target: "host:".to_string()
775 }
776 );
777 }
778
779 #[test]
780 fn tcp_missing_host_rejected() {
781 // fails if an empty host substring before the colon is accepted as a
782 // literal empty hostname instead of rejected
783 assert_eq!(
784 ProbeTarget::parse(&probe_config(ProbeKind::Tcp, ":8080")).unwrap_err(),
785 ProbeTargetError::MissingHost {
786 target: ":8080".to_string()
787 }
788 );
789 }
790
791 #[test]
792 fn tcp_bracketed_ipv6_with_port_accepted() {
793 // fails if parse_tcp hands its target straight to rsplit_once(':')
794 // instead of routing through split_authority: that puts "1]" in the
795 // host, and separately `("[::1]", 8080)` fails DNS lookup where
796 // `("::1", 8080)` succeeds.
797 let target = ProbeTarget::parse(&probe_config(ProbeKind::Tcp, "[::1]:5432")).unwrap();
798 assert_eq!(
799 target,
800 ProbeTarget::Tcp {
801 host: "::1".to_string(),
802 port: 5432,
803 }
804 );
805 }
806
807 #[test]
808 fn tcp_bracketed_ipv6_without_port_rejected_as_missing_port() {
809 // fails if the bracket branch's "no port" case is defaulted to 80
810 // for TCP the way it is for HTTP: TCP has no default port
811 assert_eq!(
812 ProbeTarget::parse(&probe_config(ProbeKind::Tcp, "[::1]")).unwrap_err(),
813 ProbeTargetError::MissingPort {
814 target: "[::1]".to_string()
815 }
816 );
817 }
818
819 #[test]
820 fn tcp_unbracketed_ipv6_rejected_as_invalid_host() {
821 // An unbracketed IPv6-shaped host is ambiguous: naive splitting on
822 // the last colon would put "::1" in the host and "5432" in the
823 // port, a spelling only the bracketed form should mean.
824 assert_eq!(
825 ProbeTarget::parse(&probe_config(ProbeKind::Tcp, "::1:5432")).unwrap_err(),
826 ProbeTargetError::InvalidHost {
827 target: "::1:5432".to_string()
828 }
829 );
830 }
831
832 #[test]
833 fn exec_arbitrary_command_line_accepted_unmodified() {
834 // fails if Exec narrows the accepted grammar beyond emptiness, e.g.
835 // rejecting shell metacharacters or splitting on whitespace
836 let command = "sh -c 'curl -f http://localhost/ || exit 1'";
837 let target = ProbeTarget::parse(&probe_config(ProbeKind::Exec, command)).unwrap();
838 assert_eq!(
839 target,
840 ProbeTarget::Exec {
841 command: command.to_string()
842 }
843 );
844 }
845}