pub(crate) fn escape_html(text: &str) -> String {
let mut result = String::with_capacity(text.len());
for ch in text.chars() {
match ch {
'&' => result.push_str("&"),
'<' => result.push_str("<"),
'>' => result.push_str(">"),
'"' => result.push_str("""),
'\'' => result.push_str("'"),
_ => result.push(ch),
}
}
result
}
pub(crate) fn escape_html_attr(text: &str) -> String {
escape_html(text)
}
pub(crate) fn hex_color(value: &str) -> Option<String> {
if value.eq_ignore_ascii_case("auto") {
return None;
}
let digits = value.strip_prefix('#').unwrap_or(value);
if !matches!(digits.len(), 3 | 6 | 8) {
return None;
}
if !digits.bytes().all(|b| b.is_ascii_hexdigit()) {
return None;
}
Some(format!("#{digits}"))
}
pub(crate) fn font_family(name: &str) -> Option<String> {
let trimmed = name.trim();
if trimmed.is_empty() {
return None;
}
let safe = trimmed.chars().all(|c| {
!c.is_control()
&& !matches!(
c,
'\'' | '"' | ';' | '{' | '}' | '(' | ')' | '<' | '>' | '&' | '\\' | '/'
)
});
if !safe {
return None;
}
Some(format!("'{trimmed}'"))
}
const SAFE_URL_SCHEMES: &[&str] = &["http", "https", "mailto", "tel", "ftp", "ftps"];
pub(crate) fn safe_url(url: &str) -> Option<&str> {
let trimmed = url.trim();
if trimmed.is_empty() {
return None;
}
let Some(scheme_end) = trimmed.find(':') else {
return Some(trimmed);
};
let scheme = &trimmed[..scheme_end];
let looks_like_scheme = !scheme.is_empty()
&& scheme.starts_with(|c: char| c.is_ascii_alphabetic())
&& scheme
.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.'));
if !looks_like_scheme {
return Some(trimmed);
}
SAFE_URL_SCHEMES
.iter()
.any(|s| scheme.eq_ignore_ascii_case(s))
.then_some(trimmed)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn colors_must_be_hex() {
assert_eq!(hex_color("FF0000").as_deref(), Some("#FF0000"));
assert_eq!(hex_color("#abc").as_deref(), Some("#abc"));
assert_eq!(hex_color("11223344").as_deref(), Some("#11223344"));
assert_eq!(hex_color("auto"), None);
assert_eq!(hex_color("red"), None);
assert_eq!(hex_color("FF0000;} body{display:none"), None);
assert_eq!(hex_color("\" onload=\"alert(1)"), None);
}
#[test]
fn font_names_cannot_break_out() {
assert_eq!(
font_family("Times New Roman").as_deref(),
Some("'Times New Roman'")
);
assert_eq!(font_family("Arial\" onmouseover=\"alert(1)"), None);
assert_eq!(font_family("Arial'; background:url(x); '"), None);
assert_eq!(font_family(" "), None);
}
#[test]
fn only_safe_url_schemes_pass() {
assert_eq!(safe_url("https://example.com"), Some("https://example.com"));
assert_eq!(safe_url("mailto:a@b.c"), Some("mailto:a@b.c"));
assert_eq!(safe_url("#anchor"), Some("#anchor"));
assert_eq!(safe_url("page.html"), Some("page.html"));
assert_eq!(safe_url("javascript:alert(1)"), None);
assert_eq!(safe_url("JaVaScRiPt:alert(1)"), None);
assert_eq!(safe_url("data:text/html;base64,PHNjcmlwdD4="), None);
assert_eq!(safe_url(" javascript:alert(1) "), None);
assert_eq!(safe_url(""), None);
}
#[test]
fn escaping_covers_quote_characters() {
assert_eq!(
escape_html("<a href=\"x\">&'"),
"<a href="x">&'"
);
}
}