use url::Url;
fn is_whitespace(c: char) -> bool {
matches!(c, ' ' | '\t' | '\x0c' | '\n' | '\r')
}
fn split_ws_set(value: &str) -> impl Iterator<Item = &str> {
value.split(is_whitespace).filter(|token| !token.is_empty())
}
pub(crate) fn check_custom_element_name(value: &str) -> Result<(), String> {
const PROHIBITED_NAMES: &[&str] = &[
"annotation-xml",
"color-profile",
"font-face",
"font-face-format",
"font-face-name",
"font-face-src",
"font-face-uri",
"missing-glyph",
];
let mut chars = value.chars();
match chars.next() {
Some(c) if c.is_ascii_lowercase() => {}
_ => {
return Err(format!(
"custom element name must start with a lowercase ASCII letter: {value:?}"
));
}
}
let mut has_hyphen = false;
for c in chars {
if c == '-' {
has_hyphen = true;
continue;
}
if c.is_ascii_uppercase() || is_whitespace(c) || c == '\0' || c == '/' || c == '>' {
return Err(format!(
"invalid character {c:?} in custom element name: {value:?}"
));
}
}
if !has_hyphen {
return Err(format!(
"custom element name must contain a hyphen: {value:?}"
));
}
if PROHIBITED_NAMES.contains(&value) {
return Err(format!(
"custom element name is a reserved built-in name: {value:?}"
));
}
Ok(())
}
pub(crate) fn check_autocomplete_any(value: &str) -> Result<(), String> {
const CONTACT_TYPES: &[&str] = &["home", "work", "mobile", "fax", "pager"];
const FIELD_NAMES: &[&str] = &[
"name",
"honorific-prefix",
"given-name",
"additional-name",
"family-name",
"honorific-suffix",
"nickname",
"username",
"new-password",
"current-password",
"one-time-code",
"organization-title",
"organization",
"street-address",
"address-line1",
"address-line2",
"address-line3",
"address-level4",
"address-level3",
"address-level2",
"address-level1",
"country",
"country-name",
"postal-code",
"cc-name",
"cc-given-name",
"cc-additional-name",
"cc-family-name",
"cc-number",
"cc-exp",
"cc-exp-month",
"cc-exp-year",
"cc-csc",
"cc-type",
"transaction-currency",
"transaction-amount",
"language",
"bday",
"bday-day",
"bday-month",
"bday-year",
"sex",
"url",
"photo",
"tel",
"tel-country-code",
"tel-national",
"tel-area-code",
"tel-local",
"tel-local-prefix",
"tel-local-suffix",
"tel-extension",
"email",
"impp",
];
let trimmed = value.trim_matches(is_whitespace);
if trimmed.is_empty() {
return Err("autocomplete value must not be empty".to_string());
}
let tokens: Vec<String> = split_ws_set(trimmed).map(str::to_ascii_lowercase).collect();
let mut idx = 0;
if idx < tokens.len() && tokens[idx].starts_with("section-") {
idx += 1;
}
if idx < tokens.len() && (tokens[idx] == "shipping" || tokens[idx] == "billing") {
idx += 1;
}
if idx < tokens.len() && CONTACT_TYPES.contains(&tokens[idx].as_str()) {
idx += 1;
}
let remaining = &tokens[idx..];
if remaining.is_empty() {
return Err("autocomplete value has no field-name token".to_string());
}
let last = remaining.len() - 1;
let mut field_name_count = 0;
for (i, token) in remaining.iter().enumerate() {
if token == "webauthn" {
if i != last {
return Err("\"webauthn\" is only valid as the sole or last token".to_string());
}
continue;
}
if token.starts_with("section-")
|| token == "shipping"
|| token == "billing"
|| CONTACT_TYPES.contains(&token.as_str())
{
return Err(format!(
"token {token:?} is only valid earlier in the autocomplete sequence"
));
}
if !FIELD_NAMES.contains(&token.as_str()) {
return Err(format!("unknown autocomplete field name: {token:?}"));
}
field_name_count += 1;
}
if field_name_count == 0 && (tokens.len() != 1 || tokens[0] != "webauthn") {
return Err("autocomplete value has no field-name token".to_string());
}
if field_name_count > 1 {
return Err("autocomplete value has more than one field-name token".to_string());
}
Ok(())
}
pub(crate) fn check_browsing_context(value: &str) -> Result<(), String> {
if value.is_empty() {
return Err("browsing context name must not be empty".to_string());
}
if value.starts_with('_') {
return Err("browsing context name must not start with '_'".to_string());
}
Ok(())
}
pub(crate) fn check_browsing_context_or_keyword(value: &str) -> Result<(), String> {
if value.is_empty() {
return Err("browsing context name or keyword must not be empty".to_string());
}
if let Some(rest) = value.strip_prefix('_') {
match rest.to_ascii_lowercase().as_str() {
"blank" | "self" | "top" | "parent" => Ok(()),
_ => Err(format!(
"browsing context keyword must be one of _blank/_self/_top/_parent: {value:?}"
)),
}
} else {
Ok(())
}
}
pub(crate) fn check_keylabellist(value: &str) -> Result<(), String> {
let mut seen: Vec<&str> = Vec::new();
for token in value.split_whitespace() {
if token.chars().count() != 1 {
return Err(format!(
"key label token must be exactly one character: {token:?}"
));
}
if seen.contains(&token) {
return Err(format!("duplicate key label token: {token:?}"));
}
seen.push(token);
}
Ok(())
}
const LINK_RELATIONS: &[&str] = &[
"about",
"acl",
"alternate",
"amphtml",
"api-catalog",
"appendix",
"apple-touch-icon",
"apple-touch-startup-image",
"archives",
"author",
"blocked-by",
"bookmark",
"c2pa-manifest",
"canonical",
"chapter",
"cite-as",
"collection",
"compression-dictionary",
"contents",
"convertedfrom",
"copyright",
"create-form",
"current",
"deprecation",
"describedby",
"describes",
"disclosure",
"dns-prefetch",
"duplicate",
"edit",
"edit-form",
"edit-media",
"enclosure",
"external",
"first",
"geofeed",
"glossary",
"help",
"hosts",
"hub",
"ice-server",
"icon",
"index",
"intervalafter",
"intervalbefore",
"intervalcontains",
"intervaldisjoint",
"intervalduring",
"intervalequals",
"intervalfinishedby",
"intervalfinishes",
"intervalin",
"intervalmeets",
"intervalmetby",
"intervaloverlappedby",
"intervaloverlaps",
"intervalstartedby",
"intervalstarts",
"item",
"last",
"latest-version",
"license",
"linkset",
"lrdd",
"manifest",
"mask-icon",
"me",
"media-feed",
"memento",
"micropub",
"modulepreload",
"monitor",
"monitor-group",
"next",
"next-archive",
"nofollow",
"noopener",
"noreferrer",
"opener",
"openid2.local_id",
"openid2.provider",
"original",
"p3pv1",
"payment",
"pingback",
"preconnect",
"predecessor-version",
"prefetch",
"preload",
"prerender",
"prev",
"prev-archive",
"preview",
"previous",
"privacy-policy",
"profile",
"publication",
"rdap-active",
"rdap-bottom",
"rdap-down",
"rdap-top",
"rdap-up",
"related",
"replies",
"restconf",
"ruleinput",
"search",
"section",
"self",
"service",
"service-desc",
"service-doc",
"service-meta",
"sip-trunking-capability",
"sitemap",
"sponsored",
"start",
"status",
"stylesheet",
"subsection",
"successor-version",
"sunset",
"tag",
"terms-of-service",
"timegate",
"timemap",
"type",
"ugc",
"up",
"version-history",
"via",
"webmention",
"working-copy",
"working-copy-of",
];
const REL_TYPO_MAX_DISTANCE: usize = 2;
fn find_closest_rel_typo(token: &str) -> Option<&'static str> {
let mut best: Option<(&'static str, usize)> = None;
for &candidate in LINK_RELATIONS {
if candidate.len() <= 3 {
continue;
}
let distance = levenshtein_distance(token, candidate);
if distance == 0 || distance > REL_TYPO_MAX_DISTANCE {
continue;
}
if token.len().abs_diff(candidate.len()) > 2 {
continue;
}
let same_start = token.as_bytes().first() == candidate.as_bytes().first();
let same_end = token.as_bytes().last() == candidate.as_bytes().last();
if !same_start && !same_end {
continue;
}
if best.is_none_or(|(_, best_distance)| distance < best_distance) {
best = Some((candidate, distance));
}
}
best.map(|(candidate, _)| candidate)
}
fn levenshtein_distance(a: &str, b: &str) -> usize {
let a = a.as_bytes();
let b = b.as_bytes();
let mut previous_row: Vec<usize> = (0..=b.len()).collect();
let mut current_row = vec![0usize; b.len() + 1];
for (i, &a_byte) in a.iter().enumerate() {
current_row[0] = i + 1;
for (j, &b_byte) in b.iter().enumerate() {
let cost = usize::from(a_byte != b_byte);
current_row[j + 1] = (previous_row[j + 1] + 1)
.min(current_row[j] + 1)
.min(previous_row[j] + cost);
}
std::mem::swap(&mut previous_row, &mut current_row);
}
previous_row[b.len()]
}
pub(crate) fn check_rel_value(value: &str) -> Result<(), String> {
let mut seen: Vec<&str> = Vec::new();
for token in split_ws_set(value) {
let stripped = token.strip_prefix(':').unwrap_or(token);
if seen.contains(&stripped) {
return Err(format!("duplicate rel-value token: {stripped:?}"));
}
seen.push(stripped);
if stripped.len() <= 3 {
continue;
}
let lower = stripped.to_ascii_lowercase();
if LINK_RELATIONS.contains(&lower.as_str()) {
continue;
}
if let Some(closest) = find_closest_rel_typo(&lower) {
return Err(format!(
"rel-value {stripped:?} looks like a typo for {closest:?}"
));
}
}
Ok(())
}
pub(crate) fn check_sandbox_allow_list(value: &str) -> Result<(), String> {
const SANDBOX_KEYWORDS: &[&str] = &[
"allow-downloads",
"allow-forms",
"allow-modals",
"allow-orientation-lock",
"allow-pointer-lock",
"allow-popups",
"allow-popups-to-escape-sandbox",
"allow-presentation",
"allow-same-origin",
"allow-scripts",
"allow-top-navigation",
"allow-top-navigation-by-user-activation",
"allow-top-navigation-to-custom-protocols",
];
let mut seen: Vec<String> = Vec::new();
for token in split_ws_set(value) {
let lower = token.to_ascii_lowercase();
if seen.iter().any(|s| s == &lower) {
return Err(format!("duplicate sandbox keyword: {lower:?}"));
}
if !SANDBOX_KEYWORDS.contains(&lower.as_str()) {
return Err(format!("unknown sandbox keyword: {lower:?}"));
}
seen.push(lower);
}
let has = |keyword: &str| seen.iter().any(|s| s == keyword);
if has("allow-scripts") && has("allow-same-origin") {
return Err("sandbox must not combine allow-scripts with allow-same-origin".to_string());
}
if has("allow-top-navigation") && has("allow-top-navigation-by-user-activation") {
return Err(
"sandbox must not combine allow-top-navigation with allow-top-navigation-by-user-activation"
.to_string(),
);
}
Ok(())
}
fn is_mime_token_char(c: char) -> bool {
matches!(c as u32, 33..=126)
&& !matches!(
c,
'(' | ')'
| '<'
| '>'
| '@'
| ','
| ';'
| ':'
| '\\'
| '"'
| '/'
| '['
| ']'
| '?'
| '='
| '{'
| '}'
)
}
fn take_mime_token(chars: &mut std::iter::Peekable<std::str::Chars>) -> String {
let mut token = String::new();
while let Some(&c) = chars.peek() {
if is_mime_token_char(c) {
token.push(c);
chars.next();
} else {
break;
}
}
token
}
pub(crate) fn check_script_type(value: &str) -> Result<(), String> {
const JS_MIME_TYPES: &[&str] = &[
"application/ecmascript",
"application/javascript",
"application/x-ecmascript",
"application/x-javascript",
"text/ecmascript",
"text/javascript",
"text/javascript1.0",
"text/javascript1.1",
"text/javascript1.2",
"text/javascript1.3",
"text/javascript1.4",
"text/javascript1.5",
"text/jscript",
"text/livescript",
"text/x-ecmascript",
"text/x-javascript",
];
let mut chars = value.chars().peekable();
let media_type = take_mime_token(&mut chars);
if media_type.is_empty() {
return Err(format!("script type is missing a MIME type: {value:?}"));
}
if chars.next() != Some('/') {
return Err(format!("script type is missing '/': {value:?}"));
}
let subtype = take_mime_token(&mut chars);
if subtype.is_empty() {
return Err(format!("script type is missing a subtype: {value:?}"));
}
let full_type = format!("{media_type}/{subtype}");
let is_js_type = JS_MIME_TYPES.contains(&full_type.as_str());
if chars.peek().is_none() {
return Ok(());
}
if is_js_type {
return Err(format!(
"JavaScript script type {full_type:?} must not have parameters: {value:?}"
));
}
loop {
match chars.next() {
Some(';') => {}
Some(c) => {
return Err(format!(
"unexpected character {c:?} in script type: {value:?}"
));
}
None => break,
}
while chars.peek().is_some_and(|&c| is_whitespace(c)) {
chars.next();
}
let name = take_mime_token(&mut chars);
if name.is_empty() {
return Err(format!(
"script type parameter is missing a name: {value:?}"
));
}
if chars.next() != Some('=') {
return Err(format!("script type parameter is missing '=': {value:?}"));
}
if chars.peek() == Some(&'"') {
chars.next();
loop {
match chars.next() {
Some('\\') => {
if chars.next().is_none() {
return Err(format!(
"script type parameter has a dangling escape: {value:?}"
));
}
}
Some('"') => break,
Some(_) => {}
None => {
return Err(format!(
"script type parameter has an unterminated quoted string: {value:?}"
));
}
}
}
} else {
let param_value = take_mime_token(&mut chars);
if param_value.is_empty() {
return Err(format!(
"script type parameter is missing a value: {value:?}"
));
}
}
while chars.peek().is_some_and(|&c| is_whitespace(c)) {
chars.next();
}
if chars.peek().is_none() {
break;
}
}
Ok(())
}
pub(crate) fn check_microdata_property(value: &str) -> Result<(), String> {
if value.contains('.') || value.contains(':') {
if Url::parse(value).is_ok() {
Ok(())
} else {
Err(format!(
"microdata property containing '.' or ':' must be an absolute URL: {value:?}"
))
}
} else {
Ok(())
}
}
pub(crate) fn check_simple_color(value: &str) -> Result<(), String> {
let chars: Vec<char> = value.chars().collect();
if chars.len() != 7 {
return Err(format!(
"simple color must be exactly 7 characters: {value:?}"
));
}
if chars[0] != '#' {
return Err(format!("simple color must start with '#': {value:?}"));
}
if !chars[1..].iter().all(char::is_ascii_hexdigit) {
return Err(format!(
"simple color must be '#' followed by 6 hex digits: {value:?}"
));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn custom_element_name_accepts_valid_name() {
assert!(check_custom_element_name("my-element").is_ok());
}
#[test]
fn custom_element_name_rejects_missing_hyphen() {
assert!(check_custom_element_name("myelement").is_err());
}
#[test]
fn custom_element_name_rejects_uppercase() {
assert!(check_custom_element_name("my-Element").is_err());
}
#[test]
fn custom_element_name_rejects_reserved_name() {
assert!(check_custom_element_name("font-face").is_err());
}
#[test]
fn autocomplete_any_accepts_simple_field_name() {
assert!(check_autocomplete_any("email").is_ok());
}
#[test]
fn autocomplete_any_accepts_webauthn_alone() {
assert!(check_autocomplete_any("webauthn").is_ok());
}
#[test]
fn autocomplete_any_rejects_webauthn_not_last() {
assert!(check_autocomplete_any("webauthn name").is_err());
}
#[test]
fn autocomplete_any_rejects_unknown_field_name() {
assert!(check_autocomplete_any("not-a-field").is_err());
}
#[test]
fn autocomplete_any_rejects_two_field_names() {
assert!(check_autocomplete_any("name email").is_err());
}
#[test]
fn browsing_context_or_keyword_accepts_blank() {
assert!(check_browsing_context_or_keyword("_blank").is_ok());
}
#[test]
fn browsing_context_or_keyword_rejects_bogus_keyword() {
assert!(check_browsing_context_or_keyword("_bogus").is_err());
}
#[test]
fn browsing_context_or_keyword_accepts_plain_name() {
assert!(check_browsing_context_or_keyword("my-frame").is_ok());
}
#[test]
fn keylabellist_rejects_duplicate_tokens() {
assert!(check_keylabellist("a b a").is_err());
}
#[test]
fn keylabellist_rejects_multi_char_token() {
assert!(check_keylabellist("a ab").is_err());
}
#[test]
fn keylabellist_accepts_distinct_single_char_tokens() {
assert!(check_keylabellist("a b c").is_ok());
}
#[test]
fn rel_value_rejects_duplicate_token() {
assert!(check_rel_value("icon icon").is_err());
}
#[test]
fn rel_value_accepts_short_token_always() {
assert!(check_rel_value("xyz").is_ok());
}
#[test]
fn rel_value_accepts_unknown_non_duplicate_token() {
assert!(check_rel_value("totally-unknown-relation").is_ok());
}
#[test]
fn rel_value_flags_likely_typo() {
assert!(check_rel_value("alternat").is_err());
assert!(check_rel_value("styleshet").is_err());
assert!(check_rel_value("authr").is_err());
assert!(check_rel_value("canonicl").is_err());
}
#[test]
fn rel_value_accepts_exact_known_relation() {
assert!(check_rel_value("alternate stylesheet").is_ok());
}
#[test]
fn rel_value_accepts_exact_known_relation_case_insensitively() {
assert!(check_rel_value("ALTERNATE").is_ok());
}
#[test]
fn levenshtein_distance_matches_known_values() {
assert_eq!(levenshtein_distance("kitten", "sitting"), 3);
assert_eq!(levenshtein_distance("alternate", "alternate"), 0);
assert_eq!(levenshtein_distance("alternat", "alternate"), 1);
}
#[test]
fn find_closest_rel_typo_returns_none_for_an_exact_match() {
assert_eq!(find_closest_rel_typo("alternate"), None);
}
#[test]
fn find_closest_rel_typo_finds_the_expected_candidate() {
assert_eq!(find_closest_rel_typo("alternat"), Some("alternate"));
assert_eq!(find_closest_rel_typo("styleshet"), Some("stylesheet"));
}
#[test]
fn sandbox_allow_list_rejects_unknown_keyword() {
assert!(check_sandbox_allow_list("allow-bogus").is_err());
}
#[test]
fn sandbox_allow_list_rejects_duplicate() {
assert!(check_sandbox_allow_list("allow-forms allow-forms").is_err());
}
#[test]
fn sandbox_allow_list_rejects_scripts_and_same_origin() {
assert!(check_sandbox_allow_list("allow-scripts allow-same-origin").is_err());
}
#[test]
fn sandbox_allow_list_rejects_conflicting_top_navigation() {
assert!(
check_sandbox_allow_list(
"allow-top-navigation allow-top-navigation-by-user-activation"
)
.is_err()
);
}
#[test]
fn script_type_accepts_plain_javascript() {
assert!(check_script_type("text/javascript").is_ok());
}
#[test]
fn script_type_rejects_javascript_with_params() {
assert!(check_script_type("text/javascript;charset=utf-8").is_err());
}
#[test]
fn script_type_accepts_non_js_type_with_params() {
assert!(check_script_type("text/plain;charset=utf-8").is_ok());
}
#[test]
fn microdata_property_accepts_bare_token() {
assert!(check_microdata_property("name").is_ok());
}
#[test]
fn microdata_property_accepts_valid_absolute_url() {
assert!(check_microdata_property("https://example.com/prop").is_ok());
}
#[test]
fn microdata_property_rejects_invalid_url_with_colon() {
assert!(check_microdata_property("not a url:").is_err());
}
#[test]
fn simple_color_accepts_valid_hex() {
assert!(check_simple_color("#ff0000").is_ok());
}
#[test]
fn simple_color_rejects_wrong_length() {
assert!(check_simple_color("#fff").is_err());
}
#[test]
fn simple_color_rejects_non_hex_char() {
assert!(check_simple_color("#gg0000").is_err());
}
}