Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/releases/v0.1.0-preview.3.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
133 changes: 112 additions & 21 deletions src/cleaner.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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| {
Expand All @@ -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();

Expand Down Expand Up @@ -105,37 +103,91 @@ 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)))
.unwrap_or((part, None))
}

fn extract_host(url: &str) -> Option<String> {
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::<Vec<_>>();

!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)]
Expand All @@ -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();
Expand Down Expand Up @@ -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);
}
}
}
4 changes: 4 additions & 0 deletions tests/fixtures/youtube-short-share-schemeless.plainlink-case
Original file line number Diff line number Diff line change
@@ -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