Skip to main content

har/
normalize.rs

1use crate::opaque::{is_opaque, is_uuid};
2
3/// Collapse identifier-like path segments into `{id}` and opaque blobs into
4/// `{blob}` so routes group together and secret-bearing config blobs are hidden.
5pub fn normalize_path(path: &str) -> String {
6    let mut parts: Vec<String> = Vec::new();
7    for (i, seg) in path.split('/').enumerate() {
8        match segment_token(seg, i) {
9            Some(tok) => parts.push(tok.to_string()),
10            None => parts.push(seg.to_string()),
11        }
12    }
13    parts.join("/")
14}
15
16fn segment_token(seg: &str, index: usize) -> Option<&'static str> {
17    if seg.is_empty() {
18        return None;
19    }
20    // Pure numeric: id unless a single leading digit (keeps `/3/tv/popular`).
21    if seg.bytes().all(|b| b.is_ascii_digit()) {
22        return if index == 1 && seg.len() == 1 {
23            None
24        } else {
25            Some("{id}")
26        };
27    }
28    if is_uuid(seg) {
29        return Some("{id}");
30    }
31    if is_long_hex(seg) {
32        return Some("{id}");
33    }
34    if is_opaque(seg) {
35        return Some("{blob}");
36    }
37    None
38}
39
40fn is_long_hex(s: &str) -> bool {
41    s.len() >= 16 && s.bytes().all(|b| b.is_ascii_hexdigit())
42}
43
44#[cfg(test)]
45mod tests {
46    use super::normalize_path;
47
48    #[test]
49    fn collapses_numeric_ids() {
50        assert_eq!(
51            normalize_path("/users/123/orders/456"),
52            "/users/{id}/orders/{id}"
53        );
54    }
55
56    #[test]
57    fn collapses_uuid() {
58        assert_eq!(
59            normalize_path("/v1/items/550e8400-e29b-41d4-a716-446655440000"),
60            "/v1/items/{id}"
61        );
62    }
63
64    #[test]
65    fn collapses_long_hex() {
66        assert_eq!(normalize_path("/blob/0123456789abcdef0123"), "/blob/{id}");
67    }
68
69    #[test]
70    fn keeps_normal_words() {
71        assert_eq!(normalize_path("/3/tv/popular"), "/3/tv/popular");
72    }
73
74    #[test]
75    fn preserves_leading_and_trailing_slashes() {
76        assert_eq!(normalize_path("/a/123/"), "/a/{id}/");
77    }
78
79    #[test]
80    fn collapses_opaque_blob_to_blob_token() {
81        assert_eq!(
82            normalize_path("/cfg/eyJtYXhUb3JyZW50cyI6OCwiZGVicmlkIjp0cnVlfQ==/manifest.json"),
83            "/cfg/{blob}/manifest.json"
84        );
85    }
86
87    #[test]
88    fn collapses_percent_encoded_blob() {
89        assert_eq!(
90            normalize_path("/%7B%22NexioTorii%22%3A%22eyJ1c2VFbmdsaXNo%22%7D/manifest.json"),
91            "/{blob}/manifest.json"
92        );
93    }
94
95    #[test]
96    fn numeric_id_still_uses_id_token() {
97        assert_eq!(
98            normalize_path("/users/123/orders/456"),
99            "/users/{id}/orders/{id}"
100        );
101    }
102}