use chromiumoxide::Page;
use crate::cli::WcagLevel;
use crate::wcag::types::{RuleMetadata, Severity, Violation};
pub(super) const INPUT_PURPOSE_RULE: RuleMetadata = RuleMetadata {
id: "1.3.5",
name: "Identify Input Purpose",
level: WcagLevel::AA,
severity: Severity::Medium,
description: "The purpose of each input field can be programmatically determined",
help_url: "https://www.w3.org/WAI/WCAG22/Understanding/identify-input-purpose.html",
axe_id: "autocomplete-valid",
tags: &["wcag2aa", "wcag135", "cat.forms"],
};
const AUTOCOMPLETE_TOKENS: &[&str] = &[
"name",
"given-name",
"family-name",
"additional-name",
"honorific-prefix",
"honorific-suffix",
"nickname",
"username",
"new-password",
"current-password",
"one-time-code",
"email",
"tel",
"tel-national",
"street-address",
"address-line1",
"address-line2",
"address-line3",
"address-level1",
"address-level2",
"postal-code",
"country",
"country-name",
"cc-name",
"cc-number",
"cc-exp",
"cc-exp-month",
"cc-exp-year",
"cc-csc",
"cc-type",
"bday",
"bday-day",
"bday-month",
"bday-year",
"sex",
"url",
"organization",
"organization-title",
];
const USER_INPUT_TYPES: &[&str] = &["text", "email", "tel", "url", "search", "password"];
const USER_INFO_WORDS: &[&str] = &[
"email",
"phone",
"telephone",
"tel",
"fax",
"address",
"city",
"zip",
"zipcode",
"postcode",
"postal",
"country",
"password",
"username",
"first",
"last",
"birthday",
"birth",
"dob",
"telefon",
"handy",
"handynummer",
"mobilnummer",
"faxnummer",
"plz",
"postleitzahl",
"adresse",
"anschrift",
"strasse",
"straße",
"hausnummer",
"ort",
"wohnort",
"stadt",
"land",
"passwort",
"kennwort",
"geburtstag",
"geburtsdatum",
];
const USER_INFO_PREFIXES: &[&str] = &[
"telefon", "phone", "email", "postal", "postleit", "password", "passwort", "birth", "geburts",
];
const USER_INFO_SUFFIXES: &[&str] = &[
"adresse", "address", "telefon", "strasse", "straße", "passwort", "password", "kennwort",
];
fn label_asks_for_user_info(label: &str) -> bool {
let words = words(label);
words.iter().enumerate().any(|(i, word)| {
let w = word.as_str();
USER_INFO_WORDS.contains(&w)
|| USER_INFO_PREFIXES.iter().any(|p| w.starts_with(p))
|| USER_INFO_SUFFIXES.iter().any(|s| w.ends_with(s))
|| (w == "mail" && i > 0 && words[i - 1] == "e")
})
}
const PERSONAL_NAME_WORDS: &[&str] = &[
"firstname",
"lastname",
"fullname",
"surname",
"givenname",
"familyname",
"middlename",
"username",
"nickname",
"vorname",
"nachname",
"familienname",
"benutzername",
"geburtsname",
"rufname",
];
const PERSON_OR_NEUTRAL_QUALIFIERS: &[&str] = &[
"your",
"my",
"full",
"first",
"last",
"given",
"family",
"middle",
"legal",
"maiden",
"user",
"contact",
"customer",
"person",
"member",
"guest",
"applicant",
"author",
"cardholder",
"ihr",
"ihren",
"dein",
"deinen",
"vollständiger",
"voller",
"input",
"field",
"form",
"fld",
"txt",
"inp",
"text",
"billing",
"shipping",
"account",
"profile",
"signup",
"register",
"registration",
"checkout",
];
const THING_NAME_FOLLOWERS: &[&str] = &["of", "for", "der", "des", "für", "von"];
const INPUT_PURPOSE_JS: &str = r#"
(function() {
var types = ['text', 'email', 'tel', 'url', 'search', 'password'];
var out = [];
var inputs = document.querySelectorAll('input');
for (var i = 0; i < inputs.length; i++) {
var el = inputs[i];
var type = (el.getAttribute('type') || 'text').toLowerCase();
if (types.indexOf(type) === -1) continue;
if (el.getClientRects().length === 0 || el.closest('[aria-hidden="true"]')) continue;
var label = el.getAttribute('aria-label') || '';
if (!label && el.getAttribute('aria-labelledby')) {
label = el.getAttribute('aria-labelledby').split(/\s+/).map(function(id) {
var ref = document.getElementById(id);
return ref ? ref.textContent : '';
}).join(' ');
}
if (!label && el.labels && el.labels.length) label = el.labels[0].textContent;
if (!label) label = el.getAttribute('title') || el.getAttribute('placeholder') || '';
var selector = el.id ? '#' + CSS.escape(el.id)
: el.getAttribute('name') ? 'input[name="' + el.getAttribute('name') + '"]'
: 'input[type="' + type + '"]';
out.push({
type: type,
autocomplete: el.getAttribute('autocomplete'),
label: label.replace(/\s+/g, ' ').trim(),
selector: selector,
id: el.id || '',
name_attr: el.getAttribute('name') || ''
});
}
return out;
})()
"#;
#[derive(Debug, Clone, serde::Deserialize)]
struct InputCandidate {
#[serde(rename = "type")]
input_type: String,
autocomplete: Option<String>,
label: String,
selector: String,
#[serde(default)]
id: String,
#[serde(default)]
name_attr: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum NameReference {
Person,
Thing,
}
fn words(text: &str) -> Vec<String> {
let mut out = Vec::new();
let mut current = String::new();
let mut prev_lower = false;
for ch in text.chars() {
if !ch.is_alphanumeric() {
if !current.is_empty() {
out.push(std::mem::take(&mut current));
}
prev_lower = false;
continue;
}
if ch.is_uppercase() && prev_lower {
out.push(std::mem::take(&mut current));
}
prev_lower = ch.is_lowercase();
current.extend(ch.to_lowercase());
}
if !current.is_empty() {
out.push(current);
}
out
}
fn name_reference(text: &str) -> Option<NameReference> {
let words = words(text);
let mut result = None;
for (i, word) in words.iter().enumerate() {
if PERSONAL_NAME_WORDS.contains(&word.as_str()) {
return Some(NameReference::Person);
}
if word != "name" {
continue;
}
let qualifier = words[..i]
.iter()
.rev()
.find(|w| !w.chars().all(|c| c.is_ascii_digit()));
let qualified_by_thing =
qualifier.is_some_and(|q| !PERSON_OR_NEUTRAL_QUALIFIERS.contains(&q.as_str()));
let followed_by_thing = words
.get(i + 1)
.is_some_and(|next| THING_NAME_FOLLOWERS.contains(&next.as_str()));
if qualified_by_thing || followed_by_thing {
result = Some(NameReference::Thing);
} else {
return Some(NameReference::Person);
}
}
result
}
fn asks_for_personal_name(c: &InputCandidate) -> bool {
if name_reference(&c.label) != Some(NameReference::Person) {
return false;
}
let attr_refs = [name_reference(&c.id), name_reference(&c.name_attr)];
attr_refs.contains(&Some(NameReference::Person))
|| !attr_refs.contains(&Some(NameReference::Thing))
}
pub async fn check_input_purpose_with_page(page: &Page) -> Vec<Violation> {
let Ok(result) = page.evaluate(INPUT_PURPOSE_JS).await else {
return Vec::new();
};
let Some(candidates) = result
.value()
.and_then(|v| serde_json::from_value::<Vec<InputCandidate>>(v.clone()).ok())
else {
return Vec::new();
};
evaluate(&candidates)
}
fn evaluate(candidates: &[InputCandidate]) -> Vec<Violation> {
let mut violations = Vec::new();
for c in candidates {
if !USER_INPUT_TYPES.contains(&c.input_type.as_str()) {
continue;
}
let ac_value = c
.autocomplete
.as_deref()
.unwrap_or("")
.trim()
.to_lowercase();
let has_autocomplete = !ac_value.is_empty() && ac_value != "off" && ac_value != "on";
if has_autocomplete {
let last_token = ac_value.split_whitespace().last().unwrap_or("");
if !AUTOCOMPLETE_TOKENS.contains(&last_token) {
violations.push(
Violation::new(
INPUT_PURPOSE_RULE.id,
INPUT_PURPOSE_RULE.name,
INPUT_PURPOSE_RULE.level,
Severity::Low,
format!("Invalid autocomplete value: '{}'", ac_value),
c.selector.clone(),
)
.with_selector(c.selector.clone())
.with_name(Some(c.label.clone()))
.with_fix("Use a valid autocomplete token from the HTML specification")
.with_help_url(INPUT_PURPOSE_RULE.help_url)
.with_rule_id(INPUT_PURPOSE_RULE.axe_id),
);
}
continue;
}
let label_lower = c.label.to_lowercase();
if label_asks_for_user_info(&c.label) || asks_for_personal_name(c) {
violations.push(
Violation::new(
INPUT_PURPOSE_RULE.id,
INPUT_PURPOSE_RULE.name,
INPUT_PURPOSE_RULE.level,
Severity::Medium,
format!(
"Input '{}' appears to collect user info but lacks autocomplete attribute",
label_lower
),
c.selector.clone(),
)
.with_selector(c.selector.clone())
.with_name(Some(c.label.clone()))
.with_fix(
"Add an appropriate autocomplete attribute (e.g., autocomplete=\"email\")",
)
.with_help_url(INPUT_PURPOSE_RULE.help_url)
.with_rule_id(INPUT_PURPOSE_RULE.axe_id),
);
}
}
violations
}
#[cfg(test)]
mod tests {
use super::*;
fn input(label: &str, autocomplete: Option<&str>) -> InputCandidate {
InputCandidate {
input_type: "text".to_string(),
autocomplete: autocomplete.map(str::to_string),
label: label.to_string(),
selector: "#x".to_string(),
id: String::new(),
name_attr: String::new(),
}
}
fn named_input(label: &str, id: &str, name_attr: &str) -> InputCandidate {
InputCandidate {
id: id.to_string(),
name_attr: name_attr.to_string(),
..input(label, None)
}
}
#[test]
fn thing_qualifier_in_id_rules_out_bare_name_label() {
assert!(evaluate(&[named_input("Name", "view-name", "")]).is_empty());
assert!(evaluate(&[named_input("Name", "", "projectName")]).is_empty());
}
#[test]
fn thing_names_in_label_are_not_personal() {
for label in [
"View name",
"File name",
"Filename",
"Project name",
"Company name",
"Name of the view",
"Dateiname",
"Projektname",
"Name der Ansicht",
"Rename",
] {
assert!(evaluate(&[input(label, None)]).is_empty(), "{label}");
}
}
#[test]
fn personal_name_fields_still_flagged() {
for label in [
"Name",
"Your name",
"Full name",
"First name",
"Last Name *",
"Surname",
"Vorname",
"Nachname",
"Ihr Name",
"Benutzername",
] {
assert_eq!(evaluate(&[input(label, None)]).len(), 1, "{label}");
}
}
#[test]
fn neutral_id_parts_keep_personal_name() {
assert_eq!(evaluate(&[named_input("Name", "input-name", "")]).len(), 1);
assert_eq!(
evaluate(&[named_input("Name", "billing_first_name", "")]).len(),
1
);
assert_eq!(
evaluate(&[named_input("Name", "field-2-name", "")]).len(),
1
);
assert_eq!(
evaluate(&[named_input("Name", "view-name", "fullName")]).len(),
1
);
}
#[test]
fn whole_word_keywords_flagged() {
for label in [
"Email",
"E-Mail",
"Phone",
"Tel.",
"Tel.-Nr.",
"Fax",
"Street address",
"City",
"ZIP code",
"Zip/Postal code",
"Country",
"Password",
"Username",
"First",
"Date of birth",
"PLZ",
"Ort",
"Straße",
"Land",
"Passwort",
"Geburtstag",
] {
assert_eq!(evaluate(&[input(label, None)]).len(), 1, "{label}");
}
}
#[test]
fn compound_keywords_flagged() {
for label in [
"Telefonnummer",
"Postleitzahl",
"Handynummer",
"E-Mail-Adresse",
"Emailadresse",
"EmailAddress",
"phoneNumber",
"Lieferadresse",
"Birthdate",
"Geburtsdatum",
"Festnetztelefon",
"Hauptstrasse",
"Neues Kennwort",
] {
assert_eq!(evaluate(&[input(label, None)]).len(), 1, "{label}");
}
}
#[test]
fn keywords_inside_other_words_not_flagged() {
for label in [
"Hotel name",
"Hotel",
"Title",
"Hostel",
"Zipper size",
"Cityscape",
"Lastly",
"Firstly",
"Countryside",
"Portal",
"Sorting",
"Landing page",
] {
assert!(evaluate(&[input(label, None)]).is_empty(), "{label}");
}
}
#[test]
fn words_split_camel_case_and_separators() {
assert_eq!(words("viewName"), ["view", "name"]);
assert_eq!(words("billing_first-name"), ["billing", "first", "name"]);
assert_eq!(words("FIRSTNAME"), ["firstname"]);
}
#[test]
fn test_valid_autocomplete() {
assert!(evaluate(&[input("Email", Some("email"))]).is_empty());
}
#[test]
fn test_missing_autocomplete_on_email_field() {
let v = evaluate(&[input("Email Address", None)]);
assert_eq!(v.len(), 1);
assert_eq!(v[0].rule, "1.3.5");
}
#[test]
fn test_invalid_autocomplete_token() {
assert_eq!(evaluate(&[input("Name", Some("foobar"))]).len(), 1);
}
#[test]
fn test_generic_input_no_violation() {
assert!(evaluate(&[input("Search query", None)]).is_empty());
}
#[test]
fn prefixed_token_is_valid() {
assert!(evaluate(&[input("Street", Some("shipping street-address"))]).is_empty());
}
#[test]
fn off_counts_as_missing() {
assert_eq!(evaluate(&[input("Email", Some("off"))]).len(), 1);
}
}