From 6bf0a5cb5034a7e684dcc3500e841785237ce2dd Mon Sep 17 00:00:00 2001 From: Daniel Baumann Date: Sun, 7 Apr 2024 19:32:43 +0200 Subject: Adding upstream version 1:115.7.0. Signed-off-by: Daniel Baumann --- .../formautofill/shared/AddressComponent.sys.mjs | 1090 ++++++++++++++++ .../formautofill/shared/AddressParser.sys.mjs | 281 ++++ .../formautofill/shared/CreditCardRuleset.sys.mjs | 1212 ++++++++++++++++++ .../formautofill/shared/FieldScanner.sys.mjs | 211 +++ .../shared/FormAutofillHandler.sys.mjs | 400 ++++++ .../shared/FormAutofillHeuristics.sys.mjs | 1168 +++++++++++++++++ .../shared/FormAutofillNameUtils.sys.mjs | 406 ++++++ .../shared/FormAutofillSection.sys.mjs | 1353 ++++++++++++++++++++ .../formautofill/shared/FormAutofillUtils.sys.mjs | 1253 ++++++++++++++++++ .../formautofill/shared/FormStateManager.sys.mjs | 154 +++ .../formautofill/shared/HeuristicsRegExp.sys.mjs | 620 +++++++++ .../formautofill/shared/LabelUtils.sys.mjs | 120 ++ 12 files changed, 8268 insertions(+) create mode 100644 toolkit/components/formautofill/shared/AddressComponent.sys.mjs create mode 100644 toolkit/components/formautofill/shared/AddressParser.sys.mjs create mode 100644 toolkit/components/formautofill/shared/CreditCardRuleset.sys.mjs create mode 100644 toolkit/components/formautofill/shared/FieldScanner.sys.mjs create mode 100644 toolkit/components/formautofill/shared/FormAutofillHandler.sys.mjs create mode 100644 toolkit/components/formautofill/shared/FormAutofillHeuristics.sys.mjs create mode 100644 toolkit/components/formautofill/shared/FormAutofillNameUtils.sys.mjs create mode 100644 toolkit/components/formautofill/shared/FormAutofillSection.sys.mjs create mode 100644 toolkit/components/formautofill/shared/FormAutofillUtils.sys.mjs create mode 100644 toolkit/components/formautofill/shared/FormStateManager.sys.mjs create mode 100644 toolkit/components/formautofill/shared/HeuristicsRegExp.sys.mjs create mode 100644 toolkit/components/formautofill/shared/LabelUtils.sys.mjs (limited to 'toolkit/components/formautofill/shared') diff --git a/toolkit/components/formautofill/shared/AddressComponent.sys.mjs b/toolkit/components/formautofill/shared/AddressComponent.sys.mjs new file mode 100644 index 0000000000..95779837b8 --- /dev/null +++ b/toolkit/components/formautofill/shared/AddressComponent.sys.mjs @@ -0,0 +1,1090 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { FormAutofill } from "resource://autofill/FormAutofill.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + AddressParser: "resource://gre/modules/shared/AddressParser.sys.mjs", + FormAutofillNameUtils: + "resource://gre/modules/shared/FormAutofillNameUtils.sys.mjs", + FormAutofillUtils: "resource://gre/modules/shared/FormAutofillUtils.sys.mjs", + PhoneNumber: "resource://autofill/phonenumberutils/PhoneNumber.sys.mjs", + PhoneNumberNormalizer: + "resource://autofill/phonenumberutils/PhoneNumberNormalizer.sys.mjs", +}); + +/** + * Class representing a collection of tokens extracted from a string. + */ +class Tokens { + #tokens = null; + + // By default we split passed string with whitespace. + constructor(value, sep = /\s+/) { + this.#tokens = value.split(sep); + } + + get tokens() { + return this.#tokens; + } + + /** + * Checks if all the tokens in the current object can be found in another + * token object. + * + * @param {Tokens} other The other Tokens instance to compare with. + * @param {Function} compare An optional custom comparison function. + * @returns {boolean} True if the current Token object is a subset of the + * other Token object, false otherwise. + */ + isSubset(other, compare = (a, b) => a == b) { + return this.tokens.every(tokenSelf => { + for (const tokenOther of other.tokens) { + if (compare(tokenSelf, tokenOther)) { + return true; + } + } + return false; + }); + } + + /** + * Checks if all the tokens in the current object can be found in another + * Token object's tokens (in order). + * For example, ["John", "Doe"] is a subset of ["John", "Michael", "Doe"] + * in order but not a subset of ["Doe", "Michael", "John"] in order. + * + * @param {Tokens} other The other Tokens instance to compare with. + * @param {Function} compare An optional custom comparison function. + * @returns {boolean} True if the current Token object is a subset of the + * other Token object, false otherwise. + */ + isSubsetInOrder(other, compare = (a, b) => a == b) { + if (this.tokens.length > other.tokens.length) { + return false; + } + + let idx = 0; + return this.tokens.every(tokenSelf => { + for (; idx < other.tokens.length; idx++) { + if (compare(tokenSelf, other.tokens[idx])) { + return true; + } + } + return false; + }); + } +} + +/** + * The AddressField class is a base class representing a single address field. + */ +class AddressField { + #userValue = null; + + #region = null; + + /** + * Create a representation of a single address field. + * + * @param {string} value + * The unnormalized value of an address field. + * + * @param {string} region + * The region of a single address field. Used to determine what collator should be + * for string comparisons of the address's field value. + */ + constructor(value, region) { + this.#userValue = value?.trim(); + this.#region = region; + } + + /** + * Get the unnormalized value of the address field. + * + * @returns {string} The unnormalized field value. + */ + get userValue() { + return this.#userValue; + } + + /** + * Get the collator used for string comparisons. + * + * @returns {Intl.Collator} The collator. + */ + get collator() { + return lazy.FormAutofillUtils.getSearchCollators(this.#region, { + ignorePunctuation: false, + }); + } + + get region() { + return this.#region; + } + + /** + * Compares two strings using the collator. + * + * @param {string} a The first string to compare. + * @param {string} b The second string to compare. + * @returns {number} A negative, zero, or positive value, depending on the comparison result. + */ + localeCompare(a, b) { + return lazy.FormAutofillUtils.strCompare(a, b, this.collator); + } + + /** + * Checks if the field value is empty. + * + * @returns {boolean} True if the field value is empty, false otherwise. + */ + isEmpty() { + return !this.#userValue; + } + + /** + * Normalizes the unnormalized field value using the provided options. + * + * @param {object} options - Options for normalization. + * @returns {string} The normalized field value. + */ + normalizeUserValue(options) { + return lazy.AddressParser.normalizeString(this.#userValue, options); + } + + /** + * Returns a string representation of the address field. + * Ex. "Country: US", "PostalCode: 55123", etc. + */ + toString() { + return `${this.constructor.name}: ${this.#userValue}\n`; + } + + /** + * Checks if the field value is valid. + * + * @returns {boolean} True if the field value is valid, false otherwise. + */ + isValid() { + throw Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED); + } + + /** + * Compares the current field value with another field value for equality. + */ + equals() { + throw Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED); + } + + /** + * Checks if the current field value contains another field value. + */ + contains() { + throw Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED); + } +} + +/** + * A street address. + * See autocomplete="street-address". + */ +class StreetAddress extends AddressField { + #structuredStreetAddress = null; + + constructor(value, region) { + super(value, region); + + this.#structuredStreetAddress = lazy.AddressParser.parseStreetAddress( + lazy.AddressParser.replaceControlCharacters(this.userValue, " ") + ); + } + + get structuredStreetAddress() { + return this.#structuredStreetAddress; + } + get street_number() { + return this.#structuredStreetAddress?.street_number; + } + get street_name() { + return this.#structuredStreetAddress?.street_name; + } + get floor_number() { + return this.#structuredStreetAddress?.floor_number; + } + get apartment_number() { + return this.#structuredStreetAddress?.apartment_number; + } + + isValid() { + return this.userValue ? !!/[\p{Letter}]/u.exec(this.userValue) : true; + } + + equals(other) { + return ( + this.street_number?.toLowerCase() == other.street_number?.toLowerCase() && + this.street_name?.toLowerCase() == other.street_name?.toLowerCase() && + this.apartment_number?.toLowerCase() == + other.apartment_number?.toLowerCase() && + this.floor_number?.toLowerCase() == other.floor_number?.toLowerCase() + ); + } + + contains(other) { + let selfStreetName = this.userValue; + let otherStreetName = other.userValue; + + // Compare street number, apartment number and floor number if + // both addresses are parsed successfully. + if (this.structuredStreetAddress && other.structuredStreetAddress) { + if ( + (other.street_number && this.street_number != other.street_number) || + (other.apartment_number && + this.apartment_number != other.apartment_number) || + (other.floor_number && this.floor_number != other.floor_number) + ) { + return false; + } + + // Use parsed street name to compare + selfStreetName = this.street_name; + otherStreetName = other.street_name; + } + + // Check if one street name contains the other + const options = { + ignore_case: true, + replace_punctuation: " ", + }; + const selfTokens = new Tokens( + lazy.AddressParser.normalizeString(selfStreetName, options), + /[\s\n\r]+/ + ); + const otherTokens = new Tokens( + lazy.AddressParser.normalizeString(otherStreetName, options), + /[\s\n\r]+/ + ); + + return otherTokens.isSubsetInOrder(selfTokens, (a, b) => + this.localeCompare(a, b) + ); + } +} + +/** + * A postal code / zip code + * See autocomplete="postal-code" + */ +class PostalCode extends AddressField { + constructor(value, region) { + super(value, region); + } + + isValid() { + const { postalCodePattern } = lazy.FormAutofillUtils.getFormFormat( + this.region + ); + const regexp = new RegExp(`^${postalCodePattern}$`); + return regexp.test(this.userValue); + } + + equals(other) { + const options = { + ignore_case: true, + remove_whitespace: true, + remove_punctuation: true, + }; + + return ( + this.normalizeUserValue(options) == other.normalizeUserValue(options) + ); + } + + contains(other) { + const options = { + ignore_case: true, + remove_whitespace: true, + remove_punctuation: true, + }; + + const self_normalized_value = this.normalizeUserValue(options); + const other_normalized_value = other.normalizeUserValue(options); + + return ( + self_normalized_value.endsWith(other_normalized_value) || + self_normalized_value.startsWith(other_normalized_value) + ); + } +} + +/** + * City name. + * See autocomplete="address-level1" + */ +class City extends AddressField { + #city = null; + + constructor(value, region) { + super(value, region); + + const options = { + ignore_case: true, + }; + this.#city = this.normalizeUserValue(options); + } + + get city() { + return this.#city; + } + + isValid() { + return this.userValue ? !!/[\p{Letter}]/u.exec(this.userValue) : true; + } + + equals(other) { + return this.city == other.city; + } + + contains(other) { + const options = { + ignore_case: true, + replace_punctuation: " ", + merge_whitespace: true, + }; + + const selfTokens = new Tokens(this.normalizeUserValue(options)); + const otherTokens = new Tokens(other.normalizeUserValue(options)); + + return otherTokens.isSubsetInOrder(selfTokens, (a, b) => + this.localeCompare(a, b) + ); + } +} + +/** + * State. + * See autocomplete="address-level2" + */ +class State extends AddressField { + // The abbreviated region name. For example, California is abbreviated as CA + #state = null; + + constructor(value, region) { + super(value, region); + + if (!this.userValue) { + return; + } + + const options = { + merge_whitespace: true, + remove_punctuation: true, + }; + this.#state = lazy.FormAutofillUtils.getAbbreviatedSubregionName( + this.normalizeUserValue(options), + region + ); + } + + get state() { + return this.#state; + } + + isValid() { + // If we can't get the abbreviated name, assume this is an invalid state name + return !!this.#state; + } + + equals(other) { + // If we have an abbreviated name, compare with it. + if (this.state) { + return this.state == other.state; + } + + // If we don't have an abbreviated name, just compare the userValue + return this.userValue == other.userValue; + } + + contains(other) { + return this.equals(other); + } +} + +/** + * A country or territory code. + * See autocomplete="country" + */ +class Country extends AddressField { + // iso 3166 2-alpha code + #country_code = null; + + constructor(value, region) { + super(value, region); + + if (this.isEmpty()) { + return; + } + + const options = { + merge_whitespace: true, + remove_punctuation: true, + }; + + const country = this.normalizeUserValue(options); + this.#country_code = lazy.FormAutofillUtils.identifyCountryCode(country); + + // When the country name is not a valid one, we use the current region instead + if (!this.#country_code) { + this.#country_code = lazy.FormAutofillUtils.identifyCountryCode(region); + } + } + + get country_code() { + return this.#country_code; + } + + isValid() { + return !!this.#country_code; + } + + equals(other) { + return this.country_code == other.country_code; + } + + contains(other) { + return false; + } +} + +/** + * The field expects the value to be a person's full name. + * See autocomplete="name" + */ +class Name extends AddressField { + constructor(value, region) { + super(value, region); + } + + // Reference: + // https://source.chromium.org/chromium/chromium/src/+/main:components/autofill/core/browser/data_model/autofill_profile_comparator.cc;drc=566369da19275cc306eeb51a3d3451885299dabb;bpv=1;bpt=1;l=935 + static createNameVariants(name) { + let tokens = name.trim().split(" "); + + let variants = [""]; + if (!tokens[0]) { + return variants; + } + + for (const token of tokens) { + let tmp = []; + for (const variant of variants) { + tmp.push(variant + " " + token); + tmp.push(variant + " " + token[0]); + } + variants = variants.concat(tmp); + } + + const options = { + merge_whitespace: true, + }; + return variants.map(v => lazy.AddressParser.normalizeString(v, options)); + } + + isValid() { + return this.userValue ? !!/[\p{Letter}]/u.exec(this.userValue) : true; + } + + equals(other) { + const options = { + ignore_case: true, + }; + return ( + this.normalizeUserValue(options) == other.normalizeUserValue(options) + ); + } + + contains(other) { + // Unify puncutation while comparing so users can choose the right one + // if the only different part is puncutation + // Ex. John O'Brian is similar to John O`Brian + let options = { + ignore_case: true, + replace_punctuation: " ", + merge_whitespace: true, + }; + let selfName = this.normalizeUserValue(options); + let otherName = other.normalizeUserValue(options); + let selfTokens = new Tokens(selfName); + let otherTokens = new Tokens(otherName); + + if ( + otherTokens.isSubsetInOrder(selfTokens, (a, b) => + this.localeCompare(a, b) + ) + ) { + return true; + } + + // Remove puncutation from self and test whether current contains other + // Ex. John O'Brian is similar to John OBrian + selfName = this.normalizeUserValue({ + ignore_case: true, + remove_punctuation: true, + merge_whitespace: true, + }); + otherName = other.normalizeUserValue({ + ignore_case: true, + remove_punctuation: true, + merge_whitespace: true, + }); + + selfTokens = new Tokens(selfName); + otherTokens = new Tokens(otherName); + if ( + otherTokens.isSubsetInOrder(selfTokens, (a, b) => + this.localeCompare(a, b) + ) + ) { + return true; + } + + // Create variants of the names by generating initials for given and middle names. + + selfName = lazy.FormAutofillNameUtils.splitName(selfName); + otherName = lazy.FormAutofillNameUtils.splitName(otherName); + // In the following we compare cases when people abbreviate first name + // and middle name with initials. So if family name is different, + // we can just skip and assume the two names are different + if (!this.localeCompare(selfName.family, otherName.family)) { + return false; + } + + const otherNameWithoutFamily = lazy.FormAutofillNameUtils.joinNameParts({ + given: otherName.given, + middle: otherName.middle, + }); + let givenVariants = Name.createNameVariants(selfName.given); + let middleVariants = Name.createNameVariants(selfName.middle); + + for (const given of givenVariants) { + for (const middle of middleVariants) { + const nameVariant = lazy.FormAutofillNameUtils.joinNameParts({ + given, + middle, + }); + + if (this.localeCompare(nameVariant, otherNameWithoutFamily)) { + return true; + } + } + } + + // Check cases when given name and middle name are abbreviated with initial + // and the initials are put together. ex. John Michael Doe to JM. Doe + if (selfName.given && selfName.middle) { + const nameVariant = [ + ...selfName.given.split(" "), + ...selfName.middle.split(" "), + ].reduce((initials, name) => { + initials += name[0]; + return initials; + }, ""); + + if (this.localeCompare(nameVariant, otherNameWithoutFamily)) { + return true; + } + } + + return false; + } +} + +/** + * A full telephone number, including the country code. + * See autocomplete="tel" + */ +class Tel extends AddressField { + #valid = false; + + // The country code part of a telphone number, such as "1" for the United States + #country_code = null; + + // The national part of a telphone number. For example, the phone number "+1 520-248-6621" + // national part is "520-248-6621". + #national_number = null; + + constructor(value, region) { + super(value, region); + + if (!this.userValue) { + return; + } + + // TODO: Support parse telephone extension + // We compress all tel-related fields into a single tel field when an an form + // is submitted, so we need to decompress it here. + const parsed_tel = lazy.PhoneNumber.Parse(this.userValue, region); + if (parsed_tel) { + this.#national_number = parsed_tel?.nationalNumber; + this.#country_code = parsed_tel?.countryCode; + + this.#valid = true; + } else { + this.#national_number = lazy.PhoneNumberNormalizer.Normalize( + this.userValue + ); + + const md = lazy.PhoneNumber.FindMetaDataForRegion(region); + this.#country_code = md ? "+" + md.nationalPrefix : null; + + this.#valid = lazy.PhoneNumber.IsValid(this.#national_number, md); + } + } + + get country_code() { + return this.#country_code; + } + + get national_number() { + return this.#national_number; + } + + isValid() { + return this.#valid; + } + + equals(other) { + return ( + this.national_number == other.national_number && + this.country_code == other.country_code + ); + } + + contains(other) { + if (!this.country_code || this.country_code != other.country_code) { + return false; + } + + return this.national_number.endsWith(other.national_number); + } + + toString() { + return `${this.constructor.name}: ${this.country_code} ${this.national_number}\n`; + } +} + +/** + * A company or organization name. + * See autocomplete="organization". + */ +class Organization extends AddressField { + constructor(value, region) { + super(value, region); + } + + isValid() { + return this.userValue + ? !!/[\p{Letter}\p{Number}]/u.exec(this.userValue) + : true; + } + + /** + * Two company names are considered equal only when everything is the same. + */ + equals(other) { + return this.userValue == other.userValue; + } + + // Mergeable use locale compare + contains(other) { + const options = { + replace_punctuation: " ", // mozilla org vs mozilla-org + merge_whitespace: true, + ignore_case: true, // mozilla vs Mozilla + }; + + // If every token in B can be found in A without considering order + // Example, 'Food & Pharmacy' contains 'Pharmacy & Food' + const selfTokens = new Tokens(this.normalizeUserValue(options)); + const otherTokens = new Tokens(other.normalizeUserValue(options)); + + return otherTokens.isSubset(selfTokens, (a, b) => this.localeCompare(a, b)); + } +} + +/** + * An email address + * See autocomplete="email". + */ +class Email extends AddressField { + constructor(value, region) { + super(value, region); + } + + // Since we are using the valid check to determine whether we capture the email field when users submitting a forma, + // use a less restrict email verification method so we capture an email for most of the cases. + // The current algorithm is based on the regular expression defined in + // https://html.spec.whatwg.org/multipage/input.html#valid-e-mail-address + // + // We might also change this to something similar to the algorithm used in + // EmailInputType::IsValidEmailAddress if we want a more strict email validation algorithm. + isValid() { + const regex = + /^[a-zA-Z0-9.!#$%&'*+\/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/; + const match = this.userValue.match(regex); + if (!match) { + return false; + } + + return true; + } + + /* + // JS version of EmailInputType::IsValidEmailAddress + isValid() { + const regex = /^([a-zA-Z0-9.!#$%&'*+-/=?^_`{|}~]+)@([a-zA-Z0-9-]+\.[a-zA-Z]{2,})$/; + const match = this.userValue.match(regex); + if (!match) { + return false; + } + const local = match[1]; + const domain = match[2]; + + // The domain name can't begin with a dot or a dash. + if (['-', '.'].includes(domain[0])) { + return false; + } + + // A dot can't follow a dot or a dash. + // A dash can't follow a dot. + const pattern = /(\.\.)|(\.-)|(-\.)/; + if (pattern.test(domain)) { + return false; + } + + return true; + } +*/ + + equals(other) { + const options = { + ignore_case: true, + }; + + // email is case-insenstive + return ( + this.normalizeUserValue(options) == other.normalizeUserValue(options) + ); + } + + contains(other) { + return false; + } +} + +/** + * The AddressComparison class compares two AddressComponent instances and + * provides information about the differences or similarities between them. + * + * The comparison result is stored and the object and can be retrieved by calling + * 'result' getter. + */ +export class AddressComparison { + // Const to define the comparison result for two address fields + static BOTH_EMPTY = 0; + static A_IS_EMPTY = 1; + static B_IS_EMPTY = 2; + static A_CONTAINS_B = 3; + static B_CONTAINS_A = 4; + // When A contains B and B contains A Ex. "Pizza & Food vs Food & Pizza" + static SIMILAR = 5; + static SAME = 6; + static DIFFERENT = 7; + + // The comparion result, keyed by field name. + #result = {}; + + /** + * Constructs AddressComparison by comparing two AddressComponent objects. + * + * @class + * @param {AddressComponent} addressA - The first address to compare. + * @param {AddressComponent} addressB - The second address to compare. + */ + constructor(addressA, addressB) { + for (const fieldA of addressA.getAllFields()) { + const fieldName = fieldA.constructor.name; + const fieldB = addressB.getField(fieldName); + if (fieldB) { + this.#result[fieldName] = AddressComparison.compare(fieldA, fieldB); + } else { + this.#result[fieldName] = AddressComparison.B_IS_EMPTY; + } + } + + for (const fieldB of addressB.getAllFields()) { + const fieldName = fieldB.constructor.name; + if (!addressB.getField(fieldName)) { + this.#result[fieldName] = AddressComparison.A_IS_EMPTY; + } + } + } + + /** + * Retrieves the result object containing the comparison results. + * + * @returns {object} The result object with keys corresponding to field names + * and values being comparison constants. + */ + get result() { + return this.#result; + } + + /** + * Compares two address fields and returns the comparison result. + * + * @param {AddressField} fieldA The first field to compare. + * @param {AddressField} fieldB The second field to compare. + * @returns {number} A constant representing the comparison result. + */ + static compare(fieldA, fieldB) { + if (fieldA.isEmpty()) { + return fieldB.isEmpty() + ? AddressComparison.BOTH_EMPTY + : AddressComparison.A_IS_EMPTY; + } else if (fieldB.isEmpty()) { + return AddressComparison.B_IS_EMPTY; + } + + if (fieldA.equals(fieldB)) { + return AddressComparison.SAME; + } + + if (fieldB.contains(fieldA)) { + if (fieldA.contains(fieldB)) { + return AddressComparison.SIMILAR; + } + return AddressComparison.B_CONTAINS_A; + } else if (fieldA.contains(fieldB)) { + return AddressComparison.A_CONTAINS_B; + } + + return AddressComparison.DIFFERENT; + } + + /** + * Converts a comparison result constant to a readable string. + * + * @param {number} result The comparison result constant. + * @returns {string} A readable string representing the comparison result. + */ + static resultToString(result) { + switch (result) { + case AddressComparison.BOTH_EMPTY: + return "both fields are empty"; + case AddressComparison.A_IS_EMPTY: + return "field A is empty"; + case AddressComparison.B_IS_EMPTY: + return "field B is empty"; + case AddressComparison.A_CONTAINS_B: + return "field A contains field B"; + case AddressComparison.B_CONTAINS_B: + return "field B contains field A"; + case AddressComparison.SIMILAR: + return "field A and field B are similar"; + case AddressComparison.SAME: + return "two fields are the same"; + case AddressComparison.DIFFERENT: + return "two fields are different"; + } + return ""; + } + + /** + * Returns a formatted string representing the comparison results for each field. + * + * @returns {string} A formatted string with field names and their respective + * comparison results. + */ + toString() { + let string = "Comparison Result:\n"; + for (const [name, result] of Object.entries(this.#result)) { + string += `${name}: ${AddressComparison.resultToString(result)}\n`; + } + return string; + } +} + +/** + * The AddressComponent class represents a structured address that is transformed + * from address record created in FormAutofillHandler 'createRecord' function. + * + * An AddressComponent object consisting of various fields such as state, city, + * country, postal code, etc. The class provides a compare methods + * to compare another AddressComponent against the current instance. + * + * Note. This class assumes records that pass to it have already been normalized. + */ +export class AddressComponent { + /** + * An object that stores individual address field instances + * (e.g., class State, class City, class Country, etc.), keyed by the + * field's clas name. + */ + #fields = {}; + + /** + * Constructs an AddressComponent object by converting passed address record object. + * + * @class + * @param {object} record The address record object containing address data. + * @param {string} defaultRegion The default region to use if the record's + * country is not specified. + * @param {object} [options = {}] a list of options for this method + * @param {boolean} [options.ignoreInvalid = true] Whether to ignore invalid address + * fields in the AddressComponent object. If set to true, + * invalid fields will be ignored. + */ + constructor( + record, + defaultRegion = FormAutofill.DEFAULT_REGION, + { ignoreInvalid = false } = {} + ) { + const fieldValue = this.#recordToFieldValue(record); + + // Get country code first so we can use it to parse other fields + const country = new Country(fieldValue.country, defaultRegion); + this.#fields[Country.name] = country; + const region = country.isEmpty() ? defaultRegion : country.country_code; + + this.#fields[State.name] = new State(fieldValue.state, region); + this.#fields[City.name] = new City(fieldValue.city, region); + this.#fields[PostalCode.name] = new PostalCode( + fieldValue.postal_code, + region + ); + this.#fields[Tel.name] = new Tel(fieldValue.tel, region); + this.#fields[StreetAddress.name] = new StreetAddress( + fieldValue.street_address, + region + ); + this.#fields[Name.name] = new Name(fieldValue.name, region); + this.#fields[Organization.name] = new Organization( + fieldValue.organization, + region + ); + this.#fields[Email.name] = new Email(fieldValue.email, region); + + if (ignoreInvalid) { + // TODO: We have to reset it or ignore non-existing fields while comparing + this.#fields.filter(f => f.IsValid()); + } + } + + /** + * Converts address record to a field value object. + * + * @param {object} record The record object containing address data. + * @returns {object} A value object with keys corresponding to specific + * address fields and their respective values. + */ + #recordToFieldValue(record) { + let value = {}; + + if (record.name) { + value.name = record.name; + } else { + value.name = lazy.FormAutofillNameUtils.joinNameParts({ + given: record["given-name"], + middle: record["additional-name"], + family: record["family-name"], + }); + } + + value.email = record.email ?? ""; + value.organization = record.organization ?? ""; + value.street_address = record["street-address"] ?? ""; + value.state = record["address-level1"] ?? ""; + value.city = record["address-level2"] ?? ""; + value.country = record.country ?? ""; + value.postal_code = record["postal-code"] ?? ""; + value.tel = record.tel ?? ""; + + return value; + } + + /** + * Retrieves all the address fields. + * + * @returns {Array} An array of address field objects. + */ + getAllFields() { + return Object.values(this.#fields); + } + + /** + * Retrieves the field object with the specified name. + * + * @param {string} name The name of the field to retrieve. + * @returns {object} The address field object with the specified name, + * or undefined if the field is not found. + */ + getField(name) { + return this.#fields[name]; + } + + /** + * Compares the current AddressComponent with another AddressComponent. + * + * @param {AddressComponent} address The AddressComponent object to compare + * against the current one. + * @returns {object} An object containing comparison results. The keys of the object represent + * individual address field, and the values are strings indicating the comparison result: + * - "same" if both components are either empty or the same, + * - "superset" if the current contains the input or the input is empty, + * - "subset" if the input contains the current or the current is empty, + * - "similar" if the two address components are similar, + * - "different" if the two address components are different. + */ + compare(address) { + let result = {}; + + const comparison = new AddressComparison(this, address); + for (const [k, v] of Object.entries(comparison.result)) { + if ([AddressComparison.BOTH_EMPTY, AddressComparison.SAME].includes(v)) { + result[k] = "same"; + } else if ( + [AddressComparison.B_IS_EMPTY, AddressComparison.A_CONTAINS_B].includes( + v + ) + ) { + result[k] = "superset"; + } else if ( + [AddressComparison.A_IS_EMPTY, AddressComparison.B_CONTAINS_A].includes( + v + ) + ) { + result[k] = "subset"; + } else if ([AddressComparison.SIMILAR].includes(v)) { + result[k] = "similar"; + } else { + result[k] = "different"; + } + } + return result; + } + + /** + * Print all the fields in this AddressComponent object. + */ + toString() { + let string = ""; + for (const field of Object.values(this.#fields)) { + string += field.toString(); + } + return string; + } +} diff --git a/toolkit/components/formautofill/shared/AddressParser.sys.mjs b/toolkit/components/formautofill/shared/AddressParser.sys.mjs new file mode 100644 index 0000000000..8fe0dc7f80 --- /dev/null +++ b/toolkit/components/formautofill/shared/AddressParser.sys.mjs @@ -0,0 +1,281 @@ +/* eslint-disable no-useless-concat */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +// NamedCaptureGroup class represents a named capturing group in a regular expression +class NamedCaptureGroup { + // The named of this capturing group + #name = null; + + // The capturing group + #capture = null; + + // The matched result + #match = null; + + constructor(name, capture) { + this.#name = name; + this.#capture = capture; + } + + get name() { + return this.#name; + } + + get capture() { + return this.#capture; + } + + get match() { + return this.#match; + } + + // Setter for the matched result based on the match groups + setMatch(matchGroups) { + this.#match = matchGroups[this.#name]; + } +} + +// Base class for different part of a street address regular expression. +// The regular expression is constructed with prefix, pattern, suffix +// and separator to extract "value" part. +// For examplem, when we write "apt 4." to for floor number, its prefix is `apt`, +// suffix is `.` and value to represent apartment number is `4`. +class StreetAddressPartRegExp extends NamedCaptureGroup { + constructor(name, prefix, pattern, suffix, sep, optional = false) { + prefix = prefix ?? ""; + suffix = suffix ?? ""; + super( + name, + `((?:${prefix})(?<${name}>${pattern})(?:${suffix})(?:${sep})+)${ + optional ? "?" : "" + }` + ); + } +} + +// A regular expression to match the street number portion of a street address, +class StreetNumberRegExp extends StreetAddressPartRegExp { + static PREFIX = "((no|°|º|number)(\\.|-|\\s)*)?"; // From chromium source + + static PATTERN = "\\d+\\w?"; + + // TODO: possible suffix : (th\\.|\\.)? + static SUFFIX = null; + + constructor(sep, optional) { + super( + StreetNumberRegExp.name, + StreetNumberRegExp.PREFIX, + StreetNumberRegExp.PATTERN, + StreetNumberRegExp.SUFFIX, + sep, + optional + ); + } +} + +// A regular expression to match the street name portion of a street address, +class StreetNameRegExp extends StreetAddressPartRegExp { + static PREFIX = null; + + static PATTERN = "(?:[^\\s,]+(?:[^\\S\\r\\n]+[^\\s,]+)*?)"; // From chromium source + + // TODO: Should we consider suffix like (ave|st)? + static SUFFIX = null; + + constructor(sep, optional) { + super( + StreetNameRegExp.name, + StreetNameRegExp.PREFIX, + StreetNameRegExp.PATTERN, + StreetNameRegExp.SUFFIX, + sep, + optional + ); + } +} + +// A regular expression to match the apartment number portion of a street address, +class ApartmentNumberRegExp extends StreetAddressPartRegExp { + static keyword = "apt|apartment|wohnung|apto|-" + "|unit|suite|ste|#|room"; // From chromium source // Firefox specific + static PREFIX = `(${ApartmentNumberRegExp.keyword})(\\.|\\s|-)*`; + + static PATTERN = "\\w*([-|\\/]\\w*)?"; + + static SUFFIX = "(\\.|\\s|-)*(ª)?"; // From chromium source + + constructor(sep, optional) { + super( + ApartmentNumberRegExp.name, + ApartmentNumberRegExp.PREFIX, + ApartmentNumberRegExp.PATTERN, + ApartmentNumberRegExp.SUFFIX, + sep, + optional + ); + } +} + +// A regular expression to match the floor number portion of a street address, +class FloorNumberRegExp extends StreetAddressPartRegExp { + static keyword = + "floor|flur|fl|og|obergeschoss|ug|untergeschoss|geschoss|andar|piso|º" + // From chromium source + "|level|lvl"; // Firefox specific + static PREFIX = `(${FloorNumberRegExp.keyword})?(\\.|\\s|-)*`; // TODO + static PATTERN = "\\d{1,3}\\w?"; + static SUFFIX = `(st|nd|rd|th)?(\\.|\\s|-)*(${FloorNumberRegExp.keyword})?`; // TODO + + constructor(sep, optional) { + super( + FloorNumberRegExp.name, + FloorNumberRegExp.PREFIX, + FloorNumberRegExp.PATTERN, + FloorNumberRegExp.SUFFIX, + sep, + optional + ); + } +} + +/** + * Class represents a street address with the following fields: + * - street number + * - street name + * - apartment number + * - floor number + */ +export class StructuredStreetAddress { + #street_number = null; + #street_name = null; + #apartment_number = null; + #floor_number = null; + + constructor(street_number, street_name, apartment_number, floor_number) { + this.#street_number = street_number?.toString(); + this.#street_name = street_name?.toString(); + this.#apartment_number = apartment_number?.toString(); + this.#floor_number = floor_number?.toString(); + } + + get street_number() { + return this.#street_number; + } + + get street_name() { + return this.#street_name; + } + + get apartment_number() { + return this.#apartment_number; + } + + get floor_number() { + return this.#floor_number; + } + + toString() { + return ` + street number: ${this.#street_number}\n + street name: ${this.#street_name}\n + apartment number: ${this.#apartment_number}\n + floor number: ${this.#floor_number}\n + `; + } +} + +export class AddressParser { + /** + * Parse street address with the following pattern. + * street number, street name, apartment number(optional), floor number(optional) + * For example, 2 Harrison St #175 floor 2 + * + * @param {string} address The street address to be parsed. + * @returns {StructuredStreetAddress} + */ + static parseStreetAddress(address) { + const separator = "(\\s|,|$)"; + + const regexpes = [ + new StreetNumberRegExp(separator), + new StreetNameRegExp(separator), + new ApartmentNumberRegExp(separator, true), + new FloorNumberRegExp(separator, true), + ]; + + return AddressParser.parse(address, regexpes) + ? new StructuredStreetAddress(...regexpes.map(regexp => regexp.match)) + : null; + } + + static parse(address, regexpes) { + const options = { + trim: true, + merge_whitespace: true, + ignore_case: true, + }; + address = AddressParser.normalizeString(address, options); + + const match = address.match( + new RegExp(`^(${regexpes.map(regexp => regexp.capture).join("")})$`) + ); + if (!match) { + return null; + } + + regexpes.forEach(regexp => regexp.setMatch(match.groups)); + return regexpes.reduce((acc, current) => { + return { ...acc, [current.name]: current.match }; + }, {}); + } + + static normalizeString(s, options) { + if (typeof s != "string") { + return s; + } + + if (options.ignore_case) { + s = s.toLowerCase(); + } + + // process punctuation before whitespace because if a punctuation + // is replaced with whitespace, we might want to merge it later + if (options.remove_punctuation) { + s = AddressParser.replacePunctuation(s, ""); + } else if ("replace_punctuation" in options) { + const replace = options.replace_punctuation; + s = AddressParser.replacePunctuation(s, replace); + } + + // process whitespace + if (options.merge_whitespace) { + s = AddressParser.mergeWhitespace(s); + } else if (options.remove_whitespace) { + s = AddressParser.removeWhitespace(s); + } + + return s.trim(); + } + + static replacePunctuation(s, replace) { + const regex = /\p{Punctuation}/gu; + return s?.replace(regex, replace); + } + + static removePunctuation(s) { + return s?.replace(/[.,\/#!$%\^&\*;:{}=\-_~()]/g, ""); + } + + static replaceControlCharacters(s, replace) { + return s?.replace(/[\t\n\r]/g, " "); + } + + static removeWhitespace(s) { + return s?.replace(/[\s]/g, ""); + } + + static mergeWhitespace(s) { + return s?.replace(/\s{2,}/g, " "); + } +} diff --git a/toolkit/components/formautofill/shared/CreditCardRuleset.sys.mjs b/toolkit/components/formautofill/shared/CreditCardRuleset.sys.mjs new file mode 100644 index 0000000000..ed72d26018 --- /dev/null +++ b/toolkit/components/formautofill/shared/CreditCardRuleset.sys.mjs @@ -0,0 +1,1212 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/** + * Fathom ML model for identifying the fields of credit-card forms + * + * This is developed out-of-tree at https://github.com/mozilla-services/fathom- + * form-autofill, where there is also over a GB of training, validation, and + * testing data. To make changes, do your edits there (whether adding new + * training pages, adding new rules, or both), retrain and evaluate as + * documented at https://mozilla.github.io/fathom/training.html, paste the + * coefficients emitted by the trainer into the ruleset, and finally copy the + * ruleset's "CODE TO COPY INTO PRODUCTION" section to this file's "CODE FROM + * TRAINING REPOSITORY" section. + */ + +/** + * CODE UNIQUE TO PRODUCTION--NOT IN THE TRAINING REPOSITORY: + */ + +import { + element as clickedElement, + out, + rule, + ruleset, + score, + type, +} from "resource://gre/modules/third_party/fathom/fathom.mjs"; +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; +import { FormAutofillUtils } from "resource://gre/modules/shared/FormAutofillUtils.sys.mjs"; +import { + CreditCard, + NETWORK_NAMES, +} from "resource://gre/modules/CreditCard.sys.mjs"; + +import { FormLikeFactory } from "resource://gre/modules/FormLikeFactory.sys.mjs"; +import { LabelUtils } from "resource://gre/modules/shared/LabelUtils.sys.mjs"; + +/** + * Callthrough abstraction to allow .getAutocompleteInfo() to be mocked out + * during training + * + * @param {Element} element DOM element to get info about + * @returns {object} Page-author-provided autocomplete metadata + */ +function getAutocompleteInfo(element) { + return element.getAutocompleteInfo(); +} + +/** + * @param {string} selector A CSS selector that prunes away ineligible elements + * @returns {Lhs} An LHS yielding the element the user has clicked or, if + * pruned, none + */ +function queriedOrClickedElements(selector) { + return clickedElement(selector); +} + +/** + * START OF CODE PASTED FROM TRAINING REPOSITORY + */ +var FathomHeuristicsRegExp = { + RULES: { + "cc-name": undefined, + "cc-number": undefined, + "cc-exp-month": undefined, + "cc-exp-year": undefined, + "cc-exp": undefined, + "cc-type": undefined, + }, + + RULE_SETS: [ + { + /* eslint-disable */ + // Let us keep our consistent wrapping. + "cc-name": + // Firefox-specific rules + "account.*holder.*name" + + // de-DE + "|^(kredit)?(karten|konto)inhaber" + + "|^(name).*karte" + + // fr-FR + "|nom.*(titulaire|détenteur)" + + "|(titulaire|détenteur).*(carte)" + + // it-IT + "|titolare.*carta" + + // pl-PL + "|posiadacz.*karty" + + // Rules from Bitwarden + "|cc-?name" + + "|card-?name" + + "|cardholder-?name" + + "|(^nom$)" + + // Rules are from Chromium source codes + "|card.?(?:holder|owner)|name.*(\\b)?on(\\b)?.*card" + + "|(?:card|cc).?name|cc.?full.?name" + + "|(?:card|cc).?owner" + + "|nombre.*tarjeta" + // es + "|nom.*carte" + // fr-FR + "|nome.*cart" + // it-IT + "|名前" + // ja-JP + "|Имя.*карты" + // ru + "|信用卡开户名|开户名|持卡人姓名" + // zh-CN + "|持卡人姓名", // zh-TW + + "cc-number": + // Firefox-specific rules + // de-DE + "(cc|kk)nr" + + "|(kredit)?(karten)(nummer|nr)" + + // it-IT + "|numero.*carta" + + // fr-FR + "|(numero|número|numéro).*(carte)" + + // pl-PL + "|numer.*karty" + + // Rules from Bitwarden + "|cc-?number" + + "|cc-?num" + + "|card-?number" + + "|card-?num" + + "|cc-?no" + + "|card-?no" + + "|numero-?carte" + + "|num-?carte" + + "|cb-?num" + + // Rules are from Chromium source codes + "|(add)?(?:card|cc|acct).?(?:number|#|no|num)" + + "|カード番号" + // ja-JP + "|Номер.*карты" + // ru + "|信用卡号|信用卡号码" + // zh-CN + "|信用卡卡號" + // zh-TW + "|카드", // ko-KR + + "cc-exp": + // Firefox-specific rules + "mm\\s*(\/|\\|-)\\s*(yy|jj|aa)" + + "|(month|mois)\\s*(\/|\\|-|et)\\s*(year|année)" + + // de-DE + // fr-FR + // Rules from Bitwarden + "|(^cc-?exp$)" + + "|(^card-?exp$)" + + "|(^cc-?expiration$)" + + "|(^card-?expiration$)" + + "|(^cc-?ex$)" + + "|(^card-?ex$)" + + "|(^card-?expire$)" + + "|(^card-?expiry$)" + + "|(^validite$)" + + "|(^expiration$)" + + "|(^expiry$)" + + "|mm-?yy" + + "|mm-?yyyy" + + "|yy-?mm" + + "|yyyy-?mm" + + "|expiration-?date" + + "|payment-?card-?expiration" + + "|(^payment-?cc-?date$)" + + // Rules are from Chromium source codes + "|expir|exp.*date|^expfield$" + + "|ablaufdatum|gueltig|gültig" + // de-DE + "|fecha" + // es + "|date.*exp" + // fr-FR + "|scadenza" + // it-IT + "|有効期限" + // ja-JP + "|validade" + // pt-BR, pt-PT + "|Срок действия карты", // ru + + "cc-exp-month": + // Firefox-specific rules + "(cc|kk)month" + // de-DE + // Rules from Bitwarden + "|(^exp-?month$)" + + "|(^cc-?exp-?month$)" + + "|(^cc-?month$)" + + "|(^card-?month$)" + + "|(^cc-?mo$)" + + "|(^card-?mo$)" + + "|(^exp-?mo$)" + + "|(^card-?exp-?mo$)" + + "|(^cc-?exp-?mo$)" + + "|(^card-?expiration-?month$)" + + "|(^expiration-?month$)" + + "|(^cc-?mm$)" + + "|(^cc-?m$)" + + "|(^card-?mm$)" + + "|(^card-?m$)" + + "|(^card-?exp-?mm$)" + + "|(^cc-?exp-?mm$)" + + "|(^exp-?mm$)" + + "|(^exp-?m$)" + + "|(^expire-?month$)" + + "|(^expire-?mo$)" + + "|(^expiry-?month$)" + + "|(^expiry-?mo$)" + + "|(^card-?expire-?month$)" + + "|(^card-?expire-?mo$)" + + "|(^card-?expiry-?month$)" + + "|(^card-?expiry-?mo$)" + + "|(^mois-?validite$)" + + "|(^mois-?expiration$)" + + "|(^m-?validite$)" + + "|(^m-?expiration$)" + + "|(^expiry-?date-?field-?month$)" + + "|(^expiration-?date-?month$)" + + "|(^expiration-?date-?mm$)" + + "|(^exp-?mon$)" + + "|(^validity-?mo$)" + + "|(^exp-?date-?mo$)" + + "|(^cb-?date-?mois$)" + + "|(^date-?m$)" + + // Rules are from Chromium source codes + "|exp.*mo|ccmonth|cardmonth|addmonth" + + "|monat" + // de-DE + // "|fecha" + // es + // "|date.*exp" + // fr-FR + // "|scadenza" + // it-IT + // "|有効期限" + // ja-JP + // "|validade" + // pt-BR, pt-PT + // "|Срок действия карты" + // ru + "|月", // zh-CN + + "cc-exp-year": + // Firefox-specific rules + "(cc|kk)year" + // de-DE + // Rules from Bitwarden + "|(^exp-?year$)" + + "|(^cc-?exp-?year$)" + + "|(^cc-?year$)" + + "|(^card-?year$)" + + "|(^cc-?yr$)" + + "|(^card-?yr$)" + + "|(^exp-?yr$)" + + "|(^card-?exp-?yr$)" + + "|(^cc-?exp-?yr$)" + + "|(^card-?expiration-?year$)" + + "|(^expiration-?year$)" + + "|(^cc-?yy$)" + + "|(^cc-?y$)" + + "|(^card-?yy$)" + + "|(^card-?y$)" + + "|(^card-?exp-?yy$)" + + "|(^cc-?exp-?yy$)" + + "|(^exp-?yy$)" + + "|(^exp-?y$)" + + "|(^cc-?yyyy$)" + + "|(^card-?yyyy$)" + + "|(^card-?exp-?yyyy$)" + + "|(^cc-?exp-?yyyy$)" + + "|(^expire-?year$)" + + "|(^expire-?yr$)" + + "|(^expiry-?year$)" + + "|(^expiry-?yr$)" + + "|(^card-?expire-?year$)" + + "|(^card-?expire-?yr$)" + + "|(^card-?expiry-?year$)" + + "|(^card-?expiry-?yr$)" + + "|(^an-?validite$)" + + "|(^an-?expiration$)" + + "|(^annee-?validite$)" + + "|(^annee-?expiration$)" + + "|(^expiry-?date-?field-?year$)" + + "|(^expiration-?date-?year$)" + + "|(^cb-?date-?ann$)" + + "|(^expiration-?date-?yy$)" + + "|(^expiration-?date-?yyyy$)" + + "|(^validity-?year$)" + + "|(^exp-?date-?year$)" + + "|(^date-?y$)" + + // Rules are from Chromium source codes + "|(add)?year" + + "|jahr" + // de-DE + // "|fecha" + // es + // "|scadenza" + // it-IT + // "|有効期限" + // ja-JP + // "|validade" + // pt-BR, pt-PT + // "|Срок действия карты" + // ru + "|年|有效期", // zh-CN + + "cc-type": + // Firefox-specific rules + "type" + + // de-DE + "|Kartenmarke" + + // Rules from Bitwarden + "|(^cc-?type$)" + + "|(^card-?type$)" + + "|(^card-?brand$)" + + "|(^cc-?brand$)" + + "|(^cb-?type$)", + // Rules are from Chromium source codes + }, + ], + + _getRule(name) { + let rules = []; + this.RULE_SETS.forEach(set => { + if (set[name]) { + rules.push(`(${set[name]})`.normalize("NFKC")); + } + }); + + const value = new RegExp(rules.join("|"), "iu"); + Object.defineProperty(this.RULES, name, { get: undefined }); + Object.defineProperty(this.RULES, name, { value }); + return value; + }, + + init() { + Object.keys(this.RULES).forEach(field => + Object.defineProperty(this.RULES, field, { + get() { + return FathomHeuristicsRegExp._getRule(field); + }, + }) + ); + }, +}; + +FathomHeuristicsRegExp.init(); + +const MMRegExp = /^mm$|\(mm\)/i; +const YYorYYYYRegExp = /^(yy|yyyy)$|\(yy\)|\(yyyy\)/i; +const monthRegExp = /month/i; +const yearRegExp = /year/i; +const MMYYRegExp = /mm\s*(\/|\\)\s*yy/i; +const VisaCheckoutRegExp = /visa(-|\s)checkout/i; +const CREDIT_CARD_NETWORK_REGEXP = new RegExp( + CreditCard.getSupportedNetworks() + .concat(Object.keys(NETWORK_NAMES)) + .join("|"), + "gui" + ); +const TwoDigitYearRegExp = /(?:exp.*date[^y\\n\\r]*|mm\\s*[-/]?\\s*)yy(?:[^y]|$)/i; +const FourDigitYearRegExp = /(?:exp.*date[^y\\n\\r]*|mm\\s*[-/]?\\s*)yyyy(?:[^y]|$)/i; +const dwfrmRegExp = /^dwfrm/i; +const bmlRegExp = /bml/i; +const templatedValue = /^\{\{.*\}\}$/; +const firstRegExp = /first/i; +const lastRegExp = /last/i; +const giftRegExp = /gift/i; +const subscriptionRegExp = /subscription/i; + +function autocompleteStringMatches(element, ccString) { + const info = getAutocompleteInfo(element); + return info.fieldName === ccString; +} + +function getFillableFormElements(element) { + const formLike = FormLikeFactory.createFromField(element); + return Array.from(formLike.elements).filter(el => + FormAutofillUtils.isCreditCardOrAddressFieldType(el) + ); +} + +function nextFillableFormField(element) { + const fillableFormElements = getFillableFormElements(element); + const elementIndex = fillableFormElements.indexOf(element); + return fillableFormElements[elementIndex + 1]; +} + +function previousFillableFormField(element) { + const fillableFormElements = getFillableFormElements(element); + const elementIndex = fillableFormElements.indexOf(element); + return fillableFormElements[elementIndex - 1]; +} + +function nextFieldPredicateIsTrue(element, predicate) { + const nextField = nextFillableFormField(element); + return !!nextField && predicate(nextField); +} + +function previousFieldPredicateIsTrue(element, predicate) { + const previousField = previousFillableFormField(element); + return !!previousField && predicate(previousField); +} + +function nextFieldMatchesExpYearAutocomplete(fnode) { + return nextFieldPredicateIsTrue(fnode.element, nextField => + autocompleteStringMatches(nextField, "cc-exp-year") + ); +} + +function previousFieldMatchesExpMonthAutocomplete(fnode) { + return previousFieldPredicateIsTrue(fnode.element, previousField => + autocompleteStringMatches(previousField, "cc-exp-month") + ); +} + +////////////////////////////////////////////// +// Attribute Regular Expression Rules +function idOrNameMatchRegExp(element, regExp) { + for (const str of [element.id, element.name]) { + if (regExp.test(str)) { + return true; + } + } + return false; +} + +function getElementLabels(element) { + return { + *[Symbol.iterator]() { + const labels = LabelUtils.findLabelElements(element); + for (let label of labels) { + yield* LabelUtils.extractLabelStrings(label); + } + }, + }; +} + +function labelsMatchRegExp(element, regExp) { + const elemStrings = getElementLabels(element); + for (const str of elemStrings) { + if (regExp.test(str)) { + return true; + } + } + + const parentElement = element.parentElement; + // Bug 1634819: element.parentElement is null if element.parentNode is a ShadowRoot + if (!parentElement) { + return false; + } + // Check if the input is in a , and, if so, check the textContent of the containing + if (parentElement.tagName === "TD" && parentElement.parentElement) { + // TODO: How bad is the assumption that the won't be the parent of the ? + return regExp.test(parentElement.parentElement.textContent); + } + + // Check if the input is in a
, and, if so, check the textContent of the preceding
+ if ( + parentElement.tagName === "DD" && + // previousElementSibling can be null + parentElement.previousElementSibling + ) { + return regExp.test(parentElement.previousElementSibling.textContent); + } + return false; +} + +function closestLabelMatchesRegExp(element, regExp) { + const previousElementSibling = element.previousElementSibling; + if ( + previousElementSibling !== null && + previousElementSibling.tagName === "LABEL" + ) { + return regExp.test(previousElementSibling.textContent); + } + + const nextElementSibling = element.nextElementSibling; + if (nextElementSibling !== null && nextElementSibling.tagName === "LABEL") { + return regExp.test(nextElementSibling.textContent); + } + + return false; +} + +function ariaLabelMatchesRegExp(element, regExp) { + const ariaLabel = element.getAttribute("aria-label"); + return !!ariaLabel && regExp.test(ariaLabel); +} + +function placeholderMatchesRegExp(element, regExp) { + const placeholder = element.getAttribute("placeholder"); + return !!placeholder && regExp.test(placeholder); +} + +function nextFieldIdOrNameMatchRegExp(element, regExp) { + return nextFieldPredicateIsTrue(element, nextField => + idOrNameMatchRegExp(nextField, regExp) + ); +} + +function nextFieldLabelsMatchRegExp(element, regExp) { + return nextFieldPredicateIsTrue(element, nextField => + labelsMatchRegExp(nextField, regExp) + ); +} + +function nextFieldPlaceholderMatchesRegExp(element, regExp) { + return nextFieldPredicateIsTrue(element, nextField => + placeholderMatchesRegExp(nextField, regExp) + ); +} + +function nextFieldAriaLabelMatchesRegExp(element, regExp) { + return nextFieldPredicateIsTrue(element, nextField => + ariaLabelMatchesRegExp(nextField, regExp) + ); +} + +function previousFieldIdOrNameMatchRegExp(element, regExp) { + return previousFieldPredicateIsTrue(element, previousField => + idOrNameMatchRegExp(previousField, regExp) + ); +} + +function previousFieldLabelsMatchRegExp(element, regExp) { + return previousFieldPredicateIsTrue(element, previousField => + labelsMatchRegExp(previousField, regExp) + ); +} + +function previousFieldPlaceholderMatchesRegExp(element, regExp) { + return previousFieldPredicateIsTrue(element, previousField => + placeholderMatchesRegExp(previousField, regExp) + ); +} + +function previousFieldAriaLabelMatchesRegExp(element, regExp) { + return previousFieldPredicateIsTrue(element, previousField => + ariaLabelMatchesRegExp(previousField, regExp) + ); +} +////////////////////////////////////////////// + +function isSelectWithCreditCardOptions(fnode) { + // Check every select for options that match credit card network names in + // value or label. + const element = fnode.element; + if (element.tagName === "SELECT") { + for (let option of element.querySelectorAll("option")) { + if ( + CreditCard.getNetworkFromName(option.value) || + CreditCard.getNetworkFromName(option.text) + ) { + return true; + } + } + } + return false; +} + +/** + * If any of the regular expressions match multiple times, we assume the tested + * string belongs to a radio button for payment type instead of card type. + * + * @param {Fnode} fnode + * @returns {boolean} + */ +function isRadioWithCreditCardText(fnode) { + const element = fnode.element; + const inputType = element.type; + if (!!inputType && inputType === "radio") { + const valueMatches = element.value.match(CREDIT_CARD_NETWORK_REGEXP); + if (valueMatches) { + return valueMatches.length === 1; + } + + // Here we are checking that only one label matches only one entry in the regular expression. + const labels = getElementLabels(element); + let labelsMatched = 0; + for (const label of labels) { + const labelMatches = label.match(CREDIT_CARD_NETWORK_REGEXP); + if (labelMatches) { + if (labelMatches.length > 1) { + return false; + } + labelsMatched++; + } + } + if (labelsMatched > 0) { + return labelsMatched === 1; + } + + const textContentMatches = element.textContent.match( + CREDIT_CARD_NETWORK_REGEXP + ); + if (textContentMatches) { + return textContentMatches.length === 1; + } + } + return false; +} + +function matchContiguousSubArray(array, subArray) { + return array.some((elm, i) => + subArray.every((sElem, j) => sElem === array[i + j]) + ); +} + +function isExpirationMonthLikely(element) { + if (element.tagName !== "SELECT") { + return false; + } + + const options = [...element.options]; + const desiredValues = Array(12) + .fill(1) + .map((v, i) => v + i); + + // The number of month options shouldn't be less than 12 or larger than 13 + // including the default option. + if (options.length < 12 || options.length > 13) { + return false; + } + + return ( + matchContiguousSubArray( + options.map(e => +e.value), + desiredValues + ) || + matchContiguousSubArray( + options.map(e => +e.label), + desiredValues + ) + ); +} + +function isExpirationYearLikely(element) { + if (element.tagName !== "SELECT") { + return false; + } + + const options = [...element.options]; + // A normal expiration year select should contain at least the last three years + // in the list. + const curYear = new Date().getFullYear(); + const desiredValues = Array(3) + .fill(0) + .map((v, i) => v + curYear + i); + + return ( + matchContiguousSubArray( + options.map(e => +e.value), + desiredValues + ) || + matchContiguousSubArray( + options.map(e => +e.label), + desiredValues + ) + ); +} + +function nextFieldIsExpirationYearLikely(fnode) { + return nextFieldPredicateIsTrue(fnode.element, isExpirationYearLikely); +} + +function previousFieldIsExpirationMonthLikely(fnode) { + return previousFieldPredicateIsTrue(fnode.element, isExpirationMonthLikely); +} + +function attrsMatchExpWith2Or4DigitYear(fnode, regExpMatchingFunction) { + const element = fnode.element; + return ( + regExpMatchingFunction(element, TwoDigitYearRegExp) || + regExpMatchingFunction(element, FourDigitYearRegExp) + ); +} + +function maxLengthIs(fnode, maxLengthValue) { + return fnode.element.maxLength === maxLengthValue; +} + +function roleIsMenu(fnode) { + const role = fnode.element.getAttribute("role"); + return !!role && role === "menu"; +} + +function idOrNameMatchDwfrmAndBml(fnode) { + return ( + idOrNameMatchRegExp(fnode.element, dwfrmRegExp) && + idOrNameMatchRegExp(fnode.element, bmlRegExp) + ); +} + +function hasTemplatedValue(fnode) { + const value = fnode.element.getAttribute("value"); + return !!value && templatedValue.test(value); +} + +function inputTypeNotNumbery(fnode) { + const inputType = fnode.element.type; + if (inputType) { + return !["text", "tel", "number"].includes(inputType); + } + return false; +} + +function idOrNameMatchFirstAndLast(fnode) { + return ( + idOrNameMatchRegExp(fnode.element, firstRegExp) && + idOrNameMatchRegExp(fnode.element, lastRegExp) + ); +} + +/** + * Compactly generate a series of rules that all take a single LHS type with no + * .when() clause and have only a score() call on the right- hand side. + * + * @param {Lhs} inType The incoming fnode type that all rules take + * @param {object} ruleMap A simple object used as a map with rule names + * pointing to scoring callbacks + * @yields {Rule} + */ +function* simpleScoringRules(inType, ruleMap) { + for (const [name, scoringCallback] of Object.entries(ruleMap)) { + yield rule(type(inType), score(scoringCallback), { name }); + } +} + +function makeRuleset(coeffs, biases) { + return ruleset( + [ + /** + * Factor out the page scan just for a little more speed during training. + * This selector is good for most fields. cardType is an exception: it + * cannot be type=month. + */ + rule( + queriedOrClickedElements( + "input:not([type]), input[type=text], input[type=textbox], input[type=email], input[type=tel], input[type=number], input[type=month], select, button" + ), + type("typicalCandidates") + ), + + /** + * number rules + */ + rule(type("typicalCandidates"), type("cc-number")), + ...simpleScoringRules("cc-number", { + idOrNameMatchNumberRegExp: fnode => + idOrNameMatchRegExp( + fnode.element, + FathomHeuristicsRegExp.RULES["cc-number"] + ), + labelsMatchNumberRegExp: fnode => + labelsMatchRegExp(fnode.element, FathomHeuristicsRegExp.RULES["cc-number"]), + closestLabelMatchesNumberRegExp: fnode => + closestLabelMatchesRegExp(fnode.element, FathomHeuristicsRegExp.RULES["cc-number"]), + placeholderMatchesNumberRegExp: fnode => + placeholderMatchesRegExp( + fnode.element, + FathomHeuristicsRegExp.RULES["cc-number"] + ), + ariaLabelMatchesNumberRegExp: fnode => + ariaLabelMatchesRegExp( + fnode.element, + FathomHeuristicsRegExp.RULES["cc-number"] + ), + idOrNameMatchGift: fnode => + idOrNameMatchRegExp(fnode.element, giftRegExp), + labelsMatchGift: fnode => labelsMatchRegExp(fnode.element, giftRegExp), + placeholderMatchesGift: fnode => + placeholderMatchesRegExp(fnode.element, giftRegExp), + ariaLabelMatchesGift: fnode => + ariaLabelMatchesRegExp(fnode.element, giftRegExp), + idOrNameMatchSubscription: fnode => + idOrNameMatchRegExp(fnode.element, subscriptionRegExp), + idOrNameMatchDwfrmAndBml, + hasTemplatedValue, + inputTypeNotNumbery, + }), + rule(type("cc-number"), out("cc-number")), + + /** + * name rules + */ + rule(type("typicalCandidates"), type("cc-name")), + ...simpleScoringRules("cc-name", { + idOrNameMatchNameRegExp: fnode => + idOrNameMatchRegExp(fnode.element, FathomHeuristicsRegExp.RULES["cc-name"]), + labelsMatchNameRegExp: fnode => + labelsMatchRegExp(fnode.element, FathomHeuristicsRegExp.RULES["cc-name"]), + closestLabelMatchesNameRegExp: fnode => + closestLabelMatchesRegExp(fnode.element, FathomHeuristicsRegExp.RULES["cc-name"]), + placeholderMatchesNameRegExp: fnode => + placeholderMatchesRegExp( + fnode.element, + FathomHeuristicsRegExp.RULES["cc-name"] + ), + ariaLabelMatchesNameRegExp: fnode => + ariaLabelMatchesRegExp( + fnode.element, + FathomHeuristicsRegExp.RULES["cc-name"] + ), + idOrNameMatchFirst: fnode => + idOrNameMatchRegExp(fnode.element, firstRegExp), + labelsMatchFirst: fnode => + labelsMatchRegExp(fnode.element, firstRegExp), + placeholderMatchesFirst: fnode => + placeholderMatchesRegExp(fnode.element, firstRegExp), + ariaLabelMatchesFirst: fnode => + ariaLabelMatchesRegExp(fnode.element, firstRegExp), + idOrNameMatchLast: fnode => + idOrNameMatchRegExp(fnode.element, lastRegExp), + labelsMatchLast: fnode => labelsMatchRegExp(fnode.element, lastRegExp), + placeholderMatchesLast: fnode => + placeholderMatchesRegExp(fnode.element, lastRegExp), + ariaLabelMatchesLast: fnode => + ariaLabelMatchesRegExp(fnode.element, lastRegExp), + idOrNameMatchSubscription: fnode => + idOrNameMatchRegExp(fnode.element, subscriptionRegExp), + idOrNameMatchFirstAndLast, + idOrNameMatchDwfrmAndBml, + hasTemplatedValue, + }), + rule(type("cc-name"), out("cc-name")), + + /** + * cardType rules + */ + rule( + queriedOrClickedElements( + "input:not([type]), input[type=text], input[type=textbox], input[type=email], input[type=tel], input[type=number], input[type=radio], select, button" + ), + type("cc-type") + ), + ...simpleScoringRules("cc-type", { + idOrNameMatchTypeRegExp: fnode => + idOrNameMatchRegExp(fnode.element, FathomHeuristicsRegExp.RULES["cc-type"]), + labelsMatchTypeRegExp: fnode => + labelsMatchRegExp(fnode.element, FathomHeuristicsRegExp.RULES["cc-type"]), + closestLabelMatchesTypeRegExp: fnode => + closestLabelMatchesRegExp(fnode.element, FathomHeuristicsRegExp.RULES["cc-type"]), + idOrNameMatchVisaCheckout: fnode => + idOrNameMatchRegExp(fnode.element, VisaCheckoutRegExp), + ariaLabelMatchesVisaCheckout: fnode => + ariaLabelMatchesRegExp(fnode.element, VisaCheckoutRegExp), + isSelectWithCreditCardOptions, + isRadioWithCreditCardText, + idOrNameMatchSubscription: fnode => + idOrNameMatchRegExp(fnode.element, subscriptionRegExp), + idOrNameMatchDwfrmAndBml, + hasTemplatedValue, + }), + rule(type("cc-type"), out("cc-type")), + + /** + * expiration rules + */ + rule(type("typicalCandidates"), type("cc-exp")), + ...simpleScoringRules("cc-exp", { + labelsMatchExpRegExp: fnode => + labelsMatchRegExp(fnode.element, FathomHeuristicsRegExp.RULES["cc-exp"]), + closestLabelMatchesExpRegExp: fnode => + closestLabelMatchesRegExp(fnode.element, FathomHeuristicsRegExp.RULES["cc-exp"]), + placeholderMatchesExpRegExp: fnode => + placeholderMatchesRegExp( + fnode.element, + FathomHeuristicsRegExp.RULES["cc-exp"] + ), + labelsMatchExpWith2Or4DigitYear: fnode => + attrsMatchExpWith2Or4DigitYear(fnode, labelsMatchRegExp), + placeholderMatchesExpWith2Or4DigitYear: fnode => + attrsMatchExpWith2Or4DigitYear(fnode, placeholderMatchesRegExp), + labelsMatchMMYY: fnode => labelsMatchRegExp(fnode.element, MMYYRegExp), + placeholderMatchesMMYY: fnode => + placeholderMatchesRegExp(fnode.element, MMYYRegExp), + maxLengthIs7: fnode => maxLengthIs(fnode, 7), + idOrNameMatchSubscription: fnode => + idOrNameMatchRegExp(fnode.element, subscriptionRegExp), + idOrNameMatchDwfrmAndBml, + hasTemplatedValue, + isExpirationMonthLikely: fnode => + isExpirationMonthLikely(fnode.element), + isExpirationYearLikely: fnode => isExpirationYearLikely(fnode.element), + idOrNameMatchMonth: fnode => + idOrNameMatchRegExp(fnode.element, monthRegExp), + idOrNameMatchYear: fnode => + idOrNameMatchRegExp(fnode.element, yearRegExp), + idOrNameMatchExpMonthRegExp: fnode => + idOrNameMatchRegExp( + fnode.element, + FathomHeuristicsRegExp.RULES["cc-exp-month"] + ), + idOrNameMatchExpYearRegExp: fnode => + idOrNameMatchRegExp( + fnode.element, + FathomHeuristicsRegExp.RULES["cc-exp-year"] + ), + idOrNameMatchValidation: fnode => + idOrNameMatchRegExp(fnode.element, /validate|validation/i), + }), + rule(type("cc-exp"), out("cc-exp")), + + /** + * expirationMonth rules + */ + rule(type("typicalCandidates"), type("cc-exp-month")), + ...simpleScoringRules("cc-exp-month", { + idOrNameMatchExpMonthRegExp: fnode => + idOrNameMatchRegExp( + fnode.element, + FathomHeuristicsRegExp.RULES["cc-exp-month"] + ), + labelsMatchExpMonthRegExp: fnode => + labelsMatchRegExp( + fnode.element, + FathomHeuristicsRegExp.RULES["cc-exp-month"] + ), + closestLabelMatchesExpMonthRegExp: fnode => + closestLabelMatchesRegExp( + fnode.element, + FathomHeuristicsRegExp.RULES["cc-exp-month"] + ), + placeholderMatchesExpMonthRegExp: fnode => + placeholderMatchesRegExp( + fnode.element, + FathomHeuristicsRegExp.RULES["cc-exp-month"] + ), + ariaLabelMatchesExpMonthRegExp: fnode => + ariaLabelMatchesRegExp( + fnode.element, + FathomHeuristicsRegExp.RULES["cc-exp-month"] + ), + idOrNameMatchMonth: fnode => + idOrNameMatchRegExp(fnode.element, monthRegExp), + labelsMatchMonth: fnode => + labelsMatchRegExp(fnode.element, monthRegExp), + placeholderMatchesMonth: fnode => + placeholderMatchesRegExp(fnode.element, monthRegExp), + ariaLabelMatchesMonth: fnode => + ariaLabelMatchesRegExp(fnode.element, monthRegExp), + nextFieldIdOrNameMatchExpYearRegExp: fnode => + nextFieldIdOrNameMatchRegExp( + fnode.element, + FathomHeuristicsRegExp.RULES["cc-exp-year"] + ), + nextFieldLabelsMatchExpYearRegExp: fnode => + nextFieldLabelsMatchRegExp( + fnode.element, + FathomHeuristicsRegExp.RULES["cc-exp-year"] + ), + nextFieldPlaceholderMatchExpYearRegExp: fnode => + nextFieldPlaceholderMatchesRegExp( + fnode.element, + FathomHeuristicsRegExp.RULES["cc-exp-year"] + ), + nextFieldAriaLabelMatchExpYearRegExp: fnode => + nextFieldAriaLabelMatchesRegExp( + fnode.element, + FathomHeuristicsRegExp.RULES["cc-exp-year"] + ), + nextFieldIdOrNameMatchYear: fnode => + nextFieldIdOrNameMatchRegExp(fnode.element, yearRegExp), + nextFieldLabelsMatchYear: fnode => + nextFieldLabelsMatchRegExp(fnode.element, yearRegExp), + nextFieldPlaceholderMatchesYear: fnode => + nextFieldPlaceholderMatchesRegExp(fnode.element, yearRegExp), + nextFieldAriaLabelMatchesYear: fnode => + nextFieldAriaLabelMatchesRegExp(fnode.element, yearRegExp), + nextFieldMatchesExpYearAutocomplete, + isExpirationMonthLikely: fnode => + isExpirationMonthLikely(fnode.element), + nextFieldIsExpirationYearLikely, + maxLengthIs2: fnode => maxLengthIs(fnode, 2), + placeholderMatchesMM: fnode => + placeholderMatchesRegExp(fnode.element, MMRegExp), + roleIsMenu, + idOrNameMatchSubscription: fnode => + idOrNameMatchRegExp(fnode.element, subscriptionRegExp), + idOrNameMatchDwfrmAndBml, + hasTemplatedValue, + }), + rule(type("cc-exp-month"), out("cc-exp-month")), + + /** + * expirationYear rules + */ + rule(type("typicalCandidates"), type("cc-exp-year")), + ...simpleScoringRules("cc-exp-year", { + idOrNameMatchExpYearRegExp: fnode => + idOrNameMatchRegExp( + fnode.element, + FathomHeuristicsRegExp.RULES["cc-exp-year"] + ), + labelsMatchExpYearRegExp: fnode => + labelsMatchRegExp( + fnode.element, + FathomHeuristicsRegExp.RULES["cc-exp-year"] + ), + closestLabelMatchesExpYearRegExp: fnode => + closestLabelMatchesRegExp( + fnode.element, + FathomHeuristicsRegExp.RULES["cc-exp-year"] + ), + placeholderMatchesExpYearRegExp: fnode => + placeholderMatchesRegExp( + fnode.element, + FathomHeuristicsRegExp.RULES["cc-exp-year"] + ), + ariaLabelMatchesExpYearRegExp: fnode => + ariaLabelMatchesRegExp( + fnode.element, + FathomHeuristicsRegExp.RULES["cc-exp-year"] + ), + idOrNameMatchYear: fnode => + idOrNameMatchRegExp(fnode.element, yearRegExp), + labelsMatchYear: fnode => labelsMatchRegExp(fnode.element, yearRegExp), + placeholderMatchesYear: fnode => + placeholderMatchesRegExp(fnode.element, yearRegExp), + ariaLabelMatchesYear: fnode => + ariaLabelMatchesRegExp(fnode.element, yearRegExp), + previousFieldIdOrNameMatchExpMonthRegExp: fnode => + previousFieldIdOrNameMatchRegExp( + fnode.element, + FathomHeuristicsRegExp.RULES["cc-exp-month"] + ), + previousFieldLabelsMatchExpMonthRegExp: fnode => + previousFieldLabelsMatchRegExp( + fnode.element, + FathomHeuristicsRegExp.RULES["cc-exp-month"] + ), + previousFieldPlaceholderMatchExpMonthRegExp: fnode => + previousFieldPlaceholderMatchesRegExp( + fnode.element, + FathomHeuristicsRegExp.RULES["cc-exp-month"] + ), + previousFieldAriaLabelMatchExpMonthRegExp: fnode => + previousFieldAriaLabelMatchesRegExp( + fnode.element, + FathomHeuristicsRegExp.RULES["cc-exp-month"] + ), + previousFieldIdOrNameMatchMonth: fnode => + previousFieldIdOrNameMatchRegExp(fnode.element, monthRegExp), + previousFieldLabelsMatchMonth: fnode => + previousFieldLabelsMatchRegExp(fnode.element, monthRegExp), + previousFieldPlaceholderMatchesMonth: fnode => + previousFieldPlaceholderMatchesRegExp(fnode.element, monthRegExp), + previousFieldAriaLabelMatchesMonth: fnode => + previousFieldAriaLabelMatchesRegExp(fnode.element, monthRegExp), + previousFieldMatchesExpMonthAutocomplete, + isExpirationYearLikely: fnode => isExpirationYearLikely(fnode.element), + previousFieldIsExpirationMonthLikely, + placeholderMatchesYYOrYYYY: fnode => + placeholderMatchesRegExp(fnode.element, YYorYYYYRegExp), + roleIsMenu, + idOrNameMatchSubscription: fnode => + idOrNameMatchRegExp(fnode.element, subscriptionRegExp), + idOrNameMatchDwfrmAndBml, + hasTemplatedValue, + }), + rule(type("cc-exp-year"), out("cc-exp-year")), + ], + coeffs, + biases + ); +} + +const coefficients = { + "cc-number": [ + ["idOrNameMatchNumberRegExp", 7.679469585418701], + ["labelsMatchNumberRegExp", 5.122580051422119], + ["closestLabelMatchesNumberRegExp", 2.1256935596466064], + ["placeholderMatchesNumberRegExp", 9.471800804138184], + ["ariaLabelMatchesNumberRegExp", 6.067715644836426], + ["idOrNameMatchGift", -22.946273803710938], + ["labelsMatchGift", -7.852959632873535], + ["placeholderMatchesGift", -2.355496406555176], + ["ariaLabelMatchesGift", -2.940307855606079], + ["idOrNameMatchSubscription", 0.11255314946174622], + ["idOrNameMatchDwfrmAndBml", -0.0006645023822784424], + ["hasTemplatedValue", -0.11370040476322174], + ["inputTypeNotNumbery", -3.750155210494995] + ], + "cc-name": [ + ["idOrNameMatchNameRegExp", 7.496212959289551], + ["labelsMatchNameRegExp", 6.081472873687744], + ["closestLabelMatchesNameRegExp", 2.600574254989624], + ["placeholderMatchesNameRegExp", 5.750874042510986], + ["ariaLabelMatchesNameRegExp", 5.162227153778076], + ["idOrNameMatchFirst", -6.742659091949463], + ["labelsMatchFirst", -0.5234538912773132], + ["placeholderMatchesFirst", -3.4615235328674316], + ["ariaLabelMatchesFirst", -1.3145145177841187], + ["idOrNameMatchLast", -12.561869621276855], + ["labelsMatchLast", -0.27417105436325073], + ["placeholderMatchesLast", -1.434966802597046], + ["ariaLabelMatchesLast", -2.9319725036621094], + ["idOrNameMatchFirstAndLast", 24.123435974121094], + ["idOrNameMatchSubscription", 0.08349418640136719], + ["idOrNameMatchDwfrmAndBml", 0.01882520318031311], + ["hasTemplatedValue", 0.182317852973938] + ], + "cc-type": [ + ["idOrNameMatchTypeRegExp", 2.0581533908843994], + ["labelsMatchTypeRegExp", 1.0784518718719482], + ["closestLabelMatchesTypeRegExp", 0.6995877623558044], + ["idOrNameMatchVisaCheckout", -3.320356845855713], + ["ariaLabelMatchesVisaCheckout", -3.4196767807006836], + ["isSelectWithCreditCardOptions", 10.337477684020996], + ["isRadioWithCreditCardText", 4.530318737030029], + ["idOrNameMatchSubscription", -3.7206356525421143], + ["idOrNameMatchDwfrmAndBml", -0.08782318234443665], + ["hasTemplatedValue", 0.1772511601448059] + ], + "cc-exp": [ + ["labelsMatchExpRegExp", 7.588159561157227], + ["closestLabelMatchesExpRegExp", 1.41484534740448], + ["placeholderMatchesExpRegExp", 8.759064674377441], + ["labelsMatchExpWith2Or4DigitYear", -3.876218795776367], + ["placeholderMatchesExpWith2Or4DigitYear", 2.8364884853363037], + ["labelsMatchMMYY", 8.836017608642578], + ["placeholderMatchesMMYY", -0.5231751799583435], + ["maxLengthIs7", 1.3565447330474854], + ["idOrNameMatchSubscription", 0.1779913753271103], + ["idOrNameMatchDwfrmAndBml", 0.21037884056568146], + ["hasTemplatedValue", 0.14900512993335724], + ["isExpirationMonthLikely", -3.223409652709961], + ["isExpirationYearLikely", -2.536919593811035], + ["idOrNameMatchMonth", -3.6893014907836914], + ["idOrNameMatchYear", -3.108184337615967], + ["idOrNameMatchExpMonthRegExp", -2.264357089996338], + ["idOrNameMatchExpYearRegExp", -2.7957723140716553], + ["idOrNameMatchValidation", -2.29402756690979] + ], + "cc-exp-month": [ + ["idOrNameMatchExpMonthRegExp", 0.2787344455718994], + ["labelsMatchExpMonthRegExp", 1.298413634300232], + ["closestLabelMatchesExpMonthRegExp", -11.206244468688965], + ["placeholderMatchesExpMonthRegExp", 1.2605619430541992], + ["ariaLabelMatchesExpMonthRegExp", 1.1330018043518066], + ["idOrNameMatchMonth", 6.1464314460754395], + ["labelsMatchMonth", 0.7051732540130615], + ["placeholderMatchesMonth", 0.7463492751121521], + ["ariaLabelMatchesMonth", 1.8244760036468506], + ["nextFieldIdOrNameMatchExpYearRegExp", 0.06347066164016724], + ["nextFieldLabelsMatchExpYearRegExp", -0.1692247837781906], + ["nextFieldPlaceholderMatchExpYearRegExp", 1.0434566736221313], + ["nextFieldAriaLabelMatchExpYearRegExp", 1.751156210899353], + ["nextFieldIdOrNameMatchYear", -0.532447338104248], + ["nextFieldLabelsMatchYear", 1.3248541355133057], + ["nextFieldPlaceholderMatchesYear", 0.604235827922821], + ["nextFieldAriaLabelMatchesYear", 1.5364223718643188], + ["nextFieldMatchesExpYearAutocomplete", 6.285938262939453], + ["isExpirationMonthLikely", 13.117807388305664], + ["nextFieldIsExpirationYearLikely", 7.182341575622559], + ["maxLengthIs2", 4.477289199829102], + ["placeholderMatchesMM", 14.403288841247559], + ["roleIsMenu", 5.770959854125977], + ["idOrNameMatchSubscription", -0.043085768818855286], + ["idOrNameMatchDwfrmAndBml", 0.02823038399219513], + ["hasTemplatedValue", 0.07234494388103485] + ], + "cc-exp-year": [ + ["idOrNameMatchExpYearRegExp", 5.426016807556152], + ["labelsMatchExpYearRegExp", 1.3240209817886353], + ["closestLabelMatchesExpYearRegExp", -8.702284812927246], + ["placeholderMatchesExpYearRegExp", 0.9059725999832153], + ["ariaLabelMatchesExpYearRegExp", 0.5550334453582764], + ["idOrNameMatchYear", 5.362994194030762], + ["labelsMatchYear", 2.7185044288635254], + ["placeholderMatchesYear", 0.7883157134056091], + ["ariaLabelMatchesYear", 0.311492383480072], + ["previousFieldIdOrNameMatchExpMonthRegExp", 1.8155208826065063], + ["previousFieldLabelsMatchExpMonthRegExp", -0.46133187413215637], + ["previousFieldPlaceholderMatchExpMonthRegExp", 1.0374903678894043], + ["previousFieldAriaLabelMatchExpMonthRegExp", -0.5901495814323425], + ["previousFieldIdOrNameMatchMonth", -5.960310935974121], + ["previousFieldLabelsMatchMonth", 0.6495584845542908], + ["previousFieldPlaceholderMatchesMonth", 0.7198042273521423], + ["previousFieldAriaLabelMatchesMonth", 3.4590985774993896], + ["previousFieldMatchesExpMonthAutocomplete", 2.986003875732422], + ["isExpirationYearLikely", 4.021566390991211], + ["previousFieldIsExpirationMonthLikely", 9.298635482788086], + ["placeholderMatchesYYOrYYYY", 10.457176208496094], + ["roleIsMenu", 1.1051956415176392], + ["idOrNameMatchSubscription", 0.000688597559928894], + ["idOrNameMatchDwfrmAndBml", 0.15687309205532074], + ["hasTemplatedValue", -0.19141331315040588] + ], +}; + +const biases = [ + ["cc-number", -4.948795795440674], + ["cc-name", -5.3578081130981445], + ["cc-type", -5.979659557342529], + ["cc-exp", -5.849575996398926], + ["cc-exp-month", -8.844199180603027], + ["cc-exp-year", -6.499860763549805], +]; + +/** + * END OF CODE PASTED FROM TRAINING REPOSITORY + */ + +/** + * MORE CODE UNIQUE TO PRODUCTION--NOT IN THE TRAINING REPOSITORY: + */ +// Currently there is a bug when a ruleset has multple types (ex, cc-name, cc-number) +// and those types also has the same rules (ex. rule `hasTemplatedValue` is used in +// all the tyoes). When the above case exists, the coefficient of the rule will be +// overwritten, which means, we can't have different coefficient for the same rule on +// different types. To workaround this issue, we create a new ruleset for each type. +export var CreditCardRulesets = { + init() { + XPCOMUtils.defineLazyPreferenceGetter( + this, + "supportedTypes", + "extensions.formautofill.creditCards.heuristics.fathom.types", + null, + null, + val => val.split(",") + ); + + for (const type of this.types) { + this[type] = makeRuleset([...coefficients[type]], biases); + } + }, + + get types() { + return this.supportedTypes; + }, +}; + +CreditCardRulesets.init(); + +export default CreditCardRulesets; diff --git a/toolkit/components/formautofill/shared/FieldScanner.sys.mjs b/toolkit/components/formautofill/shared/FieldScanner.sys.mjs new file mode 100644 index 0000000000..ba64d046ea --- /dev/null +++ b/toolkit/components/formautofill/shared/FieldScanner.sys.mjs @@ -0,0 +1,211 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/** + * Represents the detailed information about a form field, including + * the inferred field name, the approach used for inferring, and additional metadata. + */ +export class FieldDetail { + // Reference to the elemenet + elementWeakRef = null; + + // The inferred field name for this element + fieldName = null; + + // The approach we use to infer the information for this element + // The possible values are "autocomplete", "fathom", and "regex-heuristic" + reason = null; + + /* + * The "section", "addressType", and "contactType" values are + * used to identify the exact field when the serializable data is received + * from the backend. There cannot be multiple fields which have + * the same exact combination of these values. + */ + + // Which section the field belongs to. The value comes from autocomplete attribute. + // See https://html.spec.whatwg.org/multipage/form-control-infrastructure.html#autofill-detail-tokens for more details + section = ""; + addressType = ""; + contactType = ""; + + // When a field is split into N fields, we use part to record which field it is + // For example, a credit card number field is split into 4 fields, the value of + // "part" for the first cc-number field is 1, for the last one is 4. + // If the field is not split, the value is null + part = null; + + // Confidence value when the field name is inferred by "fathom" + confidence = null; + + constructor( + element, + fieldName, + { autocompleteInfo = {}, confidence = null } + ) { + this.elementWeakRef = Cu.getWeakReference(element); + this.fieldName = fieldName; + + if (autocompleteInfo) { + this.reason = "autocomplete"; + this.section = autocompleteInfo.section; + this.addressType = autocompleteInfo.addressType; + this.contactType = autocompleteInfo.contactType; + } else if (confidence) { + this.reason = "fathom"; + this.confidence = confidence; + } else { + this.reason = "regex-heuristic"; + } + } + + get element() { + return this.elementWeakRef.get(); + } + + get sectionName() { + return this.section || this.addressType; + } +} + +/** + * A scanner for traversing all elements in a form. It also provides a + * cursor (parsingIndex) to indicate which element is waiting for parsing. + * + * The scanner retrives the field detail by calling heuristics handlers + * `inferFieldInfo` function. + */ +export class FieldScanner { + #elementsWeakRef = null; + #inferFieldInfoFn = null; + + #parsingIndex = 0; + + fieldDetails = []; + + /** + * Create a FieldScanner based on form elements with the existing + * fieldDetails. + * + * @param {Array.DOMElement} elements + * The elements from a form for each parser. + * @param {Funcion} inferFieldInfoFn + * The callback function that is used to infer the field info of a given element + */ + constructor(elements, inferFieldInfoFn) { + this.#elementsWeakRef = Cu.getWeakReference(elements); + this.#inferFieldInfoFn = inferFieldInfoFn; + } + + get #elements() { + return this.#elementsWeakRef.get(); + } + + /** + * This cursor means the index of the element which is waiting for parsing. + * + * @returns {number} + * The index of the element which is waiting for parsing. + */ + get parsingIndex() { + return this.#parsingIndex; + } + + get parsingFinished() { + return this.parsingIndex >= this.#elements.length; + } + + /** + * Move the parsingIndex to the next elements. Any elements behind this index + * means the parsing tasks are finished. + * + * @param {number} index + * The latest index of elements waiting for parsing. + */ + set parsingIndex(index) { + if (index > this.#elements.length) { + throw new Error("The parsing index is out of range."); + } + this.#parsingIndex = index; + } + + /** + * Retrieve the field detail by the index. If the field detail is not ready, + * the elements will be traversed until matching the index. + * + * @param {number} index + * The index of the element that you want to retrieve. + * @returns {object} + * The field detail at the specific index. + */ + getFieldDetailByIndex(index) { + if (index >= this.#elements.length) { + throw new Error( + `The index ${index} is out of range.(${this.#elements.length})` + ); + } + + if (index < this.fieldDetails.length) { + return this.fieldDetails[index]; + } + + for (let i = this.fieldDetails.length; i < index + 1; i++) { + this.pushDetail(); + } + + return this.fieldDetails[index]; + } + + /** + * This function retrieves the first unparsed element and obtains its + * information by invoking the `inferFieldInfoFn` callback function. + * The field information is then stored in a FieldDetail object and + * appended to the `fieldDetails` array. + * + * Any element without the related detail will be used for adding the detail + * to the end of field details. + */ + pushDetail() { + const elementIndex = this.fieldDetails.length; + if (elementIndex >= this.#elements.length) { + throw new Error("Try to push the non-existing element info."); + } + const element = this.#elements[elementIndex]; + const [fieldName, autocompleteInfo, confidence] = + this.#inferFieldInfoFn(element); + const fieldDetail = new FieldDetail(element, fieldName, { + autocompleteInfo, + confidence, + }); + + this.fieldDetails.push(fieldDetail); + } + + /** + * When a field detail should be changed its fieldName after parsing, use + * this function to update the fieldName which is at a specific index. + * + * @param {number} index + * The index indicates a field detail to be updated. + * @param {string} fieldName + * The new fieldName + * @param {string} reason + * What approach we use to identify this field + */ + updateFieldName(index, fieldName, reason = null) { + if (index >= this.fieldDetails.length) { + throw new Error("Try to update the non-existing field detail."); + } + this.fieldDetails[index].fieldName = fieldName; + if (reason) { + this.fieldDetails[index].reason = reason; + } + } + + elementExisting(index) { + return index < this.#elements.length; + } +} + +export default FieldScanner; diff --git a/toolkit/components/formautofill/shared/FormAutofillHandler.sys.mjs b/toolkit/components/formautofill/shared/FormAutofillHandler.sys.mjs new file mode 100644 index 0000000000..b84064b716 --- /dev/null +++ b/toolkit/components/formautofill/shared/FormAutofillHandler.sys.mjs @@ -0,0 +1,400 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { FormAutofill } from "resource://autofill/FormAutofill.sys.mjs"; +import { FormAutofillUtils } from "resource://gre/modules/shared/FormAutofillUtils.sys.mjs"; +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; + +const lazy = {}; +ChromeUtils.defineESModuleGetters(lazy, { + FormAutofillAddressSection: + "resource://gre/modules/shared/FormAutofillSection.sys.mjs", + FormAutofillCreditCardSection: + "resource://gre/modules/shared/FormAutofillSection.sys.mjs", + FormAutofillHeuristics: + "resource://gre/modules/shared/FormAutofillHeuristics.sys.mjs", + FormLikeFactory: "resource://gre/modules/FormLikeFactory.sys.mjs", + FormSection: "resource://gre/modules/shared/FormAutofillHeuristics.sys.mjs", +}); + +const { FIELD_STATES } = FormAutofillUtils; + +/** + * Handles profile autofill for a DOM Form element. + */ +export class FormAutofillHandler { + // The window to which this form belongs + window = null; + + // A WindowUtils reference of which Window the form belongs + winUtils = null; + + // DOM Form element to which this object is attached + form = null; + + // An array of section that are found in this form + sections = []; + + // The section contains the focused input + #focusedSection = null; + + // Caches the element to section mapping + #cachedSectionByElement = new WeakMap(); + + // Keeps track of filled state for all identified elements + #filledStateByElement = new WeakMap(); + /** + * Array of collected data about relevant form fields. Each item is an object + * storing the identifying details of the field and a reference to the + * originally associated element from the form. + * + * The "section", "addressType", "contactType", and "fieldName" values are + * used to identify the exact field when the serializable data is received + * from the backend. There cannot be multiple fields which have + * the same exact combination of these values. + * + * A direct reference to the associated element cannot be sent to the user + * interface because processing may be done in the parent process. + */ + fieldDetails = null; + + /** + * Initialize the form from `FormLike` object to handle the section or form + * operations. + * + * @param {FormLike} form Form that need to be auto filled + * @param {Function} onFormSubmitted Function that can be invoked + * to simulate form submission. Function is passed + * three arguments: (1) a FormLike for the form being + * submitted, (2) the corresponding Window, and (3) the + * responsible FormAutofillHandler. + * @param {Function} onAutofillCallback Function that can be invoked + * when we want to suggest autofill on a form. + */ + constructor(form, onFormSubmitted = () => {}, onAutofillCallback = () => {}) { + this._updateForm(form); + + this.window = this.form.rootElement.ownerGlobal; + this.winUtils = this.window.windowUtils; + + // Enum for form autofill MANUALLY_MANAGED_STATES values + this.FIELD_STATE_ENUM = { + // not themed + [FIELD_STATES.NORMAL]: null, + // highlighted + [FIELD_STATES.AUTO_FILLED]: "autofill", + // highlighted && grey color text + [FIELD_STATES.PREVIEW]: "-moz-autofill-preview", + }; + + /** + * This function is used if the form handler (or one of its sections) + * determines that it needs to act as if the form had been submitted. + */ + this.onFormSubmitted = () => { + onFormSubmitted(this.form, this.window, this); + }; + + this.onAutofillCallback = onAutofillCallback; + + XPCOMUtils.defineLazyGetter(this, "log", () => + FormAutofill.defineLogGetter(this, "FormAutofillHandler") + ); + } + + handleEvent(event) { + switch (event.type) { + case "input": { + if (!event.isTrusted) { + return; + } + const target = event.target; + const targetFieldDetail = this.getFieldDetailByElement(target); + const isCreditCardField = FormAutofillUtils.isCreditCardField( + targetFieldDetail.fieldName + ); + + // If the user manually blanks a credit card field, then + // we want the popup to be activated. + if ( + !HTMLSelectElement.isInstance(target) && + isCreditCardField && + target.value === "" + ) { + this.onAutofillCallback(); + } + + if (this.getFilledStateByElement(target) == FIELD_STATES.NORMAL) { + return; + } + + this.changeFieldState(targetFieldDetail, FIELD_STATES.NORMAL); + const section = this.getSectionByElement( + targetFieldDetail.elementWeakRef.get() + ); + section?.clearFilled(targetFieldDetail); + } + } + } + + set focusedInput(element) { + const section = this.getSectionByElement(element); + if (!section) { + return; + } + + this.#focusedSection = section; + this.#focusedSection.focusedInput = element; + } + + getSectionByElement(element) { + const section = + this.#cachedSectionByElement.get(element) ?? + this.sections.find(s => s.getFieldDetailByElement(element)); + if (!section) { + return null; + } + + this.#cachedSectionByElement.set(element, section); + return section; + } + + getFieldDetailByElement(element) { + for (const section of this.sections) { + const detail = section.getFieldDetailByElement(element); + if (detail) { + return detail; + } + } + return null; + } + + get activeSection() { + return this.#focusedSection; + } + + /** + * Check the form is necessary to be updated. This function should be able to + * detect any changes including all control elements in the form. + * + * @param {HTMLElement} element The element supposed to be in the form. + * @returns {boolean} FormAutofillHandler.form is updated or not. + */ + updateFormIfNeeded(element) { + // When the following condition happens, FormAutofillHandler.form should be + // updated: + // * The count of form controls is changed. + // * When the element can not be found in the current form. + // + // However, we should improve the function to detect the element changes. + // e.g. a tel field is changed from type="hidden" to type="tel". + + let _formLike; + const getFormLike = () => { + if (!_formLike) { + _formLike = lazy.FormLikeFactory.createFromField(element); + } + return _formLike; + }; + + const currentForm = element.form ?? getFormLike(); + if (currentForm.elements.length != this.form.elements.length) { + this.log.debug("The count of form elements is changed."); + this._updateForm(getFormLike()); + return true; + } + + if (!this.form.elements.includes(element)) { + this.log.debug("The element can not be found in the current form."); + this._updateForm(getFormLike()); + return true; + } + + return false; + } + + /** + * Update the form with a new FormLike, and the related fields should be + * updated or clear to ensure the data consistency. + * + * @param {FormLike} form a new FormLike to replace the original one. + */ + _updateForm(form) { + this.form = form; + + this.fieldDetails = null; + + this.sections = []; + this.#cachedSectionByElement = new WeakMap(); + } + + /** + * Set fieldDetails from the form about fields that can be autofilled. + * + * @returns {Array} The valid address and credit card details. + */ + collectFormFields(ignoreInvalid = true) { + const sections = lazy.FormAutofillHeuristics.getFormInfo(this.form); + const allValidDetails = []; + for (const section of sections) { + let autofillableSection; + if (section.type == lazy.FormSection.ADDRESS) { + autofillableSection = new lazy.FormAutofillAddressSection( + section, + this + ); + } else { + autofillableSection = new lazy.FormAutofillCreditCardSection( + section, + this + ); + } + + if (ignoreInvalid && !autofillableSection.isValidSection()) { + continue; + } + + this.sections.push(autofillableSection); + allValidDetails.push(...autofillableSection.fieldDetails); + } + + this.fieldDetails = allValidDetails; + return allValidDetails; + } + + #hasFilledSection() { + return this.sections.some(section => section.isFilled()); + } + + getFilledStateByElement(element) { + return this.#filledStateByElement.get(element); + } + + /** + * Change the state of a field to correspond with different presentations. + * + * @param {object} fieldDetail + * A fieldDetail of which its element is about to update the state. + * @param {string} nextState + * Used to determine the next state + */ + changeFieldState(fieldDetail, nextState) { + const element = fieldDetail.elementWeakRef.get(); + if (!element) { + this.log.warn( + fieldDetail.fieldName, + "is unreachable while changing state" + ); + return; + } + if (!(nextState in this.FIELD_STATE_ENUM)) { + this.log.warn( + fieldDetail.fieldName, + "is trying to change to an invalid state" + ); + return; + } + + if (this.#filledStateByElement.get(element) == nextState) { + return; + } + + let nextStateValue = null; + for (const [state, mmStateValue] of Object.entries(this.FIELD_STATE_ENUM)) { + // The NORMAL state is simply the absence of other manually + // managed states so we never need to add or remove it. + if (!mmStateValue) { + continue; + } + + if (state == nextState) { + nextStateValue = mmStateValue; + } else { + this.winUtils.removeManuallyManagedState(element, mmStateValue); + } + } + + if (nextStateValue) { + this.winUtils.addManuallyManagedState(element, nextStateValue); + } + + if (nextState == FIELD_STATES.AUTO_FILLED) { + element.addEventListener("input", this, { mozSystemGroup: true }); + } + + this.#filledStateByElement.set(element, nextState); + } + + /** + * Processes form fields that can be autofilled, and populates them with the + * profile provided by backend. + * + * @param {object} profile + * A profile to be filled in. + */ + async autofillFormFields(profile) { + const noFilledSectionsPreviously = !this.#hasFilledSection(); + await this.activeSection.autofillFields(profile); + + const onChangeHandler = e => { + if (!e.isTrusted) { + return; + } + if (e.type == "reset") { + this.sections.map(section => section.resetFieldStates()); + } + // Unregister listeners once no field is in AUTO_FILLED state. + if (!this.#hasFilledSection()) { + this.form.rootElement.removeEventListener("input", onChangeHandler, { + mozSystemGroup: true, + }); + this.form.rootElement.removeEventListener("reset", onChangeHandler, { + mozSystemGroup: true, + }); + } + }; + + if (noFilledSectionsPreviously) { + // Handle the highlight style resetting caused by user's correction afterward. + this.log.debug("register change handler for filled form:", this.form); + this.form.rootElement.addEventListener("input", onChangeHandler, { + mozSystemGroup: true, + }); + this.form.rootElement.addEventListener("reset", onChangeHandler, { + mozSystemGroup: true, + }); + } + } + + /** + * Collect the filled sections within submitted form and convert all the valid + * field data into multiple records. + * + * @returns {object} records + * {Array.} records.address + * {Array.} records.creditCard + */ + createRecords() { + const records = { + address: [], + creditCard: [], + }; + + for (const section of this.sections) { + const secRecord = section.createRecord(); + if (!secRecord) { + continue; + } + if (section instanceof lazy.FormAutofillAddressSection) { + records.address.push(secRecord); + } else if (section instanceof lazy.FormAutofillCreditCardSection) { + records.creditCard.push(secRecord); + } else { + throw new Error("Unknown section type"); + } + } + + return records; + } +} diff --git a/toolkit/components/formautofill/shared/FormAutofillHeuristics.sys.mjs b/toolkit/components/formautofill/shared/FormAutofillHeuristics.sys.mjs new file mode 100644 index 0000000000..f73af3a8f3 --- /dev/null +++ b/toolkit/components/formautofill/shared/FormAutofillHeuristics.sys.mjs @@ -0,0 +1,1168 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { FormAutofill } from "resource://autofill/FormAutofill.sys.mjs"; +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; +import { HeuristicsRegExp } from "resource://gre/modules/shared/HeuristicsRegExp.sys.mjs"; + +const lazy = {}; +ChromeUtils.defineESModuleGetters(lazy, { + CreditCard: "resource://gre/modules/CreditCard.sys.mjs", + CreditCardRulesets: "resource://gre/modules/shared/CreditCardRuleset.sys.mjs", + FieldScanner: "resource://gre/modules/shared/FieldScanner.sys.mjs", + FormAutofillUtils: "resource://gre/modules/shared/FormAutofillUtils.sys.mjs", + LabelUtils: "resource://gre/modules/shared/LabelUtils.sys.mjs", +}); + +XPCOMUtils.defineLazyGetter(lazy, "log", () => + FormAutofill.defineLogGetter(lazy, "FormAutofillHeuristics") +); + +/** + * To help us classify sections, we want to know what fields can appear + * multiple times in a row. + * Such fields, like `address-line{X}`, should not break sections. + */ +const MULTI_FIELD_NAMES = [ + "address-level3", + "address-level2", + "address-level1", + "tel", + "postal-code", + "email", + "street-address", +]; + +/** + * To help us classify sections that can appear only N times in a row. + * For example, the only time multiple cc-number fields are valid is when + * there are four of these fields in a row. + * Otherwise, multiple cc-number fields should be in separate sections. + */ +const MULTI_N_FIELD_NAMES = { + "cc-number": 4, +}; + +export class FormSection { + static ADDRESS = "address"; + static CREDIT_CARD = "creditCard"; + + #fieldDetails = []; + + #name = ""; + + constructor(fieldDetails) { + if (!fieldDetails.length) { + throw new TypeError("A section should contain at least one field"); + } + + fieldDetails.forEach(field => this.addField(field)); + + const fieldName = fieldDetails[0].fieldName; + if (lazy.FormAutofillUtils.isAddressField(fieldName)) { + this.type = FormSection.ADDRESS; + } else if (lazy.FormAutofillUtils.isCreditCardField(fieldName)) { + this.type = FormSection.CREDIT_CARD; + } else { + throw new Error("Unknown field type to create a section."); + } + } + + get fieldDetails() { + return this.#fieldDetails; + } + + get name() { + return this.#name; + } + + addField(fieldDetail) { + this.#name ||= fieldDetail.sectionName; + this.#fieldDetails.push(fieldDetail); + } +} + +/** + * Returns the autocomplete information of fields according to heuristics. + */ +export const FormAutofillHeuristics = { + RULES: HeuristicsRegExp.getRules(), + + CREDIT_CARD_FIELDNAMES: [], + ADDRESS_FIELDNAMES: [], + /** + * Try to find a contiguous sub-array within an array. + * + * @param {Array} array + * @param {Array} subArray + * + * @returns {boolean} + * Return whether subArray was found within the array or not. + */ + _matchContiguousSubArray(array, subArray) { + return array.some((elm, i) => + subArray.every((sElem, j) => sElem == array[i + j]) + ); + }, + + /** + * Try to find the field that is look like a month select. + * + * @param {DOMElement} element + * @returns {boolean} + * Return true if we observe the trait of month select in + * the current element. + */ + _isExpirationMonthLikely(element) { + if (!HTMLSelectElement.isInstance(element)) { + return false; + } + + const options = [...element.options]; + const desiredValues = Array(12) + .fill(1) + .map((v, i) => v + i); + + // The number of month options shouldn't be less than 12 or larger than 13 + // including the default option. + if (options.length < 12 || options.length > 13) { + return false; + } + + return ( + this._matchContiguousSubArray( + options.map(e => +e.value), + desiredValues + ) || + this._matchContiguousSubArray( + options.map(e => +e.label), + desiredValues + ) + ); + }, + + /** + * Try to find the field that is look like a year select. + * + * @param {DOMElement} element + * @returns {boolean} + * Return true if we observe the trait of year select in + * the current element. + */ + _isExpirationYearLikely(element) { + if (!HTMLSelectElement.isInstance(element)) { + return false; + } + + const options = [...element.options]; + // A normal expiration year select should contain at least the last three years + // in the list. + const curYear = new Date().getFullYear(); + const desiredValues = Array(3) + .fill(0) + .map((v, i) => v + curYear + i); + + return ( + this._matchContiguousSubArray( + options.map(e => +e.value), + desiredValues + ) || + this._matchContiguousSubArray( + options.map(e => +e.label), + desiredValues + ) + ); + }, + + /** + * Try to match the telephone related fields to the grammar + * list to see if there is any valid telephone set and correct their + * field names. + * + * @param {FieldScanner} fieldScanner + * The current parsing status for all elements + * @returns {boolean} + * Return true if there is any field can be recognized in the parser, + * otherwise false. + */ + _parsePhoneFields(fieldScanner) { + let matchingResult; + + const GRAMMARS = this.PHONE_FIELD_GRAMMARS; + for (let i = 0; i < GRAMMARS.length; i++) { + let detailStart = fieldScanner.parsingIndex; + let ruleStart = i; + for ( + ; + i < GRAMMARS.length && + GRAMMARS[i][0] && + fieldScanner.elementExisting(detailStart); + i++, detailStart++ + ) { + let detail = fieldScanner.getFieldDetailByIndex(detailStart); + if ( + !detail || + GRAMMARS[i][0] != detail.fieldName || + detail?.reason == "autocomplete" + ) { + break; + } + let element = detail.elementWeakRef.get(); + if (!element) { + break; + } + if ( + GRAMMARS[i][2] && + (!element.maxLength || GRAMMARS[i][2] < element.maxLength) + ) { + break; + } + } + if (i >= GRAMMARS.length) { + break; + } + + if (!GRAMMARS[i][0]) { + matchingResult = { + ruleFrom: ruleStart, + ruleTo: i, + }; + break; + } + + // Fast rewinding to the next rule. + for (; i < GRAMMARS.length; i++) { + if (!GRAMMARS[i][0]) { + break; + } + } + } + + let parsedField = false; + if (matchingResult) { + let { ruleFrom, ruleTo } = matchingResult; + let detailStart = fieldScanner.parsingIndex; + for (let i = ruleFrom; i < ruleTo; i++) { + fieldScanner.updateFieldName(detailStart, GRAMMARS[i][1]); + fieldScanner.parsingIndex++; + detailStart++; + parsedField = true; + } + } + + if (fieldScanner.parsingFinished) { + return parsedField; + } + + let nextField = fieldScanner.getFieldDetailByIndex( + fieldScanner.parsingIndex + ); + if ( + nextField && + nextField.reason != "autocomplete" && + fieldScanner.parsingIndex > 0 + ) { + const regExpTelExtension = new RegExp( + "\\bext|ext\\b|extension|ramal", // pt-BR, pt-PT + "iu" + ); + const previousField = fieldScanner.getFieldDetailByIndex( + fieldScanner.parsingIndex - 1 + ); + const previousFieldType = lazy.FormAutofillUtils.getCategoryFromFieldName( + previousField.fieldName + ); + if ( + previousField && + previousFieldType == "tel" && + this._matchRegexp(nextField.elementWeakRef.get(), regExpTelExtension) + ) { + fieldScanner.updateFieldName( + fieldScanner.parsingIndex, + "tel-extension" + ); + fieldScanner.parsingIndex++; + parsedField = true; + } + } + + return parsedField; + }, + + /** + * Try to find the correct address-line[1-3] sequence and correct their field + * names. + * + * @param {FieldScanner} fieldScanner + * The current parsing status for all elements + * @returns {boolean} + * Return true if there is any field can be recognized in the parser, + * otherwise false. + */ + _parseAddressFields(fieldScanner) { + if (fieldScanner.parsingFinished) { + return false; + } + + // TODO: These address-line* regexps are for the lines with numbers, and + // they are the subset of the regexps in `heuristicsRegexp.js`. We have to + // find a better way to make them consistent. + const addressLines = ["address-line1", "address-line2", "address-line3"]; + const addressLineRegexps = { + "address-line1": new RegExp( + "address[_-]?line(1|one)|address1|addr1" + + "|addrline1|address_1" + // Extra rules by Firefox + "|indirizzo1" + // it-IT + "|住所1" + // ja-JP + "|地址1" + // zh-CN + "|주소.?1", // ko-KR + "iu" + ), + "address-line2": new RegExp( + "address[_-]?line(2|two)|address2|addr2" + + "|addrline2|address_2" + // Extra rules by Firefox + "|indirizzo2" + // it-IT + "|住所2" + // ja-JP + "|地址2" + // zh-CN + "|주소.?2", // ko-KR + "iu" + ), + "address-line3": new RegExp( + "address[_-]?line(3|three)|address3|addr3" + + "|addrline3|address_3" + // Extra rules by Firefox + "|indirizzo3" + // it-IT + "|住所3" + // ja-JP + "|地址3" + // zh-CN + "|주소.?3", // ko-KR + "iu" + ), + }; + + let parsedFields = false; + const startIndex = fieldScanner.parsingIndex; + while (!fieldScanner.parsingFinished) { + let detail = fieldScanner.getFieldDetailByIndex( + fieldScanner.parsingIndex + ); + if ( + !detail || + !addressLines.includes(detail.fieldName) || + detail.reason == "autocomplete" + ) { + // When the field is not related to any address-line[1-3] fields or + // determined by autocomplete attr, it means the parsing process can be + // terminated. + break; + } + parsedFields = false; + const elem = detail.elementWeakRef.get(); + for (let regexp of Object.keys(addressLineRegexps)) { + if (this._matchRegexp(elem, addressLineRegexps[regexp])) { + fieldScanner.updateFieldName(fieldScanner.parsingIndex, regexp); + parsedFields = true; + } + } + if (!parsedFields) { + break; + } + fieldScanner.parsingIndex++; + } + + // If "address-line2" is found but the previous field is "street-address", + // then we assume what the website actually wants is "address-line1" instead + // of "street-address". + if ( + startIndex > 0 && + fieldScanner.getFieldDetailByIndex(startIndex)?.fieldName == + "address-line2" && + fieldScanner.getFieldDetailByIndex(startIndex - 1)?.fieldName == + "street-address" + ) { + fieldScanner.updateFieldName( + startIndex - 1, + "address-line1", + "regexp-heuristic" + ); + } + + return parsedFields; + }, + + // The old heuristics can be removed when we fully adopt fathom, so disable the + // esline complexity check for now + /* eslint-disable complexity */ + /** + * Try to look for expiration date fields and revise the field names if needed. + * + * @param {FieldScanner} fieldScanner + * The current parsing status for all elements + * @returns {boolean} + * Return true if there is any field can be recognized in the parser, + * otherwise false. + */ + _parseCreditCardFields(fieldScanner) { + if (fieldScanner.parsingFinished) { + return false; + } + + const savedIndex = fieldScanner.parsingIndex; + const detail = fieldScanner.getFieldDetailByIndex( + fieldScanner.parsingIndex + ); + + // Respect to autocomplete attr + if (!detail || detail?.reason == "autocomplete") { + return false; + } + + const monthAndYearFieldNames = ["cc-exp-month", "cc-exp-year"]; + // Skip the uninteresting fields + if (!["cc-exp", ...monthAndYearFieldNames].includes(detail.fieldName)) { + return false; + } + + // The heuristic below should be covered by fathom rules, so we can skip doing + // it. + if ( + lazy.FormAutofillUtils.isFathomCreditCardsEnabled() && + lazy.CreditCardRulesets.types.includes(detail.fieldName) + ) { + fieldScanner.parsingIndex++; + return true; + } + + const element = detail.elementWeakRef.get(); + + // If the input type is a month picker, then assume it's cc-exp. + if (element.type == "month") { + fieldScanner.updateFieldName(fieldScanner.parsingIndex, "cc-exp"); + fieldScanner.parsingIndex++; + + return true; + } + + // Don't process the fields if expiration month and expiration year are already + // matched by regex in correct order. + if ( + fieldScanner.getFieldDetailByIndex(fieldScanner.parsingIndex++) + .fieldName == "cc-exp-month" && + !fieldScanner.parsingFinished && + fieldScanner.getFieldDetailByIndex(fieldScanner.parsingIndex++) + .fieldName == "cc-exp-year" + ) { + return true; + } + fieldScanner.parsingIndex = savedIndex; + + // Determine the field name by checking if the fields are month select and year select + // likely. + if (this._isExpirationMonthLikely(element)) { + fieldScanner.updateFieldName(fieldScanner.parsingIndex, "cc-exp-month"); + fieldScanner.parsingIndex++; + if (!fieldScanner.parsingFinished) { + const nextDetail = fieldScanner.getFieldDetailByIndex( + fieldScanner.parsingIndex + ); + const nextElement = nextDetail.elementWeakRef.get(); + if (this._isExpirationYearLikely(nextElement)) { + fieldScanner.updateFieldName( + fieldScanner.parsingIndex, + "cc-exp-year" + ); + fieldScanner.parsingIndex++; + return true; + } + } + } + fieldScanner.parsingIndex = savedIndex; + + // Verify that the following consecutive two fields can match cc-exp-month and cc-exp-year + // respectively. + if (this._findMatchedFieldName(element, ["cc-exp-month"])) { + fieldScanner.updateFieldName(fieldScanner.parsingIndex, "cc-exp-month"); + fieldScanner.parsingIndex++; + if (!fieldScanner.parsingFinished) { + const nextDetail = fieldScanner.getFieldDetailByIndex( + fieldScanner.parsingIndex + ); + const nextElement = nextDetail.elementWeakRef.get(); + if (this._findMatchedFieldName(nextElement, ["cc-exp-year"])) { + fieldScanner.updateFieldName( + fieldScanner.parsingIndex, + "cc-exp-year" + ); + fieldScanner.parsingIndex++; + return true; + } + } + } + fieldScanner.parsingIndex = savedIndex; + + // Look for MM and/or YY(YY). + if (this._matchRegexp(element, /^mm$/gi)) { + fieldScanner.updateFieldName(fieldScanner.parsingIndex, "cc-exp-month"); + fieldScanner.parsingIndex++; + if (!fieldScanner.parsingFinished) { + const nextDetail = fieldScanner.getFieldDetailByIndex( + fieldScanner.parsingIndex + ); + const nextElement = nextDetail.elementWeakRef.get(); + if (this._matchRegexp(nextElement, /^(yy|yyyy)$/)) { + fieldScanner.updateFieldName( + fieldScanner.parsingIndex, + "cc-exp-year" + ); + fieldScanner.parsingIndex++; + + return true; + } + } + } + fieldScanner.parsingIndex = savedIndex; + + // Look for a cc-exp with 2-digit or 4-digit year. + if ( + this._matchRegexp( + element, + /(?:exp.*date[^y\\n\\r]*|mm\\s*[-/]?\\s*)yy(?:[^y]|$)/gi + ) || + this._matchRegexp( + element, + /(?:exp.*date[^y\\n\\r]*|mm\\s*[-/]?\\s*)yyyy(?:[^y]|$)/gi + ) + ) { + fieldScanner.updateFieldName(fieldScanner.parsingIndex, "cc-exp"); + fieldScanner.parsingIndex++; + return true; + } + fieldScanner.parsingIndex = savedIndex; + + // Match general cc-exp regexp at last. + if (this._findMatchedFieldName(element, ["cc-exp"])) { + fieldScanner.updateFieldName(fieldScanner.parsingIndex, "cc-exp"); + fieldScanner.parsingIndex++; + return true; + } + fieldScanner.parsingIndex = savedIndex; + + // Set current field name to null as it failed to match any patterns. + fieldScanner.updateFieldName(fieldScanner.parsingIndex, null); + fieldScanner.parsingIndex++; + return true; + }, + + /** + * This function should provide all field details of a form which are placed + * in the belonging section. The details contain the autocomplete info + * (e.g. fieldName, section, etc). + * + * @param {HTMLFormElement} form + * the elements in this form to be predicted the field info. + * @returns {Array} + * all sections within its field details in the form. + */ + getFormInfo(form) { + let elements = Array.from(form.elements).filter(element => + lazy.FormAutofillUtils.isCreditCardOrAddressFieldType(element) + ); + + // Due to potential performance impact while running visibility check on + // a large amount of elements, a comprehensive visibility check + // (considering opacity and CSS visibility) is only applied when the number + // of eligible elements is below a certain threshold. + const runVisiblityCheck = + elements.length < lazy.FormAutofillUtils.visibilityCheckThreshold; + if (!runVisiblityCheck) { + lazy.log.debug( + `Skip running visibility check, because of too many elements (${elements.length})` + ); + } + + elements = elements.filter(element => + lazy.FormAutofillUtils.isFieldVisible(element, runVisiblityCheck) + ); + + const fieldScanner = new lazy.FieldScanner(elements, element => + this.inferFieldInfo(element, elements) + ); + + while (!fieldScanner.parsingFinished) { + let parsedPhoneFields = this._parsePhoneFields(fieldScanner); + let parsedAddressFields = this._parseAddressFields(fieldScanner); + let parsedExpirationDateFields = + this._parseCreditCardFields(fieldScanner); + + // If there is no field parsed, the parsing cursor can be moved + // forward to the next one. + if ( + !parsedPhoneFields && + !parsedAddressFields && + !parsedExpirationDateFields + ) { + fieldScanner.parsingIndex++; + } + } + + lazy.LabelUtils.clearLabelMap(); + + const fields = fieldScanner.fieldDetails; + const sections = [ + ...this._classifySections( + fields.filter(f => lazy.FormAutofillUtils.isAddressField(f.fieldName)) + ), + ...this._classifySections( + fields.filter(f => + lazy.FormAutofillUtils.isCreditCardField(f.fieldName) + ) + ), + ]; + + return sections.sort( + (a, b) => + fields.indexOf(a.fieldDetails[0]) - fields.indexOf(b.fieldDetails[0]) + ); + }, + + /** + * The result is an array contains the sections with its belonging field details. + * + * @param {Array} fieldDetails field detail array to be classified + * @returns {Array} The array with the sections. + */ + _classifySections(fieldDetails) { + let sections = []; + for (let i = 0; i < fieldDetails.length; i++) { + const fieldName = fieldDetails[i].fieldName; + const sectionName = fieldDetails[i].sectionName; + + const [currentSection] = sections.slice(-1); + + // The section this field might belong to + let candidateSection = null; + + // If the field doesn't have a section name, MAYBE put it to the previous + // section if exists. If the field has a section name, maybe put it to the + // nearest section that either has the same name or it doesn't has a name. + // Otherwise, create a new section. + if (!currentSection || !sectionName) { + candidateSection = currentSection; + } else if (sectionName) { + for (let idx = sections.length - 1; idx >= 0; idx--) { + if (!sections[idx].name || sections[idx].name == sectionName) { + candidateSection = sections[idx]; + break; + } + } + } + + // We got an candidate section to put the field to, check whether the section + // already has a field with the same field name. If yes, only add the field to when + // the type of the field might appear multiple times in a row. + if (candidateSection) { + let createNewSection = true; + if (candidateSection.fieldDetails.find(f => f.fieldName == fieldName)) { + const [lastFieldDetail] = candidateSection.fieldDetails.slice(-1); + if (lastFieldDetail.fieldName == fieldName) { + if (MULTI_FIELD_NAMES.includes(fieldName)) { + createNewSection = false; + } else if (fieldName in MULTI_N_FIELD_NAMES) { + // This is the heuristic to handle special cases where we can have multiple + // fields in one section, but only if the field has appeared N times in a row. + // For example, websites can use 4 consecutive 4-digit `cc-number` fields + // instead of one 16-digit `cc-number` field. + + const N = MULTI_N_FIELD_NAMES[fieldName]; + if (lastFieldDetail.part) { + // If `part` is set, we have already identified this field can be + // merged previously + if (lastFieldDetail.part < N) { + createNewSection = false; + fieldDetails[i].part = lastFieldDetail.part + 1; + } + // If the next N fields are all the same field, we can merge them + } else if ( + N == 2 || + fieldDetails + .slice(i + 1, i + N - 1) + .every(f => f.fieldName == fieldName) + ) { + lastFieldDetail.part = 1; + fieldDetails[i].part = 2; + createNewSection = false; + } + } + } + } else { + // The field doesn't exist in the candidate section, add it. + createNewSection = false; + } + + if (!createNewSection) { + candidateSection.addField(fieldDetails[i]); + continue; + } + } + + // Create a new section + sections.push(new FormSection([fieldDetails[i]])); + } + + return sections; + }, + + _getPossibleFieldNames(element) { + let fieldNames = []; + const isAutoCompleteOff = + element.autocomplete == "off" || element.form?.autocomplete == "off"; + if ( + FormAutofill.isAutofillCreditCardsAvailable && + (!isAutoCompleteOff || FormAutofill.creditCardsAutocompleteOff) + ) { + fieldNames.push(...this.CREDIT_CARD_FIELDNAMES); + } + if ( + FormAutofill.isAutofillAddressesAvailable && + (!isAutoCompleteOff || FormAutofill.addressesAutocompleteOff) + ) { + fieldNames.push(...this.ADDRESS_FIELDNAMES); + } + + if (HTMLSelectElement.isInstance(element)) { + const FIELDNAMES_FOR_SELECT_ELEMENT = [ + "address-level1", + "address-level2", + "country", + "cc-exp-month", + "cc-exp-year", + "cc-exp", + "cc-type", + ]; + fieldNames = fieldNames.filter(name => + FIELDNAMES_FOR_SELECT_ELEMENT.includes(name) + ); + } + + return fieldNames; + }, + + /** + * Get inferred information about an input element using autocomplete info, fathom and regex-based heuristics. + * + * @param {HTMLElement} element - The input element to infer information about. + * @param {Array} elements - See `getFathomField` for details + * @returns {Array} - An array containing: + * [0]the inferred field name + * [1]autocomplete information if the element has autocompelte attribute, null otherwise. + * [2]fathom confidence if fathom considers it a cc field, null otherwise. + */ + inferFieldInfo(element, elements = []) { + const autocompleteInfo = element.getAutocompleteInfo(); + + // An input[autocomplete="on"] will not be early return here since it stll + // needs to find the field name. + if ( + autocompleteInfo?.fieldName && + !["on", "off"].includes(autocompleteInfo.fieldName) + ) { + return [autocompleteInfo.fieldName, autocompleteInfo, null]; + } + + const fields = this._getPossibleFieldNames(element); + + // "email" type of input is accurate for heuristics to determine its Email + // field or not. However, "tel" type is used for ZIP code for some web site + // (e.g. HomeDepot, BestBuy), so "tel" type should be not used for "tel" + // prediction. + if (element.type == "email" && fields.includes("email")) { + return ["email", null, null]; + } + + if (lazy.FormAutofillUtils.isFathomCreditCardsEnabled()) { + // We don't care fields that are not supported by fathom + const fathomFields = fields.filter(r => + lazy.CreditCardRulesets.types.includes(r) + ); + const [matchedFieldName, confidence] = this.getFathomField( + element, + fathomFields, + elements + ); + // At this point, use fathom's recommendation if it has one + if (matchedFieldName) { + return [matchedFieldName, null, confidence]; + } + + // Continue to run regex-based heuristics even when fathom doesn't recognize + // the field. Since the regex-based heuristic has good search coverage but + // has a worse precision. We use it in conjunction with fathom to maximize + // our search coverage. For example, when a is not considered cc-name + // by fathom but is considered cc-name by regex-based heuristic, if the form + // also contains a cc-number identified by fathom, we will treat the form as a + // valid cc form; hence both cc-number & cc-name are identified. + } + + // Check every select for options that + // match credit card network names in value or label. + if (HTMLSelectElement.isInstance(element)) { + for (let option of element.querySelectorAll("option")) { + if ( + lazy.CreditCard.getNetworkFromName(option.value) || + lazy.CreditCard.getNetworkFromName(option.text) + ) { + return ["cc-type", null, null]; + } + } + } + + if (fields.length) { + // Find a matched field name using regex-based heuristics + const matchedFieldName = this._findMatchedFieldName(element, fields); + if (matchedFieldName) { + return [matchedFieldName, null, null]; + } + } + + return [null, null, null]; + }, + + /** + * Using Fathom, say what kind of CC field an element is most likely to be. + * This function deoesn't only run fathom on the passed elements. It also + * runs fathom for all elements in the FieldScanner for optimization purpose. + * + * @param {HTMLElement} element + * @param {Array} fields + * @param {Array} elements - All other eligible elements in the same form. This is mainly used as an + * optimization approach to run fathom model on all eligible elements + * once instead of one by one + * @returns {Array} A tuple of [field name, probability] describing the + * highest-confidence classification + */ + getFathomField(element, fields, elements = []) { + if (!fields.length) { + return [null, null]; + } + + if (!this._fathomConfidences?.get(element)) { + this._fathomConfidences = new Map(); + + // This should not throw unless we run into an OOM situation, at which + // point we have worse problems and this failing is not a big deal. + elements = elements.includes(element) ? elements : [element]; + const confidences = this.getFormAutofillConfidences(elements); + + for (let i = 0; i < elements.length; i++) { + this._fathomConfidences.set(elements[i], confidences[i]); + } + } + + const elementConfidences = this._fathomConfidences.get(element); + if (!elementConfidences) { + return [null, null]; + } + + let highestField = null; + let highestConfidence = lazy.FormAutofillUtils.ccFathomConfidenceThreshold; // Start with a threshold of 0.5 + for (let [key, value] of Object.entries(elementConfidences)) { + if (!fields.includes(key)) { + // ignore field that we don't care + continue; + } + + if (value > highestConfidence) { + highestConfidence = value; + highestField = key; + } + } + + if (!highestField) { + return [null, null]; + } + + // Used by test ONLY! This ensure testcases always get the same confidence + if (lazy.FormAutofillUtils.ccFathomTestConfidence > 0) { + highestConfidence = lazy.FormAutofillUtils.ccFathomTestConfidence; + } + + return [highestField, highestConfidence]; + }, + + /** + * @param {Array} elements Array of elements that we want to get result from fathom cc rules + * @returns {object} Fathom confidence keyed by field-type. + */ + getFormAutofillConfidences(elements) { + if ( + lazy.FormAutofillUtils.ccHeuristicsMode == + lazy.FormAutofillUtils.CC_FATHOM_NATIVE + ) { + const confidences = ChromeUtils.getFormAutofillConfidences(elements); + return confidences.map(c => { + let result = {}; + for (let [fieldName, confidence] of Object.entries(c)) { + let type = + lazy.FormAutofillUtils.formAutofillConfidencesKeyToCCFieldType( + fieldName + ); + result[type] = confidence; + } + return result; + }); + } + + return elements.map(element => { + /** + * Return how confident our ML model is that `element` is a field of the + * given type. + * + * @param {string} fieldName The Fathom type to check against. This is + * conveniently the same as the autocomplete attribute value that means + * the same thing. + * @returns {number} Confidence in range [0, 1] + */ + function confidence(fieldName) { + const ruleset = lazy.CreditCardRulesets[fieldName]; + const fnodes = ruleset.against(element).get(fieldName); + + // fnodes is either 0 or 1 item long, since we ran the ruleset + // against a single element: + return fnodes.length ? fnodes[0].scoreFor(fieldName) : 0; + } + + // Bang the element against the ruleset for every type of field: + const confidences = {}; + lazy.CreditCardRulesets.types.map(fieldName => { + confidences[fieldName] = confidence(fieldName); + }); + + return confidences; + }); + }, + + /** + * @typedef ElementStrings + * @type {object} + * @yields {string} id - element id. + * @yields {string} name - element name. + * @yields {Array} labels - extracted labels. + */ + + /** + * Extract all the signature strings of an element. + * + * @param {HTMLElement} element + * @returns {ElementStrings} + */ + _getElementStrings(element) { + return { + *[Symbol.iterator]() { + yield element.id; + yield element.name; + yield element.placeholder?.trim(); + + const labels = lazy.LabelUtils.findLabelElements(element); + for (let label of labels) { + yield* lazy.LabelUtils.extractLabelStrings(label); + } + }, + }; + }, + + // In order to support webkit we need to avoid usage of negative lookbehind due to low support + // First safari version with support is 16.4 (Release Date: 27th March 2023) + // https://caniuse.com/js-regexp-lookbehind + // We can mimic the behaviour of negative lookbehinds by using a named capture group + // (? (?notword)|word + // TODO: Bug 1829583 + testRegex(regex, string) { + const matches = string?.matchAll(regex); + if (!matches) { + return false; + } + + const excludeNegativeCaptureGroups = []; + + for (const match of matches) { + excludeNegativeCaptureGroups.push( + ...match.filter(m => m !== match?.groups?.neg).filter(Boolean) + ); + } + return excludeNegativeCaptureGroups?.length > 0; + }, + + /** + * Find the first matched field name of the element wih given regex list. + * + * @param {HTMLElement} element + * @param {Array} regexps + * The regex key names that correspond to pattern in the rule list. It will + * be matched against the element string converted to lower case. + * @returns {?string} The first matched field name + */ + _findMatchedFieldName(element, regexps) { + const getElementStrings = this._getElementStrings(element); + for (let regexp of regexps) { + for (let string of getElementStrings) { + if (this.testRegex(this.RULES[regexp], string?.toLowerCase())) { + return regexp; + } + } + } + + return null; + }, + + /** + * Determine whether the regexp can match any of element strings. + * + * @param {HTMLElement} element + * @param {RegExp} regexp + * + * @returns {boolean} + */ + _matchRegexp(element, regexp) { + const elemStrings = this._getElementStrings(element); + for (const str of elemStrings) { + if (regexp.test(str)) { + return true; + } + } + return false; + }, + + /** + * Phone field grammars - first matched grammar will be parsed. Grammars are + * separated by { REGEX_SEPARATOR, FIELD_NONE, 0 }. Suffix and extension are + * parsed separately unless they are necessary parts of the match. + * The following notation is used to describe the patterns: + * - country code field. + * - area code field. + * - phone or prefix. + * - suffix. + * - extension. + * :N means field is limited to N characters, otherwise it is unlimited. + * (pattern )? means pattern is optional and matched separately. + * + * This grammar list from Chromium will be enabled partially once we need to + * support more cases of Telephone fields. + */ + PHONE_FIELD_GRAMMARS: [ + // Country code: Area Code: Phone: (- + + // (Ext: )?)? + // {REGEX_COUNTRY, FIELD_COUNTRY_CODE, 0}, + // {REGEX_AREA, FIELD_AREA_CODE, 0}, + // {REGEX_PHONE, FIELD_PHONE, 0}, + // {REGEX_SEPARATOR, FIELD_NONE, 0}, + + // \( \) :3 :4 (Ext: )? + // {REGEX_AREA_NOTEXT, FIELD_AREA_CODE, 3}, + // {REGEX_PREFIX_SEPARATOR, FIELD_PHONE, 3}, + // {REGEX_PHONE, FIELD_SUFFIX, 4}, + // {REGEX_SEPARATOR, FIELD_NONE, 0}, + + // Phone: :3 - :3 - :4 (Ext: )? + // {REGEX_PHONE, FIELD_COUNTRY_CODE, 0}, + // {REGEX_PHONE, FIELD_AREA_CODE, 3}, + // {REGEX_PREFIX_SEPARATOR, FIELD_PHONE, 3}, + // {REGEX_SUFFIX_SEPARATOR, FIELD_SUFFIX, 4}, + // {REGEX_SEPARATOR, FIELD_NONE, 0}, + + // Phone: :3 :3 :3 :4 (Ext: )? + ["tel", "tel-country-code", 3], + ["tel", "tel-area-code", 3], + ["tel", "tel-local-prefix", 3], + ["tel", "tel-local-suffix", 4], + [null, null, 0], + + // Area Code: Phone: (- (Ext: )?)? + // {REGEX_AREA, FIELD_AREA_CODE, 0}, + // {REGEX_PHONE, FIELD_PHONE, 0}, + // {REGEX_SEPARATOR, FIELD_NONE, 0}, + + // Phone: :3 :4 (Ext: )? + // {REGEX_PHONE, FIELD_AREA_CODE, 0}, + // {REGEX_PHONE, FIELD_PHONE, 3}, + // {REGEX_PHONE, FIELD_SUFFIX, 4}, + // {REGEX_SEPARATOR, FIELD_NONE, 0}, + + // Phone: \( \) (- (Ext: )?)? + // {REGEX_PHONE, FIELD_COUNTRY_CODE, 0}, + // {REGEX_AREA_NOTEXT, FIELD_AREA_CODE, 0}, + // {REGEX_PREFIX_SEPARATOR, FIELD_PHONE, 0}, + // {REGEX_SEPARATOR, FIELD_NONE, 0}, + + // Phone: \( \) (- (Ext: )?)? + // {REGEX_PHONE, FIELD_COUNTRY_CODE, 0}, + // {REGEX_AREA_NOTEXT, FIELD_AREA_CODE, 0}, + // {REGEX_PREFIX_SEPARATOR, FIELD_PHONE, 0}, + // {REGEX_SEPARATOR, FIELD_NONE, 0}, + + // Phone: - - - (Ext: )? + // {REGEX_PHONE, FIELD_COUNTRY_CODE, 0}, + // {REGEX_PREFIX_SEPARATOR, FIELD_AREA_CODE, 0}, + // {REGEX_PREFIX_SEPARATOR, FIELD_PHONE, 0}, + // {REGEX_SUFFIX_SEPARATOR, FIELD_SUFFIX, 0}, + // {REGEX_SEPARATOR, FIELD_NONE, 0}, + + // Area code: :3 Prefix: :3 Suffix: :4 (Ext: )? + // {REGEX_AREA, FIELD_AREA_CODE, 3}, + // {REGEX_PREFIX, FIELD_PHONE, 3}, + // {REGEX_SUFFIX, FIELD_SUFFIX, 4}, + // {REGEX_SEPARATOR, FIELD_NONE, 0}, + + // Phone: Prefix: Suffix: (Ext: )? + // {REGEX_PHONE, FIELD_AREA_CODE, 0}, + // {REGEX_PREFIX, FIELD_PHONE, 0}, + // {REGEX_SUFFIX, FIELD_SUFFIX, 0}, + // {REGEX_SEPARATOR, FIELD_NONE, 0}, + + // Phone: - :3 - :4 (Ext: )? + ["tel", "tel-area-code", 0], + ["tel", "tel-local-prefix", 3], + ["tel", "tel-local-suffix", 4], + [null, null, 0], + + // Phone: - - (Ext: )? + // {REGEX_PHONE, FIELD_COUNTRY_CODE, 0}, + // {REGEX_PREFIX_SEPARATOR, FIELD_AREA_CODE, 0}, + // {REGEX_SUFFIX_SEPARATOR, FIELD_PHONE, 0}, + // {REGEX_SEPARATOR, FIELD_NONE, 0}, + + // Phone: - (Ext: )? + // {REGEX_AREA, FIELD_AREA_CODE, 0}, + // {REGEX_PHONE, FIELD_PHONE, 0}, + // {REGEX_SEPARATOR, FIELD_NONE, 0}, + + // Phone: :3 - :10 (Ext: )? + // {REGEX_PHONE, FIELD_COUNTRY_CODE, 3}, + // {REGEX_PHONE, FIELD_PHONE, 10}, + // {REGEX_SEPARATOR, FIELD_NONE, 0}, + + // Ext: + // {REGEX_EXTENSION, FIELD_EXTENSION, 0}, + // {REGEX_SEPARATOR, FIELD_NONE, 0}, + + // Phone: (Ext: )? + // {REGEX_PHONE, FIELD_PHONE, 0}, + // {REGEX_SEPARATOR, FIELD_NONE, 0}, + ], +}; + +XPCOMUtils.defineLazyGetter( + FormAutofillHeuristics, + "CREDIT_CARD_FIELDNAMES", + () => + Object.keys(FormAutofillHeuristics.RULES).filter(name => + lazy.FormAutofillUtils.isCreditCardField(name) + ) +); + +XPCOMUtils.defineLazyGetter(FormAutofillHeuristics, "ADDRESS_FIELDNAMES", () => + Object.keys(FormAutofillHeuristics.RULES).filter(name => + lazy.FormAutofillUtils.isAddressField(name) + ) +); + +export default FormAutofillHeuristics; diff --git a/toolkit/components/formautofill/shared/FormAutofillNameUtils.sys.mjs b/toolkit/components/formautofill/shared/FormAutofillNameUtils.sys.mjs new file mode 100644 index 0000000000..8a1d5ba55e --- /dev/null +++ b/toolkit/components/formautofill/shared/FormAutofillNameUtils.sys.mjs @@ -0,0 +1,406 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +// FormAutofillNameUtils is initially translated from +// https://cs.chromium.org/chromium/src/components/autofill/core/browser/autofill_data_util.cc?rcl=b861deff77abecff11ae6a9f6946e9cc844b9817 +export var FormAutofillNameUtils = { + NAME_PREFIXES: [ + "1lt", + "1st", + "2lt", + "2nd", + "3rd", + "admiral", + "capt", + "captain", + "col", + "cpt", + "dr", + "gen", + "general", + "lcdr", + "lt", + "ltc", + "ltg", + "ltjg", + "maj", + "major", + "mg", + "mr", + "mrs", + "ms", + "pastor", + "prof", + "rep", + "reverend", + "rev", + "sen", + "st", + ], + + NAME_SUFFIXES: [ + "b.a", + "ba", + "d.d.s", + "dds", + "i", + "ii", + "iii", + "iv", + "ix", + "jr", + "m.a", + "m.d", + "ma", + "md", + "ms", + "ph.d", + "phd", + "sr", + "v", + "vi", + "vii", + "viii", + "x", + ], + + FAMILY_NAME_PREFIXES: [ + "d'", + "de", + "del", + "der", + "di", + "la", + "le", + "mc", + "san", + "st", + "ter", + "van", + "von", + ], + + // The common and non-ambiguous CJK surnames (last names) that have more than + // one character. + COMMON_CJK_MULTI_CHAR_SURNAMES: [ + // Korean, taken from the list of surnames: + // https://ko.wikipedia.org/wiki/%ED%95%9C%EA%B5%AD%EC%9D%98_%EC%84%B1%EC%94%A8_%EB%AA%A9%EB%A1%9D + "남궁", + "사공", + "서문", + "선우", + "제갈", + "황보", + "독고", + "망절", + + // Chinese, taken from the top 10 Chinese 2-character surnames: + // https://zh.wikipedia.org/wiki/%E8%A4%87%E5%A7%93#.E5.B8.B8.E8.A6.8B.E7.9A.84.E8.A4.87.E5.A7.93 + // Simplified Chinese (mostly mainland China) + "欧阳", + "令狐", + "皇甫", + "上官", + "司徒", + "诸葛", + "司马", + "宇文", + "呼延", + "端木", + // Traditional Chinese (mostly Taiwan) + "張簡", + "歐陽", + "諸葛", + "申屠", + "尉遲", + "司馬", + "軒轅", + "夏侯", + ], + + // All Korean surnames that have more than one character, even the + // rare/ambiguous ones. + KOREAN_MULTI_CHAR_SURNAMES: [ + "강전", + "남궁", + "독고", + "동방", + "망절", + "사공", + "서문", + "선우", + "소봉", + "어금", + "장곡", + "제갈", + "황목", + "황보", + ], + + // The whitespace definition based on + // https://cs.chromium.org/chromium/src/base/strings/string_util_constants.cc?l=9&rcl=b861deff77abecff11ae6a9f6946e9cc844b9817 + WHITESPACE: [ + "\u0009", // CHARACTER TABULATION + "\u000A", // LINE FEED (LF) + "\u000B", // LINE TABULATION + "\u000C", // FORM FEED (FF) + "\u000D", // CARRIAGE RETURN (CR) + "\u0020", // SPACE + "\u0085", // NEXT LINE (NEL) + "\u00A0", // NO-BREAK SPACE + "\u1680", // OGHAM SPACE MARK + "\u2000", // EN QUAD + "\u2001", // EM QUAD + "\u2002", // EN SPACE + "\u2003", // EM SPACE + "\u2004", // THREE-PER-EM SPACE + "\u2005", // FOUR-PER-EM SPACE + "\u2006", // SIX-PER-EM SPACE + "\u2007", // FIGURE SPACE + "\u2008", // PUNCTUATION SPACE + "\u2009", // THIN SPACE + "\u200A", // HAIR SPACE + "\u2028", // LINE SEPARATOR + "\u2029", // PARAGRAPH SEPARATOR + "\u202F", // NARROW NO-BREAK SPACE + "\u205F", // MEDIUM MATHEMATICAL SPACE + "\u3000", // IDEOGRAPHIC SPACE + ], + + // The middle dot is used as a separator for foreign names in Japanese. + MIDDLE_DOT: [ + "\u30FB", // KATAKANA MIDDLE DOT + "\u00B7", // A (common?) typo for "KATAKANA MIDDLE DOT" + ], + + // The Unicode range is based on Wiki: + // https://en.wikipedia.org/wiki/CJK_Unified_Ideographs + // https://en.wikipedia.org/wiki/Hangul + // https://en.wikipedia.org/wiki/Japanese_writing_system + CJK_RANGE: [ + "\u1100-\u11FF", // Hangul Jamo + "\u3040-\u309F", // Hiragana + "\u30A0-\u30FF", // Katakana + "\u3105-\u312C", // Bopomofo + "\u3130-\u318F", // Hangul Compatibility Jamo + "\u31F0-\u31FF", // Katakana Phonetic Extensions + "\u3200-\u32FF", // Enclosed CJK Letters and Months + "\u3400-\u4DBF", // CJK unified ideographs Extension A + "\u4E00-\u9FFF", // CJK Unified Ideographs + "\uA960-\uA97F", // Hangul Jamo Extended-A + "\uAC00-\uD7AF", // Hangul Syllables + "\uD7B0-\uD7FF", // Hangul Jamo Extended-B + "\uFF00-\uFFEF", // Halfwidth and Fullwidth Forms + ], + + HANGUL_RANGE: [ + "\u1100-\u11FF", // Hangul Jamo + "\u3130-\u318F", // Hangul Compatibility Jamo + "\uA960-\uA97F", // Hangul Jamo Extended-A + "\uAC00-\uD7AF", // Hangul Syllables + "\uD7B0-\uD7FF", // Hangul Jamo Extended-B + ], + + _dataLoaded: false, + + // Returns true if |set| contains |token|, modulo a final period. + _containsString(set, token) { + let target = token.replace(/\.$/, "").toLowerCase(); + return set.includes(target); + }, + + // Removes common name prefixes from |name_tokens|. + _stripPrefixes(nameTokens) { + for (let i in nameTokens) { + if (!this._containsString(this.NAME_PREFIXES, nameTokens[i])) { + return nameTokens.slice(i); + } + } + return []; + }, + + // Removes common name suffixes from |name_tokens|. + _stripSuffixes(nameTokens) { + for (let i = nameTokens.length - 1; i >= 0; i--) { + if (!this._containsString(this.NAME_SUFFIXES, nameTokens[i])) { + return nameTokens.slice(0, i + 1); + } + } + return []; + }, + + _isCJKName(name) { + // The name is considered to be a CJK name if it is only CJK characters, + // spaces, and "middle dot" separators, with at least one CJK character, and + // no more than 2 words. + // + // Chinese and Japanese names are usually spelled out using the Han + // characters (logographs), which constitute the "CJK Unified Ideographs" + // block in Unicode, also referred to as Unihan. Korean names are usually + // spelled out in the Korean alphabet (Hangul), although they do have a Han + // equivalent as well. + + if (!name) { + return false; + } + + let previousWasCJK = false; + let wordCount = 0; + + for (let c of name) { + let isMiddleDot = this.MIDDLE_DOT.includes(c); + let isCJK = !isMiddleDot && this.reCJK.test(c); + if (!isCJK && !isMiddleDot && !this.WHITESPACE.includes(c)) { + return false; + } + if (isCJK && !previousWasCJK) { + wordCount++; + } + previousWasCJK = isCJK; + } + + return wordCount > 0 && wordCount < 3; + }, + + // Tries to split a Chinese, Japanese, or Korean name into its given name & + // surname parts. If splitting did not work for whatever reason, returns null. + _splitCJKName(nameTokens) { + // The convention for CJK languages is to put the surname (last name) first, + // and the given name (first name) second. In a continuous text, there is + // normally no space between the two parts of the name. When entering their + // name into a field, though, some people add a space to disambiguate. CJK + // names (almost) never have a middle name. + + let reHangulName = new RegExp( + "^[" + this.HANGUL_RANGE.join("") + this.WHITESPACE.join("") + "]+$", + "u" + ); + let nameParts = { + given: "", + middle: "", + family: "", + }; + + if (nameTokens.length == 1) { + // There is no space between the surname and given name. Try to infer + // where to separate between the two. Most Chinese and Korean surnames + // have only one character, but there are a few that have 2. If the name + // does not start with a surname from a known list, default to one + // character. + let name = nameTokens[0]; + let isKorean = reHangulName.test(name); + let surnameLength = 0; + + // 4-character Korean names are more likely to be 2/2 than 1/3, so use + // the full list of Korean 2-char surnames. (instead of only the common + // ones) + let multiCharSurnames = + isKorean && name.length > 3 + ? this.KOREAN_MULTI_CHAR_SURNAMES + : this.COMMON_CJK_MULTI_CHAR_SURNAMES; + + // Default to 1 character if the surname is not in the list. + surnameLength = multiCharSurnames.some(surname => + name.startsWith(surname) + ) + ? 2 + : 1; + + nameParts.family = name.substr(0, surnameLength); + nameParts.given = name.substr(surnameLength); + } else if (nameTokens.length == 2) { + // The user entered a space between the two name parts. This makes our job + // easier. Family name first, given name second. + nameParts.family = nameTokens[0]; + nameParts.given = nameTokens[1]; + } else { + return null; + } + + return nameParts; + }, + + init() { + if (this._dataLoaded) { + return; + } + this._dataLoaded = true; + + this.reCJK = new RegExp("[" + this.CJK_RANGE.join("") + "]", "u"); + }, + + splitName(name) { + let nameParts = { + given: "", + middle: "", + family: "", + }; + + if (!name) { + return nameParts; + } + + let nameTokens = name.trim().split(/[ ,\u3000\u30FB\u00B7]+/); + nameTokens = this._stripPrefixes(nameTokens); + + if (this._isCJKName(name)) { + let parts = this._splitCJKName(nameTokens); + if (parts) { + return parts; + } + } + + // Don't assume "Ma" is a suffix in John Ma. + if (nameTokens.length > 2) { + nameTokens = this._stripSuffixes(nameTokens); + } + + if (!nameTokens.length) { + // Bad things have happened; just assume the whole thing is a given name. + nameParts.given = name; + return nameParts; + } + + // Only one token, assume given name. + if (nameTokens.length == 1) { + nameParts.given = nameTokens[0]; + return nameParts; + } + + // 2 or more tokens. Grab the family, which is the last word plus any + // recognizable family prefixes. + let familyTokens = [nameTokens.pop()]; + while (nameTokens.length) { + let lastToken = nameTokens[nameTokens.length - 1]; + if (!this._containsString(this.FAMILY_NAME_PREFIXES, lastToken)) { + break; + } + familyTokens.unshift(lastToken); + nameTokens.pop(); + } + nameParts.family = familyTokens.join(" "); + + // Take the last remaining token as the middle name (if there are at least 2 + // tokens). + if (nameTokens.length >= 2) { + nameParts.middle = nameTokens.pop(); + } + + // Remainder is given name. + nameParts.given = nameTokens.join(" "); + + return nameParts; + }, + + joinNameParts({ given, middle, family }) { + if (this._isCJKName(given) && this._isCJKName(family) && !middle) { + return family + given; + } + return [given, middle, family] + .filter(part => part && part.length) + .join(" "); + }, +}; + +FormAutofillNameUtils.init(); diff --git a/toolkit/components/formautofill/shared/FormAutofillSection.sys.mjs b/toolkit/components/formautofill/shared/FormAutofillSection.sys.mjs new file mode 100644 index 0000000000..c7eb7622b5 --- /dev/null +++ b/toolkit/components/formautofill/shared/FormAutofillSection.sys.mjs @@ -0,0 +1,1353 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs"; +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; +import { FormAutofillUtils } from "resource://gre/modules/shared/FormAutofillUtils.sys.mjs"; +import { FormAutofill } from "resource://autofill/FormAutofill.sys.mjs"; + +const lazy = {}; +ChromeUtils.defineESModuleGetters(lazy, { + CreditCard: "resource://gre/modules/CreditCard.sys.mjs", + AutofillTelemetry: "resource://autofill/AutofillTelemetry.sys.mjs", +}); + +const { FIELD_STATES } = FormAutofillUtils; + +export class FormAutofillSection { + static SHOULD_FOCUS_ON_AUTOFILL = true; + #focusedInput = null; + + #section = null; + + constructor(section, handler) { + this.#section = section; + + if (!this.isValidSection()) { + return; + } + + this.handler = handler; + this.filledRecordGUID = null; + + XPCOMUtils.defineLazyGetter(this, "reauthPasswordPromptMessage", () => { + const brandShortName = + FormAutofillUtils.brandBundle.GetStringFromName("brandShortName"); + // The string name for Mac is changed because the value needed updating. + const platform = AppConstants.platform.replace("macosx", "macos"); + return FormAutofillUtils.stringBundle.formatStringFromName( + `useCreditCardPasswordPrompt.${platform}`, + [brandShortName] + ); + }); + + XPCOMUtils.defineLazyGetter(this, "log", () => + FormAutofill.defineLogGetter(this, "FormAutofillHandler") + ); + + this._cacheValue = { + allFieldNames: null, + matchingSelectOption: null, + }; + + // Identifier used to correlate events relating to the same form + this.flowId = Services.uuid.generateUUID().toString(); + this.log.debug( + "Creating new credit card section with flowId =", + this.flowId + ); + } + + get fieldDetails() { + return this.#section.fieldDetails; + } + + /* + * Examine the section is a valid section or not based on its fieldDetails or + * other information. This method must be overrided. + * + * @returns {boolean} True for a valid section, otherwise false + * + */ + isValidSection() { + throw new TypeError("isValidSection method must be overrided"); + } + + /* + * Examine the section is an enabled section type or not based on its + * preferences. This method must be overrided. + * + * @returns {boolean} True for an enabled section type, otherwise false + * + */ + isEnabled() { + throw new TypeError("isEnabled method must be overrided"); + } + + /* + * Examine the section is createable for storing the profile. This method + * must be overrided. + * + * @param {Object} record The record for examining createable + * @returns {boolean} True for the record is createable, otherwise false + * + */ + isRecordCreatable(record) { + throw new TypeError("isRecordCreatable method must be overridden"); + } + + /* + * Override this method if any data for `createRecord` is needed to be + * normalized before submitting the record. + * + * @param {Object} profile + * A record for normalization. + */ + createNormalizedRecord(data) {} + + /** + * Override this method if the profile is needed to apply some transformers. + * + * @param {object} profile + * A profile should be converted based on the specific requirement. + */ + applyTransformers(profile) {} + + /** + * Override this method if the profile is needed to be customized for + * previewing values. + * + * @param {object} profile + * A profile for pre-processing before previewing values. + */ + preparePreviewProfile(profile) {} + + /** + * Override this method if the profile is needed to be customized for filling + * values. + * + * @param {object} profile + * A profile for pre-processing before filling values. + * @returns {boolean} Whether the profile should be filled. + */ + async prepareFillingProfile(profile) { + return true; + } + + /** + * Override this method if the profile is needed to be customized for filling + * values. + * + * @param {object} fieldDetail A fieldDetail of the related element. + * @param {object} profile The profile to fill. + * @returns {string} The value to fill for the given field. + */ + getFilledValueFromProfile(fieldDetail, profile) { + return ( + profile[`${fieldDetail.fieldName}-formatted`] || + profile[fieldDetail.fieldName] + ); + } + + /* + * Override this method if there is any field value needs to compute for a + * specific case. Return the original value in the default case. + * @param {String} value + * The original field value. + * @param {Object} fieldDetail + * A fieldDetail of the related element. + * @param {HTMLElement} element + * A element for checking converting value. + * + * @returns {String} + * A string of the converted value. + */ + computeFillingValue(value, fieldName, element) { + return value; + } + + set focusedInput(element) { + this.#focusedInput = element; + } + + getFieldDetailByElement(element) { + return this.fieldDetails.find( + detail => detail.elementWeakRef.get() == element + ); + } + + getFieldDetailByName(fieldName) { + return this.fieldDetails.find(detail => detail.fieldName == fieldName); + } + + get allFieldNames() { + if (!this._cacheValue.allFieldNames) { + this._cacheValue.allFieldNames = this.fieldDetails.map( + record => record.fieldName + ); + } + return this._cacheValue.allFieldNames; + } + + matchSelectOptions(profile) { + if (!this._cacheValue.matchingSelectOption) { + this._cacheValue.matchingSelectOption = new WeakMap(); + } + + for (let fieldName in profile) { + let fieldDetail = this.getFieldDetailByName(fieldName); + if (!fieldDetail) { + continue; + } + + let element = fieldDetail.elementWeakRef.get(); + if (!HTMLSelectElement.isInstance(element)) { + continue; + } + + let cache = this._cacheValue.matchingSelectOption.get(element) || {}; + let value = profile[fieldName]; + if (cache[value] && cache[value].get()) { + continue; + } + + let option = FormAutofillUtils.findSelectOption( + element, + profile, + fieldName + ); + if (option) { + cache[value] = Cu.getWeakReference(option); + this._cacheValue.matchingSelectOption.set(element, cache); + } else { + if (cache[value]) { + delete cache[value]; + this._cacheValue.matchingSelectOption.set(element, cache); + } + // Delete the field so the phishing hint won't treat it as a "also fill" + // field. + delete profile[fieldName]; + } + } + } + + adaptFieldMaxLength(profile) { + for (let key in profile) { + let detail = this.getFieldDetailByName(key); + if (!detail) { + continue; + } + + let element = detail.elementWeakRef.get(); + if (!element) { + continue; + } + + let maxLength = element.maxLength; + if ( + maxLength === undefined || + maxLength < 0 || + profile[key].toString().length <= maxLength + ) { + continue; + } + + if (maxLength) { + switch (typeof profile[key]) { + case "string": + // If this is an expiration field and our previous + // adaptations haven't resulted in a string that is + // short enough to satisfy the field length, and the + // field is constrained to a length of 5, then we + // assume it is intended to hold an expiration of the + // form "MM/YY". + if (key == "cc-exp" && maxLength == 5) { + const month2Digits = ( + "0" + profile["cc-exp-month"].toString() + ).slice(-2); + const year2Digits = profile["cc-exp-year"].toString().slice(-2); + profile[key] = `${month2Digits}/${year2Digits}`; + } else if (key == "cc-number") { + // We want to show the last four digits of credit card so that + // the masked credit card previews correctly and appears correctly + // in the autocomplete menu + profile[key] = profile[key].substr( + profile[key].length - maxLength + ); + } else { + profile[key] = profile[key].substr(0, maxLength); + } + break; + case "number": + // There's no way to truncate a number smaller than a + // single digit. + if (maxLength < 1) { + maxLength = 1; + } + // The only numbers we store are expiration month/year, + // and if they truncate, we want the final digits, not + // the initial ones. + profile[key] = profile[key] % Math.pow(10, maxLength); + break; + default: + } + } else { + delete profile[key]; + delete profile[`${key}-formatted`]; + } + } + } + + fillFieldValue(element, value) { + if (FormAutofillUtils.focusOnAutofill) { + element.focus({ preventScroll: true }); + } + if (HTMLInputElement.isInstance(element)) { + element.setUserInput(value); + } else if (HTMLSelectElement.isInstance(element)) { + // Set the value of the select element so that web event handlers can react accordingly + element.value = value; + element.dispatchEvent( + new element.ownerGlobal.Event("input", { bubbles: true }) + ); + element.dispatchEvent( + new element.ownerGlobal.Event("change", { bubbles: true }) + ); + } + } + + getAdaptedProfiles(originalProfiles) { + for (let profile of originalProfiles) { + this.applyTransformers(profile); + } + return originalProfiles; + } + + /** + * Processes form fields that can be autofilled, and populates them with the + * profile provided by backend. + * + * @param {object} profile + * A profile to be filled in. + * @returns {boolean} + * True if successful, false if failed + */ + async autofillFields(profile) { + if (!this.#focusedInput) { + throw new Error("No focused input."); + } + + const focusedDetail = this.getFieldDetailByElement(this.#focusedInput); + if (!focusedDetail) { + throw new Error("No fieldDetail for the focused input."); + } + + if (!(await this.prepareFillingProfile(profile))) { + this.log.debug("profile cannot be filled"); + return false; + } + + this.filledRecordGUID = profile.guid; + for (const fieldDetail of this.fieldDetails) { + // Avoid filling field value in the following cases: + // 1. a non-empty input field for an unfocused input + // 2. the invalid value set + // 3. value already chosen in select element + + const element = fieldDetail.elementWeakRef.get(); + // Skip the field if it is null or readonly or disabled + if (!FormAutofillUtils.isFieldAutofillable(element)) { + continue; + } + + element.previewValue = ""; + // Bug 1687679: Since profile appears to be presentation ready data, we need to utilize the "x-formatted" field + // that is generated when presentation ready data doesn't fit into the autofilling element. + // For example, autofilling expiration month into an input element will not work as expected if + // the month is less than 10, since the input is expected a zero-padded string. + // See Bug 1722941 for follow up. + const value = this.getFilledValueFromProfile(fieldDetail, profile); + + if (HTMLInputElement.isInstance(element) && value) { + // For the focused input element, it will be filled with a valid value + // anyway. + // For the others, the fields should be only filled when their values are empty + // or their values are equal to the site prefill value + // or are the result of an earlier auto-fill. + if ( + element == this.#focusedInput || + (element != this.#focusedInput && + (!element.value || element.value == element.defaultValue)) || + this.handler.getFilledStateByElement(element) == + FIELD_STATES.AUTO_FILLED + ) { + this.fillFieldValue(element, value); + this.handler.changeFieldState(fieldDetail, FIELD_STATES.AUTO_FILLED); + } + } else if (HTMLSelectElement.isInstance(element)) { + let cache = this._cacheValue.matchingSelectOption.get(element) || {}; + let option = cache[value] && cache[value].get(); + if (!option) { + continue; + } + // Do not change value or dispatch events if the option is already selected. + // Use case for multiple select is not considered here. + if (!option.selected) { + option.selected = true; + this.fillFieldValue(element, option.value); + } + // Autofill highlight appears regardless if value is changed or not + this.handler.changeFieldState(fieldDetail, FIELD_STATES.AUTO_FILLED); + } + } + this.#focusedInput.focus({ preventScroll: true }); + + lazy.AutofillTelemetry.recordFormInteractionEvent("filled", this, { + profile, + }); + + return true; + } + + /** + * Populates result to the preview layers with given profile. + * + * @param {object} profile + * A profile to be previewed with + */ + previewFormFields(profile) { + this.preparePreviewProfile(profile); + + for (const fieldDetail of this.fieldDetails) { + let element = fieldDetail.elementWeakRef.get(); + // Skip the field if it is null or readonly or disabled + if (!FormAutofillUtils.isFieldAutofillable(element)) { + continue; + } + + let value = + profile[`${fieldDetail.fieldName}-formatted`] || + profile[fieldDetail.fieldName] || + ""; + if (HTMLSelectElement.isInstance(element)) { + // Unlike text input, select element is always previewed even if + // the option is already selected. + if (value) { + const cache = + this._cacheValue.matchingSelectOption.get(element) ?? {}; + const option = cache[value]?.get(); + value = option?.text ?? ""; + } + } else if (element.value && element.value != element.defaultValue) { + // Skip the field if the user has already entered text and that text is not the site prefilled value. + continue; + } + element.previewValue = value; + this.handler.changeFieldState( + fieldDetail, + value ? FIELD_STATES.PREVIEW : FIELD_STATES.NORMAL + ); + } + } + + /** + * Clear a previously autofilled field in this section + */ + clearFilled(fieldDetail) { + lazy.AutofillTelemetry.recordFormInteractionEvent("filled_modified", this, { + fieldName: fieldDetail.fieldName, + }); + + let isAutofilled = false; + const dimFieldDetails = []; + for (const fieldDetail of this.fieldDetails) { + const element = fieldDetail.elementWeakRef.get(); + + if (HTMLSelectElement.isInstance(element)) { + // Dim fields are those we don't attempt to revert their value + // when clear the target set, such as element to its selected option or the first option if there is none selected. + * + * @param {HTMLElement} element + * @memberof FormAutofillSection + */ + _resetSelectElementValue(element) { + if (!element.options.length) { + return; + } + let selected = [...element.options].find(option => + option.hasAttribute("selected") + ); + element.value = selected ? selected.value : element.options[0].value; + element.dispatchEvent( + new element.ownerGlobal.Event("input", { bubbles: true }) + ); + element.dispatchEvent( + new element.ownerGlobal.Event("change", { bubbles: true }) + ); + } +} + +export class FormAutofillAddressSection extends FormAutofillSection { + constructor(fieldDetails, handler) { + super(fieldDetails, handler); + + if (!this.isValidSection()) { + return; + } + + this._cacheValue.oneLineStreetAddress = null; + + lazy.AutofillTelemetry.recordDetectedSectionCount(this); + lazy.AutofillTelemetry.recordFormInteractionEvent("detected", this); + } + + isValidSection() { + const fields = new Set(this.fieldDetails.map(f => f.fieldName)); + return fields.size >= FormAutofillUtils.AUTOFILL_FIELDS_THRESHOLD; + } + + isEnabled() { + return FormAutofill.isAutofillAddressesEnabled; + } + + isRecordCreatable(record) { + if ( + record.country && + !FormAutofill.isAutofillAddressesAvailableInCountry(record.country) + ) { + // We don't want to save data in the wrong fields due to not having proper + // heuristic regexes in countries we don't yet support. + this.log.warn( + "isRecordCreatable: Country not supported:", + record.country + ); + return false; + } + + let hasName = 0; + let length = 0; + for (let key of Object.keys(record)) { + if (!record[key]) { + continue; + } + if (FormAutofillUtils.getCategoryFromFieldName(key) == "name") { + hasName = 1; + continue; + } + length++; + } + return length + hasName >= FormAutofillUtils.AUTOFILL_FIELDS_THRESHOLD; + } + + _getOneLineStreetAddress(address) { + if (!this._cacheValue.oneLineStreetAddress) { + this._cacheValue.oneLineStreetAddress = {}; + } + if (!this._cacheValue.oneLineStreetAddress[address]) { + this._cacheValue.oneLineStreetAddress[address] = + FormAutofillUtils.toOneLineAddress(address); + } + return this._cacheValue.oneLineStreetAddress[address]; + } + + addressTransformer(profile) { + if (profile["street-address"]) { + // "-moz-street-address-one-line" is used by the labels in + // ProfileAutoCompleteResult. + profile["-moz-street-address-one-line"] = this._getOneLineStreetAddress( + profile["street-address"] + ); + let streetAddressDetail = this.getFieldDetailByName("street-address"); + if ( + streetAddressDetail && + HTMLInputElement.isInstance(streetAddressDetail.elementWeakRef.get()) + ) { + profile["street-address"] = profile["-moz-street-address-one-line"]; + } + + let waitForConcat = []; + for (let f of ["address-line3", "address-line2", "address-line1"]) { + waitForConcat.unshift(profile[f]); + if (this.getFieldDetailByName(f)) { + if (waitForConcat.length > 1) { + profile[f] = FormAutofillUtils.toOneLineAddress(waitForConcat); + } + waitForConcat = []; + } + } + } + } + + /** + * Replace tel with tel-national if tel violates the input element's + * restriction. + * + * @param {object} profile + * A profile to be converted. + */ + telTransformer(profile) { + if (!profile.tel || !profile["tel-national"]) { + return; + } + + let detail = this.getFieldDetailByName("tel"); + if (!detail) { + return; + } + + let element = detail.elementWeakRef.get(); + let _pattern; + let testPattern = str => { + if (!_pattern) { + // The pattern has to match the entire value. + _pattern = new RegExp("^(?:" + element.pattern + ")$", "u"); + } + return _pattern.test(str); + }; + if (element.pattern) { + if (testPattern(profile.tel)) { + return; + } + } else if (element.maxLength) { + if ( + detail.reason == "autocomplete" && + profile.tel.length <= element.maxLength + ) { + return; + } + } + + if (detail.reason != "autocomplete") { + // Since we only target people living in US and using en-US websites in + // MVP, it makes more sense to fill `tel-national` instead of `tel` + // if the field is identified by heuristics and no other clues to + // determine which one is better. + // TODO: [Bug 1407545] This should be improved once more countries are + // supported. + profile.tel = profile["tel-national"]; + } else if (element.pattern) { + if (testPattern(profile["tel-national"])) { + profile.tel = profile["tel-national"]; + } + } else if (element.maxLength) { + if (profile["tel-national"].length <= element.maxLength) { + profile.tel = profile["tel-national"]; + } + } + } + + /* + * Apply all address related transformers. + * + * @param {Object} profile + * A profile for adjusting address related value. + * @override + */ + applyTransformers(profile) { + this.addressTransformer(profile); + this.telTransformer(profile); + this.matchSelectOptions(profile); + this.adaptFieldMaxLength(profile); + } + + computeFillingValue(value, fieldDetail, element) { + // Try to abbreviate the value of select element. + if ( + fieldDetail.fieldName == "address-level1" && + HTMLSelectElement.isInstance(element) + ) { + // Don't save the record when the option value is empty *OR* there + // are multiple options being selected. The empty option is usually + // assumed to be default along with a meaningless text to users. + if (!value || element.selectedOptions.length != 1) { + // Keep the property and preserve more information for address updating + value = ""; + } else { + let text = element.selectedOptions[0].text.trim(); + value = + FormAutofillUtils.getAbbreviatedSubregionName([value, text]) || text; + } + } + return value; + } + + createNormalizedRecord(address) { + if (!address) { + return; + } + + // Normalize Country + if (address.record.country) { + let detail = this.getFieldDetailByName("country"); + // Try identifying country field aggressively if it doesn't come from + // @autocomplete. + if (detail.reason != "autocomplete") { + let countryCode = FormAutofillUtils.identifyCountryCode( + address.record.country + ); + if (countryCode) { + address.record.country = countryCode; + } + } + } + + // Normalize Tel + FormAutofillUtils.compressTel(address.record); + if (address.record.tel) { + let allTelComponentsAreUntouched = Object.keys(address.record) + .filter( + field => FormAutofillUtils.getCategoryFromFieldName(field) == "tel" + ) + .every(field => address.untouchedFields.includes(field)); + if (allTelComponentsAreUntouched) { + // No need to verify it if none of related fields are modified after autofilling. + if (!address.untouchedFields.includes("tel")) { + address.untouchedFields.push("tel"); + } + } else { + let strippedNumber = address.record.tel.replace(/[\s\(\)-]/g, ""); + + // Remove "tel" if it contains invalid characters or the length of its + // number part isn't between 5 and 15. + // (The maximum length of a valid number in E.164 format is 15 digits + // according to https://en.wikipedia.org/wiki/E.164 ) + if (!/^(\+?)[\da-zA-Z]{5,15}$/.test(strippedNumber)) { + address.record.tel = ""; + } + } + } + } +} + +export class FormAutofillCreditCardSection extends FormAutofillSection { + /** + * Credit Card Section Constructor + * + * @param {object} fieldDetails + * The fieldDetail objects for the fields in this section + * @param {object} handler + * The FormAutofillHandler responsible for this section + */ + constructor(fieldDetails, handler) { + super(fieldDetails, handler); + + if (!this.isValidSection()) { + return; + } + + lazy.AutofillTelemetry.recordDetectedSectionCount(this); + lazy.AutofillTelemetry.recordFormInteractionEvent("detected", this); + + // Check whether the section is in an