diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-07 17:32:43 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-07 17:32:43 +0000 |
commit | 6bf0a5cb5034a7e684dcc3500e841785237ce2dd (patch) | |
tree | a68f146d7fa01f0134297619fbe7e33db084e0aa /toolkit/components/formautofill | |
parent | Initial commit. (diff) | |
download | thunderbird-upstream.tar.xz thunderbird-upstream.zip |
Adding upstream version 1:115.7.0.upstream/1%115.7.0upstream
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'toolkit/components/formautofill')
39 files changed, 18336 insertions, 0 deletions
diff --git a/toolkit/components/formautofill/.eslintrc.js b/toolkit/components/formautofill/.eslintrc.js new file mode 100644 index 0000000000..5a84c2c3e0 --- /dev/null +++ b/toolkit/components/formautofill/.eslintrc.js @@ -0,0 +1,67 @@ +/* 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/. */ + +"use strict"; + +module.exports = { + rules: { + // Rules from the mozilla plugin + "mozilla/balanced-listeners": "error", + "mozilla/no-aArgs": "error", + "mozilla/var-only-at-top-level": "error", + + // No expressions where a statement is expected + "no-unused-expressions": "error", + + // No declaring variables that are never used + "no-unused-vars": [ + "error", + { + args: "none", + vars: "all", + }, + ], + + // No using variables before defined + "no-use-before-define": "error", + + // Disallow using variables outside the blocks they are defined (especially + // since only let and const are used, see "no-var"). + "block-scoped-var": "error", + + // Warn about cyclomatic complexity in functions. + complexity: ["error", { max: 26 }], + + // Maximum depth callbacks can be nested. + "max-nested-callbacks": ["error", 4], + + // Disallow using the console API. + "no-console": ["error", { allow: ["error"] }], + + // Disallow fallthrough of case statements, except if there is a comment. + "no-fallthrough": "error", + + // Disallow use of multiline strings (use template strings instead). + "no-multi-str": "error", + + // Disallow usage of __proto__ property. + "no-proto": "error", + + // Disallow use of assignment in return statement. It is preferable for a + // single line of code to have only one easily predictable effect. + "no-return-assign": "error", + + // Require use of the second argument for parseInt(). + radix: "error", + + // Require "use strict" to be defined globally in the script. + strict: ["error", "global"], + + // Disallow Yoda conditions (where literal value comes first). + yoda: "error", + + // Disallow function or variable declarations in nested blocks + "no-inner-declarations": "error", + }, +}; diff --git a/toolkit/components/formautofill/AutofillProfileAutoComplete.sys.mjs b/toolkit/components/formautofill/AutofillProfileAutoComplete.sys.mjs new file mode 100644 index 0000000000..a8955b5fc2 --- /dev/null +++ b/toolkit/components/formautofill/AutofillProfileAutoComplete.sys.mjs @@ -0,0 +1,417 @@ +/* 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/. */ + +/** + * Form Autofill content process module. + */ + +/* eslint-disable no-use-before-define */ + +const Cm = Components.manager; + +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + AddressResult: "resource://autofill/ProfileAutoCompleteResult.sys.mjs", + ComponentUtils: "resource://gre/modules/ComponentUtils.sys.mjs", + CreditCardResult: "resource://autofill/ProfileAutoCompleteResult.sys.mjs", + FormAutofill: "resource://autofill/FormAutofill.sys.mjs", + FormAutofillUtils: "resource://gre/modules/shared/FormAutofillUtils.sys.mjs", + FormAutofillContent: "resource://autofill/FormAutofillContent.sys.mjs", + InsecurePasswordUtils: "resource://gre/modules/InsecurePasswordUtils.sys.mjs", +}); + +const autocompleteController = Cc[ + "@mozilla.org/autocomplete/controller;1" +].getService(Ci.nsIAutoCompleteController); + +XPCOMUtils.defineLazyGetter( + lazy, + "ADDRESSES_COLLECTION_NAME", + () => lazy.FormAutofillUtils.ADDRESSES_COLLECTION_NAME +); +XPCOMUtils.defineLazyGetter( + lazy, + "CREDITCARDS_COLLECTION_NAME", + () => lazy.FormAutofillUtils.CREDITCARDS_COLLECTION_NAME +); +XPCOMUtils.defineLazyGetter( + lazy, + "FIELD_STATES", + () => lazy.FormAutofillUtils.FIELD_STATES +); + +function getActorFromWindow(contentWindow, name = "FormAutofill") { + // In unit tests, contentWindow isn't a real window. + if (!contentWindow) { + return null; + } + + return contentWindow.windowGlobalChild + ? contentWindow.windowGlobalChild.getActor(name) + : null; +} + +// Register/unregister a constructor as a factory. +function AutocompleteFactory() {} +AutocompleteFactory.prototype = { + register(targetConstructor) { + let proto = targetConstructor.prototype; + this._classID = proto.classID; + + let factory = + lazy.ComponentUtils.generateSingletonFactory(targetConstructor); + this._factory = factory; + + let registrar = Cm.QueryInterface(Ci.nsIComponentRegistrar); + registrar.registerFactory( + proto.classID, + proto.classDescription, + proto.contractID, + factory + ); + + if (proto.classID2) { + this._classID2 = proto.classID2; + registrar.registerFactory( + proto.classID2, + proto.classDescription, + proto.contractID2, + factory + ); + } + }, + + unregister() { + let registrar = Cm.QueryInterface(Ci.nsIComponentRegistrar); + registrar.unregisterFactory(this._classID, this._factory); + if (this._classID2) { + registrar.unregisterFactory(this._classID2, this._factory); + } + this._factory = null; + }, +}; + +/** + * @class + * + * @implements {nsIAutoCompleteSearch} + */ +function AutofillProfileAutoCompleteSearch() { + this.log = lazy.FormAutofill.defineLogGetter( + this, + "AutofillProfileAutoCompleteSearch" + ); +} +AutofillProfileAutoCompleteSearch.prototype = { + classID: Components.ID("4f9f1e4c-7f2c-439e-9c9e-566b68bc187d"), + contractID: "@mozilla.org/autocomplete/search;1?name=autofill-profiles", + classDescription: "AutofillProfileAutoCompleteSearch", + QueryInterface: ChromeUtils.generateQI(["nsIAutoCompleteSearch"]), + + // Begin nsIAutoCompleteSearch implementation + + /** + * Searches for a given string and notifies a listener (either synchronously + * or asynchronously) of the result + * + * @param {string} searchString the string to search for + * @param {string} searchParam + * @param {object} previousResult a previous result to use for faster searchinig + * @param {object} listener the listener to notify when the search is complete + */ + startSearch(searchString, searchParam, previousResult, listener) { + let { + activeInput, + activeSection, + activeFieldDetail, + activeHandler, + savedFieldNames, + } = lazy.FormAutofillContent; + this.forceStop = false; + + let isAddressField = lazy.FormAutofillUtils.isAddressField( + activeFieldDetail.fieldName + ); + const isCreditCardField = lazy.FormAutofillUtils.isCreditCardField( + activeFieldDetail.fieldName + ); + let isInputAutofilled = + activeHandler.getFilledStateByElement(activeInput) == + lazy.FIELD_STATES.AUTO_FILLED; + let allFieldNames = activeSection.allFieldNames; + let filledRecordGUID = activeSection.filledRecordGUID; + + let searchPermitted = isAddressField + ? lazy.FormAutofill.isAutofillAddressesEnabled + : lazy.FormAutofill.isAutofillCreditCardsEnabled; + let AutocompleteResult = isAddressField + ? lazy.AddressResult + : lazy.CreditCardResult; + let isFormAutofillSearch = true; + let pendingSearchResult = null; + + ProfileAutocomplete.lastProfileAutoCompleteFocusedInput = activeInput; + // Fallback to form-history if ... + // - specified autofill feature is pref off. + // - no profile can fill the currently-focused input. + // - the current form has already been populated and the field is not + // an empty credit card field. + // - (address only) less than 3 inputs are covered by all saved fields in the storage. + if ( + !searchPermitted || + !savedFieldNames.has(activeFieldDetail.fieldName) || + (!isInputAutofilled && + filledRecordGUID && + !(isCreditCardField && activeInput.value === "")) || + (isAddressField && + allFieldNames.filter(field => savedFieldNames.has(field)).length < + lazy.FormAutofillUtils.AUTOFILL_FIELDS_THRESHOLD) + ) { + isFormAutofillSearch = false; + if (activeInput.autocomplete == "off") { + // Create a dummy result as an empty search result. + pendingSearchResult = new AutocompleteResult("", "", [], [], {}); + } else { + pendingSearchResult = new Promise(resolve => { + let formHistory = Cc[ + "@mozilla.org/autocomplete/search;1?name=form-history" + ].createInstance(Ci.nsIAutoCompleteSearch); + formHistory.startSearch(searchString, searchParam, previousResult, { + onSearchResult: (_, result) => resolve(result), + }); + }); + } + } else if (isInputAutofilled) { + pendingSearchResult = new AutocompleteResult(searchString, "", [], [], { + isInputAutofilled, + }); + } else { + let infoWithoutElement = { ...activeFieldDetail }; + delete infoWithoutElement.elementWeakRef; + + let data = { + collectionName: isAddressField + ? lazy.ADDRESSES_COLLECTION_NAME + : lazy.CREDITCARDS_COLLECTION_NAME, + info: infoWithoutElement, + searchString, + }; + + pendingSearchResult = this._getRecords(activeInput, data).then( + records => { + if (this.forceStop) { + return null; + } + // Sort addresses by timeLastUsed for showing the lastest used address at top. + records.sort((a, b) => b.timeLastUsed - a.timeLastUsed); + + let adaptedRecords = activeSection.getAdaptedProfiles(records); + let handler = lazy.FormAutofillContent.activeHandler; + let isSecure = lazy.InsecurePasswordUtils.isFormSecure(handler.form); + + return new AutocompleteResult( + searchString, + activeFieldDetail.fieldName, + allFieldNames, + adaptedRecords, + { isSecure, isInputAutofilled } + ); + } + ); + } + + Promise.resolve(pendingSearchResult).then(result => { + if (this.forceStop) { + // If we notify the listener the search result when the search is already + // cancelled, it corrupts the internal state of the listener. So we only + // reset the controller's state in this case. + if (isFormAutofillSearch) { + autocompleteController.resetInternalState(); + } + return; + } + + listener.onSearchResult(this, result); + // Don't save cache results or reset state when returning non-autofill results such as the + // form history fallback above. + if (isFormAutofillSearch) { + ProfileAutocomplete.lastProfileAutoCompleteResult = result; + // Reset AutoCompleteController's state at the end of startSearch to ensure that + // none of form autofill result will be cached in other places and make the + // result out of sync. + autocompleteController.resetInternalState(); + } else { + // Clear the cache so that we don't try to autofill from it after falling + // back to form history. + ProfileAutocomplete.lastProfileAutoCompleteResult = null; + } + }); + }, + + /** + * Stops an asynchronous search that is in progress + */ + stopSearch() { + ProfileAutocomplete.lastProfileAutoCompleteResult = null; + this.forceStop = true; + }, + + /** + * Get the records from parent process for AutoComplete result. + * + * @private + * @param {object} input + * Input element for autocomplete. + * @param {object} data + * Parameters for querying the corresponding result. + * @param {string} data.collectionName + * The name used to specify which collection to retrieve records. + * @param {string} data.searchString + * The typed string for filtering out the matched records. + * @param {string} data.info + * The input autocomplete property's information. + * @returns {Promise} + * Promise that resolves when addresses returned from parent process. + */ + _getRecords(input, data) { + if (!input) { + return []; + } + + let actor = getActorFromWindow(input.ownerGlobal); + return actor.sendQuery("FormAutofill:GetRecords", data); + }, +}; + +export const ProfileAutocomplete = { + QueryInterface: ChromeUtils.generateQI(["nsIObserver"]), + + lastProfileAutoCompleteResult: null, + lastProfileAutoCompleteFocusedInput: null, + _registered: false, + _factory: null, + + ensureRegistered() { + if (this._registered) { + return; + } + + this.log = lazy.FormAutofill.defineLogGetter(this, "ProfileAutocomplete"); + this.debug("ensureRegistered"); + this._factory = new AutocompleteFactory(); + this._factory.register(AutofillProfileAutoCompleteSearch); + this._registered = true; + + Services.obs.addObserver(this, "autocomplete-will-enter-text"); + + this.debug( + "ensureRegistered. Finished with _registered:", + this._registered + ); + }, + + ensureUnregistered() { + if (!this._registered) { + return; + } + + this.debug("ensureUnregistered"); + this._factory.unregister(); + this._factory = null; + this._registered = false; + this._lastAutoCompleteResult = null; + + Services.obs.removeObserver(this, "autocomplete-will-enter-text"); + }, + + async observe(subject, topic, data) { + switch (topic) { + case "autocomplete-will-enter-text": { + if (!lazy.FormAutofillContent.activeInput) { + // The observer notification is for autocomplete in a different process. + break; + } + lazy.FormAutofillContent.autofillPending = true; + Services.obs.notifyObservers(null, "autofill-fill-starting"); + await this._fillFromAutocompleteRow( + lazy.FormAutofillContent.activeInput + ); + Services.obs.notifyObservers(null, "autofill-fill-complete"); + lazy.FormAutofillContent.autofillPending = false; + break; + } + } + }, + + _getSelectedIndex(contentWindow) { + let actor = getActorFromWindow(contentWindow, "AutoComplete"); + if (!actor) { + throw new Error("Invalid autocomplete selectedIndex"); + } + + return actor.selectedIndex; + }, + + async _fillFromAutocompleteRow(focusedInput) { + this.debug("_fillFromAutocompleteRow:", focusedInput); + let formDetails = lazy.FormAutofillContent.activeFormDetails; + if (!formDetails) { + // The observer notification is for a different frame. + return; + } + + let selectedIndex = this._getSelectedIndex(focusedInput.ownerGlobal); + if ( + selectedIndex == -1 || + !this.lastProfileAutoCompleteResult || + this.lastProfileAutoCompleteResult.getStyleAt(selectedIndex) != + "autofill-profile" + ) { + return; + } + + let profile = JSON.parse( + this.lastProfileAutoCompleteResult.getCommentAt(selectedIndex) + ); + + await lazy.FormAutofillContent.activeHandler.autofillFormFields(profile); + }, + + _clearProfilePreview() { + if ( + !this.lastProfileAutoCompleteFocusedInput || + !lazy.FormAutofillContent.activeSection + ) { + return; + } + + lazy.FormAutofillContent.activeSection.clearPreviewedFormFields(); + }, + + _previewSelectedProfile(selectedIndex) { + if ( + !lazy.FormAutofillContent.activeInput || + !lazy.FormAutofillContent.activeFormDetails + ) { + // The observer notification is for a different process/frame. + return; + } + + if ( + !this.lastProfileAutoCompleteResult || + this.lastProfileAutoCompleteResult.getStyleAt(selectedIndex) != + "autofill-profile" + ) { + return; + } + + let profile = JSON.parse( + this.lastProfileAutoCompleteResult.getCommentAt(selectedIndex) + ); + lazy.FormAutofillContent.activeSection.previewFormFields(profile); + }, +}; diff --git a/toolkit/components/formautofill/AutofillTelemetry.sys.mjs b/toolkit/components/formautofill/AutofillTelemetry.sys.mjs new file mode 100644 index 0000000000..1d8dea44b8 --- /dev/null +++ b/toolkit/components/formautofill/AutofillTelemetry.sys.mjs @@ -0,0 +1,557 @@ +/* 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 { FormAutofillUtils } from "resource://gre/modules/shared/FormAutofillUtils.sys.mjs"; +import { FormAutofillCreditCardSection } from "resource://gre/modules/shared/FormAutofillSection.sys.mjs"; + +const { FIELD_STATES } = FormAutofillUtils; + +class AutofillTelemetryBase { + SUPPORTED_FIELDS = {}; + + EVENT_CATEGORY = null; + EVENT_OBJECT_FORM_INTERACTION = null; + + SCALAR_DETECTED_SECTION_COUNT = null; + SCALAR_SUBMITTED_SECTION_COUNT = null; + SCALAR_AUTOFILL_PROFILE_COUNT = null; + + HISTOGRAM_NUM_USES = null; + HISTOGRAM_PROFILE_NUM_USES = null; + HISTOGRAM_PROFILE_NUM_USES_KEY = null; + + #initFormEventExtra(value) { + let extra = {}; + for (const field of Object.values(this.SUPPORTED_FIELDS)) { + extra[field] = value; + } + return extra; + } + + #setFormEventExtra(extra, key, value) { + if (!this.SUPPORTED_FIELDS[key]) { + return; + } + + extra[this.SUPPORTED_FIELDS[key]] = value; + } + + recordFormDetected(section) { + let extra = this.#initFormEventExtra("false"); + let identified = new Set(); + section.fieldDetails.forEach(detail => { + identified.add(detail.fieldName); + + if (detail.reason == "autocomplete") { + this.#setFormEventExtra(extra, detail.fieldName, "true"); + } else { + // confidence exists only when a field is identified by fathom. + let confidence = + detail.confidence > 0 ? Math.floor(100 * detail.confidence) / 100 : 0; + this.#setFormEventExtra(extra, detail.fieldName, confidence.toString()); + } + }); + + this.recordFormEvent("detected", section.flowId, extra); + } + + recordPopupShown(section, fieldName) { + const extra = { field_name: fieldName }; + this.recordFormEvent("popup_shown", section.flowId, extra); + } + + recordFormFilled(section, profile) { + // Calculate values for telemetry + let extra = this.#initFormEventExtra("unavailable"); + + for (let fieldDetail of section.fieldDetails) { + let element = fieldDetail.elementWeakRef.get(); + let state = profile[fieldDetail.fieldName] ? "filled" : "not_filled"; + if ( + section.handler.getFilledStateByElement(element) == + FIELD_STATES.NORMAL && + (HTMLSelectElement.isInstance(element) || + (HTMLInputElement.isInstance(element) && element.value.length)) + ) { + state = "user_filled"; + } + this.#setFormEventExtra(extra, fieldDetail.fieldName, state); + } + + this.recordFormEvent("filled", section.flowId, extra); + } + + recordFilledModified(section, fieldName) { + const extra = { field_name: fieldName }; + this.recordFormEvent("filled_modified", section.flowId, extra); + } + + recordFormSubmitted(section, record, form) { + let extra = this.#initFormEventExtra("unavailable"); + + if (record.guid !== null) { + // If the `guid` is not null, it means we're editing an existing record. + // In that case, all fields in the record are autofilled, and fields in + // `untouchedFields` are unmodified. + for (const [fieldName, value] of Object.entries(record.record)) { + if (record.untouchedFields?.includes(fieldName)) { + this.#setFormEventExtra(extra, fieldName, "autofilled"); + } else if (value) { + this.#setFormEventExtra(extra, fieldName, "user_filled"); + } else { + this.#setFormEventExtra(extra, fieldName, "not_filled"); + } + } + } else { + Object.keys(record.record).forEach(fieldName => + this.#setFormEventExtra(extra, fieldName, "user_filled") + ); + } + + this.recordFormEvent("submitted", section.flowId, extra); + } + + recordFormCleared(section, fieldName) { + const extra = { field_name: fieldName }; + + // Note that when a form is cleared, we also record `filled_modified` events + // for all the fields that have been cleared. + this.recordFormEvent("cleared", section.flowId, extra); + } + + recordFormEvent(method, flowId, extra) { + Services.telemetry.recordEvent( + this.EVENT_CATEGORY, + method, + this.EVENT_OBJECT_FORM_INTERACTION, + flowId, + extra + ); + } + + recordFormInteractionEvent( + method, + section, + { fieldName, profile, record, form } = {} + ) { + if (!this.EVENT_OBJECT_FORM_INTERACTION) { + return undefined; + } + switch (method) { + case "detected": + return this.recordFormDetected(section); + case "popup_shown": + return this.recordPopupShown(section, fieldName); + case "filled": + return this.recordFormFilled(section, profile); + case "filled_modified": + return this.recordFilledModified(section, fieldName); + case "submitted": + return this.recordFormSubmitted(section, record, form); + case "cleared": + return this.recordFormCleared(section, fieldName); + } + return undefined; + } + + recordDoorhangerEvent(method, flowId, isCapture) { + Services.telemetry.recordEvent( + this.EVENT_CATEGORY, + method, + isCapture ? "capture_doorhanger" : "update_doorhanger", + flowId + ); + } + + recordManageEvent(method, object) { + Services.telemetry.recordEvent(this.EVENT_CATEGORY, method, object); + } + + recordAutofillProfileCount(count) { + if (!this.SCALAR_AUTOFILL_PROFILE_COUNT) { + return; + } + + Services.telemetry.scalarSet(this.SCALAR_AUTOFILL_PROFILE_COUNT, count); + } + + recordDetectedSectionCount() { + if (!this.SCALAR_DETECTED_SECTION_COUNT) { + return; + } + + Services.telemetry.scalarAdd(this.SCALAR_DETECTED_SECTION_COUNT, 1); + } + + recordSubmittedSectionCount(count) { + if (!this.SCALAR_SUBMITTED_SECTION_COUNT || !count) { + return; + } + + Services.telemetry.scalarAdd(this.SCALAR_SUBMITTED_SECTION_COUNT, count); + } + + recordNumberOfUse(records) { + let histogram = Services.telemetry.getKeyedHistogramById( + this.HISTOGRAM_PROFILE_NUM_USES + ); + histogram.clear(); + + for (let record of records) { + histogram.add(this.HISTOGRAM_PROFILE_NUM_USES_KEY, record.timesUsed); + } + } +} + +export class AddressTelemetry extends AutofillTelemetryBase { + EVENT_CATEGORY = "address"; + EVENT_OBJECT_FORM_INTERACTION = "address_form"; + EVENT_OBJECT_FORM_INTERACTION_EXT = "address_form_ext"; + + SCALAR_DETECTED_SECTION_COUNT = + "formautofill.addresses.detected_sections_count"; + SCALAR_SUBMITTED_SECTION_COUNT = + "formautofill.addresses.submitted_sections_count"; + SCALAR_AUTOFILL_PROFILE_COUNT = + "formautofill.addresses.autofill_profiles_count"; + + HISTOGRAM_PROFILE_NUM_USES = "AUTOFILL_PROFILE_NUM_USES"; + HISTOGRAM_PROFILE_NUM_USES_KEY = "address"; + + // Fields that are record in `address_form` and `address_form_ext` telemetry + SUPPORTED_FIELDS = { + "street-address": "street_address", + "address-line1": "address_line1", + "address-line2": "address_line2", + "address-line3": "address_line3", + "address-level1": "address_level1", + "address-level2": "address_level2", + "postal-code": "postal_code", + country: "country", + name: "name", + "given-name": "given_name", + "additional-name": "additional_name", + "family-name": "family_name", + email: "email", + organization: "organization", + tel: "tel", + }; + + // Fields that are record in `address_form` event telemetry extra_keys + static SUPPORTED_FIELDS_IN_FORM = [ + "street_address", + "address_line1", + "address_line2", + "address_line3", + "address_level2", + "address_level1", + "postal_code", + "country", + ]; + + // Fields that are record in `address_form_ext` event telemetry extra_keys + static SUPPORTED_FIELDS_IN_FORM_EXT = [ + "name", + "given_name", + "additional_name", + "family_name", + "email", + "organization", + "tel", + ]; + + recordFormEvent(method, flowId, extra) { + let extExtra = {}; + if (["detected", "filled", "submitted"].includes(method)) { + for (const [key, value] of Object.entries(extra)) { + if (AddressTelemetry.SUPPORTED_FIELDS_IN_FORM_EXT.includes(key)) { + extExtra[key] = value; + delete extra[key]; + } + } + } + + Services.telemetry.recordEvent( + this.EVENT_CATEGORY, + method, + this.EVENT_OBJECT_FORM_INTERACTION, + flowId, + extra + ); + + if (Object.keys(extExtra).length) { + Services.telemetry.recordEvent( + this.EVENT_CATEGORY, + method, + this.EVENT_OBJECT_FORM_INTERACTION_EXT, + flowId, + extExtra + ); + } + } +} + +class CreditCardTelemetry extends AutofillTelemetryBase { + EVENT_CATEGORY = "creditcard"; + EVENT_OBJECT_FORM_INTERACTION = "cc_form_v2"; + + SCALAR_DETECTED_SECTION_COUNT = + "formautofill.creditCards.detected_sections_count"; + SCALAR_SUBMITTED_SECTION_COUNT = + "formautofill.creditCards.submitted_sections_count"; + SCALAR_AUTOFILL_PROFILE_COUNT = + "formautofill.creditCards.autofill_profiles_count"; + + HISTOGRAM_NUM_USES = "CREDITCARD_NUM_USES"; + HISTOGRAM_PROFILE_NUM_USES = "AUTOFILL_PROFILE_NUM_USES"; + HISTOGRAM_PROFILE_NUM_USES_KEY = "credit_card"; + + // Mapping of field name used in formautofill code to the field name + // used in the telemetry. + SUPPORTED_FIELDS = { + "cc-name": "cc_name", + "cc-number": "cc_number", + "cc-type": "cc_type", + "cc-exp": "cc_exp", + "cc-exp-month": "cc_exp_month", + "cc-exp-year": "cc_exp_year", + }; + + recordLegacyFormEvent(method, flowId, extra = null) { + Services.telemetry.recordEvent( + this.EVENT_CATEGORY, + method, + "cc_form", + flowId, + extra + ); + } + + recordFormDetected(section) { + super.recordFormDetected(section); + + let identified = new Set(); + section.fieldDetails.forEach(detail => { + identified.add(detail.fieldName); + }); + let extra = { + cc_name_found: identified.has("cc-name") ? "true" : "false", + cc_number_found: identified.has("cc-number") ? "true" : "false", + cc_exp_found: + identified.has("cc-exp") || + (identified.has("cc-exp-month") && identified.has("cc-exp-year")) + ? "true" + : "false", + }; + + this.recordLegacyFormEvent("detected", section.flowId, extra); + } + + recordPopupShown(section, fieldName) { + super.recordPopupShown(section, fieldName); + + this.recordLegacyFormEvent("popup_shown", section.flowId); + } + + recordFormFilled(section, profile) { + super.recordFormFilled(section, profile); + // Calculate values for telemetry + let extra = { + cc_name: "unavailable", + cc_number: "unavailable", + cc_exp: "unavailable", + }; + + for (let fieldDetail of section.fieldDetails) { + let element = fieldDetail.elementWeakRef.get(); + let state = profile[fieldDetail.fieldName] ? "filled" : "not_filled"; + if ( + section.handler.getFilledStateByElement(element) == + FIELD_STATES.NORMAL && + (HTMLSelectElement.isInstance(element) || + (HTMLInputElement.isInstance(element) && element.value.length)) + ) { + state = "user_filled"; + } + switch (fieldDetail.fieldName) { + case "cc-name": + extra.cc_name = state; + break; + case "cc-number": + extra.cc_number = state; + break; + case "cc-exp": + case "cc-exp-month": + case "cc-exp-year": + extra.cc_exp = state; + break; + } + } + + this.recordLegacyFormEvent("filled", section.flowId, extra); + } + + recordFilledModified(section, fieldName) { + super.recordFilledModified(section, fieldName); + + let extra = { field_name: fieldName }; + this.recordLegacyFormEvent("filled_modified", section.flowId, extra); + } + + /** + * Called when a credit card form is submitted + * + * @param {object} section Section that produces this record + * @param {object} record Credit card record filled in the form. + * @param {Array<HTMLForm>} form Form that contains the section + */ + recordFormSubmitted(section, record, form) { + super.recordFormSubmitted(section, record, form); + + // For legacy cc_form event telemetry + let extra = { + fields_not_auto: "0", + fields_auto: "0", + fields_modified: "0", + }; + + if (record.guid !== null) { + let totalCount = form.elements.length; + let autofilledCount = Object.keys(record.record).length; + let unmodifiedCount = record.untouchedFields.length; + + extra.fields_not_auto = (totalCount - autofilledCount).toString(); + extra.fields_auto = autofilledCount.toString(); + extra.fields_modified = (autofilledCount - unmodifiedCount).toString(); + } else { + // If the `guid` is null, we're filling a new form. + // In that case, all not-null fields are manually filled. + extra.fields_not_auto = Array.from(form.elements) + .filter(element => !!element.value?.trim().length) + .length.toString(); + } + + this.recordLegacyFormEvent("submitted", section.flowId, extra); + } + + recordNumberOfUse(records) { + super.recordNumberOfUse(records); + + if (!this.HISTOGRAM_NUM_USES) { + return; + } + + let histogram = Services.telemetry.getHistogramById( + this.HISTOGRAM_NUM_USES + ); + histogram.clear(); + + for (let record of records) { + histogram.add(record.timesUsed); + } + } +} + +export class AutofillTelemetry { + static #creditCardTelemetry = new CreditCardTelemetry(); + static #addressTelemetry = new AddressTelemetry(); + + // const for `type` parameter used in the utility functions + static ADDRESS = "address"; + static CREDIT_CARD = "creditcard"; + + static #getTelemetryBySection(section) { + return section instanceof FormAutofillCreditCardSection + ? this.#creditCardTelemetry + : this.#addressTelemetry; + } + + static #getTelemetryByType(type) { + return type == AutofillTelemetry.CREDIT_CARD + ? this.#creditCardTelemetry + : this.#addressTelemetry; + } + /** + * Utility functions for `doorhanger` event (defined in Events.yaml) + * + * Category: address or creditcard + * Event name: doorhanger + */ + + static recordDoorhangerShown(type, flowId, isCapture) { + const telemetry = this.#getTelemetryByType(type); + telemetry.recordDoorhangerEvent("show", flowId, isCapture); + } + + static recordDoorhangerClicked(type, method, flowId, isCapture) { + const telemetry = this.#getTelemetryByType(type); + + // We don't have `create` method in telemetry, we treat `create` as `save` + switch (method) { + case "create": + method = "save"; + break; + case "open-pref": + method = "pref"; + break; + } + + telemetry.recordDoorhangerEvent(method, flowId, isCapture); + } + + /** + * Utility functions for form event (defined in Events.yaml) + * + * Category: address or creditcard + * Event name: cc_form, cc_form_v2, or address_form + */ + + static recordFormInteractionEvent( + method, + section, + { fieldName, profile, record, form } = {} + ) { + const telemetry = this.#getTelemetryBySection(section); + telemetry.recordFormInteractionEvent(method, section, { + fieldName, + profile, + record, + form, + }); + } + + /** + * Utility functions for submitted section count scalar (defined in Scalars.yaml) + * + * Category: formautofill.creditCards or formautofill.addresses + * Scalar name: submitted_sections_count + */ + static recordDetectedSectionCount(section) { + const telemetry = this.#getTelemetryBySection(section); + telemetry.recordDetectedSectionCount(); + } + + static recordSubmittedSectionCount(type, count) { + const telemetry = this.#getTelemetryByType(type); + telemetry.recordSubmittedSectionCount(count); + } + + static recordManageEvent(type, method) { + const telemetry = this.#getTelemetryByType(type); + telemetry.recordManageEvent(method, "manage"); + } + + static recordAutofillProfileCount(type, count) { + const telemetry = this.#getTelemetryByType(type); + telemetry.recordAutofillProfileCount(count); + } + + /** + * Utility functions for address/credit card number of use + */ + static recordNumberOfUse(type, records) { + const telemetry = this.#getTelemetryByType(type); + telemetry.recordNumberOfUse(records); + } +} diff --git a/toolkit/components/formautofill/Constants.ios.mjs b/toolkit/components/formautofill/Constants.ios.mjs new file mode 100644 index 0000000000..619c1b9aad --- /dev/null +++ b/toolkit/components/formautofill/Constants.ios.mjs @@ -0,0 +1,102 @@ +/* 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/. */ + +const IOS_DEFAULT_PREFERENCES = { + "extensions.formautofill.creditCards.heuristics.mode": 1, + "extensions.formautofill.creditCards.heuristics.fathom.confidenceThreshold": 0.5, + "extensions.formautofill.creditCards.heuristics.fathom.highConfidenceThreshold": 0.95, + "extensions.formautofill.creditCards.heuristics.fathom.testConfidence": 0, + "extensions.formautofill.creditCards.heuristics.fathom.types": + "cc-number,cc-name", + "extensions.formautofill.loglevel": "Warn", + "extensions.formautofill.firstTimeUse": true, + "extensions.formautofill.addresses.supported": "off", + "extensions.formautofill.creditCards.supported": "detect", + "browser.search.region": "US", + "extensions.formautofill.creditCards.supportedCountries": "US,CA,GB,FR,DE", + "extensions.formautofill.addresses.enabled": false, + "extensions.formautofill.addresses.capture.enabled": false, + "extensions.formautofill.addresses.capture.v2.enabled": false, + "extensions.formautofill.addresses.supportedCountries": "", + "extensions.formautofill.creditCards.enabled": true, + "extensions.formautofill.reauth.enabled": true, + "extensions.formautofill.creditCards.hideui": false, + "extensions.formautofill.supportRTL": false, + "extensions.formautofill.creditCards.ignoreAutocompleteOff": true, + "extensions.formautofill.addresses.ignoreAutocompleteOff": true, + "extensions.formautofill.heuristics.enabled": true, + "extensions.formautofill.section.enabled": true, + // WebKit doesn't support the checkVisibility API, setting the threshold value to 0 to esnure + // `IsFieldVisible` function doesn't use it + "extensions.formautofill.heuristics.visibilityCheckThreshold": 0, + "extensions.formautofill.focusOnAutofill": false, +}; + +// Used Mimic the behavior of .getAutocompleteInfo() +// List from: https://searchfox.org/mozilla-central/source/dom/base/AutocompleteFieldList.h#89-149 +// Also found here: https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/autocomplete +const VALID_AUTOCOMPLETE_FIELDS = [ + "off", + "on", + "name", + "honorific-prefix", + "given-name", + "additional-name", + "family-name", + "honorific-suffix", + "nickname", + "email", + "username", + "new-password", + "current-password", + "one-time-code", + "organization-title", + "organization", + "street-address", + "address-line1", + "address-line2", + "address-line3", + "address-level4", + "address-level3", + "address-level2", + "address-level1", + "country", + "country-name", + "postal-code", + "cc-name", + "cc-given-name", + "cc-additional-name", + "cc-family-name", + "cc-number", + "cc-exp", + "cc-exp-month", + "cc-exp-year", + "cc-csc", + "cc-type", + "transaction-currency", + "transaction-amount", + "language", + "bday", + "bday-day", + "bday-month", + "bday-year", + "sex", + "tel", + "tel-country-code", + "tel-national", + "tel-area-code", + "tel-local", + "tel-extension", + "impp", + "url", + "photo", +]; + +export const IOSAppConstants = Object.freeze({ + platform: "ios", + prefs: IOS_DEFAULT_PREFERENCES, + validAutocompleteFields: VALID_AUTOCOMPLETE_FIELDS, +}); + +export default IOSAppConstants; diff --git a/toolkit/components/formautofill/FormAutofill.ios.sys.mjs b/toolkit/components/formautofill/FormAutofill.ios.sys.mjs new file mode 100644 index 0000000000..8e205c16c6 --- /dev/null +++ b/toolkit/components/formautofill/FormAutofill.ios.sys.mjs @@ -0,0 +1,17 @@ +/* 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"; + +FormAutofill.defineLogGetter = (scope, logPrefix) => ({ + // TODO: Bug 1828405. Explore how logging should be handled. + // Maybe it makes more sense to do it on swift side and have JS just send messages. + info: () => {}, + error: () => {}, + warn: () => {}, + debug: () => {}, +}); + +export { FormAutofill }; +export default FormAutofill; diff --git a/toolkit/components/formautofill/FormAutofill.sys.mjs b/toolkit/components/formautofill/FormAutofill.sys.mjs new file mode 100644 index 0000000000..49c594a345 --- /dev/null +++ b/toolkit/components/formautofill/FormAutofill.sys.mjs @@ -0,0 +1,269 @@ +/* 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 { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; +import { Region } from "resource://gre/modules/Region.sys.mjs"; + +const ADDRESSES_FIRST_TIME_USE_PREF = "extensions.formautofill.firstTimeUse"; +const AUTOFILL_ADDRESSES_AVAILABLE_PREF = + "extensions.formautofill.addresses.supported"; +// This pref should be refactored after the migration of the old bool pref +const AUTOFILL_CREDITCARDS_AVAILABLE_PREF = + "extensions.formautofill.creditCards.supported"; +const BROWSER_SEARCH_REGION_PREF = "browser.search.region"; +const CREDITCARDS_AUTOFILL_SUPPORTED_COUNTRIES_PREF = + "extensions.formautofill.creditCards.supportedCountries"; +const ENABLED_AUTOFILL_ADDRESSES_PREF = + "extensions.formautofill.addresses.enabled"; +const ENABLED_AUTOFILL_ADDRESSES_CAPTURE_PREF = + "extensions.formautofill.addresses.capture.enabled"; +const ENABLED_AUTOFILL_ADDRESSES_CAPTURE_V2_PREF = + "extensions.formautofill.addresses.capture.v2.enabled"; +const ENABLED_AUTOFILL_ADDRESSES_SUPPORTED_COUNTRIES_PREF = + "extensions.formautofill.addresses.supportedCountries"; +const ENABLED_AUTOFILL_CREDITCARDS_PREF = + "extensions.formautofill.creditCards.enabled"; +const ENABLED_AUTOFILL_CREDITCARDS_REAUTH_PREF = + "extensions.formautofill.reauth.enabled"; +const AUTOFILL_CREDITCARDS_HIDE_UI_PREF = + "extensions.formautofill.creditCards.hideui"; +const FORM_AUTOFILL_SUPPORT_RTL_PREF = "extensions.formautofill.supportRTL"; +const AUTOFILL_CREDITCARDS_AUTOCOMPLETE_OFF_PREF = + "extensions.formautofill.creditCards.ignoreAutocompleteOff"; +const AUTOFILL_ADDRESSES_AUTOCOMPLETE_OFF_PREF = + "extensions.formautofill.addresses.ignoreAutocompleteOff"; + +export const FormAutofill = { + ENABLED_AUTOFILL_ADDRESSES_PREF, + ENABLED_AUTOFILL_ADDRESSES_CAPTURE_PREF, + ENABLED_AUTOFILL_ADDRESSES_CAPTURE_V2_PREF, + ENABLED_AUTOFILL_CREDITCARDS_PREF, + ENABLED_AUTOFILL_CREDITCARDS_REAUTH_PREF, + ADDRESSES_FIRST_TIME_USE_PREF, + AUTOFILL_CREDITCARDS_AUTOCOMPLETE_OFF_PREF, + AUTOFILL_ADDRESSES_AUTOCOMPLETE_OFF_PREF, + + get DEFAULT_REGION() { + return Region.home || "US"; + }, + /** + * Determines if an autofill feature should be enabled based on the "available" + * and "supportedCountries" parameters. + * + * @param {string} available Available can be one of the following: "on", "detect", "off". + * "on" forces the particular Form Autofill feature on, while "detect" utilizes the supported countries + * to see if the feature should be available. + * @param {string[]} supportedCountries + * @returns {boolean} `true` if autofill feature is supported in the current browser search region + */ + _isSupportedRegion(available, supportedCountries) { + if (available == "on") { + return true; + } else if (available == "detect") { + if (!FormAutofill.supportRTL && Services.locale.isAppLocaleRTL) { + return false; + } + + return supportedCountries.includes(FormAutofill.browserSearchRegion); + } + return false; + }, + isAutofillAddressesAvailableInCountry(country) { + return FormAutofill._addressAutofillSupportedCountries.includes(country); + }, + get isAutofillEnabled() { + return this.isAutofillAddressesEnabled || this.isAutofillCreditCardsEnabled; + }, + /** + * Determines if the credit card autofill feature is available to use in the browser. + * If the feature is not available, then there are no user facing ways to enable it. + * + * @returns {boolean} `true` if credit card autofill is available + */ + get isAutofillCreditCardsAvailable() { + return this._isSupportedRegion( + FormAutofill._isAutofillCreditCardsAvailable, + FormAutofill._creditCardAutofillSupportedCountries + ); + }, + /** + * Determines if the address autofill feature is available to use in the browser. + * If the feature is not available, then there are no user facing ways to enable it. + * + * @returns {boolean} `true` if address autofill is available + */ + get isAutofillAddressesAvailable() { + return this._isSupportedRegion( + FormAutofill._isAutofillAddressesAvailable, + FormAutofill._addressAutofillSupportedCountries + ); + }, + /** + * Determines if the user has enabled or disabled credit card autofill. + * + * @returns {boolean} `true` if credit card autofill is enabled + */ + get isAutofillCreditCardsEnabled() { + return ( + this.isAutofillCreditCardsAvailable && + FormAutofill._isAutofillCreditCardsEnabled + ); + }, + /** + * Determines if credit card autofill is locked by policy. + * + * @returns {boolean} `true` if credit card autofill is locked + */ + get isAutofillCreditCardsLocked() { + return Services.prefs.prefIsLocked(ENABLED_AUTOFILL_CREDITCARDS_PREF); + }, + /** + * Determines if the user has enabled or disabled address autofill. + * + * @returns {boolean} `true` if address autofill is enabled + */ + get isAutofillAddressesEnabled() { + return ( + this.isAutofillAddressesAvailable && + FormAutofill._isAutofillAddressesEnabled + ); + }, + /** + * Determines if address autofill is locked by policy. + * + * @returns {boolean} `true` if address autofill is locked + */ + get isAutofillAddressesLocked() { + return Services.prefs.prefIsLocked(ENABLED_AUTOFILL_ADDRESSES_PREF); + }, + + defineLogGetter(scope, logPrefix) { + // A logging helper for debug logging to avoid creating Console objects + // or triggering expensive JS -> C++ calls when debug logging is not + // enabled. + // + // Console objects, even natively-implemented ones, can consume a lot of + // memory, and since this code may run in every content process, that + // memory can add up quickly. And, even when debug-level messages are + // being ignored, console.debug() calls can be expensive. + // + // This helper avoids both of those problems by never touching the + // console object unless debug logging is enabled. + scope.debug = function debug() { + if (FormAutofill.logLevel.toLowerCase() == "debug") { + this.log.debug(...arguments); + } + }; + + let { ConsoleAPI } = ChromeUtils.importESModule( + "resource://gre/modules/Console.sys.mjs" + ); + return new ConsoleAPI({ + maxLogLevelPref: "extensions.formautofill.loglevel", + prefix: logPrefix, + }); + }, +}; + +// TODO: Bug 1747284. Use Region.home instead of reading "browser.serach.region" +// by default. However, Region.home doesn't observe preference change at this point, +// we should also fix that issue. +XPCOMUtils.defineLazyPreferenceGetter( + FormAutofill, + "browserSearchRegion", + BROWSER_SEARCH_REGION_PREF, + FormAutofill.DEFAULT_REGION +); +XPCOMUtils.defineLazyPreferenceGetter( + FormAutofill, + "logLevel", + "extensions.formautofill.loglevel", + "Warn" +); + +XPCOMUtils.defineLazyPreferenceGetter( + FormAutofill, + "_isAutofillAddressesAvailable", + AUTOFILL_ADDRESSES_AVAILABLE_PREF +); +XPCOMUtils.defineLazyPreferenceGetter( + FormAutofill, + "_isAutofillAddressesEnabled", + ENABLED_AUTOFILL_ADDRESSES_PREF +); +XPCOMUtils.defineLazyPreferenceGetter( + FormAutofill, + "isAutofillAddressesCaptureEnabled", + ENABLED_AUTOFILL_ADDRESSES_CAPTURE_PREF +); +XPCOMUtils.defineLazyPreferenceGetter( + FormAutofill, + "isAutofillAddressesCaptureV2Enabled", + ENABLED_AUTOFILL_ADDRESSES_CAPTURE_V2_PREF +); +XPCOMUtils.defineLazyPreferenceGetter( + FormAutofill, + "_isAutofillCreditCardsAvailable", + AUTOFILL_CREDITCARDS_AVAILABLE_PREF +); +XPCOMUtils.defineLazyPreferenceGetter( + FormAutofill, + "_isAutofillCreditCardsEnabled", + ENABLED_AUTOFILL_CREDITCARDS_PREF +); +XPCOMUtils.defineLazyPreferenceGetter( + FormAutofill, + "isAutofillCreditCardsHideUI", + AUTOFILL_CREDITCARDS_HIDE_UI_PREF +); +XPCOMUtils.defineLazyPreferenceGetter( + FormAutofill, + "isAutofillAddressesFirstTimeUse", + ADDRESSES_FIRST_TIME_USE_PREF +); +XPCOMUtils.defineLazyPreferenceGetter( + FormAutofill, + "_addressAutofillSupportedCountries", + ENABLED_AUTOFILL_ADDRESSES_SUPPORTED_COUNTRIES_PREF, + null, + val => val.split(",") +); +XPCOMUtils.defineLazyPreferenceGetter( + FormAutofill, + "_creditCardAutofillSupportedCountries", + CREDITCARDS_AUTOFILL_SUPPORTED_COUNTRIES_PREF, + null, + null, + val => val.split(",") +); +XPCOMUtils.defineLazyPreferenceGetter( + FormAutofill, + "supportRTL", + FORM_AUTOFILL_SUPPORT_RTL_PREF +); +XPCOMUtils.defineLazyPreferenceGetter( + FormAutofill, + "creditCardsAutocompleteOff", + AUTOFILL_CREDITCARDS_AUTOCOMPLETE_OFF_PREF +); +XPCOMUtils.defineLazyPreferenceGetter( + FormAutofill, + "addressesAutocompleteOff", + AUTOFILL_ADDRESSES_AUTOCOMPLETE_OFF_PREF +); + +// XXX: This should be invalidated on intl:app-locales-changed. +XPCOMUtils.defineLazyGetter(FormAutofill, "countries", () => { + let availableRegionCodes = + Services.intl.getAvailableLocaleDisplayNames("region"); + let displayNames = Services.intl.getRegionDisplayNames( + undefined, + availableRegionCodes + ); + let result = new Map(); + for (let i = 0; i < availableRegionCodes.length; i++) { + result.set(availableRegionCodes[i].toUpperCase(), displayNames[i]); + } + return result; +}); diff --git a/toolkit/components/formautofill/FormAutofillChild.ios.sys.mjs b/toolkit/components/formautofill/FormAutofillChild.ios.sys.mjs new file mode 100644 index 0000000000..9c2be17778 --- /dev/null +++ b/toolkit/components/formautofill/FormAutofillChild.ios.sys.mjs @@ -0,0 +1,65 @@ +/* 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/. */ + +/* eslint-disable no-undef,mozilla/balanced-listeners */ +import { FormAutofillUtils } from "resource://gre/modules/shared/FormAutofillUtils.sys.mjs"; +import { FormStateManager } from "resource://gre/modules/shared/FormStateManager.sys.mjs"; + +export class FormAutofillChild { + constructor(onSubmitCallback, onAutofillCallback) { + this.onFocusIn = this.onFocusIn.bind(this); + this.onSubmit = this.onSubmit.bind(this); + + this.onSubmitCallback = onSubmitCallback; + this.onAutofillCallback = onAutofillCallback; + + this.fieldDetailsManager = new FormStateManager(); + + document.addEventListener("focusin", this.onFocusIn); + document.addEventListener("submit", this.onSubmit); + } + + _doIdentifyAutofillFields(element) { + this.fieldDetailsManager.updateActiveInput(element); + const validDetails = + this.fieldDetailsManager.identifyAutofillFields(element); + + // Only ping swift if current field is a cc field + if (validDetails?.find(field => field.elementWeakRef.get() === element)) { + const fieldNamesWithValues = validDetails?.reduce( + (acc, field) => ({ + ...acc, + [field.fieldName]: field.elementWeakRef.get().value, + }), + {} + ); + this.onAutofillCallback(fieldNamesWithValues); + } + } + + onFocusIn(evt) { + const element = evt.target; + this.fieldDetailsManager.updateActiveInput(element); + if (!FormAutofillUtils.isCreditCardOrAddressFieldType(element)) { + return; + } + this._doIdentifyAutofillFields(element); + } + + onSubmit(evt) { + this.fieldDetailsManager.activeHandler.onFormSubmitted(); + const records = this.fieldDetailsManager.activeHandler.createRecords(); + if (records.creditCard) { + this.onSubmitCallback(records.creditCard.map(entry => entry.record)); + } + } + + fillFormFields(payload) { + this.fieldDetailsManager.activeHandler.autofillFormFields( + JSON.parse(payload) + ); + } +} + +export default FormAutofillChild; diff --git a/toolkit/components/formautofill/FormAutofillChild.sys.mjs b/toolkit/components/formautofill/FormAutofillChild.sys.mjs new file mode 100644 index 0000000000..acefdcdc87 --- /dev/null +++ b/toolkit/components/formautofill/FormAutofillChild.sys.mjs @@ -0,0 +1,195 @@ +/* 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/. */ + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + AutoCompleteChild: "resource://gre/actors/AutoCompleteChild.sys.mjs", + FormAutofill: "resource://autofill/FormAutofill.sys.mjs", + FormAutofillContent: "resource://autofill/FormAutofillContent.sys.mjs", + FormAutofillUtils: "resource://gre/modules/shared/FormAutofillUtils.sys.mjs", + setTimeout: "resource://gre/modules/Timer.sys.mjs", +}); + +/** + * Handles content's interactions for the frame. + */ +export class FormAutofillChild extends JSWindowActorChild { + constructor() { + super(); + + this._nextHandleElement = null; + this._alreadyDOMContentLoaded = false; + this._hasDOMContentLoadedHandler = false; + this._hasPendingTask = false; + this.testListener = null; + + lazy.AutoCompleteChild.addPopupStateListener(this); + } + + didDestroy() { + lazy.AutoCompleteChild.removePopupStateListener(this); + } + + popupStateChanged(messageName, data, target) { + let docShell; + try { + docShell = this.docShell; + } catch (ex) { + lazy.AutoCompleteChild.removePopupStateListener(this); + return; + } + + if (!lazy.FormAutofill.isAutofillEnabled) { + return; + } + + const { chromeEventHandler } = docShell; + + switch (messageName) { + case "FormAutoComplete:PopupClosed": { + lazy.FormAutofillContent.onPopupClosed(data.selectedRowStyle); + Services.tm.dispatchToMainThread(() => { + chromeEventHandler.removeEventListener( + "keydown", + lazy.FormAutofillContent._onKeyDown, + true + ); + }); + + break; + } + case "FormAutoComplete:PopupOpened": { + lazy.FormAutofillContent.onPopupOpened(); + chromeEventHandler.addEventListener( + "keydown", + lazy.FormAutofillContent._onKeyDown, + true + ); + break; + } + } + } + + _doIdentifyAutofillFields() { + if (this._hasPendingTask) { + return; + } + this._hasPendingTask = true; + + lazy.setTimeout(() => { + lazy.FormAutofillContent.identifyAutofillFields(this._nextHandleElement); + this._hasPendingTask = false; + this._nextHandleElement = null; + // This is for testing purpose only which sends a notification to indicate that the + // form has been identified, and ready to open popup. + this.sendAsyncMessage("FormAutofill:FieldsIdentified"); + lazy.FormAutofillContent.updateActiveInput(); + }); + } + + shouldIgnoreFormAutofillEvent(event) { + let nodePrincipal = event.target.nodePrincipal; + return ( + nodePrincipal.isSystemPrincipal || + nodePrincipal.isNullPrincipal || + nodePrincipal.schemeIs("about") + ); + } + + handleEvent(evt) { + if (!evt.isTrusted) { + return; + } + + if (this.shouldIgnoreFormAutofillEvent(evt)) { + return; + } + + switch (evt.type) { + case "focusin": { + if (lazy.FormAutofill.isAutofillEnabled) { + this.onFocusIn(evt); + } + break; + } + case "DOMFormBeforeSubmit": { + if (lazy.FormAutofill.isAutofillEnabled) { + this.onDOMFormBeforeSubmit(evt); + } + break; + } + + default: { + throw new Error("Unexpected event type"); + } + } + } + + onFocusIn(evt) { + lazy.FormAutofillContent.updateActiveInput(); + + let element = evt.target; + if (!lazy.FormAutofillUtils.isCreditCardOrAddressFieldType(element)) { + return; + } + this._nextHandleElement = element; + + if (!this._alreadyDOMContentLoaded) { + let doc = element.ownerDocument; + if (doc.readyState === "loading") { + if (!this._hasDOMContentLoadedHandler) { + this._hasDOMContentLoadedHandler = true; + doc.addEventListener( + "DOMContentLoaded", + () => this._doIdentifyAutofillFields(), + { once: true } + ); + } + return; + } + this._alreadyDOMContentLoaded = true; + } + + this._doIdentifyAutofillFields(); + } + + /** + * Handle the DOMFormBeforeSubmit event. + * + * @param {Event} evt + */ + onDOMFormBeforeSubmit(evt) { + let formElement = evt.target; + + if (!lazy.FormAutofill.isAutofillEnabled) { + return; + } + + lazy.FormAutofillContent.formSubmitted(formElement); + } + + receiveMessage(message) { + if (!lazy.FormAutofill.isAutofillEnabled) { + return; + } + + const doc = this.document; + + switch (message.name) { + case "FormAutofill:PreviewProfile": { + lazy.FormAutofillContent.previewProfile(doc); + break; + } + case "FormAutofill:ClearForm": { + lazy.FormAutofillContent.clearForm(); + break; + } + case "FormAutofill:FillForm": { + lazy.FormAutofillContent.activeHandler.autofillFormFields(message.data); + break; + } + } + } +} diff --git a/toolkit/components/formautofill/FormAutofillContent.sys.mjs b/toolkit/components/formautofill/FormAutofillContent.sys.mjs new file mode 100644 index 0000000000..a7a1031b34 --- /dev/null +++ b/toolkit/components/formautofill/FormAutofillContent.sys.mjs @@ -0,0 +1,418 @@ +/* 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/. */ + +/** + * Form Autofill content process module. + */ + +/* eslint-disable no-use-before-define */ + +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; +import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + FormAutofill: "resource://autofill/FormAutofill.sys.mjs", + FormAutofillUtils: "resource://gre/modules/shared/FormAutofillUtils.sys.mjs", + PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs", + FormStateManager: "resource://gre/modules/shared/FormStateManager.sys.mjs", + ProfileAutocomplete: + "resource://autofill/AutofillProfileAutoComplete.sys.mjs", + AutofillTelemetry: "resource://autofill/AutofillTelemetry.sys.mjs", +}); + +XPCOMUtils.defineLazyPreferenceGetter( + lazy, + "DELEGATE_AUTOCOMPLETE", + "toolkit.autocomplete.delegate", + false +); + +const formFillController = Cc[ + "@mozilla.org/satchel/form-fill-controller;1" +].getService(Ci.nsIFormFillController); + +function getActorFromWindow(contentWindow, name = "FormAutofill") { + // In unit tests, contentWindow isn't a real window. + if (!contentWindow) { + return null; + } + + return contentWindow.windowGlobalChild + ? contentWindow.windowGlobalChild.getActor(name) + : null; +} + +/** + * Handles content's interactions for the process. + * + */ +export var FormAutofillContent = { + /** + * @type {Set} Set of the fields with usable values in any saved profile. + */ + get savedFieldNames() { + return Services.cpmm.sharedData.get("FormAutofill:savedFieldNames"); + }, + + /** + * @type {boolean} Flag indicating whether a focus action requiring + * the popup to be active is pending. + */ + _popupPending: false, + + /** + * @type {boolean} Flag indicating whether the form is waiting to be + * filled by Autofill. + */ + _autofillPending: false, + + init() { + this.log = lazy.FormAutofill.defineLogGetter(this, "FormAutofillContent"); + this.debug("init"); + + // eslint-disable-next-line mozilla/balanced-listeners + Services.cpmm.sharedData.addEventListener("change", this); + + let autofillEnabled = Services.cpmm.sharedData.get("FormAutofill:enabled"); + // If storage hasn't be initialized yet autofillEnabled is undefined but we need to ensure + // autocomplete is registered before the focusin so register it in this case as long as the + // pref is true. + let shouldEnableAutofill = + autofillEnabled === undefined && + (lazy.FormAutofill.isAutofillAddressesEnabled || + lazy.FormAutofill.isAutofillCreditCardsEnabled); + if (autofillEnabled || shouldEnableAutofill) { + lazy.ProfileAutocomplete.ensureRegistered(); + } + + /** + * @type {FormAutofillFieldDetailsManager} handling state management of current forms and handlers. + */ + this._fieldDetailsManager = new lazy.FormStateManager( + this.formSubmitted.bind(this), + this._showPopup.bind(this) + ); + }, + + get activeFieldDetail() { + return this._fieldDetailsManager.activeFieldDetail; + }, + + get activeFormDetails() { + return this._fieldDetailsManager.activeFormDetails; + }, + + get activeInput() { + return this._fieldDetailsManager.activeInput; + }, + + get activeHandler() { + return this._fieldDetailsManager.activeHandler; + }, + + get activeSection() { + return this._fieldDetailsManager.activeSection; + }, + + /** + * Send the profile to parent for doorhanger and storage saving/updating. + * + * @param {object} profile Submitted form's address/creditcard guid and record. + * @param {object} domWin Current content window. + */ + _onFormSubmit(profile, domWin) { + let actor = getActorFromWindow(domWin); + actor.sendAsyncMessage("FormAutofill:OnFormSubmit", profile); + }, + + /** + * Handle a form submission and early return when: + * 1. In private browsing mode. + * 2. Could not map any autofill handler by form element. + * 3. Number of filled fields is less than autofill threshold + * + * @param {HTMLElement} formElement Root element which receives submit event. + * @param {Window} domWin Content window; passed for unit tests and when + * invoked by the FormAutofillSection + * @param {object} handler FormAutofillHander, if known by caller + */ + formSubmitted( + formElement, + domWin = formElement.ownerGlobal, + handler = undefined + ) { + this.debug("Handling form submission"); + + if (!lazy.FormAutofill.isAutofillEnabled) { + this.debug("Form Autofill is disabled"); + return; + } + + // The `domWin` truthiness test is used by unit tests to bypass this check. + if (domWin && lazy.PrivateBrowsingUtils.isContentWindowPrivate(domWin)) { + this.debug("Ignoring submission in a private window"); + return; + } + + handler = handler || this._fieldDetailsManager._getFormHandler(formElement); + const records = this._fieldDetailsManager.getRecords(formElement, handler); + + if (!records || !handler) { + this.debug("Form element could not map to an existing handler"); + return; + } + + [records.address, records.creditCard].forEach((rs, idx) => { + lazy.AutofillTelemetry.recordSubmittedSectionCount( + idx == 0 + ? lazy.AutofillTelemetry.ADDRESS + : lazy.AutofillTelemetry.CREDIT_CARD, + rs?.length + ); + + rs?.forEach(r => { + lazy.AutofillTelemetry.recordFormInteractionEvent( + "submitted", + r.section, + { + record: r, + form: handler.form, + } + ); + delete r.section; + }); + }); + + this._onFormSubmit(records, domWin); + }, + + _showPopup() { + formFillController.showPopup(); + }, + + handleEvent(evt) { + switch (evt.type) { + case "change": { + if (!evt.changedKeys.includes("FormAutofill:enabled")) { + return; + } + if (Services.cpmm.sharedData.get("FormAutofill:enabled")) { + lazy.ProfileAutocomplete.ensureRegistered(); + if (this._popupPending) { + this._popupPending = false; + this.debug("handleEvent: Opening deferred popup"); + this._showPopup(); + } + } else { + lazy.ProfileAutocomplete.ensureUnregistered(); + } + break; + } + } + }, + + /** + * All active items should be updated according the active element of + * `formFillController.focusedInput`. All of them including element, + * handler, section, and field detail, can be retrieved by their own getters. + * + * @param {HTMLElement|null} element The active item should be updated based + * on this or `formFillController.focusedInput` will be taken. + */ + updateActiveInput(element) { + element = element || formFillController.focusedInput; + if (!element) { + this.debug("updateActiveElement: no element selected"); + return; + } + this._fieldDetailsManager.updateActiveInput(element); + this.debug("updateActiveElement: checking for popup-on-focus"); + // We know this element just received focus. If it's a credit card field, + // open its popup. + if (this._autofillPending) { + this.debug("updateActiveElement: skipping check; autofill is imminent"); + } else if (element.value?.length !== 0) { + this.debug( + `updateActiveElement: Not opening popup because field is not empty.` + ); + } else { + this.debug( + "updateActiveElement: checking if empty field is cc-*: ", + this.activeFieldDetail?.fieldName + ); + + if ( + this.activeFieldDetail?.fieldName?.startsWith("cc-") || + AppConstants.platform === "android" + ) { + if (Services.cpmm.sharedData.get("FormAutofill:enabled")) { + this.debug("updateActiveElement: opening pop up"); + this._showPopup(); + } else { + this.debug( + "updateActiveElement: Deferring pop-up until Autofill is ready" + ); + this._popupPending = true; + } + } + } + }, + + set autofillPending(flag) { + this.debug("Setting autofillPending to", flag); + this._autofillPending = flag; + }, + + identifyAutofillFields(element) { + this.debug( + `identifyAutofillFields: ${element.ownerDocument.location?.hostname}` + ); + + if (lazy.DELEGATE_AUTOCOMPLETE || !this.savedFieldNames) { + this.debug("identifyAutofillFields: savedFieldNames are not known yet"); + let actor = getActorFromWindow(element.ownerGlobal); + if (actor) { + actor.sendAsyncMessage("FormAutofill:InitStorage"); + } + } + + const validDetails = + this._fieldDetailsManager.identifyAutofillFields(element); + + validDetails?.forEach(detail => + this._markAsAutofillField(detail.elementWeakRef.get()) + ); + }, + + clearForm() { + let focusedInput = + this.activeInput || + lazy.ProfileAutocomplete._lastAutoCompleteFocusedInput; + if (!focusedInput) { + return; + } + + this.activeSection.clearPopulatedForm(); + + let fieldName = FormAutofillContent.activeFieldDetail?.fieldName; + if (lazy.FormAutofillUtils.isCreditCardField(fieldName)) { + lazy.AutofillTelemetry.recordFormInteractionEvent( + "cleared", + this.activeSection, + { fieldName } + ); + } + }, + + previewProfile(doc) { + let docWin = doc.ownerGlobal; + let selectedIndex = lazy.ProfileAutocomplete._getSelectedIndex(docWin); + let lastAutoCompleteResult = + lazy.ProfileAutocomplete.lastProfileAutoCompleteResult; + let focusedInput = this.activeInput; + let actor = getActorFromWindow(docWin); + + if ( + selectedIndex === -1 || + !focusedInput || + !lastAutoCompleteResult || + lastAutoCompleteResult.getStyleAt(selectedIndex) != "autofill-profile" + ) { + actor.sendAsyncMessage("FormAutofill:UpdateWarningMessage", {}); + + lazy.ProfileAutocomplete._clearProfilePreview(); + } else { + let focusedInputDetails = this.activeFieldDetail; + let profile = JSON.parse( + lastAutoCompleteResult.getCommentAt(selectedIndex) + ); + let allFieldNames = FormAutofillContent.activeSection.allFieldNames; + let profileFields = allFieldNames.filter( + fieldName => !!profile[fieldName] + ); + + let focusedCategory = lazy.FormAutofillUtils.getCategoryFromFieldName( + focusedInputDetails.fieldName + ); + let categories = + lazy.FormAutofillUtils.getCategoriesFromFieldNames(profileFields); + actor.sendAsyncMessage("FormAutofill:UpdateWarningMessage", { + focusedCategory, + categories, + }); + + lazy.ProfileAutocomplete._previewSelectedProfile(selectedIndex); + } + }, + + onPopupClosed(selectedRowStyle) { + this.debug("Popup has closed."); + lazy.ProfileAutocomplete._clearProfilePreview(); + + let lastAutoCompleteResult = + lazy.ProfileAutocomplete.lastProfileAutoCompleteResult; + let focusedInput = FormAutofillContent.activeInput; + if ( + lastAutoCompleteResult && + FormAutofillContent._keyDownEnterForInput && + focusedInput === FormAutofillContent._keyDownEnterForInput && + focusedInput === + lazy.ProfileAutocomplete.lastProfileAutoCompleteFocusedInput + ) { + if (selectedRowStyle == "autofill-footer") { + let actor = getActorFromWindow(focusedInput.ownerGlobal); + actor.sendAsyncMessage("FormAutofill:OpenPreferences"); + } else if (selectedRowStyle == "autofill-clear-button") { + FormAutofillContent.clearForm(); + } + } + }, + + onPopupOpened() { + this.debug( + "Popup has opened, automatic =", + formFillController.passwordPopupAutomaticallyOpened + ); + + let fieldName = FormAutofillContent.activeFieldDetail?.fieldName; + if (fieldName && this.activeSection) { + lazy.AutofillTelemetry.recordFormInteractionEvent( + "popup_shown", + this.activeSection, + { fieldName } + ); + } + }, + + _markAsAutofillField(field) { + // Since Form Autofill popup is only for input element, any non-Input + // element should be excluded here. + if (!HTMLInputElement.isInstance(field)) { + return; + } + + formFillController.markAsAutofillField(field); + }, + + _onKeyDown(e) { + delete FormAutofillContent._keyDownEnterForInput; + let lastAutoCompleteResult = + lazy.ProfileAutocomplete.lastProfileAutoCompleteResult; + let focusedInput = FormAutofillContent.activeInput; + if ( + e.keyCode != e.DOM_VK_RETURN || + !lastAutoCompleteResult || + !focusedInput || + focusedInput != + lazy.ProfileAutocomplete.lastProfileAutoCompleteFocusedInput + ) { + return; + } + FormAutofillContent._keyDownEnterForInput = focusedInput; + }, +}; + +FormAutofillContent.init(); diff --git a/toolkit/components/formautofill/FormAutofillNative.cpp b/toolkit/components/formautofill/FormAutofillNative.cpp new file mode 100644 index 0000000000..f86dc695b7 --- /dev/null +++ b/toolkit/components/formautofill/FormAutofillNative.cpp @@ -0,0 +1,1483 @@ +/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2; -*- */ +/* 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/. */ + +#include "FormAutofillNative.h" + +#include <math.h> + +#include "mozilla/ClearOnShutdown.h" +#include "mozilla/ComputedStyle.h" +#include "mozilla/dom/AutocompleteInfoBinding.h" +#include "mozilla/dom/Document.h" +#include "mozilla/dom/Element.h" +#include "mozilla/dom/HTMLInputElement.h" +#include "mozilla/dom/HTMLLabelElement.h" +#include "mozilla/dom/HTMLOptionElement.h" +#include "mozilla/dom/HTMLSelectElement.h" +#include "mozilla/HashTable.h" +#include "mozilla/RustRegex.h" +#include "nsContentUtils.h" +#include "nsIFrame.h" +#include "nsIFrameInlines.h" +#include "nsLayoutUtils.h" +#include "nsTStringHasher.h" +#include "mozilla/StaticPtr.h" + +namespace mozilla::dom { + +static const char kWhitespace[] = "\b\t\r\n "; + +enum class RegexKey : uint8_t { + CC_NAME, + CC_NUMBER, + CC_EXP, + CC_EXP_MONTH, + CC_EXP_YEAR, + CC_TYPE, + MM_MONTH, + YY_OR_YYYY, + MONTH, + YEAR, + MMYY, + VISA_CHECKOUT, + CREDIT_CARD_NETWORK, + CREDIT_CARD_NETWORK_EXACT_MATCH, + CREDIT_CARD_NETWORK_LONG, + TWO_OR_FOUR_DIGIT_YEAR, + DWFRM, + BML, + TEMPLATED_VALUE, + FIRST, + LAST, + GIFT, + SUBSCRIPTION, + VALIDATION, + + Count +}; + +// We don't follow the coding style (naming start with capital letter) here and +// the following CCXXX enum class because we want to sync the rule naming with +// the JS implementation. +enum class CCNumberParams : uint8_t { + idOrNameMatchNumberRegExp, + labelsMatchNumberRegExp, + closestLabelMatchesNumberRegExp, + placeholderMatchesNumberRegExp, + ariaLabelMatchesNumberRegExp, + idOrNameMatchGift, + labelsMatchGift, + placeholderMatchesGift, + ariaLabelMatchesGift, + idOrNameMatchSubscription, + idOrNameMatchDwfrmAndBml, + hasTemplatedValue, + inputTypeNotNumbery, + + Count, +}; + +enum class CCNameParams : uint8_t { + idOrNameMatchNameRegExp, + labelsMatchNameRegExp, + closestLabelMatchesNameRegExp, + placeholderMatchesNameRegExp, + ariaLabelMatchesNameRegExp, + idOrNameMatchFirst, + labelsMatchFirst, + placeholderMatchesFirst, + ariaLabelMatchesFirst, + idOrNameMatchLast, + labelsMatchLast, + placeholderMatchesLast, + ariaLabelMatchesLast, + idOrNameMatchFirstAndLast, + idOrNameMatchSubscription, + idOrNameMatchDwfrmAndBml, + hasTemplatedValue, + + Count, +}; + +enum class CCTypeParams : uint8_t { + idOrNameMatchTypeRegExp, + labelsMatchTypeRegExp, + closestLabelMatchesTypeRegExp, + idOrNameMatchVisaCheckout, + ariaLabelMatchesVisaCheckout, + isSelectWithCreditCardOptions, + isRadioWithCreditCardText, + idOrNameMatchSubscription, + idOrNameMatchDwfrmAndBml, + hasTemplatedValue, + + Count, +}; + +enum class CCExpParams : uint8_t { + labelsMatchExpRegExp, + closestLabelMatchesExpRegExp, + placeholderMatchesExpRegExp, + labelsMatchExpWith2Or4DigitYear, + placeholderMatchesExpWith2Or4DigitYear, + labelsMatchMMYY, + placeholderMatchesMMYY, + maxLengthIs7, + idOrNameMatchSubscription, + idOrNameMatchDwfrmAndBml, + hasTemplatedValue, + isExpirationMonthLikely, + isExpirationYearLikely, + idOrNameMatchMonth, + idOrNameMatchYear, + idOrNameMatchExpMonthRegExp, + idOrNameMatchExpYearRegExp, + idOrNameMatchValidation, + Count, +}; + +enum class CCExpMonthParams : uint8_t { + idOrNameMatchExpMonthRegExp, + labelsMatchExpMonthRegExp, + closestLabelMatchesExpMonthRegExp, + placeholderMatchesExpMonthRegExp, + ariaLabelMatchesExpMonthRegExp, + idOrNameMatchMonth, + labelsMatchMonth, + placeholderMatchesMonth, + ariaLabelMatchesMonth, + nextFieldIdOrNameMatchExpYearRegExp, + nextFieldLabelsMatchExpYearRegExp, + nextFieldPlaceholderMatchExpYearRegExp, + nextFieldAriaLabelMatchExpYearRegExp, + nextFieldIdOrNameMatchYear, + nextFieldLabelsMatchYear, + nextFieldPlaceholderMatchesYear, + nextFieldAriaLabelMatchesYear, + nextFieldMatchesExpYearAutocomplete, + isExpirationMonthLikely, + nextFieldIsExpirationYearLikely, + maxLengthIs2, + placeholderMatchesMM, + roleIsMenu, + idOrNameMatchSubscription, + idOrNameMatchDwfrmAndBml, + hasTemplatedValue, + + Count, +}; + +enum class CCExpYearParams : uint8_t { + idOrNameMatchExpYearRegExp, + labelsMatchExpYearRegExp, + closestLabelMatchesExpYearRegExp, + placeholderMatchesExpYearRegExp, + ariaLabelMatchesExpYearRegExp, + idOrNameMatchYear, + labelsMatchYear, + placeholderMatchesYear, + ariaLabelMatchesYear, + previousFieldIdOrNameMatchExpMonthRegExp, + previousFieldLabelsMatchExpMonthRegExp, + previousFieldPlaceholderMatchExpMonthRegExp, + previousFieldAriaLabelMatchExpMonthRegExp, + previousFieldIdOrNameMatchMonth, + previousFieldLabelsMatchMonth, + previousFieldPlaceholderMatchesMonth, + previousFieldAriaLabelMatchesMonth, + previousFieldMatchesExpMonthAutocomplete, + isExpirationYearLikely, + previousFieldIsExpirationMonthLikely, + placeholderMatchesYYOrYYYY, + roleIsMenu, + idOrNameMatchSubscription, + idOrNameMatchDwfrmAndBml, + hasTemplatedValue, + + Count, +}; + +struct AutofillParams { + EnumeratedArray<CCNumberParams, CCNumberParams::Count, double> + mCCNumberParams; + EnumeratedArray<CCNameParams, CCNameParams::Count, double> mCCNameParams; + EnumeratedArray<CCTypeParams, CCTypeParams::Count, double> mCCTypeParams; + EnumeratedArray<CCExpParams, CCExpParams::Count, double> mCCExpParams; + EnumeratedArray<CCExpMonthParams, CCExpMonthParams::Count, double> + mCCExpMonthParams; + EnumeratedArray<CCExpYearParams, CCExpYearParams::Count, double> + mCCExpYearParams; +}; + +// clang-format off +constexpr AutofillParams kCoefficents{ + .mCCNumberParams = { + /* 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 + }, + .mCCNameParams = { + /* 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 + }, + .mCCTypeParams = { + /* 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 + }, + .mCCExpParams = { + /* 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 + }, + .mCCExpMonthParams = { + /* 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 + }, + .mCCExpYearParams = { + /* 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 + } +}; +// clang-format off + +constexpr float kCCNumberBias = -4.948795795440674; +constexpr float kCCNameBias = -5.3578081130981445; +// Comment out code that are not used right now +/* +constexpr float kCCTypeBias = -5.979659557342529; +constexpr float kCCExpBias = -5.849575996398926; +constexpr float kCCExpMonthBias = -8.844199180603027; +constexpr float kCCExpYearBias = -6.499860763549805; +*/ + +struct Rule { + RegexKey key; + const char* pattern; +}; + +const Rule kFirefoxRules[] = { + {RegexKey::MM_MONTH, "^mm$|\\(mm\\)"}, + {RegexKey::YY_OR_YYYY, "^(yy|yyyy)$|\\(yy\\)|\\(yyyy\\)"}, + {RegexKey::MONTH, "month"}, + {RegexKey::YEAR, "year"}, + {RegexKey::MMYY, "mm\\s*(/|\\\\)\\s*yy"}, + {RegexKey::VISA_CHECKOUT, "visa(-|\\s)checkout"}, + // This should be a union of NETWORK_NAMES in CreditCard.sys.mjs + {RegexKey::CREDIT_CARD_NETWORK_LONG, + "american express|master card|union pay"}, + // Please also update CREDIT_CARD_NETWORK_EXACT_MATCH while updating + // CREDIT_CARD_NETWORK + {RegexKey::CREDIT_CARD_NETWORK, + "amex|cartebancaire|diners|discover|jcb|mastercard|mir|unionpay|visa"}, + {RegexKey::CREDIT_CARD_NETWORK_EXACT_MATCH, + "^\\s*(?:amex|cartebancaire|diners|discover|jcb|mastercard|mir|unionpay|" + "visa)\\s*$"}, + {RegexKey::TWO_OR_FOUR_DIGIT_YEAR, + "(?:exp.*date[^y\\\\n\\\\r]*|mm\\\\s*[-/]?\\\\s*)yy(?:yy)?(?:[^y]|$)"}, + {RegexKey::DWFRM, "^dwfrm"}, + {RegexKey::BML, "BML"}, + {RegexKey::TEMPLATED_VALUE, "^\\{\\{.*\\}\\}$"}, + {RegexKey::FIRST, "first"}, + {RegexKey::LAST, "last"}, + {RegexKey::GIFT, "gift"}, + {RegexKey::SUBSCRIPTION, "subscription"}, + {RegexKey::VALIDATION, "validate|validation"}, +}; + +// These are the rules used by Bitwarden [0], converted into RegExp form. +// [0] +// https://github.com/bitwarden/browser/blob/c2b8802201fac5e292d55d5caf3f1f78088d823c/src/services/autofill.service.ts#L436 +const Rule kCreditCardRules[] = { + /* eslint-disable */ + // Let us keep our consistent wrapping. + {RegexKey::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 + /* eslint-enable */ + + {RegexKey::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 + + {RegexKey::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 + + {RegexKey::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 + + {RegexKey::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 + + {RegexKey::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 +}; + +static double Sigmoid(double x) { return 1.0 / (1.0 + exp(-x)); } + +class FormAutofillImpl { + public: + FormAutofillImpl(); + + void GetFormAutofillConfidences( + GlobalObject& aGlobal, const Sequence<OwningNonNull<Element>>& aElements, + nsTArray<FormAutofillConfidences>& aResults, ErrorResult& aRv); + + private: + const RustRegex& GetRegex(RegexKey key); + + bool StringMatchesRegExp(const nsACString& str, RegexKey key); + bool StringMatchesRegExp(const nsAString& str, RegexKey key); + bool TextContentMatchesRegExp(Element& element, RegexKey key); + size_t CountRegExpMatches(const nsACString& str, RegexKey key); + size_t CountRegExpMatches(const nsAString& str, RegexKey key); + bool IdOrNameMatchRegExp(Element& element, RegexKey key); + bool NextFieldMatchesExpYearAutocomplete(Element* aNextField); + bool PreviousFieldMatchesExpMonthAutocomplete(Element* aPrevField); + bool LabelMatchesRegExp(Element& element, const nsTArray<nsCString>* labels, + RegexKey key); + bool ClosestLabelMatchesRegExp(Element& aElement, RegexKey aKey); + bool PlaceholderMatchesRegExp(Element& element, RegexKey key); + bool AriaLabelMatchesRegExp(Element& element, RegexKey key); + bool AutocompleteStringMatches(Element& aElement, const nsAString& aKey); + + bool HasTemplatedValue(Element& element); + bool MaxLengthIs(Element& aElement, int32_t aValue); + bool IsExpirationMonthLikely(Element& element); + bool IsExpirationYearLikely(Element& element); + bool InputTypeNotNumbery(Element& element); + bool IsSelectWithCreditCardOptions(Element& element); + bool IsRadioWithCreditCardText(Element& element, + const nsTArray<nsCString>* labels, + ErrorResult& aRv); + bool MatchesExpYearAutocomplete(Element& element); + bool RoleIsMenu(Element& element); + + Element* FindRootForField(Element* aElement); + + Element* FindField(const Sequence<OwningNonNull<Element>>& aElements, + uint32_t aStartIndex, int8_t aDirection); + Element* NextField(const Sequence<OwningNonNull<Element>>& aElements, + uint32_t aStartIndex); + Element* PrevField(const Sequence<OwningNonNull<Element>>& aElements, + uint32_t aStartIndex); + + // Array contains regular expressions to match the corresponding + // field. Ex, CC number, CC type, etc. + using RegexStringArray = + EnumeratedArray<RegexKey, RegexKey::Count, nsCString>; + RegexStringArray mRuleMap; + + // Array that holds RegexWrapper that created by regex::ffi::regex_new + using RegexWrapperArray = + EnumeratedArray<RegexKey, RegexKey::Count, + RustRegex>; + RegexWrapperArray mRegexes; +}; + +FormAutofillImpl::FormAutofillImpl() { + const Rule* rulesets[] = {&kFirefoxRules[0], &kCreditCardRules[0]}; + size_t rulesetLengths[] = {ArrayLength(kFirefoxRules), + ArrayLength(kCreditCardRules)}; + + for (uint32_t i = 0; i < ArrayLength(rulesetLengths); ++i) { + for (uint32_t j = 0; j < rulesetLengths[i]; ++j) { + nsCString& rule = mRuleMap[rulesets[i][j].key]; + if (!rule.IsEmpty()) { + rule.Append("|"); + } + rule.Append(rulesets[i][j].pattern); + } + } +} + +const RustRegex& FormAutofillImpl::GetRegex(RegexKey aKey) { + if (!mRegexes[aKey]) { + RustRegex regex(mRuleMap[aKey], RustRegexOptions().CaseInsensitive(true)); + MOZ_DIAGNOSTIC_ASSERT(regex); + mRegexes[aKey] = std::move(regex); + } + return mRegexes[aKey]; +} + +bool FormAutofillImpl::StringMatchesRegExp(const nsACString& aStr, + RegexKey aKey) { + return GetRegex(aKey).IsMatch(aStr); +} + +bool FormAutofillImpl::StringMatchesRegExp(const nsAString& aStr, + RegexKey aKey) { + return StringMatchesRegExp(NS_ConvertUTF16toUTF8(aStr), aKey); +} + +bool FormAutofillImpl::TextContentMatchesRegExp(Element& element, + RegexKey key) { + ErrorResult rv; + nsAutoString text; + element.GetTextContent(text, rv); + if (rv.Failed()) { + return false; + } + + return StringMatchesRegExp(text, key); +} + +size_t FormAutofillImpl::CountRegExpMatches(const nsACString& aStr, + RegexKey aKey) { + return GetRegex(aKey).CountMatches(aStr); +} + +size_t FormAutofillImpl::CountRegExpMatches(const nsAString& aStr, + RegexKey aKey) { + return CountRegExpMatches(NS_ConvertUTF16toUTF8(aStr), aKey); +} + +bool FormAutofillImpl::NextFieldMatchesExpYearAutocomplete( + Element* aNextField) { + return AutocompleteStringMatches(*aNextField, u"cc-exp-year"_ns); +} + +bool FormAutofillImpl::PreviousFieldMatchesExpMonthAutocomplete( + Element* aPrevField) { + return AutocompleteStringMatches(*aPrevField, u"cc-exp-month"_ns); +} + +bool FormAutofillImpl::IdOrNameMatchRegExp(Element& aElement, RegexKey key) { + nsAutoString str; + aElement.GetId(str); + if (StringMatchesRegExp(str, key)) { + return true; + } + aElement.GetAttr(nsGkAtoms::name, str); + return StringMatchesRegExp(str, key); +} + +bool FormAutofillImpl::LabelMatchesRegExp( + Element& aElement, const nsTArray<nsCString>* labelStrings, RegexKey key) { + if (!labelStrings) { + return false; + } + + for (const auto& str : *labelStrings) { + if (StringMatchesRegExp(str, key)) { + return true; + } + } + + Element* parent = aElement.GetParentElement(); + if (!parent) { + return false; + } + + ErrorResult aRv; + if (parent->IsHTMLElement(nsGkAtoms::td)) { + Element* pp = parent->GetParentElement(); + if (pp) { + return TextContentMatchesRegExp(*pp, key); + } + } + if (parent->IsHTMLElement(nsGkAtoms::td)) { + Element* pes = aElement.GetPreviousElementSibling(); + if (pes) { + return TextContentMatchesRegExp(*pes, key); + } + } + return false; +} + +bool FormAutofillImpl::ClosestLabelMatchesRegExp(Element& aElement, + RegexKey aKey) { + ErrorResult aRv; + Element* pes = aElement.GetPreviousElementSibling(); + if (pes && pes->IsHTMLElement(nsGkAtoms::label)) { + return TextContentMatchesRegExp(*pes, aKey); + } + + Element* nes = aElement.GetNextElementSibling(); + if (nes && nes->IsHTMLElement(nsGkAtoms::label)) { + return TextContentMatchesRegExp(*nes, aKey); + } + + return false; +} + +bool FormAutofillImpl::PlaceholderMatchesRegExp(Element& aElement, + RegexKey aKey) { + nsAutoString str; + if (!aElement.GetAttr(nsGkAtoms::placeholder, str)) { + return false; + } + return StringMatchesRegExp(str, aKey); +} + +bool FormAutofillImpl::AriaLabelMatchesRegExp(Element& aElement, + RegexKey aKey) { + nsAutoString str; + if (!aElement.GetAttr(nsGkAtoms::aria_label, str)) { + return false; + } + return StringMatchesRegExp(str, aKey); +} + +bool FormAutofillImpl::AutocompleteStringMatches(Element& aElement, + const nsAString& aKey) { + Nullable<AutocompleteInfo> info; + if (auto* input = HTMLInputElement::FromNode(aElement)) { + input->GetAutocompleteInfo(info); + } else { + AutocompleteInfo autoInfo; + if (auto* select = HTMLSelectElement::FromNode(aElement)) { + select->GetAutocompleteInfo(autoInfo); + info.SetValue(autoInfo); + } + } + + if (info.IsNull()) { + return false; + } + + return info.Value().mFieldName.Equals(aKey); +} + +bool FormAutofillImpl::HasTemplatedValue(Element& aElement) { + nsAutoString str; + if (!aElement.GetAttr(nsGkAtoms::value, str)) { + return false; + } + return StringMatchesRegExp(str, RegexKey::TEMPLATED_VALUE); +} + +bool FormAutofillImpl::RoleIsMenu(Element& aElement) { + return aElement.AttrValueIs(kNameSpaceID_None, nsGkAtoms::role, + nsGkAtoms::menu, eCaseMatters); +} + +bool FormAutofillImpl::InputTypeNotNumbery(Element& aElement) { + auto* input = HTMLInputElement::FromNode(aElement); + if (!input) { + return true; + } + + auto type = input->ControlType(); + return type != FormControlType::InputText && + type != FormControlType::InputTel && + type != FormControlType::InputNumber; +} + +bool FormAutofillImpl::IsSelectWithCreditCardOptions(Element& aElement) { + auto* select = HTMLSelectElement::FromNode(aElement); + if (!select) { + return false; + } + + nsCOMPtr<nsIHTMLCollection> options = select->Options(); + for (uint32_t i = 0; i < options->Length(); ++i) { + auto* item = options->Item(i); + auto* option = HTMLOptionElement::FromNode(item); + if (!option) { + continue; + } + // Bug 1756799, consider using getAttribute("value") instead of .value + nsAutoString str; + option->GetValue(str); + if (StringMatchesRegExp(str, RegexKey::CREDIT_CARD_NETWORK_EXACT_MATCH) || + StringMatchesRegExp(str, RegexKey::CREDIT_CARD_NETWORK_LONG)) { + return true; + } + + option->GetText(str); + if (StringMatchesRegExp(str, RegexKey::CREDIT_CARD_NETWORK_EXACT_MATCH) || + StringMatchesRegExp(str, RegexKey::CREDIT_CARD_NETWORK_LONG)) { + return true; + } + } + return false; +} + +bool FormAutofillImpl::IsRadioWithCreditCardText( + Element& aElement, const nsTArray<nsCString>* aLabels, ErrorResult& aRv) { + auto* input = HTMLInputElement::FromNode(aElement); + if (!input) { + return false; + } + auto type = input->ControlType(); + if (type != FormControlType::InputRadio) { + return false; + } + + nsAutoString str; + input->GetValue(str, CallerType::System); + if (CountRegExpMatches(str, RegexKey::CREDIT_CARD_NETWORK) == 1) { + return true; + } + + if (aLabels) { + size_t labelsMatched = 0; + for (const auto& label : *aLabels) { + size_t labelMatches = + CountRegExpMatches(label, RegexKey::CREDIT_CARD_NETWORK); + if (labelMatches > 1) { + return false; + } + if (labelMatches > 0) { + labelsMatched++; + } + } + + if (labelsMatched) { + return labelsMatched == 1; + } + } + + // Bug 1756798 : Remove reading text content in a <input> + nsAutoString text; + aElement.GetTextContent(text, aRv); + if (aRv.Failed()) { + return false; + } + return CountRegExpMatches(text, RegexKey::CREDIT_CARD_NETWORK) == 1; +} + +bool FormAutofillImpl::MaxLengthIs(Element& aElement, int32_t aValue) { + auto* input = HTMLInputElement::FromNode(aElement); + if (!input) { + return false; + } + return input->MaxLength() == aValue; +} + +static bool TestOptionElementForInteger(Element* aElement, int32_t aTestValue) { + auto* option = HTMLOptionElement::FromNodeOrNull(aElement); + if (!option) { + return false; + } + nsAutoString str; + option->GetValue(str); + nsContentUtils::ParseHTMLIntegerResultFlags parseFlags; + int32_t val = nsContentUtils::ParseHTMLInteger(str, &parseFlags); + if (val == aTestValue) { + return true; + } + option->GetRenderedLabel(str); + val = nsContentUtils::ParseHTMLInteger(str, &parseFlags); + return val == aTestValue; +} + +static bool MatchOptionContiguousInteger(HTMLOptionsCollection* aOptions, + uint32_t aNumContiguous, + int32_t aInteger) { + uint32_t len = aOptions->Length(); + if (aNumContiguous > len) { + return false; + } + + for (uint32_t i = 0; i <= aOptions->Length() - aNumContiguous; i++) { + bool match = true; + for (uint32_t j = 0; j < aNumContiguous; j++) { + if (!TestOptionElementForInteger(aOptions->GetElementAt(i + j), + aInteger + j)) { + match = false; + break; + } + } + if (match) { + return true; + } + } + return false; +} + +bool FormAutofillImpl::IsExpirationYearLikely(Element& aElement) { + auto* select = HTMLSelectElement::FromNode(aElement); + if (!select) { + return false; + } + + auto* options = select->Options(); + if (!options) { + return false; + } + + PRExplodedTime tm; + PR_ExplodeTime(PR_Now(), PR_LocalTimeParameters, &tm); + uint16_t currentYear = tm.tm_year; + + return MatchOptionContiguousInteger(options, 3, currentYear); +} + +bool FormAutofillImpl::IsExpirationMonthLikely(Element& aElement) { + auto* select = HTMLSelectElement::FromNode(aElement); + if (!select) { + return false; + } + + auto* options = select->Options(); + if (!options) { + return false; + } + + if (options->Length() != 12 && options->Length() != 13) { + return false; + } + + return MatchOptionContiguousInteger(options, 12, 1); +} + +Element* FormAutofillImpl::FindRootForField(Element* aElement) { + if (const auto* control = + nsGenericHTMLFormControlElement::FromNode(aElement)) { + if (Element* form = control->GetForm()) { + return form; + } + } + + return aElement->OwnerDoc()->GetDocumentElement(); +} + +Element* FormAutofillImpl::FindField( + const Sequence<OwningNonNull<Element>>& aElements, uint32_t aStartIndex, + int8_t aDirection) { + MOZ_ASSERT(aDirection == 1 || aDirection == -1); + MOZ_ASSERT(aStartIndex < aElements.Length()); + + Element* curFieldRoot = FindRootForField(aElements[aStartIndex]); + bool isRootForm = curFieldRoot->IsHTMLElement(nsGkAtoms::form); + + uint32_t num = + aDirection == 1 ? aElements.Length() - aStartIndex - 1 : aStartIndex; + for (uint32_t i = 0, searchIndex = aStartIndex; i < num; i++) { + searchIndex += aDirection; + const auto& element = aElements[searchIndex]; + Element* root = FindRootForField(element); + + if (isRootForm) { + // Only search fields that are within the same root element. + if (curFieldRoot != root) { + return nullptr; + } + } else { + // Exclude elements inside the rootElement that are already in a <form>. + if (root->IsHTMLElement(nsGkAtoms::form)) { + continue; + } + } + + if (element->IsAnyOfHTMLElements(nsGkAtoms::input, nsGkAtoms::select)) { + return element.get(); + } + } + + return nullptr; +} + +Element* FormAutofillImpl::NextField( + const Sequence<OwningNonNull<Element>>& aElements, uint32_t aStartIndex) { + return FindField(aElements, aStartIndex, 1); +} + +Element* FormAutofillImpl::PrevField( + const Sequence<OwningNonNull<Element>>& aElements, uint32_t aStartIndex) { + return FindField(aElements, aStartIndex, -1); +} + +static void ExtractLabelStrings(nsINode* aNode, nsTArray<nsCString>& aStrings, + ErrorResult& aRv) { + if (aNode->IsAnyOfHTMLElements(nsGkAtoms::script, nsGkAtoms::noscript, + nsGkAtoms::option, nsGkAtoms::style)) { + return; + } + + if (aNode->IsText() || !aNode->HasChildren()) { + nsAutoString text; + aNode->GetTextContent(text, aRv); + if (aRv.Failed()) { + return; + } + + text.Trim(kWhitespace); + CopyUTF16toUTF8(text, *aStrings.AppendElement()); + return; + } + + for (nsINode* child = aNode->GetFirstChild(); child; + child = child->GetNextSibling()) { + if (child->IsElement() || child->IsText()) { + ExtractLabelStrings(child, aStrings, aRv); + if (aRv.Failed()) { + return; + } + } + } +} + +nsTArray<nsCString>* GetLabelStrings( + Element* aElement, + const nsTHashMap<void*, nsTArray<nsCString>>& aElementMap, + const nsTHashMap<nsAtom*, nsTArray<nsCString>>& aIdMap) { + if (!aElement) { + return nullptr; + } + + if (nsAtom* idAtom = aElement->GetID()) { + return aIdMap.Lookup(idAtom).DataPtrOrNull(); + } + + return aElementMap.Lookup(aElement).DataPtrOrNull(); +} + +void FormAutofillImpl::GetFormAutofillConfidences( + GlobalObject& aGlobal, const Sequence<OwningNonNull<Element>>& aElements, + nsTArray<FormAutofillConfidences>& aResults, ErrorResult& aRv) { + if (aElements.IsEmpty()) { + return; + } + + // Create Labels + auto* document = aElements[0]->OwnerDoc(); +#ifdef DEBUG + for (uint32_t i = 1; i < aElements.Length(); ++i) { + MOZ_ASSERT(document == aElements[i]->OwnerDoc()); + } +#endif + + RefPtr<nsContentList> labels = document->GetElementsByTagName(u"label"_ns); + nsTHashMap<void*, nsTArray<nsCString>> elementsToLabelStrings; + nsTHashMap<nsAtom*, nsTArray<nsCString>> elementsIdToLabelStrings; + if (labels) { + for (uint32_t i = 0; i < labels->Length(); ++i) { + auto* item = labels->Item(i); + auto* label = HTMLLabelElement::FromNode(item); + if (NS_WARN_IF(!label)) { + continue; + } + auto* control = label->GetControl(); + if (!control) { + continue; + } + nsTArray<nsCString> labelStrings; + ExtractLabelStrings(label, labelStrings, aRv); + if (aRv.Failed()) { + return; + } + + // We need two maps here to keep track controls with id and without id. + // We can't just use map without id to cover all cases because there + // might be multiple elements with the same id. + if (control->GetID()) { + elementsIdToLabelStrings.LookupOrInsert(control->GetID()) + .AppendElements(std::move(labelStrings)); + } else { + elementsToLabelStrings.LookupOrInsert(control).AppendElements( + std::move(labelStrings)); + } + } + } + + nsTArray<AutofillParams> paramSet; + paramSet.SetLength(aElements.Length()); + + for (uint32_t i = 0; i < aElements.Length(); ++i) { + auto& params = paramSet[i]; + const auto& element = aElements[i]; + + const nsTArray<nsCString>* labelStrings = GetLabelStrings( + element, elementsToLabelStrings, elementsIdToLabelStrings); + + bool idOrNameMatchDwfrmAndBml = + IdOrNameMatchRegExp(element, RegexKey::DWFRM) && + IdOrNameMatchRegExp(element, RegexKey::BML); + bool hasTemplatedValue = HasTemplatedValue(element); + bool inputTypeNotNumbery = InputTypeNotNumbery(element); + bool idOrNameMatchSubscription = + IdOrNameMatchRegExp(element, RegexKey::SUBSCRIPTION); + bool idOrNameMatchFirstAndLast = + IdOrNameMatchRegExp(element, RegexKey::FIRST) && + IdOrNameMatchRegExp(element, RegexKey::LAST); + +#define RULE_IMPL2(rule, type) params.m##type##Params[type##Params::rule] +#define RULE_IMPL(rule, type) RULE_IMPL2(rule, type) +#define RULE(rule) RULE_IMPL(rule, RULE_TYPE) + + // cc-number +#define RULE_TYPE CCNumber + RULE(idOrNameMatchNumberRegExp) = + IdOrNameMatchRegExp(element, RegexKey::CC_NUMBER); + RULE(labelsMatchNumberRegExp) = + LabelMatchesRegExp(element, labelStrings, RegexKey::CC_NUMBER); + RULE(closestLabelMatchesNumberRegExp) = + ClosestLabelMatchesRegExp(element, RegexKey::CC_NUMBER); + RULE(placeholderMatchesNumberRegExp) = + PlaceholderMatchesRegExp(element, RegexKey::CC_NUMBER); + RULE(ariaLabelMatchesNumberRegExp) = + AriaLabelMatchesRegExp(element, RegexKey::CC_NUMBER); + RULE(idOrNameMatchGift) = IdOrNameMatchRegExp(element, RegexKey::GIFT); + RULE(labelsMatchGift) = + LabelMatchesRegExp(element, labelStrings, RegexKey::GIFT); + RULE(placeholderMatchesGift) = + PlaceholderMatchesRegExp(element, RegexKey::GIFT); + RULE(ariaLabelMatchesGift) = + AriaLabelMatchesRegExp(element, RegexKey::GIFT); + RULE(idOrNameMatchSubscription) = idOrNameMatchSubscription; + RULE(idOrNameMatchDwfrmAndBml) = idOrNameMatchDwfrmAndBml; + RULE(hasTemplatedValue) = hasTemplatedValue; + RULE(inputTypeNotNumbery) = inputTypeNotNumbery; +#undef RULE_TYPE + + // cc-name +#define RULE_TYPE CCName + RULE(idOrNameMatchNameRegExp) = + IdOrNameMatchRegExp(element, RegexKey::CC_NAME); + RULE(labelsMatchNameRegExp) = + LabelMatchesRegExp(element, labelStrings, RegexKey::CC_NAME); + RULE(closestLabelMatchesNameRegExp) = + ClosestLabelMatchesRegExp(element, RegexKey::CC_NAME); + RULE(placeholderMatchesNameRegExp) = + PlaceholderMatchesRegExp(element, RegexKey::CC_NAME); + RULE(ariaLabelMatchesNameRegExp) = + AriaLabelMatchesRegExp(element, RegexKey::CC_NAME); + RULE(idOrNameMatchFirst) = IdOrNameMatchRegExp(element, RegexKey::FIRST); + RULE(labelsMatchFirst) = + LabelMatchesRegExp(element, labelStrings, RegexKey::FIRST); + RULE(placeholderMatchesFirst) = + PlaceholderMatchesRegExp(element, RegexKey::FIRST); + RULE(ariaLabelMatchesFirst) = + AriaLabelMatchesRegExp(element, RegexKey::FIRST); + RULE(idOrNameMatchLast) = IdOrNameMatchRegExp(element, RegexKey::LAST); + RULE(labelsMatchLast) = + LabelMatchesRegExp(element, labelStrings, RegexKey::LAST); + RULE(placeholderMatchesLast) = + PlaceholderMatchesRegExp(element, RegexKey::LAST); + RULE(ariaLabelMatchesLast) = + AriaLabelMatchesRegExp(element, RegexKey::LAST); + RULE(idOrNameMatchSubscription) = idOrNameMatchSubscription; + RULE(idOrNameMatchFirstAndLast) = idOrNameMatchFirstAndLast; + RULE(idOrNameMatchDwfrmAndBml) = idOrNameMatchDwfrmAndBml; + RULE(hasTemplatedValue) = hasTemplatedValue; +#undef RULE_TYPE + + // We only use Fathom to detect cc-number & cc-name fields for now. + // Comment out code below instead of removing them to make it clear that + // the current design is to support multiple rules. +/* + Element* nextFillableField = NextField(aElements, i); + Element* prevFillableField = PrevField(aElements, i); + + const nsTArray<nsCString>* nextLabelStrings = GetLabelStrings( + nextFillableField, elementsToLabelStrings, elementsIdToLabelStrings); + const nsTArray<nsCString>* prevLabelStrings = GetLabelStrings( + prevFillableField, elementsToLabelStrings, elementsIdToLabelStrings); + bool roleIsMenu = RoleIsMenu(element); + + // cc-type +#define RULE_TYPE CCType + RULE(idOrNameMatchTypeRegExp) = + IdOrNameMatchRegExp(element, RegexKey::CC_TYPE); + RULE(labelsMatchTypeRegExp) = + LabelMatchesRegExp(element, labelStrings, RegexKey::CC_TYPE); + RULE(closestLabelMatchesTypeRegExp) = + ClosestLabelMatchesRegExp(element, RegexKey::CC_TYPE); + RULE(idOrNameMatchVisaCheckout) = + IdOrNameMatchRegExp(element, RegexKey::VISA_CHECKOUT); + RULE(ariaLabelMatchesVisaCheckout) = + AriaLabelMatchesRegExp(element, RegexKey::VISA_CHECKOUT); + RULE(isSelectWithCreditCardOptions) = + IsSelectWithCreditCardOptions(element); + RULE(isRadioWithCreditCardText) = + IsRadioWithCreditCardText(element, labelStrings, aRv); + if (aRv.Failed()) { + return; + } + + RULE(idOrNameMatchSubscription) = idOrNameMatchSubscription; + RULE(idOrNameMatchDwfrmAndBml) = idOrNameMatchDwfrmAndBml; + RULE(hasTemplatedValue) = hasTemplatedValue; +#undef RULE_TYPE + + // cc-exp +#define RULE_TYPE CCExp + RULE(labelsMatchExpRegExp) = + LabelMatchesRegExp(element, labelStrings, RegexKey::CC_EXP); + RULE(closestLabelMatchesExpRegExp) = + ClosestLabelMatchesRegExp(element, RegexKey::CC_EXP); + RULE(placeholderMatchesExpRegExp) = + PlaceholderMatchesRegExp(element, RegexKey::CC_EXP); + RULE(labelsMatchExpWith2Or4DigitYear) = LabelMatchesRegExp( + element, labelStrings, RegexKey::TWO_OR_FOUR_DIGIT_YEAR); + RULE(placeholderMatchesExpWith2Or4DigitYear) = + PlaceholderMatchesRegExp(element, RegexKey::TWO_OR_FOUR_DIGIT_YEAR); + RULE(labelsMatchMMYY) = + LabelMatchesRegExp(element, labelStrings, RegexKey::MMYY); + RULE(placeholderMatchesMMYY) = + PlaceholderMatchesRegExp(element, RegexKey::MMYY); + RULE(maxLengthIs7) = MaxLengthIs(element, 7); + RULE(idOrNameMatchSubscription) = idOrNameMatchSubscription; + RULE(idOrNameMatchDwfrmAndBml) = idOrNameMatchDwfrmAndBml; + RULE(hasTemplatedValue) = hasTemplatedValue; + RULE(isExpirationMonthLikely) = IsExpirationMonthLikely(element); + RULE(isExpirationYearLikely) = IsExpirationYearLikely(element); + RULE(idOrNameMatchMonth) = IdOrNameMatchRegExp(element, RegexKey::MONTH); + RULE(idOrNameMatchYear) = IdOrNameMatchRegExp(element, RegexKey::YEAR); + RULE(idOrNameMatchExpMonthRegExp) = + IdOrNameMatchRegExp(element, RegexKey::CC_EXP_MONTH); + RULE(idOrNameMatchExpYearRegExp) = + IdOrNameMatchRegExp(element, RegexKey::CC_EXP_YEAR); + RULE(idOrNameMatchValidation) = + IdOrNameMatchRegExp(element, RegexKey::VALIDATION); +#undef RULE_TYPE + + // cc-exp-month +#define RULE_TYPE CCExpMonth + RULE(idOrNameMatchExpMonthRegExp) = + IdOrNameMatchRegExp(element, RegexKey::CC_EXP_MONTH); + RULE(labelsMatchExpMonthRegExp) = + LabelMatchesRegExp(element, labelStrings, RegexKey::CC_EXP_MONTH); + RULE(closestLabelMatchesExpMonthRegExp) = + ClosestLabelMatchesRegExp(element, RegexKey::CC_EXP_MONTH); + RULE(placeholderMatchesExpMonthRegExp) = + PlaceholderMatchesRegExp(element, RegexKey::CC_EXP_MONTH); + RULE(ariaLabelMatchesExpMonthRegExp) = + AriaLabelMatchesRegExp(element, RegexKey::CC_EXP_MONTH); + RULE(idOrNameMatchMonth) = IdOrNameMatchRegExp(element, RegexKey::MONTH); + RULE(labelsMatchMonth) = + LabelMatchesRegExp(element, labelStrings, RegexKey::MONTH); + RULE(placeholderMatchesMonth) = + PlaceholderMatchesRegExp(element, RegexKey::MONTH); + RULE(ariaLabelMatchesMonth) = + AriaLabelMatchesRegExp(element, RegexKey::MONTH); + RULE(nextFieldIdOrNameMatchExpYearRegExp) = + nextFillableField && + IdOrNameMatchRegExp(*nextFillableField, RegexKey::CC_EXP_YEAR); + RULE(nextFieldLabelsMatchExpYearRegExp) = + nextFillableField && + LabelMatchesRegExp(element, nextLabelStrings, RegexKey::CC_EXP_YEAR); + RULE(nextFieldPlaceholderMatchExpYearRegExp) = + nextFillableField && + PlaceholderMatchesRegExp(*nextFillableField, RegexKey::CC_EXP_YEAR); + RULE(nextFieldAriaLabelMatchExpYearRegExp) = + nextFillableField && + AriaLabelMatchesRegExp(*nextFillableField, RegexKey::CC_EXP_YEAR); + RULE(nextFieldIdOrNameMatchYear) = + nextFillableField && + IdOrNameMatchRegExp(*nextFillableField, RegexKey::YEAR); + RULE(nextFieldLabelsMatchYear) = + nextFillableField && + LabelMatchesRegExp(element, nextLabelStrings, RegexKey::YEAR); + RULE(nextFieldPlaceholderMatchesYear) = + nextFillableField && + PlaceholderMatchesRegExp(*nextFillableField, RegexKey::YEAR); + RULE(nextFieldAriaLabelMatchesYear) = + nextFillableField && + AriaLabelMatchesRegExp(*nextFillableField, RegexKey::YEAR); + RULE(nextFieldMatchesExpYearAutocomplete) = + nextFillableField && + NextFieldMatchesExpYearAutocomplete(nextFillableField); + RULE(isExpirationMonthLikely) = IsExpirationMonthLikely(element); + RULE(nextFieldIsExpirationYearLikely) = + nextFillableField && IsExpirationYearLikely(*nextFillableField); + RULE(maxLengthIs2) = MaxLengthIs(element, 2); + RULE(placeholderMatchesMM) = + PlaceholderMatchesRegExp(element, RegexKey::MM_MONTH); + RULE(roleIsMenu) = roleIsMenu; + RULE(idOrNameMatchSubscription) = idOrNameMatchSubscription; + RULE(idOrNameMatchDwfrmAndBml) = idOrNameMatchDwfrmAndBml; + RULE(hasTemplatedValue) = hasTemplatedValue; +#undef RULE_TYPE + + // cc-exp-year +#define RULE_TYPE CCExpYear + RULE(idOrNameMatchExpYearRegExp) = + IdOrNameMatchRegExp(element, RegexKey::CC_EXP_YEAR); + RULE(labelsMatchExpYearRegExp) = + LabelMatchesRegExp(element, labelStrings, RegexKey::CC_EXP_YEAR); + RULE(closestLabelMatchesExpYearRegExp) = + ClosestLabelMatchesRegExp(element, RegexKey::CC_EXP_YEAR); + RULE(placeholderMatchesExpYearRegExp) = + PlaceholderMatchesRegExp(element, RegexKey::CC_EXP_YEAR); + RULE(ariaLabelMatchesExpYearRegExp) = + AriaLabelMatchesRegExp(element, RegexKey::CC_EXP_YEAR); + RULE(idOrNameMatchYear) = IdOrNameMatchRegExp(element, RegexKey::YEAR); + RULE(labelsMatchYear) = + LabelMatchesRegExp(element, labelStrings, RegexKey::YEAR); + RULE(placeholderMatchesYear) = + PlaceholderMatchesRegExp(element, RegexKey::YEAR); + RULE(ariaLabelMatchesYear) = + AriaLabelMatchesRegExp(element, RegexKey::YEAR); + RULE(previousFieldIdOrNameMatchExpMonthRegExp) = + prevFillableField && + IdOrNameMatchRegExp(*prevFillableField, RegexKey::CC_EXP_MONTH); + RULE(previousFieldLabelsMatchExpMonthRegExp) = + prevFillableField && + LabelMatchesRegExp(element, prevLabelStrings, RegexKey::CC_EXP_MONTH); + RULE(previousFieldPlaceholderMatchExpMonthRegExp) = + prevFillableField && + PlaceholderMatchesRegExp(*prevFillableField, RegexKey::CC_EXP_MONTH); + RULE(previousFieldAriaLabelMatchExpMonthRegExp) = + prevFillableField && + AriaLabelMatchesRegExp(*prevFillableField, RegexKey::CC_EXP_MONTH); + RULE(previousFieldIdOrNameMatchMonth) = + prevFillableField && + IdOrNameMatchRegExp(*prevFillableField, RegexKey::MONTH); + RULE(previousFieldLabelsMatchMonth) = + prevFillableField && + LabelMatchesRegExp(element, prevLabelStrings, RegexKey::MONTH); + RULE(previousFieldPlaceholderMatchesMonth) = + prevFillableField && + PlaceholderMatchesRegExp(*prevFillableField, RegexKey::MONTH); + RULE(previousFieldAriaLabelMatchesMonth) = + prevFillableField && + AriaLabelMatchesRegExp(*prevFillableField, RegexKey::MONTH); + RULE(previousFieldMatchesExpMonthAutocomplete) = + prevFillableField && + PreviousFieldMatchesExpMonthAutocomplete(prevFillableField); + RULE(isExpirationYearLikely) = IsExpirationYearLikely(element); + RULE(previousFieldIsExpirationMonthLikely) = + prevFillableField && IsExpirationMonthLikely(*prevFillableField); + RULE(placeholderMatchesYYOrYYYY) = + PlaceholderMatchesRegExp(element, RegexKey::YY_OR_YYYY); + RULE(roleIsMenu) = roleIsMenu; + RULE(idOrNameMatchSubscription) = idOrNameMatchSubscription; + RULE(idOrNameMatchDwfrmAndBml) = idOrNameMatchDwfrmAndBml; + RULE(hasTemplatedValue) = hasTemplatedValue; +#undef RULE_TYPE +*/ + +#undef RULE_IMPL2 +#undef RULE_IMPL +#undef RULE + +#define CALCULATE_SCORE(type, score) \ + for (auto i : MakeEnumeratedRange(type##Params::Count)) { \ + (score) += params.m##type##Params[i] * kCoefficents.m##type##Params[i]; \ + } \ + (score) = Sigmoid(score + k##type##Bias); + + // Calculating the final score of each rule + FormAutofillConfidences score; + CALCULATE_SCORE(CCNumber, score.mCcNumber) + CALCULATE_SCORE(CCName, score.mCcName) + + // Comment out code that are not used right now + // CALCULATE_SCORE(CCType, score.mCcType) + // CALCULATE_SCORE(CCExp, score.mCcExp) + // CALCULATE_SCORE(CCExpMonth, score.mCcExpMonth) + // CALCULATE_SCORE(CCExpYear, score.mCcExpYear) + +#undef CALCULATE_SCORE + + aResults.AppendElement(score); + } +} + +static StaticAutoPtr<FormAutofillImpl> sFormAutofillInstance; + +static FormAutofillImpl* GetFormAutofillImpl() { + if (!sFormAutofillInstance) { + sFormAutofillInstance = new FormAutofillImpl(); + ClearOnShutdown(&sFormAutofillInstance); + } + return sFormAutofillInstance; +} + +/* static */ +void FormAutofillNative::GetFormAutofillConfidences( + GlobalObject& aGlobal, const Sequence<OwningNonNull<Element>>& aElements, + nsTArray<FormAutofillConfidences>& aResults, ErrorResult& aRv) { + GetFormAutofillImpl()->GetFormAutofillConfidences(aGlobal, aElements, + aResults, aRv); +} + +} // namespace mozilla::dom diff --git a/toolkit/components/formautofill/FormAutofillNative.h b/toolkit/components/formautofill/FormAutofillNative.h new file mode 100644 index 0000000000..bca124631a --- /dev/null +++ b/toolkit/components/formautofill/FormAutofillNative.h @@ -0,0 +1,24 @@ +/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2; -*- */ +/* 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/. */ + +#ifndef mozilla_dom_FormAutofillNative_h +#define mozilla_dom_FormAutofillNative_h + +#include "mozilla/dom/BindingDeclarations.h" +#include "mozilla/dom/ChromeUtilsBinding.h" + +namespace mozilla::dom { +class Element; + +class FormAutofillNative { + public: + static void GetFormAutofillConfidences( + GlobalObject& aGlobal, const Sequence<OwningNonNull<Element>>& aElements, + nsTArray<FormAutofillConfidences>& aResults, ErrorResult& aRv); +}; + +} // namespace mozilla::dom + +#endif // mozilla_dom_FormAutofillNative_h diff --git a/toolkit/components/formautofill/FormAutofillParent.sys.mjs b/toolkit/components/formautofill/FormAutofillParent.sys.mjs new file mode 100644 index 0000000000..c0ae98b851 --- /dev/null +++ b/toolkit/components/formautofill/FormAutofillParent.sys.mjs @@ -0,0 +1,607 @@ +/* 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/. */ + +/* + * Implements a service used to access storage and communicate with content. + * + * A "fields" array is used to communicate with FormAutofillContent. Each item + * represents a single input field in the content page as well as its + * @autocomplete properties. The schema is as below. Please refer to + * FormAutofillContent.js for more details. + * + * [ + * { + * section, + * addressType, + * contactType, + * fieldName, + * value, + * index + * }, + * { + * // ... + * } + * ] + */ + +// We expose a singleton from this module. Some tests may import the +// constructor via a backstage pass. +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, { + AddressComponent: "resource://gre/modules/shared/AddressComponent.sys.mjs", + FormAutofillPreferences: + "resource://autofill/FormAutofillPreferences.sys.mjs", + FormAutofillPrompter: "resource://autofill/FormAutofillPrompter.sys.mjs", + OSKeyStore: "resource://gre/modules/OSKeyStore.sys.mjs", +}); + +XPCOMUtils.defineLazyModuleGetters(lazy, { + BrowserWindowTracker: "resource:///modules/BrowserWindowTracker.jsm", +}); + +XPCOMUtils.defineLazyGetter(lazy, "log", () => + FormAutofill.defineLogGetter(lazy, "FormAutofillParent") +); + +const { ENABLED_AUTOFILL_ADDRESSES_PREF, ENABLED_AUTOFILL_CREDITCARDS_PREF } = + FormAutofill; + +const { ADDRESSES_COLLECTION_NAME, CREDITCARDS_COLLECTION_NAME } = + FormAutofillUtils; + +let gMessageObservers = new Set(); + +export let FormAutofillStatus = { + _initialized: false, + + /** + * Cache of the Form Autofill status (considering preferences and storage). + */ + _active: null, + + /** + * Initializes observers and registers the message handler. + */ + init() { + if (this._initialized) { + return; + } + this._initialized = true; + + Services.obs.addObserver(this, "privacy-pane-loaded"); + + // Observing the pref and storage changes + Services.prefs.addObserver(ENABLED_AUTOFILL_ADDRESSES_PREF, this); + Services.obs.addObserver(this, "formautofill-storage-changed"); + + // Only listen to credit card related preference if it is available + if (FormAutofill.isAutofillCreditCardsAvailable) { + Services.prefs.addObserver(ENABLED_AUTOFILL_CREDITCARDS_PREF, this); + } + + // We have to use empty window type to get all opened windows here because the + // window type parameter may not be available during startup. + for (let win of Services.wm.getEnumerator("")) { + let { documentElement } = win.document; + if (documentElement?.getAttribute("windowtype") == "navigator:browser") { + this.injectElements(win.document); + } else { + // Manually call onOpenWindow for windows that are already opened but not + // yet have the window type set. This ensures we inject the elements we need + // when its docuemnt is ready. + this.onOpenWindow(win); + } + } + Services.wm.addListener(this); + + Services.telemetry.setEventRecordingEnabled("creditcard", true); + Services.telemetry.setEventRecordingEnabled("address", true); + }, + + /** + * Uninitializes FormAutofillStatus. This is for testing only. + * + * @private + */ + uninit() { + lazy.gFormAutofillStorage._saveImmediately(); + + if (!this._initialized) { + return; + } + this._initialized = false; + + this._active = null; + + Services.obs.removeObserver(this, "privacy-pane-loaded"); + Services.prefs.removeObserver(ENABLED_AUTOFILL_ADDRESSES_PREF, this); + Services.wm.removeListener(this); + + if (FormAutofill.isAutofillCreditCardsAvailable) { + Services.prefs.removeObserver(ENABLED_AUTOFILL_CREDITCARDS_PREF, this); + } + }, + + get formAutofillStorage() { + return lazy.gFormAutofillStorage; + }, + + /** + * Broadcast the status to frames when the form autofill status changes. + */ + onStatusChanged() { + lazy.log.debug("onStatusChanged: Status changed to", this._active); + Services.ppmm.sharedData.set("FormAutofill:enabled", this._active); + // Sync autofill enabled to make sure the value is up-to-date + // no matter when the new content process is initialized. + Services.ppmm.sharedData.flush(); + }, + + /** + * Query preference and storage status to determine the overall status of the + * form autofill feature. + * + * @returns {boolean} whether form autofill is active (enabled and has data) + */ + computeStatus() { + const savedFieldNames = Services.ppmm.sharedData.get( + "FormAutofill:savedFieldNames" + ); + + return ( + (Services.prefs.getBoolPref(ENABLED_AUTOFILL_ADDRESSES_PREF) || + Services.prefs.getBoolPref(ENABLED_AUTOFILL_CREDITCARDS_PREF)) && + savedFieldNames && + savedFieldNames.size > 0 + ); + }, + + /** + * Update the status and trigger onStatusChanged, if necessary. + */ + updateStatus() { + lazy.log.debug("updateStatus"); + let wasActive = this._active; + this._active = this.computeStatus(); + if (this._active !== wasActive) { + this.onStatusChanged(); + } + }, + + async updateSavedFieldNames() { + lazy.log.debug("updateSavedFieldNames"); + + let savedFieldNames; + const addressNames = + await lazy.gFormAutofillStorage.addresses.getSavedFieldNames(); + + // Don't access the credit cards store unless it is enabled. + if (FormAutofill.isAutofillCreditCardsAvailable) { + const creditCardNames = + await lazy.gFormAutofillStorage.creditCards.getSavedFieldNames(); + savedFieldNames = new Set([...addressNames, ...creditCardNames]); + } else { + savedFieldNames = addressNames; + } + + Services.ppmm.sharedData.set( + "FormAutofill:savedFieldNames", + savedFieldNames + ); + Services.ppmm.sharedData.flush(); + + this.updateStatus(); + }, + + injectElements(doc) { + Services.scriptloader.loadSubScript( + "chrome://formautofill/content/customElements.js", + doc.ownerGlobal + ); + }, + + onOpenWindow(xulWindow) { + const win = xulWindow.docShell.domWindow; + win.addEventListener( + "load", + () => { + if ( + win.document.documentElement.getAttribute("windowtype") == + "navigator:browser" + ) { + this.injectElements(win.document); + } + }, + { once: true } + ); + }, + + onCloseWindow() {}, + + async observe(subject, topic, data) { + lazy.log.debug("observe:", topic, "with data:", data); + switch (topic) { + case "privacy-pane-loaded": { + let formAutofillPreferences = new lazy.FormAutofillPreferences(); + let document = subject.document; + let prefFragment = formAutofillPreferences.init(document); + let formAutofillGroupBox = document.getElementById( + "formAutofillGroupBox" + ); + formAutofillGroupBox.appendChild(prefFragment); + break; + } + + case "nsPref:changed": { + // Observe pref changes and update _active cache if status is changed. + this.updateStatus(); + break; + } + + case "formautofill-storage-changed": { + // Early exit if only metadata is changed + if (data == "notifyUsed") { + break; + } + + await this.updateSavedFieldNames(); + break; + } + + default: { + throw new Error( + `FormAutofillStatus: Unexpected topic observed: ${topic}` + ); + } + } + }, +}; + +// Lazily load the storage JSM to avoid disk I/O until absolutely needed. +// Once storage is loaded we need to update saved field names and inform content processes. +XPCOMUtils.defineLazyGetter(lazy, "gFormAutofillStorage", () => { + let { formAutofillStorage } = ChromeUtils.importESModule( + "resource://autofill/FormAutofillStorage.sys.mjs" + ); + lazy.log.debug("Loading formAutofillStorage"); + + formAutofillStorage.initialize().then(() => { + // Update the saved field names to compute the status and update child processes. + FormAutofillStatus.updateSavedFieldNames(); + }); + + return formAutofillStorage; +}); + +export class FormAutofillParent extends JSWindowActorParent { + constructor() { + super(); + FormAutofillStatus.init(); + } + + static addMessageObserver(observer) { + gMessageObservers.add(observer); + } + + static removeMessageObserver(observer) { + gMessageObservers.delete(observer); + } + + /** + * Handles the message coming from FormAutofillContent. + * + * @param {object} message + * @param {string} message.name The name of the message. + * @param {object} message.data The data of the message. + */ + async receiveMessage({ name, data }) { + switch (name) { + case "FormAutofill:InitStorage": { + await lazy.gFormAutofillStorage.initialize(); + await FormAutofillStatus.updateSavedFieldNames(); + break; + } + case "FormAutofill:GetRecords": { + return FormAutofillParent._getRecords(data); + } + case "FormAutofill:OnFormSubmit": { + this.notifyMessageObservers("onFormSubmitted", data); + await this._onFormSubmit(data); + break; + } + case "FormAutofill:OpenPreferences": { + const win = lazy.BrowserWindowTracker.getTopWindow(); + win.openPreferences("privacy-form-autofill"); + break; + } + case "FormAutofill:GetDecryptedString": { + let { cipherText, reauth } = data; + if (!FormAutofillUtils._reauthEnabledByUser) { + lazy.log.debug("Reauth is disabled"); + reauth = false; + } + let string; + try { + string = await lazy.OSKeyStore.decrypt(cipherText, reauth); + } catch (e) { + if (e.result != Cr.NS_ERROR_ABORT) { + throw e; + } + lazy.log.warn("User canceled encryption login"); + } + return string; + } + case "FormAutofill:UpdateWarningMessage": + this.notifyMessageObservers("updateWarningNote", data); + break; + + case "FormAutofill:FieldsIdentified": + this.notifyMessageObservers("fieldsIdentified", data); + break; + + // The remaining Save and Remove messages are invoked only by tests. + case "FormAutofill:SaveAddress": { + if (data.guid) { + await lazy.gFormAutofillStorage.addresses.update( + data.guid, + data.address + ); + } else { + await lazy.gFormAutofillStorage.addresses.add(data.address); + } + break; + } + case "FormAutofill:SaveCreditCard": { + if (!(await FormAutofillUtils.ensureLoggedIn()).authenticated) { + lazy.log.warn("User canceled encryption login"); + return undefined; + } + await lazy.gFormAutofillStorage.creditCards.add(data.creditcard); + break; + } + case "FormAutofill:RemoveAddresses": { + data.guids.forEach(guid => + lazy.gFormAutofillStorage.addresses.remove(guid) + ); + break; + } + case "FormAutofill:RemoveCreditCards": { + data.guids.forEach(guid => + lazy.gFormAutofillStorage.creditCards.remove(guid) + ); + break; + } + } + + return undefined; + } + + notifyMessageObservers(callbackName, data) { + for (let observer of gMessageObservers) { + try { + if (callbackName in observer) { + observer[callbackName]( + data, + this.manager.browsingContext.topChromeWindow + ); + } + } catch (ex) { + console.error(ex); + } + } + } + + /** + * Get the records from profile store and return results back to content + * process. It will decrypt the credit card number and append + * "cc-number-decrypted" to each record if OSKeyStore isn't set. + * + * This is static as a unit test calls this. + * + * @private + * @param {object} data + * @param {string} data.collectionName + * The name used to specify which collection to retrieve records. + * @param {string} data.searchString + * The typed string for filtering out the matched records. + * @param {string} data.info + * The input autocomplete property's information. + */ + static async _getRecords({ collectionName, searchString, info }) { + let collection = lazy.gFormAutofillStorage[collectionName]; + if (!collection) { + return []; + } + + let recordsInCollection = await collection.getAll(); + if (!info || !info.fieldName || !recordsInCollection.length) { + return recordsInCollection; + } + + let isCC = collectionName == CREDITCARDS_COLLECTION_NAME; + // We don't filter "cc-number" + if (isCC && info.fieldName == "cc-number") { + recordsInCollection = recordsInCollection.filter( + record => !!record["cc-number"] + ); + return recordsInCollection; + } + + let records = []; + let lcSearchString = searchString.toLowerCase(); + + for (let record of recordsInCollection) { + let fieldValue = record[info.fieldName]; + if (!fieldValue) { + continue; + } + + if ( + collectionName == ADDRESSES_COLLECTION_NAME && + record.country && + !FormAutofill.isAutofillAddressesAvailableInCountry(record.country) + ) { + // Address autofill isn't supported for the record's country so we don't + // want to attempt to potentially incorrectly fill the address fields. + continue; + } + + if ( + lcSearchString && + !String(fieldValue).toLowerCase().startsWith(lcSearchString) + ) { + continue; + } + records.push(record); + } + + return records; + } + + async _onAddressSubmit(address, browser) { + const storage = lazy.gFormAutofillStorage.addresses; + + // Make sure record is normalized before comparing with records in the storage + storage._normalizeRecord(address.record); + + const newAddress = new lazy.AddressComponent( + address.record, + // Invalid address fields in the address form will not be captured. + { ignoreInvalid: true } + ); + + let mergeableRecord = null; + let mergeableFields = []; + + // Exams all stored record to determine whether to show the prompt or not. + for (const record of await storage.getAll()) { + const savedAddress = new lazy.AddressComponent(record); + // filter invalid field + const result = newAddress.compare(savedAddress); + + // If any of the fields in the new address are different from the corresponding fields + // in the saved address, the two addresses are considered different. For example, if + // the name, email, country are the same but the street address is different, the two + // addresses are not considered the same. + if (Object.values(result).includes("different")) { + continue; + // If every field of the new address is either the same or is subset of the corresponding + // field in the saved address, the new address is duplicated. We don't need capture + // the new address. + } else if ( + Object.values(result).every(r => ["same", "subset"].includes(r)) + ) { + lazy.log.debug( + "A duplicated address record is found, do not show the prompt" + ); + storage.notifyUsed(record.guid); + return false; + // If the new address is neither a duplicate of the saved address nor a different address. + // There must be at least one field we can merge, show the update doorhanger + } else { + lazy.log.debug( + "A mergeable address record is found, show the update prompt" + ); + // If we find multiple mergeable records, choose the record with fewest mergeable fields. + // TODO: Bug 1830841. Add a testcase + let fields = Object.entries(result) + .filter(v => ["superset", "similar"].includes(v[1])) + .map(v => v[0]); + if (!mergeableFields.length || mergeableFields.length > fields.length) { + mergeableRecord = record; + mergeableFields = fields; + } + } + } + + if ( + !FormAutofill.isAutofillAddressesCaptureEnabled && + !FormAutofill.isAutofillAddressesCaptureV2Enabled + ) { + return false; + } + + return async () => { + await lazy.FormAutofillPrompter.promptToSaveAddress( + browser, + storage, + address.record, + address.flowId, + { mergeableRecord, mergeableFields } + ); + }; + } + + async _onCreditCardSubmit(creditCard, browser) { + // Let's reset the credit card to empty, and then network auto-detect will + // pick it up. + delete creditCard.record["cc-type"]; + + const storage = lazy.gFormAutofillStorage.creditCards; + // Make sure record is normalized before comparing with records in the storage + storage._normalizeRecord(creditCard.record); + + // If the record alreay exists in the storage, don't bother showing the prompt + const matchRecord = ( + await storage.getMatchRecords(creditCard.record).next() + ).value; + if (matchRecord) { + storage.notifyUsed(matchRecord.guid); + return false; + } + + // Suppress the pending doorhanger from showing up if user disabled credit card in previous doorhanger. + if (!FormAutofill.isAutofillCreditCardsEnabled) { + return false; + } + + return async () => { + await lazy.FormAutofillPrompter.promptToSaveCreditCard( + browser, + storage, + creditCard.record, + creditCard.flowId + ); + }; + } + + async _onFormSubmit(data) { + let { address, creditCard } = data; + + let browser = this.manager.browsingContext.top.embedderElement; + + // Transmit the telemetry immediately in the meantime form submitted, and handle these pending + // doorhangers at a later. + await Promise.all( + [ + await Promise.all( + address.map(addrRecord => this._onAddressSubmit(addrRecord, browser)) + ), + await Promise.all( + creditCard.map(ccRecord => + this._onCreditCardSubmit(ccRecord, browser) + ) + ), + ] + .map(pendingDoorhangers => { + return pendingDoorhangers.filter( + pendingDoorhanger => + !!pendingDoorhanger && typeof pendingDoorhanger == "function" + ); + }) + .map(pendingDoorhangers => + (async () => { + for (const showDoorhanger of pendingDoorhangers) { + await showDoorhanger(); + } + })() + ) + ); + } +} diff --git a/toolkit/components/formautofill/FormAutofillPreferences.sys.mjs b/toolkit/components/formautofill/FormAutofillPreferences.sys.mjs new file mode 100644 index 0000000000..55e757a2af --- /dev/null +++ b/toolkit/components/formautofill/FormAutofillPreferences.sys.mjs @@ -0,0 +1,389 @@ +/* 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/. */ + +/** + * Injects the form autofill section into about:preferences. + */ + +// Add addresses enabled flag in telemetry environment for recording the number of +// users who disable/enable the address autofill feature. +const BUNDLE_URI = "chrome://formautofill/locale/formautofill.properties"; +const MANAGE_ADDRESSES_URL = + "chrome://formautofill/content/manageAddresses.xhtml"; +const MANAGE_CREDITCARDS_URL = + "chrome://formautofill/content/manageCreditCards.xhtml"; + +import { FormAutofill } from "resource://autofill/FormAutofill.sys.mjs"; +import { FormAutofillUtils } from "resource://gre/modules/shared/FormAutofillUtils.sys.mjs"; +import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs"; + +const lazy = {}; +ChromeUtils.defineESModuleGetters(lazy, { + OSKeyStore: "resource://gre/modules/OSKeyStore.sys.mjs", +}); + +const { + ENABLED_AUTOFILL_ADDRESSES_PREF, + ENABLED_AUTOFILL_CREDITCARDS_PREF, + ENABLED_AUTOFILL_CREDITCARDS_REAUTH_PREF, +} = FormAutofill; +const { + MANAGE_ADDRESSES_L10N_IDS, + EDIT_ADDRESS_L10N_IDS, + MANAGE_CREDITCARDS_L10N_IDS, + EDIT_CREDITCARD_L10N_IDS, +} = FormAutofillUtils; +// Add credit card enabled flag in telemetry environment for recording the number of +// users who disable/enable the credit card autofill feature. + +const HTML_NS = "http://www.w3.org/1999/xhtml"; + +export function FormAutofillPreferences() { + this.bundle = Services.strings.createBundle(BUNDLE_URI); +} + +FormAutofillPreferences.prototype = { + /** + * Create the Form Autofill preference group. + * + * @param {HTMLDocument} document + * @returns {XULElement} + */ + init(document) { + this.createPreferenceGroup(document); + this.attachEventListeners(); + + return this.refs.formAutofillFragment; + }, + + /** + * Remove event listeners and the preference group. + */ + uninit() { + this.detachEventListeners(); + this.refs.formAutofillGroup.remove(); + }, + + /** + * Create Form Autofill preference group + * + * @param {HTMLDocument} document + */ + createPreferenceGroup(document) { + let formAutofillFragment = document.createDocumentFragment(); + let formAutofillGroupBoxLabel = document.createXULElement("label"); + let formAutofillGroupBoxLabelHeading = document.createElementNS( + HTML_NS, + "h2" + ); + let formAutofillGroup = document.createXULElement("vbox"); + // Wrappers are used to properly compute the search tooltip positions + // let savedAddressesBtnWrapper = document.createXULElement("hbox"); + // let savedCreditCardsBtnWrapper = document.createXULElement("hbox"); + this.refs = {}; + this.refs.formAutofillGroup = formAutofillGroup; + this.refs.formAutofillFragment = formAutofillFragment; + + formAutofillGroupBoxLabel.appendChild(formAutofillGroupBoxLabelHeading); + formAutofillFragment.appendChild(formAutofillGroupBoxLabel); + formAutofillFragment.appendChild(formAutofillGroup); + + let showAddressUI = FormAutofill.isAutofillAddressesAvailable; + let showCreditCardUI = FormAutofill.isAutofillCreditCardsAvailable; + + if (!showAddressUI && !showCreditCardUI) { + return; + } + + formAutofillGroupBoxLabelHeading.textContent = + this.bundle.GetStringFromName("autofillHeader"); + + if (showAddressUI) { + let savedAddressesBtnWrapper = document.createXULElement("hbox"); + let addressAutofill = document.createXULElement("hbox"); + let addressAutofillCheckboxGroup = document.createXULElement("hbox"); + let addressAutofillCheckbox = document.createXULElement("checkbox"); + let addressAutofillLearnMore = document.createElement("a", { + is: "moz-support-link", + }); + let savedAddressesBtn = document.createXULElement("button", { + is: "highlightable-button", + }); + savedAddressesBtn.className = "accessory-button"; + addressAutofillCheckbox.className = "tail-with-learn-more"; + + formAutofillGroup.id = "formAutofillGroup"; + addressAutofill.id = "addressAutofill"; + addressAutofillLearnMore.id = "addressAutofillLearnMore"; + + addressAutofill.setAttribute("data-subcategory", "address-autofill"); + addressAutofillCheckbox.setAttribute( + "label", + this.bundle.GetStringFromName("autofillAddressesCheckbox") + ); + savedAddressesBtn.setAttribute( + "label", + this.bundle.GetStringFromName("savedAddressesBtnLabel") + ); + // Align the start to keep the savedAddressesBtn as original size + // when addressAutofillCheckboxGroup's height is changed by a longer l10n string + savedAddressesBtnWrapper.setAttribute("align", "start"); + + addressAutofillLearnMore.setAttribute( + "support-page", + "autofill-card-address" + ); + + // Add preferences search support + savedAddressesBtn.setAttribute( + "search-l10n-ids", + MANAGE_ADDRESSES_L10N_IDS.concat(EDIT_ADDRESS_L10N_IDS).join(",") + ); + + // Manually set the checked state + if (FormAutofill.isAutofillAddressesEnabled) { + addressAutofillCheckbox.setAttribute("checked", true); + } + if (FormAutofill.isAutofillAddressesLocked) { + addressAutofillCheckbox.disabled = true; + } + + addressAutofillCheckboxGroup.setAttribute("align", "center"); + addressAutofillCheckboxGroup.setAttribute("flex", "1"); + + formAutofillGroup.appendChild(addressAutofill); + addressAutofill.appendChild(addressAutofillCheckboxGroup); + addressAutofillCheckboxGroup.appendChild(addressAutofillCheckbox); + addressAutofillCheckboxGroup.appendChild(addressAutofillLearnMore); + addressAutofill.appendChild(savedAddressesBtnWrapper); + savedAddressesBtnWrapper.appendChild(savedAddressesBtn); + + this.refs.formAutofillFragment = formAutofillFragment; + this.refs.addressAutofillCheckbox = addressAutofillCheckbox; + this.refs.savedAddressesBtn = savedAddressesBtn; + } + + if (showCreditCardUI) { + let savedCreditCardsBtnWrapper = document.createXULElement("hbox"); + let creditCardAutofill = document.createXULElement("hbox"); + let creditCardAutofillCheckboxGroup = document.createXULElement("hbox"); + let creditCardAutofillCheckbox = document.createXULElement("checkbox"); + let creditCardAutofillLearnMore = document.createElement("a", { + is: "moz-support-link", + }); + let savedCreditCardsBtn = document.createXULElement("button", { + is: "highlightable-button", + }); + savedCreditCardsBtn.className = "accessory-button"; + creditCardAutofillCheckbox.className = "tail-with-learn-more"; + + creditCardAutofill.id = "creditCardAutofill"; + creditCardAutofillLearnMore.id = "creditCardAutofillLearnMore"; + + creditCardAutofill.setAttribute( + "data-subcategory", + "credit-card-autofill" + ); + creditCardAutofillCheckbox.setAttribute( + "label", + this.bundle.GetStringFromName("autofillCreditCardsCheckbox") + ); + + savedCreditCardsBtn.setAttribute( + "label", + this.bundle.GetStringFromName("savedCreditCardsBtnLabel") + ); + // Align the start to keep the savedCreditCardsBtn as original size + // when creditCardAutofillCheckboxGroup's height is changed by a longer l10n string + savedCreditCardsBtnWrapper.setAttribute("align", "start"); + + creditCardAutofillLearnMore.setAttribute( + "support-page", + "credit-card-autofill" + ); + + // Add preferences search support + savedCreditCardsBtn.setAttribute( + "search-l10n-ids", + MANAGE_CREDITCARDS_L10N_IDS.concat(EDIT_CREDITCARD_L10N_IDS).join(",") + ); + + // Manually set the checked state + if (FormAutofill.isAutofillCreditCardsEnabled) { + creditCardAutofillCheckbox.setAttribute("checked", true); + } + if (FormAutofill.isAutofillCreditCardsLocked) { + creditCardAutofillCheckbox.disabled = true; + } + + creditCardAutofillCheckboxGroup.setAttribute("align", "center"); + creditCardAutofillCheckboxGroup.setAttribute("flex", "1"); + + formAutofillGroup.appendChild(creditCardAutofill); + creditCardAutofill.appendChild(creditCardAutofillCheckboxGroup); + creditCardAutofillCheckboxGroup.appendChild(creditCardAutofillCheckbox); + creditCardAutofillCheckboxGroup.appendChild(creditCardAutofillLearnMore); + creditCardAutofill.appendChild(savedCreditCardsBtnWrapper); + savedCreditCardsBtnWrapper.appendChild(savedCreditCardsBtn); + + this.refs.creditCardAutofillCheckbox = creditCardAutofillCheckbox; + this.refs.savedCreditCardsBtn = savedCreditCardsBtn; + + if (lazy.OSKeyStore.canReauth()) { + let reauth = document.createXULElement("hbox"); + let reauthCheckboxGroup = document.createXULElement("hbox"); + let reauthCheckbox = document.createXULElement("checkbox"); + let reauthLearnMore = document.createElement("a", { + is: "moz-support-link", + }); + + reauthCheckboxGroup.classList.add("indent"); + reauthCheckbox.classList.add("tail-with-learn-more"); + reauthCheckbox.setAttribute("flex", "1"); + reauthCheckbox.disabled = !FormAutofill.isAutofillCreditCardsEnabled; + + reauth.id = "creditCardReauthenticate"; + reauthLearnMore.id = "creditCardReauthenticateLearnMore"; + + reauth.setAttribute("data-subcategory", "reauth-credit-card-autofill"); + + let autofillReauthCheckboxLabel = "autofillReauthCheckbox"; + // We reuse the if/else order from wizard markup to increase + // odds of consistent behavior. + if (AppConstants.platform == "macosx") { + autofillReauthCheckboxLabel += "Mac"; + } else if (AppConstants.platform == "linux") { + autofillReauthCheckboxLabel += "Lin"; + } else { + autofillReauthCheckboxLabel += "Win"; + } + reauthCheckbox.setAttribute( + "label", + this.bundle.GetStringFromName(autofillReauthCheckboxLabel) + ); + + reauthLearnMore.setAttribute( + "support-page", + "credit-card-autofill#w_require-authentication-for-autofill" + ); + + // Manually set the checked state + if (FormAutofillUtils._reauthEnabledByUser) { + reauthCheckbox.setAttribute("checked", true); + } + + reauthCheckboxGroup.setAttribute("align", "center"); + reauthCheckboxGroup.setAttribute("flex", "1"); + + formAutofillGroup.appendChild(reauth); + reauth.appendChild(reauthCheckboxGroup); + reauthCheckboxGroup.appendChild(reauthCheckbox); + reauthCheckboxGroup.appendChild(reauthLearnMore); + this.refs.reauthCheckbox = reauthCheckbox; + } + } + }, + + /** + * Handle events + * + * @param {DOMEvent} event + */ + async handleEvent(event) { + switch (event.type) { + case "command": { + let target = event.target; + + if (target == this.refs.addressAutofillCheckbox) { + // Set preference directly instead of relying on <Preference> + Services.prefs.setBoolPref( + ENABLED_AUTOFILL_ADDRESSES_PREF, + target.checked + ); + } else if (target == this.refs.creditCardAutofillCheckbox) { + Services.prefs.setBoolPref( + ENABLED_AUTOFILL_CREDITCARDS_PREF, + target.checked + ); + if (this.refs.reauthCheckbox) { + this.refs.reauthCheckbox.disabled = !target.checked; + } + } else if (target == this.refs.reauthCheckbox) { + if (!lazy.OSKeyStore.canReauth()) { + break; + } + + let messageTextId = "autofillReauthOSDialog"; + // We reuse the if/else order from wizard markup to increase + // odds of consistent behavior. + if (AppConstants.platform == "macosx") { + messageTextId += "Mac"; + } else if (AppConstants.platform == "linux") { + messageTextId += "Lin"; + } else { + messageTextId += "Win"; + } + + let messageText = this.bundle.GetStringFromName(messageTextId); + + const brandBundle = Services.strings.createBundle( + "chrome://branding/locale/brand.properties" + ); + let win = target.ownerGlobal.docShell.chromeEventHandler.ownerGlobal; + let loggedIn = await lazy.OSKeyStore.ensureLoggedIn( + messageText, + brandBundle.GetStringFromName("brandFullName"), + win, + false + ); + if (!loggedIn.authenticated) { + target.checked = !target.checked; + break; + } + + Services.prefs.setBoolPref( + ENABLED_AUTOFILL_CREDITCARDS_REAUTH_PREF, + target.checked + ); + } else if (target == this.refs.savedAddressesBtn) { + target.ownerGlobal.gSubDialog.open(MANAGE_ADDRESSES_URL); + } else if (target == this.refs.savedCreditCardsBtn) { + target.ownerGlobal.gSubDialog.open(MANAGE_CREDITCARDS_URL); + } + break; + } + case "click": { + let target = event.target; + + if (target == this.refs.addressAutofillCheckboxLabel) { + let pref = FormAutofill.isAutofillAddressesEnabled; + Services.prefs.setBoolPref(ENABLED_AUTOFILL_ADDRESSES_PREF, !pref); + this.refs.addressAutofillCheckbox.checked = !pref; + } else if (target == this.refs.creditCardAutofillCheckboxLabel) { + let pref = FormAutofill.isAutofillCreditCardsEnabled; + Services.prefs.setBoolPref(ENABLED_AUTOFILL_CREDITCARDS_PREF, !pref); + this.refs.creditCardAutofillCheckbox.checked = !pref; + this.refs.reauthCheckbox.disabled = pref; + } + break; + } + } + }, + + /** + * Attach event listener + */ + attachEventListeners() { + this.refs.formAutofillGroup.addEventListener("command", this); + this.refs.formAutofillGroup.addEventListener("click", this); + }, + + /** + * Remove event listener + */ + detachEventListeners() { + this.refs.formAutofillGroup.removeEventListener("command", this); + this.refs.formAutofillGroup.removeEventListener("click", this); + }, +}; diff --git a/toolkit/components/formautofill/FormAutofillStorageBase.sys.mjs b/toolkit/components/formautofill/FormAutofillStorageBase.sys.mjs new file mode 100644 index 0000000000..186b53d78b --- /dev/null +++ b/toolkit/components/formautofill/FormAutofillStorageBase.sys.mjs @@ -0,0 +1,2219 @@ +/* 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/. */ + +/* + * Interface for the storage of Form Autofill. + * + * The data is stored in JSON format, without indentation and the computed + * fields, using UTF-8 encoding. With indentation and computed fields applied, + * the schema would look like this: + * + * { + * version: 1, + * addresses: [ + * { + * guid, // 12 characters + * version, // schema version in integer + * + * // address fields + * given-name, + * additional-name, + * family-name, + * organization, // Company + * street-address, // (Multiline) + * address-level3, // Suburb/Sublocality + * address-level2, // City/Town + * address-level1, // Province (Standardized code if possible) + * postal-code, + * country, // ISO 3166 + * tel, // Stored in E.164 format + * email, + * + * // computed fields (These fields are computed based on the above fields + * // and are not allowed to be modified directly.) + * name, + * address-line1, + * address-line2, + * address-line3, + * country-name, + * tel-country-code, + * tel-national, + * tel-area-code, + * tel-local, + * tel-local-prefix, + * tel-local-suffix, + * + * // metadata + * timeCreated, // in ms + * timeLastUsed, // in ms + * timeLastModified, // in ms + * timesUsed, + * _sync: { ... optional sync metadata }, + * ...unknown fields // We keep fields we don't understand/expect from other clients + * // to prevent data loss for other clients, we roundtrip them for sync + * } + * ], + * creditCards: [ + * { + * guid, // 12 characters + * version, // schema version in integer + * + * // credit card fields + * billingAddressGUID, // An optional GUID of an autofill address record + * which may or may not exist locally. + * + * cc-name, + * cc-number, // will be stored in masked format (************1234) + * // (see details below) + * cc-exp-month, + * cc-exp-year, // 2-digit year will be converted to 4 digits + * // upon saving + * cc-type, // Optional card network id (instrument type) + * + * // computed fields (These fields are computed based on the above fields + * // and are not allowed to be modified directly.) + * cc-given-name, + * cc-additional-name, + * cc-family-name, + * cc-number-encrypted, // encrypted from the original unmasked "cc-number" + * // (see details below) + * cc-exp, + * + * // metadata + * timeCreated, // in ms + * timeLastUsed, // in ms + * timeLastModified, // in ms + * timesUsed, + * _sync: { ... optional sync metadata }, + * ...unknown fields // We keep fields we don't understand/expect from other clients + * // to prevent data loss for other clients, we roundtrip them for sync + * } + * ] + * } + * + * + * Encrypt-related Credit Card Fields (cc-number & cc-number-encrypted): + * + * When saving or updating a credit-card record, the storage will encrypt the + * value of "cc-number", store the encrypted number in "cc-number-encrypted" + * field, and replace "cc-number" field with the masked number. These all happen + * in "computeFields". We do reverse actions in "_stripComputedFields", which + * decrypts "cc-number-encrypted", restores it to "cc-number", and deletes + * "cc-number-encrypted". Therefore, calling "_stripComputedFields" followed by + * "computeFields" can make sure the encrypt-related fields are up-to-date. + * + * In general, you have to decrypt the number by your own outside FormAutofillStorage + * when necessary. However, you will get the decrypted records when querying + * data with "rawData=true" to ensure they're ready to sync. + * + * + * Sync Metadata: + * + * Records may also have a _sync field, which consists of: + * { + * changeCounter, // integer - the number of changes made since the last + * // sync. + * lastSyncedFields, // object - hashes of the original values for fields + * // changed since the last sync. + * } + * + * Records with such a field have previously been synced. Records without such + * a field are yet to be synced, so are treated specially in some cases (eg, + * they don't need a tombstone, de-duping logic treats them as special etc). + * Records without the field are always considered "dirty" from Sync's POV + * (meaning they will be synced on the next sync), at which time they will gain + * this new field. + */ + +import { FormAutofill } from "resource://autofill/FormAutofill.sys.mjs"; +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + CreditCard: "resource://gre/modules/CreditCard.sys.mjs", + FormAutofillNameUtils: + "resource://gre/modules/shared/FormAutofillNameUtils.sys.mjs", + FormAutofillUtils: "resource://gre/modules/shared/FormAutofillUtils.sys.mjs", + OSKeyStore: "resource://gre/modules/OSKeyStore.sys.mjs", + PhoneNumber: "resource://autofill/phonenumberutils/PhoneNumber.sys.mjs", +}); + +XPCOMUtils.defineLazyModuleGetters(lazy, { + AutofillTelemetry: "resource://autofill/AutofillTelemetry.jsm", +}); + +const CryptoHash = Components.Constructor( + "@mozilla.org/security/hash;1", + "nsICryptoHash", + "initWithString" +); + +const STORAGE_SCHEMA_VERSION = 1; + +// NOTE: It's likely this number can never change. +// Please talk to the sync team before changing this! +// (And if it did ever change, it must never be "4" due to the reconcile hacks +// below which repairs credit-cards with version=4) +export const ADDRESS_SCHEMA_VERSION = 1; + +// Version 2: Bug 1486954 - Encrypt `cc-number` +// Version 3: Bug 1639795 - Update keystore name +// Version 4: (deprecated!!! See Bug 1812235): Bug 1667257 - Do not store `cc-type` field +// Next version should be 5 +// NOTE: It's likely this number can never change. +// Please talk to the sync team before changing this! +export const CREDIT_CARD_SCHEMA_VERSION = 3; + +const VALID_ADDRESS_FIELDS = [ + "given-name", + "additional-name", + "family-name", + "organization", + "street-address", + "address-level3", + "address-level2", + "address-level1", + "postal-code", + "country", + "tel", + "email", +]; + +const STREET_ADDRESS_COMPONENTS = [ + "address-line1", + "address-line2", + "address-line3", +]; + +const TEL_COMPONENTS = [ + "tel-country-code", + "tel-national", + "tel-area-code", + "tel-local", + "tel-local-prefix", + "tel-local-suffix", +]; + +const VALID_ADDRESS_COMPUTED_FIELDS = ["name", "country-name"].concat( + STREET_ADDRESS_COMPONENTS, + TEL_COMPONENTS +); + +const VALID_CREDIT_CARD_FIELDS = [ + "billingAddressGUID", + "cc-name", + "cc-number", + "cc-exp-month", + "cc-exp-year", + "cc-type", +]; + +const VALID_CREDIT_CARD_COMPUTED_FIELDS = [ + "cc-given-name", + "cc-additional-name", + "cc-family-name", + "cc-number-encrypted", + "cc-exp", +]; + +const INTERNAL_FIELDS = [ + "guid", + "version", + "timeCreated", + "timeLastUsed", + "timeLastModified", + "timesUsed", +]; + +function sha512(string) { + if (string == null) { + return null; + } + let encoder = new TextEncoder(); + let bytes = encoder.encode(string); + let hash = new CryptoHash("sha512"); + hash.update(bytes, bytes.length); + return hash.finish(/* base64 */ true); +} + +/** + * Class that manipulates records in a specified collection. + * + * Note that it is responsible for converting incoming data to a consistent + * format in the storage. For example, computed fields will be transformed to + * the original fields and 2-digit years will be calculated into 4 digits. + */ +class AutofillRecords { + /** + * Creates an AutofillRecords. + * + * @param {JSONFile} store + * An instance of JSONFile. + * @param {string} collectionName + * A key of "store.data". + * @param {Array.<string>} validFields + * A list containing non-metadata field names. + * @param {Array.<string>} validComputedFields + * A list containing computed field names. + * @param {number} schemaVersion + * The schema version for the new record. + */ + constructor( + store, + collectionName, + validFields, + validComputedFields, + schemaVersion + ) { + this.log = FormAutofill.defineLogGetter( + lazy, + "AutofillRecords:" + collectionName + ); + + this.VALID_FIELDS = validFields; + this.VALID_COMPUTED_FIELDS = validComputedFields; + + this._store = store; + this._collectionName = collectionName; + this._schemaVersion = schemaVersion; + + this._initialize(); + + Services.obs.addObserver(this, "formautofill-storage-changed"); + } + + _initialize() { + this._initializePromise = Promise.all( + this._data.map(async (record, index) => + this._migrateRecord(record, index) + ) + ).then(hasChangesArr => { + let dataHasChanges = hasChangesArr.includes(true); + if (dataHasChanges) { + this._store.saveSoon(); + } + }); + } + + observe(subject, topic, data) { + switch (topic) { + case "formautofill-storage-changed": + let collectionName = subject.wrappedJSObject.collectionName; + if (collectionName != this._collectionName) { + return; + } + const telemetryType = + subject.wrappedJSObject.collectionName == "creditCards" + ? lazy.AutofillTelemetry.CREDIT_CARD + : lazy.AutofillTelemetry.ADDRESS; + const count = this._data.filter(entry => !entry.deleted).length; + lazy.AutofillTelemetry.recordAutofillProfileCount(telemetryType, count); + break; + } + } + + /** + * Gets the schema version number. + * + * @returns {number} + * The current schema version number. + */ + get version() { + return this._schemaVersion; + } + + /** + * Gets the data of this collection. + * + * @returns {Array} + * The data object. + */ + get _data() { + return this._getData(); + } + + _getData() { + return this._store.data[this._collectionName]; + } + + // Ensures that we don't try to apply synced records with newer schema + // versions. This is a temporary measure to ensure we don't accidentally + // bump the schema version without a syncing strategy in place (bug 1377204). + _ensureMatchingVersion(record) { + if (record.version != this.version) { + throw new Error( + `Got unknown record version ${record.version}; want ${this.version}` + ); + } + } + + /** + * Initialize the records in the collection, resolves when the migration completes. + * + * @returns {Promise} + */ + initialize() { + return this._initializePromise; + } + + /** + * Adds a new record. + * + * @param {object} record + * The new record for saving. + * @param {object} options + * @param {boolean} [options.sourceSync = false] + * Did sync generate this addition? + * @returns {Promise<string>} + * The GUID of the newly added item.. + */ + async add(record, { sourceSync = false } = {}) { + let recordToSave = this._clone(record); + + if (sourceSync) { + // Remove tombstones for incoming items that were changed on another + // device. Local deletions always lose to avoid data loss. + let index = this._findIndexByGUID(recordToSave.guid, { + includeDeleted: true, + }); + if (index > -1) { + let existing = this._data[index]; + if (existing.deleted) { + this._data.splice(index, 1); + } else { + throw new Error(`Record ${recordToSave.guid} already exists`); + } + } + } else if (!recordToSave.deleted) { + this._normalizeRecord(recordToSave); + // _normalizeRecord shouldn't do any validation (throw) because in the + // `update` case it is called with partial records whereas + // `_validateFields` is called with a complete one. + this._validateFields(recordToSave); + + recordToSave.guid = this._generateGUID(); + recordToSave.version = this.version; + + // Metadata + let now = Date.now(); + recordToSave.timeCreated = now; + recordToSave.timeLastModified = now; + recordToSave.timeLastUsed = 0; + recordToSave.timesUsed = 0; + } + + return this._saveRecord(recordToSave, { sourceSync }); + } + + async _saveRecord(record, { sourceSync = false } = {}) { + if (!record.guid) { + throw new Error("Record missing GUID"); + } + + let recordToSave; + if (record.deleted) { + if (this._findByGUID(record.guid, { includeDeleted: true })) { + throw new Error("a record with this GUID already exists"); + } + recordToSave = { + guid: record.guid, + timeLastModified: record.timeLastModified || Date.now(), + deleted: true, + }; + } else { + this._ensureMatchingVersion(record); + recordToSave = record; + await this.computeFields(recordToSave); + } + + if (sourceSync) { + let sync = this._getSyncMetaData(recordToSave, true); + sync.changeCounter = 0; + } + + this._data.push(recordToSave); + + this.updateUseCountTelemetry(); + + this._store.saveSoon(); + + Services.obs.notifyObservers( + { + wrappedJSObject: { + sourceSync, + guid: record.guid, + collectionName: this._collectionName, + }, + }, + "formautofill-storage-changed", + "add" + ); + return recordToSave.guid; + } + + _generateGUID() { + let guid; + while (!guid || this._findByGUID(guid)) { + guid = Services.uuid + .generateUUID() + .toString() + .replace(/[{}-]/g, "") + .substring(0, 12); + } + return guid; + } + + /** + * Update the specified record. + * + * @param {string} guid + * Indicates which record to update. + * @param {object} record + * The new record used to overwrite the old one. + * @param {Promise<boolean>} [preserveOldProperties = false] + * Preserve old record's properties if they don't exist in new record. + */ + async update(guid, record, preserveOldProperties = false) { + this.log.debug(`update: ${guid}`); + + let recordFoundIndex = this._findIndexByGUID(guid); + if (recordFoundIndex == -1) { + throw new Error("No matching record."); + } + + // Clone the record before modifying it to avoid exposing incomplete changes. + let recordFound = this._clone(this._data[recordFoundIndex]); + await this._stripComputedFields(recordFound); + + let recordToUpdate = this._clone(record); + this._normalizeRecord(recordToUpdate, true); + + let hasValidField = false; + for (let field of this.VALID_FIELDS) { + let oldValue = recordFound[field]; + let newValue = recordToUpdate[field]; + + // Resume the old field value in the perserve case + if (preserveOldProperties && newValue === undefined) { + newValue = oldValue; + } + + if (newValue === undefined || newValue === "") { + delete recordFound[field]; + } else { + hasValidField = true; + recordFound[field] = newValue; + } + + this._maybeStoreLastSyncedField(recordFound, field, oldValue); + } + + if (!hasValidField) { + throw new Error("Record contains no valid field."); + } + + // _normalizeRecord above is called with the `record` argument provided to + // `update` which may not contain all resulting fields when + // `preserveOldProperties` is used. This means we need to validate for + // missing fields after we compose the record (`recordFound`) with the stored + // record like we do in the loop above. + this._validateFields(recordFound); + + recordFound.timeLastModified = Date.now(); + let syncMetadata = this._getSyncMetaData(recordFound); + if (syncMetadata) { + syncMetadata.changeCounter += 1; + } + + await this.computeFields(recordFound); + this._data[recordFoundIndex] = recordFound; + + this._store.saveSoon(); + + Services.obs.notifyObservers( + { + wrappedJSObject: { + guid, + collectionName: this._collectionName, + }, + }, + "formautofill-storage-changed", + "update" + ); + } + + /** + * Notifies the storage of the use of the specified record, so we can update + * the metadata accordingly. This does not bump the Sync change counter, since + * we don't sync `timesUsed` or `timeLastUsed`. + * + * @param {string} guid + * Indicates which record to be notified. + */ + notifyUsed(guid) { + this.log.debug("notifyUsed:", guid); + + let recordFound = this._findByGUID(guid); + if (!recordFound) { + throw new Error("No matching record."); + } + + recordFound.timesUsed++; + recordFound.timeLastUsed = Date.now(); + + this.updateUseCountTelemetry(); + + this._store.saveSoon(); + Services.obs.notifyObservers( + { + wrappedJSObject: { + guid, + collectionName: this._collectionName, + }, + }, + "formautofill-storage-changed", + "notifyUsed" + ); + } + + updateUseCountTelemetry() { + const telemetryType = + this._collectionName == "creditCards" + ? lazy.AutofillTelemetry.CREDIT_CARD + : lazy.AutofillTelemetry.ADDRESS; + let records = this._data.filter(r => !r.deleted); + lazy.AutofillTelemetry.recordNumberOfUse(telemetryType, records); + } + + /** + * Removes the specified record. No error occurs if the record isn't found. + * + * @param {string} guid + * Indicates which record to remove. + * @param {object} options + * @param {boolean} [options.sourceSync = false] + * Did Sync generate this removal? + */ + remove(guid, { sourceSync = false } = {}) { + this.log.debug("remove:", guid); + + if (sourceSync) { + this._removeSyncedRecord(guid); + } else { + let index = this._findIndexByGUID(guid, { includeDeleted: false }); + if (index == -1) { + this.log.warn("attempting to remove non-existing entry", guid); + return; + } + let existing = this._data[index]; + if (existing.deleted) { + return; // already a tombstone - don't touch it. + } + let existingSync = this._getSyncMetaData(existing); + if (existingSync) { + // existing sync metadata means it has been synced. This means we must + // leave a tombstone behind. + this._data[index] = { + guid, + timeLastModified: Date.now(), + deleted: true, + _sync: existingSync, + }; + existingSync.changeCounter++; + } else { + // If there's no sync meta-data, this record has never been synced, so + // we can delete it. + this._data.splice(index, 1); + } + } + + this.updateUseCountTelemetry(); + + this._store.saveSoon(); + Services.obs.notifyObservers( + { + wrappedJSObject: { + sourceSync, + guid, + collectionName: this._collectionName, + }, + }, + "formautofill-storage-changed", + "remove" + ); + } + + /** + * Returns the record with the specified GUID. + * + * @param {string} guid + * Indicates which record to retrieve. + * @param {object} options + * @param {boolean} [options.rawData = false] + * Returns a raw record without modifications and the computed fields + * (this includes private fields) + * @returns {Promise<object>} + * A clone of the record. + */ + async get(guid, { rawData = false } = {}) { + this.log.debug(`get: ${guid}`); + + let recordFound = this._findByGUID(guid); + if (!recordFound) { + return null; + } + + // The record is cloned to avoid accidental modifications from outside. + let clonedRecord = this._cloneAndCleanUp(recordFound); + if (rawData) { + await this._stripComputedFields(clonedRecord); + } else { + this._recordReadProcessor(clonedRecord); + } + return clonedRecord; + } + + /** + * Returns all records. + * + * @param {object} options + * @param {boolean} [options.rawData = false] + * Returns raw records without modifications and the computed fields. + * @param {boolean} [options.includeDeleted = false] + * Also return any tombstone records. + * @returns {Promise<Array.<object>>} + * An array containing clones of all records. + */ + async getAll({ rawData = false, includeDeleted = false } = {}) { + this.log.debug(`getAll. includeDeleted = ${includeDeleted}`); + + let records = this._data.filter(r => !r.deleted || includeDeleted); + // Records are cloned to avoid accidental modifications from outside. + let clonedRecords = records.map(r => this._cloneAndCleanUp(r)); + await Promise.all( + clonedRecords.map(async record => { + if (rawData) { + await this._stripComputedFields(record); + } else { + this._recordReadProcessor(record); + } + }) + ); + return clonedRecords; + } + + /** + * Return all saved field names in the collection. + * + * @returns {Promise<Set>} Set containing saved field names. + */ + async getSavedFieldNames() { + this.log.debug("getSavedFieldNames"); + + let records = this._data.filter(r => !r.deleted); + records + .map(record => this._cloneAndCleanUp(record)) + .forEach(record => this._recordReadProcessor(record)); + + let fieldNames = new Set(); + for (let record of records) { + for (let fieldName of Object.keys(record)) { + if (INTERNAL_FIELDS.includes(fieldName) || !record[fieldName]) { + continue; + } + fieldNames.add(fieldName); + } + } + + return fieldNames; + } + + /** + * Functions intended to be used in the support of Sync. + */ + + /** + * Stores a hash of the last synced value for a field in a locally updated + * record. We use this value to rebuild the shared parent, or base, when + * reconciling incoming records that may have changed on another device. + * + * Storing the hash of the values that we last wrote to the Sync server lets + * us determine if a remote change conflicts with a local change. If the + * hashes for the base, current local value, and remote value all differ, we + * have a conflict. + * + * These fields are not themselves synced, and will be removed locally as + * soon as we have successfully written the record to the Sync server - so + * it is expected they will not remain for long, as changes which cause a + * last synced field to be written will itself cause a sync. + * + * We also skip this for updates made by Sync, for internal fields, for + * records that haven't been uploaded yet, and for fields which have already + * been changed since the last sync. + * + * @param {object} record + * The updated local record. + * @param {string} field + * The field name. + * @param {string} lastSyncedValue + * The last synced field value. + */ + _maybeStoreLastSyncedField(record, field, lastSyncedValue) { + let sync = this._getSyncMetaData(record); + if (!sync) { + // The record hasn't been uploaded yet, so we can't end up with merge + // conflicts. + return; + } + let alreadyChanged = field in sync.lastSyncedFields; + if (alreadyChanged) { + // This field was already changed multiple times since the last sync. + return; + } + let newValue = record[field]; + if (lastSyncedValue != newValue) { + sync.lastSyncedFields[field] = sha512(lastSyncedValue); + } + } + + /** + * Attempts a three-way merge between a changed local record, an incoming + * remote record, and the shared parent that we synthesize from the last + * synced fields - see _maybeStoreLastSyncedField. + * + * @param {object} strippedLocalRecord + * The changed local record, currently in storage. Computed fields + * are stripped. + * @param {object} remoteRecord + * The remote record. + * @returns {object | null} + * The merged record, or `null` if there are conflicts and the + * records can't be merged. + */ + _mergeSyncedRecords(strippedLocalRecord, remoteRecord) { + let sync = this._getSyncMetaData(strippedLocalRecord, true); + + // Copy all internal fields from the remote record. We'll update their + // values in `_replaceRecordAt`. + let mergedRecord = {}; + for (let field of INTERNAL_FIELDS) { + if (remoteRecord[field] != null) { + mergedRecord[field] = remoteRecord[field]; + } + } + + for (let field of this.VALID_FIELDS) { + let isLocalSame = false; + let isRemoteSame = false; + if (field in sync.lastSyncedFields) { + // If the field has changed since the last sync, compare hashes to + // determine if the local and remote values are different. Hashing is + // expensive, but we don't expect this to happen frequently. + let lastSyncedValue = sync.lastSyncedFields[field]; + isLocalSame = lastSyncedValue == sha512(strippedLocalRecord[field]); + isRemoteSame = lastSyncedValue == sha512(remoteRecord[field]); + } else { + // Otherwise, if the field hasn't changed since the last sync, we know + // it's the same locally. + isLocalSame = true; + isRemoteSame = strippedLocalRecord[field] == remoteRecord[field]; + } + + let value; + if (isLocalSame && isRemoteSame) { + // Local and remote are the same; doesn't matter which one we pick. + value = strippedLocalRecord[field]; + } else if (isLocalSame && !isRemoteSame) { + value = remoteRecord[field]; + } else if (!isLocalSame && isRemoteSame) { + // We don't need to bump the change counter when taking the local + // change, because the counter must already be > 0 if we're attempting + // a three-way merge. + value = strippedLocalRecord[field]; + } else if (strippedLocalRecord[field] == remoteRecord[field]) { + // Shared parent doesn't match either local or remote, but the values + // are identical, so there's no conflict. + value = strippedLocalRecord[field]; + } else { + // Both local and remote changed to different values. We'll need to fork + // the local record to resolve the conflict. + return null; + } + + if (value != null) { + mergedRecord[field] = value; + } + } + + // When merging records, we shouldn't persist any unknown fields on the local and instead + // rely on the remote for unknown fields, so we filter the fields we know and keep the rest + Object.keys(remoteRecord) + .filter( + key => + !this.VALID_FIELDS.includes(key) && !INTERNAL_FIELDS.includes(key) + ) + .forEach(key => (mergedRecord[key] = remoteRecord[key])); + return mergedRecord; + } + + /** + * Replaces a local record with a remote or merged record, copying internal + * fields and Sync metadata. + * + * @param {number} index + * @param {object} remoteRecord + * @param {object} options + * @param {Promise<boolean>} [options.keepSyncMetadata = false] + * Should we copy Sync metadata? This is true if `remoteRecord` is a + * merged record with local changes that we need to upload. Passing + * `keepSyncMetadata` retains the record's change counter and + * last synced fields, so that we don't clobber the local change if + * the sync is interrupted after the record is merged, but before + * it's uploaded. + */ + async _replaceRecordAt( + index, + remoteRecord, + { keepSyncMetadata = false } = {} + ) { + let localRecord = this._data[index]; + let newRecord = this._clone(remoteRecord); + + await this._stripComputedFields(newRecord); + + this._data[index] = newRecord; + + if (keepSyncMetadata) { + // It's safe to move the Sync metadata from the old record to the new + // record, since we always clone records when we return them, and we + // never hand out references to the metadata object via public methods. + newRecord._sync = localRecord._sync; + } else { + // As a side effect, `_getSyncMetaData` marks the record as syncing if the + // existing `localRecord` is a dupe of `remoteRecord`, and we're replacing + // local with remote. + let sync = this._getSyncMetaData(newRecord, true); + sync.changeCounter = 0; + } + + if ( + !newRecord.timeCreated || + localRecord.timeCreated < newRecord.timeCreated + ) { + newRecord.timeCreated = localRecord.timeCreated; + } + + if ( + !newRecord.timeLastModified || + localRecord.timeLastModified > newRecord.timeLastModified + ) { + newRecord.timeLastModified = localRecord.timeLastModified; + } + + // Copy local-only fields from the existing local record. + for (let field of ["timeLastUsed", "timesUsed"]) { + if (localRecord[field] != null) { + newRecord[field] = localRecord[field]; + } + } + + await this.computeFields(newRecord); + } + + /** + * Clones a local record, giving the clone a new GUID and Sync metadata. The + * original record remains unchanged in storage. + * + * @param {object} strippedLocalRecord + * The local record. Computed fields are stripped. + * @returns {string} + * A clone of the local record with a new GUID. + */ + async _forkLocalRecord(strippedLocalRecord) { + let forkedLocalRecord = this._cloneAndCleanUp(strippedLocalRecord); + forkedLocalRecord.guid = this._generateGUID(); + + // Give the record fresh Sync metadata and bump its change counter as a + // side effect. This also excludes the forked record from de-duping on the + // next sync, if the current sync is interrupted before the record can be + // uploaded. + this._getSyncMetaData(forkedLocalRecord, true); + + await this.computeFields(forkedLocalRecord); + this._data.push(forkedLocalRecord); + + return forkedLocalRecord; + } + + /** + * Reconciles an incoming remote record into the matching local record. This + * method is only used by Sync; other callers should use `merge`. + * + * @param {object} remoteRecord + * The incoming record. `remoteRecord` must not be a tombstone, and + * must have a matching local record with the same GUID. Use + * `add` to insert remote records that don't exist locally, and + * `remove` to apply remote tombstones. + * @returns {Promise<object>} + * A `{forkedGUID}` tuple. `forkedGUID` is `null` if the merge + * succeeded without conflicts, or a new GUID referencing the + * existing locally modified record if the conflicts could not be + * resolved. + */ + async reconcile(remoteRecord) { + this._ensureMatchingVersion(remoteRecord); + if (remoteRecord.deleted) { + throw new Error(`Can't reconcile tombstone ${remoteRecord.guid}`); + } + + let localIndex = this._findIndexByGUID(remoteRecord.guid); + if (localIndex < 0) { + throw new Error(`Record ${remoteRecord.guid} not found`); + } + + let localRecord = this._data[localIndex]; + let sync = this._getSyncMetaData(localRecord, true); + + let forkedGUID = null; + + // NOTE: This implies a credit-card - so it's critical ADDRESS_SCHEMA_VERSION + // never equals 4 while this code exists! + let requiresForceUpdate = + localRecord.version != remoteRecord.version && remoteRecord.version == 4; + + if (requiresForceUpdate) { + // Another desktop device that is still using version=4 has created or + // modified a remote record. Here we downgrade it to version=3 so we can + // treat it normally, then cause it to be re-uploaded so other desktop + // or mobile devices can still see it. + // That device still using version=4 *will* again see it, and again + // upgrade it, but thankfully that 3->4 migration doesn't force a reupload + // of all records, or we'd be going back and forward on every sync. + // Once that version=4 device gets updated to roll back to version=3, it + // will then yet again re-upload it, this time with version=3, but the + // content will be the same here, so everything should work out in the end. + // + // If we just ignored this incoming record, it would remain on the server + // with version=4. If the device that wrote that went away (ie, never + // synced again) nothing would ever repair it back to 3, which would + // be bad because mobile would remain broken until the user edited the + // card somewhere. + remoteRecord = await this._computeMigratedRecord(remoteRecord); + } + if (sync.changeCounter === 0) { + // Local not modified. Replace local with remote. + await this._replaceRecordAt(localIndex, remoteRecord, { + keepSyncMetadata: false, + }); + } else { + let strippedLocalRecord = this._clone(localRecord); + await this._stripComputedFields(strippedLocalRecord); + + let mergedRecord = this._mergeSyncedRecords( + strippedLocalRecord, + remoteRecord + ); + if (mergedRecord) { + // Local and remote modified, but we were able to merge. Replace the + // local record with the merged record. + await this._replaceRecordAt(localIndex, mergedRecord, { + keepSyncMetadata: true, + }); + } else { + // Merge conflict. Fork the local record, then replace the original + // with the merged record. + let forkedLocalRecord = await this._forkLocalRecord( + strippedLocalRecord + ); + forkedGUID = forkedLocalRecord.guid; + await this._replaceRecordAt(localIndex, remoteRecord, { + keepSyncMetadata: false, + }); + } + } + + if (requiresForceUpdate) { + // The incoming record was version=4 and we want to re-upload it as version=3. + // We need to reach directly into self._data[] so we can poke at the + // sync metadata directly. + let indexToUpdate = this._findIndexByGUID(remoteRecord.guid); + let toUpdate = this._data[indexToUpdate]; + this._getSyncMetaData(toUpdate, true).changeCounter += 1; + this.log.info( + `Flagging record ${toUpdate.guid} for re-upload after record version downgrade` + ); + } + + this._store.saveSoon(); + Services.obs.notifyObservers( + { + wrappedJSObject: { + sourceSync: true, + guid: remoteRecord.guid, + forkedGUID, + collectionName: this._collectionName, + }, + }, + "formautofill-storage-changed", + "reconcile" + ); + + return { forkedGUID }; + } + + _removeSyncedRecord(guid) { + let index = this._findIndexByGUID(guid, { includeDeleted: true }); + if (index == -1) { + // Removing a record we don't know about. It may have been synced and + // removed by another device before we saw it. Store the tombstone in + // case the server is later wiped and we need to reupload everything. + let tombstone = { + guid, + timeLastModified: Date.now(), + deleted: true, + }; + + let sync = this._getSyncMetaData(tombstone, true); + sync.changeCounter = 0; + this._data.push(tombstone); + return; + } + + let existing = this._data[index]; + let sync = this._getSyncMetaData(existing, true); + if (sync.changeCounter > 0) { + // Deleting a record with unsynced local changes. To avoid potential + // data loss, we ignore the deletion in favor of the changed record. + this.log.info( + "Ignoring deletion for record with local changes", + existing + ); + return; + } + + if (existing.deleted) { + this.log.info("Ignoring deletion for tombstone", existing); + return; + } + + // Removing a record that's not changed locally, and that's not already + // deleted. Replace the record with a synced tombstone. + this._data[index] = { + guid, + timeLastModified: Date.now(), + deleted: true, + _sync: sync, + }; + } + + /** + * Provide an object that describes the changes to sync. + * + * This is called at the start of the sync process to determine what needs + * to be updated on the server. As the server is updated, sync will update + * entries in the returned object, and when sync is complete it will pass + * the object to pushSyncChanges, which will apply the changes to the store. + * + * @returns {object} + * An object describing the changes to sync. + */ + pullSyncChanges() { + let changes = {}; + + let profiles = this._data; + for (let profile of profiles) { + let sync = this._getSyncMetaData(profile, true); + if (sync.changeCounter < 1) { + if (sync.changeCounter != 0) { + this.log.error("negative change counter", profile); + } + continue; + } + changes[profile.guid] = { + profile, + counter: sync.changeCounter, + modified: profile.timeLastModified, + synced: false, + }; + } + this._store.saveSoon(); + + return changes; + } + + /** + * Apply the metadata changes made by Sync. + * + * This is called with metadata about what was synced - see pullSyncChanges. + * + * @param {object} changes + * The possibly modified object obtained via pullSyncChanges. + */ + pushSyncChanges(changes) { + for (let [guid, { counter, synced }] of Object.entries(changes)) { + if (!synced) { + continue; + } + let recordFound = this._findByGUID(guid, { includeDeleted: true }); + if (!recordFound) { + this.log.warn("No profile found to persist changes for guid " + guid); + continue; + } + let sync = this._getSyncMetaData(recordFound, true); + sync.changeCounter = Math.max(0, sync.changeCounter - counter); + if (sync.changeCounter === 0) { + // Clear the shared parent fields once we've uploaded all pending + // changes, since the server now matches what we have locally. + sync.lastSyncedFields = {}; + } + } + this._store.saveSoon(); + } + + /** + * Reset all sync metadata for all items. + * + * This is called when Sync is disconnected from this device. All sync + * metadata for all items is removed. + */ + resetSync() { + for (let record of this._data) { + delete record._sync; + } + // XXX - we should probably also delete all tombstones? + this.log.info("All sync metadata was reset"); + } + + /** + * Changes the GUID of an item. This should be called only by Sync. There + * must be an existing record with oldID and it must never have been synced + * or an error will be thrown. There must be no existing record with newID. + * + * No tombstone will be created for the old GUID - we check it hasn't + * been synced, so no tombstone is necessary. + * + * @param {string} oldID + * GUID of the existing item to change the GUID of. + * @param {string} newID + * The new GUID for the item. + */ + changeGUID(oldID, newID) { + this.log.debug("changeGUID: ", oldID, newID); + if (oldID == newID) { + throw new Error("changeGUID: old and new IDs are the same"); + } + if (this._findIndexByGUID(newID) >= 0) { + throw new Error("changeGUID: record with destination id exists already"); + } + + let index = this._findIndexByGUID(oldID); + let profile = this._data[index]; + if (!profile) { + throw new Error("changeGUID: no source record"); + } + if (this._getSyncMetaData(profile)) { + throw new Error("changeGUID: existing record has already been synced"); + } + + profile.guid = newID; + + this._store.saveSoon(); + } + + // Used to get, and optionally create, sync metadata. Brand new records will + // *not* have sync meta-data - it will be created when they are first + // synced. + _getSyncMetaData(record, forceCreate = false) { + if (!record._sync && forceCreate) { + // create default metadata and indicate we need to save. + record._sync = { + changeCounter: 1, + lastSyncedFields: {}, + }; + this._store.saveSoon(); + } + return record._sync; + } + + /** + * Finds a local record with matching common fields and a different GUID. + * Sync uses this method to find and update unsynced local records with + * fields that match incoming remote records. This avoids creating + * duplicate profiles with the same information. + * + * @param {object} remoteRecord + * The remote record. + * @returns {Promise<string|null>} + * The GUID of the matching local record, or `null` if no records + * match. + */ + async findDuplicateGUID(remoteRecord) { + if (!remoteRecord.guid) { + throw new Error("Record missing GUID"); + } + this._ensureMatchingVersion(remoteRecord); + if (remoteRecord.deleted) { + // Tombstones don't carry enough info to de-dupe, and we should have + // handled them separately when applying the record. + throw new Error("Tombstones can't have duplicates"); + } + let localRecords = this._data; + for (let localRecord of localRecords) { + if (localRecord.deleted) { + continue; + } + if (localRecord.guid == remoteRecord.guid) { + throw new Error(`Record ${remoteRecord.guid} already exists`); + } + if (this._getSyncMetaData(localRecord)) { + // This local record has already been uploaded, so it can't be a dupe of + // another incoming item. + continue; + } + + // Ignore computed fields when matching records as they aren't synced at all. + let strippedLocalRecord = this._clone(localRecord); + await this._stripComputedFields(strippedLocalRecord); + + let keys = new Set(Object.keys(remoteRecord)); + for (let key of Object.keys(strippedLocalRecord)) { + keys.add(key); + } + // Ignore internal fields when matching records. Internal fields are synced, + // but almost certainly have different values than the local record, and + // we'll update them in `reconcile`. + for (let field of INTERNAL_FIELDS) { + keys.delete(field); + } + if (!keys.size) { + // This shouldn't ever happen; a valid record will always have fields + // that aren't computed or internal. Sync can't do anything about that, + // so we ignore the dubious local record instead of throwing. + continue; + } + let same = true; + for (let key of keys) { + // For now, we ensure that both (or neither) records have the field + // with matching values. This doesn't account for the version yet + // (bug 1377204). + same = + key in strippedLocalRecord == key in remoteRecord && + strippedLocalRecord[key] == remoteRecord[key]; + if (!same) { + break; + } + } + if (same) { + return strippedLocalRecord.guid; + } + } + return null; + } + + /** + * Internal helper functions. + */ + + _clone(record) { + return Object.assign({}, record); + } + + _cloneAndCleanUp(record) { + let result = {}; + for (let key in record) { + // Do not expose hidden fields and fields with empty value (mainly used + // as placeholders of the computed fields). + if (!key.startsWith("_") && record[key] !== "") { + result[key] = record[key]; + } + } + return result; + } + + _findByGUID(guid, { includeDeleted = false } = {}) { + let found = this._findIndexByGUID(guid, { includeDeleted }); + return found < 0 ? undefined : this._data[found]; + } + + _findIndexByGUID(guid, { includeDeleted = false } = {}) { + return this._data.findIndex(record => { + return record.guid == guid && (!record.deleted || includeDeleted); + }); + } + + async _migrateRecord(record, index) { + let hasChanges = false; + + if (record.deleted) { + return hasChanges; + } + + if (!record.version || isNaN(record.version) || record.version < 1) { + this.log.warn("Invalid record version:", record.version); + + // Force to run the migration. + record.version = 0; + } + + if (this._isMigrationNeeded(record.version)) { + hasChanges = true; + + record = await this._computeMigratedRecord(record); + + if (record.deleted) { + // record is deleted by _computeMigratedRecord(), + // go ahead and put it in the store. + this._data[index] = record; + return hasChanges; + } + + // Compute the computed fields before putting it to store. + await this.computeFields(record); + this._data[index] = record; + + return hasChanges; + } + + hasChanges |= await this.computeFields(record); + return hasChanges; + } + + _normalizeRecord(record, preserveEmptyFields = false) { + this._normalizeFields(record); + + for (let key in record) { + if (!this.VALID_FIELDS.includes(key)) { + // Though we allow unknown fields, certain fields are still protected + // from being changed + if (INTERNAL_FIELDS.includes(key)) { + throw new Error(`"${key}" is not a valid field.`); + } else { + // We shouldn't try to normalize unknown fields. We'll just roundtrip them + this.log.warn(`${key} is not a known field. Skipping normalization.`); + continue; + } + } + if (typeof record[key] !== "string" && typeof record[key] !== "number") { + throw new Error( + `"${key}" contains invalid data type: ${typeof record[key]}` + ); + } + if (!preserveEmptyFields && record[key] === "") { + delete record[key]; + } + } + + if (!Object.keys(record).length) { + throw new Error("Record contains no valid field."); + } + } + + /** + * Merge the record if storage has multiple mergeable records. + * + * @param {object} targetRecord + * The record for merge. + * @param {boolean} [strict = false] + * In strict merge mode, we'll treat the subset record with empty field + * as unable to be merged, but mergeable if in non-strict mode. + * @returns {Array.<string>} + * Return an array of the merged GUID string. + */ + async mergeToStorage(targetRecord, strict = false) { + let mergedGUIDs = []; + for (let record of this._data) { + if ( + !record.deleted && + (await this.mergeIfPossible(record.guid, targetRecord, strict)) + ) { + mergedGUIDs.push(record.guid); + } + } + this.log.debug( + "Existing records matching and merging count is", + mergedGUIDs.length + ); + return mergedGUIDs; + } + + /** + * Unconditionally remove all data and tombstones for this collection. + */ + removeAll({ sourceSync = false } = {}) { + this._store.data[this._collectionName] = []; + this._store.saveSoon(); + Services.obs.notifyObservers( + { + wrappedJSObject: { + sourceSync, + collectionName: this._collectionName, + }, + }, + "formautofill-storage-changed", + "removeAll" + ); + } + + _isMigrationNeeded(recordVersion) { + return recordVersion < this.version; + } + + /** + * Strip the computed fields based on the record version. + * + * @param {object} record The record to migrate + * @returns {object} Migrated record. + * Record is always cloned, with version updated, + * with computed fields stripped. + * Could be a tombstone record, if the record + * should be discorded. + */ + async _computeMigratedRecord(record) { + if (!record.deleted) { + record = this._clone(record); + await this._stripComputedFields(record); + record.version = this.version; + } + return record; + } + + async _stripComputedFields(record) { + this.VALID_COMPUTED_FIELDS.forEach(field => delete record[field]); + } + + // An interface to be inherited. + _recordReadProcessor(record) {} + + // An interface to be inherited. + async computeFields(record) {} + + /** + * An interface to be inherited to mutate the argument to normalize it. + * + * @param {object} partialRecord containing the record passed by the consumer of + * storage and in the case of `update` with + * `preserveOldProperties` will only include the + * properties that the user is changing so the + * lack of a field doesn't mean that the record + * won't have that field. + */ + _normalizeFields(partialRecord) {} + + /** + * An interface to be inherited to validate that the complete record is + * consistent and isn't missing required fields. Overrides should throw for + * invalid records. + * + * @param {object} record containing the complete record that would be stored + * if this doesn't throw due to an error. + * @throws + */ + _validateFields(record) {} + + // An interface to be inherited. + async mergeIfPossible(guid, record, strict) {} +} + +export class AddressesBase extends AutofillRecords { + constructor(store) { + super( + store, + "addresses", + VALID_ADDRESS_FIELDS, + VALID_ADDRESS_COMPUTED_FIELDS, + ADDRESS_SCHEMA_VERSION + ); + } + + _recordReadProcessor(address) { + if (address.country && !FormAutofill.countries.has(address.country)) { + delete address.country; + delete address["country-name"]; + } + } + + async computeFields(address) { + // NOTE: Remember to bump the schema version number if any of the existing + // computing algorithm changes. (No need to bump when just adding new + // computed fields.) + + // NOTE: Computed fields should be always present in the storage no matter + // it's empty or not. + + let hasNewComputedFields = false; + + if (address.deleted) { + return hasNewComputedFields; + } + + // Compute name + if (!("name" in address)) { + let name = lazy.FormAutofillNameUtils.joinNameParts({ + given: address["given-name"], + middle: address["additional-name"], + family: address["family-name"], + }); + address.name = name; + hasNewComputedFields = true; + } + + // Compute address lines + if (!("address-line1" in address)) { + let streetAddress = []; + if (address["street-address"]) { + streetAddress = address["street-address"] + .split("\n") + .map(s => s.trim()); + } + for (let i = 0; i < 3; i++) { + address[`address-line${i + 1}`] = streetAddress[i] || ""; + } + if (streetAddress.length > 3) { + address["address-line3"] = lazy.FormAutofillUtils.toOneLineAddress( + streetAddress.slice(2) + ); + } + hasNewComputedFields = true; + } + + // Compute country name + if (!("country-name" in address)) { + if (address.country) { + try { + address["country-name"] = Services.intl.getRegionDisplayNames( + undefined, + [address.country] + ); + } catch (e) { + address["country-name"] = ""; + } + } else { + address["country-name"] = ""; + } + hasNewComputedFields = true; + } + + // Compute tel + if (!("tel-national" in address)) { + if (address.tel) { + let tel = lazy.PhoneNumber.Parse( + address.tel, + address.country || FormAutofill.DEFAULT_REGION + ); + if (tel) { + if (tel.countryCode) { + address["tel-country-code"] = tel.countryCode; + } + if (tel.nationalNumber) { + address["tel-national"] = tel.nationalNumber; + } + + // PhoneNumberUtils doesn't support parsing the components of a telephone + // number so we hard coded the parser for US numbers only. We will need + // to figure out how to parse numbers from other regions when we support + // new countries in the future. + if (tel.nationalNumber && tel.countryCode == "+1") { + let telComponents = tel.nationalNumber.match( + /(\d{3})((\d{3})(\d{4}))$/ + ); + if (telComponents) { + address["tel-area-code"] = telComponents[1]; + address["tel-local"] = telComponents[2]; + address["tel-local-prefix"] = telComponents[3]; + address["tel-local-suffix"] = telComponents[4]; + } + } + } else { + // Treat "tel" as "tel-national" directly if it can't be parsed. + address["tel-national"] = address.tel; + } + } + + TEL_COMPONENTS.forEach(c => { + address[c] = address[c] || ""; + }); + } + + return hasNewComputedFields; + } + + _normalizeFields(address) { + this._normalizeName(address); + this._normalizeAddress(address); + this._normalizeCountry(address); + this._normalizeTel(address); + } + + _normalizeName(address) { + if (address.name) { + let nameParts = lazy.FormAutofillNameUtils.splitName(address.name); + if (!address["given-name"] && nameParts.given) { + address["given-name"] = nameParts.given; + } + if (!address["additional-name"] && nameParts.middle) { + address["additional-name"] = nameParts.middle; + } + if (!address["family-name"] && nameParts.family) { + address["family-name"] = nameParts.family; + } + } + delete address.name; + } + + _normalizeAddress(address) { + if (STREET_ADDRESS_COMPONENTS.some(c => !!address[c])) { + // Treat "street-address" as "address-line1" if it contains only one line + // and "address-line1" is omitted. + if ( + !address["address-line1"] && + address["street-address"] && + !address["street-address"].includes("\n") + ) { + address["address-line1"] = address["street-address"]; + delete address["street-address"]; + } + + // Concatenate "address-line*" if "street-address" is omitted. + if (!address["street-address"]) { + address["street-address"] = STREET_ADDRESS_COMPONENTS.map( + c => address[c] + ) + .join("\n") + .replace(/\n+$/, ""); + } + } + STREET_ADDRESS_COMPONENTS.forEach(c => delete address[c]); + } + + _normalizeCountry(address) { + let country; + + if (address.country) { + country = address.country.toUpperCase(); + } else if (address["country-name"]) { + country = lazy.FormAutofillUtils.identifyCountryCode( + address["country-name"] + ); + } + + // Only values included in the region list will be saved. + let hasLocalizedName = false; + try { + if (country) { + let localizedName = Services.intl.getRegionDisplayNames(undefined, [ + country, + ]); + hasLocalizedName = localizedName != country; + } + } catch (e) {} + + if (country && hasLocalizedName) { + address.country = country; + } else { + delete address.country; + } + + delete address["country-name"]; + } + + _normalizeTel(address) { + if (address.tel || TEL_COMPONENTS.some(c => !!address[c])) { + lazy.FormAutofillUtils.compressTel(address); + + let possibleRegion = address.country || FormAutofill.DEFAULT_REGION; + let tel = lazy.PhoneNumber.Parse(address.tel, possibleRegion); + + if (tel && tel.internationalNumber) { + // Force to save numbers in E.164 format if parse success. + address.tel = tel.internationalNumber; + } + } + TEL_COMPONENTS.forEach(c => delete address[c]); + } + + /** + * Merge new address into the specified address if mergeable. + * + * @param {string} guid + * Indicates which address to merge. + * @param {object} address + * The new address used to merge into the old one. + * @param {boolean} strict + * In strict merge mode, we'll treat the subset record with empty field + * as unable to be merged, but mergeable if in non-strict mode. + * @returns {Promise<boolean>} + * Return true if address is merged into target with specific guid or false if not. + */ + async mergeIfPossible(guid, address, strict) { + throw Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED); + } + + compareAddressField(field, a, b, collator) { + switch (field) { + case "street-address": + let ret = lazy.FormAutofillUtils.compareStreetAddress(a, b, collator); + return ret; + // TODO: support other cases + default: + return a == b; + } + } + + /** + * Normalize the given record and return records that are either the same + * or is superset of the normalized given record. + * + * See the comments in `getDuplicateRecords` to see the difference between + * `getDuplicateRecords` and `getMatchRecords` + * + * @param {object} record + * The address entry for match checking. please make sure the + * record is normalized. + * @returns {object} + * Return the first matched record found in storage, null otherwise. + */ + async *getMatchRecords(record) { + const collators = lazy.FormAutofillUtils.getSearchCollators( + FormAutofill.DEFAULT_REGION + ); + + for (const recordInStorage of this._data) { + if ( + this.VALID_FIELDS.every( + field => + !record[field] || + this.compareAddressField( + field, + record[field], + recordInStorage[field], + collators + ) + ) + ) { + yield recordInStorage; + } + } + return null; + } + + /** + * Normalize the given record and return a duplicate address record in + * the storage. + * + * This is different from `getMatchRecords`, which ensures all the fields with + * value in the the record is equal to the returned record. + * + * @param {object} record + * The address entry for duplication checking. please make sure the + * record is normalized. + * @returns {object} + * Return the first duplicated record found in storage, null otherwise. + */ + async *getDuplicateRecords(record) { + const collators = lazy.FormAutofillUtils.getSearchCollators( + FormAutofill.DEFAULT_REGION + ); + + for (const recordInStorage of this._data) { + if ( + this.VALID_FIELDS.every( + field => + !record[field] || + !recordInStorage[field] || + this.compareAddressField( + field, + record[field], + recordInStorage[field], + collators + ) + ) + ) { + yield recordInStorage; + } + } + return null; + } +} + +export class CreditCardsBase extends AutofillRecords { + constructor(store) { + super( + store, + "creditCards", + VALID_CREDIT_CARD_FIELDS, + VALID_CREDIT_CARD_COMPUTED_FIELDS, + CREDIT_CARD_SCHEMA_VERSION + ); + } + + async computeFields(creditCard) { + // NOTE: Remember to bump the schema version number if any of the existing + // computing algorithm changes. (No need to bump when just adding new + // computed fields.) + + // NOTE: Computed fields should be always present in the storage no matter + // it's empty or not. + + let hasNewComputedFields = false; + + if (creditCard.deleted) { + return hasNewComputedFields; + } + + let type = lazy.CreditCard.getType(creditCard["cc-number"]); + if (type) { + creditCard["cc-type"] = type; + } + + // Compute split names + if (!("cc-given-name" in creditCard)) { + let nameParts = lazy.FormAutofillNameUtils.splitName( + creditCard["cc-name"] + ); + creditCard["cc-given-name"] = nameParts.given; + creditCard["cc-additional-name"] = nameParts.middle; + creditCard["cc-family-name"] = nameParts.family; + hasNewComputedFields = true; + } + + // Compute credit card expiration date + if (!("cc-exp" in creditCard)) { + if (creditCard["cc-exp-month"] && creditCard["cc-exp-year"]) { + creditCard["cc-exp"] = + String(creditCard["cc-exp-year"]) + + "-" + + String(creditCard["cc-exp-month"]).padStart(2, "0"); + } else { + creditCard["cc-exp"] = ""; + } + hasNewComputedFields = true; + } + + // Encrypt credit card number + await this._encryptNumber(creditCard); + + return hasNewComputedFields; + } + + async _encryptNumber(creditCard) { + throw Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED); + } + + _isMigrationNeeded(recordVersion) { + return ( + // version 4 is deprecated and is rolled back to version 3 + recordVersion == 4 || recordVersion < this.version + ); + } + + async _computeMigratedRecord(creditCard) { + if (creditCard.version <= 2) { + if (creditCard["cc-number-encrypted"]) { + // We cannot decrypt the data, so silently remove the record for + // the user. + if (!creditCard.deleted) { + this.log.warn( + "Removing version", + creditCard.version, + "credit card record to migrate to new encryption:", + creditCard.guid + ); + + // Replace the record with a tombstone record here, + // regardless of existence of sync metadata. + let existingSync = this._getSyncMetaData(creditCard); + creditCard = { + guid: creditCard.guid, + timeLastModified: Date.now(), + deleted: true, + }; + + if (existingSync) { + creditCard._sync = existingSync; + existingSync.changeCounter++; + } + } + } + } + + // Do not remove the migration code until we're sure no users have version 4 + // credit card records (created in Fx110 or Fx111) + if (creditCard.version == 4) { + // Version 4 is deprecated, so downgrade or upgrade to the current version + // Since the only change made in version 4 is deleting `cc-type` field, so + // nothing else need to be done here expect flagging sync needed + let existingSync = this._getSyncMetaData(creditCard); + if (existingSync) { + existingSync.changeCounter++; + } + } + + return super._computeMigratedRecord(creditCard); + } + + async _stripComputedFields(creditCard) { + if (creditCard["cc-number-encrypted"]) { + try { + creditCard["cc-number"] = await lazy.OSKeyStore.decrypt( + creditCard["cc-number-encrypted"] + ); + } catch (ex) { + if (ex.result == Cr.NS_ERROR_ABORT) { + throw ex; + } + // Quietly recover from encryption error, + // so existing credit card entry with undecryptable number + // can be updated. + } + } + await super._stripComputedFields(creditCard); + } + + _normalizeFields(creditCard) { + this._normalizeCCName(creditCard); + this._normalizeCCNumber(creditCard); + this._normalizeCCExpirationDate(creditCard); + } + + _normalizeCCName(creditCard) { + if ( + creditCard["cc-given-name"] || + creditCard["cc-additional-name"] || + creditCard["cc-family-name"] + ) { + if (!creditCard["cc-name"]) { + creditCard["cc-name"] = lazy.FormAutofillNameUtils.joinNameParts({ + given: creditCard["cc-given-name"], + middle: creditCard["cc-additional-name"], + family: creditCard["cc-family-name"], + }); + } + } + delete creditCard["cc-given-name"]; + delete creditCard["cc-additional-name"]; + delete creditCard["cc-family-name"]; + } + + _normalizeCCNumber(creditCard) { + if (!("cc-number" in creditCard)) { + return; + } + if (!lazy.CreditCard.isValidNumber(creditCard["cc-number"])) { + delete creditCard["cc-number"]; + return; + } + let card = new lazy.CreditCard({ number: creditCard["cc-number"] }); + creditCard["cc-number"] = card.number; + } + + _normalizeCCExpirationDate(creditCard) { + let normalizedExpiration = lazy.CreditCard.normalizeExpiration({ + expirationMonth: creditCard["cc-exp-month"], + expirationYear: creditCard["cc-exp-year"], + expirationString: creditCard["cc-exp"], + }); + if (normalizedExpiration.month) { + creditCard["cc-exp-month"] = normalizedExpiration.month; + } else { + delete creditCard["cc-exp-month"]; + } + if (normalizedExpiration.year) { + creditCard["cc-exp-year"] = normalizedExpiration.year; + } else { + delete creditCard["cc-exp-year"]; + } + delete creditCard["cc-exp"]; + } + + _validateFields(creditCard) { + if (!creditCard["cc-number"]) { + throw new Error("Missing/invalid cc-number"); + } + } + + _ensureMatchingVersion(record) { + if (!record.version || isNaN(record.version) || record.version < 1) { + throw new Error( + `Got invalid record version ${record.version}; want ${this.version}` + ); + } + + if (record.version == 4) { + // Version 4 is deprecated, we need to force downloading it from sync + // and let migration do the work to downgrade it back to the current version. + return true; + } else if (record.version < this.version) { + switch (record.version) { + case 1: + case 2: + // The difference between version 1 and 2 is only about the encryption + // method used for the cc-number-encrypted field. + // The difference between version 2 and 3 is the name of the OS + // key encryption record. + // As long as the record is already decrypted, it is safe to bump the + // version directly. + if (!record["cc-number-encrypted"]) { + record.version = this.version; + } else { + throw new Error( + "Could not migrate record version:", + record.version, + "->", + this.version + ); + } + break; + default: + throw new Error( + "Unknown credit card version to match: " + record.version + ); + } + } + + return super._ensureMatchingVersion(record); + } + + /** + * Find a match credit card record in storage that is either exactly the same + * as the given record or a superset of the given record. + * + * See the comments in `getDuplicateRecords` to see the difference between + * `getDuplicateRecords` and `getMatchRecords` + * + * @param {object} record + * The credit card for match checking. please make sure the + * record is normalized. + * @returns {object} + * Return the first matched record found in storage, null otherwise. + */ + async *getMatchRecords(record) { + for await (const recordInStorage of this.getDuplicateRecords(record)) { + const fields = this.VALID_FIELDS.filter(f => f != "cc-number"); + if ( + fields.every( + field => !record[field] || record[field] == recordInStorage[field] + ) + ) { + yield recordInStorage; + } + } + return null; + } + + /** + * Find a duplicate credit card record in the storage. + * + * A record is considered as a duplicate of another record when two records + * are the "same". This might be true even when some of their fields are + * different. For example, one record has the same credit card number but has + * different expiration date as the other record are still considered as + * "duplicate". + * This is different from `getMatchRecords`, which ensures all the fields with + * value in the the record is equal to the returned record. + * + * @param {object} record + * The credit card for duplication checking. please make sure the + * record is normalized. + * @returns {object} + * Return the first duplicated record found in storage, null otherwise. + */ + async *getDuplicateRecords(record) { + if (!record["cc-number"]) { + return null; + } + + for (const recordInStorage of this._data) { + if (recordInStorage.deleted) { + continue; + } + + const decrypted = await lazy.OSKeyStore.decrypt( + recordInStorage["cc-number-encrypted"], + false + ); + + if (decrypted == record["cc-number"]) { + yield recordInStorage; + } + } + return null; + } + + /** + * Merge new credit card into the specified record if cc-number is identical. + * (Note that credit card records always do non-strict merge.) + * + * @param {string} guid + * Indicates which credit card to merge. + * @param {object} creditCard + * The new credit card used to merge into the old one. + * @returns {boolean} + * Return true if credit card is merged into target with specific guid or false if not. + */ + async mergeIfPossible(guid, creditCard) { + throw Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED); + } +} + +export class FormAutofillStorageBase { + constructor(path) { + this._path = path; + this._initializePromise = null; + this.INTERNAL_FIELDS = INTERNAL_FIELDS; + } + + get version() { + return STORAGE_SCHEMA_VERSION; + } + + get addresses() { + return this.getAddresses(); + } + + get creditCards() { + return this.getCreditCards(); + } + + getAddresses() { + throw Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED); + } + + getCreditCards() { + throw Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED); + } + + /** + * Initialize storage to memory. + * + * @returns {Promise} When the operation finished successfully. + * @throws JavaScript exception. + */ + initialize() { + if (!this._initializePromise) { + this._store = this._initializeStore(); + this._initializePromise = this._store.load().then(() => { + let initializeAutofillRecords = [ + this.addresses.initialize(), + this.creditCards.initialize(), + ]; + return Promise.all(initializeAutofillRecords); + }); + } + return this._initializePromise; + } + + _initializeStore() { + throw Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED); + } + + // For test only. + _saveImmediately() { + return this._store._save(); + } + + _finalize() { + return this._store.finalize(); + } +} diff --git a/toolkit/components/formautofill/FormAutofillSync.sys.mjs b/toolkit/components/formautofill/FormAutofillSync.sys.mjs new file mode 100644 index 0000000000..d45c2c04ca --- /dev/null +++ b/toolkit/components/formautofill/FormAutofillSync.sys.mjs @@ -0,0 +1,386 @@ +/* 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 { + Changeset, + Store, + SyncEngine, + Tracker, +} from "resource://services-sync/engines.sys.mjs"; +import { CryptoWrapper } from "resource://services-sync/record.sys.mjs"; +import { Utils } from "resource://services-sync/util.sys.mjs"; + +import { SCORE_INCREMENT_XLARGE } from "resource://services-sync/constants.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + Log: "resource://gre/modules/Log.sys.mjs", + formAutofillStorage: "resource://autofill/FormAutofillStorage.sys.mjs", +}); + +// A helper to sanitize address and creditcard records suitable for logging. +export function sanitizeStorageObject(ob) { + if (!ob) { + return null; + } + const allowList = ["timeCreated", "timeLastUsed", "timeLastModified"]; + let result = {}; + for (let key of Object.keys(ob)) { + let origVal = ob[key]; + if (allowList.includes(key)) { + result[key] = origVal; + } else if (typeof origVal == "string") { + result[key] = "X".repeat(origVal.length); + } else { + result[key] = typeof origVal; // *shrug* + } + } + return result; +} + +export function AutofillRecord(collection, id) { + CryptoWrapper.call(this, collection, id); +} + +AutofillRecord.prototype = { + toEntry() { + return Object.assign( + { + guid: this.id, + }, + this.entry + ); + }, + + fromEntry(entry) { + this.id = entry.guid; + this.entry = entry; + // The GUID is already stored in record.id, so we nuke it from the entry + // itself to save a tiny bit of space. The formAutofillStorage clones profiles, + // so nuking in-place is OK. + delete this.entry.guid; + }, + + cleartextToString() { + // And a helper so logging a *Sync* record auto sanitizes. + let record = this.cleartext; + return JSON.stringify({ entry: sanitizeStorageObject(record.entry) }); + }, +}; +Object.setPrototypeOf(AutofillRecord.prototype, CryptoWrapper.prototype); + +// Profile data is stored in the "entry" object of the record. +Utils.deferGetSet(AutofillRecord, "cleartext", ["entry"]); + +function FormAutofillStore(name, engine) { + Store.call(this, name, engine); +} + +FormAutofillStore.prototype = { + _subStorageName: null, // overridden below. + _storage: null, + + get storage() { + if (!this._storage) { + this._storage = lazy.formAutofillStorage[this._subStorageName]; + } + return this._storage; + }, + + async getAllIDs() { + let result = {}; + for (let { guid } of await this.storage.getAll({ includeDeleted: true })) { + result[guid] = true; + } + return result; + }, + + async changeItemID(oldID, newID) { + this.storage.changeGUID(oldID, newID); + }, + + // Note: this function intentionally returns false in cases where we only have + // a (local) tombstone - and formAutofillStorage.get() filters them for us. + async itemExists(id) { + return Boolean(await this.storage.get(id)); + }, + + async applyIncoming(remoteRecord) { + if (remoteRecord.deleted) { + this._log.trace("Deleting record", remoteRecord); + this.storage.remove(remoteRecord.id, { sourceSync: true }); + return; + } + + if (await this.itemExists(remoteRecord.id)) { + // We will never get a tombstone here, so we are updating a real record. + await this._doUpdateRecord(remoteRecord); + return; + } + + // No matching local record. Try to dedupe a NEW local record. + let localDupeID = await this.storage.findDuplicateGUID( + remoteRecord.toEntry() + ); + if (localDupeID) { + this._log.trace( + `Deduping local record ${localDupeID} to remote`, + remoteRecord + ); + // Change the local GUID to match the incoming record, then apply the + // incoming record. + await this.changeItemID(localDupeID, remoteRecord.id); + await this._doUpdateRecord(remoteRecord); + return; + } + + // We didn't find a dupe, either, so must be a new record (or possibly + // a non-deleted version of an item we have a tombstone for, which add() + // handles for us.) + this._log.trace("Add record", remoteRecord); + let entry = remoteRecord.toEntry(); + await this.storage.add(entry, { sourceSync: true }); + }, + + async createRecord(id, collection) { + this._log.trace("Create record", id); + let record = new AutofillRecord(collection, id); + let entry = await this.storage.get(id, { + rawData: true, + }); + if (entry) { + record.fromEntry(entry); + } else { + // We should consider getting a more authortative indication it's actually deleted. + this._log.debug( + `Failed to get autofill record with id "${id}", assuming deleted` + ); + record.deleted = true; + } + return record; + }, + + async _doUpdateRecord(record) { + this._log.trace("Updating record", record); + + let entry = record.toEntry(); + let { forkedGUID } = await this.storage.reconcile(entry); + if (this._log.level <= lazy.Log.Level.Debug) { + let forkedRecord = forkedGUID ? await this.storage.get(forkedGUID) : null; + let reconciledRecord = await this.storage.get(record.id); + this._log.debug("Updated local record", { + forked: sanitizeStorageObject(forkedRecord), + updated: sanitizeStorageObject(reconciledRecord), + }); + } + }, + + // NOTE: Because we re-implement the incoming/reconcilliation logic we leave + // the |create|, |remove| and |update| methods undefined - the base + // implementation throws, which is what we want to happen so we can identify + // any places they are "accidentally" called. +}; +Object.setPrototypeOf(FormAutofillStore.prototype, Store.prototype); + +function FormAutofillTracker(name, engine) { + Tracker.call(this, name, engine); +} + +FormAutofillTracker.prototype = { + async observe(subject, topic, data) { + if (topic != "formautofill-storage-changed") { + return; + } + if ( + subject && + subject.wrappedJSObject && + subject.wrappedJSObject.sourceSync + ) { + return; + } + switch (data) { + case "add": + case "update": + case "remove": + this.score += SCORE_INCREMENT_XLARGE; + break; + default: + this._log.debug("unrecognized autofill notification", data); + break; + } + }, + + onStart() { + Services.obs.addObserver(this, "formautofill-storage-changed"); + }, + + onStop() { + Services.obs.removeObserver(this, "formautofill-storage-changed"); + }, +}; +Object.setPrototypeOf(FormAutofillTracker.prototype, Tracker.prototype); + +// This uses the same conventions as BookmarkChangeset in +// services/sync/modules/engines/bookmarks.js. Specifically, +// - "synced" means the item has already been synced (or we have another reason +// to ignore it), and should be ignored in most methods. +class AutofillChangeset extends Changeset { + constructor() { + super(); + } + + getModifiedTimestamp(id) { + throw new Error("Don't use timestamps to resolve autofill merge conflicts"); + } + + has(id) { + let change = this.changes[id]; + if (change) { + return !change.synced; + } + return false; + } + + delete(id) { + let change = this.changes[id]; + if (change) { + // Mark the change as synced without removing it from the set. We do this + // so that we can update FormAutofillStorage in `trackRemainingChanges`. + change.synced = true; + } + } +} + +function FormAutofillEngine(service, name) { + SyncEngine.call(this, name, service); +} + +FormAutofillEngine.prototype = { + // the priority for this engine is == addons, so will happen after bookmarks + // prefs and tabs, but before forms, history, etc. + syncPriority: 5, + + // We don't use SyncEngine.initialize() for this, as we initialize even if + // the engine is disabled, and we don't want to be the loader of + // FormAutofillStorage in this case. + async _syncStartup() { + await lazy.formAutofillStorage.initialize(); + await SyncEngine.prototype._syncStartup.call(this); + }, + + // We handle reconciliation in the store, not the engine. + async _reconcile() { + return true; + }, + + emptyChangeset() { + return new AutofillChangeset(); + }, + + async _uploadOutgoing() { + this._modified.replace(this._store.storage.pullSyncChanges()); + await SyncEngine.prototype._uploadOutgoing.call(this); + }, + + // Typically, engines populate the changeset before downloading records. + // However, we handle conflict resolution in the store, so we can wait + // to pull changes until we're ready to upload. + async pullAllChanges() { + return {}; + }, + + async pullNewChanges() { + return {}; + }, + + async trackRemainingChanges() { + this._store.storage.pushSyncChanges(this._modified.changes); + }, + + _deleteId(id) { + this._noteDeletedId(id); + }, + + async _resetClient() { + await lazy.formAutofillStorage.initialize(); + this._store.storage.resetSync(); + }, + + async _wipeClient() { + await lazy.formAutofillStorage.initialize(); + this._store.storage.removeAll({ sourceSync: true }); + }, +}; +Object.setPrototypeOf(FormAutofillEngine.prototype, SyncEngine.prototype); + +// The concrete engines + +function AddressesRecord(collection, id) { + AutofillRecord.call(this, collection, id); +} + +AddressesRecord.prototype = { + _logName: "Sync.Record.Addresses", +}; +Object.setPrototypeOf(AddressesRecord.prototype, AutofillRecord.prototype); + +function AddressesStore(name, engine) { + FormAutofillStore.call(this, name, engine); +} + +AddressesStore.prototype = { + _subStorageName: "addresses", +}; +Object.setPrototypeOf(AddressesStore.prototype, FormAutofillStore.prototype); + +export function AddressesEngine(service) { + FormAutofillEngine.call(this, service, "Addresses"); +} + +AddressesEngine.prototype = { + _trackerObj: FormAutofillTracker, + _storeObj: AddressesStore, + _recordObj: AddressesRecord, + + get prefName() { + return "addresses"; + }, +}; +Object.setPrototypeOf(AddressesEngine.prototype, FormAutofillEngine.prototype); + +function CreditCardsRecord(collection, id) { + AutofillRecord.call(this, collection, id); +} + +CreditCardsRecord.prototype = { + _logName: "Sync.Record.CreditCards", +}; +Object.setPrototypeOf(CreditCardsRecord.prototype, AutofillRecord.prototype); + +function CreditCardsStore(name, engine) { + FormAutofillStore.call(this, name, engine); +} + +CreditCardsStore.prototype = { + _subStorageName: "creditCards", +}; +Object.setPrototypeOf(CreditCardsStore.prototype, FormAutofillStore.prototype); + +export function CreditCardsEngine(service) { + FormAutofillEngine.call(this, service, "CreditCards"); +} + +CreditCardsEngine.prototype = { + _trackerObj: FormAutofillTracker, + _storeObj: CreditCardsStore, + _recordObj: CreditCardsRecord, + get prefName() { + return "creditcards"; + }, +}; +Object.setPrototypeOf( + CreditCardsEngine.prototype, + FormAutofillEngine.prototype +); diff --git a/toolkit/components/formautofill/Helpers.ios.mjs b/toolkit/components/formautofill/Helpers.ios.mjs new file mode 100644 index 0000000000..b634056e60 --- /dev/null +++ b/toolkit/components/formautofill/Helpers.ios.mjs @@ -0,0 +1,166 @@ +/* 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 { IOSAppConstants } from "resource://gre/modules/shared/Constants.ios.mjs"; +import Overrides from "resource://gre/modules/Overrides.ios.js"; + +/* eslint mozilla/use-isInstance: 0 */ +HTMLSelectElement.isInstance = element => element instanceof HTMLSelectElement; +HTMLInputElement.isInstance = element => element instanceof HTMLInputElement; +HTMLFormElement.isInstance = element => element instanceof HTMLFormElement; +ShadowRoot.isInstance = element => element instanceof ShadowRoot; + +HTMLElement.prototype.ownerGlobal = window; +HTMLInputElement.prototype.setUserInput = function (value) { + this.value = value; + this.dispatchEvent(new Event("input", { bubbles: true })); +}; + +// TODO: Bug 1828408. +// Use WeakRef API directly in our codebase instead of legacy Cu.getWeakReference. +window.Cu = class { + static getWeakReference(elements) { + const elementsWeakRef = new WeakRef(elements); + return { + get: () => elementsWeakRef.deref(), + }; + } +}; + +// Mimic the behavior of .getAutocompleteInfo() +// It should return an object with a fieldName property matching the autocomplete attribute +// only if it's a valid value from this list https://searchfox.org/mozilla-central/source/dom/base/AutocompleteFieldList.h#89-149 +// Also found here: https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/autocomplete +HTMLElement.prototype.getAutocompleteInfo = function () { + const autocomplete = this.getAttribute("autocomplete"); + + return { + fieldName: IOSAppConstants.validAutocompleteFields.includes(autocomplete) + ? autocomplete + : "", + }; +}; + +// Bug 1835024. Webkit doesn't support `checkVisibility` API +// https://drafts.csswg.org/cssom-view-1/#dom-element-checkvisibility +HTMLElement.prototype.checkVisibility = function (options) { + throw new Error(`Not implemented: WebKit doesn't support checkVisibility `); +}; + +// This function helps us debug better when an error occurs because a certain mock is missing +const withNotImplementedError = obj => + new Proxy(obj, { + get(target, prop) { + if (!Object.keys(target).includes(prop)) { + throw new Error( + `Not implemented: ${prop} doesn't exist in mocked object ` + ); + } + return Reflect.get(...arguments); + }, + }); + +// Webpack needs to be able to statically analyze require statements in order to build the dependency graph +// In order to require modules dynamically at runtime, we use require.context() to create a dynamic require +// that is still able to be parsed by Webpack at compile time. The "./" and ".mjs" tells webpack that files +// in the current directory ending with .mjs might be needed and should be added to the dependency graph. +// NOTE: This can't handle circular dependencies. A static import can be used in this case. +// https://webpack.js.org/guides/dependency-management/ +const internalModuleResolvers = { + resolveModule(moduleURI) { + // eslint-disable-next-line no-undef + const moduleResolver = require.context("./", false, /.mjs$/); + // Desktop code uses uris for importing modules of the form resource://gre/modules/<module_path> + // We only need the filename here + const moduleName = moduleURI.split("/").pop(); + const modulePath = + "./" + (Overrides.ModuleOverrides[moduleName] ?? moduleName); + return moduleResolver(modulePath); + }, + + resolveModules(obj, modules) { + for (const [exportName, moduleURI] of Object.entries(modules)) { + const resolvedModule = this.resolveModule(moduleURI); + obj[exportName] = resolvedModule?.[exportName]; + } + }, +}; + +// Define mock for XPCOMUtils +export const XPCOMUtils = withNotImplementedError({ + defineLazyGetter: (obj, prop, getFn) => { + obj[prop] = getFn?.(); + }, + defineLazyPreferenceGetter: ( + obj, + prop, + pref, + defaultValue = null, + onUpdate = null, + transform = val => val + ) => { + if (!Object.keys(IOSAppConstants.prefs).includes(pref)) { + throw Error(`Pref ${pref} is not defined.`); + } + obj[prop] = transform(IOSAppConstants.prefs[pref] ?? defaultValue); + }, + defineLazyModuleGetters(obj, modules) { + internalModuleResolvers.resolveModules(obj, modules); + }, +}); + +// eslint-disable-next-line no-shadow +export const ChromeUtils = withNotImplementedError({ + defineESModuleGetters(obj, modules) { + internalModuleResolvers.resolveModules(obj, modules); + }, + importESModule(moduleURI) { + return internalModuleResolvers.resolveModule(moduleURI); + }, +}); +window.ChromeUtils = ChromeUtils; + +// Define mock for Region.sys.mjs +export const Region = withNotImplementedError({ + home: "US", +}); + +// Define mock for OSKeyStore.sys.mjs +export const OSKeyStore = withNotImplementedError({ + ensureLoggedIn: () => true, +}); + +// Define mock for Services +// NOTE: Services is a global so we need to attach it to the window +// eslint-disable-next-line no-shadow +export const Services = withNotImplementedError({ + intl: withNotImplementedError({ + getAvailableLocaleDisplayNames: () => [], + getRegionDisplayNames: () => [], + }), + locale: withNotImplementedError({ isAppLocaleRTL: false }), + prefs: withNotImplementedError({ prefIsLocked: () => false }), + strings: withNotImplementedError({ + createBundle: () => + withNotImplementedError({ + GetStringFromName: () => "", + formatStringFromName: () => "", + }), + }), + uuid: withNotImplementedError({ generateUUID: () => "" }), +}); +window.Services = Services; + +export const windowUtils = withNotImplementedError({ + removeManuallyManagedState: () => {}, + addManuallyManagedState: () => {}, +}); +window.windowUtils = windowUtils; + +export const AutofillTelemetry = withNotImplementedError({ + recordFormInteractionEvent: () => {}, + recordDetectedSectionCount: () => {}, +}); + +export { IOSAppConstants as AppConstants } from "resource://gre/modules/shared/Constants.ios.mjs"; diff --git a/toolkit/components/formautofill/Overrides.ios.js b/toolkit/components/formautofill/Overrides.ios.js new file mode 100644 index 0000000000..a0023a267c --- /dev/null +++ b/toolkit/components/formautofill/Overrides.ios.js @@ -0,0 +1,22 @@ +/* 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/. */ + +"use strict"; + +// This array defines overrides that webpack will use when bundling the JS on iOS +// in order to load the right modules +const ModuleOverrides = { + "AutofillTelemetry.sys.mjs": "Helpers.ios.mjs", + "AppConstants.sys.mjs": "Helpers.ios.mjs", + "XPCOMUtils.sys.mjs": "Helpers.ios.mjs", + "Region.sys.mjs": "Helpers.ios.mjs", + "OSKeyStore.sys.mjs": "Helpers.ios.mjs", + "FormAutofill.sys.mjs": "FormAutofill.ios.sys.mjs", + "EntryFile.sys.mjs": "FormAutofillChild.ios.sys.mjs", +}; + +// We need this because not all webpack libraries used in iOS are ES Modules +// Hence we defer to CommonJS. +// eslint-disable-next-line no-undef +module.exports = { ModuleOverrides }; diff --git a/toolkit/components/formautofill/ProfileAutoCompleteResult.sys.mjs b/toolkit/components/formautofill/ProfileAutoCompleteResult.sys.mjs new file mode 100644 index 0000000000..6707af7a58 --- /dev/null +++ b/toolkit/components/formautofill/ProfileAutoCompleteResult.sys.mjs @@ -0,0 +1,485 @@ +/* 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 { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + CreditCard: "resource://gre/modules/CreditCard.sys.mjs", + FormAutofillUtils: "resource://gre/modules/shared/FormAutofillUtils.sys.mjs", +}); + +XPCOMUtils.defineLazyGetter( + lazy, + "l10n", + () => new Localization(["browser/preferences/formAutofill.ftl"], true) +); + +class ProfileAutoCompleteResult { + constructor( + searchString, + focusedFieldName, + allFieldNames, + matchingProfiles, + { resultCode = null, isSecure = true, isInputAutofilled = false } + ) { + // nsISupports + this.QueryInterface = ChromeUtils.generateQI(["nsIAutoCompleteResult"]); + + // The user's query string + this.searchString = searchString; + // The field name of the focused input. + this._focusedFieldName = focusedFieldName; + // The matching profiles contains the information for filling forms. + this._matchingProfiles = matchingProfiles; + // The default item that should be entered if none is selected + this.defaultIndex = 0; + // The reason the search failed + this.errorDescription = ""; + // The value used to determine whether the form is secure or not. + this._isSecure = isSecure; + // The value to indicate whether the focused input has been autofilled or not. + this._isInputAutofilled = isInputAutofilled; + // All fillable field names in the form including the field name of the currently-focused input. + this._allFieldNames = [ + ...this._matchingProfiles.reduce((fieldSet, curProfile) => { + for (let field of Object.keys(curProfile)) { + fieldSet.add(field); + } + + return fieldSet; + }, new Set()), + ].filter(field => allFieldNames.includes(field)); + + // Force return success code if the focused field is auto-filled in order + // to show clear form button popup. + if (isInputAutofilled) { + resultCode = Ci.nsIAutoCompleteResult.RESULT_SUCCESS; + } + // The result code of this result object. + if (resultCode) { + this.searchResult = resultCode; + } else if (matchingProfiles.length) { + this.searchResult = Ci.nsIAutoCompleteResult.RESULT_SUCCESS; + } else { + this.searchResult = Ci.nsIAutoCompleteResult.RESULT_NOMATCH; + } + + // An array of primary and secondary labels for each profile. + this._popupLabels = this._generateLabels( + this._focusedFieldName, + this._allFieldNames, + this._matchingProfiles + ); + } + + /** + * @returns {number} The number of results + */ + get matchCount() { + return this._popupLabels.length; + } + + _checkIndexBounds(index) { + if (index < 0 || index >= this._popupLabels.length) { + throw Components.Exception( + "Index out of range.", + Cr.NS_ERROR_ILLEGAL_VALUE + ); + } + } + + /** + * Get the secondary label based on the focused field name and related field names + * in the same form. + * + * @param {string} focusedFieldName The field name of the focused input + * @param {Array<object>} allFieldNames The field names in the same section + * @param {object} profile The profile providing the labels to show. + * @returns {string} The secondary label + */ + _getSecondaryLabel(focusedFieldName, allFieldNames, profile) { + return ""; + } + + _generateLabels(focusedFieldName, allFieldNames, profiles) {} + + /** + * Get the value of the result at the given index. + * + * Always return empty string for form autofill feature to suppress + * AutoCompleteController from autofilling, as we'll populate the + * fields on our own. + * + * @param {number} index The index of the result requested + * @returns {string} The result at the specified index + */ + getValueAt(index) { + this._checkIndexBounds(index); + return ""; + } + + getLabelAt(index) { + this._checkIndexBounds(index); + + let label = this._popupLabels[index]; + if (typeof label == "string") { + return label; + } + return JSON.stringify(label); + } + + /** + * Retrieves a comment (metadata instance) + * + * @param {number} index The index of the comment requested + * @returns {string} The comment at the specified index + */ + getCommentAt(index) { + this._checkIndexBounds(index); + return JSON.stringify(this._matchingProfiles[index]); + } + + /** + * Retrieves a style hint specific to a particular index. + * + * @param {number} index The index of the style hint requested + * @returns {string} The style hint at the specified index + */ + getStyleAt(index) { + this._checkIndexBounds(index); + if (index == this.matchCount - 1) { + return "autofill-footer"; + } + if (this._isInputAutofilled) { + return "autofill-clear-button"; + } + + return "autofill-profile"; + } + + /** + * Retrieves an image url. + * + * @param {number} index The index of the image url requested + * @returns {string} The image url at the specified index + */ + getImageAt(index) { + this._checkIndexBounds(index); + return ""; + } + + /** + * Retrieves a result + * + * @param {number} index The index of the result requested + * @returns {string} The result at the specified index + */ + getFinalCompleteValueAt(index) { + return this.getValueAt(index); + } + + /** + * Returns true if the value at the given index is removable + * + * @param {number} index The index of the result to remove + * @returns {boolean} True if the value is removable + */ + isRemovableAt(index) { + return true; + } + + /** + * Removes a result from the resultset + * + * @param {number} index The index of the result to remove + */ + removeValueAt(index) { + // There is no plan to support removing profiles via autocomplete. + } +} + +export class AddressResult extends ProfileAutoCompleteResult { + constructor(...args) { + super(...args); + } + + _getSecondaryLabel(focusedFieldName, allFieldNames, profile) { + // We group similar fields into the same field name so we won't pick another + // field in the same group as the secondary label. + const GROUP_FIELDS = { + name: ["name", "given-name", "additional-name", "family-name"], + "street-address": [ + "street-address", + "address-line1", + "address-line2", + "address-line3", + ], + "country-name": ["country", "country-name"], + tel: [ + "tel", + "tel-country-code", + "tel-national", + "tel-area-code", + "tel-local", + "tel-local-prefix", + "tel-local-suffix", + ], + }; + + const secondaryLabelOrder = [ + "street-address", // Street address + "name", // Full name + "address-level3", // Townland / Neighborhood / Village + "address-level2", // City/Town + "organization", // Company or organization name + "address-level1", // Province/State (Standardized code if possible) + "country-name", // Country name + "postal-code", // Postal code + "tel", // Phone number + "email", // Email address + ]; + + for (let field in GROUP_FIELDS) { + if (GROUP_FIELDS[field].includes(focusedFieldName)) { + focusedFieldName = field; + break; + } + } + + for (const currentFieldName of secondaryLabelOrder) { + if (focusedFieldName == currentFieldName || !profile[currentFieldName]) { + continue; + } + + let matching = GROUP_FIELDS[currentFieldName] + ? allFieldNames.some(fieldName => + GROUP_FIELDS[currentFieldName].includes(fieldName) + ) + : allFieldNames.includes(currentFieldName); + + if (matching) { + if ( + currentFieldName == "street-address" && + profile["-moz-street-address-one-line"] + ) { + return profile["-moz-street-address-one-line"]; + } + return profile[currentFieldName]; + } + } + + return ""; // Nothing matched. + } + + _generateLabels(focusedFieldName, allFieldNames, profiles) { + if (this._isInputAutofilled) { + return [ + { primary: "", secondary: "" }, // Clear button + { primary: "", secondary: "" }, // Footer + ]; + } + + // Skip results without a primary label. + let labels = profiles + .filter(profile => { + return !!profile[focusedFieldName]; + }) + .map(profile => { + let primaryLabel = profile[focusedFieldName]; + if ( + focusedFieldName == "street-address" && + profile["-moz-street-address-one-line"] + ) { + primaryLabel = profile["-moz-street-address-one-line"]; + } + return { + primary: primaryLabel, + secondary: this._getSecondaryLabel( + focusedFieldName, + allFieldNames, + profile + ), + }; + }); + // Add an empty result entry for footer. Its content will come from + // the footer binding, so don't assign any value to it. + // The additional properties: categories and focusedCategory are required of + // the popup to generate autofill hint on the footer. + labels.push({ + primary: "", + secondary: "", + categories: lazy.FormAutofillUtils.getCategoriesFromFieldNames( + this._allFieldNames + ), + focusedCategory: lazy.FormAutofillUtils.getCategoryFromFieldName( + this._focusedFieldName + ), + }); + + return labels; + } +} + +export class CreditCardResult extends ProfileAutoCompleteResult { + constructor(...args) { + super(...args); + this._cardTypes = this._generateCardTypes( + this._focusedFieldName, + this._allFieldNames, + this._matchingProfiles + ); + } + + _getSecondaryLabel(focusedFieldName, allFieldNames, profile) { + const GROUP_FIELDS = { + "cc-name": [ + "cc-name", + "cc-given-name", + "cc-additional-name", + "cc-family-name", + ], + "cc-exp": ["cc-exp", "cc-exp-month", "cc-exp-year"], + }; + + const secondaryLabelOrder = [ + "cc-number", // Credit card number + "cc-name", // Full name + "cc-exp", // Expiration date + ]; + + for (let field in GROUP_FIELDS) { + if (GROUP_FIELDS[field].includes(focusedFieldName)) { + focusedFieldName = field; + break; + } + } + + for (const currentFieldName of secondaryLabelOrder) { + if (focusedFieldName == currentFieldName || !profile[currentFieldName]) { + continue; + } + + let matching = GROUP_FIELDS[currentFieldName] + ? allFieldNames.some(fieldName => + GROUP_FIELDS[currentFieldName].includes(fieldName) + ) + : allFieldNames.includes(currentFieldName); + + if (matching) { + if (currentFieldName == "cc-number") { + let { affix, label } = lazy.CreditCard.formatMaskedNumber( + profile[currentFieldName] + ); + return affix + label; + } + return profile[currentFieldName]; + } + } + + return ""; // Nothing matched. + } + + _generateLabels(focusedFieldName, allFieldNames, profiles) { + if (!this._isSecure) { + let brandName = + lazy.FormAutofillUtils.brandBundle.GetStringFromName("brandShortName"); + + return [ + lazy.FormAutofillUtils.stringBundle.formatStringFromName( + "insecureFieldWarningDescription", + [brandName] + ), + ]; + } + + if (this._isInputAutofilled) { + return [ + { primary: "", secondary: "" }, // Clear button + { primary: "", secondary: "" }, // Footer + ]; + } + + // Skip results without a primary label. + let labels = profiles + .filter(profile => { + return !!profile[focusedFieldName]; + }) + .map(profile => { + let primaryAffix; + let primary = profile[focusedFieldName]; + + if (focusedFieldName == "cc-number") { + let { affix, label } = lazy.CreditCard.formatMaskedNumber(primary); + primaryAffix = affix; + primary = label; + } + const secondary = this._getSecondaryLabel( + focusedFieldName, + allFieldNames, + profile + ); + // The card type is displayed visually using an image. For a11y, we need + // to expose it as text. We do this using aria-label. However, + // aria-label overrides the text content, so we must include that also. + const ccType = profile["cc-type"]; + const ccTypeL10nId = lazy.CreditCard.getNetworkL10nId(ccType); + const ccTypeName = ccTypeL10nId + ? lazy.l10n.formatValueSync(ccTypeL10nId) + : ccType ?? ""; // Unknown card type + const ariaLabel = [ccTypeName, primaryAffix, primary, secondary] + .filter(chunk => !!chunk) // Exclude empty chunks. + .join(" "); + return { + primaryAffix, + primary, + secondary, + ariaLabel, + }; + }); + // Add an empty result entry for footer. + labels.push({ primary: "", secondary: "" }); + + return labels; + } + + // This method needs to return an array that parallels the + // array returned by _generateLabels, above. As a consequence, + // its logic follows very closely. + _generateCardTypes(focusedFieldName, allFieldNames, profiles) { + if (this._isInputAutofilled) { + return [ + "", // Clear button + "", // Footer + ]; + } + + // Skip results without a primary label. + let cardTypes = profiles + .filter(profile => { + return !!profile[focusedFieldName]; + }) + .map(profile => profile["cc-type"]); + + // Add an empty result entry for footer. + cardTypes.push(""); + return cardTypes; + } + + getStyleAt(index) { + this._checkIndexBounds(index); + if (!this._isSecure) { + return "autofill-insecureWarning"; + } + + return super.getStyleAt(index); + } + + getImageAt(index) { + this._checkIndexBounds(index); + let network = this._cardTypes[index]; + return lazy.CreditCard.getCreditCardLogo(network); + } +} diff --git a/toolkit/components/formautofill/android/FormAutofillPrompter.sys.mjs b/toolkit/components/formautofill/android/FormAutofillPrompter.sys.mjs new file mode 100644 index 0000000000..6ac3744dac --- /dev/null +++ b/toolkit/components/formautofill/android/FormAutofillPrompter.sys.mjs @@ -0,0 +1,70 @@ +/* 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/. */ + +/* + * Implements doorhanger singleton that wraps up the PopupNotifications and handles + * the doorhager UI for formautofill related features. + */ + +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + GeckoViewPrompter: "resource://gre/modules/GeckoViewPrompter.sys.mjs", +}); + +XPCOMUtils.defineLazyModuleGetters(lazy, { + CreditCard: "resource://gre/modules/GeckoViewAutocomplete.jsm", + GeckoViewAutocomplete: "resource://gre/modules/GeckoViewAutocomplete.jsm", +}); + +// Sync with Autocomplete.SaveOption.Hint in Autocomplete.java +const CreditCardStorageHint = { + NONE: 0, + GENERATED: 1 << 0, + LOW_CONFIDENCE: 1 << 1, +}; + +export let FormAutofillPrompter = { + _createMessage(creditCards) { + let hint = CreditCardStorageHint.NONE; + return { + // Sync with PromptController + type: "Autocomplete:Save:CreditCard", + hint, + creditCards, + }; + }, + + async promptToSaveAddress(browser, type, description) { + throw Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED); + }, + + async promptToSaveCreditCard(browser, storage, record, flowId) { + const prompt = new lazy.GeckoViewPrompter(browser.ownerGlobal); + + const duplicateRecord = (await storage.getDuplicateRecords(record).next()) + .value; + let newCreditCard; + if (duplicateRecord) { + newCreditCard = { ...duplicateRecord, ...record }; + } else { + newCreditCard = record; + } + + prompt.asyncShowPrompt( + this._createMessage([lazy.CreditCard.fromGecko(newCreditCard)]), + result => { + const selectedCreditCard = result?.selection?.value; + + if (!selectedCreditCard) { + return; + } + + lazy.GeckoViewAutocomplete.onCreditCardSave(selectedCreditCard); + } + ); + }, +}; diff --git a/toolkit/components/formautofill/android/FormAutofillStorage.sys.mjs b/toolkit/components/formautofill/android/FormAutofillStorage.sys.mjs new file mode 100644 index 0000000000..76d583536b --- /dev/null +++ b/toolkit/components/formautofill/android/FormAutofillStorage.sys.mjs @@ -0,0 +1,277 @@ +/* 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/. */ + +/* + * Implements an interface of the storage of Form Autofill for GeckoView. + */ + +// We expose a singleton from this module. Some tests may import the +// constructor via a backstage pass. +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; + +import { + FormAutofillStorageBase, + CreditCardsBase, + AddressesBase, +} from "resource://autofill/FormAutofillStorageBase.sys.mjs"; +import { JSONFile } from "resource://gre/modules/JSONFile.sys.mjs"; + +const lazy = {}; + +XPCOMUtils.defineLazyModuleGetters(lazy, { + Address: "resource://gre/modules/GeckoViewAutocomplete.jsm", + CreditCard: "resource://gre/modules/GeckoViewAutocomplete.jsm", + GeckoViewAutocomplete: "resource://gre/modules/GeckoViewAutocomplete.jsm", +}); + +class GeckoViewStorage extends JSONFile { + constructor() { + super({ path: null, sanitizedBasename: "GeckoViewStorage" }); + } + + async updateCreditCards() { + const creditCards = + await lazy.GeckoViewAutocomplete.fetchCreditCards().then( + results => results?.map(r => lazy.CreditCard.parse(r).toGecko()) ?? [], + _ => [] + ); + super.data.creditCards = creditCards; + } + + async updateAddresses() { + const addresses = await lazy.GeckoViewAutocomplete.fetchAddresses().then( + results => results?.map(r => lazy.Address.parse(r).toGecko()) ?? [], + _ => [] + ); + super.data.addresses = addresses; + } + + async load() { + super.data = { creditCards: {}, addresses: {} }; + await this.updateCreditCards(); + await this.updateAddresses(); + } + + ensureDataReady() { + if (this.dataReady) { + return; + } + throw Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED); + } + + async _save() { + throw Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED); + } +} + +class Addresses extends AddressesBase { + // Override AutofillRecords methods. + + _initialize() { + this._initializePromise = Promise.resolve(); + } + + async _saveRecord(record, { sourceSync = false } = {}) { + lazy.GeckoViewAutocomplete.onAddressSave(lazy.Address.fromGecko(record)); + } + + /** + * Returns the record with the specified GUID. + * + * @param {string} guid + * Indicates which record to retrieve. + * @param {object} options + * @param {boolean} [options.rawData = false] + * Returns a raw record without modifications and the computed fields + * (this includes private fields) + * @returns {Promise<object>} + * A clone of the record. + */ + async get(guid, { rawData = false } = {}) { + await this._store.updateAddresses(); + return super.get(guid, { rawData }); + } + + /** + * Returns all records. + * + * @param {object} options + * @param {boolean} [options.rawData = false] + * Returns raw records without modifications and the computed fields. + * @param {boolean} [options.includeDeleted = false] + * Also return any tombstone records. + * @returns {Promise<Array.<object>>} + * An array containing clones of all records. + */ + async getAll({ rawData = false, includeDeleted = false } = {}) { + await this._store.updateAddresses(); + return super.getAll({ rawData, includeDeleted }); + } + + /** + * Return all saved field names in the collection. + * + * @returns {Set} Set containing saved field names. + */ + async getSavedFieldNames() { + await this._store.updateAddresses(); + return super.getSavedFieldNames(); + } + + async reconcile(remoteRecord) { + throw Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED); + } + + async findDuplicateGUID(remoteRecord) { + throw Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED); + } + + async mergeToStorage(targetRecord, strict = false) { + throw Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED); + } +} + +class CreditCards extends CreditCardsBase { + async _encryptNumber(creditCard) { + // Don't encrypt or obfuscate for GV, since we don't store or show + // the number. The API has to always provide the original number. + } + + // Override AutofillRecords methods. + + _initialize() { + this._initializePromise = Promise.resolve(); + } + + async _saveRecord(record, { sourceSync = false } = {}) { + lazy.GeckoViewAutocomplete.onCreditCardSave( + lazy.CreditCard.fromGecko(record) + ); + } + + /** + * Returns the record with the specified GUID. + * + * @param {string} guid + * Indicates which record to retrieve. + * @param {object} options + * @param {boolean} [options.rawData = false] + * Returns a raw record without modifications and the computed fields + * (this includes private fields) + * @returns {Promise<object>} + * A clone of the record. + */ + async get(guid, { rawData = false } = {}) { + await this._store.updateCreditCards(); + return super.get(guid, { rawData }); + } + + /** + * Returns all records. + * + * @param {object} options + * @param {boolean} [options.rawData = false] + * Returns raw records without modifications and the computed fields. + * @param {boolean} [options.includeDeleted = false] + * Also return any tombstone records. + * @returns {Promise<Array.<object>>} + * An array containing clones of all records. + */ + async getAll({ rawData = false, includeDeleted = false } = {}) { + await this._store.updateCreditCards(); + return super.getAll({ rawData, includeDeleted }); + } + + /** + * Return all saved field names in the collection. + * + * @returns {Set} Set containing saved field names. + */ + async getSavedFieldNames() { + await this._store.updateCreditCards(); + return super.getSavedFieldNames(); + } + + /** + * Find a duplicate credit card record in the storage. + * + * A record is considered as a duplicate of another record when two records + * are the "same". This might be true even when some of their fields are + * different. For example, one record has the same credit card number but has + * different expiration date as the other record are still considered as + * "duplicate". + * This is different from `getMatchRecord`, which ensures all the fields with + * value in the the record is equal to the returned record. + * + * @param {object} record + * The credit card for duplication checking. please make sure the + * record is normalized. + * @returns {object} + * Return the first duplicated record found in storage, null otherwise. + */ + async *getDuplicateRecords(record) { + if (!record["cc-number"]) { + return null; + } + + await this._store.updateCreditCards(); + for (const recordInStorage of this._data) { + if (recordInStorage.deleted) { + continue; + } + + if (recordInStorage["cc-number"] == record["cc-number"]) { + yield recordInStorage; + } + } + return null; + } + + async reconcile(remoteRecord) { + throw Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED); + } + + async findDuplicateGUID(remoteRecord) { + throw Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED); + } + + async mergeToStorage(targetRecord, strict = false) { + throw Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED); + } +} + +export class FormAutofillStorage extends FormAutofillStorageBase { + constructor() { + super(null); + } + + getAddresses() { + if (!this._addresses) { + this._store.ensureDataReady(); + this._addresses = new Addresses(this._store); + } + return this._addresses; + } + + getCreditCards() { + if (!this._creditCards) { + this._store.ensureDataReady(); + this._creditCards = new CreditCards(this._store); + } + return this._creditCards; + } + + /** + * Initializes the in-memory async store API. + * + * @returns {JSONFile} + * The JSONFile store. + */ + _initializeStore() { + return new GeckoViewStorage(); + } +} + +// The singleton exposed by this module. +export const formAutofillStorage = new FormAutofillStorage(); diff --git a/toolkit/components/formautofill/default/FormAutofillPrompter.sys.mjs b/toolkit/components/formautofill/default/FormAutofillPrompter.sys.mjs new file mode 100644 index 0000000000..31975cd968 --- /dev/null +++ b/toolkit/components/formautofill/default/FormAutofillPrompter.sys.mjs @@ -0,0 +1,677 @@ +/* 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/. */ + +/* + * Implements doorhanger singleton that wraps up the PopupNotifications and handles + * the doorhager UI for formautofill related features. + */ + +import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs"; +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 { AutofillTelemetry } = ChromeUtils.import( + "resource://autofill/AutofillTelemetry.jsm" +); + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + CreditCard: "resource://gre/modules/CreditCard.sys.mjs", +}); + +XPCOMUtils.defineLazyGetter(lazy, "log", () => + FormAutofill.defineLogGetter(lazy, "FormAutofillPrompter") +); + +const { ENABLED_AUTOFILL_CREDITCARDS_PREF } = FormAutofill; + +const GetStringFromName = FormAutofillUtils.stringBundle.GetStringFromName; +const formatStringFromName = + FormAutofillUtils.stringBundle.formatStringFromName; +const brandShortName = + FormAutofillUtils.brandBundle.GetStringFromName("brandShortName"); +let changeAutofillOptsKey = "changeAutofillOptions"; +let autofillOptsKey = "autofillOptionsLink"; +if (AppConstants.platform == "macosx") { + changeAutofillOptsKey += "OSX"; + autofillOptsKey += "OSX"; +} + +const CONTENT = { + addFirstTimeUse: { + notificationId: "autofill-address", + message: formatStringFromName("saveAddressesMessage", [brandShortName]), + anchor: { + id: "autofill-address-notification-icon", + URL: "chrome://formautofill/content/formfill-anchor.svg", + tooltiptext: GetStringFromName("openAutofillMessagePanel"), + }, + mainAction: { + label: GetStringFromName(changeAutofillOptsKey), + accessKey: GetStringFromName("changeAutofillOptionsAccessKey"), + callbackState: "open-pref", + }, + options: { + persistWhileVisible: true, + popupIconURL: "chrome://formautofill/content/icon-address-save.svg", + checkbox: { + get checked() { + return Services.prefs.getBoolPref("services.sync.engine.addresses"); + }, + get label() { + // If sync account is not set, return null label to hide checkbox + return Services.prefs.prefHasUserValue("services.sync.username") + ? GetStringFromName("addressesSyncCheckbox") + : null; + }, + callback(event) { + let checked = event.target.checked; + Services.prefs.setBoolPref("services.sync.engine.addresses", checked); + lazy.log.debug("Set addresses sync to", checked); + }, + }, + hideClose: true, + }, + }, + addAddress: { + notificationId: "autofill-address", + message: formatStringFromName("saveAddressesMessage", [brandShortName]), + descriptionLabel: GetStringFromName("saveAddressDescriptionLabel"), + descriptionIcon: true, + linkMessage: GetStringFromName(autofillOptsKey), + spotlightURL: "about:preferences#privacy-address-autofill", + anchor: { + id: "autofill-address-notification-icon", + URL: "chrome://formautofill/content/formfill-anchor.svg", + tooltiptext: GetStringFromName("openAutofillMessagePanel"), + }, + mainAction: { + label: GetStringFromName("saveAddressLabel"), + accessKey: GetStringFromName("saveAddressAccessKey"), + callbackState: "create", + }, + secondaryActions: [ + { + label: GetStringFromName("cancelAddressLabel"), + accessKey: GetStringFromName("cancelAddressAccessKey"), + callbackState: "cancel", + }, + ], + options: { + persistWhileVisible: true, + popupIconURL: "chrome://formautofill/content/icon-address-update.svg", + hideClose: true, + }, + }, + updateAddress: { + notificationId: "autofill-address", + message: GetStringFromName("updateAddressMessage"), + descriptionLabel: GetStringFromName("updateAddressNewDescriptionLabel"), + additionalDescriptionLabel: GetStringFromName( + "updateAddressOldDescriptionLabel" + ), + descriptionIcon: false, + linkMessage: GetStringFromName(autofillOptsKey), + spotlightURL: "about:preferences#privacy-address-autofill", + anchor: { + id: "autofill-address-notification-icon", + URL: "chrome://formautofill/content/formfill-anchor.svg", + tooltiptext: GetStringFromName("openAutofillMessagePanel"), + }, + mainAction: { + label: GetStringFromName("updateAddressLabel"), + accessKey: GetStringFromName("updateAddressAccessKey"), + callbackState: "update", + }, + secondaryActions: [ + { + label: GetStringFromName("createAddressLabel"), + accessKey: GetStringFromName("createAddressAccessKey"), + callbackState: "create", + }, + ], + options: { + persistWhileVisible: true, + popupIconURL: "chrome://formautofill/content/icon-address-update.svg", + hideClose: true, + }, + }, + addCreditCard: { + notificationId: "autofill-credit-card", + message: formatStringFromName("saveCreditCardMessage", [brandShortName]), + descriptionLabel: GetStringFromName("saveCreditCardDescriptionLabel"), + descriptionIcon: true, + linkMessage: GetStringFromName(autofillOptsKey), + spotlightURL: "about:preferences#privacy-credit-card-autofill", + anchor: { + id: "autofill-credit-card-notification-icon", + URL: "chrome://formautofill/content/formfill-anchor.svg", + tooltiptext: GetStringFromName("openAutofillMessagePanel"), + }, + mainAction: { + label: GetStringFromName("saveCreditCardLabel"), + accessKey: GetStringFromName("saveCreditCardAccessKey"), + callbackState: "save", + }, + secondaryActions: [ + { + label: GetStringFromName("cancelCreditCardLabel"), + accessKey: GetStringFromName("cancelCreditCardAccessKey"), + callbackState: "cancel", + }, + { + label: GetStringFromName("neverSaveCreditCardLabel"), + accessKey: GetStringFromName("neverSaveCreditCardAccessKey"), + callbackState: "disable", + }, + ], + options: { + persistWhileVisible: true, + popupIconURL: "chrome://formautofill/content/icon-credit-card.svg", + hideClose: true, + checkbox: { + get checked() { + return Services.prefs.getBoolPref("services.sync.engine.creditcards"); + }, + get label() { + // Only set the label when the fallowing conditions existed: + // - sync account is set + // - credit card sync is disabled + // - credit card sync is available + // otherwise return null label to hide checkbox. + return Services.prefs.prefHasUserValue("services.sync.username") && + !Services.prefs.getBoolPref("services.sync.engine.creditcards") && + Services.prefs.getBoolPref( + "services.sync.engine.creditcards.available" + ) + ? GetStringFromName("creditCardsSyncCheckbox") + : null; + }, + callback(event) { + let { secondaryButton, menubutton } = + event.target.closest("popupnotification"); + let checked = event.target.checked; + Services.prefs.setBoolPref( + "services.sync.engine.creditcards", + checked + ); + secondaryButton.disabled = checked; + menubutton.disabled = checked; + lazy.log.debug("Set creditCard sync to", checked); + }, + }, + }, + }, + updateCreditCard: { + notificationId: "autofill-credit-card", + message: GetStringFromName("updateCreditCardMessage"), + descriptionLabel: GetStringFromName("updateCreditCardDescriptionLabel"), + descriptionIcon: true, + linkMessage: GetStringFromName(autofillOptsKey), + spotlightURL: "about:preferences#privacy-credit-card-autofill", + anchor: { + id: "autofill-credit-card-notification-icon", + URL: "chrome://formautofill/content/formfill-anchor.svg", + tooltiptext: GetStringFromName("openAutofillMessagePanel"), + }, + mainAction: { + label: GetStringFromName("updateCreditCardLabel"), + accessKey: GetStringFromName("updateCreditCardAccessKey"), + callbackState: "update", + }, + secondaryActions: [ + { + label: GetStringFromName("createCreditCardLabel"), + accessKey: GetStringFromName("createCreditCardAccessKey"), + callbackState: "create", + }, + ], + options: { + persistWhileVisible: true, + popupIconURL: "chrome://formautofill/content/icon-credit-card.svg", + hideClose: true, + }, + }, +}; + +export let FormAutofillPrompter = { + /** + * Generate the main action and secondary actions from content parameters and + * promise resolve. + * + * @private + * @param {object} mainActionParams + * Parameters for main action. + * @param {Array<object>} secondaryActionParams + * Array of the parameters for secondary actions. + * @param {Function} resolve Should be called in action callback. + * @returns {Array<object>} + Return the mainAction and secondary actions in an array for showing doorhanger + */ + _createActions(mainActionParams, secondaryActionParams, resolve) { + if (!mainActionParams) { + return [null, null]; + } + + let { label, accessKey, callbackState } = mainActionParams; + let callback = resolve.bind(null, callbackState); + let mainAction = { label, accessKey, callback }; + + if (!secondaryActionParams) { + return [mainAction, null]; + } + + let secondaryActions = []; + for (let params of secondaryActionParams) { + let cb = resolve.bind(null, params.callbackState); + secondaryActions.push({ + label: params.label, + accessKey: params.accessKey, + callback: cb, + }); + } + + return [mainAction, secondaryActions]; + }, + _getNotificationElm(browser, id) { + let notificationId = id + "-notification"; + let chromeDoc = browser.ownerDocument; + return chromeDoc.getElementById(notificationId); + }, + /** + * Append the link label element to the popupnotificationcontent. + * + * @param {XULElement} content + * popupnotificationcontent + * @param {string} message + * The localized string for link title. + * @param {string} link + * Makes it possible to open and highlight a section in preferences + */ + _appendPrivacyPanelLink(content, message, link) { + let chromeDoc = content.ownerDocument; + let privacyLinkElement = chromeDoc.createXULElement("label", { + is: "text-link", + }); + privacyLinkElement.setAttribute("useoriginprincipal", true); + privacyLinkElement.setAttribute( + "href", + link || "about:preferences#privacy-form-autofill" + ); + privacyLinkElement.setAttribute("value", message); + content.appendChild(privacyLinkElement); + }, + + /** + * Append the description section to the popupnotificationcontent. + * + * @param {XULElement} content + * popupnotificationcontent + * @param {string} descriptionLabel + * The label showing above description. + * @param {string} descriptionIcon + * The src of description icon. + * @param {string} descriptionId + * The id of description + */ + _appendDescription( + content, + descriptionLabel, + descriptionIcon, + descriptionId + ) { + let chromeDoc = content.ownerDocument; + let docFragment = chromeDoc.createDocumentFragment(); + + let descriptionLabelElement = chromeDoc.createXULElement("label"); + descriptionLabelElement.setAttribute("value", descriptionLabel); + docFragment.appendChild(descriptionLabelElement); + + let descriptionWrapper = chromeDoc.createXULElement("hbox"); + descriptionWrapper.className = "desc-message-box"; + + if (descriptionIcon) { + let descriptionIconElement = chromeDoc.createXULElement("image"); + if ( + typeof descriptionIcon == "string" && + (descriptionIcon.includes("cc-logo") || + descriptionIcon.includes("icon-credit")) + ) { + descriptionIconElement.setAttribute("src", descriptionIcon); + } + descriptionWrapper.appendChild(descriptionIconElement); + } + + let descriptionElement = chromeDoc.createXULElement(descriptionId); + descriptionWrapper.appendChild(descriptionElement); + docFragment.appendChild(descriptionWrapper); + + content.appendChild(docFragment); + }, + + _updateDescription(content, descriptionId, description) { + let element = content.querySelector(descriptionId); + element.textContent = description; + }, + + /** + * Create an image element for notification anchor if it doesn't already exist. + * + * @param {XULElement} browser + * Target browser element for showing doorhanger. + * @param {object} anchor + * Anchor options for setting the anchor element. + * @param {string} anchor.id + * ID of the anchor element. + * @param {string} anchor.URL + * Path of the icon asset. + * @param {string} anchor.tooltiptext + * Tooltip string for the anchor. + */ + _setAnchor(browser, anchor) { + let chromeDoc = browser.ownerDocument; + let { id, URL, tooltiptext } = anchor; + let anchorEt = chromeDoc.getElementById(id); + if (!anchorEt) { + let notificationPopupBox = chromeDoc.getElementById( + "notification-popup-box" + ); + // Icon shown on URL bar + let anchorElement = chromeDoc.createXULElement("image"); + anchorElement.id = id; + anchorElement.setAttribute("src", URL); + anchorElement.classList.add("notification-anchor-icon"); + anchorElement.setAttribute("role", "button"); + anchorElement.setAttribute("tooltiptext", tooltiptext); + notificationPopupBox.appendChild(anchorElement); + } + }, + _addCheckboxListener(browser, { notificationId, options }) { + if (!options.checkbox) { + return; + } + let { checkbox } = this._getNotificationElm(browser, notificationId); + + if (checkbox && !checkbox.hidden) { + checkbox.addEventListener("command", options.checkbox.callback); + } + }, + + _removeCheckboxListener(browser, { notificationId, options }) { + if (!options.checkbox) { + return; + } + let { checkbox } = this._getNotificationElm(browser, notificationId); + + if (checkbox && !checkbox.hidden) { + checkbox.removeEventListener("command", options.checkbox.callback); + } + }, + + /** + * Show save or update address doorhanger + * + * @param {Element<browser>} browser Browser to show the save/update address prompt + * @param {object} storage Address storage + * @param {object} newRecord Address record to save + * @param {string} flowId Unique GUID to record a series of the same user action + * @param {object} options + * @param {object} [options.mergeableRecord] Record to be merged + * @param {Array} [options.mergeableFields] List of field name that can be merged + */ + async promptToSaveAddress( + browser, + storage, + newRecord, + flowId, + { mergeableRecord, mergeableFields } + ) { + // Overwrite the guid if there is a duplicate + let doorhangerType; + if (mergeableRecord) { + doorhangerType = "updateAddress"; + } else if (FormAutofill.isAutofillAddressesCaptureV2Enabled) { + doorhangerType = "addAddress"; + } else { + doorhangerType = "addFirstTimeUse"; + this._updateStorageAfterInteractWithPrompt("save", storage, newRecord); + + // Show first time use doorhanger + if (FormAutofill.isAutofillAddressesFirstTimeUse) { + Services.prefs.setBoolPref( + FormAutofill.ADDRESSES_FIRST_TIME_USE_PREF, + false + ); + } else { + return; + } + } + + const description = FormAutofillUtils.getAddressLabel(newRecord); + const additionalDescription = mergeableRecord + ? FormAutofillUtils.getAddressLabel(mergeableRecord) + : null; + + const state = await FormAutofillPrompter._showCCorAddressCaptureDoorhanger( + browser, + doorhangerType, + description, + flowId, + { additionalDescription } + ); + + if (state == "cancel") { + return; + } else if (state == "open-pref") { + browser.ownerGlobal.openPreferences("privacy-address-autofill"); + return; + } + + this._updateStorageAfterInteractWithPrompt( + state, + storage, + newRecord, + mergeableRecord?.guid + ); + }, + + async promptToSaveCreditCard(browser, storage, record, flowId) { + // Overwrite the guid if there is a duplicate + let doorhangerType; + const duplicateRecord = (await storage.getDuplicateRecords(record).next()) + .value; + if (duplicateRecord) { + doorhangerType = "updateCreditCard"; + } else { + doorhangerType = "addCreditCard"; + } + + const number = record["cc-number"] || record["cc-number-decrypted"]; + const name = record["cc-name"]; + const network = lazy.CreditCard.getType(number); + const maskedNumber = lazy.CreditCard.getMaskedNumber(number); + const description = `${maskedNumber}` + (name ? `, ${name}` : ``); + const descriptionIcon = lazy.CreditCard.getCreditCardLogo(network); + + const state = await FormAutofillPrompter._showCCorAddressCaptureDoorhanger( + browser, + doorhangerType, + description, + flowId, + { descriptionIcon } + ); + + if (state == "cancel") { + return; + } else if (state == "disable") { + Services.prefs.setBoolPref(ENABLED_AUTOFILL_CREDITCARDS_PREF, false); + return; + } + + if (!(await FormAutofillUtils.ensureLoggedIn()).authenticated) { + lazy.log.warn("User canceled encryption login"); + return; + } + + this._updateStorageAfterInteractWithPrompt( + state, + storage, + record, + duplicateRecord?.guid + ); + }, + + async _updateStorageAfterInteractWithPrompt( + state, + storage, + record, + guid = null + ) { + let changedGUID = null; + if (state == "create" || state == "save") { + changedGUID = await storage.add(record); + } else if (state == "update") { + await storage.update(guid, record, true); + changedGUID = guid; + } + storage.notifyUsed(changedGUID); + }, + + _getUpdatedCCIcon(network) { + return FormAutofillUtils.getCreditCardLogo(network); + }, + + /** + * Show different types of doorhanger by leveraging PopupNotifications. + * + * @param {XULElement} browser Target browser element for showing doorhanger. + * @param {string} type The type of the doorhanger. There will have first time use/update/credit card. + * @param {string} description The message that provides more information on doorhanger. + * @param {string} flowId guid used to correlate events relating to the same form + * @param {object} [options = {}] a list of options for this method + * @param {string} options.descriptionIcon The icon for descriotion + * @param {string} options.additionalDescription The message that provides more information on doorhanger. + * @returns {Promise} Resolved with action type when action callback is triggered. + */ + async _showCCorAddressCaptureDoorhanger( + browser, + type, + description, + flowId, + { descriptionIcon = null, additionalDescription = null } + ) { + const telemetryType = type.endsWith("CreditCard") + ? AutofillTelemetry.CREDIT_CARD + : AutofillTelemetry.ADDRESS; + const isCapture = type.startsWith("add"); + + AutofillTelemetry.recordDoorhangerShown(telemetryType, flowId, isCapture); + + lazy.log.debug("show doorhanger with type:", type); + return new Promise(resolve => { + let { + notificationId, + message, + descriptionLabel, + additionalDescriptionLabel, + linkMessage, + spotlightURL, + anchor, + mainAction, + secondaryActions, + options, + } = CONTENT[type]; + descriptionIcon = descriptionIcon ?? CONTENT[type].descriptionIcon; + + const { ownerGlobal: chromeWin, ownerDocument: chromeDoc } = browser; + options.eventCallback = topic => { + lazy.log.debug("eventCallback:", topic); + + if (topic == "removed" || topic == "dismissed") { + this._removeCheckboxListener(browser, { notificationId, options }); + return; + } + + // The doorhanger is customizable only when notification box is shown + if (topic != "shown") { + return; + } + this._addCheckboxListener(browser, { notificationId, options }); + + // There's no preferences link or other customization in first time use doorhanger. + if (type == "addFirstTimeUse") { + return; + } + + const DESCRIPTION_ID = "description"; + const ADDITIONAL_DESCRIPTION_ID = "additional-description"; + const NOTIFICATION_ID = notificationId + "-notification"; + + const notification = chromeDoc.getElementById(NOTIFICATION_ID); + const notificationContent = + notification.querySelector("popupnotificationcontent") || + chromeDoc.createXULElement("popupnotificationcontent"); + if (!notification.contains(notificationContent)) { + notificationContent.setAttribute("orient", "vertical"); + + this._appendDescription( + notificationContent, + descriptionLabel, + descriptionIcon, + DESCRIPTION_ID + ); + + if (additionalDescription) { + this._appendDescription( + notificationContent, + additionalDescriptionLabel, + descriptionIcon, + ADDITIONAL_DESCRIPTION_ID + ); + } + + this._appendPrivacyPanelLink( + notificationContent, + linkMessage, + spotlightURL + ); + + notification.appendNotificationContent(notificationContent); + } + + this._updateDescription( + notificationContent, + DESCRIPTION_ID, + description + ); + if (additionalDescription) { + this._updateDescription( + notificationContent, + ADDITIONAL_DESCRIPTION_ID, + additionalDescription + ); + } + }; + this._setAnchor(browser, anchor); + chromeWin.PopupNotifications.show( + browser, + notificationId, + message, + anchor.id, + ...this._createActions(mainAction, secondaryActions, resolve), + options + ); + }).then(state => { + AutofillTelemetry.recordDoorhangerClicked( + telemetryType, + state, + flowId, + isCapture + ); + return state; + }); + }, +}; diff --git a/toolkit/components/formautofill/default/FormAutofillStorage.sys.mjs b/toolkit/components/formautofill/default/FormAutofillStorage.sys.mjs new file mode 100644 index 0000000000..c45186453d --- /dev/null +++ b/toolkit/components/formautofill/default/FormAutofillStorage.sys.mjs @@ -0,0 +1,274 @@ +/* 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/. */ + +/* + * Implements an interface of the storage of Form Autofill. + */ + +// We expose a singleton from this module. Some tests may import the +// constructor via a backstage pass. +import { + AddressesBase, + CreditCardsBase, + FormAutofillStorageBase, +} from "resource://autofill/FormAutofillStorageBase.sys.mjs"; +import { FormAutofill } from "resource://autofill/FormAutofill.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + CreditCard: "resource://gre/modules/CreditCard.sys.mjs", + FormAutofillUtils: "resource://gre/modules/shared/FormAutofillUtils.sys.mjs", + JSONFile: "resource://gre/modules/JSONFile.sys.mjs", + OSKeyStore: "resource://gre/modules/OSKeyStore.sys.mjs", +}); + +const PROFILE_JSON_FILE_NAME = "autofill-profiles.json"; + +class Addresses extends AddressesBase { + /** + * Merge new address into the specified address if mergeable. + * + * @param {string} guid + * Indicates which address to merge. + * @param {object} address + * The new address used to merge into the old one. + * @param {boolean} strict + * In strict merge mode, we'll treat the subset record with empty field + * as unable to be merged, but mergeable if in non-strict mode. + * @returns {Promise<boolean>} + * Return true if address is merged into target with specific guid or false if not. + */ + async mergeIfPossible(guid, address, strict) { + this.log.debug(`mergeIfPossible: ${guid}`); + + let addressFound = this._findByGUID(guid); + if (!addressFound) { + throw new Error("No matching address."); + } + + let addressToMerge = this._clone(address); + this._normalizeRecord(addressToMerge, strict); + let hasMatchingField = false; + + let country = + addressFound.country || + addressToMerge.country || + FormAutofill.DEFAULT_REGION; + let collators = lazy.FormAutofillUtils.getSearchCollators(country); + for (let field of this.VALID_FIELDS) { + let existingField = addressFound[field]; + let incomingField = addressToMerge[field]; + if (incomingField !== undefined && existingField !== undefined) { + if (incomingField != existingField) { + // Treat "street-address" as mergeable if their single-line versions + // match each other. + if ( + field == "street-address" && + lazy.FormAutofillUtils.compareStreetAddress( + existingField, + incomingField, + collators + ) + ) { + // Keep the street-address in storage if its amount of lines is greater than + // or equal to the incoming one. + if ( + existingField.split("\n").length >= + incomingField.split("\n").length + ) { + // Replace the incoming field with the one in storage so it will + // be further merged back to storage. + addressToMerge[field] = existingField; + } + } else if ( + field != "street-address" && + lazy.FormAutofillUtils.strCompare( + existingField, + incomingField, + collators + ) + ) { + addressToMerge[field] = existingField; + } else { + this.log.debug("Conflicts: field", field, "has different value."); + return false; + } + } + hasMatchingField = true; + } + } + + // We merge the address only when at least one field has the same value. + if (!hasMatchingField) { + this.log.debug("Unable to merge because no field has the same value"); + return false; + } + + // Early return if the data is the same or subset. + let noNeedToUpdate = this.VALID_FIELDS.every(field => { + // When addressFound doesn't contain a field, it's unnecessary to update + // if the same field in addressToMerge is omitted or an empty string. + if (addressFound[field] === undefined) { + return !addressToMerge[field]; + } + + // When addressFound contains a field, it's unnecessary to update if + // the same field in addressToMerge is omitted or a duplicate. + return ( + addressToMerge[field] === undefined || + addressFound[field] === addressToMerge[field] + ); + }); + if (noNeedToUpdate) { + return true; + } + + await this.update(guid, addressToMerge, true); + return true; + } +} + +class CreditCards extends CreditCardsBase { + constructor(store) { + super(store); + } + + async _encryptNumber(creditCard) { + if (!("cc-number-encrypted" in creditCard)) { + if ("cc-number" in creditCard) { + let ccNumber = creditCard["cc-number"]; + if (lazy.CreditCard.isValidNumber(ccNumber)) { + creditCard["cc-number"] = + lazy.CreditCard.getLongMaskedNumber(ccNumber); + } else { + // Credit card numbers can be entered on versions of Firefox that don't validate + // the number and then synced to this version of Firefox. Therefore, mask the + // full number if the number is invalid on this version. + creditCard["cc-number"] = "*".repeat(ccNumber.length); + } + creditCard["cc-number-encrypted"] = await lazy.OSKeyStore.encrypt( + ccNumber + ); + } else { + creditCard["cc-number-encrypted"] = ""; + } + } + } + + /** + * Merge new credit card into the specified record if cc-number is identical. + * (Note that credit card records always do non-strict merge.) + * + * @param {string} guid + * Indicates which credit card to merge. + * @param {object} creditCard + * The new credit card used to merge into the old one. + * @returns {boolean} + * Return true if credit card is merged into target with specific guid or false if not. + */ + async mergeIfPossible(guid, creditCard) { + this.log.debug(`mergeIfPossible: ${guid}`); + + // Credit card number is required since it also must match. + if (!creditCard["cc-number"]) { + return false; + } + + // Query raw data for comparing the decrypted credit card number + let creditCardFound = await this.get(guid, { rawData: true }); + if (!creditCardFound) { + throw new Error("No matching credit card."); + } + + let creditCardToMerge = this._clone(creditCard); + this._normalizeRecord(creditCardToMerge); + + for (let field of this.VALID_FIELDS) { + let existingField = creditCardFound[field]; + + // Make sure credit card field is existed and have value + if ( + field == "cc-number" && + (!existingField || !creditCardToMerge[field]) + ) { + return false; + } + + if (!creditCardToMerge[field] && typeof existingField != "undefined") { + creditCardToMerge[field] = existingField; + } + + let incomingField = creditCardToMerge[field]; + if (incomingField && existingField) { + if (incomingField != existingField) { + this.log.debug("Conflicts: field", field, "has different value."); + return false; + } + } + } + + // Early return if the data is the same. + let exactlyMatch = this.VALID_FIELDS.every( + field => creditCardFound[field] === creditCardToMerge[field] + ); + if (exactlyMatch) { + return true; + } + + await this.update(guid, creditCardToMerge, true); + return true; + } +} + +export class FormAutofillStorage extends FormAutofillStorageBase { + constructor(path) { + super(path); + } + + getAddresses() { + if (!this._addresses) { + this._store.ensureDataReady(); + this._addresses = new Addresses(this._store); + } + return this._addresses; + } + + getCreditCards() { + if (!this._creditCards) { + this._store.ensureDataReady(); + this._creditCards = new CreditCards(this._store); + } + return this._creditCards; + } + + /** + * Loads the profile data from file to memory. + * + * @returns {JSONFile} + * The JSONFile store. + */ + _initializeStore() { + return new lazy.JSONFile({ + path: this._path, + dataPostProcessor: this._dataPostProcessor.bind(this), + }); + } + + _dataPostProcessor(data) { + data.version = this.version; + if (!data.addresses) { + data.addresses = []; + } + if (!data.creditCards) { + data.creditCards = []; + } + return data; + } +} + +// The singleton exposed by this module. +export const formAutofillStorage = new FormAutofillStorage( + PathUtils.join(PathUtils.profileDir, PROFILE_JSON_FILE_NAME) +); diff --git a/toolkit/components/formautofill/jar.mn b/toolkit/components/formautofill/jar.mn new file mode 100644 index 0000000000..d840f267d3 --- /dev/null +++ b/toolkit/components/formautofill/jar.mn @@ -0,0 +1,17 @@ +# 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/. + +toolkit.jar: +% resource autofill %res/autofill/ + res/autofill/ (./*.sys.mjs) + res/autofill/phonenumberutils/ (./phonenumberutils/*.sys.mjs) + res/autofill/addressmetadata/ (./addressmetadata/*) + res/autofill/content/ (./content/*) +#ifdef ANDROID + res/autofill/FormAutofillPrompter.sys.mjs (./android/FormAutofillPrompter.sys.mjs) + res/autofill/FormAutofillStorage.sys.mjs (./android/FormAutofillStorage.sys.mjs) +#else + res/autofill/FormAutofillPrompter.sys.mjs (./default/FormAutofillPrompter.sys.mjs) + res/autofill/FormAutofillStorage.sys.mjs (./default/FormAutofillStorage.sys.mjs) +#endif diff --git a/toolkit/components/formautofill/moz.build b/toolkit/components/formautofill/moz.build new file mode 100644 index 0000000000..9795990ba6 --- /dev/null +++ b/toolkit/components/formautofill/moz.build @@ -0,0 +1,33 @@ +# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*- +# vim: set filetype=python: +# 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/. + +JAR_MANIFESTS += ["jar.mn"] + +with Files("**"): + BUG_COMPONENT = ("Toolkit", "Form Autofill") + +EXTRA_JS_MODULES.shared += [ + "shared/AddressComponent.sys.mjs", + "shared/AddressParser.sys.mjs", + "shared/CreditCardRuleset.sys.mjs", + "shared/FieldScanner.sys.mjs", + "shared/FormAutofillHandler.sys.mjs", + "shared/FormAutofillHeuristics.sys.mjs", + "shared/FormAutofillNameUtils.sys.mjs", + "shared/FormAutofillSection.sys.mjs", + "shared/FormAutofillUtils.sys.mjs", + "shared/FormStateManager.sys.mjs", + "shared/HeuristicsRegExp.sys.mjs", + "shared/LabelUtils.sys.mjs", +] + +EXPORTS.mozilla += ["FormAutofillNative.h"] + +UNIFIED_SOURCES += ["FormAutofillNative.cpp"] + +include("/ipc/chromium/chromium-config.mozbuild") + +FINAL_LIBRARY = "xul" diff --git a/toolkit/components/formautofill/phonenumberutils/PhoneNumber.sys.mjs b/toolkit/components/formautofill/phonenumberutils/PhoneNumber.sys.mjs new file mode 100644 index 0000000000..80b5e43acb --- /dev/null +++ b/toolkit/components/formautofill/phonenumberutils/PhoneNumber.sys.mjs @@ -0,0 +1,474 @@ +/* This Source Code Form is subject to the terms of the Apache License, Version + * 2.0. If a copy of the Apache License was not distributed with this file, You + * can obtain one at https://www.apache.org/licenses/LICENSE-2.0 */ + +// This library came from https://github.com/andreasgal/PhoneNumber.js but will +// be further maintained by our own in Form Autofill codebase. + +import { PHONE_NUMBER_META_DATA } from "resource://autofill/phonenumberutils/PhoneNumberMetaData.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + PhoneNumberNormalizer: + "resource://autofill/phonenumberutils/PhoneNumberNormalizer.sys.mjs", +}); + +export var PhoneNumber = (function (dataBase) { + const MAX_PHONE_NUMBER_LENGTH = 50; + const NON_ALPHA_CHARS = /[^a-zA-Z]/g; + const NON_DIALABLE_CHARS = /[^,#+\*\d]/g; + const NON_DIALABLE_CHARS_ONCE = new RegExp(NON_DIALABLE_CHARS.source); + const SPLIT_FIRST_GROUP = /^(\d+)(.*)$/; + const LEADING_PLUS_CHARS_PATTERN = /^[+\uFF0B]+/g; + + // Format of the string encoded meta data. If the name contains "^" or "$" + // we will generate a regular expression from the value, with those special + // characters as prefix/suffix. + const META_DATA_ENCODING = [ + "region", + "^(?:internationalPrefix)", + "nationalPrefix", + "^(?:nationalPrefixForParsing)", + "nationalPrefixTransformRule", + "nationalPrefixFormattingRule", + "^possiblePattern$", + "^nationalPattern$", + "formats", + ]; + + const FORMAT_ENCODING = [ + "^pattern$", + "nationalFormat", + "^leadingDigits", + "nationalPrefixFormattingRule", + "internationalFormat", + ]; + + let regionCache = Object.create(null); + + // Parse an array of strings into a convenient object. We store meta + // data as arrays since thats much more compact than JSON. + function ParseArray(array, encoding, obj) { + for (let n = 0; n < encoding.length; ++n) { + let value = array[n]; + if (!value) { + continue; + } + let field = encoding[n]; + let fieldAlpha = field.replace(NON_ALPHA_CHARS, ""); + if (field != fieldAlpha) { + value = new RegExp(field.replace(fieldAlpha, value)); + } + obj[fieldAlpha] = value; + } + return obj; + } + + // Parse string encoded meta data into a convenient object + // representation. + function ParseMetaData(countryCode, md) { + let array = JSON.parse(md); + md = ParseArray(array, META_DATA_ENCODING, { countryCode }); + regionCache[md.region] = md; + return md; + } + + // Parse string encoded format data into a convenient object + // representation. + function ParseFormat(md) { + let formats = md.formats; + if (!formats) { + return; + } + // Bail if we already parsed the format definitions. + if (!Array.isArray(formats[0])) { + return; + } + for (let n = 0; n < formats.length; ++n) { + formats[n] = ParseArray(formats[n], FORMAT_ENCODING, {}); + } + } + + // Search for the meta data associated with a region identifier ("US") in + // our database, which is indexed by country code ("1"). Since we have + // to walk the entire database for this, we cache the result of the lookup + // for future reference. + function FindMetaDataForRegion(region) { + // Check in the region cache first. This will find all entries we have + // already resolved (parsed from a string encoding). + let md = regionCache[region]; + if (md) { + return md; + } + for (let countryCode in dataBase) { + let entry = dataBase[countryCode]; + // Each entry is a string encoded object of the form '["US..', or + // an array of strings. We don't want to parse the string here + // to save memory, so we just substring the region identifier + // and compare it. For arrays, we compare against all region + // identifiers with that country code. We skip entries that are + // of type object, because they were already resolved (parsed into + // an object), and their country code should have been in the cache. + if (Array.isArray(entry)) { + for (let n = 0; n < entry.length; n++) { + if (typeof entry[n] == "string" && entry[n].substr(2, 2) == region) { + if (n > 0) { + // Only the first entry has the formats field set. + // Parse the main country if we haven't already and use + // the formats field from the main country. + if (typeof entry[0] == "string") { + entry[0] = ParseMetaData(countryCode, entry[0]); + } + let formats = entry[0].formats; + let current = ParseMetaData(countryCode, entry[n]); + current.formats = formats; + entry[n] = current; + return entry[n]; + } + + entry[n] = ParseMetaData(countryCode, entry[n]); + return entry[n]; + } + } + continue; + } + if (typeof entry == "string" && entry.substr(2, 2) == region) { + dataBase[countryCode] = ParseMetaData(countryCode, entry); + return dataBase[countryCode]; + } + } + } + + // Format a national number for a given region. The boolean flag "intl" + // indicates whether we want the national or international format. + function FormatNumber(regionMetaData, number, intl) { + // We lazily parse the format description in the meta data for the region, + // so make sure to parse it now if we haven't already done so. + ParseFormat(regionMetaData); + let formats = regionMetaData.formats; + if (!formats) { + return null; + } + for (let n = 0; n < formats.length; ++n) { + let format = formats[n]; + // The leading digits field is optional. If we don't have it, just + // use the matching pattern to qualify numbers. + if (format.leadingDigits && !format.leadingDigits.test(number)) { + continue; + } + if (!format.pattern.test(number)) { + continue; + } + if (intl) { + // If there is no international format, just fall back to the national + // format. + let internationalFormat = format.internationalFormat; + if (!internationalFormat) { + internationalFormat = format.nationalFormat; + } + // Some regions have numbers that can't be dialed from outside the + // country, indicated by "NA" for the international format of that + // number format pattern. + if (internationalFormat == "NA") { + return null; + } + // Prepend "+" and the country code. + number = + "+" + + regionMetaData.countryCode + + " " + + number.replace(format.pattern, internationalFormat); + } else { + number = number.replace(format.pattern, format.nationalFormat); + // The region has a national prefix formatting rule, and it can be overwritten + // by each actual number format rule. + let nationalPrefixFormattingRule = + regionMetaData.nationalPrefixFormattingRule; + if (format.nationalPrefixFormattingRule) { + nationalPrefixFormattingRule = format.nationalPrefixFormattingRule; + } + if (nationalPrefixFormattingRule) { + // The prefix formatting rule contains two magic markers, "$NP" and "$FG". + // "$NP" will be replaced by the national prefix, and "$FG" with the + // first group of numbers. + let match = number.match(SPLIT_FIRST_GROUP); + if (match) { + let firstGroup = match[1]; + let rest = match[2]; + let prefix = nationalPrefixFormattingRule; + prefix = prefix.replace("$NP", regionMetaData.nationalPrefix); + prefix = prefix.replace("$FG", firstGroup); + number = prefix + rest; + } + } + } + return number == "NA" ? null : number; + } + return null; + } + + function NationalNumber(regionMetaData, number) { + this.region = regionMetaData.region; + this.regionMetaData = regionMetaData; + this.number = number; + } + + // NationalNumber represents the result of parsing a phone number. We have + // three getters on the prototype that format the number in national and + // international format. Once called, the getters put a direct property + // onto the object, caching the result. + NationalNumber.prototype = { + // +1 949-726-2896 + get internationalFormat() { + let value = FormatNumber(this.regionMetaData, this.number, true); + Object.defineProperty(this, "internationalFormat", { + value, + enumerable: true, + }); + return value; + }, + // (949) 726-2896 + get nationalFormat() { + let value = FormatNumber(this.regionMetaData, this.number, false); + Object.defineProperty(this, "nationalFormat", { + value, + enumerable: true, + }); + return value; + }, + // +19497262896 + get internationalNumber() { + let value = this.internationalFormat + ? this.internationalFormat.replace(NON_DIALABLE_CHARS, "") + : null; + Object.defineProperty(this, "internationalNumber", { + value, + enumerable: true, + }); + return value; + }, + // 9497262896 + get nationalNumber() { + let value = this.nationalFormat + ? this.nationalFormat.replace(NON_DIALABLE_CHARS, "") + : null; + Object.defineProperty(this, "nationalNumber", { + value, + enumerable: true, + }); + return value; + }, + // country name 'US' + get countryName() { + let value = this.region ? this.region : null; + Object.defineProperty(this, "countryName", { value, enumerable: true }); + return value; + }, + // country code '+1' + get countryCode() { + let value = this.regionMetaData.countryCode + ? "+" + this.regionMetaData.countryCode + : null; + Object.defineProperty(this, "countryCode", { value, enumerable: true }); + return value; + }, + }; + + // Check whether the number is valid for the given region. + function IsValidNumber(number, md) { + return md.possiblePattern.test(number); + } + + // Check whether the number is a valid national number for the given region. + /* eslint-disable no-unused-vars */ + function IsNationalNumber(number, md) { + return IsValidNumber(number, md) && md.nationalPattern.test(number); + } + + // Determine the country code a number starts with, or return null if + // its not a valid country code. + function ParseCountryCode(number) { + for (let n = 1; n <= 3; ++n) { + let cc = number.substr(0, n); + if (dataBase[cc]) { + return cc; + } + } + return null; + } + + // Parse a national number for a specific region. Return null if the + // number is not a valid national number (it might still be a possible + // number for parts of that region). + function ParseNationalNumber(number, md) { + if (!md.possiblePattern.test(number) || !md.nationalPattern.test(number)) { + return null; + } + // Success. + return new NationalNumber(md, number); + } + + function ParseNationalNumberAndCheckNationalPrefix(number, md) { + let ret; + + // This is not an international number. See if its a national one for + // the current region. National numbers can start with the national + // prefix, or without. + if (md.nationalPrefixForParsing) { + // Some regions have specific national prefix parse rules. Apply those. + let withoutPrefix = number.replace( + md.nationalPrefixForParsing, + md.nationalPrefixTransformRule || "" + ); + ret = ParseNationalNumber(withoutPrefix, md); + if (ret) { + return ret; + } + } else { + // If there is no specific national prefix rule, just strip off the + // national prefix from the beginning of the number (if there is one). + let nationalPrefix = md.nationalPrefix; + if ( + nationalPrefix && + number.indexOf(nationalPrefix) == 0 && + (ret = ParseNationalNumber(number.substr(nationalPrefix.length), md)) + ) { + return ret; + } + } + ret = ParseNationalNumber(number, md); + if (ret) { + return ret; + } + } + + function ParseNumberByCountryCode(number, countryCode) { + let ret; + + // Lookup the meta data for the region (or regions) and if the rest of + // the number parses for that region, return the parsed number. + let entry = dataBase[countryCode]; + if (Array.isArray(entry)) { + for (let n = 0; n < entry.length; ++n) { + if (typeof entry[n] == "string") { + entry[n] = ParseMetaData(countryCode, entry[n]); + } + if (n > 0) { + entry[n].formats = entry[0].formats; + } + ret = ParseNationalNumberAndCheckNationalPrefix(number, entry[n]); + if (ret) { + return ret; + } + } + return null; + } + if (typeof entry == "string") { + entry = dataBase[countryCode] = ParseMetaData(countryCode, entry); + } + return ParseNationalNumberAndCheckNationalPrefix(number, entry); + } + + // Parse an international number that starts with the country code. Return + // null if the number is not a valid international number. + function ParseInternationalNumber(number) { + // Parse and strip the country code. + let countryCode = ParseCountryCode(number); + if (!countryCode) { + return null; + } + number = number.substr(countryCode.length); + + return ParseNumberByCountryCode(number, countryCode); + } + + // Parse a number and transform it into the national format, removing any + // international dial prefixes and country codes. + function ParseNumber(number, defaultRegion) { + let ret; + + // Remove formating characters and whitespace. + number = lazy.PhoneNumberNormalizer.Normalize(number); + + // If there is no defaultRegion or the defaultRegion is the global region, + // we can't parse international access codes. + if ((!defaultRegion || defaultRegion === "001") && number[0] !== "+") { + return null; + } + + // Detect and strip leading '+'. + if (number[0] === "+") { + return ParseInternationalNumber( + number.replace(LEADING_PLUS_CHARS_PATTERN, "") + ); + } + + // If "defaultRegion" is a country code, use it to parse the number directly. + let matches = String(defaultRegion).match(/^\+?(\d+)/); + if (matches) { + let countryCode = ParseCountryCode(matches[1]); + if (!countryCode) { + return null; + } + return ParseNumberByCountryCode(number, countryCode); + } + + // Lookup the meta data for the given region. + let md = FindMetaDataForRegion(defaultRegion.toUpperCase()); + if (!md) { + dump("Couldn't find Meta Data for region: " + defaultRegion + "\n"); + return null; + } + + // See if the number starts with an international prefix, and if the + // number resulting from stripping the code is valid, then remove the + // prefix and flag the number as international. + if (md.internationalPrefix.test(number)) { + let possibleNumber = number.replace(md.internationalPrefix, ""); + ret = ParseInternationalNumber(possibleNumber); + if (ret) { + return ret; + } + } + + ret = ParseNationalNumberAndCheckNationalPrefix(number, md); + if (ret) { + return ret; + } + + // Now lets see if maybe its an international number after all, but + // without '+' or the international prefix. + ret = ParseInternationalNumber(number); + if (ret) { + return ret; + } + + // If the number matches the possible numbers of the current region, + // return it as a possible number. + if (md.possiblePattern.test(number)) { + return new NationalNumber(md, number); + } + + // We couldn't parse the number at all. + return null; + } + + function IsPlainPhoneNumber(number) { + if (typeof number !== "string") { + return false; + } + + let length = number.length; + let isTooLong = length > MAX_PHONE_NUMBER_LENGTH; + let isEmpty = length === 0; + return !(isTooLong || isEmpty || NON_DIALABLE_CHARS_ONCE.test(number)); + } + + return { + IsPlain: IsPlainPhoneNumber, + IsValid: IsValidNumber, + Parse: ParseNumber, + FindMetaDataForRegion, + }; +})(PHONE_NUMBER_META_DATA); diff --git a/toolkit/components/formautofill/phonenumberutils/PhoneNumberMetaData.sys.mjs b/toolkit/components/formautofill/phonenumberutils/PhoneNumberMetaData.sys.mjs new file mode 100644 index 0000000000..3338ce7c16 --- /dev/null +++ b/toolkit/components/formautofill/phonenumberutils/PhoneNumberMetaData.sys.mjs @@ -0,0 +1,291 @@ +/* This Source Code Form is subject to the terms of the Apache License, Version + * 2.0. If a copy of the Apache License was not distributed with this file, You + * can obtain one at https://www.apache.org/licenses/LICENSE-2.0 */ + +/* + * This data was generated base on libphonenumber v8.4.1 via the script in + * https://github.com/andreasgal/PhoneNumber.js + * + * The XML format of libphonenumber has changed since v8.4.2 so we can only stay + * in this version for now. + */ + +export var PHONE_NUMBER_META_DATA = { + 46: '["SE","00","0",null,null,"$NP$FG","\\\\d{6,12}","[1-35-9]\\\\d{5,11}|4\\\\d{6,8}",[["(8)(\\\\d{2,3})(\\\\d{2,3})(\\\\d{2})","$1-$2 $3 $4","8",null,"$1 $2 $3 $4"],["([1-69]\\\\d)(\\\\d{2,3})(\\\\d{2})(\\\\d{2})","$1-$2 $3 $4","1[013689]|2[0136]|3[1356]|4[0246]|54|6[03]|90",null,"$1 $2 $3 $4"],["([1-469]\\\\d)(\\\\d{3})(\\\\d{2})","$1-$2 $3","1[136]|2[136]|3[356]|4[0246]|6[03]|90",null,"$1 $2 $3"],["(\\\\d{3})(\\\\d{2})(\\\\d{2})(\\\\d{2})","$1-$2 $3 $4","1[2457]|2(?:[247-9]|5[0138])|3[0247-9]|4[1357-9]|5[0-35-9]|6(?:[124-689]|7[0-2])|9(?:[125-8]|3[0-5]|4[0-3])",null,"$1 $2 $3 $4"],["(\\\\d{3})(\\\\d{2,3})(\\\\d{2})","$1-$2 $3","1[2457]|2(?:[247-9]|5[0138])|3[0247-9]|4[1357-9]|5[0-35-9]|6(?:[124-689]|7[0-2])|9(?:[125-8]|3[0-5]|4[0-3])",null,"$1 $2 $3"],["(7\\\\d)(\\\\d{3})(\\\\d{2})(\\\\d{2})","$1-$2 $3 $4","7",null,"$1 $2 $3 $4"],["(77)(\\\\d{2})(\\\\d{2})","$1-$2$3","7",null,"$1 $2 $3"],["(20)(\\\\d{2,3})(\\\\d{2})","$1-$2 $3","20",null,"$1 $2 $3"],["(9[034]\\\\d)(\\\\d{2})(\\\\d{2})(\\\\d{3})","$1-$2 $3 $4","9[034]",null,"$1 $2 $3 $4"],["(9[034]\\\\d)(\\\\d{4})","$1-$2","9[034]",null,"$1 $2"],["(\\\\d{3})(\\\\d{2})(\\\\d{3})(\\\\d{2})(\\\\d{2})","$1-$2 $3 $4 $5","25[245]|67[3-6]",null,"$1 $2 $3 $4 $5"]]]', + 299: '["GL","00",null,null,null,null,"\\\\d{6}","[1-689]\\\\d{5}",[["(\\\\d{2})(\\\\d{2})(\\\\d{2})","$1 $2 $3",null,null]]]', + 385: '["HR","00","0",null,null,"$NP$FG","\\\\d{6,9}","[1-7]\\\\d{5,8}|[89]\\\\d{6,8}",[["(1)(\\\\d{4})(\\\\d{3})","$1 $2 $3","1",null],["([2-5]\\\\d)(\\\\d{3})(\\\\d{3,4})","$1 $2 $3","[2-5]",null],["(9\\\\d)(\\\\d{3})(\\\\d{3,4})","$1 $2 $3","9",null],["(6[01])(\\\\d{2})(\\\\d{2,3})","$1 $2 $3","6[01]",null],["([67]\\\\d)(\\\\d{3})(\\\\d{3,4})","$1 $2 $3","[67]",null],["(80[01])(\\\\d{2})(\\\\d{2,3})","$1 $2 $3","8",null],["(80[01])(\\\\d{3})(\\\\d{3})","$1 $2 $3","8",null]]]', + 670: '["TL","00",null,null,null,null,"\\\\d{7,8}","[2-489]\\\\d{6}|7\\\\d{6,7}",[["(\\\\d{3})(\\\\d{4})","$1 $2","[2-489]",null],["(\\\\d{4})(\\\\d{4})","$1 $2","7",null]]]', + 258: '["MZ","00",null,null,null,null,"\\\\d{8,9}","[28]\\\\d{7,8}",[["([28]\\\\d)(\\\\d{3})(\\\\d{3,4})","$1 $2 $3","2|8[2-7]",null],["(80\\\\d)(\\\\d{3})(\\\\d{3})","$1 $2 $3","80",null]]]', + 359: '["BG","00","0",null,null,"$NP$FG","\\\\d{5,9}","[23567]\\\\d{5,7}|[489]\\\\d{6,8}",[["(2)(\\\\d)(\\\\d{2})(\\\\d{2})","$1 $2 $3 $4","2",null],["(2)(\\\\d{3})(\\\\d{3,4})","$1 $2 $3","2",null],["(\\\\d{3})(\\\\d{4})","$1 $2","43[124-7]|70[1-9]",null],["(\\\\d{3})(\\\\d{3})(\\\\d{2})","$1 $2 $3","43[124-7]|70[1-9]",null],["(\\\\d{3})(\\\\d{2})(\\\\d{3})","$1 $2 $3","[78]00",null],["(\\\\d{3})(\\\\d{3})(\\\\d{3})","$1 $2 $3","999",null],["(\\\\d{2})(\\\\d{3})(\\\\d{2,3})","$1 $2 $3","[356]|4[124-7]|7[1-9]|8[1-6]|9[1-7]",null],["(\\\\d{2})(\\\\d{3})(\\\\d{3,4})","$1 $2 $3","48|8[7-9]|9[08]",null]]]', + 682: '["CK","00",null,null,null,null,"\\\\d{5}","[2-8]\\\\d{4}",[["(\\\\d{2})(\\\\d{3})","$1 $2",null,null]]]', + 852: '["HK","00(?:[126-9]|30|5[09])?",null,null,null,null,"\\\\d{5,11}","[235-7]\\\\d{7}|8\\\\d{7,8}|9\\\\d{4,10}",[["(\\\\d{4})(\\\\d{4})","$1 $2","[235-7]|[89](?:0[1-9]|[1-9])",null],["(800)(\\\\d{3})(\\\\d{3})","$1 $2 $3","800",null],["(900)(\\\\d{2})(\\\\d{3})(\\\\d{3})","$1 $2 $3 $4","900",null],["(900)(\\\\d{2,5})","$1 $2","900",null]]]', + 998: '["UZ","810","8",null,null,"$NP $FG","\\\\d{7,9}","[679]\\\\d{8}",[["([679]\\\\d)(\\\\d{3})(\\\\d{2})(\\\\d{2})","$1 $2 $3 $4",null,null]]]', + 291: '["ER","00","0",null,null,"$NP$FG","\\\\d{6,7}","[178]\\\\d{6}",[["(\\\\d)(\\\\d{3})(\\\\d{3})","$1 $2 $3",null,null]]]', + 95: '["MM","00","0",null,null,"$NP$FG","\\\\d{5,10}","[1478]\\\\d{5,7}|[256]\\\\d{5,8}|9(?:[279]\\\\d{0,2}|[58]|[34]\\\\d{1,2}|6\\\\d?)\\\\d{6}",[["(\\\\d)(\\\\d{3})(\\\\d{3,4})","$1 $2 $3","1|2[245]",null],["(2)(\\\\d{4})(\\\\d{4})","$1 $2 $3","251",null],["(\\\\d)(\\\\d{2})(\\\\d{3})","$1 $2 $3","16|2",null],["(\\\\d{2})(\\\\d{3})(\\\\d{3,4})","$1 $2 $3","67|81",null],["(\\\\d{2})(\\\\d{2})(\\\\d{3,4})","$1 $2 $3","[4-8]",null],["(9)(\\\\d{3})(\\\\d{4,6})","$1 $2 $3","9(?:2[0-4]|[35-9]|4[137-9])",null],["(9)([34]\\\\d{4})(\\\\d{4})","$1 $2 $3","9(?:3[0-36]|4[0-57-9])",null],["(9)(\\\\d{3})(\\\\d{3})(\\\\d{3})","$1 $2 $3 $4","92[56]",null],["(9)(\\\\d{3})(\\\\d{3})(\\\\d{2})","$1 $2 $3 $4","93",null]]]', + 266: '["LS","00",null,null,null,null,"\\\\d{8}","[2568]\\\\d{7}",[["(\\\\d{4})(\\\\d{4})","$1 $2",null,null]]]', + 245: '["GW","00",null,null,null,null,"\\\\d{7,9}","(?:4(?:0\\\\d{5}|4\\\\d{7})|9\\\\d{8})",[["(\\\\d{3})(\\\\d{3})(\\\\d{3})","$1 $2 $3","44|9[567]",null],["(\\\\d{3})(\\\\d{4})","$1 $2","40",null]]]', + 374: '["AM","00","0",null,null,"($NP$FG)","\\\\d{5,8}","[1-9]\\\\d{7}",[["(\\\\d{2})(\\\\d{6})","$1 $2","1|47",null],["(\\\\d{2})(\\\\d{6})","$1 $2","4[1349]|[5-7]|9[1-9]","$NP$FG"],["(\\\\d{3})(\\\\d{5})","$1 $2","[23]",null],["(\\\\d{3})(\\\\d{2})(\\\\d{3})","$1 $2 $3","8|90","$NP $FG"]]]', + 61: [ + '["AU","(?:14(?:1[14]|34|4[17]|[56]6|7[47]|88))?001[14-689]","0",null,null,null,"\\\\d{6,10}","1\\\\d{4,9}|[2-578]\\\\d{8}",[["([2378])(\\\\d{4})(\\\\d{4})","$1 $2 $3","[2378]","($NP$FG)"],["(\\\\d{3})(\\\\d{3})(\\\\d{3})","$1 $2 $3","[45]|14","$NP$FG"],["(16)(\\\\d{3,4})","$1 $2","16","$NP$FG"],["(16)(\\\\d{3})(\\\\d{2,4})","$1 $2 $3","16","$NP$FG"],["(1[389]\\\\d{2})(\\\\d{3})(\\\\d{3})","$1 $2 $3","1(?:[38]0|90)","$FG"],["(180)(2\\\\d{3})","$1 $2","180","$FG"],["(19\\\\d)(\\\\d{3})","$1 $2","19[13]","$FG"],["(19\\\\d{2})(\\\\d{4})","$1 $2","19[679]","$FG"],["(13)(\\\\d{2})(\\\\d{2})","$1 $2 $3","13[1-9]","$FG"]]]', + '["CC","(?:14(?:1[14]|34|4[17]|[56]6|7[47]|88))?001[14-689]","0",null,null,null,"\\\\d{6,10}","[1458]\\\\d{5,9}"]', + '["CX","(?:14(?:1[14]|34|4[17]|[56]6|7[47]|88))?001[14-689]","0",null,null,null,"\\\\d{6,10}","[1458]\\\\d{5,9}"]', + ], + 500: '["FK","00",null,null,null,null,"\\\\d{5}","[2-7]\\\\d{4}"]', + 261: '["MG","00","0",null,null,"$NP$FG","\\\\d{7,9}","[23]\\\\d{8}",[["([23]\\\\d)(\\\\d{2})(\\\\d{3})(\\\\d{2})","$1 $2 $3 $4",null,null]]]', + 92: '["PK","00","0",null,null,"($NP$FG)","\\\\d{6,12}","1\\\\d{8}|[2-8]\\\\d{5,11}|9(?:[013-9]\\\\d{4,9}|2\\\\d(?:111\\\\d{6}|\\\\d{3,7}))",[["(\\\\d{2})(111)(\\\\d{3})(\\\\d{3})","$1 $2 $3 $4","(?:2[125]|4[0-246-9]|5[1-35-7]|6[1-8]|7[14]|8[16]|91)1",null],["(\\\\d{3})(111)(\\\\d{3})(\\\\d{3})","$1 $2 $3 $4","2[349]|45|54|60|72|8[2-5]|9[2-9]",null],["(\\\\d{2})(\\\\d{7,8})","$1 $2","(?:2[125]|4[0-246-9]|5[1-35-7]|6[1-8]|7[14]|8[16]|91)[2-9]",null],["(\\\\d{3})(\\\\d{6,7})","$1 $2","2[349]|45|54|60|72|8[2-5]|9[2-9]",null],["(3\\\\d{2})(\\\\d{7})","$1 $2","3","$NP$FG"],["([15]\\\\d{3})(\\\\d{5,6})","$1 $2","58[12]|1",null],["(586\\\\d{2})(\\\\d{5})","$1 $2","586",null],["([89]00)(\\\\d{3})(\\\\d{2})","$1 $2 $3","[89]00","$NP$FG"]]]', + 234: '["NG","009","0",null,null,"$NP$FG","\\\\d{5,14}","[1-6]\\\\d{5,8}|9\\\\d{5,9}|[78]\\\\d{5,13}",[["(\\\\d)(\\\\d{3})(\\\\d{3,4})","$1 $2 $3","[12]|9(?:0[3-9]|[1-9])",null],["(\\\\d{2})(\\\\d{3})(\\\\d{2,3})","$1 $2 $3","[3-6]|7(?:[1-79]|0[1-9])|8[2-9]",null],["(\\\\d{3})(\\\\d{3})(\\\\d{3,4})","$1 $2 $3","70|8[01]|90[235-9]",null],["([78]00)(\\\\d{4})(\\\\d{4,5})","$1 $2 $3","[78]00",null],["([78]00)(\\\\d{5})(\\\\d{5,6})","$1 $2 $3","[78]00",null],["(78)(\\\\d{2})(\\\\d{3})","$1 $2 $3","78",null]]]', + 350: '["GI","00",null,null,null,null,"\\\\d{8}","[2568]\\\\d{7}",[["(\\\\d{3})(\\\\d{5})","$1 $2","2",null]]]', + 45: '["DK","00",null,null,null,null,"\\\\d{8}","[2-9]\\\\d{7}",[["(\\\\d{2})(\\\\d{2})(\\\\d{2})(\\\\d{2})","$1 $2 $3 $4",null,null]]]', + 963: '["SY","00","0",null,null,"$NP$FG","\\\\d{6,9}","[1-59]\\\\d{7,8}",[["(\\\\d{2})(\\\\d{3})(\\\\d{3,4})","$1 $2 $3","[1-5]",null],["(9\\\\d{2})(\\\\d{3})(\\\\d{3})","$1 $2 $3","9",null]]]', + 226: '["BF","00",null,null,null,null,"\\\\d{8}","[25-7]\\\\d{7}",[["(\\\\d{2})(\\\\d{2})(\\\\d{2})(\\\\d{2})","$1 $2 $3 $4",null,null]]]', + 974: '["QA","00",null,null,null,null,"\\\\d{7,8}","[2-8]\\\\d{6,7}",[["([28]\\\\d{2})(\\\\d{4})","$1 $2","[28]",null],["([3-7]\\\\d{3})(\\\\d{4})","$1 $2","[3-7]",null]]]', + 218: '["LY","00","0",null,null,"$NP$FG","\\\\d{7,9}","[25679]\\\\d{8}",[["([25679]\\\\d)(\\\\d{7})","$1-$2",null,null]]]', + 51: '["PE","19(?:1[124]|77|90)00","0",null,null,"($NP$FG)","\\\\d{6,9}","[14-9]\\\\d{7,8}",[["(1)(\\\\d{7})","$1 $2","1",null],["([4-8]\\\\d)(\\\\d{6})","$1 $2","[4-7]|8[2-4]",null],["(\\\\d{3})(\\\\d{5})","$1 $2","80",null],["(9\\\\d{2})(\\\\d{3})(\\\\d{3})","$1 $2 $3","9","$FG"]]]', + 62: '["ID","0(?:0[1789]|10(?:00|1[67]))","0",null,null,"$NP$FG","\\\\d{5,12}","(?:[1-79]\\\\d{6,10}|8\\\\d{7,11})",[["(\\\\d{2})(\\\\d{5,8})","$1 $2","2[124]|[36]1","($NP$FG)"],["(\\\\d{3})(\\\\d{5,8})","$1 $2","[4579]|2[035-9]|[36][02-9]","($NP$FG)"],["(8\\\\d{2})(\\\\d{3,4})(\\\\d{3})","$1-$2-$3","8[1-35-9]",null],["(8\\\\d{2})(\\\\d{4})(\\\\d{4,5})","$1-$2-$3","8[1-35-9]",null],["(1)(500)(\\\\d{3})","$1 $2 $3","15","$FG"],["(177)(\\\\d{6,8})","$1 $2","17",null],["(800)(\\\\d{5,7})","$1 $2","800",null],["(804)(\\\\d{3})(\\\\d{4})","$1 $2 $3","804",null],["(80\\\\d)(\\\\d)(\\\\d{3})(\\\\d{3})","$1 $2 $3 $4","80[79]",null]]]', + 298: '["FO","00",null,"(10(?:01|[12]0|88))",null,null,"\\\\d{6}","[2-9]\\\\d{5}",[["(\\\\d{6})","$1",null,null]]]', + 381: '["RS","00","0",null,null,"$NP$FG","\\\\d{5,12}","[126-9]\\\\d{4,11}|3(?:[0-79]\\\\d{3,10}|8[2-9]\\\\d{2,9})",[["([23]\\\\d{2})(\\\\d{4,9})","$1 $2","(?:2[389]|39)0",null],["([1-3]\\\\d)(\\\\d{5,10})","$1 $2","1|2(?:[0-24-7]|[389][1-9])|3(?:[0-8]|9[1-9])",null],["(6\\\\d)(\\\\d{6,8})","$1 $2","6",null],["([89]\\\\d{2})(\\\\d{3,9})","$1 $2","[89]",null],["(7[26])(\\\\d{4,9})","$1 $2","7[26]",null],["(7[08]\\\\d)(\\\\d{4,9})","$1 $2","7[08]",null]]]', + 975: '["BT","00",null,null,null,null,"\\\\d{6,8}","[1-8]\\\\d{6,7}",[["(\\\\d{2})(\\\\d{2})(\\\\d{2})(\\\\d{2})","$1 $2 $3 $4","1|77",null],["([2-8])(\\\\d{3})(\\\\d{3})","$1 $2 $3","[2-68]|7[246]",null]]]', + 34: '["ES","00",null,null,null,null,"\\\\d{9}","[5-9]\\\\d{8}",[["([89]00)(\\\\d{3})(\\\\d{3})","$1 $2 $3","[89]00",null],["([5-9]\\\\d{2})(\\\\d{2})(\\\\d{2})(\\\\d{2})","$1 $2 $3 $4","[568]|[79][0-8]",null]]]', + 881: '["001",null,null,null,null,null,"\\\\d{9}","[67]\\\\d{8}",[["(\\\\d)(\\\\d{3})(\\\\d{5})","$1 $2 $3","[67]",null]]]', + 855: '["KH","00[14-9]","0",null,null,null,"\\\\d{6,10}","[1-9]\\\\d{7,9}",[["(\\\\d{2})(\\\\d{3})(\\\\d{3,4})","$1 $2 $3","1\\\\d[1-9]|[2-9]","$NP$FG"],["(1[89]00)(\\\\d{3})(\\\\d{3})","$1 $2 $3","1[89]0",null]]]', + 420: '["CZ","00",null,null,null,null,"\\\\d{9,12}","[2-8]\\\\d{8}|9\\\\d{8,11}",[["([2-9]\\\\d{2})(\\\\d{3})(\\\\d{3})","$1 $2 $3","[2-8]|9[015-7]",null],["(96\\\\d)(\\\\d{3})(\\\\d{3})(\\\\d{3})","$1 $2 $3 $4","96",null],["(9\\\\d)(\\\\d{3})(\\\\d{3})(\\\\d{3})","$1 $2 $3 $4","9[36]",null]]]', + 216: '["TN","00",null,null,null,null,"\\\\d{8}","[2-57-9]\\\\d{7}",[["(\\\\d{2})(\\\\d{3})(\\\\d{3})","$1 $2 $3",null,null]]]', + 673: '["BN","00",null,null,null,null,"\\\\d{7}","[2-578]\\\\d{6}",[["([2-578]\\\\d{2})(\\\\d{4})","$1 $2",null,null]]]', + 290: [ + '["SH","00",null,null,null,null,"\\\\d{4,5}","[256]\\\\d{4}"]', + '["TA","00",null,null,null,null,"\\\\d{4}","8\\\\d{3}"]', + ], + 882: '["001",null,null,null,null,null,"\\\\d{7,12}","[13]\\\\d{6,11}",[["(\\\\d{2})(\\\\d{4})(\\\\d{3})","$1 $2 $3","3[23]",null],["(\\\\d{2})(\\\\d{5})","$1 $2","16|342",null],["(\\\\d{2})(\\\\d{4})(\\\\d{4})","$1 $2 $3","34[57]",null],["(\\\\d{3})(\\\\d{4})(\\\\d{4})","$1 $2 $3","348",null],["(\\\\d{2})(\\\\d{2})(\\\\d{4})","$1 $2 $3","1",null],["(\\\\d{2})(\\\\d{3,4})(\\\\d{4})","$1 $2 $3","16",null],["(\\\\d{2})(\\\\d{4,5})(\\\\d{5})","$1 $2 $3","16|39",null]]]', + 267: '["BW","00",null,null,null,null,"\\\\d{7,8}","[2-79]\\\\d{6,7}",[["(\\\\d{3})(\\\\d{4})","$1 $2","[2-6]",null],["(7\\\\d)(\\\\d{3})(\\\\d{3})","$1 $2 $3","7",null],["(90)(\\\\d{5})","$1 $2","9",null]]]', + 94: '["LK","00","0",null,null,"$NP$FG","\\\\d{7,9}","[1-9]\\\\d{8}",[["(\\\\d{2})(\\\\d{1})(\\\\d{6})","$1 $2 $3","[1-689]",null],["(\\\\d{2})(\\\\d{3})(\\\\d{4})","$1 $2 $3","7",null]]]', + 356: '["MT","00",null,null,null,null,"\\\\d{8}","[2357-9]\\\\d{7}",[["(\\\\d{4})(\\\\d{4})","$1 $2",null,null]]]', + 375: '["BY","810","8","8?0?",null,null,"\\\\d{5,11}","[1-4]\\\\d{8}|800\\\\d{3,7}|[89]\\\\d{9,10}",[["(\\\\d{2})(\\\\d{3})(\\\\d{2})(\\\\d{2})","$1 $2-$3-$4","17[0-3589]|2[4-9]|[34]","$NP 0$FG"],["(\\\\d{3})(\\\\d{2})(\\\\d{2})(\\\\d{2})","$1 $2-$3-$4","1(?:5[24]|6[235]|7[467])|2(?:1[246]|2[25]|3[26])","$NP 0$FG"],["(\\\\d{4})(\\\\d{2})(\\\\d{3})","$1 $2-$3","1(?:5[169]|6[3-5]|7[179])|2(?:1[35]|2[34]|3[3-5])","$NP 0$FG"],["([89]\\\\d{2})(\\\\d{3})(\\\\d{4})","$1 $2 $3","8[01]|9","$NP $FG"],["(82\\\\d)(\\\\d{4})(\\\\d{4})","$1 $2 $3","82","$NP $FG"],["(800)(\\\\d{3})","$1 $2","800","$NP $FG"],["(800)(\\\\d{2})(\\\\d{2,4})","$1 $2 $3","800","$NP $FG"]]]', + 690: '["TK","00",null,null,null,null,"\\\\d{4,7}","[2-47]\\\\d{3,6}"]', + 507: '["PA","00",null,null,null,null,"\\\\d{7,8}","[1-9]\\\\d{6,7}",[["(\\\\d{3})(\\\\d{4})","$1-$2","[1-57-9]",null],["(\\\\d{4})(\\\\d{4})","$1-$2","6",null]]]', + 692: '["MH","011","1",null,null,null,"\\\\d{7}","[2-6]\\\\d{6}",[["(\\\\d{3})(\\\\d{4})","$1-$2",null,null]]]', + 250: '["RW","00","0",null,null,null,"\\\\d{8,9}","[027-9]\\\\d{7,8}",[["(2\\\\d{2})(\\\\d{3})(\\\\d{3})","$1 $2 $3","2","$FG"],["([7-9]\\\\d{2})(\\\\d{3})(\\\\d{3})","$1 $2 $3","[7-9]","$NP$FG"],["(0\\\\d)(\\\\d{2})(\\\\d{2})(\\\\d{2})","$1 $2 $3 $4","0",null]]]', + 81: '["JP","010","0",null,null,"$NP$FG","\\\\d{8,17}","[1-9]\\\\d{8,9}|00(?:[36]\\\\d{7,14}|7\\\\d{5,7}|8\\\\d{7})",[["(\\\\d{3})(\\\\d{3})(\\\\d{3})","$1-$2-$3","(?:12|57|99)0",null],["(\\\\d{3})(\\\\d{3})(\\\\d{4})","$1-$2-$3","800",null],["(\\\\d{4})(\\\\d{4})","$1-$2","0077","$FG","NA"],["(\\\\d{4})(\\\\d{2})(\\\\d{3,4})","$1-$2-$3","0077","$FG","NA"],["(\\\\d{4})(\\\\d{2})(\\\\d{4})","$1-$2-$3","0088","$FG","NA"],["(\\\\d{4})(\\\\d{3})(\\\\d{3,4})","$1-$2-$3","00(?:37|66)","$FG","NA"],["(\\\\d{4})(\\\\d{4})(\\\\d{4,5})","$1-$2-$3","00(?:37|66)","$FG","NA"],["(\\\\d{4})(\\\\d{5})(\\\\d{5,6})","$1-$2-$3","00(?:37|66)","$FG","NA"],["(\\\\d{4})(\\\\d{6})(\\\\d{6,7})","$1-$2-$3","00(?:37|66)","$FG","NA"],["(\\\\d{2})(\\\\d{4})(\\\\d{4})","$1-$2-$3","[2579]0|80[1-9]",null],["(\\\\d{4})(\\\\d)(\\\\d{4})","$1-$2-$3","1(?:26|3[79]|4[56]|5[4-68]|6[3-5])|5(?:76|97)|499|746|8(?:3[89]|63|47|51)|9(?:49|80|9[16])",null],["(\\\\d{3})(\\\\d{2})(\\\\d{4})","$1-$2-$3","1(?:2[3-6]|3[3-9]|4[2-6]|5[2-8]|[68][2-7]|7[2-689]|9[1-578])|2(?:2[03-689]|3[3-58]|4[0-468]|5[04-8]|6[013-8]|7[06-9]|8[02-57-9]|9[13])|4(?:2[28]|3[689]|6[035-7]|7[05689]|80|9[3-5])|5(?:3[1-36-9]|4[4578]|5[013-8]|6[1-9]|7[2-8]|8[14-7]|9[4-9])|7(?:2[15]|3[5-9]|4[02-9]|6[135-8]|7[0-4689]|9[014-9])|8(?:2[49]|3[3-8]|4[5-8]|5[2-9]|6[35-9]|7[579]|8[03-579]|9[2-8])|9(?:[23]0|4[02-46-9]|5[024-79]|6[4-9]|7[2-47-9]|8[02-7]|9[3-7])",null],["(\\\\d{2})(\\\\d{3})(\\\\d{4})","$1-$2-$3","1|2(?:2[37]|5[5-9]|64|78|8[39]|91)|4(?:2[2689]|64|7[347])|5(?:[2-589]|39)|60|8(?:[46-9]|3[279]|2[124589])|9(?:[235-8]|93)",null],["(\\\\d{3})(\\\\d{2})(\\\\d{4})","$1-$2-$3","2(?:9[14-79]|74|[34]7|[56]9)|82|993",null],["(\\\\d)(\\\\d{4})(\\\\d{4})","$1-$2-$3","3|4(?:2[09]|7[01])|6[1-9]",null],["(\\\\d{2})(\\\\d{3})(\\\\d{4})","$1-$2-$3","[2479][1-9]",null]]]', + 237: '["CM","00",null,null,null,null,"\\\\d{8,9}","[2368]\\\\d{7,8}",[["([26])(\\\\d{2})(\\\\d{2})(\\\\d{2})(\\\\d{2})","$1 $2 $3 $4 $5","[26]",null],["(\\\\d{2})(\\\\d{2})(\\\\d{2})(\\\\d{2})","$1 $2 $3 $4","[23]|88",null],["(800)(\\\\d{2})(\\\\d{3})","$1 $2 $3","80",null]]]', + 351: '["PT","00",null,null,null,null,"\\\\d{9}","[2-46-9]\\\\d{8}",[["(2\\\\d)(\\\\d{3})(\\\\d{4})","$1 $2 $3","2[12]",null],["([2-46-9]\\\\d{2})(\\\\d{3})(\\\\d{3})","$1 $2 $3","2[3-9]|[346-9]",null]]]', + 246: '["IO","00",null,null,null,null,"\\\\d{7}","3\\\\d{6}",[["(\\\\d{3})(\\\\d{4})","$1 $2",null,null]]]', + 227: '["NE","00",null,null,null,null,"\\\\d{8}","[0289]\\\\d{7}",[["(\\\\d{2})(\\\\d{2})(\\\\d{2})(\\\\d{2})","$1 $2 $3 $4","[289]|09",null],["(08)(\\\\d{3})(\\\\d{3})","$1 $2 $3","08",null]]]', + 27: '["ZA","00","0",null,null,"$NP$FG","\\\\d{5,9}","[1-79]\\\\d{8}|8\\\\d{4,8}",[["(860)(\\\\d{3})(\\\\d{3})","$1 $2 $3","860",null],["(\\\\d{2})(\\\\d{3,4})","$1 $2","8[1-4]",null],["(\\\\d{2})(\\\\d{3})(\\\\d{2,3})","$1 $2 $3","8[1-4]",null],["(\\\\d{2})(\\\\d{3})(\\\\d{4})","$1 $2 $3","[1-79]|8(?:[0-57]|6[1-9])",null]]]', + 962: '["JO","00","0",null,null,"$NP$FG","\\\\d{8,9}","[235-9]\\\\d{7,8}",[["(\\\\d)(\\\\d{3})(\\\\d{4})","$1 $2 $3","[2356]|87","($NP$FG)"],["(7)(\\\\d{4})(\\\\d{4})","$1 $2 $3","7[457-9]",null],["(\\\\d{3})(\\\\d{5,6})","$1 $2","70|8[0158]|9",null]]]', + 387: '["BA","00","0",null,null,"$NP$FG","\\\\d{6,9}","[3-9]\\\\d{7,8}",[["(\\\\d{2})(\\\\d{3})(\\\\d{3})","$1 $2-$3","[3-5]",null],["(\\\\d{2})(\\\\d{3})(\\\\d{3})","$1 $2 $3","6[1-356]|[7-9]",null],["(\\\\d{2})(\\\\d{2})(\\\\d{2})(\\\\d{3})","$1 $2 $3 $4","6[047]",null]]]', + 33: '["FR","00","0",null,null,"$NP$FG","\\\\d{9}","[1-9]\\\\d{8}",[["([1-79])(\\\\d{2})(\\\\d{2})(\\\\d{2})(\\\\d{2})","$1 $2 $3 $4 $5","[1-79]",null],["(1\\\\d{2})(\\\\d{3})","$1 $2","11","$FG","NA"],["(8\\\\d{2})(\\\\d{2})(\\\\d{2})(\\\\d{2})","$1 $2 $3 $4","8","$NP $FG"]]]', + 972: '["IL","0(?:0|1[2-9])","0",null,null,"$FG","\\\\d{4,12}","1\\\\d{6,11}|[2-589]\\\\d{3}(?:\\\\d{3,6})?|6\\\\d{3}|7\\\\d{6,9}",[["([2-489])(\\\\d{3})(\\\\d{4})","$1-$2-$3","[2-489]","$NP$FG"],["([57]\\\\d)(\\\\d{3})(\\\\d{4})","$1-$2-$3","[57]","$NP$FG"],["(153)(\\\\d{1,2})(\\\\d{3})(\\\\d{4})","$1 $2 $3 $4","153",null],["(1)([7-9]\\\\d{2})(\\\\d{3})(\\\\d{3})","$1-$2-$3-$4","1[7-9]",null],["(1255)(\\\\d{3})","$1-$2","125",null],["(1200)(\\\\d{3})(\\\\d{3})","$1-$2-$3","120",null],["(1212)(\\\\d{2})(\\\\d{2})","$1-$2-$3","121",null],["(1599)(\\\\d{6})","$1-$2","15",null],["(\\\\d{4})","*$1","[2-689]",null]]]', + 248: '["SC","0(?:[02]|10?)",null,null,null,null,"\\\\d{6,7}","[24689]\\\\d{5,6}",[["(\\\\d)(\\\\d{3})(\\\\d{3})","$1 $2 $3","[246]",null]]]', + 297: '["AW","00",null,null,null,null,"\\\\d{7}","[25-9]\\\\d{6}",[["(\\\\d{3})(\\\\d{4})","$1 $2",null,null]]]', + 421: '["SK","00","0",null,null,"$NP$FG","\\\\d{6,9}","(?:[2-68]\\\\d{5,8}|9\\\\d{6,8})",[["(2)(1[67])(\\\\d{3,4})","$1 $2 $3","21[67]",null],["([3-5]\\\\d)(1[67])(\\\\d{2,3})","$1 $2 $3","[3-5]",null],["(2)(\\\\d{3})(\\\\d{3})(\\\\d{2})","$1/$2 $3 $4","2",null],["([3-5]\\\\d)(\\\\d{3})(\\\\d{2})(\\\\d{2})","$1/$2 $3 $4","[3-5]",null],["([689]\\\\d{2})(\\\\d{3})(\\\\d{3})","$1 $2 $3","[689]",null],["(9090)(\\\\d{3})","$1 $2","9090",null]]]', + 672: '["NF","00",null,null,null,null,"\\\\d{5,6}","[13]\\\\d{5}",[["(\\\\d{2})(\\\\d{4})","$1 $2","1",null],["(\\\\d)(\\\\d{5})","$1 $2","3",null]]]', + 870: '["001",null,null,null,null,null,"\\\\d{9}","[35-7]\\\\d{8}",[["(\\\\d{3})(\\\\d{3})(\\\\d{3})","$1 $2 $3",null,null]]]', + 883: '["001",null,null,null,null,null,"\\\\d{9}(?:\\\\d{3})?","51\\\\d{7}(?:\\\\d{3})?",[["(\\\\d{3})(\\\\d{3})(\\\\d{3})","$1 $2 $3","510",null],["(\\\\d{3})(\\\\d{3})(\\\\d{3})(\\\\d{3})","$1 $2 $3 $4","510",null],["(\\\\d{4})(\\\\d{4})(\\\\d{4})","$1 $2 $3","51[13]",null]]]', + 264: '["NA","00","0",null,null,"$NP$FG","\\\\d{8,9}","[68]\\\\d{7,8}",[["(8\\\\d)(\\\\d{3})(\\\\d{4})","$1 $2 $3","8[1235]",null],["(6\\\\d)(\\\\d{3})(\\\\d{3,4})","$1 $2 $3","6",null],["(88)(\\\\d{3})(\\\\d{3})","$1 $2 $3","88",null],["(870)(\\\\d{3})(\\\\d{3})","$1 $2 $3","870",null]]]', + 878: '["001",null,null,null,null,null,"\\\\d{12}","1\\\\d{11}",[["(\\\\d{2})(\\\\d{5})(\\\\d{5})","$1 $2 $3",null,null]]]', + 239: '["ST","00",null,null,null,null,"\\\\d{7}","[29]\\\\d{6}",[["(\\\\d{3})(\\\\d{4})","$1 $2",null,null]]]', + 357: '["CY","00",null,null,null,null,"\\\\d{8}","[257-9]\\\\d{7}",[["(\\\\d{2})(\\\\d{6})","$1 $2",null,null]]]', + 240: '["GQ","00",null,null,null,null,"\\\\d{9}","[23589]\\\\d{8}",[["(\\\\d{3})(\\\\d{3})(\\\\d{3})","$1 $2 $3","[235]",null],["(\\\\d{3})(\\\\d{6})","$1 $2","[89]",null]]]', + 506: '["CR","00",null,"(19(?:0[012468]|1[09]|20|66|77|99))",null,null,"\\\\d{8,10}","[24-9]\\\\d{7,9}",[["(\\\\d{4})(\\\\d{4})","$1 $2","[24-7]|8[3-9]",null],["(\\\\d{3})(\\\\d{3})(\\\\d{4})","$1-$2-$3","[89]0",null]]]', + 86: '["CN","(1(?:[129]\\\\d{3}|79\\\\d{2}))?00","0","(1(?:[129]\\\\d{3}|79\\\\d{2}))|0",null,null,"\\\\d{4,12}","[1-7]\\\\d{6,11}|8[0-357-9]\\\\d{6,9}|9\\\\d{7,10}",[["(80\\\\d{2})(\\\\d{4})","$1 $2","80[2678]","$NP$FG"],["([48]00)(\\\\d{3})(\\\\d{4})","$1 $2 $3","[48]00",null],["(\\\\d{5,6})","$1","100|95",null,"NA"],["(\\\\d{2})(\\\\d{5,6})","$1 $2","(?:10|2\\\\d)[19]","$NP$FG"],["(\\\\d{3})(\\\\d{5,6})","$1 $2","[3-9]","$NP$FG"],["(\\\\d{3,4})(\\\\d{4})","$1 $2","[2-9]",null,"NA"],["(21)(\\\\d{4})(\\\\d{4,6})","$1 $2 $3","21","$NP$FG"],["([12]\\\\d)(\\\\d{4})(\\\\d{4})","$1 $2 $3","10[1-9]|2[02-9]","$NP$FG"],["(\\\\d{3})(\\\\d{3})(\\\\d{4})","$1 $2 $3","3(?:1[02-9]|35|49|5|7[02-68]|9[1-68])|4(?:1[02-9]|2[179]|[35][2-9]|6[4789]|7\\\\d|8[23])|5(?:3[03-9]|4[36]|5[02-9]|6[1-46]|7[028]|80|9[2-46-9])|6(?:3[1-5]|6[0238]|9[12])|7(?:01|[1579]|2[248]|3[04-9]|4[3-6]|6[2368])|8(?:1[236-8]|2[5-7]|3|5[1-9]|7[02-9]|8[3678]|9[1-7])|9(?:0[1-3689]|1[1-79]|[379]|4[13]|5[1-5])","$NP$FG"],["(\\\\d{3})(\\\\d{4})(\\\\d{4})","$1 $2 $3","3(?:11|7[179])|4(?:[15]1|3[1-35])|5(?:1|2[37]|3[12]|51|7[13-79]|9[15])|7(?:31|5[457]|6[09]|91)|8(?:[57]1|98)","$NP$FG"],["(\\\\d{4})(\\\\d{3})(\\\\d{4})","$1 $2 $3","807","$NP$FG"],["(\\\\d{3})(\\\\d{4})(\\\\d{4})","$1 $2 $3","1[3-578]",null],["(10800)(\\\\d{3})(\\\\d{4})","$1 $2 $3","108",null],["(\\\\d{3})(\\\\d{7,8})","$1 $2","950",null]]]', + 257: '["BI","00",null,null,null,null,"\\\\d{8}","[267]\\\\d{7}",[["(\\\\d{2})(\\\\d{2})(\\\\d{2})(\\\\d{2})","$1 $2 $3 $4",null,null]]]', + 683: '["NU","00",null,null,null,null,"\\\\d{4}","[1-5]\\\\d{3}"]', + 43: '["AT","00","0",null,null,"$NP$FG","\\\\d{3,13}","[1-9]\\\\d{3,12}",[["(116\\\\d{3})","$1","116","$FG"],["(1)(\\\\d{3,12})","$1 $2","1",null],["(5\\\\d)(\\\\d{3,5})","$1 $2","5[079]",null],["(5\\\\d)(\\\\d{3})(\\\\d{3,4})","$1 $2 $3","5[079]",null],["(5\\\\d)(\\\\d{4})(\\\\d{4,7})","$1 $2 $3","5[079]",null],["(\\\\d{3})(\\\\d{3,10})","$1 $2","316|46|51|732|6(?:5[0-3579]|[6-9])|7(?:[28]0)|[89]",null],["(\\\\d{4})(\\\\d{3,9})","$1 $2","2|3(?:1[1-578]|[3-8])|4[2378]|5[2-6]|6(?:[12]|4[1-9]|5[468])|7(?:2[1-8]|35|4[1-8]|[5-79])",null]]]', + 247: '["AC","00",null,null,null,null,"\\\\d{5,6}","[46]\\\\d{4}|[01589]\\\\d{5}"]', + 675: '["PG","00",null,null,null,null,"\\\\d{7,8}","[1-9]\\\\d{6,7}",[["(\\\\d{3})(\\\\d{4})","$1 $2","[13-689]|27",null],["(\\\\d{4})(\\\\d{4})","$1 $2","20|7",null]]]', + 376: '["AD","00",null,null,null,null,"\\\\d{6,9}","[16]\\\\d{5,8}|[37-9]\\\\d{5}",[["(\\\\d{3})(\\\\d{3})","$1 $2","[137-9]|6[0-8]",null],["(\\\\d{4})(\\\\d{4})","$1 $2","180",null],["(\\\\d{3})(\\\\d{3})(\\\\d{3})","$1 $2 $3","690",null]]]', + 63: '["PH","00","0",null,null,null,"\\\\d{5,13}","2\\\\d{5,7}|[3-9]\\\\d{7,9}|1800\\\\d{7,9}",[["(2)(\\\\d{3})(\\\\d{4})","$1 $2 $3","2","($NP$FG)"],["(2)(\\\\d{5})","$1 $2","2","($NP$FG)"],["(\\\\d{4})(\\\\d{4,6})","$1 $2","3(?:23|39|46)|4(?:2[3-6]|[35]9|4[26]|76)|5(?:22|44)|642|8(?:62|8[245])","($NP$FG)"],["(\\\\d{5})(\\\\d{4})","$1 $2","346|4(?:27|9[35])|883","($NP$FG)"],["([3-8]\\\\d)(\\\\d{3})(\\\\d{4})","$1 $2 $3","[3-8]","($NP$FG)"],["(\\\\d{3})(\\\\d{3})(\\\\d{4})","$1 $2 $3","81|9","$NP$FG"],["(1800)(\\\\d{3})(\\\\d{4})","$1 $2 $3","1",null],["(1800)(\\\\d{1,2})(\\\\d{3})(\\\\d{4})","$1 $2 $3 $4","1",null]]]', + 236: '["CF","00",null,null,null,null,"\\\\d{8}","[278]\\\\d{7}",[["(\\\\d{2})(\\\\d{2})(\\\\d{2})(\\\\d{2})","$1 $2 $3 $4",null,null]]]', + 590: [ + '["GP","00","0",null,null,"$NP$FG","\\\\d{9}","[56]\\\\d{8}",[["([56]90)(\\\\d{2})(\\\\d{4})","$1 $2-$3",null,null]]]', + '["BL","00","0",null,null,null,"\\\\d{9}","[56]\\\\d{8}"]', + '["MF","00","0",null,null,null,"\\\\d{9}","[56]\\\\d{8}"]', + ], + 53: '["CU","119","0",null,null,"($NP$FG)","\\\\d{4,8}","[2-57]\\\\d{5,7}",[["(\\\\d)(\\\\d{6,7})","$1 $2","7",null],["(\\\\d{2})(\\\\d{4,6})","$1 $2","[2-4]",null],["(\\\\d)(\\\\d{7})","$1 $2","5","$NP$FG"]]]', + 64: '["NZ","0(?:0|161)","0",null,null,"$NP$FG","\\\\d{7,11}","6[235-9]\\\\d{6}|[2-57-9]\\\\d{7,10}",[["([34679])(\\\\d{3})(\\\\d{4})","$1-$2 $3","[346]|7[2-57-9]|9[1-9]",null],["(24099)(\\\\d{3})","$1 $2","240",null],["(\\\\d{2})(\\\\d{3})(\\\\d{3})","$1 $2 $3","21",null],["(\\\\d{2})(\\\\d{3})(\\\\d{3,5})","$1 $2 $3","2(?:1[1-9]|[69]|7[0-35-9])|70|86",null],["(2\\\\d)(\\\\d{3,4})(\\\\d{4})","$1 $2 $3","2[028]",null],["(\\\\d{3})(\\\\d{3})(\\\\d{3,4})","$1 $2 $3","2(?:10|74)|5|[89]0",null]]]', + 965: '["KW","00",null,null,null,null,"\\\\d{7,8}","[12569]\\\\d{6,7}",[["(\\\\d{4})(\\\\d{3,4})","$1 $2","[16]|2(?:[0-35-9]|4[0-35-9])|9[024-9]|52[25]",null],["(\\\\d{3})(\\\\d{5})","$1 $2","244|5(?:[015]|66)",null]]]', + 224: '["GN","00",null,null,null,null,"\\\\d{8,9}","[367]\\\\d{7,8}",[["(\\\\d{2})(\\\\d{2})(\\\\d{2})(\\\\d{2})","$1 $2 $3 $4","3",null],["(\\\\d{3})(\\\\d{2})(\\\\d{2})(\\\\d{2})","$1 $2 $3 $4","[67]",null]]]', + 973: '["BH","00",null,null,null,null,"\\\\d{8}","[136-9]\\\\d{7}",[["(\\\\d{4})(\\\\d{4})","$1 $2",null,null]]]', + 32: '["BE","00","0",null,null,"$NP$FG","\\\\d{8,9}","[1-9]\\\\d{7,8}",[["(\\\\d{3})(\\\\d{2})(\\\\d{2})(\\\\d{2})","$1 $2 $3 $4","4[6-9]",null],["(\\\\d)(\\\\d{3})(\\\\d{2})(\\\\d{2})","$1 $2 $3 $4","[23]|4[23]|9[2-4]",null],["(\\\\d{2})(\\\\d{2})(\\\\d{2})(\\\\d{2})","$1 $2 $3 $4","[156]|7[018]|8(?:0[1-9]|[1-79])",null],["(\\\\d{3})(\\\\d{2})(\\\\d{3})","$1 $2 $3","(?:80|9)0",null]]]', + 249: '["SD","00","0",null,null,"$NP$FG","\\\\d{9}","[19]\\\\d{8}",[["(\\\\d{2})(\\\\d{3})(\\\\d{4})","$1 $2 $3",null,null]]]', + 678: '["VU","00",null,null,null,null,"\\\\d{5,7}","[2-57-9]\\\\d{4,6}",[["(\\\\d{3})(\\\\d{4})","$1 $2","[579]",null]]]', + 52: '["MX","0[09]","01","0[12]|04[45](\\\\d{10})","1$1","$NP $FG","\\\\d{7,11}","[1-9]\\\\d{9,10}",[["([358]\\\\d)(\\\\d{4})(\\\\d{4})","$1 $2 $3","33|55|81",null],["(\\\\d{3})(\\\\d{3})(\\\\d{4})","$1 $2 $3","[2467]|3[0-2457-9]|5[089]|8[02-9]|9[0-35-9]",null],["(1)([358]\\\\d)(\\\\d{4})(\\\\d{4})","044 $2 $3 $4","1(?:33|55|81)","$FG","$1 $2 $3 $4"],["(1)(\\\\d{3})(\\\\d{3})(\\\\d{4})","044 $2 $3 $4","1(?:[2467]|3[0-2457-9]|5[089]|8[2-9]|9[1-35-9])","$FG","$1 $2 $3 $4"]]]', + 968: '["OM","00",null,null,null,null,"\\\\d{7,9}","(?:5|[279]\\\\d)\\\\d{6}|800\\\\d{5,6}",[["(2\\\\d)(\\\\d{6})","$1 $2","2",null],["([79]\\\\d{3})(\\\\d{4})","$1 $2","[79]",null],["([58]00)(\\\\d{4,6})","$1 $2","[58]",null]]]', + 599: [ + '["CW","00",null,null,null,null,"\\\\d{7,8}","[169]\\\\d{6,7}",[["(\\\\d{3})(\\\\d{4})","$1 $2","[13-7]",null],["(9)(\\\\d{3})(\\\\d{4})","$1 $2 $3","9",null]]]', + '["BQ","00",null,null,null,null,"\\\\d{7}","[347]\\\\d{6}"]', + ], + 800: '["001",null,null,null,null,null,"\\\\d{8}","\\\\d{8}",[["(\\\\d{4})(\\\\d{4})","$1 $2",null,null]]]', + 386: '["SI","00","0",null,null,"$NP$FG","\\\\d{5,8}","[1-7]\\\\d{6,7}|[89]\\\\d{4,7}",[["(\\\\d)(\\\\d{3})(\\\\d{2})(\\\\d{2})","$1 $2 $3 $4","[12]|3[24-8]|4[24-8]|5[2-8]|7[3-8]","($NP$FG)"],["([3-7]\\\\d)(\\\\d{3})(\\\\d{3})","$1 $2 $3","[37][01]|4[0139]|51|6",null],["([89][09])(\\\\d{3,6})","$1 $2","[89][09]",null],["([58]\\\\d{2})(\\\\d{5})","$1 $2","59|8[1-3]",null]]]', + 679: '["FJ","0(?:0|52)",null,null,null,null,"\\\\d{7}(?:\\\\d{4})?","[35-9]\\\\d{6}|0\\\\d{10}",[["(\\\\d{3})(\\\\d{4})","$1 $2","[35-9]",null],["(\\\\d{4})(\\\\d{3})(\\\\d{4})","$1 $2 $3","0",null]]]', + 238: '["CV","0",null,null,null,null,"\\\\d{7}","[259]\\\\d{6}",[["(\\\\d{3})(\\\\d{2})(\\\\d{2})","$1 $2 $3",null,null]]]', + 691: '["FM","00",null,null,null,null,"\\\\d{7}","[39]\\\\d{6}",[["(\\\\d{3})(\\\\d{4})","$1 $2",null,null]]]', + 262: [ + '["RE","00","0",null,null,"$NP$FG","\\\\d{9}","[268]\\\\d{8}",[["([268]\\\\d{2})(\\\\d{2})(\\\\d{2})(\\\\d{2})","$1 $2 $3 $4",null,null]]]', + '["YT","00","0",null,null,"$NP$FG","\\\\d{9}","[268]\\\\d{8}"]', + ], + 241: '["GA","00",null,null,null,null,"\\\\d{7,8}","0?\\\\d{7}",[["(\\\\d)(\\\\d{2})(\\\\d{2})(\\\\d{2})","$1 $2 $3 $4","[2-7]","0$FG"],["(\\\\d{2})(\\\\d{2})(\\\\d{2})(\\\\d{2})","$1 $2 $3 $4","0",null]]]', + 370: '["LT","00","8","[08]",null,"($NP-$FG)","\\\\d{8}","[3-9]\\\\d{7}",[["([34]\\\\d)(\\\\d{6})","$1 $2","37|4(?:1|5[45]|6[2-4])",null],["([3-6]\\\\d{2})(\\\\d{5})","$1 $2","3[148]|4(?:[24]|6[09])|528|6",null],["([7-9]\\\\d{2})(\\\\d{2})(\\\\d{3})","$1 $2 $3","[7-9]","$NP $FG"],["(5)(2\\\\d{2})(\\\\d{4})","$1 $2 $3","52[0-79]",null]]]', + 256: '["UG","00[057]","0",null,null,"$NP$FG","\\\\d{5,9}","\\\\d{9}",[["(\\\\d{3})(\\\\d{6})","$1 $2","[7-9]|20(?:[013-8]|2[5-9])|4(?:6[45]|[7-9])",null],["(\\\\d{2})(\\\\d{7})","$1 $2","3|4(?:[1-5]|6[0-36-9])",null],["(2024)(\\\\d{5})","$1 $2","2024",null]]]', + 677: '["SB","0[01]",null,null,null,null,"\\\\d{5,7}","[1-9]\\\\d{4,6}",[["(\\\\d{2})(\\\\d{5})","$1 $2","[7-9]",null]]]', + 377: '["MC","00","0",null,null,"$NP$FG","\\\\d{8,9}","[34689]\\\\d{7,8}",[["(\\\\d{2})(\\\\d{2})(\\\\d{2})(\\\\d{2})","$1 $2 $3 $4","[39]","$FG"],["(\\\\d{2})(\\\\d{3})(\\\\d{3})","$1 $2 $3","4",null],["(6)(\\\\d{2})(\\\\d{2})(\\\\d{2})(\\\\d{2})","$1 $2 $3 $4 $5","6",null],["(\\\\d{3})(\\\\d{3})(\\\\d{2})","$1 $2 $3","8","$FG"]]]', + 382: '["ME","00","0",null,null,"$NP$FG","\\\\d{6,9}","[2-9]\\\\d{7,8}",[["(\\\\d{2})(\\\\d{3})(\\\\d{3})","$1 $2 $3","[2-57-9]|6[036-9]",null]]]', + 231: '["LR","00","0",null,null,"$NP$FG","\\\\d{7,9}","2\\\\d{7,8}|[378]\\\\d{8}|4\\\\d{6}|5\\\\d{6,8}",[["(2\\\\d)(\\\\d{3})(\\\\d{3})","$1 $2 $3","2",null],["([4-5])(\\\\d{3})(\\\\d{3})","$1 $2 $3","[45]",null],["(\\\\d{2})(\\\\d{3})(\\\\d{4})","$1 $2 $3","[23578]",null]]]', + 591: '["BO","00(1\\\\d)?","0","0(1\\\\d)?",null,null,"\\\\d{7,8}","[23467]\\\\d{7}",[["([234])(\\\\d{7})","$1 $2","[234]",null],["([67]\\\\d{7})","$1","[67]",null]]]', + 808: '["001",null,null,null,null,null,"\\\\d{8}","\\\\d{8}",[["(\\\\d{4})(\\\\d{4})","$1 $2",null,null]]]', + 964: '["IQ","00","0",null,null,"$NP$FG","\\\\d{6,10}","[1-7]\\\\d{7,9}",[["(1)(\\\\d{3})(\\\\d{4})","$1 $2 $3","1",null],["([2-6]\\\\d)(\\\\d{3})(\\\\d{3,4})","$1 $2 $3","[2-6]",null],["(7\\\\d{2})(\\\\d{3})(\\\\d{4})","$1 $2 $3","7",null]]]', + 225: '["CI","00",null,null,null,null,"\\\\d{8}","[02-8]\\\\d{7}",[["(\\\\d{2})(\\\\d{2})(\\\\d{2})(\\\\d{2})","$1 $2 $3 $4",null,null]]]', + 992: '["TJ","810","8",null,null,"$FG","\\\\d{3,9}","[3-57-9]\\\\d{8}",[["([349]\\\\d{2})(\\\\d{2})(\\\\d{4})","$1 $2 $3","[34]7|91[78]",null],["([457-9]\\\\d)(\\\\d{3})(\\\\d{4})","$1 $2 $3","4[148]|[578]|9(?:1[59]|[0235-9])",null],["(331700)(\\\\d)(\\\\d{2})","$1 $2 $3","331",null],["(\\\\d{4})(\\\\d)(\\\\d{4})","$1 $2 $3","3[1-5]",null]]]', + 55: '["BR","00(?:1[245]|2[1-35]|31|4[13]|[56]5|99)","0","(?:0|90)(?:(1[245]|2[135]|[34]1)(\\\\d{10,11}))?","$2",null,"\\\\d{8,11}","[1-46-9]\\\\d{7,10}|5(?:[0-4]\\\\d{7,9}|5(?:[2-8]\\\\d{7}|9\\\\d{7,8}))",[["(\\\\d{4})(\\\\d{4})","$1-$2","[2-9](?:[1-9]|0[1-9])","$FG","NA"],["(\\\\d{5})(\\\\d{4})","$1-$2","9(?:[1-9]|0[1-9])","$FG","NA"],["(\\\\d{3,5})","$1","1[125689]","$FG","NA"],["(\\\\d{2})(\\\\d{4})(\\\\d{4})","$1 $2-$3","[1-9][1-9]","($FG)"],["(\\\\d{2})(\\\\d{5})(\\\\d{4})","$1 $2-$3","(?:[14689][1-9]|2[12478]|3[1-578]|5[1-5]|7[13-579])9","($FG)"],["(\\\\d{4})(\\\\d{4})","$1-$2","(?:300|40(?:0|20))",null],["([3589]00)(\\\\d{2,3})(\\\\d{4})","$1 $2 $3","[3589]00","$NP$FG"]]]', + 674: '["NR","00",null,null,null,null,"\\\\d{7}","[458]\\\\d{6}",[["(\\\\d{3})(\\\\d{4})","$1 $2",null,null]]]', + 967: '["YE","00","0",null,null,"$NP$FG","\\\\d{6,9}","[1-7]\\\\d{6,8}",[["([1-7])(\\\\d{3})(\\\\d{3,4})","$1 $2 $3","[1-6]|7[24-68]",null],["(7\\\\d{2})(\\\\d{3})(\\\\d{3})","$1 $2 $3","7[0137]",null]]]', + 49: '["DE","00","0",null,null,"$NP$FG","\\\\d{2,15}","[1-35-9]\\\\d{3,14}|4(?:[0-8]\\\\d{3,12}|9(?:[0-37]\\\\d|4(?:[1-35-8]|4\\\\d?)|5\\\\d{1,2}|6[1-8]\\\\d?)\\\\d{2,8})",[["(1\\\\d{2})(\\\\d{7,8})","$1 $2","1[67]",null],["(15\\\\d{3})(\\\\d{6})","$1 $2","15[0568]",null],["(1\\\\d{3})(\\\\d{7})","$1 $2","15",null],["(\\\\d{2})(\\\\d{3,11})","$1 $2","3[02]|40|[68]9",null],["(\\\\d{3})(\\\\d{3,11})","$1 $2","2(?:\\\\d1|0[2389]|1[24]|28|34)|3(?:[3-9][15]|40)|[4-8][1-9]1|9(?:06|[1-9]1)",null],["(\\\\d{4})(\\\\d{2,11})","$1 $2","[24-6]|[7-9](?:\\\\d[1-9]|[1-9]\\\\d)|3(?:[3569][02-46-9]|4[2-4679]|7[2-467]|8[2-46-8])",null],["(3\\\\d{4})(\\\\d{1,10})","$1 $2","3",null],["(800)(\\\\d{7,12})","$1 $2","800",null],["(\\\\d{3})(\\\\d)(\\\\d{4,10})","$1 $2 $3","(?:18|90)0|137",null],["(1\\\\d{2})(\\\\d{5,11})","$1 $2","181",null],["(18\\\\d{3})(\\\\d{6})","$1 $2","185",null],["(18\\\\d{2})(\\\\d{7})","$1 $2","18[68]",null],["(18\\\\d)(\\\\d{8})","$1 $2","18[2-579]",null],["(700)(\\\\d{4})(\\\\d{4})","$1 $2 $3","700",null],["(138)(\\\\d{4})","$1 $2","138",null],["(15[013-68])(\\\\d{2})(\\\\d{8})","$1 $2 $3","15[013-68]",null],["(15[279]\\\\d)(\\\\d{2})(\\\\d{7})","$1 $2 $3","15[279]",null],["(1[67]\\\\d)(\\\\d{2})(\\\\d{7,8})","$1 $2 $3","1(?:6[023]|7)",null]]]', + 31: '["NL","00","0",null,null,"$NP$FG","\\\\d{5,10}","1\\\\d{4,8}|[2-7]\\\\d{8}|[89]\\\\d{6,9}",[["([1-578]\\\\d)(\\\\d{3})(\\\\d{4})","$1 $2 $3","1[035]|2[0346]|3[03568]|4[0356]|5[0358]|7|8[4578]",null],["([1-5]\\\\d{2})(\\\\d{3})(\\\\d{3})","$1 $2 $3","1[16-8]|2[259]|3[124]|4[17-9]|5[124679]",null],["(6)(\\\\d{8})","$1 $2","6[0-57-9]",null],["(66)(\\\\d{7})","$1 $2","66",null],["(14)(\\\\d{3,4})","$1 $2","14","$FG"],["([89]0\\\\d)(\\\\d{4,7})","$1 $2","80|9",null]]]', + 970: '["PS","00","0",null,null,"$NP$FG","\\\\d{4,10}","[24589]\\\\d{7,8}|1(?:[78]\\\\d{8}|[49]\\\\d{2,3})",[["([2489])(2\\\\d{2})(\\\\d{4})","$1 $2 $3","[2489]",null],["(5[69]\\\\d)(\\\\d{3})(\\\\d{3})","$1 $2 $3","5",null],["(1[78]00)(\\\\d{3})(\\\\d{3})","$1 $2 $3","1[78]","$FG"]]]', + 58: '["VE","00","0",null,null,"$NP$FG","\\\\d{7,10}","[24589]\\\\d{9}",[["(\\\\d{3})(\\\\d{7})","$1-$2",null,null]]]', + 856: '["LA","00","0",null,null,"$NP$FG","\\\\d{6,10}","[2-8]\\\\d{7,9}",[["(20)(\\\\d{2})(\\\\d{3})(\\\\d{3})","$1 $2 $3 $4","20",null],["([2-8]\\\\d)(\\\\d{3})(\\\\d{3})","$1 $2 $3","2[13]|3[14]|[4-8]",null],["(30)(\\\\d{2})(\\\\d{2})(\\\\d{3})","$1 $2 $3 $4","30",null]]]', + 354: '["IS","1(?:0(?:01|10|20)|100)|00",null,null,null,null,"\\\\d{7,9}","[4-9]\\\\d{6}|38\\\\d{7}",[["(\\\\d{3})(\\\\d{4})","$1 $2","[4-9]",null],["(3\\\\d{2})(\\\\d{3})(\\\\d{3})","$1 $2 $3","3",null]]]', + 242: '["CG","00",null,null,null,null,"\\\\d{9}","[028]\\\\d{8}",[["(\\\\d{2})(\\\\d{3})(\\\\d{4})","$1 $2 $3","[02]",null],["(\\\\d)(\\\\d{4})(\\\\d{4})","$1 $2 $3","8",null]]]', + 423: '["LI","00","0","0|10(?:01|20|66)",null,null,"\\\\d{7,9}","6\\\\d{8}|[23789]\\\\d{6}",[["(\\\\d{3})(\\\\d{2})(\\\\d{2})","$1 $2 $3","[23789]",null],["(\\\\d{3})(\\\\d{3})(\\\\d{3})","$1 $2 $3","6[56]",null],["(69)(7\\\\d{2})(\\\\d{4})","$1 $2 $3","697",null]]]', + 213: '["DZ","00","0",null,null,"$NP$FG","\\\\d{8,9}","(?:[1-4]|[5-9]\\\\d)\\\\d{7}",[["([1-4]\\\\d)(\\\\d{2})(\\\\d{2})(\\\\d{2})","$1 $2 $3 $4","[1-4]",null],["([5-8]\\\\d{2})(\\\\d{2})(\\\\d{2})(\\\\d{2})","$1 $2 $3 $4","[5-8]",null],["(9\\\\d)(\\\\d{3})(\\\\d{2})(\\\\d{2})","$1 $2 $3 $4","9",null]]]', + 371: '["LV","00",null,null,null,null,"\\\\d{8}","[2689]\\\\d{7}",[["([2689]\\\\d)(\\\\d{3})(\\\\d{3})","$1 $2 $3",null,null]]]', + 503: '["SV","00",null,null,null,null,"\\\\d{7,8}|\\\\d{11}","[267]\\\\d{7}|[89]\\\\d{6}(?:\\\\d{4})?",[["(\\\\d{4})(\\\\d{4})","$1 $2","[267]",null],["(\\\\d{3})(\\\\d{4})","$1 $2","[89]",null],["(\\\\d{3})(\\\\d{4})(\\\\d{4})","$1 $2 $3","[89]",null]]]', + 685: '["WS","0",null,null,null,null,"\\\\d{5,7}","[2-8]\\\\d{4,6}",[["(8\\\\d{2})(\\\\d{3,4})","$1 $2","8",null],["(7\\\\d)(\\\\d{5})","$1 $2","7",null],["(\\\\d{5})","$1","[2-6]",null]]]', + 880: '["BD","00","0",null,null,"$NP$FG","\\\\d{6,10}","[2-79]\\\\d{5,9}|1\\\\d{9}|8[0-7]\\\\d{4,8}",[["(2)(\\\\d{7,8})","$1-$2","2",null],["(\\\\d{2})(\\\\d{4,6})","$1-$2","[3-79]1",null],["(\\\\d{4})(\\\\d{3,6})","$1-$2","1|3(?:0|[2-58]2)|4(?:0|[25]2|3[23]|[4689][25])|5(?:[02-578]2|6[25])|6(?:[0347-9]2|[26][25])|7[02-9]2|8(?:[023][23]|[4-7]2)|9(?:[02][23]|[458]2|6[016])",null],["(\\\\d{3})(\\\\d{3,7})","$1-$2","[3-79][2-9]|8",null]]]', + 265: '["MW","00","0",null,null,"$NP$FG","\\\\d{7,9}","(?:1(?:\\\\d{2})?|[2789]\\\\d{2})\\\\d{6}",[["(\\\\d)(\\\\d{3})(\\\\d{3})","$1 $2 $3","1",null],["(2\\\\d{2})(\\\\d{3})(\\\\d{3})","$1 $2 $3","2",null],["(\\\\d{3})(\\\\d{2})(\\\\d{2})(\\\\d{2})","$1 $2 $3 $4","[1789]",null]]]', + 65: '["SG","0[0-3]\\\\d",null,null,null,null,"\\\\d{8,11}","[36]\\\\d{7}|[17-9]\\\\d{7,10}",[["([3689]\\\\d{3})(\\\\d{4})","$1 $2","[369]|8[1-9]",null],["(1[89]00)(\\\\d{3})(\\\\d{4})","$1 $2 $3","1[89]",null],["(7000)(\\\\d{4})(\\\\d{3})","$1 $2 $3","70",null],["(800)(\\\\d{3})(\\\\d{4})","$1 $2 $3","80",null]]]', + 504: '["HN","00",null,null,null,null,"\\\\d{8}","[237-9]\\\\d{7}",[["(\\\\d{4})(\\\\d{4})","$1-$2",null,null]]]', + 688: '["TV","00",null,null,null,null,"\\\\d{5,7}","[279]\\\\d{4,6}"]', + 84: '["VN","00","0",null,null,"$NP$FG","\\\\d{7,10}","[167]\\\\d{6,9}|[2-59]\\\\d{7,9}|8\\\\d{6,8}",[["([17]99)(\\\\d{4})","$1 $2","[17]99",null],["([48])(\\\\d{4})(\\\\d{4})","$1 $2 $3","4|8(?:[1-57]|6[0-79]|9[0-7])",null],["([235-7]\\\\d)(\\\\d{4})(\\\\d{3})","$1 $2 $3","2[025-79]|3[0136-9]|5[2-9]|6[0-46-8]|7[02-79]",null],["(80)(\\\\d{5})","$1 $2","80",null],["(69\\\\d)(\\\\d{4,5})","$1 $2","69",null],["([235-7]\\\\d{2})(\\\\d{4})(\\\\d{3})","$1 $2 $3","2[0-489]|3[25]|5[01]|65|7[18]",null],["([89]\\\\d)(\\\\d{3})(\\\\d{2})(\\\\d{2})","$1 $2 $3 $4","8(?:68|8|9[89])|9",null],["(1[2689]\\\\d)(\\\\d{3})(\\\\d{4})","$1 $2 $3","1(?:[26]|8[68]|99)",null],["(1[89]00)(\\\\d{4,6})","$1 $2","1[89]0","$FG"]]]', + 255: '["TZ","00[056]","0",null,null,"$NP$FG","\\\\d{7,9}","\\\\d{9}",[["([24]\\\\d)(\\\\d{3})(\\\\d{4})","$1 $2 $3","[24]",null],["([67]\\\\d{2})(\\\\d{3})(\\\\d{3})","$1 $2 $3","[67]",null],["([89]\\\\d{2})(\\\\d{2})(\\\\d{4})","$1 $2 $3","[89]",null]]]', + 222: '["MR","00",null,null,null,null,"\\\\d{8}","[2-48]\\\\d{7}",[["([2-48]\\\\d)(\\\\d{2})(\\\\d{2})(\\\\d{2})","$1 $2 $3 $4",null,null]]]', + 230: '["MU","0(?:0|[2-7]0|33)",null,null,null,null,"\\\\d{7,8}","[2-9]\\\\d{6,7}",[["([2-46-9]\\\\d{2})(\\\\d{4})","$1 $2","[2-46-9]",null],["(5\\\\d{3})(\\\\d{4})","$1 $2","5",null]]]', + 592: '["GY","001",null,null,null,null,"\\\\d{7}","[2-46-9]\\\\d{6}",[["(\\\\d{3})(\\\\d{4})","$1 $2",null,null]]]', + 41: '["CH","00","0",null,null,"$NP$FG","\\\\d{9}(?:\\\\d{3})?","[2-9]\\\\d{8}|860\\\\d{9}",[["([2-9]\\\\d)(\\\\d{3})(\\\\d{2})(\\\\d{2})","$1 $2 $3 $4","[2-7]|[89]1",null],["([89]\\\\d{2})(\\\\d{3})(\\\\d{3})","$1 $2 $3","8[047]|90",null],["(\\\\d{3})(\\\\d{2})(\\\\d{3})(\\\\d{2})(\\\\d{2})","$1 $2 $3 $4 $5","860",null]]]', + 39: [ + '["IT","00",null,null,null,null,"\\\\d{6,11}","[01589]\\\\d{5,10}|3(?:[12457-9]\\\\d{8}|[36]\\\\d{7,9})",[["(\\\\d{2})(\\\\d{3,4})(\\\\d{4})","$1 $2 $3","0[26]|55",null],["(0[26])(\\\\d{4})(\\\\d{5})","$1 $2 $3","0[26]",null],["(0[26])(\\\\d{4,6})","$1 $2","0[26]",null],["(0\\\\d{2})(\\\\d{3,4})(\\\\d{4})","$1 $2 $3","0[13-57-9][0159]",null],["(\\\\d{3})(\\\\d{3,6})","$1 $2","0[13-57-9][0159]|8(?:03|4[17]|9[245])",null],["(0\\\\d{3})(\\\\d{3})(\\\\d{4})","$1 $2 $3","0[13-57-9][2-46-8]",null],["(0\\\\d{3})(\\\\d{2,6})","$1 $2","0[13-57-9][2-46-8]",null],["(\\\\d{3})(\\\\d{3})(\\\\d{3,4})","$1 $2 $3","[13]|8(?:00|4[08]|9[59])",null],["(\\\\d{4})(\\\\d{4})","$1 $2","894",null],["(\\\\d{3})(\\\\d{4})(\\\\d{4})","$1 $2 $3","3",null]]]', + '["VA","00",null,null,null,null,"\\\\d{6,11}","(?:0(?:878\\\\d{5}|6698\\\\d{5})|[1589]\\\\d{5,10}|3(?:[12457-9]\\\\d{8}|[36]\\\\d{7,9}))"]', + ], + 993: '["TM","810","8",null,null,"($NP $FG)","\\\\d{8}","[1-6]\\\\d{7}",[["(\\\\d{2})(\\\\d{2})(\\\\d{2})(\\\\d{2})","$1 $2-$3-$4","12",null],["(\\\\d{2})(\\\\d{6})","$1 $2","6","$NP $FG"],["(\\\\d{3})(\\\\d)(\\\\d{2})(\\\\d{2})","$1 $2-$3-$4","13|[2-5]",null]]]', + 888: '["001",null,null,null,null,null,"\\\\d{11}","\\\\d{11}",[["(\\\\d{3})(\\\\d{3})(\\\\d{5})","$1 $2 $3",null,null]]]', + 353: '["IE","00","0",null,null,"($NP$FG)","\\\\d{5,10}","[124-9]\\\\d{6,9}",[["(1)(\\\\d{3,4})(\\\\d{4})","$1 $2 $3","1",null],["(\\\\d{2})(\\\\d{5})","$1 $2","2[24-9]|47|58|6[237-9]|9[35-9]",null],["(\\\\d{3})(\\\\d{5})","$1 $2","40[24]|50[45]",null],["(48)(\\\\d{4})(\\\\d{4})","$1 $2 $3","48",null],["(818)(\\\\d{3})(\\\\d{3})","$1 $2 $3","81",null],["(\\\\d{2})(\\\\d{3})(\\\\d{3,4})","$1 $2 $3","[24-69]|7[14]",null],["([78]\\\\d)(\\\\d{3,4})(\\\\d{4})","$1 $2 $3","76|8[35-9]","$NP$FG"],["(700)(\\\\d{3})(\\\\d{3})","$1 $2 $3","70","$NP$FG"],["(\\\\d{4})(\\\\d{3})(\\\\d{3})","$1 $2 $3","1(?:8[059]|5)","$FG"]]]', + 966: '["SA","00","0",null,null,"$NP$FG","\\\\d{7,10}","1\\\\d{7,8}|(?:[2-467]|92)\\\\d{7}|5\\\\d{8}|8\\\\d{9}",[["([1-467])(\\\\d{3})(\\\\d{4})","$1 $2 $3","[1-467]",null],["(1\\\\d)(\\\\d{3})(\\\\d{4})","$1 $2 $3","1[1-467]",null],["(5\\\\d)(\\\\d{3})(\\\\d{4})","$1 $2 $3","5",null],["(92\\\\d{2})(\\\\d{5})","$1 $2","92","$FG"],["(800)(\\\\d{3})(\\\\d{4})","$1 $2 $3","80","$FG"],["(811)(\\\\d{3})(\\\\d{3,4})","$1 $2 $3","81",null]]]', + 380: '["UA","00","0",null,null,"$NP$FG","\\\\d{5,9}","[3-9]\\\\d{8}",[["([3-9]\\\\d)(\\\\d{3})(\\\\d{4})","$1 $2 $3","[38]9|4(?:[45][0-5]|87)|5(?:0|6[37]|7[37])|6[36-8]|7|9[1-9]",null],["([3-689]\\\\d{2})(\\\\d{3})(\\\\d{3})","$1 $2 $3","3[1-8]2|4[13678]2|5(?:[12457]2|6[24])|6(?:[49]2|[12][29]|5[24])|8[0-8]|90",null],["([3-6]\\\\d{3})(\\\\d{5})","$1 $2","3(?:5[013-9]|[1-46-8])|4(?:[137][013-9]|6|[45][6-9]|8[4-6])|5(?:[1245][013-9]|6[0135-9]|3|7[4-6])|6(?:[49][013-9]|5[0135-9]|[12][13-8])",null]]]', + 98: '["IR","00","0",null,null,"$NP$FG","\\\\d{4,10}","[1-8]\\\\d{9}|9(?:[0-4]\\\\d{8}|9\\\\d{2,8})",[["(21)(\\\\d{3,5})","$1 $2","21",null],["(\\\\d{2})(\\\\d{4})(\\\\d{4})","$1 $2 $3","[1-8]",null],["(\\\\d{3})(\\\\d{3})","$1 $2","9",null],["(\\\\d{3})(\\\\d{2})(\\\\d{2,3})","$1 $2 $3","9",null],["(\\\\d{3})(\\\\d{3})(\\\\d{3,4})","$1 $2 $3","9",null]]]', + 971: '["AE","00","0",null,null,"$NP$FG","\\\\d{5,12}","[2-79]\\\\d{7,8}|800\\\\d{2,9}",[["([2-4679])(\\\\d{3})(\\\\d{4})","$1 $2 $3","[2-4679][2-8]",null],["(5\\\\d)(\\\\d{3})(\\\\d{4})","$1 $2 $3","5",null],["([479]00)(\\\\d)(\\\\d{5})","$1 $2 $3","[479]0","$FG"],["([68]00)(\\\\d{2,9})","$1 $2","60|8","$FG"]]]', + 30: '["GR","00",null,null,null,null,"\\\\d{10}","[26-9]\\\\d{9}",[["([27]\\\\d)(\\\\d{4})(\\\\d{4})","$1 $2 $3","21|7",null],["(\\\\d{3})(\\\\d{3})(\\\\d{4})","$1 $2 $3","2[2-9]1|[689]",null],["(2\\\\d{3})(\\\\d{6})","$1 $2","2[2-9][02-9]",null]]]', + 228: '["TG","00",null,null,null,null,"\\\\d{8}","[29]\\\\d{7}",[["(\\\\d{2})(\\\\d{2})(\\\\d{2})(\\\\d{2})","$1 $2 $3 $4","[29]",null]]]', + 48: '["PL","00",null,null,null,null,"\\\\d{6,9}","[12]\\\\d{6,8}|[3-57-9]\\\\d{8}|6\\\\d{5,8}",[["(\\\\d{2})(\\\\d{3})(\\\\d{2})(\\\\d{2})","$1 $2 $3 $4","[14]|2[0-57-9]|3[2-4]|5[24-689]|6[1-3578]|7[14-7]|8[1-79]|9[145]",null],["(\\\\d{2})(\\\\d{1})(\\\\d{4})","$1 $2 $3","[12]2",null],["(\\\\d{3})(\\\\d{3})(\\\\d{3})","$1 $2 $3","26|39|5[0137]|6[0469]|7[02389]|8[08]",null],["(\\\\d{3})(\\\\d{2})(\\\\d{2,3})","$1 $2 $3","64",null],["(\\\\d{3})(\\\\d{3})","$1 $2","64",null]]]', + 886: '["TW","0(?:0[25679]|19)","0",null,null,"$NP$FG","\\\\d{7,10}","2\\\\d{6,8}|[3-689]\\\\d{7,8}|7\\\\d{7,9}",[["(20)(\\\\d)(\\\\d{4})","$1 $2 $3","202",null],["(20)(\\\\d{3})(\\\\d{4})","$1 $2 $3","20[013-9]",null],["([2-8])(\\\\d{3,4})(\\\\d{4})","$1 $2 $3","2[23-8]|[3-6]|[78][1-9]",null],["([89]\\\\d{2})(\\\\d{3})(\\\\d{3})","$1 $2 $3","80|9",null],["(70)(\\\\d{4})(\\\\d{4})","$1 $2 $3","70",null]]]', + 212: [ + '["MA","00","0",null,null,"$NP$FG","\\\\d{9}","[5-9]\\\\d{8}",[["([5-7]\\\\d{2})(\\\\d{6})","$1-$2","5(?:2[015-7]|3[0-4])|[67]",null],["([58]\\\\d{3})(\\\\d{5})","$1-$2","5(?:2[2-489]|3[5-9]|92)|892",null],["(5\\\\d{4})(\\\\d{4})","$1-$2","5(?:29|38)",null],["([5]\\\\d{2})(\\\\d{2})(\\\\d{2})(\\\\d{2})","$1 $2 $3 $4","5(?:4[067]|5[03])",null],["(8[09])(\\\\d{7})","$1-$2","8(?:0|9[013-9])",null]]]', + '["EH","00","0",null,null,"$NP$FG","\\\\d{9}","[5-9]\\\\d{8}"]', + ], + 372: '["EE","00",null,null,null,null,"\\\\d{4,10}","1\\\\d{3,4}|[3-9]\\\\d{6,7}|800\\\\d{6,7}",[["([3-79]\\\\d{2})(\\\\d{4})","$1 $2","[369]|4[3-8]|5(?:[0-2]|5[0-478]|6[45])|7[1-9]",null],["(70)(\\\\d{2})(\\\\d{4})","$1 $2 $3","70",null],["(8000)(\\\\d{3})(\\\\d{3})","$1 $2 $3","800",null],["([458]\\\\d{3})(\\\\d{3,4})","$1 $2","40|5|8(?:00|[1-5])",null]]]', + 598: '["UY","0(?:1[3-9]\\\\d|0)","0",null,null,null,"\\\\d{7,8}","[2489]\\\\d{6,7}",[["(\\\\d{4})(\\\\d{4})","$1 $2","[24]",null],["(\\\\d{2})(\\\\d{3})(\\\\d{3})","$1 $2 $3","9[1-9]","$NP$FG"],["(\\\\d{3})(\\\\d{4})","$1 $2","[89]0","$NP$FG"]]]', + 502: '["GT","00",null,null,null,null,"\\\\d{8}(?:\\\\d{3})?","[2-7]\\\\d{7}|1[89]\\\\d{9}",[["(\\\\d{4})(\\\\d{4})","$1 $2","[2-7]",null],["(\\\\d{4})(\\\\d{3})(\\\\d{4})","$1 $2 $3","1",null]]]', + 82: '["KR","00(?:[124-68]|3\\\\d{2}|7(?:[0-8]\\\\d|9[0-79]))","0","0(8[1-46-8]|85\\\\d{2})?",null,"$NP$FG","\\\\d{3,14}","007\\\\d{9,11}|[1-7]\\\\d{3,9}|8\\\\d{8}",[["(\\\\d{5})(\\\\d{3,4})(\\\\d{4})","$1 $2 $3","00798","$FG","NA"],["(\\\\d{5})(\\\\d{2})(\\\\d{3})(\\\\d{4})","$1 $2 $3 $4","00798","$FG","NA"],["(\\\\d{2})(\\\\d{4})(\\\\d{4})","$1-$2-$3","1(?:0|1[19]|[69]9|5[458])|[57]0",null],["(\\\\d{2})(\\\\d{3,4})(\\\\d{4})","$1-$2-$3","1(?:[01]|5[1-4]|6[2-8]|[7-9])|[68]0|[3-6][1-9][1-9]",null],["(\\\\d{3})(\\\\d)(\\\\d{4})","$1-$2-$3","131",null],["(\\\\d{3})(\\\\d{2})(\\\\d{4})","$1-$2-$3","131",null],["(\\\\d{3})(\\\\d{3})(\\\\d{4})","$1-$2-$3","13[2-9]",null],["(\\\\d{2})(\\\\d{2})(\\\\d{3})(\\\\d{4})","$1-$2-$3-$4","30",null],["(\\\\d)(\\\\d{3,4})(\\\\d{4})","$1-$2-$3","2[1-9]",null],["(\\\\d)(\\\\d{3,4})","$1-$2","21[0-46-9]",null],["(\\\\d{2})(\\\\d{3,4})","$1-$2","[3-6][1-9]1",null],["(\\\\d{4})(\\\\d{4})","$1-$2","1(?:5[246-9]|6[04678]|8[03579])","$FG"]]]', + 253: '["DJ","00",null,null,null,null,"\\\\d{8}","[27]\\\\d{7}",[["(\\\\d{2})(\\\\d{2})(\\\\d{2})(\\\\d{2})","$1 $2 $3 $4",null,null]]]', + 91: '["IN","00","0",null,null,"$NP$FG","\\\\d{6,13}","008\\\\d{9}|1\\\\d{7,12}|[2-9]\\\\d{9,10}",[["(\\\\d{5})(\\\\d{5})","$1 $2","600|7(?:[02-8]|19|9[037-9])|8(?:0[015-9]|[1-9]|20)|9",null],["(\\\\d{2})(\\\\d{4})(\\\\d{4})","$1 $2 $3","11|2[02]|33|4[04]|79[1-9]|80[2-46]",null],["(\\\\d{3})(\\\\d{3})(\\\\d{4})","$1 $2 $3","1(?:2[0-249]|3[0-25]|4[145]|[59][14]|7[1257]|[68][1-9])|2(?:1[257]|3[013]|4[01]|5[0137]|6[0158]|78|8[1568]|9[14])|3(?:26|4[1-3]|5[34]|6[01489]|7[02-46]|8[159])|4(?:1[36]|2[1-47]|3[15]|5[12]|6[0-26-9]|7[0-24-9]|8[013-57]|9[014-7])|5(?:1[025]|[36][25]|22|4[28]|5[12]|[78]1|9[15])|6(?:12|[2-4]1|5[17]|6[13]|7[14]|80)|7(?:12|2[14]|3[134]|4[47]|5[15]|[67]1|88)|8(?:16|2[014]|3[126]|6[136]|7[078]|8[34]|91)",null],["(\\\\d{4})(\\\\d{3})(\\\\d{3})","$1 $2 $3","1(?:[23579]|[468][1-9])|[2-8]",null],["(\\\\d{2})(\\\\d{3})(\\\\d{4})(\\\\d{3})","$1 $2 $3 $4","008",null],["(\\\\d{3})(\\\\d{3})(\\\\d{4})","$1 $2 $3","140","$FG"],["(\\\\d{4})(\\\\d{2})(\\\\d{4})","$1 $2 $3","160","$FG"],["(\\\\d{4})(\\\\d{4,5})","$1 $2","180","$FG"],["(\\\\d{4})(\\\\d{2,4})(\\\\d{4})","$1 $2 $3","180","$FG"],["(\\\\d{4})(\\\\d{3,4})(\\\\d{4})","$1 $2 $3","186","$FG"],["(\\\\d{4})(\\\\d{3})(\\\\d{3})(\\\\d{3})","$1 $2 $3 $4","18[06]","$FG"]]]', + 389: '["MK","00","0",null,null,"$NP$FG","\\\\d{6,8}","[2-578]\\\\d{7}",[["(2)(\\\\d{3})(\\\\d{4})","$1 $2 $3","2",null],["([347]\\\\d)(\\\\d{3})(\\\\d{3})","$1 $2 $3","[347]",null],["([58]\\\\d{2})(\\\\d)(\\\\d{2})(\\\\d{2})","$1 $2 $3 $4","[58]",null]]]', + 1: [ + '["US","011","1",null,null,null,"\\\\d{7}(?:\\\\d{3})?","[2-9]\\\\d{9}",[["(\\\\d{3})(\\\\d{4})","$1-$2",null,null,"NA"],["(\\\\d{3})(\\\\d{3})(\\\\d{4})","($1) $2-$3",null,null,"$1-$2-$3"]]]', + '["AI","011","1",null,null,null,"\\\\d{7}(?:\\\\d{3})?","[2589]\\\\d{9}"]', + '["AS","011","1",null,null,null,"\\\\d{7}(?:\\\\d{3})?","[5689]\\\\d{9}"]', + '["BB","011","1",null,null,null,"\\\\d{7}(?:\\\\d{3})?","[2589]\\\\d{9}"]', + '["BM","011","1",null,null,null,"\\\\d{7}(?:\\\\d{3})?","[4589]\\\\d{9}"]', + '["BS","011","1",null,null,null,"\\\\d{7}(?:\\\\d{3})?","[2589]\\\\d{9}"]', + '["CA","011","1",null,null,null,"\\\\d{7}(?:\\\\d{3})?","[2-9]\\\\d{9}|3\\\\d{6}"]', + '["DM","011","1",null,null,null,"\\\\d{7}(?:\\\\d{3})?","[57-9]\\\\d{9}"]', + '["DO","011","1",null,null,null,"\\\\d{7}(?:\\\\d{3})?","[589]\\\\d{9}"]', + '["GD","011","1",null,null,null,"\\\\d{7}(?:\\\\d{3})?","[4589]\\\\d{9}"]', + '["GU","011","1",null,null,null,"\\\\d{7}(?:\\\\d{3})?","[5689]\\\\d{9}"]', + '["JM","011","1",null,null,null,"\\\\d{7}(?:\\\\d{3})?","[589]\\\\d{9}"]', + '["KN","011","1",null,null,null,"\\\\d{7}(?:\\\\d{3})?","[589]\\\\d{9}"]', + '["KY","011","1",null,null,null,"\\\\d{7}(?:\\\\d{3})?","[3589]\\\\d{9}"]', + '["LC","011","1",null,null,null,"\\\\d{7}(?:\\\\d{3})?","[5789]\\\\d{9}"]', + '["MP","011","1",null,null,null,"\\\\d{7}(?:\\\\d{3})?","[5689]\\\\d{9}"]', + '["MS","011","1",null,null,null,"\\\\d{7}(?:\\\\d{3})?","[5689]\\\\d{9}"]', + '["PR","011","1",null,null,null,"\\\\d{7}(?:\\\\d{3})?","[5789]\\\\d{9}"]', + '["SX","011","1",null,null,null,"\\\\d{7}(?:\\\\d{3})?","[5789]\\\\d{9}"]', + '["TC","011","1",null,null,null,"\\\\d{7}(?:\\\\d{3})?","[5689]\\\\d{9}"]', + '["TT","011","1",null,null,null,"\\\\d{7}(?:\\\\d{3})?","[589]\\\\d{9}"]', + '["AG","011","1",null,null,null,"\\\\d{7}(?:\\\\d{3})?","[2589]\\\\d{9}"]', + '["VC","011","1",null,null,null,"\\\\d{7}(?:\\\\d{3})?","[5789]\\\\d{9}"]', + '["VG","011","1",null,null,null,"\\\\d{7}(?:\\\\d{3})?","[2589]\\\\d{9}"]', + '["VI","011","1",null,null,null,"\\\\d{7}(?:\\\\d{3})?","[3589]\\\\d{9}"]', + ], + 60: '["MY","00","0",null,null,null,"\\\\d{6,10}","[13-9]\\\\d{7,9}",[["([4-79])(\\\\d{3})(\\\\d{4})","$1-$2 $3","[4-79]","$NP$FG"],["(3)(\\\\d{4})(\\\\d{4})","$1-$2 $3","3","$NP$FG"],["([18]\\\\d)(\\\\d{3})(\\\\d{3,4})","$1-$2 $3","1[02-46-9][1-9]|8","$NP$FG"],["(1)([36-8]00)(\\\\d{2})(\\\\d{4})","$1-$2-$3-$4","1[36-8]0",null],["(11)(\\\\d{4})(\\\\d{4})","$1-$2 $3","11","$NP$FG"],["(15[49])(\\\\d{3})(\\\\d{4})","$1-$2 $3","15","$NP$FG"]]]', + 355: '["AL","00","0",null,null,"$NP$FG","\\\\d{5,9}","[2-57]\\\\d{7}|6\\\\d{8}|8\\\\d{5,7}|9\\\\d{5}",[["(4)(\\\\d{3})(\\\\d{4})","$1 $2 $3","4[0-6]",null],["(6\\\\d)(\\\\d{3})(\\\\d{4})","$1 $2 $3","6",null],["(\\\\d{2})(\\\\d{3})(\\\\d{3})","$1 $2 $3","[2358][2-5]|4[7-9]",null],["(\\\\d{3})(\\\\d{3,5})","$1 $2","[235][16-9]|8[016-9]|[79]",null]]]', + 254: '["KE","000","0","005|0",null,"$NP$FG","\\\\d{7,10}","20\\\\d{6,7}|[4-9]\\\\d{6,9}",[["(\\\\d{2})(\\\\d{5,7})","$1 $2","[24-6]",null],["(\\\\d{3})(\\\\d{6})","$1 $2","7",null],["(\\\\d{3})(\\\\d{3})(\\\\d{3,4})","$1 $2 $3","[89]",null]]]', + 223: '["ML","00",null,null,null,null,"\\\\d{8}","[246-9]\\\\d{7}",[["(\\\\d{2})(\\\\d{2})(\\\\d{2})(\\\\d{2})","$1 $2 $3 $4","[246-9]",null],["(\\\\d{4})","$1","67|74",null,"NA"]]]', + 686: '["KI","00",null,"0",null,null,"\\\\d{5,8}","[2458]\\\\d{4}|3\\\\d{4,7}|7\\\\d{7}"]', + 994: '["AZ","00","0",null,null,"($NP$FG)","\\\\d{7,9}","[1-9]\\\\d{8}",[["(\\\\d{2})(\\\\d{3})(\\\\d{2})(\\\\d{2})","$1 $2 $3 $4","(?:1[28]|2(?:[45]2|[0-36])|365)",null],["(\\\\d{2})(\\\\d{3})(\\\\d{2})(\\\\d{2})","$1 $2 $3 $4","[4-8]","$NP$FG"],["(\\\\d{3})(\\\\d{2})(\\\\d{2})(\\\\d{2})","$1 $2 $3 $4","9","$NP$FG"]]]', + 979: '["001",null,null,null,null,null,"\\\\d{9}","\\\\d{9}",[["(\\\\d)(\\\\d{4})(\\\\d{4})","$1 $2 $3",null,null]]]', + 66: '["TH","00","0",null,null,"$NP$FG","\\\\d{4}|\\\\d{8,10}","[2-9]\\\\d{7,8}|1\\\\d{3}(?:\\\\d{5,6})?",[["(2)(\\\\d{3})(\\\\d{4})","$1 $2 $3","2",null],["([13-9]\\\\d)(\\\\d{3})(\\\\d{3,4})","$1 $2 $3","14|[3-9]",null],["(1[89]00)(\\\\d{3})(\\\\d{3})","$1 $2 $3","1","$FG"]]]', + 233: '["GH","00","0",null,null,"$NP$FG","\\\\d{7,9}","[235]\\\\d{8}|8\\\\d{7}",[["(\\\\d{2})(\\\\d{3})(\\\\d{4})","$1 $2 $3","[235]",null],["(\\\\d{3})(\\\\d{5})","$1 $2","8",null]]]', + 593: '["EC","00","0",null,null,"($NP$FG)","\\\\d{7,11}","1\\\\d{9,10}|[2-8]\\\\d{7}|9\\\\d{8}",[["(\\\\d)(\\\\d{3})(\\\\d{4})","$1 $2-$3","[247]|[356][2-8]",null,"$1-$2-$3"],["(\\\\d{2})(\\\\d{3})(\\\\d{4})","$1 $2 $3","9","$NP$FG"],["(1800)(\\\\d{3})(\\\\d{3,4})","$1 $2 $3","1","$FG"]]]', + 509: '["HT","00",null,null,null,null,"\\\\d{8}","[2-489]\\\\d{7}",[["(\\\\d{2})(\\\\d{2})(\\\\d{4})","$1 $2 $3",null,null]]]', + 54: '["AR","00","0","0?(?:(11|2(?:2(?:02?|[13]|2[13-79]|4[1-6]|5[2457]|6[124-8]|7[1-4]|8[13-6]|9[1267])|3(?:02?|1[467]|2[03-6]|3[13-8]|[49][2-6]|5[2-8]|[67])|4(?:7[3-578]|9)|6(?:[0136]|2[24-6]|4[6-8]?|5[15-8])|80|9(?:0[1-3]|[19]|2\\\\d|3[1-6]|4[02568]?|5[2-4]|6[2-46]|72?|8[23]?))|3(?:3(?:2[79]|6|8[2578])|4(?:0[0-24-9]|[12]|3[5-8]?|4[24-7]|5[4-68]?|6[02-9]|7[126]|8[2379]?|9[1-36-8])|5(?:1|2[1245]|3[237]?|4[1-46-9]|6[2-4]|7[1-6]|8[2-5]?)|6[24]|7(?:[069]|1[1568]|2[15]|3[145]|4[13]|5[14-8]|7[2-57]|8[126])|8(?:[01]|2[15-7]|3[2578]?|4[13-6]|5[4-8]?|6[1-357-9]|7[36-8]?|8[5-8]?|9[124])))?15)?","9$1","$NP$FG","\\\\d{6,11}","11\\\\d{8}|[2368]\\\\d{9}|9\\\\d{10}",[["([68]\\\\d{2})(\\\\d{3})(\\\\d{4})","$1-$2-$3","[68]",null],["(\\\\d{2})(\\\\d{4})","$1-$2","[2-9]","$FG","NA"],["(\\\\d{3})(\\\\d{4})","$1-$2","[2-9]","$FG","NA"],["(\\\\d{4})(\\\\d{4})","$1-$2","[2-9]","$FG","NA"],["(9)(11)(\\\\d{4})(\\\\d{4})","$2 15-$3-$4","911",null,"$1 $2 $3-$4"],["(9)(\\\\d{3})(\\\\d{3})(\\\\d{4})","$2 15-$3-$4","9(?:2[234689]|3[3-8])",null,"$1 $2 $3-$4"],["(9)(\\\\d{4})(\\\\d{2})(\\\\d{4})","$2 15-$3-$4","9[23]",null,"$1 $2 $3-$4"],["(11)(\\\\d{4})(\\\\d{4})","$1 $2-$3","1",null],["(\\\\d{3})(\\\\d{3})(\\\\d{4})","$1 $2-$3","2(?:2[013]|3[067]|49|6[01346]|80|9[147-9])|3(?:36|4[1-358]|5[138]|6[24]|7[069]|8[013578])",null],["(\\\\d{4})(\\\\d{2})(\\\\d{4})","$1 $2-$3","[23]",null],["(\\\\d{3})","$1","1[012]|911","$FG","NA"]]]', + 57: '["CO","00(?:4(?:[14]4|56)|[579])","0","0([3579]|4(?:44|56))?",null,null,"\\\\d{7,11}","(?:[13]\\\\d{0,3}|[24-8])\\\\d{7}",[["(\\\\d)(\\\\d{7})","$1 $2","1(?:8[2-9]|9[0-3]|[2-7])|[24-8]","($FG)"],["(\\\\d{3})(\\\\d{7})","$1 $2","3",null],["(1)(\\\\d{3})(\\\\d{7})","$1-$2-$3","1(?:80|9[04])","$NP$FG","$1 $2 $3"]]]', + 597: '["SR","00",null,null,null,null,"\\\\d{6,7}","[2-8]\\\\d{5,6}",[["(\\\\d{3})(\\\\d{3})","$1-$2","[2-4]|5[2-58]",null],["(\\\\d{2})(\\\\d{2})(\\\\d{2})","$1-$2-$3","56",null],["(\\\\d{3})(\\\\d{4})","$1-$2","[6-8]",null]]]', + 676: '["TO","00",null,null,null,null,"\\\\d{5,7}","[02-8]\\\\d{4,6}",[["(\\\\d{2})(\\\\d{3})","$1-$2","[1-6]|7[0-4]|8[05]",null],["(\\\\d{3})(\\\\d{4})","$1 $2","7[5-9]|8[47-9]",null],["(\\\\d{4})(\\\\d{3})","$1 $2","0",null]]]', + 505: '["NI","00",null,null,null,null,"\\\\d{8}","[12578]\\\\d{7}",[["(\\\\d{4})(\\\\d{4})","$1 $2",null,null]]]', + 850: '["KP","00|99","0",null,null,"$NP$FG","\\\\d{6,8}|\\\\d{10}","1\\\\d{9}|[28]\\\\d{7}",[["(\\\\d{3})(\\\\d{3})(\\\\d{4})","$1 $2 $3","1",null],["(\\\\d)(\\\\d{3})(\\\\d{4})","$1 $2 $3","2",null],["(\\\\d{2})(\\\\d{3})(\\\\d{3})","$1 $2 $3","8",null]]]', + 7: [ + '["RU","810","8",null,null,"$NP ($FG)","\\\\d{10}","[3489]\\\\d{9}",[["(\\\\d{3})(\\\\d{2})(\\\\d{2})","$1-$2-$3","[1-79]","$FG","NA"],["([3489]\\\\d{2})(\\\\d{3})(\\\\d{2})(\\\\d{2})","$1 $2-$3-$4","[34689]",null],["(7\\\\d{2})(\\\\d{3})(\\\\d{4})","$1 $2 $3","7",null]]]', + '["KZ","810","8",null,null,null,"\\\\d{10}","(?:33\\\\d|7\\\\d{2}|80[09])\\\\d{7}"]', + ], + 268: '["SZ","00",null,null,null,null,"\\\\d{8}","[027]\\\\d{7}",[["(\\\\d{4})(\\\\d{4})","$1 $2","[027]",null]]]', + 501: '["BZ","00",null,null,null,null,"\\\\d{7}(?:\\\\d{4})?","[2-8]\\\\d{6}|0\\\\d{10}",[["(\\\\d{3})(\\\\d{4})","$1-$2","[2-8]",null],["(0)(800)(\\\\d{4})(\\\\d{3})","$1-$2-$3-$4","0",null]]]', + 252: '["SO","00","0",null,null,null,"\\\\d{6,9}","[1-9]\\\\d{5,8}",[["(\\\\d{6})","$1","[134]",null],["(\\\\d)(\\\\d{6})","$1 $2","2[0-79]|[13-5]",null],["(\\\\d)(\\\\d{7})","$1 $2","24|[67]",null],["(\\\\d{2})(\\\\d{4})","$1 $2","8[125]",null],["(\\\\d{2})(\\\\d{5,7})","$1 $2","15|28|6[1-35-9]|799|9[2-9]",null],["(\\\\d{3})(\\\\d{3})(\\\\d{3})","$1 $2 $3","3[59]|4[89]|6[24-6]|79|8[08]|90",null]]]', + 229: '["BJ","00",null,null,null,null,"\\\\d{4,8}","[2689]\\\\d{7}|7\\\\d{3}",[["(\\\\d{2})(\\\\d{2})(\\\\d{2})(\\\\d{2})","$1 $2 $3 $4",null,null]]]', + 680: '["PW","01[12]",null,null,null,null,"\\\\d{7}","[2-8]\\\\d{6}",[["(\\\\d{3})(\\\\d{4})","$1 $2",null,null]]]', + 263: '["ZW","00","0",null,null,"$NP$FG","\\\\d{3,10}","2(?:[012457-9]\\\\d{3,8}|6(?:[14]\\\\d{7}|\\\\d{4}))|[13-79]\\\\d{4,9}|8[06]\\\\d{8}",[["([49])(\\\\d{3})(\\\\d{2,4})","$1 $2 $3","4|9[2-9]",null],["(7\\\\d)(\\\\d{3})(\\\\d{3,4})","$1 $2 $3","7",null],["(86\\\\d{2})(\\\\d{3})(\\\\d{3})","$1 $2 $3","86[24]",null],["([2356]\\\\d{2})(\\\\d{3,5})","$1 $2","2(?:0[45]|2[278]|[49]8|[78])|3(?:08|17|3[78]|7[1569]|8[37]|98)|5[15][78]|6(?:[29]8|[38]7|6[78]|75|[89]8)",null],["(\\\\d{3})(\\\\d{3})(\\\\d{3,4})","$1 $2 $3","2(?:1[39]|2[0157]|6[14]|7[35]|84)|329",null],["([1-356]\\\\d)(\\\\d{3,5})","$1 $2","1[3-9]|2[0569]|3[0-69]|5[05689]|6[0-46-9]",null],["([235]\\\\d)(\\\\d{3})(\\\\d{3,4})","$1 $2 $3","[23]9|54",null],["([25]\\\\d{3})(\\\\d{3,5})","$1 $2","(?:25|54)8",null],["(8\\\\d{3})(\\\\d{6})","$1 $2","86",null],["(80\\\\d)(\\\\d{3})(\\\\d{4})","$1 $2 $3","80",null]]]', + 90: '["TR","00","0",null,null,null,"\\\\d{7,10}","[2-589]\\\\d{9}|444\\\\d{4}",[["(\\\\d{3})(\\\\d{3})(\\\\d{2})(\\\\d{2})","$1 $2 $3 $4","[23]|4(?:[0-35-9]|4[0-35-9])","($NP$FG)"],["(\\\\d{3})(\\\\d{3})(\\\\d{2})(\\\\d{2})","$1 $2 $3 $4","5[02-69]","$NP$FG"],["(\\\\d{3})(\\\\d{3})(\\\\d{4})","$1 $2 $3","51|[89]","$NP$FG"],["(444)(\\\\d{1})(\\\\d{3})","$1 $2 $3","444",null]]]', + 352: '["LU","00",null,"(15(?:0[06]|1[12]|35|4[04]|55|6[26]|77|88|99)\\\\d)",null,null,"\\\\d{4,11}","[24-9]\\\\d{3,10}|3(?:[0-46-9]\\\\d{2,9}|5[013-9]\\\\d{1,8})",[["(\\\\d{2})(\\\\d{3})","$1 $2","[2-5]|7[1-9]|[89](?:[1-9]|0[2-9])",null],["(\\\\d{2})(\\\\d{2})(\\\\d{2})","$1 $2 $3","[2-5]|7[1-9]|[89](?:[1-9]|0[2-9])",null],["(\\\\d{2})(\\\\d{2})(\\\\d{3})","$1 $2 $3","20",null],["(\\\\d{2})(\\\\d{2})(\\\\d{2})(\\\\d{1,2})","$1 $2 $3 $4","2(?:[0367]|4[3-8])",null],["(\\\\d{2})(\\\\d{2})(\\\\d{2})(\\\\d{3})","$1 $2 $3 $4","20",null],["(\\\\d{2})(\\\\d{2})(\\\\d{2})(\\\\d{2})(\\\\d{1,2})","$1 $2 $3 $4 $5","2(?:[0367]|4[3-8])",null],["(\\\\d{2})(\\\\d{2})(\\\\d{2})(\\\\d{1,4})","$1 $2 $3 $4","2(?:[12589]|4[12])|[3-5]|7[1-9]|8(?:[1-9]|0[2-9])|9(?:[1-9]|0[2-46-9])",null],["(\\\\d{3})(\\\\d{2})(\\\\d{3})","$1 $2 $3","70|80[01]|90[015]",null],["(\\\\d{3})(\\\\d{3})(\\\\d{3})","$1 $2 $3","6",null]]]', + 47: [ + '["NO","00",null,null,null,null,"\\\\d{5}(?:\\\\d{3})?","0\\\\d{4}|[2-9]\\\\d{7}",[["([489]\\\\d{2})(\\\\d{2})(\\\\d{3})","$1 $2 $3","[489]",null],["([235-7]\\\\d)(\\\\d{2})(\\\\d{2})(\\\\d{2})","$1 $2 $3 $4","[235-7]",null]]]', + '["SJ","00",null,null,null,null,"\\\\d{5}(?:\\\\d{3})?","0\\\\d{4}|[45789]\\\\d{7}"]', + ], + 243: '["CD","00","0",null,null,"$NP$FG","\\\\d{7,9}","[2-6]\\\\d{6}|[18]\\\\d{6,8}|9\\\\d{8}",[["(\\\\d{2})(\\\\d{3})(\\\\d{4})","$1 $2 $3","12",null],["([89]\\\\d{2})(\\\\d{3})(\\\\d{3})","$1 $2 $3","8[0-2459]|9",null],["(\\\\d{2})(\\\\d{2})(\\\\d{3})","$1 $2 $3","88",null],["(\\\\d{2})(\\\\d{5})","$1 $2","[1-6]",null]]]', + 220: '["GM","00",null,null,null,null,"\\\\d{7}","[2-9]\\\\d{6}",[["(\\\\d{3})(\\\\d{4})","$1 $2",null,null]]]', + 687: '["NC","00",null,null,null,null,"\\\\d{6}","[2-57-9]\\\\d{5}",[["(\\\\d{2})(\\\\d{2})(\\\\d{2})","$1.$2.$3","[2-46-9]|5[0-4]",null]]]', + 995: '["GE","00","0",null,null,null,"\\\\d{6,9}","[34578]\\\\d{8}",[["(\\\\d{3})(\\\\d{2})(\\\\d{2})(\\\\d{2})","$1 $2 $3 $4","[348]","$NP$FG"],["(\\\\d{3})(\\\\d{3})(\\\\d{3})","$1 $2 $3","7","$NP$FG"],["(\\\\d{3})(\\\\d{2})(\\\\d{2})(\\\\d{2})","$1 $2 $3 $4","5","$FG"]]]', + 961: '["LB","00","0",null,null,null,"\\\\d{7,8}","[13-9]\\\\d{6,7}",[["(\\\\d)(\\\\d{3})(\\\\d{3})","$1 $2 $3","[13-6]|7(?:[2-57]|62|8[0-7]|9[04-9])|8[02-9]|9","$NP$FG"],["([7-9]\\\\d)(\\\\d{3})(\\\\d{3})","$1 $2 $3","[89][01]|7(?:[01]|6[013-9]|8[89]|9[1-3])",null]]]', + 40: '["RO","00","0",null,null,"$NP$FG","\\\\d{6,9}","[23]\\\\d{5,8}|[7-9]\\\\d{8}",[["(\\\\d{2})(\\\\d{3})(\\\\d{4})","$1 $2 $3","[23]1",null],["(\\\\d{2})(\\\\d{4})","$1 $2","[23]1",null],["(\\\\d{3})(\\\\d{3})(\\\\d{3})","$1 $2 $3","[23][3-7]|[7-9]",null],["(2\\\\d{2})(\\\\d{3})","$1 $2","2[3-6]",null]]]', + 232: '["SL","00","0",null,null,"($NP$FG)","\\\\d{6,8}","[2-9]\\\\d{7}",[["(\\\\d{2})(\\\\d{6})","$1 $2",null,null]]]', + 594: '["GF","00","0",null,null,"$NP$FG","\\\\d{9}","[56]\\\\d{8}",[["(\\\\d{3})(\\\\d{2})(\\\\d{2})(\\\\d{2})","$1 $2 $3 $4",null,null]]]', + 976: '["MN","001","0",null,null,"$NP$FG","\\\\d{6,10}","[12]\\\\d{7,9}|[57-9]\\\\d{7}",[["([12]\\\\d)(\\\\d{2})(\\\\d{4})","$1 $2 $3","[12]1",null],["([12]2\\\\d)(\\\\d{5,6})","$1 $2","[12]2[1-3]",null],["([12]\\\\d{3})(\\\\d{5})","$1 $2","[12](?:27|[3-5])",null],["(\\\\d{4})(\\\\d{4})","$1 $2","[57-9]","$FG"],["([12]\\\\d{4})(\\\\d{4,5})","$1 $2","[12](?:27|[3-5])",null]]]', + 20: '["EG","00","0",null,null,"$NP$FG","\\\\d{5,10}","1\\\\d{4,9}|[2456]\\\\d{8}|3\\\\d{7}|[89]\\\\d{8,9}",[["(\\\\d)(\\\\d{7,8})","$1 $2","[23]",null],["(\\\\d{3})(\\\\d{3})(\\\\d{4})","$1 $2 $3","1[012]|[89]00",null],["(\\\\d{2})(\\\\d{6,7})","$1 $2","1[35]|[4-6]|[89][2-9]",null]]]', + 689: '["PF","00",null,null,null,null,"\\\\d{6}(?:\\\\d{2})?","4\\\\d{5,7}|8\\\\d{7}",[["(\\\\d{2})(\\\\d{2})(\\\\d{2})(\\\\d{2})","$1 $2 $3 $4","4[09]|8[79]",null],["(\\\\d{2})(\\\\d{2})(\\\\d{2})","$1 $2 $3","44",null]]]', + 56: '["CL","(?:0|1(?:1[0-69]|2[0-57]|5[13-58]|69|7[0167]|8[018]))0","0","0|(1(?:1[0-69]|2[0-57]|5[13-58]|69|7[0167]|8[018]))",null,"$NP$FG","\\\\d{7,11}","(?:[2-9]|600|123)\\\\d{7,8}",[["(\\\\d)(\\\\d{4})(\\\\d{4})","$1 $2 $3","2[23]","($FG)"],["(\\\\d{2})(\\\\d{3})(\\\\d{4})","$1 $2 $3","[357]|4[1-35]|6[13-57]","($FG)"],["(9)(\\\\d{4})(\\\\d{4})","$1 $2 $3","9",null],["(44)(\\\\d{3})(\\\\d{4})","$1 $2 $3","44",null],["([68]00)(\\\\d{3})(\\\\d{3,4})","$1 $2 $3","60|8","$FG"],["(600)(\\\\d{3})(\\\\d{2})(\\\\d{3})","$1 $2 $3 $4","60","$FG"],["(1230)(\\\\d{3})(\\\\d{4})","$1 $2 $3","1","$FG"],["(\\\\d{5})(\\\\d{4})","$1 $2","219","($FG)"],["(\\\\d{4,5})","$1","[1-9]","$FG","NA"]]]', + 596: '["MQ","00","0",null,null,"$NP$FG","\\\\d{9}","[56]\\\\d{8}",[["(\\\\d{3})(\\\\d{2})(\\\\d{2})(\\\\d{2})","$1 $2 $3 $4",null,null]]]', + 508: '["PM","00","0",null,null,"$NP$FG","\\\\d{6}","[45]\\\\d{5}",[["([45]\\\\d)(\\\\d{2})(\\\\d{2})","$1 $2 $3",null,null]]]', + 269: '["KM","00",null,null,null,null,"\\\\d{7}","[3478]\\\\d{6}",[["(\\\\d{3})(\\\\d{2})(\\\\d{2})","$1 $2 $3",null,null]]]', + 358: [ + '["FI","00|99(?:[02469]|5(?:11|33|5[59]|88|9[09]))","0",null,null,"$NP$FG","\\\\d{5,12}","1\\\\d{4,11}|[2-9]\\\\d{4,10}",[["(\\\\d{3})(\\\\d{3,7})","$1 $2","(?:[1-3]00|[6-8]0)",null],["(116\\\\d{3})","$1","116","$FG"],["(\\\\d{2})(\\\\d{4,10})","$1 $2","[14]|2[09]|50|7[135]",null],["(\\\\d)(\\\\d{4,11})","$1 $2","[25689][1-8]|3",null]]]', + '["AX","00|99(?:[02469]|5(?:11|33|5[59]|88|9[09]))","0",null,null,"$NP$FG","\\\\d{5,12}","1\\\\d{5,11}|[35]\\\\d{5,9}|[27]\\\\d{4,9}|4\\\\d{5,10}|6\\\\d{7,9}|8\\\\d{6,9}"]', + ], + 251: '["ET","00","0",null,null,"$NP$FG","\\\\d{7,9}","[1-59]\\\\d{8}",[["([1-59]\\\\d)(\\\\d{3})(\\\\d{4})","$1 $2 $3",null,null]]]', + 681: '["WF","00",null,null,null,null,"\\\\d{6}","[4-8]\\\\d{5}",[["(\\\\d{2})(\\\\d{2})(\\\\d{2})","$1 $2 $3",null,null]]]', + 853: '["MO","00",null,null,null,null,"\\\\d{8}","[268]\\\\d{7}",[["([268]\\\\d{3})(\\\\d{4})","$1 $2",null,null]]]', + 44: [ + '["GB","00","0",null,null,"$NP$FG","\\\\d{4,10}","\\\\d{7,10}",[["(7\\\\d{3})(\\\\d{6})","$1 $2","7(?:[1-5789]|62)",null],["(\\\\d{2})(\\\\d{4})(\\\\d{4})","$1 $2 $3","2|5[56]|7[06]",null],["(\\\\d{3})(\\\\d{3})(\\\\d{4})","$1 $2 $3","1(?:1|\\\\d1)|3|9[018]",null],["(\\\\d{5})(\\\\d{4,5})","$1 $2","1(?:38|5[23]|69|76|94)",null],["(1\\\\d{3})(\\\\d{5,6})","$1 $2","1",null],["(800)(\\\\d{4})","$1 $2","800",null],["(845)(46)(4\\\\d)","$1 $2 $3","845",null],["(8\\\\d{2})(\\\\d{3})(\\\\d{4})","$1 $2 $3","8(?:4[2-5]|7[0-3])",null],["(80\\\\d)(\\\\d{3})(\\\\d{4})","$1 $2 $3","80",null],["([58]00)(\\\\d{6})","$1 $2","[58]00",null]]]', + '["GG","00","0",null,null,"$NP$FG","\\\\d{6,10}","[135789]\\\\d{6,9}"]', + '["IM","00","0",null,null,"$NP$FG","\\\\d{6,10}","[135789]\\\\d{6,9}"]', + '["JE","00","0",null,null,"$NP$FG","\\\\d{6,10}","[135789]\\\\d{6,9}"]', + ], + 244: '["AO","00",null,null,null,null,"\\\\d{9}","[29]\\\\d{8}",[["(\\\\d{3})(\\\\d{3})(\\\\d{3})","$1 $2 $3",null,null]]]', + 211: '["SS","00","0",null,null,null,"\\\\d{9}","[19]\\\\d{8}",[["(\\\\d{3})(\\\\d{3})(\\\\d{3})","$1 $2 $3",null,"$NP$FG"]]]', + 373: '["MD","00","0",null,null,"$NP$FG","\\\\d{8}","[235-9]\\\\d{7}",[["(\\\\d{2})(\\\\d{3})(\\\\d{3})","$1 $2 $3","22|3",null],["([25-7]\\\\d{2})(\\\\d{2})(\\\\d{3})","$1 $2 $3","2[13-9]|[5-7]",null],["([89]\\\\d{2})(\\\\d{5})","$1 $2","[89]",null]]]', + 996: '["KG","00","0",null,null,"$NP$FG","\\\\d{5,10}","[235-8]\\\\d{8,9}",[["(\\\\d{3})(\\\\d{3})(\\\\d{3})","$1 $2 $3","[25-7]|31[25]",null],["(\\\\d{4})(\\\\d{5})","$1 $2","3(?:1[36]|[2-9])",null],["(\\\\d{3})(\\\\d{3})(\\\\d)(\\\\d{3})","$1 $2 $3 $4","8",null]]]', + 93: '["AF","00","0",null,null,"$NP$FG","\\\\d{7,9}","[2-7]\\\\d{8}",[["([2-7]\\\\d)(\\\\d{3})(\\\\d{4})","$1 $2 $3","[2-7]",null]]]', + 260: '["ZM","00","0",null,null,"$NP$FG","\\\\d{9}","[289]\\\\d{8}",[["([29]\\\\d)(\\\\d{7})","$1 $2","[29]",null],["(800)(\\\\d{3})(\\\\d{3})","$1 $2 $3","8",null]]]', + 378: '["SM","00",null,"(?:0549)?([89]\\\\d{5})","0549$1",null,"\\\\d{6,10}","[05-7]\\\\d{7,9}",[["(\\\\d{2})(\\\\d{2})(\\\\d{2})(\\\\d{2})","$1 $2 $3 $4","[5-7]",null],["(0549)(\\\\d{6})","$1 $2","0",null,"($1) $2"],["(\\\\d{6})","0549 $1","[89]",null,"(0549) $1"]]]', + 235: '["TD","00|16",null,null,null,null,"\\\\d{8}","[2679]\\\\d{7}",[["(\\\\d{2})(\\\\d{2})(\\\\d{2})(\\\\d{2})","$1 $2 $3 $4",null,null]]]', + 960: '["MV","0(?:0|19)",null,null,null,null,"\\\\d{7,10}","[346-8]\\\\d{6,9}|9(?:00\\\\d{7}|\\\\d{6})",[["(\\\\d{3})(\\\\d{4})","$1-$2","[3467]|9(?:[1-9]|0[1-9])",null],["(\\\\d{3})(\\\\d{3})(\\\\d{4})","$1 $2 $3","[89]00",null]]]', + 221: '["SN","00",null,null,null,null,"\\\\d{9}","[3789]\\\\d{8}",[["(\\\\d{2})(\\\\d{3})(\\\\d{2})(\\\\d{2})","$1 $2 $3 $4","[379]",null],["(\\\\d{3})(\\\\d{2})(\\\\d{2})(\\\\d{2})","$1 $2 $3 $4","8",null]]]', + 595: '["PY","00","0",null,null,null,"\\\\d{5,9}","5[0-5]\\\\d{4,7}|[2-46-9]\\\\d{5,8}",[["(\\\\d{2})(\\\\d{5})","$1 $2","(?:[26]1|3[289]|4[124678]|7[123]|8[1236])","($NP$FG)"],["(\\\\d{2})(\\\\d{3})(\\\\d{3,4})","$1 $2 $3","(?:[26]1|3[289]|4[124678]|7[123]|8[1236])","($NP$FG)"],["(\\\\d{3})(\\\\d{3,6})","$1 $2","[2-9]0","$NP$FG"],["(\\\\d{3})(\\\\d{6})","$1 $2","9[1-9]","$NP$FG"],["(\\\\d{2})(\\\\d{3})(\\\\d{4})","$1 $2 $3","8700",null],["(\\\\d{3})(\\\\d{4,5})","$1 $2","[2-8][1-9]","($NP$FG)"],["(\\\\d{3})(\\\\d{3})(\\\\d{3})","$1 $2 $3","[2-8][1-9]","$NP$FG"]]]', + 977: '["NP","00","0",null,null,"$NP$FG","\\\\d{6,10}","[1-8]\\\\d{7}|9(?:[1-69]\\\\d{6,8}|7[2-6]\\\\d{5,7}|8\\\\d{8})",[["(1)(\\\\d{7})","$1-$2","1[2-6]",null],["(\\\\d{2})(\\\\d{6})","$1-$2","1[01]|[2-8]|9(?:[1-69]|7[15-9])",null],["(9\\\\d{2})(\\\\d{7})","$1-$2","9(?:6[013]|7[245]|8)","$FG"]]]', + 36: '["HU","00","06",null,null,"($FG)","\\\\d{6,9}","[1-9]\\\\d{7,8}",[["(1)(\\\\d{3})(\\\\d{4})","$1 $2 $3","1",null],["(\\\\d{2})(\\\\d{3})(\\\\d{3,4})","$1 $2 $3","[2-9]",null]]]', +}; diff --git a/toolkit/components/formautofill/phonenumberutils/PhoneNumberNormalizer.sys.mjs b/toolkit/components/formautofill/phonenumberutils/PhoneNumberNormalizer.sys.mjs new file mode 100644 index 0000000000..604eefe314 --- /dev/null +++ b/toolkit/components/formautofill/phonenumberutils/PhoneNumberNormalizer.sys.mjs @@ -0,0 +1,67 @@ +/* This Source Code Form is subject to the terms of the Apache License, Version + * 2.0. If a copy of the Apache License was not distributed with this file, You + * can obtain one at https://www.apache.org/licenses/LICENSE-2.0 */ + +// This library came from https://github.com/andreasgal/PhoneNumber.js but will +// be further maintained by our own in Form Autofill codebase. + +export var PhoneNumberNormalizer = (function () { + const UNICODE_DIGITS = /[\uFF10-\uFF19\u0660-\u0669\u06F0-\u06F9]/g; + const VALID_ALPHA_PATTERN = /[a-zA-Z]/g; + const LEADING_PLUS_CHARS_PATTERN = /^[+\uFF0B]+/g; + const NON_DIALABLE_CHARS = /[^,#+\*\d]/g; + + // Map letters to numbers according to the ITU E.161 standard + let E161 = { + a: 2, + b: 2, + c: 2, + d: 3, + e: 3, + f: 3, + g: 4, + h: 4, + i: 4, + j: 5, + k: 5, + l: 5, + m: 6, + n: 6, + o: 6, + p: 7, + q: 7, + r: 7, + s: 7, + t: 8, + u: 8, + v: 8, + w: 9, + x: 9, + y: 9, + z: 9, + }; + + // Normalize a number by converting unicode numbers and symbols to their + // ASCII equivalents and removing all non-dialable characters. + function NormalizeNumber(number, numbersOnly) { + if (typeof number !== "string") { + return ""; + } + + number = number.replace(UNICODE_DIGITS, function (ch) { + return String.fromCharCode(48 + (ch.charCodeAt(0) & 0xf)); + }); + if (!numbersOnly) { + number = number.replace(VALID_ALPHA_PATTERN, function (ch) { + return String(E161[ch.toLowerCase()] || 0); + }); + } + number = number.replace(LEADING_PLUS_CHARS_PATTERN, "+"); + number = number.replace(NON_DIALABLE_CHARS, ""); + return number; + } + + return { + Normalize: NormalizeNumber, + }; +})(); 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 <td>, and, if so, check the textContent of the containing <tr> + if (parentElement.tagName === "TD" && parentElement.parentElement) { + // TODO: How bad is the assumption that the <tr> won't be the parent of the <td>? + return regExp.test(parentElement.parentElement.textContent); + } + + // Check if the input is in a <dd>, and, if so, check the textContent of the preceding <dt> + 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.<Object>} records.address + * {Array.<Object>} 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<FormSection>} + * 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>} fieldDetails field detail array to be classified + * @returns {Array<FormSection>} 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<HTMLElement>} 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 <input> 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<HTMLElement>} 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<string>} 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 + // (?<!not)word -> (?<neg>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<string>} 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: + * <cc> - country code field. + * <ac> - area code field. + * <phone> - phone or prefix. + * <suffix> - suffix. + * <ext> - extension. + * :N means field is limited to N characters, otherwise it is unlimited. + * (pattern <field>)? 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: <cc> Area Code: <ac> Phone: <phone> (- <suffix> + + // (Ext: <ext>)?)? + // {REGEX_COUNTRY, FIELD_COUNTRY_CODE, 0}, + // {REGEX_AREA, FIELD_AREA_CODE, 0}, + // {REGEX_PHONE, FIELD_PHONE, 0}, + // {REGEX_SEPARATOR, FIELD_NONE, 0}, + + // \( <ac> \) <phone>:3 <suffix>:4 (Ext: <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: <cc> <ac>:3 - <phone>:3 - <suffix>:4 (Ext: <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: <cc>:3 <ac>:3 <phone>:3 <suffix>:4 (Ext: <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: <ac> Phone: <phone> (- <suffix> (Ext: <ext>)?)? + // {REGEX_AREA, FIELD_AREA_CODE, 0}, + // {REGEX_PHONE, FIELD_PHONE, 0}, + // {REGEX_SEPARATOR, FIELD_NONE, 0}, + + // Phone: <ac> <phone>:3 <suffix>:4 (Ext: <ext>)? + // {REGEX_PHONE, FIELD_AREA_CODE, 0}, + // {REGEX_PHONE, FIELD_PHONE, 3}, + // {REGEX_PHONE, FIELD_SUFFIX, 4}, + // {REGEX_SEPARATOR, FIELD_NONE, 0}, + + // Phone: <cc> \( <ac> \) <phone> (- <suffix> (Ext: <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: \( <ac> \) <phone> (- <suffix> (Ext: <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: <cc> - <ac> - <phone> - <suffix> (Ext: <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: <ac>:3 Prefix: <prefix>:3 Suffix: <suffix>:4 (Ext: <ext>)? + // {REGEX_AREA, FIELD_AREA_CODE, 3}, + // {REGEX_PREFIX, FIELD_PHONE, 3}, + // {REGEX_SUFFIX, FIELD_SUFFIX, 4}, + // {REGEX_SEPARATOR, FIELD_NONE, 0}, + + // Phone: <ac> Prefix: <phone> Suffix: <suffix> (Ext: <ext>)? + // {REGEX_PHONE, FIELD_AREA_CODE, 0}, + // {REGEX_PREFIX, FIELD_PHONE, 0}, + // {REGEX_SUFFIX, FIELD_SUFFIX, 0}, + // {REGEX_SEPARATOR, FIELD_NONE, 0}, + + // Phone: <ac> - <phone>:3 - <suffix>:4 (Ext: <ext>)? + ["tel", "tel-area-code", 0], + ["tel", "tel-local-prefix", 3], + ["tel", "tel-local-suffix", 4], + [null, null, 0], + + // Phone: <cc> - <ac> - <phone> (Ext: <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: <ac> - <phone> (Ext: <ext>)? + // {REGEX_AREA, FIELD_AREA_CODE, 0}, + // {REGEX_PHONE, FIELD_PHONE, 0}, + // {REGEX_SEPARATOR, FIELD_NONE, 0}, + + // Phone: <cc>:3 - <phone>:10 (Ext: <ext>)? + // {REGEX_PHONE, FIELD_COUNTRY_CODE, 3}, + // {REGEX_PHONE, FIELD_PHONE, 10}, + // {REGEX_SEPARATOR, FIELD_NONE, 0}, + + // Ext: <ext> + // {REGEX_EXTENSION, FIELD_EXTENSION, 0}, + // {REGEX_SEPARATOR, FIELD_NONE, 0}, + + // Phone: <phone> (Ext: <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 <select>. + dimFieldDetails.push(fieldDetail); + } else { + isAutofilled |= + this.handler.getFilledStateByElement(element) == + FIELD_STATES.AUTO_FILLED; + } + } + if (!isAutofilled) { + // Restore the dim fields to initial state as well once we knew + // that user had intention to clear the filled form manually. + for (const fieldDetail of dimFieldDetails) { + // If we can't find a selected option, then we should just reset to the first option's value + let element = fieldDetail.elementWeakRef.get(); + this._resetSelectElementValue(element); + this.handler.changeFieldState(fieldDetail, FIELD_STATES.NORMAL); + } + this.filledRecordGUID = null; + } + } + + /** + * Clear preview text and background highlight of all fields. + */ + clearPreviewedFormFields() { + this.log.debug("clear previewed fields"); + + for (const fieldDetail of this.fieldDetails) { + let element = fieldDetail.elementWeakRef.get(); + if (!element) { + this.log.warn(fieldDetail.fieldName, "is unreachable"); + continue; + } + + element.previewValue = ""; + + // We keep the state if this field has + // already been auto-filled. + if ( + this.handler.getFilledStateByElement(element) == + FIELD_STATES.AUTO_FILLED + ) { + continue; + } + + this.handler.changeFieldState(fieldDetail, FIELD_STATES.NORMAL); + } + } + + /** + * Clear value and highlight style of all filled fields. + */ + clearPopulatedForm() { + for (let fieldDetail of this.fieldDetails) { + let element = fieldDetail.elementWeakRef.get(); + if (!element) { + this.log.warn(fieldDetail.fieldName, "is unreachable"); + continue; + } + + if ( + this.handler.getFilledStateByElement(element) == + FIELD_STATES.AUTO_FILLED + ) { + if (HTMLInputElement.isInstance(element)) { + element.setUserInput(""); + } else if (HTMLSelectElement.isInstance(element)) { + // If we can't find a selected option, then we should just reset to the first option's value + this._resetSelectElementValue(element); + } + } + } + } + + resetFieldStates() { + for (const fieldDetail of this.fieldDetails) { + const element = fieldDetail.elementWeakRef.get(); + element.removeEventListener("input", this, { mozSystemGroup: true }); + this.handler.changeFieldState(fieldDetail, FIELD_STATES.NORMAL); + } + this.filledRecordGUID = null; + } + + isFilled() { + return !!this.filledRecordGUID; + } + + /** + * Condenses multiple credit card number fields into one fieldDetail + * in order to submit the credit card record correctly. + * + * @param {Array.<object>} condensedDetails + * An array of fieldDetails + * @memberof FormAutofillSection + */ + _condenseMultipleCCNumberFields(condensedDetails) { + let countOfCCNumbers = 0; + // We ignore the cases where there are more than or less than four credit card number + // fields in a form as this is not a valid case for filling the credit card number. + for (let i = condensedDetails.length - 1; i >= 0; i--) { + if (condensedDetails[i].fieldName == "cc-number") { + countOfCCNumbers++; + if (countOfCCNumbers == 4) { + countOfCCNumbers = 0; + condensedDetails[i].fieldValue = + condensedDetails[i].elementWeakRef.get()?.value + + condensedDetails[i + 1].elementWeakRef.get()?.value + + condensedDetails[i + 2].elementWeakRef.get()?.value + + condensedDetails[i + 3].elementWeakRef.get()?.value; + condensedDetails.splice(i + 1, 3); + } + } else { + countOfCCNumbers = 0; + } + } + } + /** + * Return the record that is converted from `fieldDetails` and only valid + * form record is included. + * + * @returns {object | null} + * A record object consists of three properties: + * - guid: The id of the previously-filled profile or null if omitted. + * - record: A valid record converted from details with trimmed result. + * - untouchedFields: Fields that aren't touched after autofilling. + * Return `null` for any uncreatable or invalid record. + */ + createRecord() { + let details = this.fieldDetails; + if (!this.isEnabled() || !details || !details.length) { + return null; + } + + let data = { + guid: this.filledRecordGUID, + record: {}, + untouchedFields: [], + section: this, + }; + if (this.flowId) { + data.flowId = this.flowId; + } + let condensedDetails = this.fieldDetails; + + // TODO: This is credit card specific code... + this._condenseMultipleCCNumberFields(condensedDetails); + + condensedDetails.forEach(detail => { + const element = detail.elementWeakRef.get(); + // Remove the unnecessary spaces + let value = detail.fieldValue ?? (element && element.value.trim()); + value = this.computeFillingValue(value, detail, element); + + if (!value || value.length > FormAutofillUtils.MAX_FIELD_VALUE_LENGTH) { + // Keep the property and preserve more information for updating + data.record[detail.fieldName] = ""; + return; + } + + data.record[detail.fieldName] = value; + + if ( + this.handler.getFilledStateByElement(element) == + FIELD_STATES.AUTO_FILLED + ) { + data.untouchedFields.push(detail.fieldName); + } + }); + + this.createNormalizedRecord(data); + + if (!this.isRecordCreatable(data.record)) { + return null; + } + + return data; + } + + /** + * Resets a <select> 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 <iframe>; and, if so, + // watch for the <iframe> to pagehide. + if (handler.window.location != handler.window.parent?.location) { + this.log.debug( + "Credit card form is in an iframe -- watching for pagehide", + fieldDetails + ); + handler.window.addEventListener( + "pagehide", + this._handlePageHide.bind(this) + ); + } + } + + _handlePageHide(event) { + this.handler.window.removeEventListener( + "pagehide", + this._handlePageHide.bind(this) + ); + this.log.debug("Credit card subframe is pagehideing", this.handler.form); + this.handler.onFormSubmitted(); + } + + /** + * Determine whether a set of cc fields identified by our heuristics form a + * valid credit card section. + * There are 4 different cases when a field is considered a credit card field + * 1. Identified by autocomplete attribute. ex <input autocomplete="cc-number"> + * 2. Identified by fathom and fathom is pretty confident (when confidence + * value is higher than `highConfidenceThreshold`) + * 3. Identified by fathom. Confidence value is between `fathom.confidenceThreshold` + * and `fathom.highConfidenceThreshold` + * 4. Identified by regex-based heurstic. There is no confidence value in thise case. + * + * A form is considered a valid credit card form when one of the following condition + * is met: + * A. One of the cc field is identified by autocomplete (case 1) + * B. One of the cc field is identified by fathom (case 2 or 3), and there is also + * another cc field found by any of our heuristic (case 2, 3, or 4) + * C. Only one cc field is found in the section, but fathom is very confident (Case 2). + * Currently we add an extra restriction to this rule to decrease the false-positive + * rate. See comments below for details. + * + * @returns {boolean} True for a valid section, otherwise false + */ + isValidSection() { + let ccNumberDetail = null; + let ccNameDetail = null; + let ccExpiryDetail = null; + + for (let detail of this.fieldDetails) { + switch (detail.fieldName) { + case "cc-number": + ccNumberDetail = detail; + break; + case "cc-name": + case "cc-given-name": + case "cc-additional-name": + case "cc-family-name": + ccNameDetail = detail; + break; + case "cc-exp": + case "cc-exp-month": + case "cc-exp-year": + ccExpiryDetail = detail; + break; + } + } + + // Condition A. Always trust autocomplete attribute. A section is considered a valid + // cc section as long as a field has autocomplete=cc-number, cc-name or cc-exp* + if ( + ccNumberDetail?.reason == "autocomplete" || + ccNameDetail?.reason == "autocomplete" || + ccExpiryDetail?.reason == "autocomplete" + ) { + return true; + } + + // Condition B. One of the field is identified by fathom, if this section also + // contains another cc field found by our heuristic (Case 2, 3, or 4), we consider + // this section a valid credit card seciton + if (ccNumberDetail?.reason == "fathom") { + if (ccNameDetail || ccExpiryDetail) { + return true; + } + } else if (ccNameDetail?.reason == "fathom") { + if (ccNumberDetail || ccExpiryDetail) { + return true; + } + } + + // Condition C. + let highConfidenceThreshold = + FormAutofillUtils.ccFathomHighConfidenceThreshold; + let highConfidenceField; + if (ccNumberDetail?.confidence > highConfidenceThreshold) { + highConfidenceField = ccNumberDetail; + } else if (ccNameDetail?.confidence > highConfidenceThreshold) { + highConfidenceField = ccNameDetail; + } + if (highConfidenceField) { + // Temporarily add an addtional "the field is the only visible input" constraint + // when determining whether a form has only a high-confidence cc-* field a valid + // credit card section. We can remove this restriction once we are confident + // about only using fathom. + const element = highConfidenceField.elementWeakRef.get(); + const root = element.form || element.ownerDocument; + const inputs = root.querySelectorAll("input:not([type=hidden])"); + if (inputs.length == 1 && inputs[0] == element) { + return true; + } + } + + return false; + } + + isEnabled() { + return FormAutofill.isAutofillCreditCardsEnabled; + } + + isRecordCreatable(record) { + return ( + record["cc-number"] && FormAutofillUtils.isCCNumber(record["cc-number"]) + ); + } + + /** + * Handles credit card expiry date transformation when + * the expiry date exists in a cc-exp field. + * + * @param {object} profile + * @memberof FormAutofillCreditCardSection + */ + creditCardExpiryDateTransformer(profile) { + if (!profile["cc-exp"]) { + return; + } + + let detail = this.getFieldDetailByName("cc-exp"); + if (!detail) { + return; + } + + function monthYearOrderCheck( + _expiryDateTransformFormat, + _ccExpMonth, + _ccExpYear + ) { + // Bug 1687681: This is a short term fix to other locales having + // different characters to represent year. + // For example, FR locales may use "A" to represent year. + // For example, DE locales may use "J" to represent year. + // This approach will not scale well and should be investigated in a follow up bug. + let monthChars = "m"; + let yearChars = "yaj"; + let result; + + let monthFirstCheck = new RegExp( + "(?:\\b|^)((?:[" + + monthChars + + "]{2}){1,2})\\s*([\\-/])\\s*((?:[" + + yearChars + + "]{2}){1,2})(?:\\b|$)", + "i" + ); + + // If the month first check finds a result, where placeholder is "mm - yyyy", + // the result will be structured as such: ["mm - yyyy", "mm", "-", "yyyy"] + result = monthFirstCheck.exec(_expiryDateTransformFormat); + if (result) { + return ( + _ccExpMonth.toString().padStart(result[1].length, "0") + + result[2] + + _ccExpYear.toString().substr(-1 * result[3].length) + ); + } + + let yearFirstCheck = new RegExp( + "(?:\\b|^)((?:[" + + yearChars + + "]{2}){1,2})\\s*([\\-/])\\s*((?:[" + // either one or two counts of 'yy' or 'aa' sequence + monthChars + + "]){1,2})(?:\\b|$)", + "i" // either one or two counts of a 'm' sequence + ); + + // If the year first check finds a result, where placeholder is "yyyy mm", + // the result will be structured as such: ["yyyy mm", "yyyy", " ", "mm"] + result = yearFirstCheck.exec(_expiryDateTransformFormat); + + if (result) { + return ( + _ccExpYear.toString().substr(-1 * result[1].length) + + result[2] + + _ccExpMonth.toString().padStart(result[3].length, "0") + ); + } + return null; + } + + let element = detail.elementWeakRef.get(); + let result; + let ccExpMonth = profile["cc-exp-month"]; + let ccExpYear = profile["cc-exp-year"]; + if (element.tagName == "INPUT") { + // Use the placeholder to determine the expiry string format. + if (element.placeholder) { + result = monthYearOrderCheck( + element.placeholder, + ccExpMonth, + ccExpYear + ); + } + // If the previous sibling is a label, it is most likely meant to describe the + // expiry field. + if (!result && element.previousElementSibling?.tagName == "LABEL") { + result = monthYearOrderCheck( + element.previousElementSibling.textContent, + ccExpMonth, + ccExpYear + ); + } + } + + if (result) { + profile["cc-exp"] = result; + } else { + // Bug 1688576: Change YYYY-MM to MM/YYYY since MM/YYYY is the + // preferred presentation format for credit card expiry dates. + profile["cc-exp"] = + ccExpMonth.toString().padStart(2, "0") + "/" + ccExpYear.toString(); + } + } + + /** + * Handles credit card expiry date transformation when the expiry date exists in + * the separate cc-exp-month and cc-exp-year fields + * + * @param {object} profile + * @memberof FormAutofillCreditCardSection + */ + creditCardExpMonthAndYearTransformer(profile) { + const getInputElementByField = (field, self) => { + if (!field) { + return null; + } + let detail = self.getFieldDetailByName(field); + if (!detail) { + return null; + } + let element = detail.elementWeakRef.get(); + return element.tagName === "INPUT" ? element : null; + }; + let month = getInputElementByField("cc-exp-month", this); + if (month) { + // Transform the expiry month to MM since this is a common format needed for filling. + profile["cc-exp-month-formatted"] = profile["cc-exp-month"] + ?.toString() + .padStart(2, "0"); + } + let year = getInputElementByField("cc-exp-year", this); + // If the expiration year element is an input, + // then we examine any placeholder to see if we should format the expiration year + // as a zero padded string in order to autofill correctly. + if (year) { + let placeholder = year.placeholder; + + // Checks for 'YY'|'AA'|'JJ' placeholder and converts the year to a two digit string using the last two digits. + let result = /\b(yy|aa|jj)\b/i.test(placeholder); + if (result) { + profile["cc-exp-year-formatted"] = profile["cc-exp-year"] + .toString() + .substring(2); + } + } + } + + async _decrypt(cipherText, reauth) { + // Get the window for the form field. + let window; + for (let fieldDetail of this.fieldDetails) { + let element = fieldDetail.elementWeakRef.get(); + if (element) { + window = element.ownerGlobal; + break; + } + } + if (!window) { + return null; + } + + let actor = window.windowGlobalChild.getActor("FormAutofill"); + return actor.sendQuery("FormAutofill:GetDecryptedString", { + cipherText, + reauth, + }); + } + + /* + * Apply all credit card related transformers. + * + * @param {Object} profile + * A profile for adjusting credit card related value. + * @override + */ + applyTransformers(profile) { + // The matchSelectOptions transformer must be placed after the expiry transformers. + // This ensures that the expiry value that is cached in the matchSelectOptions + // matches the expiry value that is stored in the profile ensuring that autofill works + // correctly when dealing with option elements. + this.creditCardExpiryDateTransformer(profile); + this.creditCardExpMonthAndYearTransformer(profile); + this.matchSelectOptions(profile); + this.adaptFieldMaxLength(profile); + } + + getFilledValueFromProfile(fieldDetail, profile) { + const value = super.getFilledValueFromProfile(fieldDetail, profile); + if (fieldDetail.fieldName == "cc-number" && fieldDetail.part != null) { + const part = fieldDetail.part; + return value.slice((part - 1) * 4, part * 4); + } + return value; + } + + computeFillingValue(value, fieldDetail, element) { + if ( + fieldDetail.fieldName != "cc-type" || + !HTMLSelectElement.isInstance(element) + ) { + return value; + } + + if (lazy.CreditCard.isValidNetwork(value)) { + return value; + } + + // 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) { + let selectedOption = element.selectedOptions[0]; + let networkType = + lazy.CreditCard.getNetworkFromName(selectedOption.text) ?? + lazy.CreditCard.getNetworkFromName(selectedOption.value); + if (networkType) { + return networkType; + } + } + // If we couldn't match the value to any network, we'll + // strip this field when submitting. + return value; + } + + /** + * Customize for previewing profile + * + * @param {object} profile + * A profile for pre-processing before previewing values. + * @override + */ + preparePreviewProfile(profile) { + // Always show the decrypted credit card number when Master Password is + // disabled. + if (profile["cc-number-decrypted"]) { + profile["cc-number"] = profile["cc-number-decrypted"]; + } else if (!profile["cc-number"].startsWith("****")) { + // Show the previewed credit card as "**** 4444" which is + // needed when a credit card number field has a maxlength of four. + profile["cc-number"] = "****" + profile["cc-number"]; + } + } + + /** + * Customize for filling profile + * + * @param {object} profile + * A profile for pre-processing before filling values. + * @returns {boolean} Whether the profile should be filled. + * @override + */ + async prepareFillingProfile(profile) { + // Prompt the OS login dialog to get the decrypted credit + // card number. + if (profile["cc-number-encrypted"]) { + let decrypted = await this._decrypt( + profile["cc-number-encrypted"], + this.reauthPasswordPromptMessage + ); + + if (!decrypted) { + // Early return if the decrypted is empty or undefined + return false; + } + + profile["cc-number"] = decrypted; + } + return true; + } + + async autofillFields(profile) { + this.getAdaptedProfiles([profile]); + if (!(await super.autofillFields(profile))) { + return false; + } + + return true; + } + + createNormalizedRecord(creditCard) { + if (!creditCard?.record["cc-number"]) { + return; + } + // Normalize cc-number + creditCard.record["cc-number"] = lazy.CreditCard.normalizeCardNumber( + creditCard.record["cc-number"] + ); + + // Normalize cc-exp-month and cc-exp-year + let { month, year } = lazy.CreditCard.normalizeExpiration({ + expirationString: creditCard.record["cc-exp"], + expirationMonth: creditCard.record["cc-exp-month"], + expirationYear: creditCard.record["cc-exp-year"], + }); + if (month) { + creditCard.record["cc-exp-month"] = month; + } + if (year) { + creditCard.record["cc-exp-year"] = year; + } + } +} diff --git a/toolkit/components/formautofill/shared/FormAutofillUtils.sys.mjs b/toolkit/components/formautofill/shared/FormAutofillUtils.sys.mjs new file mode 100644 index 0000000000..31845ee73c --- /dev/null +++ b/toolkit/components/formautofill/shared/FormAutofillUtils.sys.mjs @@ -0,0 +1,1253 @@ +/* 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"; + +const lazy = {}; +ChromeUtils.defineESModuleGetters(lazy, { + CreditCard: "resource://gre/modules/CreditCard.sys.mjs", + FormAutofillNameUtils: + "resource://gre/modules/shared/FormAutofillNameUtils.sys.mjs", + OSKeyStore: "resource://gre/modules/OSKeyStore.sys.mjs", +}); + +export let FormAutofillUtils; + +const ADDRESS_METADATA_PATH = "resource://autofill/addressmetadata/"; +const ADDRESS_REFERENCES = "addressReferences.js"; +const ADDRESS_REFERENCES_EXT = "addressReferencesExt.js"; + +const ADDRESSES_COLLECTION_NAME = "addresses"; +const CREDITCARDS_COLLECTION_NAME = "creditCards"; +const MANAGE_ADDRESSES_L10N_IDS = [ + "autofill-add-new-address-title", + "autofill-manage-addresses-title", +]; +const EDIT_ADDRESS_L10N_IDS = [ + "autofill-address-given-name", + "autofill-address-additional-name", + "autofill-address-family-name", + "autofill-address-organization", + "autofill-address-street", + "autofill-address-state", + "autofill-address-province", + "autofill-address-city", + "autofill-address-country", + "autofill-address-zip", + "autofill-address-postal-code", + "autofill-address-email", + "autofill-address-tel", +]; +const MANAGE_CREDITCARDS_L10N_IDS = [ + "autofill-add-new-card-title", + "autofill-manage-credit-cards-title", +]; +const EDIT_CREDITCARD_L10N_IDS = [ + "autofill-card-number", + "autofill-card-name-on-card", + "autofill-card-expires-month", + "autofill-card-expires-year", + "autofill-card-network", +]; +const FIELD_STATES = { + NORMAL: "NORMAL", + AUTO_FILLED: "AUTO_FILLED", + PREVIEW: "PREVIEW", +}; + +const ELIGIBLE_INPUT_TYPES = ["text", "email", "tel", "number", "month"]; + +// The maximum length of data to be saved in a single field for preventing DoS +// attacks that fill the user's hard drive(s). +const MAX_FIELD_VALUE_LENGTH = 200; + +export let AddressDataLoader = { + // Status of address data loading. We'll load all the countries with basic level 1 + // information while requesting conutry information, and set country to true. + // Level 1 Set is for recording which country's level 1/level 2 data is loaded, + // since we only load this when getCountryAddressData called with level 1 parameter. + _dataLoaded: { + country: false, + level1: new Set(), + }, + + /** + * Load address data and extension script into a sandbox from different paths. + * + * @param {string} path + * The path for address data and extension script. It could be root of the address + * metadata folder(addressmetadata/) or under specific country(addressmetadata/TW/). + * @returns {object} + * A sandbox that contains address data object with properties from extension. + */ + _loadScripts(path) { + let sandbox = {}; + let extSandbox = {}; + + try { + sandbox = FormAutofillUtils.loadDataFromScript(path + ADDRESS_REFERENCES); + extSandbox = FormAutofillUtils.loadDataFromScript( + path + ADDRESS_REFERENCES_EXT + ); + } catch (e) { + // Will return only address references if extension loading failed or empty sandbox if + // address references loading failed. + return sandbox; + } + + if (extSandbox.addressDataExt) { + for (let key in extSandbox.addressDataExt) { + let addressDataForKey = sandbox.addressData[key]; + if (!addressDataForKey) { + addressDataForKey = sandbox.addressData[key] = {}; + } + + Object.assign(addressDataForKey, extSandbox.addressDataExt[key]); + } + } + return sandbox; + }, + + /** + * Convert certain properties' string value into array. We should make sure + * the cached data is parsed. + * + * @param {object} data Original metadata from addressReferences. + * @returns {object} parsed metadata with property value that converts to array. + */ + _parse(data) { + if (!data) { + return null; + } + + const properties = [ + "languages", + "sub_keys", + "sub_isoids", + "sub_names", + "sub_lnames", + ]; + for (let key of properties) { + if (!data[key]) { + continue; + } + // No need to normalize data if the value is array already. + if (Array.isArray(data[key])) { + return data; + } + + data[key] = data[key].split("~"); + } + return data; + }, + + /** + * We'll cache addressData in the loader once the data loaded from scripts. + * It'll become the example below after loading addressReferences with extension: + * addressData: { + * "data/US": {"lang": ["en"], ...// Data defined in libaddressinput metadata + * "alternative_names": ... // Data defined in extension } + * "data/CA": {} // Other supported country metadata + * "data/TW": {} // Other supported country metadata + * "data/TW/台北市": {} // Other supported country level 1 metadata + * } + * + * @param {string} country + * @param {string?} level1 + * @returns {object} Default locale metadata + */ + _loadData(country, level1 = null) { + // Load the addressData if needed + if (!this._dataLoaded.country) { + this._addressData = this._loadScripts(ADDRESS_METADATA_PATH).addressData; + this._dataLoaded.country = true; + } + if (!level1) { + return this._parse(this._addressData[`data/${country}`]); + } + // If level1 is set, load addressReferences under country folder with specific + // country/level 1 for level 2 information. + if (!this._dataLoaded.level1.has(country)) { + Object.assign( + this._addressData, + this._loadScripts(`${ADDRESS_METADATA_PATH}${country}/`).addressData + ); + this._dataLoaded.level1.add(country); + } + return this._parse(this._addressData[`data/${country}/${level1}`]); + }, + + /** + * Return the region metadata with default locale and other locales (if exists). + * + * @param {string} country + * @param {string?} level1 + * @returns {object} Return default locale and other locales metadata. + */ + getData(country, level1 = null) { + let defaultLocale = this._loadData(country, level1); + if (!defaultLocale) { + return null; + } + + let countryData = this._parse(this._addressData[`data/${country}`]); + let locales = []; + // TODO: Should be able to support multi-locale level 1/ level 2 metadata query + // in Bug 1421886 + if (countryData.languages) { + let list = countryData.languages.filter(key => key !== countryData.lang); + locales = list.map(key => + this._parse(this._addressData[`${defaultLocale.id}--${key}`]) + ); + } + return { defaultLocale, locales }; + }, +}; + +FormAutofillUtils = { + get AUTOFILL_FIELDS_THRESHOLD() { + return 3; + }, + + ADDRESSES_COLLECTION_NAME, + CREDITCARDS_COLLECTION_NAME, + MANAGE_ADDRESSES_L10N_IDS, + EDIT_ADDRESS_L10N_IDS, + MANAGE_CREDITCARDS_L10N_IDS, + EDIT_CREDITCARD_L10N_IDS, + MAX_FIELD_VALUE_LENGTH, + FIELD_STATES, + + _fieldNameInfo: { + name: "name", + "given-name": "name", + "additional-name": "name", + "family-name": "name", + organization: "organization", + "street-address": "address", + "address-line1": "address", + "address-line2": "address", + "address-line3": "address", + "address-level1": "address", + "address-level2": "address", + "postal-code": "address", + country: "address", + "country-name": "address", + tel: "tel", + "tel-country-code": "tel", + "tel-national": "tel", + "tel-area-code": "tel", + "tel-local": "tel", + "tel-local-prefix": "tel", + "tel-local-suffix": "tel", + "tel-extension": "tel", + email: "email", + "cc-name": "creditCard", + "cc-given-name": "creditCard", + "cc-additional-name": "creditCard", + "cc-family-name": "creditCard", + "cc-number": "creditCard", + "cc-exp-month": "creditCard", + "cc-exp-year": "creditCard", + "cc-exp": "creditCard", + "cc-type": "creditCard", + }, + + _collators: {}, + _reAlternativeCountryNames: {}, + + isAddressField(fieldName) { + return ( + !!this._fieldNameInfo[fieldName] && !this.isCreditCardField(fieldName) + ); + }, + + isCreditCardField(fieldName) { + return this._fieldNameInfo[fieldName] == "creditCard"; + }, + + isCCNumber(ccNumber) { + return lazy.CreditCard.isValidNumber(ccNumber); + }, + + ensureLoggedIn(promptMessage) { + return lazy.OSKeyStore.ensureLoggedIn( + this._reauthEnabledByUser && promptMessage ? promptMessage : false + ); + }, + + /** + * Get the array of credit card network ids ("types") we expect and offer as valid choices + * + * @returns {Array} + */ + getCreditCardNetworks() { + return lazy.CreditCard.getSupportedNetworks(); + }, + + getCategoryFromFieldName(fieldName) { + return this._fieldNameInfo[fieldName]; + }, + + getCategoriesFromFieldNames(fieldNames) { + let categories = new Set(); + for (let fieldName of fieldNames) { + let info = this.getCategoryFromFieldName(fieldName); + if (info) { + categories.add(info); + } + } + return Array.from(categories); + }, + + getAddressSeparator() { + // The separator should be based on the L10N address format, and using a + // white space is a temporary solution. + return " "; + }, + + /** + * Get address display label. It should display information separated + * by a comma. + * + * @param {object} address + * @returns {string} + */ + getAddressLabel(address) { + // TODO: Implement a smarter way for deciding what to display + // as option text. Possibly improve the algorithm in + // ProfileAutoCompleteResult.jsm and reuse it here. + let fieldOrder = [ + "name", + "-moz-street-address-one-line", // Street address + "address-level3", // Townland / Neighborhood / Village + "address-level2", // City/Town + "organization", // Company or organization name + "address-level1", // Province/State (Standardized code if possible) + "country-name", // Country name + "postal-code", // Postal code + "tel", // Phone number + "email", // Email address + ]; + + address = { ...address }; + let parts = []; + if (address["street-address"]) { + address["-moz-street-address-one-line"] = this.toOneLineAddress( + address["street-address"] + ); + } + + if (!("name" in address)) { + address.name = lazy.FormAutofillNameUtils.joinNameParts({ + given: address["given-name"], + middle: address["additional-name"], + family: address["family-name"], + }); + } + + for (const fieldName of fieldOrder) { + let string = address[fieldName]; + if (string) { + parts.push(string); + } + } + return parts.join(", "); + }, + + /** + * Internal method to split an address to multiple parts per the provided delimiter, + * removing blank parts. + * + * @param {string} address The address the split + * @param {string} [delimiter] The separator that is used between lines in the address + * @returns {string[]} + */ + _toStreetAddressParts(address, delimiter = "\n") { + let array = typeof address == "string" ? address.split(delimiter) : address; + + if (!Array.isArray(array)) { + return []; + } + return array.map(s => (s ? s.trim() : "")).filter(s => s); + }, + + /** + * Converts a street address to a single line, removing linebreaks marked by the delimiter + * + * @param {string} address The address the convert + * @param {string} [delimiter] The separator that is used between lines in the address + * @returns {string} + */ + toOneLineAddress(address, delimiter = "\n") { + let addressParts = this._toStreetAddressParts(address, delimiter); + return addressParts.join(this.getAddressSeparator()); + }, + + /** + * Compares two addresses, removing internal whitespace + * + * @param {string} a The first address to compare + * @param {string} b The second address to compare + * @param {Array} collators Search collators that will be used for comparison + * @param {string} [delimiter="\n"] The separator that is used between lines in the address + * @returns {boolean} True if the addresses are equal, false otherwise + */ + compareStreetAddress(a, b, collators, delimiter = "\n") { + let oneLineA = this._toStreetAddressParts(a, delimiter) + .map(p => p.replace(/\s/g, "")) + .join(""); + let oneLineB = this._toStreetAddressParts(b, delimiter) + .map(p => p.replace(/\s/g, "")) + .join(""); + return this.strCompare(oneLineA, oneLineB, collators); + }, + + /** + * In-place concatenate tel-related components into a single "tel" field and + * delete unnecessary fields. + * + * @param {object} address An address record. + */ + compressTel(address) { + let telCountryCode = address["tel-country-code"] || ""; + let telAreaCode = address["tel-area-code"] || ""; + + if (!address.tel) { + if (address["tel-national"]) { + address.tel = telCountryCode + address["tel-national"]; + } else if (address["tel-local"]) { + address.tel = telCountryCode + telAreaCode + address["tel-local"]; + } else if (address["tel-local-prefix"] && address["tel-local-suffix"]) { + address.tel = + telCountryCode + + telAreaCode + + address["tel-local-prefix"] + + address["tel-local-suffix"]; + } + } + + for (let field in address) { + if (field != "tel" && this.getCategoryFromFieldName(field) == "tel") { + delete address[field]; + } + } + }, + + /** + * Determines if an element can be autofilled or not. + * + * @param {HTMLElement} element + * @returns {boolean} true if the element can be autofilled + */ + isFieldAutofillable(element) { + return element && !element.readOnly && !element.disabled; + }, + + /** + * Determines if an element is visually hidden or not. + * + * @param {HTMLElement} element + * @param {boolean} visibilityCheck true to run visiblity check against + * element.checkVisibility API. Otherwise, test by only checking + * `hidden` and `display` attributes + * @returns {boolean} true if the element is visible + */ + isFieldVisible(element, visibilityCheck = true) { + if (visibilityCheck) { + return element.checkVisibility({ + checkOpacity: true, + checkVisibilityCSS: true, + }); + } + + return !element.hidden && element.style.display != "none"; + }, + + /** + * Determines if an element is eligible to be used by credit card or address autofill. + * + * @param {HTMLElement} element + * @returns {boolean} true if element can be used by credit card or address autofill + */ + isCreditCardOrAddressFieldType(element) { + if (!element) { + return false; + } + + if (HTMLInputElement.isInstance(element)) { + // `element.type` can be recognized as `text`, if it's missing or invalid. + return ELIGIBLE_INPUT_TYPES.includes(element.type); + } + + return HTMLSelectElement.isInstance(element); + }, + + loadDataFromScript(url, sandbox = {}) { + Services.scriptloader.loadSubScript(url, sandbox); + return sandbox; + }, + + /** + * Get country address data and fallback to US if not found. + * See AddressDataLoader._loadData for more details of addressData structure. + * + * @param {string} [country=FormAutofill.DEFAULT_REGION] + * The country code for requesting specific country's metadata. It'll be + * default region if parameter is not set. + * @param {string} [level1=null] + * Return address level 1/level 2 metadata if parameter is set. + * @returns {object|null} + * Return metadata of specific region with default locale and other supported + * locales. We need to return a default country metadata for layout format + * and collator, but for sub-region metadata we'll just return null if not found. + */ + getCountryAddressRawData( + country = FormAutofill.DEFAULT_REGION, + level1 = null + ) { + let metadata = AddressDataLoader.getData(country, level1); + if (!metadata) { + if (level1) { + return null; + } + // Fallback to default region if we couldn't get data from given country. + if (country != FormAutofill.DEFAULT_REGION) { + metadata = AddressDataLoader.getData(FormAutofill.DEFAULT_REGION); + } + } + + // TODO: Now we fallback to US if we couldn't get data from default region, + // but it could be removed in bug 1423464 if it's not necessary. + if (!metadata) { + metadata = AddressDataLoader.getData("US"); + } + return metadata; + }, + + /** + * Get country address data with default locale. + * + * @param {string} country + * @param {string} level1 + * @returns {object|null} Return metadata of specific region with default locale. + * NOTE: The returned data may be for a default region if the + * specified one cannot be found. Callers who only want the specific + * region should check the returned country code. + */ + getCountryAddressData(country, level1) { + let metadata = this.getCountryAddressRawData(country, level1); + return metadata && metadata.defaultLocale; + }, + + /** + * Get country address data with all locales. + * + * @param {string} country + * @param {string} level1 + * @returns {Array<object> | null} + * Return metadata of specific region with all the locales. + * NOTE: The returned data may be for a default region if the + * specified one cannot be found. Callers who only want the specific + * region should check the returned country code. + */ + getCountryAddressDataWithLocales(country, level1) { + let metadata = this.getCountryAddressRawData(country, level1); + return metadata && [metadata.defaultLocale, ...metadata.locales]; + }, + + /** + * Get the collators based on the specified country. + * + * @param {string} country The specified country. + * @param {object} [options = {}] a list of options for this method + * @param {boolean} [options.ignorePunctuation = true] Whether punctuation should be ignored. + * @param {string} [options.sensitivity = 'base'] Which differences in the strings should lead to non-zero result values + * @param {string} [options.usage = 'search'] Whether the comparison is for sorting or for searching for matching strings + * @returns {Array} An array containing several collator objects. + */ + getSearchCollators( + country, + { ignorePunctuation = true, sensitivity = "base", usage = "search" } = {} + ) { + // TODO: Only one language should be used at a time per country. The locale + // of the page should be taken into account to do this properly. + // We are going to support more countries in bug 1370193 and this + // should be addressed when we start to implement that bug. + + if (!this._collators[country]) { + let dataset = this.getCountryAddressData(country); + let languages = dataset.languages || [dataset.lang]; + let options = { + ignorePunctuation, + sensitivity, + usage, + }; + this._collators[country] = languages.map( + lang => new Intl.Collator(lang, options) + ); + } + return this._collators[country]; + }, + + // Based on the list of fields abbreviations in + // https://github.com/googlei18n/libaddressinput/wiki/AddressValidationMetadata + FIELDS_LOOKUP: { + N: "name", + O: "organization", + A: "street-address", + S: "address-level1", + C: "address-level2", + D: "address-level3", + Z: "postal-code", + n: "newLine", + }, + + /** + * Parse a country address format string and outputs an array of fields. + * Spaces, commas, and other literals are ignored in this implementation. + * For example, format string "%A%n%C, %S" should return: + * [ + * {fieldId: "street-address", newLine: true}, + * {fieldId: "address-level2"}, + * {fieldId: "address-level1"}, + * ] + * + * @param {string} fmt Country address format string + * @returns {Array<object>} List of fields + */ + parseAddressFormat(fmt) { + if (!fmt) { + throw new Error("fmt string is missing."); + } + + return fmt.match(/%[^%]/g).reduce((parsed, part) => { + // Take the first letter of each segment and try to identify it + let fieldId = this.FIELDS_LOOKUP[part[1]]; + // Early return if cannot identify part. + if (!fieldId) { + return parsed; + } + // If a new line is detected, add an attribute to the previous field. + if (fieldId == "newLine") { + let size = parsed.length; + if (size) { + parsed[size - 1].newLine = true; + } + return parsed; + } + return parsed.concat({ fieldId }); + }, []); + }, + + /** + * Used to populate dropdowns in the UI (e.g. FormAutofill preferences). + * Use findAddressSelectOption for matching a value to a region. + * + * @param {string[]} subKeys An array of regionCode strings + * @param {string[]} subIsoids An array of ISO ID strings, if provided will be preferred over the key + * @param {string[]} subNames An array of regionName strings + * @param {string[]} subLnames An array of latinised regionName strings + * @returns {Map?} Returns null if subKeys or subNames are not truthy. + * Otherwise, a Map will be returned mapping keys -> names. + */ + buildRegionMapIfAvailable(subKeys, subIsoids, subNames, subLnames) { + // Not all regions have sub_keys. e.g. DE + if ( + !subKeys || + !subKeys.length || + (!subNames && !subLnames) || + (subNames && subKeys.length != subNames.length) || + (subLnames && subKeys.length != subLnames.length) + ) { + return null; + } + + // Overwrite subKeys with subIsoids, when available + if (subIsoids && subIsoids.length && subIsoids.length == subKeys.length) { + for (let i = 0; i < subIsoids.length; i++) { + if (subIsoids[i]) { + subKeys[i] = subIsoids[i]; + } + } + } + + // Apply sub_lnames if sub_names does not exist + let names = subNames || subLnames; + return new Map(subKeys.map((key, index) => [key, names[index]])); + }, + + /** + * Parse a require string and outputs an array of fields. + * Spaces, commas, and other literals are ignored in this implementation. + * For example, a require string "ACS" should return: + * ["street-address", "address-level2", "address-level1"] + * + * @param {string} requireString Country address require string + * @returns {Array<string>} List of fields + */ + parseRequireString(requireString) { + if (!requireString) { + throw new Error("requireString string is missing."); + } + + return requireString.split("").map(fieldId => this.FIELDS_LOOKUP[fieldId]); + }, + + /** + * Use address data and alternative country name list to identify a country code from a + * specified country name. + * + * @param {string} countryName A country name to be identified + * @param {string} [countrySpecified] A country code indicating that we only + * search its alternative names if specified. + * @returns {string} The matching country code. + */ + identifyCountryCode(countryName, countrySpecified) { + if (!countryName) { + return null; + } + + if (AddressDataLoader.getData(countryName)) { + return countryName; + } + + const countries = countrySpecified + ? [countrySpecified] + : [...FormAutofill.countries.keys()]; + + for (const country of countries) { + let collators = this.getSearchCollators(country); + let metadata = this.getCountryAddressData(country); + if (country != metadata.key) { + // We hit the fallback logic in getCountryAddressRawData so ignore it as + // it's not related to `country` and use the name from l10n instead. + metadata = { + id: `data/${country}`, + key: country, + name: FormAutofill.countries.get(country), + }; + } + let alternativeCountryNames = metadata.alternative_names || [ + metadata.name, + ]; + let reAlternativeCountryNames = this._reAlternativeCountryNames[country]; + if (!reAlternativeCountryNames) { + reAlternativeCountryNames = this._reAlternativeCountryNames[country] = + []; + } + + if (countryName.length == 3) { + if (this.strCompare(metadata.alpha_3_code, countryName, collators)) { + return country; + } + } + + for (let i = 0; i < alternativeCountryNames.length; i++) { + let name = alternativeCountryNames[i]; + let reName = reAlternativeCountryNames[i]; + if (!reName) { + reName = reAlternativeCountryNames[i] = new RegExp( + "\\b" + this.escapeRegExp(name) + "\\b", + "i" + ); + } + + if ( + this.strCompare(name, countryName, collators) || + reName.test(countryName) + ) { + return country; + } + } + } + + return null; + }, + + findSelectOption(selectEl, record, fieldName) { + if (this.isAddressField(fieldName)) { + return this.findAddressSelectOption(selectEl, record, fieldName); + } + if (this.isCreditCardField(fieldName)) { + return this.findCreditCardSelectOption(selectEl, record, fieldName); + } + return null; + }, + + /** + * Try to find the abbreviation of the given sub-region name + * + * @param {string[]} subregionValues A list of inferable sub-region values. + * @param {string} [country] A country name to be identified. + * @returns {string} The matching sub-region abbreviation. + */ + getAbbreviatedSubregionName(subregionValues, country) { + let values = Array.isArray(subregionValues) + ? subregionValues + : [subregionValues]; + + let collators = this.getSearchCollators(country); + for (let metadata of this.getCountryAddressDataWithLocales(country)) { + let { + sub_keys: subKeys, + sub_names: subNames, + sub_lnames: subLnames, + } = metadata; + if (!subKeys) { + // Not all regions have sub_keys. e.g. DE + continue; + } + // Apply sub_lnames if sub_names does not exist + subNames = subNames || subLnames; + + let speculatedSubIndexes = []; + for (const val of values) { + let identifiedValue = this.identifyValue( + subKeys, + subNames, + val, + collators + ); + if (identifiedValue) { + return identifiedValue; + } + + // Predict the possible state by partial-matching if no exact match. + [subKeys, subNames].forEach(sub => { + speculatedSubIndexes.push( + sub.findIndex(token => { + let pattern = new RegExp( + "\\b" + this.escapeRegExp(token) + "\\b" + ); + + return pattern.test(val); + }) + ); + }); + } + let subKey = subKeys[speculatedSubIndexes.find(i => !!~i)]; + if (subKey) { + return subKey; + } + } + return null; + }, + + /** + * Find the option element from select element. + * 1. Try to find the locale using the country from address. + * 2. First pass try to find exact match. + * 3. Second pass try to identify values from address value and options, + * and look for a match. + * + * @param {DOMElement} selectEl + * @param {object} address + * @param {string} fieldName + * @returns {DOMElement} + */ + findAddressSelectOption(selectEl, address, fieldName) { + if (selectEl.options.length > 512) { + // Allow enough space for all countries (roughly 300 distinct values) and all + // timezones (roughly 400 distinct values), plus some extra wiggle room. + return null; + } + let value = address[fieldName]; + if (!value) { + return null; + } + + let collators = this.getSearchCollators(address.country); + + for (let option of selectEl.options) { + if ( + this.strCompare(value, option.value, collators) || + this.strCompare(value, option.text, collators) + ) { + return option; + } + } + + switch (fieldName) { + case "address-level1": { + let { country } = address; + let identifiedValue = this.getAbbreviatedSubregionName( + [value], + country + ); + // No point going any further if we cannot identify value from address level 1 + if (!identifiedValue) { + return null; + } + for (let dataset of this.getCountryAddressDataWithLocales(country)) { + let keys = dataset.sub_keys; + if (!keys) { + // Not all regions have sub_keys. e.g. DE + continue; + } + // Apply sub_lnames if sub_names does not exist + let names = dataset.sub_names || dataset.sub_lnames; + + // Go through options one by one to find a match. + // Also check if any option contain the address-level1 key. + let pattern = new RegExp( + "\\b" + this.escapeRegExp(identifiedValue) + "\\b", + "i" + ); + for (let option of selectEl.options) { + let optionValue = this.identifyValue( + keys, + names, + option.value, + collators + ); + let optionText = this.identifyValue( + keys, + names, + option.text, + collators + ); + if ( + identifiedValue === optionValue || + identifiedValue === optionText || + pattern.test(option.value) + ) { + return option; + } + } + } + break; + } + case "country": { + if (this.getCountryAddressData(value)) { + for (let option of selectEl.options) { + if ( + this.identifyCountryCode(option.text, value) || + this.identifyCountryCode(option.value, value) + ) { + return option; + } + } + } + break; + } + } + + return null; + }, + + findCreditCardSelectOption(selectEl, creditCard, fieldName) { + let oneDigitMonth = creditCard["cc-exp-month"] + ? creditCard["cc-exp-month"].toString() + : null; + let twoDigitsMonth = oneDigitMonth ? oneDigitMonth.padStart(2, "0") : null; + let fourDigitsYear = creditCard["cc-exp-year"] + ? creditCard["cc-exp-year"].toString() + : null; + let twoDigitsYear = fourDigitsYear ? fourDigitsYear.substr(2, 2) : null; + let options = Array.from(selectEl.options); + + switch (fieldName) { + case "cc-exp-month": { + if (!oneDigitMonth) { + return null; + } + for (let option of options) { + if ( + [option.text, option.label, option.value].some(s => { + let result = /[1-9]\d*/.exec(s); + return result && result[0] == oneDigitMonth; + }) + ) { + return option; + } + } + break; + } + case "cc-exp-year": { + if (!fourDigitsYear) { + return null; + } + for (let option of options) { + if ( + [option.text, option.label, option.value].some( + s => s == twoDigitsYear || s == fourDigitsYear + ) + ) { + return option; + } + } + break; + } + case "cc-exp": { + if (!oneDigitMonth || !fourDigitsYear) { + return null; + } + let patterns = [ + oneDigitMonth + "/" + twoDigitsYear, // 8/22 + oneDigitMonth + "/" + fourDigitsYear, // 8/2022 + twoDigitsMonth + "/" + twoDigitsYear, // 08/22 + twoDigitsMonth + "/" + fourDigitsYear, // 08/2022 + oneDigitMonth + "-" + twoDigitsYear, // 8-22 + oneDigitMonth + "-" + fourDigitsYear, // 8-2022 + twoDigitsMonth + "-" + twoDigitsYear, // 08-22 + twoDigitsMonth + "-" + fourDigitsYear, // 08-2022 + twoDigitsYear + "-" + twoDigitsMonth, // 22-08 + fourDigitsYear + "-" + twoDigitsMonth, // 2022-08 + fourDigitsYear + "/" + oneDigitMonth, // 2022/8 + twoDigitsMonth + twoDigitsYear, // 0822 + twoDigitsYear + twoDigitsMonth, // 2208 + ]; + + for (let option of options) { + if ( + [option.text, option.label, option.value].some(str => + patterns.some(pattern => str.includes(pattern)) + ) + ) { + return option; + } + } + break; + } + case "cc-type": { + let network = creditCard["cc-type"] || ""; + for (let option of options) { + if ( + [option.text, option.label, option.value].some( + s => lazy.CreditCard.getNetworkFromName(s) == network + ) + ) { + return option; + } + } + break; + } + } + + return null; + }, + + /** + * Try to match value with keys and names, but always return the key. + * + * @param {Array<string>} keys + * @param {Array<string>} names + * @param {string} value + * @param {Array} collators + * @returns {string} + */ + identifyValue(keys, names, value, collators) { + let resultKey = keys.find(key => this.strCompare(value, key, collators)); + if (resultKey) { + return resultKey; + } + + let index = names.findIndex(name => + this.strCompare(value, name, collators) + ); + if (index !== -1) { + return keys[index]; + } + + return null; + }, + + /** + * Compare if two strings are the same. + * + * @param {string} a + * @param {string} b + * @param {Array} collators + * @returns {boolean} + */ + strCompare(a = "", b = "", collators) { + return collators.some(collator => !collator.compare(a, b)); + }, + + /** + * Determine whether one string(b) may be found within another string(a) + * + * @param {string} a + * @param {string} b + * @param {Array} collators + * @returns {boolean} True if the string is found + */ + strInclude(a = "", b = "", collators) { + const len = a.length - b.length; + for (let i = 0; i <= len; i++) { + if (this.strCompare(a.substring(i, i + b.length), b, collators)) { + return true; + } + } + return false; + }, + + /** + * Escaping user input to be treated as a literal string within a regular + * expression. + * + * @param {string} string + * @returns {string} + */ + escapeRegExp(string) { + return string.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + }, + + /** + * Get formatting information of a given country + * + * @param {string} country + * @returns {object} + * { + * {string} addressLevel3L10nId + * {string} addressLevel2L10nId + * {string} addressLevel1L10nId + * {string} postalCodeL10nId + * {object} fieldsOrder + * {string} postalCodePattern + * } + */ + getFormFormat(country) { + let dataset = this.getCountryAddressData(country); + // We hit a country fallback in `getCountryAddressRawData` but it's not relevant here. + if (country != dataset.key) { + // Use a sparse object so the below default values take effect. + dataset = { + /** + * Even though data/ZZ only has address-level2, include the other levels + * in case they are needed for unknown countries. Users can leave the + * unnecessary fields blank which is better than forcing users to enter + * the data in incorrect fields. + */ + fmt: "%N%n%O%n%A%n%C %S %Z", + }; + } + return { + // When particular values are missing for a country, the + // data/ZZ value should be used instead: + // https://chromium-i18n.appspot.com/ssl-aggregate-address/data/ZZ + addressLevel3L10nId: this.getAddressFieldL10nId( + dataset.sublocality_name_type || "suburb" + ), + addressLevel2L10nId: this.getAddressFieldL10nId( + dataset.locality_name_type || "city" + ), + addressLevel1L10nId: this.getAddressFieldL10nId( + dataset.state_name_type || "province" + ), + addressLevel1Options: this.buildRegionMapIfAvailable( + dataset.sub_keys, + dataset.sub_isoids, + dataset.sub_names, + dataset.sub_lnames + ), + countryRequiredFields: this.parseRequireString(dataset.require || "AC"), + fieldsOrder: this.parseAddressFormat(dataset.fmt || "%N%n%O%n%A%n%C"), + postalCodeL10nId: this.getAddressFieldL10nId( + dataset.zip_name_type || "postal-code" + ), + postalCodePattern: dataset.zip, + }; + }, + + getAddressFieldL10nId(type) { + return "autofill-address-" + type.replace(/_/g, "-"); + }, + + CC_FATHOM_NONE: 0, + CC_FATHOM_JS: 1, + CC_FATHOM_NATIVE: 2, + isFathomCreditCardsEnabled() { + return this.ccHeuristicsMode != this.CC_FATHOM_NONE; + }, + + /** + * Transform the key in FormAutofillConfidences (defined in ChromeUtils.webidl) + * to fathom recognized field type. + * + * @param {string} key key from FormAutofillConfidences dictionary + * @returns {string} fathom field type + */ + formAutofillConfidencesKeyToCCFieldType(key) { + const MAP = { + ccNumber: "cc-number", + ccName: "cc-name", + ccType: "cc-type", + ccExp: "cc-exp", + ccExpMonth: "cc-exp-month", + ccExpYear: "cc-exp-year", + }; + return MAP[key]; + }, +}; + +XPCOMUtils.defineLazyGetter(FormAutofillUtils, "stringBundle", function () { + return Services.strings.createBundle( + "chrome://formautofill/locale/formautofill.properties" + ); +}); + +XPCOMUtils.defineLazyGetter(FormAutofillUtils, "brandBundle", function () { + return Services.strings.createBundle( + "chrome://branding/locale/brand.properties" + ); +}); + +XPCOMUtils.defineLazyPreferenceGetter( + FormAutofillUtils, + "_reauthEnabledByUser", + "extensions.formautofill.reauth.enabled", + false +); + +XPCOMUtils.defineLazyPreferenceGetter( + FormAutofillUtils, + "ccHeuristicsMode", + "extensions.formautofill.creditCards.heuristics.mode", + 0 +); + +XPCOMUtils.defineLazyPreferenceGetter( + FormAutofillUtils, + "ccFathomConfidenceThreshold", + "extensions.formautofill.creditCards.heuristics.fathom.confidenceThreshold", + null, + null, + pref => parseFloat(pref) +); + +XPCOMUtils.defineLazyPreferenceGetter( + FormAutofillUtils, + "ccFathomHighConfidenceThreshold", + "extensions.formautofill.creditCards.heuristics.fathom.highConfidenceThreshold", + null, + null, + pref => parseFloat(pref) +); + +XPCOMUtils.defineLazyPreferenceGetter( + FormAutofillUtils, + "ccFathomTestConfidence", + "extensions.formautofill.creditCards.heuristics.fathom.testConfidence", + null, + null, + pref => parseFloat(pref) +); + +XPCOMUtils.defineLazyPreferenceGetter( + FormAutofillUtils, + "visibilityCheckThreshold", + "extensions.formautofill.heuristics.visibilityCheckThreshold", + 200 +); + +// This is only used in iOS +XPCOMUtils.defineLazyPreferenceGetter( + FormAutofillUtils, + "focusOnAutofill", + "extensions.formautofill.focusOnAutofill", + true +); diff --git a/toolkit/components/formautofill/shared/FormStateManager.sys.mjs b/toolkit/components/formautofill/shared/FormStateManager.sys.mjs new file mode 100644 index 0000000000..18f5a2f05b --- /dev/null +++ b/toolkit/components/formautofill/shared/FormStateManager.sys.mjs @@ -0,0 +1,154 @@ +/* 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/. */ + +const lazy = {}; +ChromeUtils.defineESModuleGetters(lazy, { + FormLikeFactory: "resource://gre/modules/FormLikeFactory.sys.mjs", + FormAutofillHandler: + "resource://gre/modules/shared/FormAutofillHandler.sys.mjs", +}); + +export class FormStateManager { + constructor(onSubmit, onAutofillCallback) { + /** + * @type {WeakMap} mapping FormLike root HTML elements to FormAutofillHandler objects. + */ + this._formsDetails = new WeakMap(); + /** + * @type {object} The object where to store the active items, e.g. element, + * handler, section, and field detail. + */ + this._activeItems = {}; + + this.onSubmit = onSubmit; + + this.onAutofillCallback = onAutofillCallback; + } + + /** + * Get the active input's information from cache which is created after page + * identified. + * + * @returns {object | null} + * Return the active input's information that cloned from content cache + * (or return null if the information is not found in the cache). + */ + get activeFieldDetail() { + if (!this._activeItems.fieldDetail) { + let formDetails = this.activeFormDetails; + if (!formDetails) { + return null; + } + for (let detail of formDetails) { + let detailElement = detail.elementWeakRef.get(); + if (detailElement && this.activeInput == detailElement) { + this._activeItems.fieldDetail = detail; + break; + } + } + } + return this._activeItems.fieldDetail; + } + + /** + * Get the active form's information from cache which is created after page + * identified. + * + * @returns {Array<object> | null} + * Return target form's information from content cache + * (or return null if the information is not found in the cache). + * + */ + get activeFormDetails() { + let formHandler = this.activeHandler; + return formHandler ? formHandler.fieldDetails : null; + } + + get activeInput() { + let elementWeakRef = this._activeItems.elementWeakRef; + return elementWeakRef ? elementWeakRef.get() : null; + } + + get activeHandler() { + const activeInput = this.activeInput; + if (!activeInput) { + return null; + } + + // XXX: We are recomputing the activeHandler every time to avoid keeping a + // reference on the active element. This might be called quite frequently + // so if _getFormHandler/findRootForField become more costly, we should + // look into caching this result (eg by adding a weakmap). + let handler = this._getFormHandler(activeInput); + if (handler) { + handler.focusedInput = activeInput; + } + return handler; + } + + get activeSection() { + let formHandler = this.activeHandler; + return formHandler ? formHandler.activeSection : null; + } + + /** + * Get the form's handler from cache which is created after page identified. + * + * @param {HTMLInputElement} element Focused input which triggered profile searching + * @returns {Array<object> | null} + * Return target form's handler from content cache + * (or return null if the information is not found in the cache). + * + */ + _getFormHandler(element) { + if (!element) { + return null; + } + let rootElement = lazy.FormLikeFactory.findRootForField(element); + return this._formsDetails.get(rootElement); + } + + identifyAutofillFields(element) { + let formHandler = this._getFormHandler(element); + if (!formHandler) { + let formLike = lazy.FormLikeFactory.createFromField(element); + formHandler = new lazy.FormAutofillHandler( + formLike, + this.onSubmit, + this.onAutofillCallback + ); + } else if (!formHandler.updateFormIfNeeded(element)) { + return formHandler.fieldDetails; + } + this._formsDetails.set(formHandler.form.rootElement, formHandler); + return formHandler.collectFormFields(); + } + + updateActiveInput(element) { + if (!element) { + this._activeItems = {}; + return; + } + this._activeItems = { + elementWeakRef: Cu.getWeakReference(element), + fieldDetail: null, + }; + } + + getRecords(formElement, handler) { + handler = handler || this._formsDetails.get(formElement); + const records = handler?.createRecords(); + + if ( + !handler || + !records || + !Object.values(records).some(typeRecords => typeRecords.length) + ) { + return null; + } + return records; + } +} + +export default FormStateManager; diff --git a/toolkit/components/formautofill/shared/HeuristicsRegExp.sys.mjs b/toolkit/components/formautofill/shared/HeuristicsRegExp.sys.mjs new file mode 100644 index 0000000000..9df83d5cd8 --- /dev/null +++ b/toolkit/components/formautofill/shared/HeuristicsRegExp.sys.mjs @@ -0,0 +1,620 @@ +/* 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/. */ + +// prettier-ignore +export const HeuristicsRegExp = { + RULES: { + email: undefined, + tel: undefined, + organization: undefined, + "street-address": undefined, + "address-line1": undefined, + "address-line2": undefined, + "address-line3": undefined, + "address-level2": undefined, + "address-level1": undefined, + "postal-code": undefined, + country: undefined, + // Note: We place the `cc-name` field for Credit Card first, because + // it is more specific than the `name` field below and we want to check + // for it before we catch the more generic one. + "cc-name": undefined, + name: undefined, + "given-name": undefined, + "additional-name": undefined, + "family-name": undefined, + "cc-number": undefined, + "cc-exp-month": undefined, + "cc-exp-year": undefined, + "cc-exp": undefined, + "cc-type": undefined, + }, + + RULE_SETS: [ + //========================================================================= + // Firefox-specific rules + { + "address-line1": "addrline1|address_1", + "address-line2": "addrline2|address_2", + "address-line3": "addrline3|address_3", + "address-level1": "land", // de-DE + "additional-name": "apellido.?materno|lastlastname", + "cc-name": + "accountholdername" + + "|titulaire", // fr-FR + "cc-number": + "(cc|kk)nr", // de-DE + "cc-exp": + "ważna.*do" + // pl-PL + "|data.*ważności", // pl-PL + "cc-exp-month": + "month" + + "|(cc|kk)month" + // de-DE + "|miesiąc", // pl-PL + "cc-exp-year": + "year" + + "|(cc|kk)year" + // de-DE + "|rok", // pl-PL + "cc-type": + "type" + + "|kartenmarke" + // de-DE + "|typ.*karty", // pl-PL + }, + + //========================================================================= + // These are the rules used by Bitwarden [0], converted into RegExp form. + // [0] https://github.com/bitwarden/browser/blob/c2b8802201fac5e292d55d5caf3f1f78088d823c/src/services/autofill.service.ts#L436 + { + email: "(^e-?mail$)|(^email-?address$)", + + tel: + "(^phone$)" + + "|(^mobile$)" + + "|(^mobile-?phone$)" + + "|(^tel$)" + + "|(^telephone$)" + + "|(^phone-?number$)", + + organization: + "(^company$)" + + "|(^company-?name$)" + + "|(^organization$)" + + "|(^organization-?name$)", + + "street-address": + "(^address$)" + + "|(^street-?address$)" + + "|(^addr$)" + + "|(^street$)" + + "|(^mailing-?addr(ess)?$)" + // Modified to not grab lines, below + "|(^billing-?addr(ess)?$)" + // Modified to not grab lines, below + "|(^mail-?addr(ess)?$)" + // Modified to not grab lines, below + "|(^bill-?addr(ess)?$)", // Modified to not grab lines, below + + "address-line1": + "(^address-?1$)" + + "|(^address-?line-?1$)" + + "|(^addr-?1$)" + + "|(^street-?1$)", + + "address-line2": + "(^address-?2$)" + + "|(^address-?line-?2$)" + + "|(^addr-?2$)" + + "|(^street-?2$)", + + "address-line3": + "(^address-?3$)" + + "|(^address-?line-?3$)" + + "|(^addr-?3$)" + + "|(^street-?3$)", + + "address-level2": + "(^city$)" + + "|(^town$)" + + "|(^address-?level-?2$)" + + "|(^address-?city$)" + + "|(^address-?town$)", + + "address-level1": + "(^state$)" + + "|(^province$)" + + "|(^provence$)" + + "|(^address-?level-?1$)" + + "|(^address-?state$)" + + "|(^address-?province$)", + + "postal-code": + "(^postal$)" + + "|(^zip$)" + + "|(^zip2$)" + + "|(^zip-?code$)" + + "|(^postal-?code$)" + + "|(^post-?code$)" + + "|(^address-?zip$)" + + "|(^address-?postal$)" + + "|(^address-?code$)" + + "|(^address-?postal-?code$)" + + "|(^address-?zip-?code$)", + + country: + "(^country$)" + + "|(^country-?code$)" + + "|(^country-?name$)" + + "|(^address-?country$)" + + "|(^address-?country-?name$)" + + "|(^address-?country-?code$)", + + name: "(^name$)|full-?name|your-?name", + + "given-name": + "(^f-?name$)" + + "|(^first-?name$)" + + "|(^given-?name$)" + + "|(^first-?n$)", + + "additional-name": + "(^m-?name$)" + + "|(^middle-?name$)" + + "|(^additional-?name$)" + + "|(^middle-?initial$)" + + "|(^middle-?n$)" + + "|(^middle-?i$)", + + "family-name": + "(^l-?name$)" + + "|(^last-?name$)" + + "|(^s-?name$)" + + "|(^surname$)" + + "|(^family-?name$)" + + "|(^family-?n$)" + + "|(^last-?n$)", + + "cc-name": + "cc-?name" + + "|card-?name" + + "|cardholder-?name" + + "|cardholder" + + // "|(^name$)" + // Removed to avoid overwriting "name", above. + "|(^nom$)", + + "cc-number": + "cc-?number" + + "|cc-?num" + + "|card-?number" + + "|card-?num" + + "|(^number$)" + + "|(^cc$)" + + "|cc-?no" + + "|card-?no" + + "|(^credit-?card$)" + + "|numero-?carte" + + "|(^carte$)" + + "|(^carte-?credit$)" + + "|num-?carte" + + "|cb-?num", + + "cc-exp": + "(^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$)", + + "cc-exp-month": + "(^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$)", + + "cc-exp-year": + "(^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$)", + + "cc-type": + "(^cc-?type$)" + + "|(^card-?type$)" + + "|(^card-?brand$)" + + "|(^cc-?brand$)" + + "|(^cb-?type$)", + }, + + //========================================================================= + // These rules are from Chromium source codes [1]. Most of them + // converted to JS format have the same meaning with the original ones except + // the first line of "address-level1". + // [1] https://source.chromium.org/chromium/chromium/src/+/master:components/autofill/core/common/autofill_regex_constants.cc + { + // ==== Email ==== + email: + "e.?mail" + + "|courriel" + // fr + "|correo.*electr(o|ó)nico" + // es-ES + "|メールアドレス" + // ja-JP + "|Электронной.?Почты" + // ru + "|邮件|邮箱" + // zh-CN + "|電郵地址" + // zh-TW + "|ഇ-മെയില്|ഇലക്ട്രോണിക്.?" + + "മെയിൽ" + // ml + "|ایمیل|پست.*الکترونیک" + // fa + "|ईमेल|इलॅक्ट्रॉनिक.?मेल" + // hi + "|(\\b|_)eposta(\\b|_)" + // tr + "|(?:이메일|전자.?우편|[Ee]-?mail)(.?주소)?", // ko-KR + + // ==== Telephone ==== + tel: + "phone|mobile|contact.?number" + + "|telefonnummer" + // de-DE + "|telefono|teléfono" + // es + "|telfixe" + // fr-FR + "|電話" + // ja-JP + "|telefone|telemovel" + // pt-BR, pt-PT + "|телефон" + // ru + "|मोबाइल" + // hi for mobile + "|(\\b|_|\\*)telefon(\\b|_|\\*)" + // tr + "|电话" + // zh-CN + "|മൊബൈല്" + // ml for mobile + "|(?:전화|핸드폰|휴대폰|휴대전화)(?:.?번호)?", // ko-KR + + // ==== Address Fields ==== + organization: + "company|business|organization|organisation" + + // In order to support webkit we convert all negative lookbehinds to a capture group + // (?<!not)word -> (?<neg>notword)|word + // TODO: Bug 1829583 + "|(?<neg>confirma)" + + "|firma|firmenname" + // de-DE + "|empresa" + // es + "|societe|société" + // fr-FR + "|ragione.?sociale" + // it-IT + "|会社" + // ja-JP + "|название.?компании" + // ru + "|单位|公司" + // zh-CN + "|شرکت" + // fa + "|회사|직장", // ko-KR + + "street-address": "streetaddress|street-address", + "address-line1": + "^address$|address[_-]?line(one)?|address1|addr1|street" + + "|(?:shipping|billing)address$" + + "|strasse|straße|hausnummer|housenumber" + // de-DE + "|house.?name" + // en-GB + "|direccion|dirección" + // es + "|adresse" + // fr-FR + "|indirizzo" + // it-IT + "|^住所$|住所1" + // ja-JP + "|morada" + // pt-BR, pt-PT + // In order to support webkit we convert all negative lookbehinds to a capture group + // (?<!not)word -> (?<neg>notword)|word + // TODO: Bug 1829583 + "|(?<neg>identificação do endereço)" + + "|(endereço)" + // pt-BR, pt-PT + "|Адрес" + // ru + "|地址" + // zh-CN + "|(\\b|_)adres(?! (başlığı(nız)?|tarifi))(\\b|_)" + // tr + "|^주소.?$|주소.?1", // ko-KR + + "address-line2": + "address[_-]?line(2|two)|address2|addr2|street|suite|unit(?!e)" + // Firefox adds `(?!e)` to unit to skip `United State` + "|adresszusatz|ergänzende.?angaben" + // de-DE + "|direccion2|colonia|adicional" + // es + "|addresssuppl|complementnom|appartement" + // fr-FR + "|indirizzo2" + // it-IT + "|住所2" + // ja-JP + "|complemento|addrcomplement" + // pt-BR, pt-PT + "|Улица" + // ru + "|地址2" + // zh-CN + "|주소.?2", // ko-KR + + "address-line3": + "address[_-]?line(3|three)|address3|addr3|street|suite|unit(?!e)" + // Firefox adds `(?!e)` to unit to skip `United State` + "|adresszusatz|ergänzende.?angaben" + // de-DE + "|direccion3|colonia|adicional" + // es + "|addresssuppl|complementnom|appartement" + // fr-FR + "|indirizzo3" + // it-IT + "|住所3" + // ja-JP + "|complemento|addrcomplement" + // pt-BR, pt-PT + "|Улица" + // ru + "|地址3" + // zh-CN + "|주소.?3", // ko-KR + + "address-level2": + "city|town" + + "|\\bort\\b|stadt" + // de-DE + "|suburb" + // en-AU + "|ciudad|provincia|localidad|poblacion" + // es + "|ville|commune" + // fr-FR + "|localita" + // it-IT + "|市区町村" + // ja-JP + "|cidade" + // pt-BR, pt-PT + "|Город" + // ru + "|市" + // zh-CN + "|分區" + // zh-TW + "|شهر" + // fa + "|शहर" + // hi for city + "|ग्राम|गाँव" + // hi for village + "|നഗരം|ഗ്രാമം" + // ml for town|village + "|((\\b|_|\\*)([İii̇]l[cç]e(miz|niz)?)(\\b|_|\\*))" + // tr + "|^시[^도·・]|시[·・]?군[·・]?구", // ko-KR + + "address-level1": + // In order to support webkit we convert all negative lookbehinds to a capture group + // (?<!not)word -> (?<neg>notword)|word + // TODO: Bug 1829583 + "(?<neg>united?.state|hist?.state|history?.state)" + + "|state|county|region|province" + + "|principality" + // en-UK + "|都道府県" + // ja-JP + "|estado|provincia" + // pt-BR, pt-PT + "|область" + // ru + "|省" + // zh-CN + "|地區" + // zh-TW + "|സംസ്ഥാനം" + // ml + "|استان" + // fa + "|राज्य" + // hi + "|((\\b|_|\\*)(eyalet|[şs]ehir|[İii̇]l(imiz)?|kent)(\\b|_|\\*))" + // tr + "|^시[·・]?도", // ko-KR + + "postal-code": + "zip|postal|post.*code|pcode" + + "|pin.?code" + // en-IN + "|postleitzahl" + // de-DE + "|\\bcp\\b" + // es + "|\\bcdp\\b" + // fr-FR + "|\\bcap\\b" + // it-IT + "|郵便番号" + // ja-JP + "|codigo|codpos|\\bcep\\b" + // pt-BR, pt-PT + "|Почтовый.?Индекс" + // ru + "|पिन.?कोड" + // hi + "|പിന്കോഡ്" + // ml + "|邮政编码|邮编" + // zh-CN + "|郵遞區號" + // zh-TW + "|(\\b|_)posta kodu(\\b|_)" + // tr + "|우편.?번호", // ko-KR + + country: + "country|countries" + + "|país|pais" + // es + "|(\\b|_)land(\\b|_)(?!.*(mark.*))" + // de-DE landmark is a type in india. + // In order to support webkit we convert all negative lookbehinds to a capture group + // (?<!not)word -> (?<neg>notword)|word + // TODO: Bug 1829583 + "|(?<neg>入国|出国)" + + "|国" + // ja-JP + "|国家" + // zh-CN + "|국가|나라" + // ko-KR + "|(\\b|_)(ülke|ulce|ulke)(\\b|_)" + // tr + "|کشور", // fa + + // ==== Name Fields ==== + "cc-name": + "card.?(?:holder|owner)|name.*(\\b)?on(\\b)?.*card" + + "|(?:card|cc).?name|cc.?full.?name" + + "|karteninhaber" + // de-DE + "|nombre.*tarjeta" + // es + "|nom.*carte" + // fr-FR + "|nome.*cart" + // it-IT + "|名前" + // ja-JP + "|Имя.*карты" + // ru + "|信用卡开户名|开户名|持卡人姓名" + // zh-CN + "|持卡人姓名", // zh-TW + + name: + "^name|full.?name|your.?name|customer.?name|bill.?name|ship.?name" + + "|name.*first.*last|firstandlastname" + + "|nombre.*y.*apellidos" + // es + "|^nom(?!bre)" + // fr-FR + "|お名前|氏名" + // ja-JP + "|^nome" + // pt-BR, pt-PT + "|نام.*نام.*خانوادگی" + // fa + "|姓名" + // zh-CN + "|(\\b|_|\\*)ad[ı]? soyad[ı]?(\\b|_|\\*)" + // tr + "|성명", // ko-KR + + "given-name": + "first.*name|initials|fname|first$|given.*name" + + "|vorname" + // de-DE + "|nombre" + // es + "|forename|prénom|prenom" + // fr-FR + "|名" + // ja-JP + "|nome" + // pt-BR, pt-PT + "|Имя" + // ru + "|نام" + // fa + "|이름" + // ko-KR + "|പേര്" + // ml + "|(\\b|_|\\*)(isim|ad|ad(i|ı|iniz|ınız)?)(\\b|_|\\*)" + // tr + "|नाम", // hi + + "additional-name": + "middle.*name|mname|middle$|middle.*initial|m\\.i\\.|mi$|\\bmi\\b", + + "family-name": + "last.*name|lname|surname|last$|secondname|family.*name" + + "|nachname" + // de-DE + "|apellidos?" + // es + "|famille|^nom(?!bre)" + // fr-FR + "|cognome" + // it-IT + "|姓" + // ja-JP + "|apelidos|surename|sobrenome" + // pt-BR, pt-PT + "|Фамилия" + // ru + "|نام.*خانوادگی" + // fa + "|उपनाम" + // hi + "|മറുപേര്" + // ml + "|(\\b|_|\\*)(soyisim|soyad(i|ı|iniz|ınız)?)(\\b|_|\\*)" + // tr + "|\\b성(?:[^명]|\\b)", // ko-KR + + // ==== Credit Card Fields ==== + // Note: `cc-name` expression has been moved up, above `name`, in + // order to handle specialization through ordering. + "cc-number": + "(add)?(?:card|cc|acct).?(?:number|#|no|num|field)" + + // In order to support webkit we convert all negative lookbehinds to a capture group + // (?<!not)word -> (?<neg>notword)|word + // TODO: Bug 1829583 + "|(?<neg>telefonnummer|hausnummer|personnummer|fødselsnummer)" + // de-DE, sv-SE, no + "|nummer" + + "|カード番号" + // ja-JP + "|Номер.*карты" + // ru + "|信用卡号|信用卡号码" + // zh-CN + "|信用卡卡號" + // zh-TW + "|카드" + // ko-KR + // es/pt/fr + "|(numero|número|numéro)(?!.*(document|fono|phone|réservation))", + + "cc-exp-month": + "expir|exp.*mo|exp.*date|ccmonth|cardmonth|addmonth" + + "|gueltig|gültig|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": + "exp|^/|(add)?year" + + "|ablaufdatum|gueltig|gültig|jahr" + // de-DE + "|fecha" + // es + "|scadenza" + // it-IT + "|有効期限" + // ja-JP + "|validade" + // pt-BR, pt-PT + "|Срок действия карты" + // ru + "|年|有效期", // zh-CN + + "cc-exp": + "expir|exp.*date|^expfield$" + + "|gueltig|gültig" + // de-DE + "|fecha" + // es + "|date.*exp" + // fr-FR + "|scadenza" + // it-IT + "|有効期限" + // ja-JP + "|validade" + // pt-BR, pt-PT + "|Срок действия карты", // ru + }, + ], + + _getRule(name) { + let rules = []; + this.RULE_SETS.forEach(set => { + if (set[name]) { + // Add the rule. + // We make the regex lower case so that we can match it against the + // lower-cased field name and get a rough equivalent of a case-insensitive + // match. This avoids a performance cliff with the "iu" flag on regular + // expressions. + rules.push(`(${set[name].toLowerCase()})`.normalize("NFKC")); + } + }); + + const value = new RegExp(rules.join("|"), "gu"); + Object.defineProperty(this.RULES, name, { get: undefined }); + Object.defineProperty(this.RULES, name, { value }); + return value; + }, + + getRules() { + Object.keys(this.RULES).forEach(field => + Object.defineProperty(this.RULES, field, { + get() { + return HeuristicsRegExp._getRule(field); + }, + }) + ); + + return this.RULES; + }, +}; + +export default HeuristicsRegExp; diff --git a/toolkit/components/formautofill/shared/LabelUtils.sys.mjs b/toolkit/components/formautofill/shared/LabelUtils.sys.mjs new file mode 100644 index 0000000000..9bfedee105 --- /dev/null +++ b/toolkit/components/formautofill/shared/LabelUtils.sys.mjs @@ -0,0 +1,120 @@ +/* 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/. */ + +/** + * This is a utility object to work with HTML labels in web pages, + * including finding label elements and label text extraction. + */ +export const LabelUtils = { + // The tag name list is from Chromium except for "STYLE": + // eslint-disable-next-line max-len + // https://cs.chromium.org/chromium/src/components/autofill/content/renderer/form_autofill_util.cc?l=216&rcl=d33a171b7c308a64dc3372fac3da2179c63b419e + EXCLUDED_TAGS: ["SCRIPT", "NOSCRIPT", "OPTION", "STYLE"], + + // A map object, whose keys are the id's of form fields and each value is an + // array consisting of label elements correponding to the id. + // @type {Map<string, array>} + _mappedLabels: null, + + // An array consisting of label elements whose correponding form field doesn't + // have an id attribute. + // @type {Array<[HTMLLabelElement, HTMLElement]>} + _unmappedLabelControls: null, + + // A weak map consisting of label element and extracted strings pairs. + // @type {WeakMap<HTMLLabelElement, array>} + _labelStrings: null, + + /** + * Extract all strings of an element's children to an array. + * "element.textContent" is a string which is merged of all children nodes, + * and this function provides an array of the strings contains in an element. + * + * @param {object} element + * A DOM element to be extracted. + * @returns {Array} + * All strings in an element. + */ + extractLabelStrings(element) { + if (this._labelStrings.has(element)) { + return this._labelStrings.get(element); + } + let strings = []; + let _extractLabelStrings = el => { + if (this.EXCLUDED_TAGS.includes(el.tagName)) { + return; + } + + if (el.nodeType == el.TEXT_NODE || !el.childNodes.length) { + let trimmedText = el.textContent.trim(); + if (trimmedText) { + strings.push(trimmedText); + } + return; + } + + for (let node of el.childNodes) { + let nodeType = node.nodeType; + if (nodeType != node.ELEMENT_NODE && nodeType != node.TEXT_NODE) { + continue; + } + _extractLabelStrings(node); + } + }; + _extractLabelStrings(element); + this._labelStrings.set(element, strings); + return strings; + }, + + generateLabelMap(doc) { + this._mappedLabels = new Map(); + this._unmappedLabelControls = []; + this._labelStrings = new WeakMap(); + + for (let label of doc.querySelectorAll("label")) { + let id = label.htmlFor; + let control; + if (!id) { + control = label.control; + if (!control) { + continue; + } + id = control.id; + } + if (id) { + let labels = this._mappedLabels.get(id); + if (labels) { + labels.push(label); + } else { + this._mappedLabels.set(id, [label]); + } + } else { + // control must be non-empty here + this._unmappedLabelControls.push({ label, control }); + } + } + }, + + clearLabelMap() { + this._mappedLabels = null; + this._unmappedLabelControls = null; + this._labelStrings = null; + }, + + findLabelElements(element) { + if (!this._mappedLabels) { + this.generateLabelMap(element.ownerDocument); + } + + let id = element.id; + if (!id) { + return this._unmappedLabelControls + .filter(lc => lc.control == element) + .map(lc => lc.label); + } + return this._mappedLabels.get(id) || []; + }, +}; + +export default LabelUtils; |