Skip to main content

link_r/
url_key.rs

1// Author: support@lituus.io
2// Copyright: lituus-io, all rights reserved
3//
4//! URL canonicalization and the [`UrlKey`] dedup/uniqueness key.
5//!
6//! Every link in the index is keyed by its *canonical* URL, so a page reached
7//! through equivalent URLs (`HTTP` vs `http`, default ports, reordered query
8//! params, a trailing slash, a fragment) collapses to one entry — re-crawling
9//! never duplicates a link, satisfying the index's uniqueness guarantee.
10//!
11//! # Canonicalization rules (deterministic)
12//!
13//! - lowercase scheme and host (paths stay case-sensitive),
14//! - drop the default port (`:80` for http, `:443` for https),
15//! - drop the fragment (`#...`),
16//! - drop userinfo (`user:pass@`),
17//! - sort query segments lexically (treated as opaque, not re-encoded),
18//! - strip a single trailing `/` except on the root path.
19
20pub use graph_r::UrlKey;
21use smallvec::SmallVec;
22use url::Url;
23
24/// Emit the canonical form of `url` as a sequence of `&str` chunks — the single
25/// source of truth shared by [`canonicalize`] (which concatenates the chunks into
26/// a `String`) and [`UrlKeyExt::from_url`] (which streams them through an `xxh3`
27/// hasher). Because both consume the *same* emission, the key is identical to
28/// `xxh3_64(canonicalize(url).as_bytes())` by construction — a property the
29/// persisted on-disk keys depend on (see the `streaming_hash_matches_string`
30/// test and the `tests/proptest.rs` equality property).
31///
32/// The canonicalization rules are the module-level ones: lowercase scheme/host,
33/// drop default port / fragment / userinfo, strip a single trailing slash except
34/// on root, sort opaque query segments.
35fn canonical_chunks(url: &Url, sink: &mut impl FnMut(&str)) {
36    // scheme + "://" (Url guarantees scheme is already lowercase).
37    sink(url.scheme());
38    sink("://");
39
40    // host + non-default port. Userinfo intentionally dropped. The host is emitted
41    // borrowed (zero-alloc) unless it contains ASCII uppercase, which `Url` already
42    // normalizes away for domain hosts, so the owned path is effectively never hit.
43    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        // `Url::port` already returns `None` for the scheme's default port.
52        sink(":");
53        let mut buf = [0u8; 5]; // u16 max is 65535 (5 digits)
54        sink(fmt_u16(port, &mut buf));
55    }
56
57    // path, with a single trailing slash stripped except on root.
58    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    // query: split on '&', sort opaque segments, rejoin. Deterministic & order-free.
68    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    // fragment intentionally dropped.
86}
87
88/// Format a `u16` into a stack buffer, returning the written decimal slice. No
89/// allocation — used for the port in [`canonical_chunks`].
90fn 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    // Every written byte is an ASCII digit, so this slice is always valid UTF-8.
101    std::str::from_utf8(&buf[i..]).unwrap_or("0")
102}
103
104/// Produce the canonical string form of a URL used for keying and dedup.
105#[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
112/// Keying URLs: [`UrlKey`] is graph-r's (xxh3 of the canonical URL), so a
113/// link-r key and a graph-r node key for the same page are the same value.
114/// Two URLs that canonicalize to the same string share a key — the index
115/// upserts, never duplicates, links.
116pub trait UrlKeyExt: Sized {
117    /// The key of a parsed URL: its canonical chunks streamed through xxh3
118    /// (no intermediate `String`), equal to
119    /// `UrlKey::of(&canonicalize(url))`.
120    fn from_url(url: &Url) -> Self;
121    /// Parse a URL string and key it.
122    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        // order-independence
172        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        // The persisted key contract: from_url must equal xxh3_64(canonicalize()).
209        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}