pub use graph_r::UrlKey;
use smallvec::SmallVec;
use url::Url;
fn canonical_chunks(url: &Url, sink: &mut impl FnMut(&str)) {
sink(url.scheme());
sink("://");
if let Some(host) = url.host_str() {
if host.bytes().any(|b| b.is_ascii_uppercase()) {
sink(&host.to_ascii_lowercase());
} else {
sink(host);
}
}
if let Some(port) = url.port() {
sink(":");
let mut buf = [0u8; 5]; sink(fmt_u16(port, &mut buf));
}
let path = url.path();
if path.len() > 1 && path.ends_with('/') {
sink(&path[..path.len() - 1]);
} else if path.is_empty() {
sink("/");
} else {
sink(path);
}
if let Some(query) = url.query() {
if !query.is_empty() {
let mut segs: SmallVec<[&str; 8]> =
query.split('&').filter(|q| !q.is_empty()).collect();
segs.sort_unstable();
if !segs.is_empty() {
sink("?");
for (i, seg) in segs.iter().enumerate() {
if i > 0 {
sink("&");
}
sink(seg);
}
}
}
}
}
fn fmt_u16(mut v: u16, buf: &mut [u8; 5]) -> &str {
if v == 0 {
return "0";
}
let mut i = buf.len();
while v > 0 {
i -= 1;
buf[i] = b'0' + (v % 10) as u8;
v /= 10;
}
std::str::from_utf8(&buf[i..]).unwrap_or("0")
}
#[must_use]
pub fn canonicalize(url: &Url) -> String {
let mut s = String::with_capacity(url.as_str().len());
canonical_chunks(url, &mut |c| s.push_str(c));
s
}
pub trait UrlKeyExt: Sized {
fn from_url(url: &Url) -> Self;
fn parse(url: &str) -> crate::Result<Self>;
}
impl UrlKeyExt for UrlKey {
fn from_url(url: &Url) -> Self {
let mut h = graph_r::hash::Hasher::new();
canonical_chunks(url, &mut |c| h.update(c.as_bytes()));
Self(h.digest64())
}
fn parse(url: &str) -> crate::Result<Self> {
let parsed =
Url::parse(url).map_err(|e| crate::Error::invalid_url(format!("{url:?}: {e}")))?;
Ok(Self::from_url(&parsed))
}
}
#[cfg(test)]
mod tests {
use super::*;
fn canon(s: &str) -> String {
canonicalize(&Url::parse(s).unwrap())
}
#[test]
fn lowercases_scheme_and_host_not_path() {
assert_eq!(
canon("HTTP://Example.COM/Docs/Intro"),
"http://example.com/Docs/Intro"
);
}
#[test]
fn drops_default_ports() {
assert_eq!(canon("http://x.dev:80/a"), "http://x.dev/a");
assert_eq!(canon("https://x.dev:443/a"), "https://x.dev/a");
assert_eq!(canon("https://x.dev:8443/a"), "https://x.dev:8443/a");
}
#[test]
fn drops_fragment_and_userinfo() {
assert_eq!(canon("https://x.dev/a#section-2"), "https://x.dev/a");
assert_eq!(canon("https://user:pass@x.dev/a"), "https://x.dev/a");
}
#[test]
fn sorts_query_segments() {
assert_eq!(canon("https://x.dev/s?b=2&a=1"), "https://x.dev/s?a=1&b=2");
assert_eq!(
canon("https://x.dev/s?a=1&b=2"),
canon("https://x.dev/s?b=2&a=1")
);
}
#[test]
fn strips_trailing_slash_except_root() {
assert_eq!(canon("https://x.dev/docs/"), "https://x.dev/docs");
assert_eq!(canon("https://x.dev/"), "https://x.dev/");
assert_eq!(canon("https://x.dev"), "https://x.dev/");
}
#[test]
fn equivalent_urls_share_a_key() {
let a = UrlKey::parse("HTTP://Example.com:80/docs/?b=2&a=1#frag").unwrap();
let b = UrlKey::parse("http://example.com/docs?a=1&b=2").unwrap();
assert_eq!(a, b);
}
#[test]
fn distinct_urls_differ() {
let a = UrlKey::parse("https://x.dev/a").unwrap();
let b = UrlKey::parse("https://x.dev/b").unwrap();
assert_ne!(a, b);
}
#[test]
fn canonicalization_is_idempotent() {
let once = canon("HTTP://Example.com:80/docs/?b=2&a=1#frag");
let twice = canonicalize(&Url::parse(&once).unwrap());
assert_eq!(once, twice);
}
#[test]
fn streaming_hash_matches_string() {
for s in [
"HTTP://Example.com:80/docs/?b=2&a=1#frag",
"https://x.dev",
"https://x.dev/",
"https://x.dev:8443/a/b/c?z=9&a=1&m=5",
"http://user:pass@Host.EXAMPLE.org:8080/Path/",
"https://x.dev/a?only=1",
"https://sub.domain.example.com/deep/path/segment",
] {
let u = Url::parse(s).unwrap();
assert_eq!(
UrlKey::from_url(&u).raw(),
UrlKey::of(&canonicalize(&u)).raw(),
"streaming hash diverged for {s:?}"
);
}
}
#[test]
fn fmt_u16_matches_display() {
for p in [0u16, 1, 9, 10, 80, 443, 8080, 8443, 65535] {
let mut buf = [0u8; 5];
assert_eq!(fmt_u16(p, &mut buf), p.to_string());
}
}
}