Skip to main content

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}