use core::fmt;
use crate::config::app::{ProbeConfig, ProbeKind};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ProbeTarget {
Http {
host: String,
port: u16,
path: String,
},
Tcp {
host: String,
port: u16,
},
Exec {
command: String,
},
}
impl ProbeTarget {
pub fn parse(config: &ProbeConfig) -> Result<Self, ProbeTargetError> {
if config.target.trim().is_empty() {
return Err(ProbeTargetError::Empty);
}
match config.kind {
ProbeKind::Http => parse_http(&config.target),
ProbeKind::Tcp => parse_tcp(&config.target),
ProbeKind::Exec => Ok(Self::Exec {
command: config.target.clone(),
}),
}
}
}
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ProbeTargetError {
Empty,
HttpsUnsupported {
target: String,
},
NotHttpUrl {
target: String,
},
MissingHost {
target: String,
},
InvalidHost {
target: String,
},
InvalidPath {
target: String,
},
MissingPort {
target: String,
},
BadPort {
target: String,
},
}
impl fmt::Display for ProbeTargetError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Empty => f.write_str("probe target is empty"),
Self::HttpsUnsupported { target } => write!(
f,
"probe target `{target}` uses https://, which shep's probe client does not \
support (no TLS)"
),
Self::NotHttpUrl { target } => {
write!(f, "probe target `{target}` is not an http:// URL")
}
Self::MissingHost { target } => write!(f, "probe target `{target}` has no host"),
Self::InvalidHost { target } => write!(
f,
"probe target `{target}` has a host containing `@`, whitespace, or an embedded \
`:`"
),
Self::InvalidPath { target } => write!(
f,
"probe target `{target}` has a path containing whitespace or a control character"
),
Self::MissingPort { target } => write!(f, "probe target `{target}` has no port"),
Self::BadPort { target } => {
write!(
f,
"probe target `{target}` has a port that is not a valid u16"
)
}
}
}
}
impl core::error::Error for ProbeTargetError {}
fn parse_http(target: &str) -> Result<ProbeTarget, ProbeTargetError> {
let trimmed = target.trim();
let Some(rest) = strip_prefix_ignore_ascii_case(trimmed, "http://") else {
if strip_prefix_ignore_ascii_case(trimmed, "https://").is_some() {
return Err(ProbeTargetError::HttpsUnsupported {
target: target.to_string(),
});
}
return Err(ProbeTargetError::NotHttpUrl {
target: target.to_string(),
});
};
let (authority, path) = match rest.find('/') {
Some(idx) => (&rest[..idx], &rest[idx..]),
None => (rest, "/"),
};
if authority.is_empty() {
return Err(ProbeTargetError::MissingHost {
target: target.to_string(),
});
}
let (host, port_str) = split_authority(authority, target)?;
if host.is_empty() {
return Err(ProbeTargetError::MissingHost {
target: target.to_string(),
});
}
validate_host(host, authority, target)?;
validate_path(path, target)?;
let port = parse_port(port_str.unwrap_or("80"), target)?;
Ok(ProbeTarget::Http {
host: host.to_string(),
port,
path: path.to_string(),
})
}
fn split_authority<'a>(
authority: &'a str,
target: &str,
) -> Result<(&'a str, Option<&'a str>), ProbeTargetError> {
if let Some(inner) = authority.strip_prefix('[') {
let close = inner
.find(']')
.ok_or_else(|| ProbeTargetError::MissingHost {
target: target.to_string(),
})?;
let host = &inner[..close];
let after = &inner[close + 1..];
let port_str = match after.strip_prefix(':') {
Some(p) => Some(p),
None if after.is_empty() => None,
None => {
return Err(ProbeTargetError::BadPort {
target: target.to_string(),
});
}
};
return Ok((host, port_str));
}
match authority.rsplit_once(':') {
Some((host, port_str)) => Ok((host, Some(port_str))),
None => Ok((authority, None)),
}
}
fn validate_host(host: &str, authority: &str, target: &str) -> Result<(), ProbeTargetError> {
let bracketed = authority.starts_with('[');
let invalid = host.contains('@')
|| host.chars().any(char::is_whitespace)
|| (!bracketed && host.contains(':'));
if invalid {
return Err(ProbeTargetError::InvalidHost {
target: target.to_string(),
});
}
Ok(())
}
fn validate_path(path: &str, target: &str) -> Result<(), ProbeTargetError> {
if path.chars().any(|c| c.is_whitespace() || c.is_control()) {
return Err(ProbeTargetError::InvalidPath {
target: target.to_string(),
});
}
Ok(())
}
fn strip_prefix_ignore_ascii_case<'a>(s: &'a str, prefix: &str) -> Option<&'a str> {
let head = s.get(..prefix.len())?;
head.eq_ignore_ascii_case(prefix)
.then_some(&s[prefix.len()..])
}
fn parse_tcp(target: &str) -> Result<ProbeTarget, ProbeTargetError> {
let (host, port_str) = split_authority(target, target)?;
if host.is_empty() {
return Err(ProbeTargetError::MissingHost {
target: target.to_string(),
});
}
validate_host(host, target, target)?;
let Some(port_str) = port_str else {
return Err(ProbeTargetError::MissingPort {
target: target.to_string(),
});
};
if port_str.is_empty() {
return Err(ProbeTargetError::MissingPort {
target: target.to_string(),
});
}
let port = parse_port(port_str, target)?;
Ok(ProbeTarget::Tcp {
host: host.to_string(),
port,
})
}
fn parse_port(port_str: &str, target: &str) -> Result<u16, ProbeTargetError> {
port_str
.parse::<u16>()
.map_err(|_| ProbeTargetError::BadPort {
target: target.to_string(),
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::values::UpDuration;
fn probe_config(kind: ProbeKind, target: &str) -> ProbeConfig {
ProbeConfig {
kind,
target: target.to_string(),
interval: UpDuration::from_millis(10_000),
timeout: UpDuration::from_millis(5_000),
failure_threshold: 3,
}
}
#[test]
fn empty_target_rejected_for_every_kind() {
for kind in [ProbeKind::Http, ProbeKind::Tcp, ProbeKind::Exec] {
assert_eq!(
ProbeTarget::parse(&probe_config(kind, "")).unwrap_err(),
ProbeTargetError::Empty
);
assert_eq!(
ProbeTarget::parse(&probe_config(kind, " ")).unwrap_err(),
ProbeTargetError::Empty
);
}
}
#[test]
fn http_full_url_with_port_and_path_accepted() {
let target = ProbeTarget::parse(&probe_config(
ProbeKind::Http,
"http://127.0.0.1:8080/healthz",
))
.unwrap();
assert_eq!(
target,
ProbeTarget::Http {
host: "127.0.0.1".to_string(),
port: 8080,
path: "/healthz".to_string(),
}
);
}
#[test]
fn http_missing_port_defaults_to_80_and_path_defaults_to_root() {
let target =
ProbeTarget::parse(&probe_config(ProbeKind::Http, "http://localhost/")).unwrap();
assert_eq!(
target,
ProbeTarget::Http {
host: "localhost".to_string(),
port: 80,
path: "/".to_string(),
}
);
}
#[test]
fn http_missing_path_defaults_to_root() {
let target =
ProbeTarget::parse(&probe_config(ProbeKind::Http, "http://localhost:3000")).unwrap();
assert_eq!(
target,
ProbeTarget::Http {
host: "localhost".to_string(),
port: 3000,
path: "/".to_string(),
}
);
}
#[test]
fn http_bracketed_ipv6_with_port_and_path_accepted() {
let target =
ProbeTarget::parse(&probe_config(ProbeKind::Http, "http://[::1]:8080/x")).unwrap();
assert_eq!(
target,
ProbeTarget::Http {
host: "::1".to_string(),
port: 8080,
path: "/x".to_string(),
}
);
}
#[test]
fn http_bracketed_ipv6_without_port_defaults_to_80() {
let target = ProbeTarget::parse(&probe_config(ProbeKind::Http, "http://[::1]/x")).unwrap();
assert_eq!(
target,
ProbeTarget::Http {
host: "::1".to_string(),
port: 80,
path: "/x".to_string(),
}
);
}
#[test]
fn https_scheme_rejected_as_unsupported() {
let err = ProbeTarget::parse(&probe_config(ProbeKind::Http, "https://x/")).unwrap_err();
assert_eq!(
err,
ProbeTargetError::HttpsUnsupported {
target: "https://x/".to_string()
}
);
assert!(err.to_string().contains("no TLS"), "{err}");
}
#[test]
fn https_scheme_matched_case_insensitively() {
assert_eq!(
ProbeTarget::parse(&probe_config(ProbeKind::Http, "HTTPS://x/")).unwrap_err(),
ProbeTargetError::HttpsUnsupported {
target: "HTTPS://x/".to_string()
}
);
}
#[test]
fn http_scheme_matched_case_insensitively() {
let target = ProbeTarget::parse(&probe_config(ProbeKind::Http, "HTTP://host/x")).unwrap();
assert_eq!(
target,
ProbeTarget::Http {
host: "host".to_string(),
port: 80,
path: "/x".to_string(),
}
);
}
#[test]
fn surrounding_whitespace_trimmed_before_scheme_match() {
let target =
ProbeTarget::parse(&probe_config(ProbeKind::Http, " http://host/ ")).unwrap();
assert_eq!(
target,
ProbeTarget::Http {
host: "host".to_string(),
port: 80,
path: "/".to_string(),
}
);
}
#[test]
fn scheme_missing_rejected_as_not_http_url() {
assert_eq!(
ProbeTarget::parse(&probe_config(ProbeKind::Http, "x/")).unwrap_err(),
ProbeTargetError::NotHttpUrl {
target: "x/".to_string()
}
);
}
#[test]
fn non_http_scheme_rejected_as_not_http_url() {
assert_eq!(
ProbeTarget::parse(&probe_config(ProbeKind::Http, "ftp://x/")).unwrap_err(),
ProbeTargetError::NotHttpUrl {
target: "ftp://x/".to_string()
}
);
}
#[test]
fn empty_authority_rejected_as_missing_host() {
assert_eq!(
ProbeTarget::parse(&probe_config(ProbeKind::Http, "http:///path")).unwrap_err(),
ProbeTargetError::MissingHost {
target: "http:///path".to_string()
}
);
}
#[test]
fn http_empty_host_before_port_rejected_as_missing_host() {
assert_eq!(
ProbeTarget::parse(&probe_config(ProbeKind::Http, "http://:8080/")).unwrap_err(),
ProbeTargetError::MissingHost {
target: "http://:8080/".to_string()
}
);
}
#[test]
fn http_unclosed_bracket_rejected_as_missing_host() {
assert_eq!(
ProbeTarget::parse(&probe_config(ProbeKind::Http, "http://[::1/")).unwrap_err(),
ProbeTargetError::MissingHost {
target: "http://[::1/".to_string()
}
);
}
#[test]
fn http_trailing_characters_after_bracket_rejected_as_bad_port() {
assert_eq!(
ProbeTarget::parse(&probe_config(ProbeKind::Http, "http://[::1]x")).unwrap_err(),
ProbeTargetError::BadPort {
target: "http://[::1]x".to_string()
}
);
}
#[test]
fn http_userinfo_in_host_rejected_as_invalid_host() {
assert_eq!(
ProbeTarget::parse(&probe_config(
ProbeKind::Http,
"http://user:pass@host:8080/"
))
.unwrap_err(),
ProbeTargetError::InvalidHost {
target: "http://user:pass@host:8080/".to_string()
}
);
}
#[test]
fn http_whitespace_in_host_rejected_as_invalid_host() {
assert_eq!(
ProbeTarget::parse(&probe_config(ProbeKind::Http, "http://my host/")).unwrap_err(),
ProbeTargetError::InvalidHost {
target: "http://my host/".to_string()
}
);
}
#[test]
fn http_second_colon_in_host_rejected_as_invalid_host() {
assert_eq!(
ProbeTarget::parse(&probe_config(ProbeKind::Http, "http://host:8080:9090/"))
.unwrap_err(),
ProbeTargetError::InvalidHost {
target: "http://host:8080:9090/".to_string()
}
);
}
#[test]
fn http_crlf_in_path_rejected_as_invalid_path() {
let target = "http://host:8080/health\r\nX-Injected: yes";
assert_eq!(
ProbeTarget::parse(&probe_config(ProbeKind::Http, target)).unwrap_err(),
ProbeTargetError::InvalidPath {
target: target.to_string()
}
);
}
#[test]
fn http_space_in_path_rejected_as_invalid_path() {
assert_eq!(
ProbeTarget::parse(&probe_config(ProbeKind::Http, "http://host/a b")).unwrap_err(),
ProbeTargetError::InvalidPath {
target: "http://host/a b".to_string()
}
);
}
#[test]
fn http_control_character_in_path_rejected_as_invalid_path() {
assert_eq!(
ProbeTarget::parse(&probe_config(ProbeKind::Http, "http://host/a\u{0}b")).unwrap_err(),
ProbeTargetError::InvalidPath {
target: "http://host/a\u{0}b".to_string()
}
);
}
#[test]
fn http_query_and_percent_encoded_path_still_accepted() {
let target = ProbeTarget::parse(&probe_config(
ProbeKind::Http,
"http://host/health?a=1&b=%20x",
))
.unwrap();
assert_eq!(
target,
ProbeTarget::Http {
host: "host".to_string(),
port: 80,
path: "/health?a=1&b=%20x".to_string(),
}
);
}
#[test]
fn tcp_userinfo_in_host_rejected_as_invalid_host() {
assert_eq!(
ProbeTarget::parse(&probe_config(ProbeKind::Tcp, "user:pass@host:5432")).unwrap_err(),
ProbeTargetError::InvalidHost {
target: "user:pass@host:5432".to_string()
}
);
}
#[test]
fn non_numeric_port_rejected_as_bad_port() {
assert_eq!(
ProbeTarget::parse(&probe_config(ProbeKind::Http, "http://host:notaport/"))
.unwrap_err(),
ProbeTargetError::BadPort {
target: "http://host:notaport/".to_string()
}
);
}
#[test]
fn port_out_of_u16_range_rejected_as_bad_port() {
assert_eq!(
ProbeTarget::parse(&probe_config(ProbeKind::Http, "http://host:99999/")).unwrap_err(),
ProbeTargetError::BadPort {
target: "http://host:99999/".to_string()
}
);
}
#[test]
fn tcp_host_and_port_accepted() {
let target = ProbeTarget::parse(&probe_config(ProbeKind::Tcp, "db.internal:5432")).unwrap();
assert_eq!(
target,
ProbeTarget::Tcp {
host: "db.internal".to_string(),
port: 5432,
}
);
}
#[test]
fn tcp_no_colon_rejected_as_missing_port() {
assert_eq!(
ProbeTarget::parse(&probe_config(ProbeKind::Tcp, "host")).unwrap_err(),
ProbeTargetError::MissingPort {
target: "host".to_string()
}
);
}
#[test]
fn tcp_trailing_colon_rejected_as_missing_port() {
assert_eq!(
ProbeTarget::parse(&probe_config(ProbeKind::Tcp, "host:")).unwrap_err(),
ProbeTargetError::MissingPort {
target: "host:".to_string()
}
);
}
#[test]
fn tcp_missing_host_rejected() {
assert_eq!(
ProbeTarget::parse(&probe_config(ProbeKind::Tcp, ":8080")).unwrap_err(),
ProbeTargetError::MissingHost {
target: ":8080".to_string()
}
);
}
#[test]
fn tcp_bracketed_ipv6_with_port_accepted() {
let target = ProbeTarget::parse(&probe_config(ProbeKind::Tcp, "[::1]:5432")).unwrap();
assert_eq!(
target,
ProbeTarget::Tcp {
host: "::1".to_string(),
port: 5432,
}
);
}
#[test]
fn tcp_bracketed_ipv6_without_port_rejected_as_missing_port() {
assert_eq!(
ProbeTarget::parse(&probe_config(ProbeKind::Tcp, "[::1]")).unwrap_err(),
ProbeTargetError::MissingPort {
target: "[::1]".to_string()
}
);
}
#[test]
fn tcp_unbracketed_ipv6_rejected_as_invalid_host() {
assert_eq!(
ProbeTarget::parse(&probe_config(ProbeKind::Tcp, "::1:5432")).unwrap_err(),
ProbeTargetError::InvalidHost {
target: "::1:5432".to_string()
}
);
}
#[test]
fn exec_arbitrary_command_line_accepted_unmodified() {
let command = "sh -c 'curl -f http://localhost/ || exit 1'";
let target = ProbeTarget::parse(&probe_config(ProbeKind::Exec, command)).unwrap();
assert_eq!(
target,
ProbeTarget::Exec {
command: command.to_string()
}
);
}
}