use std::borrow::Cow;
fn is_uuid(s: &str) -> bool {
if s.len() != 36 {
return false;
}
let b = s.as_bytes();
b[8] == b'-'
&& b[13] == b'-'
&& b[18] == b'-'
&& b[23] == b'-'
&& b.iter()
.enumerate()
.all(|(i, &c)| matches!(i, 8 | 13 | 18 | 23) || c.is_ascii_hexdigit())
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct HttpNormalized {
pub template: String,
pub params: Vec<String>,
}
fn is_numeric(seg: &str) -> bool {
!seg.is_empty() && seg.bytes().all(|b| b.is_ascii_digit())
}
fn bytecount(s: &str, target: u8) -> usize {
s.bytes().filter(|&b| b == target).count()
}
#[must_use]
pub fn normalize_http(method: &str, target: &str) -> HttpNormalized {
let (authority, path_and_query) = split_origin(target);
let (path, query_params) = match path_and_query.split_once('?') {
Some((p, q)) => (p, Some(q)),
None => (path_and_query, None),
};
let mut params = match query_params {
Some(q) => {
let cap = (bytecount(q, b'&') + 1).min(100);
let mut out = Vec::with_capacity(cap);
for pair in q.split('&').take(100) {
out.push(pair.to_string());
}
out
}
None => Vec::new(),
};
let normalized_path = normalize_path_segments(path, &mut params);
let template = match authority.and_then(host_group_prefix) {
Some(host) => format!("{method} {host}{normalized_path}"),
None => format!("{method} {normalized_path}"),
};
HttpNormalized { template, params }
}
fn normalize_path_segments(path: &str, params: &mut Vec<String>) -> String {
if path.is_empty() || path == "/" {
return "/".to_string();
}
let mut result = String::with_capacity(path.len() + 8);
for (idx, seg) in path.split('/').enumerate() {
if idx > 0 {
result.push('/');
}
if seg.is_empty() {
} else if is_uuid(seg) {
params.push(seg.to_string());
result.push_str("{uuid}");
} else if is_numeric(seg) {
params.push(seg.to_string());
result.push_str("{id}");
} else {
result.push_str(seg);
}
}
result
}
fn split_origin(target: &str) -> (Option<&str>, &str) {
match target
.strip_prefix("http://")
.or_else(|| target.strip_prefix("https://"))
{
Some(rest) => match rest.find(['/', '?', '#']) {
Some(idx) if rest.as_bytes()[idx] == b'#' => (Some(&rest[..idx]), "/"),
Some(idx) => (Some(&rest[..idx]), &rest[idx..]),
None => (Some(rest), "/"),
},
None => (None, target),
}
}
fn host_group_prefix(authority: &str) -> Option<Cow<'_, str>> {
let host_port = authority.rsplit('@').next().unwrap_or(authority);
if host_port.starts_with('[') {
return None;
}
let host = host_port.split(':').next().unwrap_or(host_port);
let host = host.strip_suffix('.').unwrap_or(host);
if host.is_empty() || is_ipv4_literal(host) {
return None;
}
if host.bytes().any(|b| b.is_ascii_uppercase()) {
Some(Cow::Owned(host.to_ascii_lowercase()))
} else {
Some(Cow::Borrowed(host))
}
}
fn is_ipv4_literal(host: &str) -> bool {
let mut octets = 0usize;
for part in host.split('.') {
if part.is_empty() || !part.bytes().all(|b| b.is_ascii_digit()) {
return false;
}
octets += 1;
if octets > 4 {
return false;
}
}
octets == 4
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn simple_path_with_numeric_id() {
let r = normalize_http("GET", "/api/orders/42/submit");
assert_eq!(r.template, "GET /api/orders/{id}/submit");
assert_eq!(r.params, vec!["42"]);
}
#[test]
fn uuid_segment() {
let r = normalize_http("GET", "/api/users/a1b2c3d4-e5f6-7890-abcd-ef1234567890");
assert_eq!(r.template, "GET /api/users/{uuid}");
assert_eq!(r.params, vec!["a1b2c3d4-e5f6-7890-abcd-ef1234567890"]);
}
#[test]
fn full_url_keeps_dns_host() {
let r = normalize_http("GET", "http://user-svc:5000/api/users/user-123");
assert_eq!(r.template, "GET user-svc/api/users/user-123");
}
#[test]
fn query_params_stripped() {
let r = normalize_http("GET", "/api/users?page=2&size=10");
assert_eq!(r.template, "GET /api/users");
assert_eq!(r.params, vec!["page=2", "size=10"]);
}
#[test]
fn full_url_with_query() {
let r = normalize_http("POST", "https://svc.internal/api/items/99?expand=true");
assert_eq!(r.template, "POST svc.internal/api/items/{id}");
assert_eq!(r.params, vec!["expand=true", "99"]);
}
#[test]
fn multiple_numeric_segments() {
let r = normalize_http("DELETE", "/api/orders/42/items/7");
assert_eq!(r.template, "DELETE /api/orders/{id}/items/{id}");
assert_eq!(r.params, vec!["42", "7"]);
}
#[test]
fn root_path() {
let r = normalize_http("GET", "/");
assert_eq!(r.template, "GET /");
assert!(r.params.is_empty());
}
#[test]
fn no_numeric_or_uuid_segments() {
let r = normalize_http("GET", "/api/health");
assert_eq!(r.template, "GET /api/health");
assert!(r.params.is_empty());
}
#[test]
fn port_in_url_not_treated_as_id() {
let r = normalize_http("GET", "http://localhost:8080/api/items");
assert_eq!(r.template, "GET localhost/api/items");
}
#[test]
fn url_without_path_keeps_host() {
let r = normalize_http("GET", "http://example.com");
assert_eq!(r.template, "GET example.com/");
assert!(r.params.is_empty());
}
#[test]
fn https_url_without_path() {
let r = normalize_http("GET", "https://example.com");
assert_eq!(r.template, "GET example.com/");
}
#[test]
fn dns_hosts_disambiguate_same_path() {
let a = normalize_http("POST", "http://ms-23205/vs2nqhh1hq");
let b = normalize_http("POST", "http://ms-53745/vs2nqhh1hq");
assert_eq!(a.template, "POST ms-23205/vs2nqhh1hq");
assert_eq!(b.template, "POST ms-53745/vs2nqhh1hq");
assert_ne!(a.template, b.template);
}
#[test]
fn ipv4_hosts_are_dropped_keeping_replica_dedup() {
let a = normalize_http("GET", "http://10.0.0.1:8080/api/x");
let b = normalize_http("GET", "http://10.0.0.2:8080/api/x");
assert_eq!(a.template, "GET /api/x");
assert_eq!(a.template, b.template);
}
#[test]
fn ipv6_host_is_dropped() {
let r = normalize_http("GET", "http://[2001:db8::1]:8080/api/x");
assert_eq!(r.template, "GET /api/x");
}
#[test]
fn host_is_lowercased() {
let r = normalize_http("GET", "http://User-SVC.Example.COM/api/x");
assert_eq!(r.template, "GET user-svc.example.com/api/x");
}
#[test]
fn userinfo_is_stripped_from_host() {
let r = normalize_http("GET", "http://user:pass@svc.internal/api/x");
assert_eq!(r.template, "GET svc.internal/api/x");
}
#[test]
fn relative_url_has_no_host() {
let r = normalize_http("GET", "/api/x");
assert_eq!(r.template, "GET /api/x");
}
#[test]
fn query_only_url_does_not_leak_into_host() {
let r = normalize_http("GET", "http://api.example.com?token=abc123secret");
assert_eq!(r.template, "GET api.example.com/");
assert!(!r.template.contains("token"), "{}", r.template);
}
#[test]
fn query_with_userinfo_does_not_leak() {
let r = normalize_http("GET", "http://user:pass@svc.internal?token=xyz");
assert_eq!(r.template, "GET svc.internal/");
assert!(!r.template.contains("token"), "{}", r.template);
}
#[test]
fn fragment_only_url_does_not_pollute_host() {
let r = normalize_http("GET", "http://svc.internal#section");
assert_eq!(r.template, "GET svc.internal/");
}
#[test]
fn trailing_dns_dot_groups_with_bare_host() {
let dotted = normalize_http("GET", "http://user-svc./api/x");
let bare = normalize_http("GET", "http://user-svc/api/x");
assert_eq!(dotted.template, "GET user-svc/api/x");
assert_eq!(dotted.template, bare.template);
}
#[test]
fn pathological_numeric_host_does_not_overflow() {
let host = vec!["1"; 260].join(".");
let r = normalize_http("GET", &format!("http://{host}/x"));
assert_eq!(r.template, format!("GET {host}/x"));
assert!(is_ipv4_literal("1.2.3.4"));
assert!(!is_ipv4_literal(&host));
}
#[test]
fn non_uuid_36_char_segment_not_replaced() {
let r = normalize_http("GET", "/api/users/abcdefghijklmnopqrstuvwxyz1234567890");
assert_eq!(
r.template,
"GET /api/users/abcdefghijklmnopqrstuvwxyz1234567890"
);
assert!(r.params.is_empty());
}
#[test]
fn empty_path() {
let r = normalize_http("GET", "");
assert_eq!(r.template, "GET /");
}
#[test]
fn trailing_slash() {
let r = normalize_http("GET", "/api/users/");
assert_eq!(r.template, "GET /api/users/");
assert!(r.params.is_empty());
}
#[test]
fn single_numeric_segment() {
let r = normalize_http("GET", "/42");
assert_eq!(r.template, "GET /{id}");
assert_eq!(r.params, vec!["42"]);
}
#[test]
fn mixed_uuid_and_numeric() {
let r = normalize_http(
"PUT",
"/api/org/a1b2c3d4-e5f6-7890-abcd-ef1234567890/user/99",
);
assert_eq!(r.template, "PUT /api/org/{uuid}/user/{id}");
assert_eq!(r.params, vec!["a1b2c3d4-e5f6-7890-abcd-ef1234567890", "99"]);
}
#[test]
fn is_uuid_valid() {
assert!(is_uuid("a1b2c3d4-e5f6-7890-abcd-ef1234567890"));
assert!(is_uuid("00000000-0000-0000-0000-000000000000"));
assert!(is_uuid("AAAABBBB-CCCC-DDDD-EEEE-FFFFFFFFFFFF"));
}
#[test]
fn is_uuid_invalid() {
assert!(!is_uuid("not-a-uuid-at-all"));
assert!(!is_uuid("")); assert!(!is_uuid("a1b2c3d4-e5f6-7890-abcd-ef123456789")); assert!(!is_uuid("a1b2c3d4-e5f6-7890-abcd-ef12345678901")); assert!(!is_uuid("a1b2c3d4xe5f6-7890-abcd-ef1234567890")); assert!(!is_uuid("g1b2c3d4-e5f6-7890-abcd-ef1234567890")); }
#[test]
fn uppercase_uuid_detected() {
let r = normalize_http("GET", "/api/item/A1B2C3D4-E5F6-7890-ABCD-EF1234567890");
assert_eq!(r.template, "GET /api/item/{uuid}");
}
#[test]
fn fragment_not_stripped_from_path() {
let r = normalize_http("GET", "/api/users/42#section");
assert_eq!(r.template, "GET /api/users/42#section");
}
#[test]
fn trailing_question_mark_only() {
let r = normalize_http("GET", "/api/users?");
assert_eq!(r.template, "GET /api/users");
assert_eq!(r.params, vec![""]);
}
#[test]
fn empty_query_param_values() {
let r = normalize_http("GET", "/api/users?id=&name=");
assert_eq!(r.template, "GET /api/users");
assert_eq!(r.params, vec!["id=", "name="]);
}
#[test]
fn double_ampersand_in_query() {
let r = normalize_http("GET", "/api/users?a=1&&b=2");
assert_eq!(r.template, "GET /api/users");
assert_eq!(r.params, vec!["a=1", "", "b=2"]);
}
#[test]
fn double_slash_in_path_preserved() {
let r = normalize_http("GET", "/api//users/42");
assert_eq!(r.template, "GET /api//users/{id}");
}
#[test]
fn url_encoded_numeric_not_detected() {
let r = normalize_http("GET", "/api/users/%34%32");
assert_eq!(r.template, "GET /api/users/%34%32");
assert!(r.params.is_empty());
}
#[test]
fn query_params_capped_at_100() {
let params: Vec<String> = (0..200).map(|i| format!("p{i}={i}")).collect();
let url = format!("/api/test?{}", params.join("&"));
let r = normalize_http("GET", &url);
assert_eq!(r.params.len(), 100);
}
}