Skip to main content

linkmarks_core/
canonical.rs

1//! URL canonicalization for LinkMarks.
2//!
3//! The full ruleset lives in this module's doc-comment on
4//! `canonicalize`.
5//!
6//! Determinism: given the same input URL, `canonicalize` returns the
7//! same output bytes across runs, platforms, and processes. This is
8//! the dedupe key and must be stable.
9
10use crate::{canonical_config::CanonicalConfig, errors::CoreError};
11use thiserror::Error;
12use url::{Host, Url};
13
14/// Error returned when a URL cannot be canonicalized.
15#[derive(Debug, Error)]
16pub enum CanonicalizeError {
17    /// The URL failed to parse.
18    #[error("parse error: {0}")]
19    Parse(String),
20    /// An IDN host that cannot be losslessly converted to ASCII.
21    #[error("idn conversion failed: {0}")]
22    Idn(String),
23}
24
25/// Tracking-parameter blocklist. Lowercased. Matched as a *prefix* for
26/// `utm_*` and `mc_*`, and as an *exact* match for the rest.
27///
28/// Sources and rationale are documented in the project README.
29pub const TRACKING_PARAMS: &[&str] = &[
30    "fbclid",
31    "gclid",
32    "gbraid",
33    "wbraid",
34    "msclkid",
35    "dclid",
36    "yclid",
37    "twclid",
38    "li_fat_id",
39    "igshid",
40    "ttclid",
41    "ref",
42    "ref_src",
43    "ref_url",
44    "source",
45    "spm",
46    "scm",
47    "_hsenc",
48    "_hsmi",
49    "mkt_tok",
50];
51
52/// Canonicalize a URL string.
53///
54/// Rules (in order):
55/// 1. Parse with the `url` crate. Fail on invalid input.
56/// 2. Lowercase the scheme.
57/// 3. Lowercase the host (ASCII). IDN hosts are converted to their
58///    punycode (`xn--`) form via `url::Host::Domain` (which already
59///    does this).
60/// 4. Strip the default port for the scheme (`:80` for http, `:443`
61///    for https).
62/// 5. Drop the fragment (`#...`). Bookmarks don't carry UI state.
63/// 6. Sort the remaining query parameters alphabetically by key.
64/// 7. Drop tracking parameters (see `TRACKING_PARAMS`). `utm_*` and
65///    `mc_*` are matched as a prefix; the rest as exact match.
66/// 8. Strip the trailing slash from `path`, except when the path is
67///    just `/` (the root).
68/// 9. Re-serialize and return the canonical string.
69///
70/// The original URL is **never** modified — `Bookmark::original_url`
71/// preserves it verbatim.
72pub fn canonicalize(input: &str) -> Result<String, CanonicalizeError> {
73    canonicalize_with(input, &CanonicalConfig::default_rules())
74}
75
76/// Canonicalize using explicit per-domain preservation rules.
77pub fn canonicalize_with(
78    input: &str,
79    config: &CanonicalConfig,
80) -> Result<String, CanonicalizeError> {
81    let trimmed = input.trim();
82    if trimmed.is_empty() {
83        return Err(CanonicalizeError::Parse("empty url".into()));
84    }
85
86    let mut url = Url::parse(trimmed).map_err(|e| CanonicalizeError::Parse(e.to_string()))?;
87
88    // 2. Lowercase scheme
89    let scheme = url.scheme().to_ascii_lowercase();
90    url.set_scheme(&scheme)
91        .map_err(|_| CanonicalizeError::Parse("invalid scheme".into()))?;
92
93    // 3. Lowercase ASCII host; ensure IDN is punycoded.
94    match url.host() {
95        Some(Host::Domain(domain)) => {
96            // `url` crate already normalizes IDN to ASCII when parsing
97            // typical browser URLs. Force lowercase for safety.
98            let lowered = domain.to_ascii_lowercase();
99            url.set_host(Some(&lowered))
100                .map_err(|e| CanonicalizeError::Parse(e.to_string()))?;
101        }
102        Some(Host::Ipv4(_) | Host::Ipv6(_)) => {
103            // Lowercase the literal representation if any hex digits
104            // are present (IPv6).
105            let host = url.host_str().unwrap_or("").to_ascii_lowercase();
106            url.set_host(Some(&host))
107                .map_err(|e| CanonicalizeError::Parse(e.to_string()))?;
108        }
109        None => {
110            return Err(CanonicalizeError::Parse("missing host".into()));
111        }
112    }
113
114    // 4. Strip default port
115    let default_port: Option<u16> = match scheme.as_str() {
116        "http" => Some(80),
117        "https" => Some(443),
118        "ftp" => Some(21),
119        _ => None,
120    };
121    if let (Some(port), Some(default)) = (url.port(), default_port) {
122        if port == default {
123            let _ = url.set_port(None);
124        }
125    }
126
127    // 5. Drop fragment
128    url.set_fragment(None);
129
130    // 6 + 7. Filter and sort query parameters.
131    let mut kept: Vec<(String, String)> = url
132        .query_pairs()
133        .filter_map(|(k, v)| {
134            let key_lc = k.to_ascii_lowercase();
135            if is_tracking(&key_lc) && !config.is_preserved(url.host_str().unwrap_or(""), &key_lc) {
136                None
137            } else {
138                Some((key_lc, v.into_owned()))
139            }
140        })
141        .collect();
142    if kept.is_empty() {
143        // Clear any trailing `?` left by the `url` crate after we
144        // dropped all params via `set_fragment` / filtering.
145        url.set_query(None);
146    } else {
147        kept.sort();
148        // Stable sort preserves values for duplicate keys; downstream
149        // consumers should treat duplicate keys as a parser quirk.
150        let pairs: Vec<(String, String)> = kept;
151        url.query_pairs_mut().clear();
152        for (k, v) in &pairs {
153            url.query_pairs_mut().append_pair(k, v);
154        }
155    }
156
157    // 8. Strip trailing slash from non-root paths.
158    let path = url.path().to_string();
159    if path.len() > 1 && path.ends_with('/') {
160        let trimmed_path = path.trim_end_matches('/').to_string();
161        url.set_path(&trimmed_path);
162    }
163
164    // 9. Re-serialize. `url::Url::as_str` returns the canonical
165    // serialization that matches our rules.
166    let canonical = url.as_str().to_string();
167
168    // sanity check: parse should round-trip
169    Url::parse(&canonical).map_err(|e| CanonicalizeError::Parse(e.to_string()))?;
170
171    Ok(canonical)
172}
173
174/// Returns `true` if the given (lowercased) query parameter name is
175/// a known tracking parameter.
176///
177/// `utm_*` and `mc_*` match by prefix. The rest match exactly.
178#[must_use]
179pub fn is_tracking(key_lc: &str) -> bool {
180    if key_lc.starts_with("utm_") || key_lc.starts_with("mc_") {
181        return true;
182    }
183    TRACKING_PARAMS.contains(&key_lc)
184}
185
186/// Convenience wrapper: canonicalize and map errors into `CoreError`.
187pub fn canonicalize_for_core(input: &str) -> Result<String, CoreError> {
188    canonicalize(input).map_err(|e| CoreError::Canonicalize(e.to_string()))
189}
190
191#[cfg(test)]
192mod tests {
193    use super::*;
194    use crate::canonical_config::{CanonicalConfig, DomainRules};
195
196    #[test]
197    fn lowercases_scheme_and_host() {
198        let out = canonicalize("HTTPS://Example.COM/path").unwrap();
199        assert_eq!(out, "https://example.com/path");
200    }
201
202    #[test]
203    fn strips_default_port_http() {
204        let out = canonicalize("http://example.com:80/x").unwrap();
205        assert_eq!(out, "http://example.com/x");
206    }
207
208    #[test]
209    fn strips_default_port_https() {
210        let out = canonicalize("https://example.com:443/x").unwrap();
211        assert_eq!(out, "https://example.com/x");
212    }
213
214    #[test]
215    fn keeps_non_default_port() {
216        let out = canonicalize("http://example.com:8080/x").unwrap();
217        assert_eq!(out, "http://example.com:8080/x");
218    }
219
220    #[test]
221    fn sorts_query_params() {
222        let out = canonicalize("https://example.com/p?b=2&a=1&c=3").unwrap();
223        assert_eq!(out, "https://example.com/p?a=1&b=2&c=3");
224    }
225
226    #[test]
227    fn drops_utm_tracking_params() {
228        let out = canonicalize("https://example.com/p?utm_source=x&id=42&utm_campaign=y").unwrap();
229        assert_eq!(out, "https://example.com/p?id=42");
230    }
231
232    #[test]
233    fn drops_known_tracking_params() {
234        let cases = [
235            ("https://example.com/p?fbclid=x", "https://example.com/p"),
236            ("https://example.com/p?gclid=x", "https://example.com/p"),
237            ("https://example.com/p?ref=x", "https://example.com/p"),
238            ("https://example.com/p?ref_src=x", "https://example.com/p"),
239            ("https://example.com/p?mc_eid=x", "https://example.com/p"),
240            ("https://example.com/p?mc_cid=x", "https://example.com/p"),
241        ];
242        for (input, expected) in cases {
243            assert_eq!(canonicalize(input).unwrap(), expected, "input={input}");
244        }
245    }
246
247    #[test]
248    fn strips_fragment() {
249        let out = canonicalize("https://example.com/p#section-1").unwrap();
250        assert_eq!(out, "https://example.com/p");
251    }
252
253    #[test]
254    fn strips_trailing_slash_from_non_root_path() {
255        assert_eq!(
256            canonicalize("https://example.com/foo/").unwrap(),
257            "https://example.com/foo"
258        );
259    }
260
261    #[test]
262    fn keeps_root_slash() {
263        assert_eq!(
264            canonicalize("https://example.com/").unwrap(),
265            "https://example.com/"
266        );
267    }
268
269    #[test]
270    fn is_deterministic_across_runs() {
271        let inputs = [
272            "https://Example.com/PATH/?utm_source=a&b=2&a=1#frag",
273            "HTTPS://example.com:443/path/?ref=x&c=3&b=2",
274        ];
275        for input in inputs {
276            let a = canonicalize(input).unwrap();
277            let b = canonicalize(input).unwrap();
278            assert_eq!(a, b);
279        }
280    }
281
282    #[test]
283    fn invalid_url_errors() {
284        assert!(canonicalize("not a url").is_err());
285        assert!(canonicalize("").is_err());
286        assert!(canonicalize("   ").is_err());
287    }
288
289    #[test]
290    fn idn_host_lowercased_to_punycode() {
291        let out = canonicalize("https://xn--bcher-kva.example/p").unwrap();
292        assert_eq!(out, "https://xn--bcher-kva.example/p");
293    }
294
295    #[test]
296    fn is_tracking_matches_prefix_and_exact() {
297        assert!(is_tracking("utm_source"));
298        assert!(is_tracking("utm_medium"));
299        assert!(is_tracking("mc_eid"));
300        assert!(is_tracking("fbclid"));
301        assert!(!is_tracking("id"));
302        assert!(!is_tracking("page"));
303    }
304
305    #[test]
306    fn preserves_functional_params_by_default() {
307        // YouTube `t` is seconds offset (functional), NOT tracking.
308        // `v` is video ID. Both must round-trip.
309        let out = canonicalize("https://www.youtube.com/watch?v=abc&t=120s").unwrap();
310        assert!(out.contains("t=120s"), "functional param dropped: {out}");
311        assert!(out.contains("v=abc"), "functional param dropped: {out}");
312    }
313
314    #[test]
315    fn drops_tracking_even_when_functional_id_present() {
316        // `fbclid` is tracking even when sibling param `id` is preserved.
317        let out = canonicalize("https://example.com/p?id=42&fbclid=xyz").unwrap();
318        assert_eq!(out, "https://example.com/p?id=42");
319    }
320
321    #[test]
322    fn per_domain_config_overrides_tracking_default() {
323        let mut config = CanonicalConfig::default_rules();
324        config.domains.insert(
325            "amazon.com".to_string(),
326            DomainRules {
327                preserve_params: vec!["tag".to_string()],
328            },
329        );
330        let out = canonicalize_with("https://amazon.com/dp/B07?ref=x&tag=lo-20", &config).unwrap();
331        assert!(
332            out.contains("tag=lo-20"),
333            "domain-override param dropped: {out}"
334        );
335        assert!(
336            !out.contains("ref=x"),
337            "non-preserved tracking leaked: {out}"
338        );
339    }
340
341    #[test]
342    fn always_functional_params_survive_global_blocklist() {
343        // `source` IS in the blocklist, but for a domain that uses it
344        // functionally (e.g. a code search engine with `source=github`
345        // filter), the config must override. Test the override path.
346        let mut config = CanonicalConfig::default_rules();
347        config.domains.insert(
348            "grep.app".to_string(),
349            DomainRules {
350                preserve_params: vec!["source".to_string()],
351            },
352        );
353        let out =
354            canonicalize_with("https://grep.app/search?q=foo&source=github", &config).unwrap();
355        assert!(
356            out.contains("source=github"),
357            "domain override failed: {out}"
358        );
359        assert!(out.contains("q=foo"), "functional q dropped: {out}");
360    }
361}