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