use std::borrow::Cow;
pub fn sanitize_url(url: &str) -> Cow<'_, str> {
let normalized = remove_disallowed_blank_chars(url);
let url_lower = normalized.trim().to_lowercase();
if url_lower.starts_with("javascript:")
|| url_lower.starts_with("data:")
|| url_lower.starts_with("vbscript:")
|| url_lower.starts_with("file:")
{
return Cow::Borrowed("#blocked-url");
}
normalized
}
pub fn sanitize(input: &str) -> Cow<'_, str> {
sanitize_opts(input, false)
}
pub fn sanitize_opts(input: &str, allow_bidi_in_code_blocks: bool) -> Cow<'_, str> {
remove_disallowed_blank_chars_opts(input, allow_bidi_in_code_blocks)
}
fn remove_disallowed_blank_chars(input: &str) -> Cow<'_, str> {
if !input.chars().any(is_disallowed_blank_char) {
return Cow::Borrowed(input);
}
let filtered: String = input
.chars()
.filter(|&ch| !is_disallowed_blank_char(ch))
.collect();
Cow::Owned(filtered)
}
fn remove_disallowed_blank_chars_opts(
input: &str,
allow_bidi_in_code_blocks: bool,
) -> Cow<'_, str> {
if !allow_bidi_in_code_blocks {
return remove_disallowed_blank_chars(input);
}
if !input.chars().any(is_disallowed_blank_char) {
return Cow::Borrowed(input);
}
let ends_with_newline = input.ends_with('\n');
let mut result = String::with_capacity(input.len());
let mut in_code_block = false;
let mut code_fence_char = '`';
for line in input.lines() {
let trimmed = line.trim_start();
if trimmed.starts_with("```") || trimmed.starts_with("~~~") {
let fence_char = if trimmed.starts_with("```") { '`' } else { '~' };
if !in_code_block {
in_code_block = true;
code_fence_char = fence_char;
} else if fence_char == code_fence_char {
in_code_block = false;
}
result.push_str(line);
result.push('\n');
continue;
}
for c in line.chars() {
let skip = if in_code_block {
is_disallowed_blank_char(c) && !is_bidi_control_char(c)
} else {
is_disallowed_blank_char(c)
};
if !skip {
result.push(c);
}
}
result.push('\n');
}
if !ends_with_newline && result.ends_with('\n') {
result.pop();
}
Cow::Owned(result)
}
fn is_bidi_control_char(ch: char) -> bool {
('\u{202A}'..='\u{202E}').contains(&ch) || ('\u{2066}'..='\u{2069}').contains(&ch)
}
fn is_disallowed_blank_char(ch: char) -> bool {
matches!(
ch,
'\u{200B}' | '\u{200C}' | '\u{200D}' | '\u{FEFF}' | '\u{3164}' ) || is_bidi_control_char(ch)
}
fn is_ascii_control_char(ch: char) -> bool {
let c = ch as u32;
matches!(c, 0x00..=0x08 | 0x0B | 0x0C | 0x0E..=0x1F | 0x7F)
}
pub fn remove_ascii_control_chars_from_markup(input: &str) -> std::borrow::Cow<'_, str> {
if !input.chars().any(is_ascii_control_char) {
return std::borrow::Cow::Borrowed(input);
}
let ends_with_newline = input.ends_with('\n');
let mut result = String::with_capacity(input.len());
let mut in_code_block = false;
let mut code_fence_char = '`';
for line in input.lines() {
let trimmed = line.trim_start();
if trimmed.starts_with("```") || trimmed.starts_with("~~~") {
let fence_char = if trimmed.starts_with("```") { '`' } else { '~' };
if !in_code_block {
in_code_block = true;
code_fence_char = fence_char;
} else if fence_char == code_fence_char {
in_code_block = false;
}
result.push_str(line);
result.push('\n');
continue;
}
if in_code_block {
result.push_str(line);
result.push('\n');
continue;
}
for c in line.chars() {
if !is_ascii_control_char(c) {
result.push(c);
}
}
result.push('\n');
}
if !ends_with_newline && result.ends_with('\n') {
result.pop();
}
std::borrow::Cow::Owned(result)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_no_html() {
let input = "Hello World";
assert_eq!(sanitize(input), "Hello World");
}
#[test]
fn test_remove_disallowed_blank_like_chars() {
let input = "A\u{200B}B\u{200C}C\u{200D}D\u{FEFF}E\u{3164}F";
assert_eq!(sanitize(input), "ABCDEF");
}
#[test]
fn test_remove_bidi_control_chars() {
let input = "A\u{202A}B\u{202E}C\u{2066}D\u{2069}E";
assert_eq!(sanitize(input), "ABCDE");
}
#[test]
fn test_bidi_still_removed_in_code_blocks_by_default() {
let input = "```\nA\u{202E}B\n```\n";
assert_eq!(sanitize(input), "```\nAB\n```\n");
}
#[test]
fn test_bidi_preserved_in_code_blocks_when_opted_in() {
let input = "```\nA\u{202E}B\n```\n";
assert_eq!(sanitize_opts(input, true), "```\nA\u{202E}B\n```\n");
}
#[test]
fn test_bidi_still_removed_outside_code_blocks_when_opted_in() {
let input = "A\u{202E}B\n```\nC\u{202E}D\n```\nE\u{2066}F";
assert_eq!(sanitize_opts(input, true), "AB\n```\nC\u{202E}D\n```\nEF");
}
#[test]
fn test_other_invisible_chars_still_removed_in_code_blocks_when_opted_in() {
let input = "```\nA\u{200B}B\n```\n";
assert_eq!(sanitize_opts(input, true), "```\nAB\n```\n");
}
#[test]
fn test_preserve_allowed_spaces_only() {
let input = "A B C";
assert_eq!(sanitize(input), "A B C");
}
#[test]
fn test_html_and_entities_pass_through_unescaped() {
assert_eq!(
sanitize("<script>alert('xss')</script>"),
"<script>alert('xss')</script>"
);
assert_eq!(sanitize("Hello World"), "Hello World");
assert_eq!(sanitize("A & B"), "A & B");
assert_eq!(sanitize("> a blockquote"), "> a blockquote");
}
#[test]
fn test_sanitize_url_safe_schemes() {
assert_eq!(sanitize_url("https://example.com"), "https://example.com");
assert_eq!(sanitize_url("http://example.com"), "http://example.com");
assert_eq!(
sanitize_url("mailto:user@example.com"),
"mailto:user@example.com"
);
assert_eq!(sanitize_url("ftp://example.com"), "ftp://example.com");
assert_eq!(sanitize_url("/relative/path"), "/relative/path");
assert_eq!(sanitize_url("./relative"), "./relative");
assert_eq!(sanitize_url("#anchor"), "#anchor");
}
#[test]
fn test_sanitize_url_custom_app_schemes() {
assert_eq!(sanitize_url("spotify:track:123"), "spotify:track:123");
assert_eq!(sanitize_url("steam://open/game"), "steam://open/game");
assert_eq!(sanitize_url("discord://invite/123"), "discord://invite/123");
assert_eq!(
sanitize_url("slack://channel?id=123"),
"slack://channel?id=123"
);
assert_eq!(sanitize_url("zoom:meeting:123"), "zoom:meeting:123");
assert_eq!(sanitize_url("vscode://file/path"), "vscode://file/path");
}
#[test]
fn test_sanitize_url_blocked_schemes() {
assert_eq!(sanitize_url("javascript:alert(1)"), "#blocked-url");
assert_eq!(sanitize_url("JavaScript:alert(1)"), "#blocked-url");
assert_eq!(sanitize_url("JAVASCRIPT:alert(1)"), "#blocked-url");
assert_eq!(
sanitize_url("data:text/html,<script>alert(1)</script>"),
"#blocked-url"
);
assert_eq!(sanitize_url("Data:text/html,test"), "#blocked-url");
assert_eq!(sanitize_url("vbscript:msgbox(1)"), "#blocked-url");
assert_eq!(sanitize_url("VBScript:msgbox(1)"), "#blocked-url");
assert_eq!(sanitize_url("file:///etc/passwd"), "#blocked-url");
assert_eq!(sanitize_url("FILE:///C:/Windows"), "#blocked-url");
}
#[test]
fn test_sanitize_url_with_whitespace() {
assert_eq!(sanitize_url(" javascript:alert(1) "), "#blocked-url");
assert_eq!(sanitize_url("\tdata:text/html,test\n"), "#blocked-url");
assert_eq!(
sanitize_url(" https://example.com "),
" https://example.com "
);
}
#[test]
fn test_sanitize_url_removes_disallowed_blank_like_chars() {
assert_eq!(
sanitize_url("https://exa\u{200B}mple.com/\u{3164}path"),
"https://example.com/path"
);
}
#[test]
fn test_sanitize_url_blocks_scheme_after_normalization() {
assert_eq!(sanitize_url("java\u{200B}script:alert(1)"), "#blocked-url");
assert_eq!(sanitize_url("data:\u{FEFF}text/html,test"), "#blocked-url");
assert_eq!(sanitize_url("java\u{202E}script:alert(1)"), "#blocked-url");
}
#[test]
fn test_ascii_control_chars_removed_from_text() {
let input = "hello\x00\x01\x07\x08\x0B\x0C\x0E\x7Fworld";
assert_eq!(remove_ascii_control_chars_from_markup(input), "helloworld");
}
#[test]
fn test_ascii_control_chars_preserved_tab_lf_cr() {
let input = "col1\tcol2\nline2\r\nline3";
let result = remove_ascii_control_chars_from_markup(input);
assert!(result.contains('\t'));
assert!(result.contains('\n'));
}
#[test]
fn test_ascii_control_chars_preserved_inside_code_fence() {
let input = "text\n```\nhello\x01world\n```\nafter";
let result = remove_ascii_control_chars_from_markup(input);
assert!(result.contains("hello\x01world"));
assert!(result.contains("text"));
assert!(result.contains("after"));
}
#[test]
fn test_ascii_control_chars_removed_outside_code_fence() {
let input = "be\x01fore\n```\nclean\n```\naf\x01ter";
let result = remove_ascii_control_chars_from_markup(input);
assert!(result.contains("before"));
assert!(result.contains("after"));
}
#[test]
fn test_ascii_control_fast_path_no_change() {
let input = "hello world\n\ttab here";
assert_eq!(remove_ascii_control_chars_from_markup(input), input);
}
#[test]
fn test_tilde_fence_also_protected() {
let input = "~~~\nhello\x01world\n~~~\n";
let result = remove_ascii_control_chars_from_markup(input);
assert!(result.contains("hello\x01world"));
}
}