pub const SAFE_HYPERLINK_SCHEMES: &[&str] = &[
"http", "https", "ftp", "ftps", "sftp", "mailto", "tel", "file",
];
pub fn is_safe_hyperlink(uri: &str) -> bool {
let cleaned: String = uri
.chars()
.filter(|c| !matches!(c, '\t' | '\r' | '\n'))
.skip_while(|&c| (c as u32) < 0x21)
.collect();
let mut chars = cleaned.chars();
let Some(first) = chars.next().filter(char::is_ascii_alphabetic) else {
return true;
};
let mut scheme = first.to_string();
for c in chars {
if c == ':' {
return scheme.len() == 1
|| SAFE_HYPERLINK_SCHEMES.contains(&scheme.to_ascii_lowercase().as_str());
}
if c.is_ascii_alphanumeric() || matches!(c, '+' | '.' | '-') {
scheme.push(c);
} else {
return true;
}
}
true
}
pub fn markdown_link_destination(uri: &str) -> String {
let mut out = String::with_capacity(uri.len());
let bytes = uri.as_bytes();
let mut i = 0;
while i < bytes.len() {
let b = bytes[i];
match b {
b'&' if starts_entity(&bytes[i + 1..]) => out.push_str("&"),
0x00..=0x20 | 0x7f | b'(' | b')' | b'<' | b'>' | b'"' | b'\\' => {
out.push_str(&format!("%{b:02X}"));
}
_ => {
let len = utf8_len(b);
out.push_str(&uri[i..i + len]);
i += len;
continue;
}
}
i += 1;
}
out
}
fn starts_entity(rest: &[u8]) -> bool {
let rest = rest.strip_prefix(b"#").unwrap_or(rest);
let n = rest
.iter()
.take_while(|b| b.is_ascii_alphanumeric() || **b == b'_')
.count();
n > 0 && rest.get(n) == Some(&b';')
}
fn utf8_len(first: u8) -> usize {
match first {
0x00..=0x7f => 1,
0xc0..=0xdf => 2,
0xe0..=0xef => 3,
_ => 4,
}
}
pub fn markdown_link(text: &str, uri: &str) -> String {
if is_safe_hyperlink(uri) {
format!("[{text}]({})", markdown_link_destination(uri))
} else {
text.to_string()
}
}
pub fn html_href(uri: &str) -> String {
let mut out = String::with_capacity(uri.len());
for c in uri.chars() {
match c {
'&' => out.push_str("&"),
'<' => out.push_str("<"),
'>' => out.push_str(">"),
'"' => out.push_str("""),
'\'' => out.push_str("'"),
c => out.push(c),
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn scheme_allow_list_read_like_a_browser() {
let cases: &[(&str, bool)] = &[
("https://example.org/a", true),
("HTTP://EXAMPLE.ORG", true),
("mailto:a@b.c", true),
("tel:+1-555", true),
("ftp://host/x", true),
("sftp://host/x", true),
("file:///tmp/x", true),
("page.html#frag", true),
("#section", true),
("../up/there", true),
("", true),
("C:\\Users\\x\\doc.pdf", true),
("c:/x", true),
("my page: notes", true),
("javascript:alert(1)", false),
("JAVASCRIPT:alert(1)", false),
("JaVaScRiPt:alert(1)", false),
(" javascript:alert(1)", false),
("\u{0}javascript:alert(1)", false),
("\u{1}\u{2} \tjavascript:alert(1)", false),
("java\nscript:alert(1)", false),
("java\tscript:alert(1)", false),
("java\r\nscript:alert(1)", false),
("data:text/html;base64,PHNjcmlwdD4=", false),
("vbscript:MsgBox", false),
("ms-word:ofe|u|https://x", false),
("x-custom+1.a:thing", false),
];
for (uri, safe) in cases {
assert_eq!(is_safe_hyperlink(uri), *safe, "{uri:?}");
}
}
#[test]
fn destination_escaping() {
let cases: &[(&str, &str)] = &[
("https://x.org/a(b)%20c d", "https://x.org/a%28b%29%20c%20d"),
("https://x.org/?a=1&b=2", "https://x.org/?a=1&b=2"),
(
"https://x.org/?a=1&b=2",
"https://x.org/?a=1&amp;b=2",
),
("https://x.org/'", "https://x.org/&#39;"),
(
"https://x.org/<b>\"q\"\\",
"https://x.org/%3Cb%3E%22q%22%5C",
),
("https://x.org/\u{7f}\u{1}", "https://x.org/%7F%01"),
("https://x.org/ünïcödé/é", "https://x.org/ünïcödé/é"),
("Duck_(film)", "Duck_%28film%29"),
];
for (uri, want) in cases {
assert_eq!(markdown_link_destination(uri), *want, "{uri:?}");
}
assert_eq!(
markdown_link("paren", "https://x.org/a(b)%20c d"),
"[paren](https://x.org/a%28b%29%20c%20d)"
);
assert_eq!(markdown_link("click", "javascript:alert(1)"), "click");
assert_eq!(markdown_link("click", "#top"), "[click](#top)");
}
#[test]
fn href_escaping() {
assert_eq!(
html_href("https://x.org/?a=1&b=\"2\"<'>"),
"https://x.org/?a=1&b="2"<'>"
);
}
}