1use std::borrow::Cow;
7
8fn is_uuid(s: &str) -> bool {
11 if s.len() != 36 {
12 return false;
13 }
14 let b = s.as_bytes();
15 b[8] == b'-'
16 && b[13] == b'-'
17 && b[18] == b'-'
18 && b[23] == b'-'
19 && b.iter()
20 .enumerate()
21 .all(|(i, &c)| matches!(i, 8 | 13 | 18 | 23) || c.is_ascii_hexdigit())
22}
23
24#[derive(Debug, Clone, PartialEq, Eq)]
26pub struct HttpNormalized {
27 pub template: String,
28 pub params: Vec<String>,
29}
30
31fn is_numeric(seg: &str) -> bool {
33 !seg.is_empty() && seg.bytes().all(|b| b.is_ascii_digit())
34}
35
36fn bytecount(s: &str, target: u8) -> usize {
38 s.bytes().filter(|&b| b == target).count()
39}
40
41#[must_use]
50pub fn normalize_http(method: &str, target: &str) -> HttpNormalized {
51 let (authority, path_and_query) = split_origin(target);
54
55 let (path, query_params) = match path_and_query.split_once('?') {
57 Some((p, q)) => (p, Some(q)),
58 None => (path_and_query, None),
59 };
60
61 let mut params = match query_params {
67 Some(q) => {
68 let cap = (bytecount(q, b'&') + 1).min(100);
69 let mut out = Vec::with_capacity(cap);
70 for pair in q.split('&').take(100) {
71 out.push(pair.to_string());
72 }
73 out
74 }
75 None => Vec::new(),
76 };
77
78 let normalized_path = normalize_path_segments(path, &mut params);
79
80 let template = match authority.and_then(host_group_prefix) {
84 Some(host) => format!("{method} {host}{normalized_path}"),
85 None => format!("{method} {normalized_path}"),
86 };
87 HttpNormalized { template, params }
88}
89
90fn normalize_path_segments(path: &str, params: &mut Vec<String>) -> String {
92 if path.is_empty() || path == "/" {
93 return "/".to_string();
94 }
95 let mut result = String::with_capacity(path.len() + 8);
96 for (idx, seg) in path.split('/').enumerate() {
97 if idx > 0 {
98 result.push('/');
99 }
100 if seg.is_empty() {
101 } else if is_uuid(seg) {
103 params.push(seg.to_string());
104 result.push_str("{uuid}");
105 } else if is_numeric(seg) {
106 params.push(seg.to_string());
107 result.push_str("{id}");
108 } else {
109 result.push_str(seg);
110 }
111 }
112 result
113}
114
115fn split_origin(target: &str) -> (Option<&str>, &str) {
120 match target
121 .strip_prefix("http://")
122 .or_else(|| target.strip_prefix("https://"))
123 {
124 Some(rest) => match rest.find(['/', '?', '#']) {
133 Some(idx) if rest.as_bytes()[idx] == b'#' => (Some(&rest[..idx]), "/"),
134 Some(idx) => (Some(&rest[..idx]), &rest[idx..]),
135 None => (Some(rest), "/"),
136 },
137 None => (None, target),
138 }
139}
140
141fn host_group_prefix(authority: &str) -> Option<Cow<'_, str>> {
147 let host_port = authority.rsplit('@').next().unwrap_or(authority);
151 if host_port.starts_with('[') {
153 return None;
154 }
155 let host = host_port.split(':').next().unwrap_or(host_port);
157 let host = host.strip_suffix('.').unwrap_or(host);
158 if host.is_empty() || is_ipv4_literal(host) {
159 return None;
160 }
161 if host.bytes().any(|b| b.is_ascii_uppercase()) {
162 Some(Cow::Owned(host.to_ascii_lowercase()))
163 } else {
164 Some(Cow::Borrowed(host))
165 }
166}
167
168fn is_ipv4_literal(host: &str) -> bool {
174 let mut octets = 0usize;
175 for part in host.split('.') {
176 if part.is_empty() || !part.bytes().all(|b| b.is_ascii_digit()) {
177 return false;
178 }
179 octets += 1;
180 if octets > 4 {
181 return false;
182 }
183 }
184 octets == 4
185}
186
187#[cfg(test)]
188mod tests {
189 use super::*;
190
191 #[test]
192 fn simple_path_with_numeric_id() {
193 let r = normalize_http("GET", "/api/orders/42/submit");
194 assert_eq!(r.template, "GET /api/orders/{id}/submit");
195 assert_eq!(r.params, vec!["42"]);
196 }
197
198 #[test]
199 fn uuid_segment() {
200 let r = normalize_http("GET", "/api/users/a1b2c3d4-e5f6-7890-abcd-ef1234567890");
201 assert_eq!(r.template, "GET /api/users/{uuid}");
202 assert_eq!(r.params, vec!["a1b2c3d4-e5f6-7890-abcd-ef1234567890"]);
203 }
204
205 #[test]
206 fn full_url_keeps_dns_host() {
207 let r = normalize_http("GET", "http://user-svc:5000/api/users/user-123");
210 assert_eq!(r.template, "GET user-svc/api/users/user-123");
211 }
212
213 #[test]
214 fn query_params_stripped() {
215 let r = normalize_http("GET", "/api/users?page=2&size=10");
216 assert_eq!(r.template, "GET /api/users");
217 assert_eq!(r.params, vec!["page=2", "size=10"]);
218 }
219
220 #[test]
221 fn full_url_with_query() {
222 let r = normalize_http("POST", "https://svc.internal/api/items/99?expand=true");
223 assert_eq!(r.template, "POST svc.internal/api/items/{id}");
224 assert_eq!(r.params, vec!["expand=true", "99"]);
225 }
226
227 #[test]
228 fn multiple_numeric_segments() {
229 let r = normalize_http("DELETE", "/api/orders/42/items/7");
230 assert_eq!(r.template, "DELETE /api/orders/{id}/items/{id}");
231 assert_eq!(r.params, vec!["42", "7"]);
232 }
233
234 #[test]
235 fn root_path() {
236 let r = normalize_http("GET", "/");
237 assert_eq!(r.template, "GET /");
238 assert!(r.params.is_empty());
239 }
240
241 #[test]
242 fn no_numeric_or_uuid_segments() {
243 let r = normalize_http("GET", "/api/health");
244 assert_eq!(r.template, "GET /api/health");
245 assert!(r.params.is_empty());
246 }
247
248 #[test]
249 fn port_in_url_not_treated_as_id() {
250 let r = normalize_http("GET", "http://localhost:8080/api/items");
252 assert_eq!(r.template, "GET localhost/api/items");
253 }
254
255 #[test]
256 fn url_without_path_keeps_host() {
257 let r = normalize_http("GET", "http://example.com");
258 assert_eq!(r.template, "GET example.com/");
259 assert!(r.params.is_empty());
260 }
261
262 #[test]
263 fn https_url_without_path() {
264 let r = normalize_http("GET", "https://example.com");
265 assert_eq!(r.template, "GET example.com/");
266 }
267
268 #[test]
269 fn dns_hosts_disambiguate_same_path() {
270 let a = normalize_http("POST", "http://ms-23205/vs2nqhh1hq");
273 let b = normalize_http("POST", "http://ms-53745/vs2nqhh1hq");
274 assert_eq!(a.template, "POST ms-23205/vs2nqhh1hq");
275 assert_eq!(b.template, "POST ms-53745/vs2nqhh1hq");
276 assert_ne!(a.template, b.template);
277 }
278
279 #[test]
280 fn ipv4_hosts_are_dropped_keeping_replica_dedup() {
281 let a = normalize_http("GET", "http://10.0.0.1:8080/api/x");
284 let b = normalize_http("GET", "http://10.0.0.2:8080/api/x");
285 assert_eq!(a.template, "GET /api/x");
286 assert_eq!(a.template, b.template);
287 }
288
289 #[test]
290 fn ipv6_host_is_dropped() {
291 let r = normalize_http("GET", "http://[2001:db8::1]:8080/api/x");
292 assert_eq!(r.template, "GET /api/x");
293 }
294
295 #[test]
296 fn host_is_lowercased() {
297 let r = normalize_http("GET", "http://User-SVC.Example.COM/api/x");
298 assert_eq!(r.template, "GET user-svc.example.com/api/x");
299 }
300
301 #[test]
302 fn userinfo_is_stripped_from_host() {
303 let r = normalize_http("GET", "http://user:pass@svc.internal/api/x");
304 assert_eq!(r.template, "GET svc.internal/api/x");
305 }
306
307 #[test]
308 fn relative_url_has_no_host() {
309 let r = normalize_http("GET", "/api/x");
311 assert_eq!(r.template, "GET /api/x");
312 }
313
314 #[test]
315 fn query_only_url_does_not_leak_into_host() {
316 let r = normalize_http("GET", "http://api.example.com?token=abc123secret");
319 assert_eq!(r.template, "GET api.example.com/");
320 assert!(!r.template.contains("token"), "{}", r.template);
321 }
322
323 #[test]
324 fn query_with_userinfo_does_not_leak() {
325 let r = normalize_http("GET", "http://user:pass@svc.internal?token=xyz");
326 assert_eq!(r.template, "GET svc.internal/");
327 assert!(!r.template.contains("token"), "{}", r.template);
328 }
329
330 #[test]
331 fn fragment_only_url_does_not_pollute_host() {
332 let r = normalize_http("GET", "http://svc.internal#section");
335 assert_eq!(r.template, "GET svc.internal/");
336 }
337
338 #[test]
339 fn trailing_dns_dot_groups_with_bare_host() {
340 let dotted = normalize_http("GET", "http://user-svc./api/x");
342 let bare = normalize_http("GET", "http://user-svc/api/x");
343 assert_eq!(dotted.template, "GET user-svc/api/x");
344 assert_eq!(dotted.template, bare.template);
345 }
346
347 #[test]
348 fn pathological_numeric_host_does_not_overflow() {
349 let host = vec!["1"; 260].join(".");
352 let r = normalize_http("GET", &format!("http://{host}/x"));
353 assert_eq!(r.template, format!("GET {host}/x"));
355 assert!(is_ipv4_literal("1.2.3.4"));
356 assert!(!is_ipv4_literal(&host));
357 }
358
359 #[test]
360 fn non_uuid_36_char_segment_not_replaced() {
361 let r = normalize_http("GET", "/api/users/abcdefghijklmnopqrstuvwxyz1234567890");
363 assert_eq!(
364 r.template,
365 "GET /api/users/abcdefghijklmnopqrstuvwxyz1234567890"
366 );
367 assert!(r.params.is_empty());
368 }
369
370 #[test]
371 fn empty_path() {
372 let r = normalize_http("GET", "");
373 assert_eq!(r.template, "GET /");
374 }
375
376 #[test]
377 fn trailing_slash() {
378 let r = normalize_http("GET", "/api/users/");
379 assert_eq!(r.template, "GET /api/users/");
380 assert!(r.params.is_empty());
381 }
382
383 #[test]
384 fn single_numeric_segment() {
385 let r = normalize_http("GET", "/42");
386 assert_eq!(r.template, "GET /{id}");
387 assert_eq!(r.params, vec!["42"]);
388 }
389
390 #[test]
391 fn mixed_uuid_and_numeric() {
392 let r = normalize_http(
393 "PUT",
394 "/api/org/a1b2c3d4-e5f6-7890-abcd-ef1234567890/user/99",
395 );
396 assert_eq!(r.template, "PUT /api/org/{uuid}/user/{id}");
397 assert_eq!(r.params, vec!["a1b2c3d4-e5f6-7890-abcd-ef1234567890", "99"]);
398 }
399
400 #[test]
401 fn is_uuid_valid() {
402 assert!(is_uuid("a1b2c3d4-e5f6-7890-abcd-ef1234567890"));
403 assert!(is_uuid("00000000-0000-0000-0000-000000000000"));
404 assert!(is_uuid("AAAABBBB-CCCC-DDDD-EEEE-FFFFFFFFFFFF"));
405 }
406
407 #[test]
408 fn is_uuid_invalid() {
409 assert!(!is_uuid("not-a-uuid-at-all"));
410 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")); }
416
417 #[test]
418 fn uppercase_uuid_detected() {
419 let r = normalize_http("GET", "/api/item/A1B2C3D4-E5F6-7890-ABCD-EF1234567890");
420 assert_eq!(r.template, "GET /api/item/{uuid}");
421 }
422
423 #[test]
426 fn fragment_not_stripped_from_path() {
427 let r = normalize_http("GET", "/api/users/42#section");
430 assert_eq!(r.template, "GET /api/users/42#section");
431 }
432
433 #[test]
436 fn trailing_question_mark_only() {
437 let r = normalize_http("GET", "/api/users?");
438 assert_eq!(r.template, "GET /api/users");
439 assert_eq!(r.params, vec![""]);
440 }
441
442 #[test]
443 fn empty_query_param_values() {
444 let r = normalize_http("GET", "/api/users?id=&name=");
445 assert_eq!(r.template, "GET /api/users");
446 assert_eq!(r.params, vec!["id=", "name="]);
447 }
448
449 #[test]
450 fn double_ampersand_in_query() {
451 let r = normalize_http("GET", "/api/users?a=1&&b=2");
452 assert_eq!(r.template, "GET /api/users");
453 assert_eq!(r.params, vec!["a=1", "", "b=2"]);
454 }
455
456 #[test]
459 fn double_slash_in_path_preserved() {
460 let r = normalize_http("GET", "/api//users/42");
461 assert_eq!(r.template, "GET /api//users/{id}");
462 }
463
464 #[test]
467 fn url_encoded_numeric_not_detected() {
468 let r = normalize_http("GET", "/api/users/%34%32");
470 assert_eq!(r.template, "GET /api/users/%34%32");
471 assert!(r.params.is_empty());
472 }
473
474 #[test]
477 fn query_params_capped_at_100() {
478 let params: Vec<String> = (0..200).map(|i| format!("p{i}={i}")).collect();
479 let url = format!("/api/test?{}", params.join("&"));
480 let r = normalize_http("GET", &url);
481 assert_eq!(r.params.len(), 100);
482 }
483}