1pub use graph_r::UrlKey;
21use smallvec::SmallVec;
22use url::Url;
23
24fn canonical_chunks(url: &Url, sink: &mut impl FnMut(&str)) {
36 sink(url.scheme());
38 sink("://");
39
40 if let Some(host) = url.host_str() {
44 if host.bytes().any(|b| b.is_ascii_uppercase()) {
45 sink(&host.to_ascii_lowercase());
46 } else {
47 sink(host);
48 }
49 }
50 if let Some(port) = url.port() {
51 sink(":");
53 let mut buf = [0u8; 5]; sink(fmt_u16(port, &mut buf));
55 }
56
57 let path = url.path();
59 if path.len() > 1 && path.ends_with('/') {
60 sink(&path[..path.len() - 1]);
61 } else if path.is_empty() {
62 sink("/");
63 } else {
64 sink(path);
65 }
66
67 if let Some(query) = url.query() {
69 if !query.is_empty() {
70 let mut segs: SmallVec<[&str; 8]> =
71 query.split('&').filter(|q| !q.is_empty()).collect();
72 segs.sort_unstable();
73 if !segs.is_empty() {
74 sink("?");
75 for (i, seg) in segs.iter().enumerate() {
76 if i > 0 {
77 sink("&");
78 }
79 sink(seg);
80 }
81 }
82 }
83 }
84
85 }
87
88fn fmt_u16(mut v: u16, buf: &mut [u8; 5]) -> &str {
91 if v == 0 {
92 return "0";
93 }
94 let mut i = buf.len();
95 while v > 0 {
96 i -= 1;
97 buf[i] = b'0' + (v % 10) as u8;
98 v /= 10;
99 }
100 std::str::from_utf8(&buf[i..]).unwrap_or("0")
102}
103
104#[must_use]
106pub fn canonicalize(url: &Url) -> String {
107 let mut s = String::with_capacity(url.as_str().len());
108 canonical_chunks(url, &mut |c| s.push_str(c));
109 s
110}
111
112pub trait UrlKeyExt: Sized {
117 fn from_url(url: &Url) -> Self;
121 fn parse(url: &str) -> crate::Result<Self>;
123}
124
125impl UrlKeyExt for UrlKey {
126 fn from_url(url: &Url) -> Self {
127 let mut h = graph_r::hash::Hasher::new();
128 canonical_chunks(url, &mut |c| h.update(c.as_bytes()));
129 Self(h.digest64())
130 }
131
132 fn parse(url: &str) -> crate::Result<Self> {
133 let parsed =
134 Url::parse(url).map_err(|e| crate::Error::invalid_url(format!("{url:?}: {e}")))?;
135 Ok(Self::from_url(&parsed))
136 }
137}
138
139#[cfg(test)]
140mod tests {
141 use super::*;
142
143 fn canon(s: &str) -> String {
144 canonicalize(&Url::parse(s).unwrap())
145 }
146
147 #[test]
148 fn lowercases_scheme_and_host_not_path() {
149 assert_eq!(
150 canon("HTTP://Example.COM/Docs/Intro"),
151 "http://example.com/Docs/Intro"
152 );
153 }
154
155 #[test]
156 fn drops_default_ports() {
157 assert_eq!(canon("http://x.dev:80/a"), "http://x.dev/a");
158 assert_eq!(canon("https://x.dev:443/a"), "https://x.dev/a");
159 assert_eq!(canon("https://x.dev:8443/a"), "https://x.dev:8443/a");
160 }
161
162 #[test]
163 fn drops_fragment_and_userinfo() {
164 assert_eq!(canon("https://x.dev/a#section-2"), "https://x.dev/a");
165 assert_eq!(canon("https://user:pass@x.dev/a"), "https://x.dev/a");
166 }
167
168 #[test]
169 fn sorts_query_segments() {
170 assert_eq!(canon("https://x.dev/s?b=2&a=1"), "https://x.dev/s?a=1&b=2");
171 assert_eq!(
173 canon("https://x.dev/s?a=1&b=2"),
174 canon("https://x.dev/s?b=2&a=1")
175 );
176 }
177
178 #[test]
179 fn strips_trailing_slash_except_root() {
180 assert_eq!(canon("https://x.dev/docs/"), "https://x.dev/docs");
181 assert_eq!(canon("https://x.dev/"), "https://x.dev/");
182 assert_eq!(canon("https://x.dev"), "https://x.dev/");
183 }
184
185 #[test]
186 fn equivalent_urls_share_a_key() {
187 let a = UrlKey::parse("HTTP://Example.com:80/docs/?b=2&a=1#frag").unwrap();
188 let b = UrlKey::parse("http://example.com/docs?a=1&b=2").unwrap();
189 assert_eq!(a, b);
190 }
191
192 #[test]
193 fn distinct_urls_differ() {
194 let a = UrlKey::parse("https://x.dev/a").unwrap();
195 let b = UrlKey::parse("https://x.dev/b").unwrap();
196 assert_ne!(a, b);
197 }
198
199 #[test]
200 fn canonicalization_is_idempotent() {
201 let once = canon("HTTP://Example.com:80/docs/?b=2&a=1#frag");
202 let twice = canonicalize(&Url::parse(&once).unwrap());
203 assert_eq!(once, twice);
204 }
205
206 #[test]
207 fn streaming_hash_matches_string() {
208 for s in [
210 "HTTP://Example.com:80/docs/?b=2&a=1#frag",
211 "https://x.dev",
212 "https://x.dev/",
213 "https://x.dev:8443/a/b/c?z=9&a=1&m=5",
214 "http://user:pass@Host.EXAMPLE.org:8080/Path/",
215 "https://x.dev/a?only=1",
216 "https://sub.domain.example.com/deep/path/segment",
217 ] {
218 let u = Url::parse(s).unwrap();
219 assert_eq!(
220 UrlKey::from_url(&u).raw(),
221 UrlKey::of(&canonicalize(&u)).raw(),
222 "streaming hash diverged for {s:?}"
223 );
224 }
225 }
226
227 #[test]
228 fn fmt_u16_matches_display() {
229 for p in [0u16, 1, 9, 10, 80, 443, 8080, 8443, 65535] {
230 let mut buf = [0u8; 5];
231 assert_eq!(fmt_u16(p, &mut buf), p.to_string());
232 }
233 }
234}