const ENCODE_DEFAULT_CHARS: &str = ";/?:@&=+$,-_.!~*'()#";
const DECODE_LINK_TEXT_EXCLUDE: &str = ";/?:@&=+$,#%";
const RECODE_HOSTNAME_FOR: [&str; 3] = ["http:", "https:", "mailto:"];
pub(crate) fn normalize_link(url: &str) -> String {
let mut parsed = Url::parse(url);
if recode_hostname(&parsed) {
let hostname = parsed.hostname.take().unwrap_or_default();
parsed.hostname = Some(
map_domain(&hostname, |label| {
if label.chars().any(|c| c > '\x7e') {
Some(format!("xn--{}", punycode_encode(label)))
} else {
Some(label.to_string())
}
})
.unwrap_or(hostname),
);
}
encode(&parsed.format())
}
pub(crate) fn normalize_link_text(url: &str) -> String {
let mut parsed = Url::parse(url);
if recode_hostname(&parsed) {
let hostname = parsed.hostname.take().unwrap_or_default();
parsed.hostname = Some(
map_domain(&hostname, |label| match label.strip_prefix("xn--") {
Some(encoded) => punycode_decode(&encoded.to_lowercase()),
None => Some(label.to_string()),
})
.unwrap_or(hostname),
);
}
decode(&parsed.format(), DECODE_LINK_TEXT_EXCLUDE)
}
pub(crate) fn validate_link(url: &str) -> bool {
let url = url.trim_matches(is_python_space).to_ascii_lowercase();
let bad = ["vbscript:", "javascript:", "file:", "data:"]
.iter()
.any(|scheme| url.starts_with(scheme));
!bad || ["gif", "png", "jpeg", "webp"]
.iter()
.any(|kind| url.starts_with(&format!("data:image/{kind};")))
}
fn recode_hostname(parsed: &Url) -> bool {
parsed
.hostname
.as_deref()
.is_some_and(|host| !host.is_empty())
&& parsed
.protocol
.as_deref()
.is_none_or(|protocol| protocol.is_empty() || RECODE_HOSTNAME_FOR.contains(&protocol))
}
fn map_domain(string: &str, mut map: impl FnMut(&str) -> Option<String>) -> Option<String> {
let parts: Vec<&str> = string.split('@').collect();
let (mut result, domain) = if parts.len() > 1 {
(format!("{}@", parts[0]), parts[1])
} else {
(String::new(), string)
};
let labels: Vec<&str> = domain
.split(['.', '\u{3002}', '\u{ff0e}', '\u{ff61}'])
.collect();
let mut mapped = Vec::with_capacity(labels.len());
for label in labels {
mapped.push(map(label)?);
}
result.push_str(&mapped.join("."));
Some(result)
}
fn is_python_space(c: char) -> bool {
c.is_whitespace() || ('\x1c'..='\x1f').contains(&c)
}
fn is_protocol_char(c: char) -> bool {
c.is_ascii_alphanumeric()
|| matches!(
c,
'.' | '+' | '-' | '\u{130}' | '\u{131}' | '\u{17f}' | '\u{212a}'
)
}
fn is_hostname_part(part: &str) -> bool {
part.chars().count() <= 63
&& part
.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '_' | '-'))
}
#[derive(Debug, Default)]
struct Url {
protocol: Option<String>,
slashes: bool,
auth: Option<String>,
port: Option<String>,
hostname: Option<String>,
hash: Option<String>,
search: Option<String>,
pathname: Option<String>,
}
fn find_any(chars: &[char], needles: &[char]) -> Option<usize> {
chars.iter().position(|c| needles.contains(c))
}
impl Url {
fn parse(url: &str) -> Url {
fn hostless(protocol: &str) -> bool {
matches!(protocol, "javascript" | "javascript:")
}
fn slashed(protocol: &str) -> bool {
matches!(
protocol,
"http"
| "https"
| "ftp"
| "gopher"
| "file"
| "http:"
| "https:"
| "ftp:"
| "gopher:"
| "file:"
)
}
const HOST_ENDING_CHARS: [char; 3] = ['/', '?', '#'];
const NON_HOST_CHARS: [char; 19] = [
'%', '/', '?', ';', '#', '\'', '{', '}', '|', '\\', '^', '`', '<', '>', '"', ' ', '\r',
'\n', '\t',
];
let mut result = Url::default();
let mut rest: Vec<char> = url.trim_matches(is_python_space).chars().collect();
let mut proto = String::new();
let mut lower_proto = String::new();
let run = rest.iter().take_while(|c| is_protocol_char(**c)).count();
if run > 0 && rest.get(run) == Some(&':') {
proto = rest[..=run].iter().collect();
lower_proto = proto.to_lowercase();
result.protocol = Some(proto.clone());
rest.drain(..=run);
}
let slashes = rest.starts_with(&['/', '/']);
if slashes && !(!proto.is_empty() && hostless(&proto)) {
rest.drain(..2);
result.slashes = true;
}
if !hostless(&proto) && (slashes || (!proto.is_empty() && !slashed(&proto))) {
let host_end = find_any(&rest, &HOST_ENDING_CHARS);
let search_to = host_end.map_or(rest.len(), |end| (end + 1).min(rest.len()));
if let Some(at_sign) = rest[..search_to].iter().rposition(|c| *c == '@') {
result.auth = Some(rest[..at_sign].iter().collect());
rest.drain(..=at_sign);
}
let mut host_end = find_any(&rest, &NON_HOST_CHARS).unwrap_or(rest.len());
if host_end > 0 && rest[host_end - 1] == ':' {
host_end -= 1;
}
let host: Vec<char> = rest.drain(..host_end).collect();
result.parse_host(&host);
let mut hostname: Vec<char> =
result.hostname.take().unwrap_or_default().chars().collect();
let ipv6 = hostname.first() == Some(&'[') && hostname.last() == Some(&']');
if !ipv6 {
let text: String = hostname.iter().collect();
let parts: Vec<&str> = text.split('.').collect();
for (index, part) in parts.iter().enumerate() {
if part.is_empty() || is_hostname_part(part) {
continue;
}
let placeholder: String = part
.chars()
.map(|c| if (c as u32) > 127 { 'x' } else { c })
.collect();
if is_hostname_part(&placeholder) {
continue;
}
let valid_len = part
.chars()
.take_while(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '_' | '-'))
.take(63)
.count();
let split = part
.char_indices()
.nth(valid_len)
.map_or(part.len(), |(i, _)| i);
let mut valid_parts: Vec<&str> = parts[..index].to_vec();
valid_parts.push(&part[..split]);
let mut not_host: Vec<&str> = vec![&part[split..]];
not_host.extend(&parts[index + 1..]);
let moved: Vec<char> = not_host.join(".").chars().collect();
rest.splice(0..0, moved);
hostname = valid_parts.join(".").chars().collect();
break;
}
}
if hostname.len() > 255 {
hostname.clear();
}
if ipv6 {
hostname = hostname[1..hostname.len() - 1].to_vec();
}
result.hostname = Some(hostname.into_iter().collect());
}
if let Some(hash) = rest.iter().position(|c| *c == '#') {
result.hash = Some(rest.drain(hash..).collect());
}
if let Some(query) = rest.iter().position(|c| *c == '?') {
result.search = Some(rest.drain(query..).collect());
}
if !rest.is_empty() {
result.pathname = Some(rest.into_iter().collect());
}
if slashed(&lower_proto)
&& result
.hostname
.as_deref()
.is_some_and(|host| !host.is_empty())
&& result.pathname.as_deref().is_none_or(str::is_empty)
{
result.pathname = Some(String::new());
}
result
}
fn parse_host(&mut self, host: &[char]) {
let mut host = host;
let digits = host.iter().rev().take_while(|c| c.is_ascii_digit()).count();
if digits < host.len() && host[host.len() - digits - 1] == ':' {
let colon = host.len() - digits - 1;
if digits > 0 {
self.port = Some(host[colon + 1..].iter().collect());
}
host = &host[..colon];
}
if !host.is_empty() {
self.hostname = Some(host.iter().collect());
}
}
fn format(&self) -> String {
let mut out = String::new();
out.push_str(self.protocol.as_deref().unwrap_or(""));
if self.slashes {
out.push_str("//");
}
if let Some(auth) = self.auth.as_deref().filter(|auth| !auth.is_empty()) {
out.push_str(auth);
out.push('@');
}
match self.hostname.as_deref() {
Some(host) if host.contains(':') => {
out.push('[');
out.push_str(host);
out.push(']');
}
host => out.push_str(host.unwrap_or("")),
}
if let Some(port) = self.port.as_deref().filter(|port| !port.is_empty()) {
out.push(':');
out.push_str(port);
}
out.push_str(self.pathname.as_deref().unwrap_or(""));
out.push_str(self.search.as_deref().unwrap_or(""));
out.push_str(self.hash.as_deref().unwrap_or(""));
out
}
}
fn encode(string: &str) -> String {
let chars: Vec<char> = string.chars().collect();
let mut out = String::with_capacity(string.len());
let mut i = 0;
while i < chars.len() {
let c = chars[i];
if c == '%'
&& i + 2 < chars.len()
&& chars[i + 1..i + 3].iter().all(char::is_ascii_hexdigit)
{
out.extend(&chars[i..i + 3]);
i += 3;
continue;
}
if c.is_ascii_alphanumeric() || ENCODE_DEFAULT_CHARS.contains(c) {
out.push(c);
} else {
let mut buffer = [0u8; 4];
for byte in c.encode_utf8(&mut buffer).bytes() {
out.push_str(&format!("%{byte:02X}"));
}
}
i += 1;
}
out
}
fn decode(string: &str, exclude: &str) -> String {
let bytes = string.as_bytes();
let hex = |at: usize| -> Option<u8> {
let pair = string.get(at..at + 2)?;
u8::from_str_radix(pair, 16)
.ok()
.filter(|_| pair.bytes().all(|b| b.is_ascii_hexdigit()))
};
let mut out = String::with_capacity(string.len());
let mut at = 0;
while at < bytes.len() {
let mut run = Vec::new();
let mut end = at;
while bytes.get(end) == Some(&b'%') {
match hex(end + 1) {
Some(value) => {
run.push(value);
end += 3;
}
None => break,
}
}
if run.is_empty() {
let c = string[at..].chars().next().expect("in bounds");
out.push(c);
at += c.len_utf8();
continue;
}
decode_run(&run, exclude, &mut out);
at = end;
}
out
}
fn decode_run(run: &[u8], exclude: &str, out: &mut String) {
let length = run.len() * 3;
let continuation = |b: u8| b & 0xc0 == 0x80;
let push_utf8 = |bytes: &[u8], out: &mut String| match std::str::from_utf8(bytes) {
Ok(text) => out.push_str(text),
Err(_) => out.extend(std::iter::repeat_n('\u{fffd}', bytes.len())),
};
let mut index = 0;
while index < run.len() {
let i = index * 3;
let b1 = run[index];
if b1 < 0x80 {
let c = b1 as char;
if exclude.contains(c) {
out.push_str(&format!("%{b1:02X}"));
} else {
out.push(c);
}
index += 1;
continue;
}
if b1 & 0xe0 == 0xc0 && i + 3 < length && continuation(run[index + 1]) {
push_utf8(&run[index..index + 2], out);
index += 2;
continue;
}
if b1 & 0xf0 == 0xe0
&& i + 6 < length
&& continuation(run[index + 1])
&& continuation(run[index + 2])
{
push_utf8(&run[index..index + 3], out);
index += 3;
continue;
}
if b1 & 0xf8 == 0xf0
&& i + 9 < length
&& continuation(run[index + 1])
&& continuation(run[index + 2])
&& continuation(run[index + 3])
{
push_utf8(&run[index..index + 4], out);
index += 4;
continue;
}
out.push('\u{fffd}');
index += 1;
}
}
const BASE: u64 = 36;
const T_MIN: u64 = 1;
const T_MAX: u64 = 26;
const SKEW: u64 = 38;
const DAMP: u64 = 700;
const INITIAL_BIAS: u64 = 72;
const INITIAL_N: u64 = 0x80;
fn threshold(k: u64, bias: u64) -> u64 {
k.saturating_sub(bias).clamp(T_MIN, T_MAX)
}
fn adapt(delta: u64, first: bool, points: u64) -> u64 {
let mut delta = if first { delta / DAMP } else { delta / 2 };
delta += delta / points;
let mut k = 0;
while delta > ((BASE - T_MIN) * T_MAX) / 2 {
delta /= BASE - T_MIN;
k += BASE;
}
k + (BASE - T_MIN + 1) * delta / (delta + SKEW)
}
fn digit(value: u64) -> char {
b"abcdefghijklmnopqrstuvwxyz0123456789"[value as usize] as char
}
fn punycode_encode(input: &str) -> String {
let points: Vec<u64> = input.chars().map(|c| c as u64).collect();
let mut output: String = input.chars().filter(|c| (*c as u32) < 0x80).collect();
let basic = output.chars().count() as u64;
if basic > 0 {
output.push('-');
}
let (mut n, mut delta, mut bias, mut handled) = (INITIAL_N, 0u64, INITIAL_BIAS, basic);
while (handled as usize) < points.len() {
let m = points
.iter()
.copied()
.filter(|p| *p >= n)
.min()
.expect("unhandled point");
delta += (m - n) * (handled + 1);
n = m;
for &point in &points {
if point < n {
delta += 1;
}
if point == n {
let mut q = delta;
let mut k = BASE;
loop {
let t = threshold(k, bias);
if q < t {
break;
}
output.push(digit(t + (q - t) % (BASE - t)));
q = (q - t) / (BASE - t);
k += BASE;
}
output.push(digit(q));
bias = adapt(delta, handled == basic, handled + 1);
delta = 0;
handled += 1;
}
}
delta += 1;
n += 1;
}
output
}
fn punycode_decode(input: &str) -> Option<String> {
if !input.is_ascii() {
return None;
}
let (base, extended) = match input.rfind('-') {
Some(position) => (&input[..position], &input[position + 1..]),
None => ("", input),
};
let mut output: Vec<char> = base.chars().collect();
let extended = extended.to_ascii_uppercase().into_bytes();
let (mut n, mut position, mut bias) = (INITIAL_N, 0u64, INITIAL_BIAS);
let mut at = 0;
let mut first = true;
while at < extended.len() {
let (mut value, mut weight, mut k) = (0u64, 1u64, BASE);
loop {
let byte = *extended.get(at)?;
at += 1;
let digit = match byte {
b'A'..=b'Z' => byte - b'A',
b'0'..=b'9' => byte - 22,
_ => return None,
} as u64;
let t = threshold(k, bias);
value = value.checked_add(digit.checked_mul(weight)?)?;
if digit < t {
break;
}
weight = weight.checked_mul(BASE - t)?;
k += BASE;
}
position = position.checked_add(value)?;
let length = output.len() as u64 + 1;
n = n.checked_add(position / length)?;
position %= length;
let point = char::from_u32(u32::try_from(n).ok()?)?;
output.insert(position as usize, point);
bias = adapt(value, first, output.len() as u64);
first = false;
position += 1;
}
Some(output.into_iter().collect())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn normalize_link_matches_markdown_it() {
let cases: &[(&str, &str, &str)] = &[
(
"http://a\x1b]0;PWN\x07",
"http://a%1B%5D0;PWN%07",
"http://a\x1b]0;PWN\x07",
),
(
"http://é.com/ü",
"http://xn--9ca.com/%C3%BC",
"http://é.com/ü",
),
("http://a b", "http://a%20b", "http://a b"),
("HTTP://é.com", "HTTP://%C3%A9.com", "HTTP://é.com"),
("http://@é.com", "http://xn--9ca.com", "http://é.com"),
("mailto:ü@é.de", "mailto:%C3%BC@xn--9ca.de", "mailto:ü@é.de"),
("//é.com/x", "//xn--9ca.com/x", "//é.com/x"),
(
"http://[::1]:80/x",
"http://%5B::1%5D:80/x",
"http://[::1]:80/x",
),
("http://a%zz%41", "http://a%25zz%41", "http://a%zzA"),
(
"javascript://é.com",
"javascript://%C3%A9.com",
"javascript://é.com",
),
("http://ff.com", "http://xn--im6c.com", "http://ff.com"),
("http://a。é.b", "http://a.xn--9ca.b", "http://a.é.b"),
("x y", "x%20y", "x y"),
("ftp://é.com", "ftp://%C3%A9.com", "ftp://é.com"),
(
"http://é.com:8080",
"http://xn--9ca.com:8080",
"http://é.com:8080",
),
(" http://é.com ", "http://xn--9ca.com", "http://é.com"),
("http://a*b.com/", "http://a*b.com/", "http://a*b.com/"),
(
"http://xn--9ca.com/%C3%BC%2F%25",
"http://xn--9ca.com/%C3%BC%2F%25",
"http://é.com/ü%2F%25",
),
(
"http://a\u{2028}b",
"http://xn--ab-x3t",
"http://a\u{2028}b",
),
(
"http://ä.com?q=ü#frag ü",
"http://xn--4ca.com?q=%C3%BC#frag%20%C3%BC",
"http://ä.com?q=ü#frag ü",
),
(
"http://user:pw@é.com",
"http://user:pw@xn--9ca.com",
"http://user:pw@é.com",
),
("http://x", "%EF%BD%88ttp://x", "http://x"),
("http://é.com:", "http://xn--9ca.com:", "http://é.com:"),
("k\u{212a}:x", "k%E2%84%AA:x", "k\u{212a}:x"),
("http://%e9.com", "http://%e9.com", "http://\u{fffd}.com"),
(
"http://a/%F0%9F%98%80%ED%A0%80%C3",
"http://a/%F0%9F%98%80%ED%A0%80%C3",
"http://a/😀\u{fffd}\u{fffd}\u{fffd}\u{fffd}",
),
(
"http://xn--zz.com",
"http://xn--zz.com",
"http://xn--zz.com",
),
(
"http://xn--ä.com",
"http://xn--xn---ooa.com",
"http://xn--ä.com",
),
("http://A.É.com", "http://A.xn--dca.com", "http://A.É.com"),
];
for (url, link, text) in cases {
assert_eq!(normalize_link(url), *link, "normalizeLink({url:?})");
assert_eq!(
normalize_link_text(url),
*text,
"normalizeLinkText({url:?})"
);
}
let long = format!("http://{}.com", "é".repeat(70));
assert_eq!(
normalize_link(&long),
format!("http://{}.com", "%C3%A9".repeat(70))
);
assert_eq!(normalize_link_text(&long), long);
}
#[test]
fn punycode_round_trips_rfc_samples() {
for (unicode, ascii) in [
("ü", "tda"),
("bücher", "bcher-kva"),
("他们为什么不说中文", "ihqwcrb4cv8a8dqg056pqjye"),
("Pročprostěnemluvíčesky", "Proprostnemluvesky-uyb24dma41a"),
("-> $1.00 <-", "-> $1.00 <--"),
] {
assert_eq!(punycode_encode(unicode), ascii);
assert_eq!(
punycode_decode(&ascii.to_lowercase()).as_deref(),
Some(unicode.to_lowercase().as_str())
);
}
assert_eq!(punycode_decode("bcher-kva").as_deref(), Some("bücher"));
assert_eq!(punycode_decode("zz"), None);
}
}