diff --git a/docs/releases/v0.1.0-preview.3.md b/docs/releases/v0.1.0-preview.3.md index ad9aaba..e9cbfeb 100644 --- a/docs/releases/v0.1.0-preview.3.md +++ b/docs/releases/v0.1.0-preview.3.md @@ -12,6 +12,7 @@ PlainLink v0.1.0 Preview 3 is an ad-hoc-signed, unnotarized technical preview fo - Multi-resolution app icon verification during packaging. - Automatic watcher installation and startup on first launch. - Exact recovery steps for macOS's misleading downloaded-app “damaged” warning. +- Cleaning for copied links without an `http://` or `https://` prefix while preserving their original schemeless form. ## Artifact diff --git a/src/cleaner.rs b/src/cleaner.rs index 65d8824..e96121f 100644 --- a/src/cleaner.rs +++ b/src/cleaner.rs @@ -23,9 +23,9 @@ impl CleanResult { pub fn clean_url(input: &str, rules: &RuleSet) -> CleanResult { let candidate = input.trim(); - if !is_supported_url(candidate) { + let Some(host) = extract_host(candidate) else { return unchanged(input); - } + }; let fragment_start = candidate.find('#'); let query_start = candidate.find('?').filter(|query_index| { @@ -44,8 +44,6 @@ pub fn clean_url(input: &str, rules: &RuleSet) -> CleanResult { let prefix = &candidate[..query_start]; let query = &candidate[query_start + 1..query_end]; let suffix = &candidate[query_end..]; - let host = extract_host(candidate).unwrap_or_default(); - let mut kept_parts = Vec::new(); let mut removed = Vec::new(); @@ -105,15 +103,6 @@ fn unchanged(input: &str) -> CleanResult { } } -fn is_supported_url(input: &str) -> bool { - input - .get(..8) - .is_some_and(|prefix| prefix.eq_ignore_ascii_case("https://")) - || input - .get(..7) - .is_some_and(|prefix| prefix.eq_ignore_ascii_case("http://")) -} - fn split_query_part(part: &str) -> (&str, Option<&str>) { part.split_once('=') .map(|(name, value)| (name, Some(value))) @@ -121,21 +110,84 @@ fn split_query_part(part: &str) -> (&str, Option<&str>) { } fn extract_host(url: &str) -> Option { - let scheme_end = url.find("://")?; - let after_scheme = &url[scheme_end + 3..]; - let authority_end = after_scheme + if url.is_empty() || url.chars().any(char::is_whitespace) { + return None; + } + + let (after_prefix, has_scheme) = if url + .get(..8) + .is_some_and(|prefix| prefix.eq_ignore_ascii_case("https://")) + { + (&url[8..], true) + } else if url + .get(..7) + .is_some_and(|prefix| prefix.eq_ignore_ascii_case("http://")) + { + (&url[7..], true) + } else if let Some(protocol_relative) = url.strip_prefix("//") { + (protocol_relative, true) + } else { + if url.contains("://") || url.contains('@') { + return None; + } + + (url, false) + }; + + let authority_end = after_prefix .find(['/', '?', '#']) - .unwrap_or(after_scheme.len()); - let authority = &after_scheme[..authority_end]; + .unwrap_or(after_prefix.len()); + let authority = &after_prefix[..authority_end]; + + if authority.is_empty() { + return None; + } + let host_port = authority.rsplit('@').next().unwrap_or(authority); if host_port.starts_with('[') { let end = host_port.find(']')?; - return Some(host_port[..=end].to_ascii_lowercase()); + let host = &host_port[..=end]; + return (has_scheme && host.len() > 2).then(|| host.to_ascii_lowercase()); } - let host = host_port.split(':').next().unwrap_or(host_port); - Some(host.trim_end_matches('.').to_ascii_lowercase()) + let host = host_port + .split(':') + .next() + .unwrap_or(host_port) + .trim_end_matches('.'); + + (!host.is_empty() && (has_scheme || is_domain_like_host(host))) + .then(|| host.to_ascii_lowercase()) +} + +fn is_domain_like_host(host: &str) -> bool { + if host.eq_ignore_ascii_case("localhost") { + return true; + } + + let mut labels = host.split('.'); + let Some(first) = labels.next() else { + return false; + }; + let remaining = labels.collect::>(); + + !remaining.is_empty() && valid_host_label(first) && remaining.into_iter().all(valid_host_label) +} + +fn valid_host_label(label: &str) -> bool { + let mut characters = label.chars(); + let Some(first) = characters.next() else { + return false; + }; + + let last = label.chars().last().unwrap_or(first); + + first.is_alphanumeric() + && last.is_alphanumeric() + && label + .chars() + .all(|character| character.is_alphanumeric() || character == '-') } #[cfg(test)] @@ -152,6 +204,31 @@ mod tests { assert_eq!(result.removed[0].name, "si"); } + #[test] + fn removes_tracking_from_schemeless_urls_without_adding_a_scheme() { + let rules = RuleSet::default_rules(); + let result = clean_url("youtu.be/LYa_ReqRlcs?si=VC4qVB_EUC90uwbo", &rules); + + assert_eq!(result.cleaned, "youtu.be/LYa_ReqRlcs"); + assert_eq!(result.removed[0].name, "si"); + } + + #[test] + fn removes_tracking_from_protocol_relative_urls() { + let rules = RuleSet::default_rules(); + let result = clean_url("//example.com/read?id=42&utm_source=newsletter", &rules); + + assert_eq!(result.cleaned, "//example.com/read?id=42"); + } + + #[test] + fn continues_to_support_schemeful_single_label_hosts() { + let rules = RuleSet::default_rules(); + let result = clean_url("http://intranet/read?id=42&utm_source=newsletter", &rules); + + assert_eq!(result.cleaned, "http://intranet/read?id=42"); + } + #[test] fn removes_global_trackers_and_preserves_fragment() { let rules = RuleSet::default_rules(); @@ -204,4 +281,18 @@ mod tests { assert_eq!(clean_url(input, &rules).cleaned, input); } + + #[test] + fn leaves_ambiguous_schemeless_text_unchanged() { + let rules = RuleSet::default_rules(); + + for input in [ + "readme/path?utm_source=notes", + "person@example.com?utm_source=mail", + "copy example.com/read?utm_source=notes", + "ftp://example.com/file?utm_source=download", + ] { + assert_eq!(clean_url(input, &rules).cleaned, input); + } + } } diff --git a/tests/fixtures/youtube-short-share-schemeless.plainlink-case b/tests/fixtures/youtube-short-share-schemeless.plainlink-case new file mode 100644 index 0000000..09ec80f --- /dev/null +++ b/tests/fixtures/youtube-short-share-schemeless.plainlink-case @@ -0,0 +1,4 @@ +name = remove YouTube share token without requiring a URL scheme +input = youtu.be/LYa_ReqRlcs?si=VC4qVB_EUC90uwbo +expected = youtu.be/LYa_ReqRlcs +removed = si