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 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}