1use crate::{canonical_config::CanonicalConfig, errors::CoreError};
11use thiserror::Error;
12use url::{Host, Url};
13
14#[derive(Debug, Error)]
16pub enum CanonicalizeError {
17 #[error("parse error: {0}")]
19 Parse(String),
20 #[error("idn conversion failed: {0}")]
22 Idn(String),
23}
24
25pub 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
52pub fn canonicalize(input: &str) -> Result<String, CanonicalizeError> {
73 canonicalize_with(input, &CanonicalConfig::default_rules())
74}
75
76pub 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 let scheme = url.scheme().to_ascii_lowercase();
90 url.set_scheme(&scheme)
91 .map_err(|_| CanonicalizeError::Parse("invalid scheme".into()))?;
92
93 match url.host() {
95 Some(Host::Domain(domain)) => {
96 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 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 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 url.set_fragment(None);
129
130 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 url.set_query(None);
146 } else {
147 kept.sort();
148 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 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 let canonical = url.as_str().to_string();
167
168 Url::parse(&canonical).map_err(|e| CanonicalizeError::Parse(e.to_string()))?;
170
171 Ok(canonical)
172}
173
174#[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
186pub 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 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 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 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}