1pub const SAFE_HYPERLINK_SCHEMES: &[&str] = &[
19 "http", "https", "ftp", "ftps", "sftp", "mailto", "tel", "file",
20];
21
22pub fn is_safe_hyperlink(uri: &str) -> bool {
30 let cleaned: String = uri
31 .chars()
32 .filter(|c| !matches!(c, '\t' | '\r' | '\n'))
33 .skip_while(|&c| (c as u32) < 0x21)
34 .collect();
35 let mut chars = cleaned.chars();
36 let Some(first) = chars.next().filter(char::is_ascii_alphabetic) else {
37 return true;
38 };
39 let mut scheme = first.to_string();
40 for c in chars {
41 if c == ':' {
42 return scheme.len() == 1
43 || SAFE_HYPERLINK_SCHEMES.contains(&scheme.to_ascii_lowercase().as_str());
44 }
45 if c.is_ascii_alphanumeric() || matches!(c, '+' | '.' | '-') {
46 scheme.push(c);
47 } else {
48 return true;
50 }
51 }
52 true
53}
54
55pub fn markdown_link_destination(uri: &str) -> String {
62 let mut out = String::with_capacity(uri.len());
63 let bytes = uri.as_bytes();
64 let mut i = 0;
65 while i < bytes.len() {
66 let b = bytes[i];
67 match b {
68 b'&' if starts_entity(&bytes[i + 1..]) => out.push_str("&"),
69 0x00..=0x20 | 0x7f | b'(' | b')' | b'<' | b'>' | b'"' | b'\\' => {
70 out.push_str(&format!("%{b:02X}"));
71 }
72 _ => {
73 let len = utf8_len(b);
75 out.push_str(&uri[i..i + len]);
76 i += len;
77 continue;
78 }
79 }
80 i += 1;
81 }
82 out
83}
84
85fn starts_entity(rest: &[u8]) -> bool {
88 let rest = rest.strip_prefix(b"#").unwrap_or(rest);
89 let n = rest
90 .iter()
91 .take_while(|b| b.is_ascii_alphanumeric() || **b == b'_')
92 .count();
93 n > 0 && rest.get(n) == Some(&b';')
94}
95
96fn utf8_len(first: u8) -> usize {
97 match first {
98 0x00..=0x7f => 1,
99 0xc0..=0xdf => 2,
100 0xe0..=0xef => 3,
101 _ => 4,
102 }
103}
104
105pub fn markdown_link(text: &str, uri: &str) -> String {
109 if is_safe_hyperlink(uri) {
110 format!("[{text}]({})", markdown_link_destination(uri))
111 } else {
112 text.to_string()
113 }
114}
115
116pub fn html_href(uri: &str) -> String {
120 let mut out = String::with_capacity(uri.len());
121 for c in uri.chars() {
122 match c {
123 '&' => out.push_str("&"),
124 '<' => out.push_str("<"),
125 '>' => out.push_str(">"),
126 '"' => out.push_str("""),
127 '\'' => out.push_str("'"),
128 c => out.push(c),
129 }
130 }
131 out
132}
133
134#[cfg(test)]
135mod tests {
136 use super::*;
137
138 #[test]
139 fn scheme_allow_list_read_like_a_browser() {
140 let cases: &[(&str, bool)] = &[
143 ("https://example.org/a", true),
144 ("HTTP://EXAMPLE.ORG", true),
145 ("mailto:a@b.c", true),
146 ("tel:+1-555", true),
147 ("ftp://host/x", true),
148 ("sftp://host/x", true),
149 ("file:///tmp/x", true),
150 ("page.html#frag", true),
151 ("#section", true),
152 ("../up/there", true),
153 ("", true),
154 ("C:\\Users\\x\\doc.pdf", true),
155 ("c:/x", true),
156 ("my page: notes", true),
157 ("javascript:alert(1)", false),
158 ("JAVASCRIPT:alert(1)", false),
159 ("JaVaScRiPt:alert(1)", false),
160 (" javascript:alert(1)", false),
161 ("\u{0}javascript:alert(1)", false),
162 ("\u{1}\u{2} \tjavascript:alert(1)", false),
163 ("java\nscript:alert(1)", false),
164 ("java\tscript:alert(1)", false),
165 ("java\r\nscript:alert(1)", false),
166 ("data:text/html;base64,PHNjcmlwdD4=", false),
167 ("vbscript:MsgBox", false),
168 ("ms-word:ofe|u|https://x", false),
169 ("x-custom+1.a:thing", false),
170 ];
171 for (uri, safe) in cases {
172 assert_eq!(is_safe_hyperlink(uri), *safe, "{uri:?}");
173 }
174 }
175
176 #[test]
177 fn destination_escaping() {
178 let cases: &[(&str, &str)] = &[
179 ("https://x.org/a(b)%20c d", "https://x.org/a%28b%29%20c%20d"),
180 ("https://x.org/?a=1&b=2", "https://x.org/?a=1&b=2"),
181 (
182 "https://x.org/?a=1&b=2",
183 "https://x.org/?a=1&amp;b=2",
184 ),
185 ("https://x.org/'", "https://x.org/&#39;"),
186 (
187 "https://x.org/<b>\"q\"\\",
188 "https://x.org/%3Cb%3E%22q%22%5C",
189 ),
190 ("https://x.org/\u{7f}\u{1}", "https://x.org/%7F%01"),
191 ("https://x.org/ünïcödé/é", "https://x.org/ünïcödé/é"),
192 ("Duck_(film)", "Duck_%28film%29"),
193 ];
194 for (uri, want) in cases {
195 assert_eq!(markdown_link_destination(uri), *want, "{uri:?}");
196 }
197 assert_eq!(
198 markdown_link("paren", "https://x.org/a(b)%20c d"),
199 "[paren](https://x.org/a%28b%29%20c%20d)"
200 );
201 assert_eq!(markdown_link("click", "javascript:alert(1)"), "click");
202 assert_eq!(markdown_link("click", "#top"), "[click](#top)");
203 }
204
205 #[test]
206 fn href_escaping() {
207 assert_eq!(
208 html_href("https://x.org/?a=1&b=\"2\"<'>"),
209 "https://x.org/?a=1&b="2"<'>"
210 );
211 }
212}