use chromiumoxide::Page;
use crate::cli::WcagLevel;
use crate::wcag::types::{Outcome, RuleMetadata, Severity, Violation};
pub(super) const IDENTIFY_PURPOSE_RULE: RuleMetadata = RuleMetadata {
id: "1.3.6",
name: "Identify Purpose",
level: WcagLevel::AAA,
severity: Severity::Low,
description: "Purpose of UI components, icons, and regions can be programmatically determined",
help_url: "https://www.w3.org/WAI/WCAG22/Understanding/identify-purpose.html",
axe_id: "identify-purpose",
tags: &["wcag2aaa", "wcag136", "cat.semantics"],
};
const IDENTIFY_PURPOSE_JS: &str = r#"
(function() {
var excluded_types = ['hidden', 'submit', 'button', 'checkbox', 'radio', 'password', 'file', 'image', 'reset'];
var candidates = [];
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 (excluded_types.indexOf(type) !== -1) {
continue;
}
candidates.push({
name: el.getAttribute('name') || '',
id: el.getAttribute('id') || '',
type: type,
has_autocomplete: !!el.getAttribute('autocomplete'),
// Its report names the input, not a CSS path, so the exclusion filter
// (#645) cannot locate it later; mark it here instead.
excluded: !!(typeof window.__amsIsExcluded === 'function' && window.__amsIsExcluded(el))
});
}
return { candidates: candidates };
})()
"#;
fn split_words(s: &str) -> Vec<String> {
let mut words = Vec::new();
let mut current = String::new();
let chars: Vec<char> = s.chars().collect();
for (i, &c) in chars.iter().enumerate() {
if c.is_alphabetic() {
if c.is_uppercase() && i > 0 && chars[i - 1].is_lowercase() && !current.is_empty() {
words.push(std::mem::take(&mut current));
}
current.extend(c.to_lowercase());
} else if !current.is_empty() {
words.push(std::mem::take(&mut current));
}
}
if !current.is_empty() {
words.push(current);
}
words
}
const PERSONAL_DATA_TOKENS: &[&str] = &[
"name",
"vorname",
"nachname",
"firstname",
"lastname",
"fullname",
"givenname",
"familyname",
"surname",
"address",
"adresse",
"street",
"strasse",
"straße",
"hausnummer",
"housenumber",
"plz",
"postleitzahl",
"postcode",
"postalcode",
"postal",
"zip",
"zipcode",
"city",
"ort",
"stadt",
"town",
"country",
"land",
"region",
"state",
"bundesland",
"phone",
"telefon",
"telefonnummer",
"handynummer",
"handy",
"mobil",
"mobilnummer",
"email",
"mail",
];
const NON_PERSONAL_ADDRESS_PREFIXES: &[&str] = &["ip", "mac", "wallet", "contract"];
fn has_personal_data_token(identifier: &str) -> bool {
let words = split_words(identifier);
for (i, word) in words.iter().enumerate() {
if word == "address" || word == "adresse" {
let preceded_by_non_personal =
i > 0 && NON_PERSONAL_ADDRESS_PREFIXES.contains(&words[i - 1].as_str());
if preceded_by_non_personal {
continue;
}
return true;
}
if PERSONAL_DATA_TOKENS.contains(&word.as_str()) {
return true;
}
}
false
}
fn is_personal_data_input(input_type: &str, name: &str, id: &str) -> bool {
if input_type == "email" || input_type == "tel" {
return true;
}
has_personal_data_token(name) || has_personal_data_token(id)
}
pub async fn check_identify_purpose_with_page(page: &Page) -> Vec<Violation> {
let not_testable = Violation::new(
IDENTIFY_PURPOSE_RULE.id,
IDENTIFY_PURPOSE_RULE.name,
IDENTIFY_PURPOSE_RULE.level,
Severity::Low,
"Full WCAG 1.3.6 compliance (identify purpose of icons and regions) requires manual review. \
Automated checks only cover inputs missing autocomplete tokens.",
"page",
)
.with_fix(
"Add appropriate ARIA landmark roles and autocomplete attributes to all interactive \
components that collect personal information.",
)
.with_rule_id(IDENTIFY_PURPOSE_RULE.axe_id)
.with_help_url(IDENTIFY_PURPOSE_RULE.help_url)
.with_kind(Outcome::Untested);
let result = match page.evaluate(IDENTIFY_PURPOSE_JS).await {
Ok(r) => r,
Err(_) => return vec![not_testable],
};
let val = match result.value() {
Some(v) => v.clone(),
None => return vec![not_testable],
};
let candidates = val.get("candidates").and_then(|v| v.as_array());
let missing: Vec<(String, bool)> = candidates
.map(|arr| {
arr.iter()
.filter_map(|c| {
let name = c.get("name").and_then(|v| v.as_str()).unwrap_or("");
let id = c.get("id").and_then(|v| v.as_str()).unwrap_or("");
let input_type = c.get("type").and_then(|v| v.as_str()).unwrap_or("text");
let has_autocomplete = c
.get("has_autocomplete")
.and_then(|v| v.as_bool())
.unwrap_or(false);
if has_autocomplete || !is_personal_data_input(input_type, name, id) {
return None;
}
let desc = if !name.is_empty() {
name
} else if !id.is_empty() {
id
} else {
input_type
};
let excluded = c.get("excluded").and_then(|v| v.as_bool()) == Some(true);
Some((desc.to_string(), excluded))
})
.collect()
})
.unwrap_or_default();
let mut findings = vec![not_testable];
let real = missing.iter().filter(|(_, excluded)| !excluded).take(5);
let excluded = missing.iter().filter(|(_, excluded)| *excluded).take(5);
for (name, in_excluded_subtree) in real.chain(excluded) {
let mut finding = Violation::new(
IDENTIFY_PURPOSE_RULE.id,
IDENTIFY_PURPOSE_RULE.name,
IDENTIFY_PURPOSE_RULE.level,
Severity::Low,
format!(
"Input '{}' collects personal information but has no autocomplete attribute.",
name
),
name.as_str(),
)
.with_selector(name.as_str())
.with_fix(
"Add an appropriate autocomplete token (e.g. autocomplete=\"email\", \
autocomplete=\"tel\", autocomplete=\"name\") to help users and assistive technologies.",
)
.with_rule_id(IDENTIFY_PURPOSE_RULE.axe_id)
.with_help_url(IDENTIFY_PURPOSE_RULE.help_url);
finding.in_excluded_subtree = *in_excluded_subtree;
findings.push(finding);
}
findings
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn split_words_handles_snake_kebab_and_camel_case() {
assert_eq!(split_words("billing_address_2"), vec!["billing", "address"]);
assert_eq!(split_words("billing-address"), vec!["billing", "address"]);
assert_eq!(split_words("billingAddress"), vec!["billing", "address"]);
assert_eq!(split_words("PLZ"), vec!["plz"]);
assert_eq!(split_words("Straße"), vec!["straße"]);
}
#[test]
fn recognizes_english_and_german_personal_data_fields() {
for name in [
"address",
"street_address",
"billing_address",
"shippingAddress",
"firstname",
"first_name",
"vorname",
"nachname",
"plz",
"postal_code",
"city",
"ort",
"phone",
"telefonnummer",
"handynummer",
"email_address",
] {
assert!(
is_personal_data_input("text", name, ""),
"{name} should be recognized as a personal-data field"
);
}
}
#[test]
fn does_not_flag_technical_or_unrelated_address_fields() {
for name in [
"ip_address",
"mac_address",
"wallet_address",
"contract_address",
] {
assert!(
!is_personal_data_input("text", name, ""),
"{name} must not be treated as a postal-address field"
);
}
}
#[test]
fn does_not_flag_generic_text_fields_by_type_alone() {
for (input_type, name) in [
("text", "promo_code"),
("text", "username"),
("search", "q"),
("text", ""),
] {
assert!(
!is_personal_data_input(input_type, name, ""),
"input[type={input_type}][name={name}] must not be flagged without a personal-data name"
);
}
}
#[test]
fn email_and_tel_types_always_count_regardless_of_name() {
assert!(is_personal_data_input("email", "field1", ""));
assert!(is_personal_data_input("tel", "field2", ""));
}
#[test]
fn id_is_checked_when_name_is_absent() {
assert!(is_personal_data_input("text", "", "billing-address"));
}
}