From 26a029d407be480d791972afb5975cf62c9360a6 Mon Sep 17 00:00:00 2001 From: Daniel Baumann Date: Fri, 19 Apr 2024 02:47:55 +0200 Subject: Adding upstream version 124.0.1. Signed-off-by: Daniel Baumann --- toolkit/components/satchel/FillHelpers.sys.mjs | 41 + .../components/satchel/FormAutoComplete.sys.mjs | 693 +++++++++++ toolkit/components/satchel/FormHistory.sys.mjs | 1307 ++++++++++++++++++++ .../components/satchel/FormHistoryChild.sys.mjs | 139 +++ .../components/satchel/FormHistoryParent.sys.mjs | 107 ++ .../components/satchel/FormHistoryStartup.sys.mjs | 99 ++ toolkit/components/satchel/FormScenarios.sys.mjs | 87 ++ .../components/satchel/SignUpFormRuleset.sys.mjs | 589 +++++++++ toolkit/components/satchel/components.conf | 33 + .../satchel/integrations/FirefoxRelay.sys.mjs | 647 ++++++++++ .../satchel/integrations/FirefoxRelayTelemetry.mjs | 73 ++ .../satchel/integrations/FirefoxRelayUtils.sys.mjs | 23 + .../satchel/integrations/WebAuthnFeature.sys.mjs | 129 ++ toolkit/components/satchel/moz.build | 57 + .../components/satchel/nsFormFillController.cpp | 1300 +++++++++++++++++++ toolkit/components/satchel/nsFormFillController.h | 146 +++ toolkit/components/satchel/nsIFormAutoComplete.idl | 44 + .../components/satchel/nsIFormFillController.idl | 69 ++ .../satchel/test/FormHistoryTestUtils.sys.mjs | 106 ++ .../components/satchel/test/browser/browser.toml | 10 + .../satchel/test/browser/browser_close_tab.js | 46 + .../test/browser/browser_popup_mouseover.js | 81 ++ .../browser/browser_privbrowsing_perwindowpb.js | 49 + toolkit/components/satchel/test/mochitest.toml | 49 + toolkit/components/satchel/test/parent_utils.js | 194 +++ toolkit/components/satchel/test/satchel_common.js | 316 +++++ .../satchel/test/subtst_form_submission_1.html | 15 + .../satchel/test/subtst_privbrowsing.html | 23 + .../components/satchel/test/test_bug_511615.html | 179 +++ .../components/satchel/test/test_bug_787624.html | 73 ++ .../satchel/test/test_capture_limit.html | 61 + .../test/test_datalist_attribute_change.html | 53 + .../satchel/test/test_datalist_dynamic.html | 82 ++ .../test/test_datalist_readonly_change.html | 41 + .../satchel/test/test_datalist_shadow_dom.html | 55 + .../satchel/test/test_datalist_with_caching.html | 64 + .../satchel/test/test_form_autocomplete.html | 698 +++++++++++ ...orm_autocomplete_validation_at_input_event.html | 83 ++ .../test/test_form_autocomplete_with_list.html | 402 ++++++ .../satchel/test/test_form_submission.html | 598 +++++++++ .../test/test_history_datalist_duplicates.html | 55 + .../test_input_valid_state_with_autocomplete.html | 125 ++ .../satchel/test/test_password_autocomplete.html | 89 ++ .../satchel/test/test_popup_direction.html | 44 + .../satchel/test/test_popup_enter_event.html | 70 ++ .../satchel/test/test_submit_on_keydown_enter.html | 108 ++ .../test/unit/asyncformhistory_expire.sqlite | Bin 0 -> 98304 bytes .../satchel/test/unit/formhistory_1000.sqlite | Bin 0 -> 164864 bytes .../satchel/test/unit/formhistory_CORRUPT.sqlite | 1 + .../satchel/test/unit/formhistory_apitest.sqlite | Bin 0 -> 5120 bytes .../test/unit/formhistory_autocomplete.sqlite | Bin 0 -> 72704 bytes .../satchel/test/unit/formhistory_v3.sqlite | Bin 0 -> 5120 bytes .../satchel/test/unit/formhistory_v3v4.sqlite | Bin 0 -> 6144 bytes .../satchel/test/unit/formhistory_v999a.sqlite | Bin 0 -> 11264 bytes .../satchel/test/unit/formhistory_v999b.sqlite | Bin 0 -> 8192 bytes .../components/satchel/test/unit/head_satchel.js | 188 +++ .../satchel/test/unit/test_async_expire.js | 134 ++ .../satchel/test/unit/test_autocomplete.js | 388 ++++++ .../satchel/test/unit/test_db_access_denied.js | 52 + .../satchel/test/unit/test_db_corrupt.js | 80 ++ .../satchel/test/unit/test_db_update_v4.js | 59 + .../satchel/test/unit/test_db_update_v4b.js | 49 + .../satchel/test/unit/test_db_update_v5.js | 29 + .../satchel/test/unit/test_db_update_v999a.js | 56 + .../satchel/test/unit/test_db_update_v999b.js | 74 ++ .../satchel/test/unit/test_history_api.js | 485 ++++++++ .../satchel/test/unit/test_history_sources.js | 88 ++ .../components/satchel/test/unit/test_notify.js | 171 +++ .../satchel/test/unit/test_previous_result.js | 25 + toolkit/components/satchel/test/unit/xpcshell.toml | 43 + toolkit/components/satchel/towel | 5 + 71 files changed, 11279 insertions(+) create mode 100644 toolkit/components/satchel/FillHelpers.sys.mjs create mode 100644 toolkit/components/satchel/FormAutoComplete.sys.mjs create mode 100644 toolkit/components/satchel/FormHistory.sys.mjs create mode 100644 toolkit/components/satchel/FormHistoryChild.sys.mjs create mode 100644 toolkit/components/satchel/FormHistoryParent.sys.mjs create mode 100644 toolkit/components/satchel/FormHistoryStartup.sys.mjs create mode 100644 toolkit/components/satchel/FormScenarios.sys.mjs create mode 100644 toolkit/components/satchel/SignUpFormRuleset.sys.mjs create mode 100644 toolkit/components/satchel/components.conf create mode 100644 toolkit/components/satchel/integrations/FirefoxRelay.sys.mjs create mode 100644 toolkit/components/satchel/integrations/FirefoxRelayTelemetry.mjs create mode 100644 toolkit/components/satchel/integrations/FirefoxRelayUtils.sys.mjs create mode 100644 toolkit/components/satchel/integrations/WebAuthnFeature.sys.mjs create mode 100644 toolkit/components/satchel/moz.build create mode 100644 toolkit/components/satchel/nsFormFillController.cpp create mode 100644 toolkit/components/satchel/nsFormFillController.h create mode 100644 toolkit/components/satchel/nsIFormAutoComplete.idl create mode 100644 toolkit/components/satchel/nsIFormFillController.idl create mode 100644 toolkit/components/satchel/test/FormHistoryTestUtils.sys.mjs create mode 100644 toolkit/components/satchel/test/browser/browser.toml create mode 100644 toolkit/components/satchel/test/browser/browser_close_tab.js create mode 100644 toolkit/components/satchel/test/browser/browser_popup_mouseover.js create mode 100644 toolkit/components/satchel/test/browser/browser_privbrowsing_perwindowpb.js create mode 100644 toolkit/components/satchel/test/mochitest.toml create mode 100644 toolkit/components/satchel/test/parent_utils.js create mode 100644 toolkit/components/satchel/test/satchel_common.js create mode 100644 toolkit/components/satchel/test/subtst_form_submission_1.html create mode 100644 toolkit/components/satchel/test/subtst_privbrowsing.html create mode 100644 toolkit/components/satchel/test/test_bug_511615.html create mode 100644 toolkit/components/satchel/test/test_bug_787624.html create mode 100644 toolkit/components/satchel/test/test_capture_limit.html create mode 100644 toolkit/components/satchel/test/test_datalist_attribute_change.html create mode 100644 toolkit/components/satchel/test/test_datalist_dynamic.html create mode 100644 toolkit/components/satchel/test/test_datalist_readonly_change.html create mode 100644 toolkit/components/satchel/test/test_datalist_shadow_dom.html create mode 100644 toolkit/components/satchel/test/test_datalist_with_caching.html create mode 100644 toolkit/components/satchel/test/test_form_autocomplete.html create mode 100644 toolkit/components/satchel/test/test_form_autocomplete_validation_at_input_event.html create mode 100644 toolkit/components/satchel/test/test_form_autocomplete_with_list.html create mode 100644 toolkit/components/satchel/test/test_form_submission.html create mode 100644 toolkit/components/satchel/test/test_history_datalist_duplicates.html create mode 100644 toolkit/components/satchel/test/test_input_valid_state_with_autocomplete.html create mode 100644 toolkit/components/satchel/test/test_password_autocomplete.html create mode 100644 toolkit/components/satchel/test/test_popup_direction.html create mode 100644 toolkit/components/satchel/test/test_popup_enter_event.html create mode 100644 toolkit/components/satchel/test/test_submit_on_keydown_enter.html create mode 100644 toolkit/components/satchel/test/unit/asyncformhistory_expire.sqlite create mode 100644 toolkit/components/satchel/test/unit/formhistory_1000.sqlite create mode 100644 toolkit/components/satchel/test/unit/formhistory_CORRUPT.sqlite create mode 100644 toolkit/components/satchel/test/unit/formhistory_apitest.sqlite create mode 100644 toolkit/components/satchel/test/unit/formhistory_autocomplete.sqlite create mode 100644 toolkit/components/satchel/test/unit/formhistory_v3.sqlite create mode 100644 toolkit/components/satchel/test/unit/formhistory_v3v4.sqlite create mode 100644 toolkit/components/satchel/test/unit/formhistory_v999a.sqlite create mode 100644 toolkit/components/satchel/test/unit/formhistory_v999b.sqlite create mode 100644 toolkit/components/satchel/test/unit/head_satchel.js create mode 100644 toolkit/components/satchel/test/unit/test_async_expire.js create mode 100644 toolkit/components/satchel/test/unit/test_autocomplete.js create mode 100644 toolkit/components/satchel/test/unit/test_db_access_denied.js create mode 100644 toolkit/components/satchel/test/unit/test_db_corrupt.js create mode 100644 toolkit/components/satchel/test/unit/test_db_update_v4.js create mode 100644 toolkit/components/satchel/test/unit/test_db_update_v4b.js create mode 100644 toolkit/components/satchel/test/unit/test_db_update_v5.js create mode 100644 toolkit/components/satchel/test/unit/test_db_update_v999a.js create mode 100644 toolkit/components/satchel/test/unit/test_db_update_v999b.js create mode 100644 toolkit/components/satchel/test/unit/test_history_api.js create mode 100644 toolkit/components/satchel/test/unit/test_history_sources.js create mode 100644 toolkit/components/satchel/test/unit/test_notify.js create mode 100644 toolkit/components/satchel/test/unit/test_previous_result.js create mode 100644 toolkit/components/satchel/test/unit/xpcshell.toml create mode 100644 toolkit/components/satchel/towel (limited to 'toolkit/components/satchel') diff --git a/toolkit/components/satchel/FillHelpers.sys.mjs b/toolkit/components/satchel/FillHelpers.sys.mjs new file mode 100644 index 0000000000..88a248adba --- /dev/null +++ b/toolkit/components/satchel/FillHelpers.sys.mjs @@ -0,0 +1,41 @@ +/* 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 item shows image, title & subtitle. +// Once selected it will send fillMessageName with fillMessageData +// to the parent actor and response will be used to fill into the field. +export class GenericAutocompleteItem { + comment = ""; + style = "generic"; + value = ""; + + constructor(image, title, subtitle, fillMessageName, fillMessageData) { + this.image = image; + this.comment = JSON.stringify({ + title, + subtitle, + fillMessageName, + fillMessageData, + }); + } +} + +/** + * Show confirmation tooltip + * + * @param {object} browser - An object representing the browser. + * @param {string} messageId - Message ID from browser/confirmationHints.ftl + * @param {string} [anchorId="identity-icon-box"] - ID of the element to anchor the hint to. + The "password-notification-icon" and "notification-popup-box" are hidden + at the point of showing the hint (for *most* cases), so approximate the + location with the next closest, visible icon as the anchor. + */ +export function showConfirmation( + browser, + messageId, + anchorId = "identity-icon-box" +) { + const anchor = browser.ownerDocument.getElementById(anchorId); + anchor.ownerGlobal.ConfirmationHint.show(anchor, messageId, {}); +} diff --git a/toolkit/components/satchel/FormAutoComplete.sys.mjs b/toolkit/components/satchel/FormAutoComplete.sys.mjs new file mode 100644 index 0000000000..1cae8b07c1 --- /dev/null +++ b/toolkit/components/satchel/FormAutoComplete.sys.mjs @@ -0,0 +1,693 @@ +/* vim: set ts=4 sts=4 sw=4 et tw=80: */ +/* 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 { GenericAutocompleteItem } from "resource://gre/modules/FillHelpers.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + FormScenarios: "resource://gre/modules/FormScenarios.sys.mjs", +}); + +const formFillController = Cc[ + "@mozilla.org/satchel/form-fill-controller;1" +].getService(Ci.nsIFormFillController); + +function isAutocompleteDisabled(aField) { + if (!aField) { + return false; + } + + if (aField.autocomplete !== "") { + return aField.autocomplete === "off"; + } + + return aField.form?.autocomplete === "off"; +} + +/** + * An abstraction to talk with the FormHistory database over + * the message layer. FormHistoryClient will take care of + * figuring out the most appropriate message manager to use, + * and what things to send. + * + * It is assumed that nsFormAutoComplete will only ever use + * one instance at a time, and will not attempt to perform more + * than one search request with the same instance at a time. + * However, nsFormAutoComplete might call remove() any number of + * times with the same instance of the client. + * + * @param {object} clientInfo + * Info required to build the FormHistoryClient + * @param {Node} clientInfo.formField + * A DOM node that we're requesting form history for. + * @param {string} clientInfo.inputName + * The name of the input to do the FormHistory look-up with. + * If this is searchbar-history, then formField needs to be null, + * otherwise constructing will throw. + */ +export class FormHistoryClient { + constructor({ formField, inputName }) { + if (formField) { + if (inputName == this.SEARCHBAR_ID) { + throw new Error( + "FormHistoryClient constructed with both a formField and an inputName. " + + "This is not supported, and only empty results will be returned." + ); + } + const window = formField.ownerGlobal; + this.windowGlobal = window.windowGlobalChild; + } + + this.inputName = inputName; + this.id = FormHistoryClient.nextRequestID++; + } + + static nextRequestID = 1; + SEARCHBAR_ID = "searchbar-history"; + cancelled = false; + inputName = ""; + + getActor() { + return this.windowGlobal?.getActor("FormHistory"); + } + + /** + * Query FormHistory for some results. + * + * @param {string} searchString + * The string to search FormHistory for. See + * FormHistory.getAutoCompleteResults. + * @param {object} params + * An Object with search properties. See + * FormHistory.getAutoCompleteResults. + * @param {string} scenarioName + * Optional autocompletion scenario name. + * @param {Function} callback + * A callback function that will take a single + * argument (the found entries). + */ + requestAutoCompleteResults(searchString, params, scenarioName, callback) { + this.cancelled = false; + + // Use the actor if possible, otherwise for the searchbar, + // use the more roundabout per-process message manager which has + // no sendQuery method. + const actor = this.getActor(); + if (actor) { + actor + .sendQuery("FormHistory:AutoCompleteSearchAsync", { + searchString, + params, + scenarioName, + }) + .then( + results => this.handleAutoCompleteResults(results, callback), + () => this.cancel() + ); + } else { + this.callback = callback; + Services.cpmm.addMessageListener( + "FormHistory:AutoCompleteSearchResults", + this + ); + Services.cpmm.sendAsyncMessage("FormHistory:AutoCompleteSearchAsync", { + id: this.id, + searchString, + params, + scenarioName, + }); + } + } + + handleAutoCompleteResults(results, callback) { + if (this.cancelled) { + return; + } + + if (!callback) { + console.error("FormHistoryClient received response with no callback"); + return; + } + + callback(results); + this.cancel(); + } + + /** + * Cancel an in-flight results request. This ensures that the + * callback that requestAutoCompleteResults was passed is never + * called from this FormHistoryClient. + */ + cancel() { + if (this.callback) { + Services.cpmm.removeMessageListener( + "FormHistory:AutoCompleteSearchResults", + this + ); + this.callback = null; + } + this.cancelled = true; + } + + /** + * Remove an item from FormHistory. + * + * @param {string} value + * + * The value to remove for this particular + * field. + * + * @param {string} guid + * + * The guid for the item being removed. + */ + remove(value, guid) { + const actor = this.getActor() || Services.cpmm; + actor.sendAsyncMessage("FormHistory:RemoveEntry", { + inputName: this.inputName, + value, + guid, + }); + } + + receiveMessage(msg) { + const { id, results } = msg.data; + if (id == this.id) { + this.handleAutoCompleteResults(results, this.callback); + } + } +} + +/** + * This autocomplete result combines 3 arrays of entries, fixedEntries and + * externalEntries. + * Entries are Form History entries, they can be removed. + * Fixed entries are "appended" to entries, they are used for datalist items, + * search suggestions and extra items from integrations. + * External entries are meant for integrations, like Firefox Relay. + * Internally entries and fixed entries are kept separated so we can + * reuse and filter them. + * + * @implements {nsIAutoCompleteResult} + */ +export class FormAutoCompleteResult { + constructor(client, entries, fieldName, searchString) { + this.client = client; + this.entries = entries; + this.fieldName = fieldName; + this.searchString = searchString; + } + + QueryInterface = ChromeUtils.generateQI([ + "nsIAutoCompleteResult", + "nsISupportsWeakReference", + ]); + + // private + client = null; + entries = null; + fieldName = null; + #fixedEntries = []; + externalEntries = []; + + set fixedEntries(value) { + this.#fixedEntries = value; + this.removeDuplicateHistoryEntries(); + } + + canSearchIncrementally(searchString) { + const prevSearchString = this.searchString.trim(); + return ( + prevSearchString.length > 1 && + searchString.includes(prevSearchString.toLowerCase()) + ); + } + + incrementalSearch(searchString) { + this.searchString = searchString; + searchString = searchString.trim().toLowerCase(); + this.#fixedEntries = this.#fixedEntries.filter(item => + item.label.toLowerCase().includes(searchString) + ); + + const searchTokens = searchString.split(/\s+/); + // We have a list of results for a shorter search string, so just + // filter them further based on the new search string and add to a new array. + let filteredEntries = []; + for (const entry of this.entries) { + // Remove results that do not contain the token + // XXX bug 394604 -- .toLowerCase can be wrong for some intl chars + if (searchTokens.some(tok => !entry.textLowerCase.includes(tok))) { + continue; + } + this.#calculateScore(entry, searchString, searchTokens); + filteredEntries.push(entry); + } + filteredEntries.sort((a, b) => b.totalScore - a.totalScore); + this.entries = filteredEntries; + this.removeDuplicateHistoryEntries(); + } + + /* + * #calculateScore + * + * entry -- an nsIAutoCompleteResult entry + * aSearchString -- current value of the input (lowercase) + * searchTokens -- array of tokens of the search string + * + * Returns: an int + */ + #calculateScore(entry, aSearchString, searchTokens) { + let boundaryCalc = 0; + // for each word, calculate word boundary weights + for (const token of searchTokens) { + if (entry.textLowerCase.startsWith(token)) { + boundaryCalc++; + } + if (entry.textLowerCase.includes(" " + token)) { + boundaryCalc++; + } + } + boundaryCalc = boundaryCalc * this._boundaryWeight; + // now add more weight if we have a traditional prefix match and + // multiply boundary bonuses by boundary weight + if (entry.textLowerCase.startsWith(aSearchString)) { + boundaryCalc += this._prefixWeight; + } + entry.totalScore = Math.round(entry.frecency * Math.max(1, boundaryCalc)); + } + + /** + * Remove items from history list that are already present in fixed list. + * We do this rather than the opposite ( i.e. remove items from fixed list) + * to reflect the order that is specified in the fixed list. + */ + removeDuplicateHistoryEntries() { + this.entries = this.entries.filter(entry => + this.#fixedEntries.every( + fixed => entry.text != (fixed.label || fixed.value) + ) + ); + } + + getAt(index) { + for (const group of [ + this.entries, + this.#fixedEntries, + this.externalEntries, + ]) { + if (index < group.length) { + return group[index]; + } + index -= group.length; + } + + throw Components.Exception( + "Index out of range.", + Cr.NS_ERROR_ILLEGAL_VALUE + ); + } + + // Allow autoCompleteSearch to get at the JS object so it can + // modify some readonly properties for internal use. + get wrappedJSObject() { + return this; + } + + // Interfaces from idl... + searchString = ""; + errorDescription = ""; + + get defaultIndex() { + return this.matchCount ? 0 : -1; + } + + get searchResult() { + return this.matchCount + ? Ci.nsIAutoCompleteResult.RESULT_SUCCESS + : Ci.nsIAutoCompleteResult.RESULT_NOMATCH; + } + + get matchCount() { + return ( + this.entries.length + + this.#fixedEntries.length + + this.externalEntries.length + ); + } + + getValueAt(index) { + const item = this.getAt(index); + return item.text || item.value; + } + + getLabelAt(index) { + const item = this.getAt(index); + return item.text || item.label || item.value; + } + + getCommentAt(index) { + return this.getAt(index).comment ?? ""; + } + + getStyleAt(index) { + const itemStyle = this.getAt(index).style; + if (itemStyle) { + return itemStyle; + } + + if (index >= 0) { + if (index < this.entries.length) { + return "fromhistory"; + } + + if (index > 0 && index == this.entries.length) { + return "datalist-first"; + } + } + return ""; + } + + getImageAt(_index) { + return ""; + } + + getFinalCompleteValueAt(index) { + return this.getValueAt(index); + } + + isRemovableAt(index) { + return this.#isFormHistoryEntry(index) || this.getAt(index).removable; + } + + removeValueAt(index) { + if (this.#isFormHistoryEntry(index)) { + const [removedEntry] = this.entries.splice(index, 1); + this.client.remove(removedEntry.text, removedEntry.guid); + } + } + + #isFormHistoryEntry(index) { + return index >= 0 && index < this.entries.length; + } +} + +export class FormAutoComplete { + constructor() { + // Preferences. Add observer so we get notified of changes. + this._prefBranch = Services.prefs.getBranch("browser.formfill."); + this._prefBranch.addObserver("", this.observer, true); + this.observer._self = this; + + this._debug = this._prefBranch.getBoolPref("debug"); + this._enabled = this._prefBranch.getBoolPref("enable"); + Services.obs.addObserver(this, "autocomplete-will-enter-text"); + } + + classID = Components.ID("{c11c21b2-71c9-4f87-a0f8-5e13f50495fd}"); + QueryInterface = ChromeUtils.generateQI([ + "nsIFormAutoComplete", + "nsISupportsWeakReference", + ]); + + // Only one query via FormHistoryClient is performed at a time, and the + // most recent FormHistoryClient which will be stored in _pendingClient + // while the query is being performed. It will be cleared when the query + // finishes, is cancelled, or an error occurs. If a new query occurs while + // one is already pending, the existing one is cancelled. + #pendingClient = null; + + fillRequestId = 0; + + observer = { + _self: null, + + QueryInterface: ChromeUtils.generateQI([ + "nsIObserver", + "nsISupportsWeakReference", + ]), + + observe(_subject, topic, data) { + const self = this._self; + + if (topic == "nsPref:changed") { + const prefName = data; + self.log(`got change to ${prefName} preference`); + + switch (prefName) { + case "debug": + self._debug = self._prefBranch.getBoolPref(prefName); + break; + case "enable": + self._enabled = self._prefBranch.getBoolPref(prefName); + break; + } + } + }, + }; + + // AutoCompleteE10S needs to be able to call autoCompleteSearchAsync without + // going through IDL in order to pass a mock DOM object field. + get wrappedJSObject() { + return this; + } + + /* + * log + * + * Internal function for logging debug messages to the Error Console + * window + */ + log(message) { + if (!this._debug) { + return; + } + dump("FormAutoComplete: " + message + "\n"); + Services.console.logStringMessage("FormAutoComplete: " + message); + } + + /* + * autoCompleteSearchAsync + * + * aInputName -- |name| or |id| attribute value from the form input being + * autocompleted + * aUntrimmedSearchString -- current value of the input + * aField -- HTMLInputElement being autocompleted (may be null if from chrome) + * aPreviousResult -- previous search result, if any. + * aAddDataList -- add results from list=datalist for aField. + * aListener -- nsIFormAutoCompleteObserver that listens for the nsIAutoCompleteResult + * that may be returned asynchronously. + */ + autoCompleteSearchAsync( + aInputName, + aUntrimmedSearchString, + aField, + aPreviousResult, + aAddDataList, + aListener + ) { + // Guard against void DOM strings filtering into this code. + if (typeof aInputName === "object") { + aInputName = ""; + } + if (typeof aUntrimmedSearchString === "object") { + aUntrimmedSearchString = ""; + } + + const client = new FormHistoryClient({ + formField: aField, + inputName: aInputName, + }); + + function reportSearchResult(result) { + aListener?.onSearchCompletion(result); + } + + // If we have datalist results, they become our "empty" result. + const result = new FormAutoCompleteResult( + client, + [], + aInputName, + aUntrimmedSearchString + ); + + if (aAddDataList) { + result.fixedEntries = this.getDataListSuggestions(aField); + } + + if (!this._enabled) { + reportSearchResult(result); + return; + } + + // Don't allow form inputs (aField != null) to get results from + // search bar history. + if (aInputName == "searchbar-history" && aField) { + this.log(`autoCompleteSearch for input name "${aInputName}" is denied`); + reportSearchResult(result); + return; + } + + if (isAutocompleteDisabled(aField)) { + this.log("autoCompleteSearch not allowed due to autcomplete=off"); + reportSearchResult(result); + return; + } + + const searchString = aUntrimmedSearchString.trim().toLowerCase(); + const prevResult = aPreviousResult?.wrappedJSObject; + if (prevResult?.canSearchIncrementally(searchString)) { + this.log("Using previous autocomplete result"); + prevResult.incrementalSearch(aUntrimmedSearchString); + reportSearchResult(prevResult); + } else { + this.log("Creating new autocomplete search result."); + this.getAutoCompleteValues( + client, + aInputName, + searchString, + lazy.FormScenarios.detect({ input: aField }).signUpForm + ? "SignUpFormScenario" + : "", + ({ formHistoryEntries, externalEntries }) => { + formHistoryEntries ??= []; + externalEntries ??= []; + + if (aField?.maxLength > -1) { + result.entries = formHistoryEntries.filter( + el => el.text.length <= aField.maxLength + ); + } else { + result.entries = formHistoryEntries; + } + + result.externalEntries.push( + ...externalEntries.map( + entry => + new GenericAutocompleteItem( + entry.image, + entry.title, + entry.subtitle, + entry.fillMessageName, + entry.fillMessageData + ) + ) + ); + + result.removeDuplicateHistoryEntries(); + reportSearchResult(result); + } + ); + } + } + + getDataListSuggestions(aField) { + const items = []; + + if (!aField?.list) { + return items; + } + + const upperFieldValue = aField.value.toUpperCase(); + + for (const option of aField.list.options) { + const label = option.label || option.text || option.value || ""; + + if (!label.toUpperCase().includes(upperFieldValue)) { + continue; + } + + items.push({ + label, + value: option.value, + }); + } + + return items; + } + + stopAutoCompleteSearch() { + if (this.#pendingClient) { + this.#pendingClient.cancel(); + this.#pendingClient = null; + } + } + + /* + * Get the values for an autocomplete list given a search string. + * + * client - a FormHistoryClient instance to perform the search with + * fieldname - fieldname field within form history (the form input name) + * searchString - string to search for + * scenarioName - Optional autocompletion scenario name. + * callback - called when the values are available. Passed an array of objects, + * containing properties for each result. The callback is only called + * when successful. + */ + getAutoCompleteValues( + client, + fieldname, + searchString, + scenarioName, + callback + ) { + this.stopAutoCompleteSearch(); + client.requestAutoCompleteResults( + searchString, + { fieldname }, + scenarioName, + entries => { + this.#pendingClient = null; + callback(entries); + } + ); + this.#pendingClient = client; + } + + async observe(subject, topic, data) { + switch (topic) { + case "autocomplete-will-enter-text": { + await this.sendFillRequestToFormHistoryParent(subject, data); + break; + } + } + } + + async sendFillRequestToFormHistoryParent(input, comment) { + if (!comment) { + return; + } + + if (!input || input != formFillController.controller?.input) { + return; + } + + const { fillMessageName, fillMessageData } = JSON.parse(comment ?? "{}"); + if (!fillMessageName) { + return; + } + + this.fillRequestId++; + const fillRequestId = this.fillRequestId; + const actor = + input.focusedInput.ownerGlobal.windowGlobalChild.getActor("FormHistory"); + const value = await actor.sendQuery(fillMessageName, fillMessageData ?? {}); + + // skip fill if another fill operation started during await + if (fillRequestId != this.fillRequestId) { + return; + } + + if (typeof value !== "string") { + return; + } + + // If FormHistoryParent returned a string to fill, we must do it here because + // nsAutoCompleteController.cpp already finished it's work before we finished await. + input.textValue = value; + input.selectTextRange(value.length, value.length); + } +} diff --git a/toolkit/components/satchel/FormHistory.sys.mjs b/toolkit/components/satchel/FormHistory.sys.mjs new file mode 100644 index 0000000000..07c33ceaed --- /dev/null +++ b/toolkit/components/satchel/FormHistory.sys.mjs @@ -0,0 +1,1307 @@ +/* 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/. */ + +/** + * FormHistory + * + * Used to store values that have been entered into forms which may later + * be used to automatically fill in the values when the form is visited again. + * + * async search(terms, queryData) + * Look up values that have been previously stored. + * terms - array of terms to return data for + * queryData - object that contains the query terms + * The query object contains properties for each search criteria to match, where the value + * of the property specifies the value that term must have. For example, + * { term1: value1, term2: value2 } + * Resolves to an array containing the found results. Each element in + * the array is an object containing a property for each search term + * specified by 'terms'. + * Rejects in case of errors. + * async count(queryData) + * Find the number of stored entries that match the given criteria. + * queryData - array of objects that indicate the query. See the search method for details. + * Resolves to the number of found entries. + * Rejects in case of errors. + * async update(changes) + * Write data to form history storage. + * changes - an array of changes to be made. If only one change is to be made, it + * may be passed as an object rather than a one-element array. + * Each change object is of the form: + * { op: operation, term1: value1, term2: value2, ... } + * Valid operations are: + * add - add a new entry + * update - update an existing entry + * remove - remove an entry + * bump - update the last accessed time on an entry + * The terms specified allow matching of one or more specific entries. If no terms + * are specified then all entries are matched. This means that { op: "remove" } is + * used to remove all entries and clear the form history. + * Resolves once the operation is complete. + * Rejects in case of errors. + * async getAutoCompeteResults(searchString, params, callback) + * Retrieve an array of form history values suitable for display in an autocomplete list. + * searchString - the string to search for, typically the entered value of a textbox + * params - zero or more filter arguments: + * fieldname - form field name + * agedWeight + * bucketSize + * expiryDate + * maxTimeGroundings + * timeGroupingSize + * prefixWeight + * boundaryWeight + * source + * callback - callback that is invoked for each result, the second argument + * is a function that can be used to cancel the operation. + * Each result is an object with four properties: + * text, textLowerCase, frecency, totalScore + * Resolves with an array of results, once the operation is complete. + * Rejects in case of errors. + * + * schemaVersion + * This property holds the version of the database schema + * + * Terms: + * guid - entry identifier. For 'add', a guid will be generated. + * fieldname - form field name + * value - form value + * timesUsed - the number of times the entry has been accessed + * firstUsed - the time the the entry was first created + * lastUsed - the time the entry was last accessed + * firstUsedStart - search for entries created after or at this time + * firstUsedEnd - search for entries created before or at this time + * lastUsedStart - search for entries last accessed after or at this time + * lastUsedEnd - search for entries last accessed before or at this time + * newGuid - a special case valid only for 'update' and allows the guid for + * an existing record to be updated. The 'guid' term is the only + * other term which can be used (ie, you can not also specify a + * fieldname, value etc) and indicates the guid of the existing + * record that should be updated. + */ + +export let FormHistory; + +import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + Sqlite: "resource://gre/modules/Sqlite.sys.mjs", + setTimeout: "resource://gre/modules/Timer.sys.mjs", +}); + +const DB_SCHEMA_VERSION = 5; +const DAY_IN_MS = 86400000; // 1 day in milliseconds +const MAX_SEARCH_TOKENS = 10; +const DB_FILENAME = "formhistory.sqlite"; + +var supportsDeletedTable = AppConstants.platform == "android"; + +const wait = ms => new Promise(res => lazy.setTimeout(res, ms)); + +var Prefs = { + _initialized: false, + + get(name) { + this.ensureInitialized(); + return this[`_${name}`]; + }, + + ensureInitialized() { + if (this._initialized) { + return; + } + + this._initialized = true; + + this._prefBranch = Services.prefs.getBranch("browser.formfill."); + this._prefBranch.addObserver("", this, true); + + this._agedWeight = this._prefBranch.getIntPref("agedWeight"); + this._boundaryWeight = this._prefBranch.getIntPref("boundaryWeight"); + this._bucketSize = this._prefBranch.getIntPref("bucketSize"); + this._debug = this._prefBranch.getBoolPref("debug"); + this._enabled = this._prefBranch.getBoolPref("enable"); + this._expireDays = this._prefBranch.getIntPref("expire_days"); + this._maxTimeGroupings = this._prefBranch.getIntPref("maxTimeGroupings"); + this._prefixWeight = this._prefBranch.getIntPref("prefixWeight"); + this._timeGroupingSize = + this._prefBranch.getIntPref("timeGroupingSize") * 1000 * 1000; + }, + + observe(_subject, topic, data) { + if (topic == "nsPref:changed") { + let prefName = data; + log(`got change to ${prefName} preference`); + + switch (prefName) { + case "agedWeight": + this._agedWeight = this._prefBranch.getIntPref(prefName); + break; + case "boundaryWeight": + this._boundaryWeight = this._prefBranch.getIntPref(prefName); + break; + case "bucketSize": + this._bucketSize = this._prefBranch.getIntPref(prefName); + break; + case "debug": + this._debug = this._prefBranch.getBoolPref(prefName); + break; + case "enable": + this._enabled = this._prefBranch.getBoolPref(prefName); + break; + case "expire_days": + this._expireDays = this._prefBranch.getIntPref("expire_days"); + break; + case "maxTimeGroupings": + this._maxTimeGroupings = this._prefBranch.getIntPref(prefName); + break; + case "prefixWeight": + this._prefixWeight = this._prefBranch.getIntPref(prefName); + break; + case "timeGroupingSize": + this._timeGroupingSize = + this._prefBranch.getIntPref(prefName) * 1000 * 1000; + break; + default: + log(`Oops! Pref ${prefName} not handled, change ignored.`); + break; + } + } + }, + + QueryInterface: ChromeUtils.generateQI([ + "nsIObserver", + "nsISupportsWeakReference", + ]), +}; + +function log(aMessage) { + if (Prefs.get("debug")) { + Services.console.logStringMessage("FormHistory: " + aMessage); + } +} + +function sendNotification(aType, aData) { + if (typeof aData == "string") { + const strWrapper = Cc["@mozilla.org/supports-string;1"].createInstance( + Ci.nsISupportsString + ); + strWrapper.data = aData; + aData = strWrapper; + } else if (typeof aData == "number") { + const intWrapper = Cc["@mozilla.org/supports-PRInt64;1"].createInstance( + Ci.nsISupportsPRInt64 + ); + intWrapper.data = aData; + aData = intWrapper; + } else if (aData) { + throw Components.Exception( + `Invalid type ${typeof aType} passed to sendNotification`, + Cr.NS_ERROR_ILLEGAL_VALUE + ); + } + + Services.obs.notifyObservers(aData, "satchel-storage-changed", aType); +} + +/** + * Current database schema + */ + +const dbSchema = { + tables: { + moz_formhistory: { + id: "INTEGER PRIMARY KEY", + fieldname: "TEXT NOT NULL", + value: "TEXT NOT NULL", + timesUsed: "INTEGER", + firstUsed: "INTEGER", + lastUsed: "INTEGER", + guid: "TEXT", + }, + moz_deleted_formhistory: { + id: "INTEGER PRIMARY KEY", + timeDeleted: "INTEGER", + guid: "TEXT", + }, + moz_sources: { + id: "INTEGER PRIMARY KEY", + source: "TEXT NOT NULL", + }, + moz_history_to_sources: { + history_id: "INTEGER", + source_id: "INTEGER", + SQL: ` + PRIMARY KEY (history_id, source_id), + FOREIGN KEY (history_id) REFERENCES moz_formhistory(id) ON DELETE CASCADE, + FOREIGN KEY (source_id) REFERENCES moz_sources(id) ON DELETE CASCADE + `, + }, + }, + indices: { + moz_formhistory_index: { + table: "moz_formhistory", + columns: ["fieldname"], + }, + moz_formhistory_lastused_index: { + table: "moz_formhistory", + columns: ["lastUsed"], + }, + moz_formhistory_guid_index: { + table: "moz_formhistory", + columns: ["guid"], + }, + }, +}; + +/** + * Validating and processing API querying data + */ + +const validFields = [ + "fieldname", + "firstUsed", + "guid", + "lastUsed", + "source", + "timesUsed", + "value", +]; + +const searchFilters = [ + "firstUsedStart", + "firstUsedEnd", + "lastUsedStart", + "lastUsedEnd", + "source", +]; + +function validateOpData(aData, aDataType) { + let thisValidFields = validFields; + // A special case to update the GUID - in this case there can be a 'newGuid' + // field and of the normally valid fields, only 'guid' is accepted. + if (aDataType == "Update" && "newGuid" in aData) { + thisValidFields = ["guid", "newGuid"]; + } + for (const field in aData) { + if (field != "op" && !thisValidFields.includes(field)) { + throw Components.Exception( + `${aDataType} query contains an unrecognized field: ${field}`, + Cr.NS_ERROR_ILLEGAL_VALUE + ); + } + } + return aData; +} + +function validateSearchData(aData, aDataType) { + for (const field in aData) { + if ( + field != "op" && + !validFields.includes(field) && + !searchFilters.includes(field) + ) { + throw Components.Exception( + `${aDataType} query contains an unrecognized field: ${field}`, + Cr.NS_ERROR_ILLEGAL_VALUE + ); + } + } +} + +function makeQueryPredicates(aQueryData, delimiter = " AND ") { + const params = {}; + const queryTerms = Object.keys(aQueryData) + .filter(field => aQueryData[field] !== undefined) + .map(field => { + params[field] = aQueryData[field]; + switch (field) { + case "firstUsedStart": + return "firstUsed >= :" + field; + case "firstUsedEnd": + return "firstUsed <= :" + field; + case "lastUsedStart": + return "lastUsed >= :" + field; + case "lastUsedEnd": + return "lastUsed <= :" + field; + case "source": + return `EXISTS( + SELECT 1 FROM moz_history_to_sources + JOIN moz_sources s ON s.id = source_id + WHERE source = :${field} + AND history_id = moz_formhistory.id + )`; + } + return field + " = :" + field; + }) + .join(delimiter); + return { queryTerms, params }; +} + +function generateGUID() { + // string like: "{f60d9eac-9421-4abc-8491-8e8322b063d4}" + const uuid = Services.uuid.generateUUID().toString(); + let raw = ""; // A string with the low bytes set to random values + let bytes = 0; + for (let i = 1; bytes < 12; i += 2) { + // Skip dashes + if (uuid[i] == "-") { + i++; + } + const hexVal = parseInt(uuid[i] + uuid[i + 1], 16); + raw += String.fromCharCode(hexVal); + bytes++; + } + return btoa(raw); +} + +var Migrators = { + // Bug 506402 - Adds deleted form history table. + async dbAsyncMigrateToVersion4(conn) { + const tableName = "moz_deleted_formhistory"; + const tableExists = await conn.tableExists(tableName); + if (!tableExists) { + await createTable(conn, tableName); + } + }, + + // Bug 1654862 - Adds sources and moz_history_to_sources tables. + async dbAsyncMigrateToVersion5(conn) { + if (!(await conn.tableExists("moz_sources"))) { + for (const tableName of ["moz_history_to_sources", "moz_sources"]) { + await createTable(conn, tableName); + } + } + }, +}; + +/** + * @typedef {object} InsertQueryData + * @property {object} updatedChange + * A change requested by FormHistory. + * @property {string} query + * The insert query string. + */ + +/** + * Prepares a query and some default parameters when inserting an entry + * to the database. + * + * @param {object} change + * The change requested by FormHistory. + * @param {number} now + * The current timestamp in microseconds. + * @returns {InsertQueryData} + * The query information needed to pass along to the database. + */ +function prepareInsertQuery(change, now) { + const params = {}; + for (const key of new Set([ + ...Object.keys(change), + // These must always be NOT NULL. + "firstUsed", + "lastUsed", + "timesUsed", + ])) { + switch (key) { + case "fieldname": + case "guid": + case "value": + params[key] = change[key]; + break; + case "firstUsed": + case "lastUsed": + params[key] = change[key] || now; + break; + case "timesUsed": + params[key] = change[key] || 1; + break; + default: + // Skip unnecessary properties. + } + } + + return { + query: ` + INSERT INTO moz_formhistory + (fieldname, value, timesUsed, firstUsed, lastUsed, guid) + VALUES (:fieldname, :value, :timesUsed, :firstUsed, :lastUsed, :guid)`, + params, + }; +} + +// There is a fieldname / value uniqueness constraint that's at this time +// only enforced at this level. This Map maps fieldnames => values that +// are in the process of being inserted into the database so that we know +// not to try to insert the same ones on top. Attempts to do so will be +// ignored. +var InProgressInserts = { + _inProgress: new Map(), + + add(fieldname, value) { + const fieldnameSet = this._inProgress.get(fieldname); + if (!fieldnameSet) { + this._inProgress.set(fieldname, new Set([value])); + return true; + } + + if (!fieldnameSet.has(value)) { + fieldnameSet.add(value); + return true; + } + + return false; + }, + + clear(fieldnamesAndValues) { + for (const [fieldname, value] of fieldnamesAndValues) { + const fieldnameSet = this._inProgress.get(fieldname); + if (fieldnameSet?.delete(value) && fieldnameSet.size == 0) { + this._inProgress.delete(fieldname); + } + } + }, +}; + +function getAddSourceToGuidQueries(source, guid) { + return [ + { + query: `INSERT OR IGNORE INTO moz_sources (source) VALUES (:source)`, + params: { source }, + }, + { + query: ` + INSERT OR IGNORE INTO moz_history_to_sources (history_id, source_id) + VALUES( + (SELECT id FROM moz_formhistory WHERE guid = :guid), + (SELECT id FROM moz_sources WHERE source = :source) + ) + `, + params: { guid, source }, + }, + ]; +} + +/** + * Constructs and executes database statements from a pre-processed list of + * inputted changes. + * + * @param {Array.} aChanges changes to form history + */ +// XXX This should be split up and the complexity reduced. +// eslint-disable-next-line complexity +async function updateFormHistoryWrite(aChanges) { + log("updateFormHistoryWrite " + aChanges.length); + + // pass 'now' down so that every entry in the batch has the same timestamp + const now = Date.now() * 1000; + let queries = []; + const notifications = []; + const adds = []; + const conn = await FormHistory.db; + + for (const change of aChanges) { + const operation = change.op; + delete change.op; + switch (operation) { + case "remove": { + log("Remove from form history " + change); + const { queryTerms, params } = makeQueryPredicates(change); + + // If source is defined, we only remove the source relation, if the + // consumer intends to remove the value from everywhere, then they + // should not pass source. This gives full control to the caller. + if (change.source) { + await conn.executeCached( + `DELETE FROM moz_history_to_sources + WHERE source_id = ( + SELECT id FROM moz_sources WHERE source = :source + ) + AND history_id = ( + SELECT id FROM moz_formhistory WHERE ${queryTerms} + ) + `, + params + ); + break; + } + + // Fetch the GUIDs we are going to delete. + try { + let query = "SELECT guid FROM moz_formhistory"; + if (queryTerms) { + query += " WHERE " + queryTerms; + } + + await conn.executeCached(query, params, row => { + notifications.push([ + "formhistory-remove", + row.getResultByName("guid"), + ]); + }); + } catch (e) { + log("Error getting guids from moz_formhistory: " + e); + } + + if (supportsDeletedTable) { + log("Moving to deleted table " + change); + let query = "INSERT INTO moz_deleted_formhistory (guid, timeDeleted)"; + + // TODO: Add these items to the deleted items table once we've sorted + // out the issues from bug 756701 + if (change.guid || queryTerms) { + query += change.guid + ? " VALUES (:guid, :timeDeleted)" + : " SELECT guid, :timeDeleted FROM moz_formhistory WHERE " + + queryTerms; + queries.push({ + query, + params: Object.assign({ timeDeleted: now }, params), + }); + } + } + + let query = "DELETE FROM moz_formhistory"; + if (queryTerms) { + log("removeEntries"); + query += " WHERE " + queryTerms; + } else { + log("removeAllEntries"); + // Not specifying any fields means we should remove all entries. We + // won't need to modify the query in this case. + } + + queries.push({ query, params }); + // Expire orphan sources. + queries.push({ + query: ` + DELETE FROM moz_sources WHERE id NOT IN ( + SELECT DISTINCT source_id FROM moz_history_to_sources + )`, + }); + break; + } + case "update": { + log("Update form history " + change); + const guid = change.guid; + delete change.guid; + // a special case for updating the GUID - the new value can be + // specified in newGuid. + if (change.newGuid) { + change.guid = change.newGuid; + delete change.newGuid; + } + + let query = "UPDATE moz_formhistory SET "; + let { queryTerms, params } = makeQueryPredicates(change, ", "); + if (!queryTerms) { + throw Components.Exception( + "Update query must define fields to modify.", + Cr.NS_ERROR_ILLEGAL_VALUE + ); + } + query += queryTerms + " WHERE guid = :existing_guid"; + queries.push({ + query, + params: Object.assign({ existing_guid: guid }, params), + }); + + notifications.push(["formhistory-update", guid]); + + // Source is ignored for "update" operations, since it's not really + // common to change the source of a value, and anyway currently this is + // mostly used to update guids. + break; + } + case "bump": { + log("Bump form history " + change); + if (change.guid) { + const query = + "UPDATE moz_formhistory " + + "SET timesUsed = timesUsed + 1, lastUsed = :lastUsed WHERE guid = :guid"; + const queryParams = { + lastUsed: now, + guid: change.guid, + }; + + queries.push({ query, params: queryParams }); + notifications.push(["formhistory-update", change.guid]); + } else { + if (!InProgressInserts.add(change.fieldname, change.value)) { + // This updateFormHistoryWrite call, or a previous one, is already + // going to add this fieldname / value pair, so we can ignore this. + continue; + } + adds.push([change.fieldname, change.value]); + change.guid = generateGUID(); + const { query, params } = prepareInsertQuery(change, now); + queries.push({ query, params }); + notifications.push(["formhistory-add", params.guid]); + } + + if (change.source) { + queries = queries.concat( + getAddSourceToGuidQueries(change.source, change.guid) + ); + } + break; + } + case "add": { + if (!InProgressInserts.add(change.fieldname, change.value)) { + // This updateFormHistoryWrite call, or a previous one, is already + // going to add this fieldname / value pair, so we can ignore this. + continue; + } + adds.push([change.fieldname, change.value]); + + log("Add to form history " + change); + if (!change.guid) { + change.guid = generateGUID(); + } + + const { query, params } = prepareInsertQuery(change, now); + queries.push({ query, params }); + + notifications.push(["formhistory-add", params.guid]); + + if (change.source) { + queries = queries.concat( + getAddSourceToGuidQueries(change.source, change.guid) + ); + } + break; + } + default: { + // We should've already guaranteed that change.op is one of the above + throw Components.Exception( + "Invalid operation " + operation, + Cr.NS_ERROR_ILLEGAL_VALUE + ); + } + } + } + + try { + await conn.executeTransaction(async () => { + for (const { query, params } of queries) { + await conn.executeCached(query, params); + } + }); + for (const [notification, param] of notifications) { + // We're either sending a GUID or nothing at all. + sendNotification(notification, param); + } + } finally { + InProgressInserts.clear(adds); + } +} + +/** + * Functions that expire entries in form history and shrinks database + * afterwards as necessary initiated by expireOldEntries. + */ + +/** + * Removes entries from database. + * + * @param {number} aExpireTime expiration timestamp + * @param {number} aBeginningCount numer of entries at first + * @returns {Promise} resolved once the work is complete + */ +async function expireOldEntriesDeletion(aExpireTime, aBeginningCount) { + log(`expireOldEntriesDeletion(${aExpireTime},${aBeginningCount})`); + + await FormHistory.update([ + { + op: "remove", + lastUsedEnd: aExpireTime, + }, + ]); + await expireOldEntriesVacuum(aExpireTime, aBeginningCount); +} + +/** + * Counts number of entries removed and shrinks database as necessary. + * + * @param {number} aExpireTime expiration timestamp + * @param {number} aBeginningCount number of entries at first + */ +async function expireOldEntriesVacuum(aExpireTime, aBeginningCount) { + const count = await FormHistory.count({}); + if (aBeginningCount - count > 500) { + log("expireOldEntriesVacuum"); + const conn = await FormHistory.db; + await conn.executeCached("VACUUM"); + } + sendNotification("formhistory-expireoldentries", aExpireTime); +} + +async function createTable(conn, tableName) { + const table = dbSchema.tables[tableName]; + const columns = Object.keys(table) + .filter(col => col != "SQL") + .map(col => [col, table[col]].join(" ")) + .join(", "); + const no_rowid = Object.keys(table).includes("id") ? "" : "WITHOUT ROWID"; + log(`Creating table ${tableName} with ${columns}`); + await conn.execute( + `CREATE TABLE ${tableName} ( + ${columns} + ${table.SQL ? "," + table.SQL : ""} + ) ${no_rowid}` + ); +} + +/** + * Database creation and access. Used by FormHistory and some of the + * utility functions, but is not exposed to the outside world. + * + * @class + */ +var DB = { + // Once we establish a database connection, we have to hold a reference + // to it so that it won't get GC'd. + _instance: null, + // MAX_ATTEMPTS is how many times we'll try to establish a connection + // or migrate a database before giving up. + MAX_ATTEMPTS: 4, + + /** String representing where the FormHistory database is on the filesystem */ + get path() { + return PathUtils.join(PathUtils.profileDir, DB_FILENAME); + }, + + /** + * Sets up and returns a connection to the FormHistory database. The + * connection also registers itself with AsyncShutdown so that the + * connection is closed on when the profile-before-change observer + * notification is fired. + * + * @returns {Promise} + * A {@link toolkit/modules/Sqlite.sys.mjs} connection to the database. + * @throws + * If connecting to the database, or migrating the database + * failed after MAX_ATTEMPTS attempts, this will reject + * with the Sqlite.sys.mjs error. + */ + get conn() { + delete this.conn; + const conn = (async () => { + try { + this._instance = await this._establishConn(); + } catch (e) { + log("Failed to establish database connection: " + e); + throw e; + } + + return this._instance; + })(); + + return (this.conn = conn); + }, + + // Private functions + + /** + * Tries to connect to the Sqlite database at this.path, and then + * migrates the database as necessary. If any of the steps to do this + * fail, this function should re-enter itself with an incremented + * attemptNum so that another attempt can be made after backing up + * and deleting the old database. + * + * @async + * @param {number} attemptNum + * The optional number of the attempt that is being made to connect + * to the database. Defaults to 0. + * @returns {Promise} + * A {@link toolkit/modules/Sqlite.sys.mjs} connection to the database. + * @throws + * If connecting to the database, or migrating the database + * failed after MAX_ATTEMPTS attempts, this will reject + * with the Sqlite.sys.mjs error. + */ + async _establishConn(attemptNum = 0) { + log(`Establishing database connection - attempt # ${attemptNum}`); + let conn; + try { + conn = await lazy.Sqlite.openConnection({ path: this.path }); + lazy.Sqlite.shutdown.addBlocker("Closing FormHistory database.", () => + conn.close() + ); + } catch (e) { + // retrying. + // If error is a db corruption error, backup the database and create a new one. + // Else, use an exponential backoff algorithm to restart up to MAX_ATTEMPTS times. + if (attemptNum < this.MAX_ATTEMPTS) { + log(`Establishing connection failed due with error ${e.result}`); + + if (e.result === Cr.NS_ERROR_FILE_CORRUPTED) { + log("Corrupt database, resetting database"); + await this._failover(conn); + } else { + if (conn) { + await conn.close(); + } + // retrying with an exponential backoff + await wait(2 ** attemptNum * 10); + } + + return this._establishConn(++attemptNum); + } + + if (conn) { + await conn.close(); + } + log("Establishing connection failed too many times. Giving up."); + throw e; + } + + try { + // Enable foreign keys support. + await conn.execute("PRAGMA foreign_keys = ON"); + + const dbVersion = parseInt(await conn.getSchemaVersion(), 10); + + // Case 1: Database is up to date and we're ready to go. + if (dbVersion == DB_SCHEMA_VERSION) { + return conn; + } + + // Case 2: Downgrade + if (dbVersion > DB_SCHEMA_VERSION) { + log("Downgrading to version " + DB_SCHEMA_VERSION); + // User's DB is newer. Sanity check that our expected columns are + // present, and if so mark the lower version and merrily continue + // on. If the columns are borked, something is wrong so blow away + // the DB and start from scratch. [Future incompatible upgrades + // should switch to a different table or file.] + if (!(await this._expectedColumnsPresent(conn))) { + throw Components.Exception( + "DB is missing expected columns", + Cr.NS_ERROR_FILE_CORRUPTED + ); + } + + // Change the stored version to the current version. If the user + // runs the newer code again, it will see the lower version number + // and re-upgrade (to fixup any entries the old code added). + await conn.setSchemaVersion(DB_SCHEMA_VERSION); + return conn; + } + + // Case 3: Very old database that cannot be migrated. + // + // When FormHistory is released, we will no longer support the various + // schema versions prior to this release that nsIFormHistory2 once did. + // We'll throw an NS_ERROR_FILE_CORRUPTED, which should cause us to wipe + // out this DB and create a new one (unless this is our MAX_ATTEMPTS + // attempt). + if (dbVersion > 0 && dbVersion < 3) { + throw Components.Exception( + "DB version is unsupported.", + Cr.NS_ERROR_FILE_CORRUPTED + ); + } + + if (dbVersion == 0) { + // Case 4: New database + await conn.executeTransaction(async () => { + log("Creating DB -- tables"); + for (const name in dbSchema.tables) { + await createTable(conn, name); + } + + log("Creating DB -- indices"); + for (const name in dbSchema.indices) { + const index = dbSchema.indices[name]; + const statement = `CREATE INDEX IF NOT EXISTS ${name} ON ${ + index.table + }(${index.columns.join(", ")})`; + await conn.execute(statement); + } + }); + } else { + // Case 5: Old database requiring a migration + await conn.executeTransaction(async () => { + for (let v = dbVersion + 1; v <= DB_SCHEMA_VERSION; v++) { + log(`Upgrading to version ${v}...`); + await Migrators["dbAsyncMigrateToVersion" + v](conn); + } + }); + } + + await conn.setSchemaVersion(DB_SCHEMA_VERSION); + + return conn; + } catch (e) { + if (e.result != Cr.NS_ERROR_FILE_CORRUPTED) { + throw e; + } + + if (attemptNum < this.MAX_ATTEMPTS) { + log("Setting up database failed."); + await this._failover(conn); + return this._establishConn(++attemptNum); + } + + if (conn) { + await conn.close(); + } + + log("Setting up database failed too many times. Giving up."); + + throw e; + } + }, + + /** + * Closes a connection to the database, then backs up the database before + * deleting it. + * + * @async + * @param {SqliteConnection | null} conn + * The connection to the database that we failed to establish or + * migrate. + * @throws If any file operations fail. + */ + async _failover(conn) { + log("Cleaning up DB file - close & remove & backup."); + if (conn) { + await conn.close(); + } + const backupFile = this.path + ".corrupt"; + const uniquePath = await IOUtils.createUniqueFile( + PathUtils.parent(backupFile), + PathUtils.filename(backupFile), + 0o600 + ); + await IOUtils.copy(this.path, uniquePath); + await IOUtils.remove(this.path); + log("Completed DB cleanup."); + }, + + /** + * Tests that a database connection contains the tables that we expect. + * + * @async + * @param {SqliteConnection | null} conn + * The connection to the database that we're testing. + * @returns {Promise} true if all expected columns are present. + */ + async _expectedColumnsPresent(conn) { + for (const name in dbSchema.tables) { + const table = dbSchema.tables[name]; + const columns = Object.keys(table).filter(col => col != "SQL"); + const query = `SELECT ${columns.join(", ")} FROM ${name}`; + try { + await conn.execute(query, null, (_row, cancel) => { + // One row is enough to let us know this worked. + cancel(); + }); + } catch (e) { + return false; + } + } + + log("Verified that expected columns are present in DB."); + return true; + }, +}; + +FormHistory = { + get db() { + return DB.conn; + }, + + get enabled() { + return Prefs.get("enabled"); + }, + + async search(aSelectTerms, aSearchData, aRowFunc) { + // if no terms selected, select everything + if (!aSelectTerms) { + // Source is not a valid column in moz_formhistory. + aSelectTerms = validFields.filter(f => f != "source"); + } + + validateSearchData(aSearchData, "Search"); + + let query = `SELECT ${aSelectTerms.join(", ")} FROM moz_formhistory`; + const { queryTerms, params } = makeQueryPredicates(aSearchData); + if (queryTerms) { + query += " WHERE " + queryTerms; + } + + const allResults = []; + + const conn = await this.db; + await conn.executeCached(query, params, row => { + const result = {}; + for (const field of aSelectTerms) { + result[field] = row.getResultByName(field); + } + aRowFunc?.(result); + allResults.push(result); + }); + + return allResults; + }, + + async count(aSearchData) { + validateSearchData(aSearchData, "Count"); + + let query = "SELECT COUNT(*) AS numEntries FROM moz_formhistory"; + const { queryTerms, params } = makeQueryPredicates(aSearchData); + if (queryTerms) { + query += " WHERE " + queryTerms; + } + + const conn = await this.db; + const rows = await conn.executeCached(query, params); + return rows[0].getResultByName("numEntries"); + }, + + async update(aChanges) { + function validIdentifier(change) { + // The identifier is only valid if one of either the guid + // or the (fieldname/value) are set (so an X-OR) + return Boolean(change.guid) != Boolean(change.fieldname && change.value); + } + + if (!("length" in aChanges)) { + aChanges = [aChanges]; + } + + const isRemoveOperation = aChanges.every(change => change?.op == "remove"); + if (!this.enabled && !isRemoveOperation) { + throw new Error( + "Form history is disabled, only remove operations are allowed" + ); + } + + for (const change of aChanges) { + switch (change.op) { + case "remove": + validateSearchData(change, "Remove"); + continue; + case "update": + if (validIdentifier(change)) { + validateOpData(change, "Update"); + if (change.guid) { + continue; + } + } else { + throw Components.Exception( + "update op='update' does not correctly reference a entry.", + Cr.NS_ERROR_ILLEGAL_VALUE + ); + } + break; + case "bump": + if (validIdentifier(change)) { + validateOpData(change, "Bump"); + if (change.guid) { + continue; + } + } else { + throw Components.Exception( + "update op='bump' does not correctly reference a entry.", + Cr.NS_ERROR_ILLEGAL_VALUE + ); + } + break; + case "add": + if (change.fieldname && change.value) { + validateOpData(change, "Add"); + } else { + throw Components.Exception( + "update op='add' must have a fieldname and a value.", + Cr.NS_ERROR_ILLEGAL_VALUE + ); + } + break; + default: + throw Components.Exception( + "update does not recognize op='" + change.op + "'", + Cr.NS_ERROR_ILLEGAL_VALUE + ); + } + + const results = await FormHistory.search(["guid"], { + fieldname: change.fieldname, + value: change.value, + }); + if (results.length > 1) { + const error = + "Database contains multiple entries with the same fieldname/value pair."; + log(error); + throw new Error(error); + } + change.guid = results[0]?.guid; + } + + await updateFormHistoryWrite(aChanges); + }, + + /** + * Gets results for the autocomplete widget. + * + * @param {string} searchString The string to search for. + * @param {object} params zero or more filter properties: + * - fieldname + * - source + * @param {Function} [isCancelled] optional function that can return true + * to cancel result retrieval + * @returns {Promise} + * An array of results. If the search was canceled it will be an empty array. + */ + async getAutoCompleteResults(searchString, params, isCancelled) { + // only do substring matching when the search string contains more than one character + let searchTokens; + let where = ""; + let boundaryCalc = ""; + + params = { + agedWeight: Prefs.get("agedWeight"), + bucketSize: Prefs.get("bucketSize"), + expiryDate: + 1000 * (Date.now() - Prefs.get("expireDays") * 24 * 60 * 60 * 1000), + maxTimeGroupings: Prefs.get("maxTimeGroupings"), + timeGroupingSize: Prefs.get("timeGroupingSize"), + prefixWeight: Prefs.get("prefixWeight"), + boundaryWeight: Prefs.get("boundaryWeight"), + ...params, + }; + + if (searchString.length >= 1) { + params.valuePrefix = searchString.replaceAll("/", "//") + "%"; + } + + if (searchString.length > 1) { + searchTokens = searchString.split(/\s+/); + + // build up the word boundary and prefix match bonus calculation + boundaryCalc = + "MAX(1, :prefixWeight * (value LIKE :valuePrefix ESCAPE '/') + ("; + // for each word, calculate word boundary weights for the SELECT clause and + // add word to the WHERE clause of the query + let tokenCalc = []; + let searchTokenCount = Math.min(searchTokens.length, MAX_SEARCH_TOKENS); + for (let i = 0; i < searchTokenCount; i++) { + let escapedToken = searchTokens[i].replaceAll("/", "//"); + params["tokenBegin" + i] = escapedToken + "%"; + params["tokenBoundary" + i] = "% " + escapedToken + "%"; + params["tokenContains" + i] = "%" + escapedToken + "%"; + + tokenCalc.push( + `(value LIKE :tokenBegin${i} ESCAPE '/') + (value LIKE :tokenBoundary${i} ESCAPE '/')` + ); + where += `AND (value LIKE :tokenContains${i} ESCAPE '/') `; + } + // add more weight if we have a traditional prefix match and + // multiply boundary bonuses by boundary weight + boundaryCalc += tokenCalc.join(" + ") + ") * :boundaryWeight)"; + } else if (searchString.length == 1) { + where = "AND (value LIKE :valuePrefix ESCAPE '/') "; + boundaryCalc = "1"; + delete params.prefixWeight; + delete params.boundaryWeight; + } else { + where = ""; + boundaryCalc = "1"; + delete params.prefixWeight; + delete params.boundaryWeight; + } + + params.now = Date.now() * 1000; // convert from ms to microseconds + + if (params.source) { + where += `AND EXISTS( + SELECT 1 FROM moz_history_to_sources + JOIN moz_sources s ON s.id = source_id + WHERE source = :source + AND history_id = moz_formhistory.id + )`; + } + + /* Three factors in the frecency calculation for an entry (in order of use in calculation): + * 1) average number of times used - items used more are ranked higher + * 2) how recently it was last used - items used recently are ranked higher + * 3) additional weight for aged entries surviving expiry - these entries are relevant + * since they have been used multiple times over a large time span so rank them higher + * The score is then divided by the bucket size and we round the result so that entries + * with a very similar frecency are bucketed together with an alphabetical sort. This is + * to reduce the amount of moving around by entries while typing. + */ + + const query = + "/* do not warn (bug 496471): can't use an index */ " + + "SELECT value, guid, " + + "ROUND( " + + "timesUsed / MAX(1.0, (lastUsed - firstUsed) / :timeGroupingSize) * " + + "MAX(1.0, :maxTimeGroupings - (:now - lastUsed) / :timeGroupingSize) * " + + "MAX(1.0, :agedWeight * (firstUsed < :expiryDate)) / " + + ":bucketSize " + + ", 3) AS frecency, " + + boundaryCalc + + " AS boundaryBonuses " + + "FROM moz_formhistory " + + "WHERE fieldname=:fieldname " + + where + + "ORDER BY ROUND(frecency * boundaryBonuses) DESC, UPPER(value) ASC"; + + let results = []; + const conn = await this.db; + await conn.executeCached(query, params, (row, cancel) => { + if (isCancelled?.()) { + cancel(); + results = []; + return; + } + + const value = row.getResultByName("value"); + const guid = row.getResultByName("guid"); + const frecency = row.getResultByName("frecency"); + const entry = { + text: value, + guid, + textLowerCase: value.toLowerCase(), + frecency, + totalScore: Math.round( + frecency * row.getResultByName("boundaryBonuses") + ), + }; + results.push(entry); + }); + return results; + }, + + // This is used only so that the test can verify deleted table support. + get _supportsDeletedTable() { + return supportsDeletedTable; + }, + set _supportsDeletedTable(val) { + supportsDeletedTable = val; + }, + + // The remaining methods are called by FormHistoryStartup.js + async expireOldEntries() { + log("expireOldEntries"); + + // Determine how many days of history we're supposed to keep. + // Calculate expireTime in microseconds + const expireTime = + (Date.now() - Prefs.get("expireDays") * DAY_IN_MS) * 1000; + + sendNotification("formhistory-beforeexpireoldentries", expireTime); + + const count = await FormHistory.count({}); + await expireOldEntriesDeletion(expireTime, count); + }, +}; + +// Prevent add-ons from redefining this API +Object.freeze(FormHistory); diff --git a/toolkit/components/satchel/FormHistoryChild.sys.mjs b/toolkit/components/satchel/FormHistoryChild.sys.mjs new file mode 100644 index 0000000000..242d8f2e29 --- /dev/null +++ b/toolkit/components/satchel/FormHistoryChild.sys.mjs @@ -0,0 +1,139 @@ +/* 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", + PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs", +}); + +XPCOMUtils.defineLazyPreferenceGetter(lazy, "gDebug", "browser.formfill.debug"); +XPCOMUtils.defineLazyPreferenceGetter( + lazy, + "gEnabled", + "browser.formfill.enable" +); +XPCOMUtils.defineLazyServiceGetter( + lazy, + "gFormFillService", + "@mozilla.org/satchel/form-fill-controller;1", + "nsIFormFillController" +); + +function log(message) { + if (!lazy.gDebug) { + return; + } + dump("satchelFormListener: " + message + "\n"); + Services.console.logStringMessage("satchelFormListener: " + message); +} + +export class FormHistoryChild extends JSWindowActorChild { + handleEvent(event) { + switch (event.type) { + case "DOMFormBeforeSubmit": + this.#onDOMFormBeforeSubmit(event.target); + break; + default: + throw new Error("Unexpected event"); + } + } + + #onDOMFormBeforeSubmit(form) { + if ( + !lazy.gEnabled || + lazy.PrivateBrowsingUtils.isContentWindowPrivate(form.ownerGlobal) + ) { + return; + } + + log("Form submit observer notified."); + + if (form.getAttribute("autocomplete")?.toLowerCase() == "off") { + return; + } + + const entries = []; + for (const input of form.elements) { + if (!HTMLInputElement.isInstance(input)) { + continue; + } + + // Only use inputs that hold text values (not including type="password") + if (!input.mozIsTextField(true)) { + continue; + } + + // Don't save fields that were previously type=password such as on sites + // that allow the user to toggle password visibility. + if (input.hasBeenTypePassword) { + continue; + } + + // Bug 1780571, Bug 394612: If Login Manager marked this input, don't save it. + // The login manager will deal with remembering it. + if (lazy.gFormFillService.isLoginManagerField(input)) { + continue; + } + + // Don't save values when @autocomplete is "off" or has a sensitive field name. + const autocompleteInfo = input.getAutocompleteInfo(); + if (autocompleteInfo?.canAutomaticallyPersist === false) { + continue; + } + + const value = input.lastInteractiveValue?.trim(); + + // Only save user entered values even if they match the default value. + // Any script input is ignored. + // See Bug 1642570 for details. + if (!value) { + continue; + } + + // Save only when user input was last. + if (value != input.value.trim()) { + continue; + } + + // Don't save credit card numbers. + if (lazy.CreditCard.isValidNumber(value)) { + log("skipping saving a credit card number"); + continue; + } + + const name = input.name || input.id; + if (!name) { + continue; + } + + if (name == "searchbar-history") { + log('addEntry for input name "' + name + '" is denied'); + continue; + } + + // Limit stored data to 200 characters. + if (name.length > 200 || value.length > 200) { + log("skipping input that has a name/value too large"); + continue; + } + + entries.push({ name, value }); + + // Limit number of fields stored per form. + if (entries.length >= 100) { + log("not saving any more entries for this form."); + break; + } + } + + if (entries.length) { + log("sending entries to parent process for form " + form.id); + this.sendAsyncMessage("FormHistory:FormSubmitEntries", entries); + } + } +} diff --git a/toolkit/components/satchel/FormHistoryParent.sys.mjs b/toolkit/components/satchel/FormHistoryParent.sys.mjs new file mode 100644 index 0000000000..8fe943566e --- /dev/null +++ b/toolkit/components/satchel/FormHistoryParent.sys.mjs @@ -0,0 +1,107 @@ +/* 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 { FirefoxRelayTelemetry } from "resource://gre/modules/FirefoxRelayTelemetry.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + FirefoxRelay: "resource://gre/modules/FirefoxRelay.sys.mjs", + FormHistory: "resource://gre/modules/FormHistory.sys.mjs", + LoginHelper: "resource://gre/modules/LoginHelper.sys.mjs", +}); + +export class FormHistoryParent extends JSWindowActorParent { + receiveMessage({ name, data }) { + switch (name) { + case "FormHistory:FormSubmitEntries": + this.#onFormSubmitEntries(data); + break; + + case "FormHistory:AutoCompleteSearchAsync": + return this.#onAutoCompleteSearch(data); + + case "FormHistory:RemoveEntry": + this.#onRemoveEntry(data); + break; + + case "PasswordManager:offerRelayIntegration": { + FirefoxRelayTelemetry.recordRelayOfferedEvent( + "clicked", + data.telemetry.flowId, + data.telemetry.scenarioName + ); + return this.#offerRelayIntegration(); + } + + case "PasswordManager:generateRelayUsername": { + FirefoxRelayTelemetry.recordRelayUsernameFilledEvent( + "clicked", + data.telemetry.flowId + ); + return this.#generateRelayUsername(); + } + } + + return undefined; + } + + #onFormSubmitEntries(entries) { + const changes = entries.map(entry => ({ + op: "bump", + fieldname: entry.name, + value: entry.value, + })); + + lazy.FormHistory.update(changes); + } + + get formOrigin() { + return lazy.LoginHelper.getLoginOrigin( + this.manager.documentPrincipal?.originNoSuffix + ); + } + + async #onAutoCompleteSearch({ searchString, params, scenarioName }) { + const formHistoryPromise = lazy.FormHistory.getAutoCompleteResults( + searchString, + params + ); + + const relayPromise = lazy.FirefoxRelay.autocompleteItemsAsync({ + formOrigin: this.formOrigin, + scenarioName, + hasInput: !!searchString.length, + }); + const [formHistoryEntries, externalEntries] = await Promise.all([ + formHistoryPromise, + relayPromise, + ]); + + return { formHistoryEntries, externalEntries }; + } + + #onRemoveEntry({ inputName, value, guid }) { + lazy.FormHistory.update({ + op: "remove", + fieldname: inputName, + value, + guid, + }); + } + + getRootBrowser() { + return this.browsingContext.topFrameElement; + } + + async #offerRelayIntegration() { + const browser = this.getRootBrowser(); + return lazy.FirefoxRelay.offerRelayIntegration(browser, this.formOrigin); + } + + async #generateRelayUsername() { + const browser = this.getRootBrowser(); + return lazy.FirefoxRelay.generateUsername(browser, this.formOrigin); + } +} diff --git a/toolkit/components/satchel/FormHistoryStartup.sys.mjs b/toolkit/components/satchel/FormHistoryStartup.sys.mjs new file mode 100644 index 0000000000..104756c583 --- /dev/null +++ b/toolkit/components/satchel/FormHistoryStartup.sys.mjs @@ -0,0 +1,99 @@ +/* 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, { + FormHistory: "resource://gre/modules/FormHistory.sys.mjs", +}); + +export class FormHistoryStartup { + classID = Components.ID("{3A0012EB-007F-4BB8-AA81-A07385F77A25}"); + + QueryInterface = ChromeUtils.generateQI([ + "nsIObserver", + "nsISupportsWeakReference", + ]); + + observe(_subject, topic, _data) { + switch (topic) { + case "idle-daily": + case "formhistory-expire-now": + lazy.FormHistory.expireOldEntries().catch(console.error); + break; + case "profile-after-change": + this.init(); + break; + } + } + + init() { + if (this.inited) { + return; + } + this.inited = true; + + // triggers needed service cleanup and db shutdown + Services.obs.addObserver(this, "idle-daily", true); + Services.obs.addObserver(this, "formhistory-expire-now", true); + + Services.ppmm.addMessageListener( + "FormHistory:AutoCompleteSearchAsync", + this + ); + Services.ppmm.addMessageListener("FormHistory:RemoveEntry", this); + } + + receiveMessage(message) { + switch (message.name) { + case "FormHistory:AutoCompleteSearchAsync": + this.#onFormHistoryAutoCompleteSearchAsync({ + ...message.data, + target: message.target, + }); + break; + + case "FormHistory:RemoveEntry": + this.#onFormHistoryRemoveEntry(message.data); + break; + } + } + + async #onFormHistoryAutoCompleteSearchAsync({ + id, + searchString, + params, + target, + }) { + // This case is only used for the search field. There is a + // similar algorithm in FormHistoryParent.jsm that uses + // sendQuery for other form fields. + + const instance = (this._queryInstance = {}); + const formHistoryEntries = await lazy.FormHistory.getAutoCompleteResults( + searchString, + params, + () => this._queryInstance != instance + ); + + if (this._queryInstance == instance) { + target.sendAsyncMessage("FormHistory:AutoCompleteSearchResults", { + id, + results: { + formHistoryEntries, + externalEntries: [], + }, + }); + } + } + + #onFormHistoryRemoveEntry({ inputName, value, guid }) { + lazy.FormHistory.update({ + op: "remove", + fieldname: inputName, + value, + guid, + }); + } +} diff --git a/toolkit/components/satchel/FormScenarios.sys.mjs b/toolkit/components/satchel/FormScenarios.sys.mjs new file mode 100644 index 0000000000..505b5443ec --- /dev/null +++ b/toolkit/components/satchel/FormScenarios.sys.mjs @@ -0,0 +1,87 @@ +/* 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 { FormLikeFactory } from "resource://gre/modules/FormLikeFactory.sys.mjs"; +import { SignUpFormRuleset } from "resource://gre/modules/SignUpFormRuleset.sys.mjs"; +import { FirefoxRelayUtils } from "resource://gre/modules/FirefoxRelayUtils.sys.mjs"; +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; + +export class FormScenarios { + /** + * Caches the scores when running the SignUpFormRuleset against a form + */ + static #cachedSignUpFormScore = new WeakMap(); + + /** + * Detect usage scenarios of the form. + * + * @param {object} options named options + * @param {HTMLInputElement} [options.input] where current focus is + * @param {FormLike} [options.form] + * + * @returns {Array} detected scenario names + */ + static detect({ input, form }) { + const scenarios = {}; + + if (!FormScenarios.signupDetectionEnabled) { + return scenarios; + } + + // Running simple heuristics first, because running the SignUpFormRuleset is expensive + if ( + input && + // At the moment Relay integration is the only interested party in "sign up form", + // so we optimize a bit by checking if it's enabled or not. + FirefoxRelayUtils.isRelayInterestedField(input) + ) { + form ??= FormLikeFactory.findRootForField(input); + + scenarios.signUpForm = FormScenarios.#isProbablyASignUpForm(form); + } + + return scenarios; + } + + /** + * Determine if the form is a sign-up form. + * This is done by running the rules of the Fathom SignUpFormRuleset against the form and calucating a score between 0 and 1. + * It's considered a sign-up form, if the score is higher than the confidence threshold (default=0.75) + * + * @param {HTMLFormElement} formElement + * @returns {boolean} returns true if the calculcated score is higher than the confidenceThreshold + */ + static #isProbablyASignUpForm(formElement) { + let score = FormScenarios.#cachedSignUpFormScore.get(formElement); + if (!score) { + TelemetryStopwatch.start("PWMGR_SIGNUP_FORM_DETECTION_MS"); + try { + const { rules, type } = SignUpFormRuleset; + const results = rules.against(formElement); + score = results.get(formElement).scoreFor(type); + TelemetryStopwatch.finish("PWMGR_SIGNUP_FORM_DETECTION_MS"); + } finally { + if (TelemetryStopwatch.running("PWMGR_SIGNUP_FORM_DETECTION_MS")) { + TelemetryStopwatch.cancel("PWMGR_SIGNUP_FORM_DETECTION_MS"); + } + } + FormScenarios.#cachedSignUpFormScore.set(formElement, score); + } + + const threshold = FormScenarios.signupDetectionConfidenceThreshold; + return score > threshold; + } +} +XPCOMUtils.defineLazyPreferenceGetter( + FormScenarios, + "signupDetectionConfidenceThreshold", + "signon.signupDetection.confidenceThreshold", + "0.75" +); +XPCOMUtils.defineLazyPreferenceGetter( + FormScenarios, + "signupDetectionEnabled", + "signon.signupDetection.enabled", + true +); diff --git a/toolkit/components/satchel/SignUpFormRuleset.sys.mjs b/toolkit/components/satchel/SignUpFormRuleset.sys.mjs new file mode 100644 index 0000000000..7a42880fdb --- /dev/null +++ b/toolkit/components/satchel/SignUpFormRuleset.sys.mjs @@ -0,0 +1,589 @@ +/* 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 sign up + * + * This is developed out-of-tree at https://github.com/mozilla-services/fathom-login-forms, + * 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. + */ + +import { + dom, + out, + rule, + ruleset, + score, + type, + element, + utils, +} from "resource://gre/modules/third_party/fathom/fathom.mjs"; + +let { isVisible, attributesMatch, setDefault } = utils; + +const DEVELOPMENT = false; + +/** + * --- START OF CODE FROM TRAINING REPOSITORY --- + */ +const coefficients = { + form: new Map([ + ["formAttributesMatchRegisterRegex", 0.4614015519618988], + ["formAttributesMatchLoginRegex", -2.608457326889038], + ["formAttributesMatchSubscriptionRegex", -3.253319501876831], + ["formAttributesMatchLoginAndRegisterRegex", 3.6423728466033936], + ["formHasAcNewPassword", 2.214113473892212], + ["formHasAcCurrentPassword", -0.43707895278930664], + ["formHasEmailField", 1.760241150856018], + ["formHasUsernameField", 1.1527059078216553], + ["formHasPasswordField", 1.6670876741409302], + ["formHasFirstOrLastNameField", 0.9517516493797302], + ["formHasRegisterButton", 1.574048638343811], + ["formHasLoginButton", -1.1688978672027588], + ["formHasSubscribeButton", -0.26299405097961426], + ["formHasContinueButton", 2.3797709941864014], + ["formHasTermsAndConditionsHyperlink", 1.764896035194397], + ["formHasPasswordForgottenHyperlink", -0.32138824462890625], + ["formHasAlreadySignedUpHyperlink", 3.160510301589966], + ["closestElementIsEmailLabelLike", 1.0336143970489502], + ["formHasRememberMeCheckbox", -1.2176686525344849], + ["formHasSubcriptionCheckbox", 0.6100747585296631], + ["docTitleMatchesRegisterRegex", 0.680654764175415], + ["docTitleMatchesEditProfileRegex", -4.104133605957031], + ["closestHeaderMatchesRegisterRegex", 1.3462989330291748], + ["closestHeaderMatchesLoginRegex", -0.1804502159357071], + ["closestHeaderMatchesSubscriptionRegex", -1.3057124614715576], + ]), +}; + +const biases = [["form", -4.402400970458984]]; + +const loginRegex = + /login|log-in|log_in|log in|signon|sign-on|sign_on|sign on|signin|sign-in|sign_in|sign in|einloggen|anmelden|logon|log-on|log_on|log on|Войти|ورود|登录|Přihlásit se|Přihlaste|Авторизоваться|Авторизация|entrar|ログイン|로그인|inloggen|Συνδέσου|accedi|ログオン|Giriş Yap|登入|connecter|connectez-vous|Connexion|Вход|inicia/i; +const registerRegex = + /regist|sign up|signup|sign-up|sign_up|join|new|登録|neu|erstellen|設定|신규|Créer|Nouveau|baru|nouă|nieuw|create[a-zA-Z\s]+account|create[a-zA-Z\s]+profile|activate[a-zA-Z\s]+account|Zugang anlegen|Angaben prüfen|Konto erstellen|ثبت نام|登録|注册|cadastr|Зарегистрироваться|Регистрация|Bellige alynmak|تسجيل|ΕΓΓΡΑΦΗΣ|Εγγραφή|Créer mon compte|Créer un compte|Mendaftar|가입하기|inschrijving|Zarejestruj się|Deschideți un cont|Создать аккаунт|ร่วม|Üye Ol|ساخت حساب کاربری|Schrijf je|S'inscrire/i; +const emailRegex = /mail/i; +const usernameRegex = /user|member/i; +const nameRegex = /first|last|middle/i; +const subscriptionRegex = + /subscri|trial|offer|information|angebote|probe|ニュースレター|abonn|promotion|news/i; +const termsAndConditionsRegex = + /terms|condition|rules|policy|privacy|nutzungsbedingungen|AGB|richtlinien|datenschutz|términos|condiciones/i; +const pwForgottenRegex = + /forgot|reset|set password|vergessen|vergeten|oublié|dimenticata|Esqueceu|esqueci|Забыли|忘记|找回|Zapomenuté|lost|忘れた|忘れられた|忘れの方|재설정|찾기|help|فراموشی| را فراموش کرده اید|Восстановить|Unuttu|perdus|重新設定|recover|remind|request|restore|trouble|olvidada/i; +const continueRegex = + /continue|go on|weiter|fortfahren|ga verder|next|continuar/i; +const rememberMeRegex = + /remember|stay|speichern|merken|bleiben|auto_login|auto-login|auto login|ricordami|manter|mantenha|savelogin|keep me logged in|keep me signed in|save email address|save id|stay signed in|次回からログオンIDの入力を省略する|メールアドレスを保存する|を保存|아이디저장|아이디 저장|로그인 상태 유지|lembrar|mantenha-me conectado|Запомни меня|запомнить меня|Запомните меня|Не спрашивать в следующий раз|下次自动登录|记住我|recordar|angemeldet bleiben/i; +const alreadySignedUpRegex = /already|bereits|schon|ya tienes cuenta/i; +const editProfile = /edit/i; + +function createRuleset(coeffs, biases) { + let descendantsCache; + let surroundingNodesCache; + + /** + * Check document characteristics + */ + function docTitleMatchesRegisterRegex(fnode) { + const docTitle = fnode.element.ownerDocument.title; + return checkValueAgainstRegex(docTitle, registerRegex); + } + function docTitleMatchesEditProfileRegex(fnode) { + const docTitle = fnode.element.ownerDocument.title; + return checkValueAgainstRegex(docTitle, editProfile); + } + + /** + * Check header + */ + function closestHeaderMatchesLoginRegex(fnode) { + return closestHeaderMatchesPredicate(fnode.element, header => + checkValueAgainstRegex(header.innerText, loginRegex) + ); + } + function closestHeaderMatchesRegisterRegex(fnode) { + return closestHeaderMatchesPredicate(fnode.element, header => + checkValueAgainstRegex(header.innerText, registerRegex) + ); + } + function closestHeaderMatchesSubscriptionRegex(fnode) { + return closestHeaderMatchesPredicate(fnode.element, header => + checkValueAgainstRegex(header.innerText, subscriptionRegex) + ); + } + + /** + * Check checkboxes + */ + function formHasRememberMeCheckbox(fnode) { + return elementHasRegexMatchingCheckbox(fnode.element, rememberMeRegex); + } + function formHasSubcriptionCheckbox(fnode) { + return elementHasRegexMatchingCheckbox(fnode.element, subscriptionRegex); + } + + /** + * Check input fields + */ + function formHasFirstOrLastNameField(fnode) { + const acValues = ["name", "given-name", "family-name"]; + return elementHasPredicateMatchingInput( + fnode.element, + elem => + atLeastOne(acValues.filter(ac => elem.autocomplete == ac)) || + inputFieldMatchesPredicate(elem, attr => + checkValueAgainstRegex(attr, nameRegex) + ) + ); + } + function formHasEmailField(fnode) { + return elementHasPredicateMatchingInput( + fnode.element, + elem => + elem.autocomplete == "email" || + elem.type == "email" || + inputFieldMatchesPredicate(elem, attr => + checkValueAgainstRegex(attr, emailRegex) + ) + ); + } + function formHasUsernameField(fnode) { + return elementHasPredicateMatchingInput( + fnode.element, + elem => + elem.autocomplete == "username" || + inputFieldMatchesPredicate(elem, attr => + checkValueAgainstRegex(attr, usernameRegex) + ) + ); + } + function formHasPasswordField(fnode) { + const acValues = ["current-password", "new-password"]; + return elementHasPredicateMatchingInput( + fnode.element, + elem => + atLeastOne(acValues.filter(ac => elem.autocomplete == ac)) || + elem.type == "password" + ); + } + + /** + * Check autocomplete values + */ + function formHasAcCurrentPassword(fnode) { + return inputFieldMatchesSelector( + fnode.element, + "autocomplete=current-password" + ); + } + function formHasAcNewPassword(fnode) { + return inputFieldMatchesSelector( + fnode.element, + "autocomplete=new-password" + ); + } + + /** + * Check hyperlinks within form + */ + function formHasTermsAndConditionsHyperlink(fnode) { + return elementHasPredicateMatchingHyperlink( + fnode.element, + termsAndConditionsRegex + ); + } + function formHasPasswordForgottenHyperlink(fnode) { + return elementHasPredicateMatchingHyperlink( + fnode.element, + pwForgottenRegex + ); + } + function formHasAlreadySignedUpHyperlink(fnode) { + return elementHasPredicateMatchingHyperlink( + fnode.element, + alreadySignedUpRegex + ); + } + + /** + * Check labels + */ + function closestElementIsEmailLabelLike(fnode) { + return elementHasPredicateMatchingInput(fnode.element, elem => + previousSiblingLabelMatchesRegex(elem, emailRegex) + ); + } + + /** + * Check buttons + */ + function formHasRegisterButton(fnode) { + return elementHasPredicateMatchingButton( + fnode.element, + button => + checkValueAgainstRegex(button.innerText, registerRegex) || + buttonMatchesPredicate(button, attr => + checkValueAgainstRegex(attr, registerRegex) + ) + ); + } + function formHasLoginButton(fnode) { + return elementHasPredicateMatchingButton( + fnode.element, + button => + checkValueAgainstRegex(button.innerText, loginRegex) || + buttonMatchesPredicate(button, attr => + checkValueAgainstRegex(attr, loginRegex) + ) + ); + } + function formHasContinueButton(fnode) { + return elementHasPredicateMatchingButton( + fnode.element, + button => + checkValueAgainstRegex(button.innerText, continueRegex) || + buttonMatchesPredicate(button, attr => + checkValueAgainstRegex(attr, continueRegex) + ) + ); + } + function formHasSubscribeButton(fnode) { + return elementHasPredicateMatchingButton( + fnode.element, + button => + checkValueAgainstRegex(button.innerText, subscriptionRegex) || + buttonMatchesPredicate(button, attr => + checkValueAgainstRegex(attr, subscriptionRegex) + ) + ); + } + + /** + * Check form attributes + */ + function formAttributesMatchRegisterRegex(fnode) { + return formMatchesPredicate(fnode.element, attr => + checkValueAgainstRegex(attr, registerRegex) + ); + } + function formAttributesMatchLoginRegex(fnode) { + return formMatchesPredicate(fnode.element, attr => + checkValueAgainstRegex(attr, loginRegex) + ); + } + function formAttributesMatchSubscriptionRegex(fnode) { + return formMatchesPredicate(fnode.element, attr => + checkValueAgainstRegex(attr, subscriptionRegex) + ); + } + function formAttributesMatchLoginAndRegisterRegex(fnode) { + return formMatchesPredicate(fnode.element, attr => + checkValueAgainstAllRegex(attr, [registerRegex, loginRegex]) + ); + } + + /** + * HELPER FUNCTIONS + */ + function elementMatchesPredicate(element, predicate, additional = []) { + return attributesMatch( + element, + predicate, + ["id", "name", "className"].concat(additional) + ); + } + function formMatchesPredicate(element, predicate) { + return elementMatchesPredicate(element, predicate, ["action"]); + } + function inputFieldMatchesPredicate(element, predicate) { + return elementMatchesPredicate(element, predicate, ["placeholder"]); + } + function inputFieldMatchesSelector(element, selector) { + return atLeastOne(getElementDescendants(element, `input[${selector}]`)); + } + function buttonMatchesPredicate(element, predicate) { + return elementMatchesPredicate(element, predicate, [ + "value", + "id", + "title", + ]); + } + function elementHasPredicateMatchingDescendant(element, selector, predicate) { + const matchingElements = getElementDescendants(element, selector); + return matchingElements.some(predicate); + } + function elementHasPredicateMatchingHeader(element, predicate) { + return ( + elementHasPredicateMatchingDescendant( + element, + "h1,h2,h3,h4,h5,h6", + predicate + ) || + elementHasPredicateMatchingDescendant( + element, + "div[class*=heading],div[class*=header],div[class*=title],header", + predicate + ) + ); + } + function elementHasPredicateMatchingButton(element, predicate) { + return elementHasPredicateMatchingDescendant( + element, + "button,input[type=submit],input[type=button]", + predicate + ); + } + function elementHasPredicateMatchingInput(element, predicate) { + return elementHasPredicateMatchingDescendant(element, "input", predicate); + } + function elementHasPredicateMatchingHyperlink(element, regexExp) { + return elementHasPredicateMatchingDescendant( + element, + "a", + link => + previousSiblingLabelMatchesRegex(link, regexExp) || + checkValueAgainstRegex(link.innerText, regexExp) || + elementMatchesPredicate( + link, + attr => checkValueAgainstRegex(attr, regexExp), + ["href"] + ) || + nextSiblingLabelMatchesRegex(link, regexExp) + ); + } + function elementHasRegexMatchingCheckbox(element, regexExp) { + return elementHasPredicateMatchingDescendant( + element, + "input[type=checkbox], div[class*=checkbox]", + box => + elementMatchesPredicate(box, attr => + checkValueAgainstRegex(attr, regexExp) + ) || nextSiblingLabelMatchesRegex(box, regexExp) + ); + } + + function nextSiblingLabelMatchesRegex(element, regexExp) { + let nextElem = element.nextElementSibling; + if (nextElem && nextElem.tagName == "LABEL") { + return checkValueAgainstRegex(nextElem.innerText, regexExp); + } + let closestElem = closestElementFollowing(element, "label"); + return closestElem + ? checkValueAgainstRegex(closestElem.innerText, regexExp) + : false; + } + + function previousSiblingLabelMatchesRegex(element, regexExp) { + let previousElem = element.previousElementSibling; + if (previousElem && previousElem.tagName == "LABEL") { + return checkValueAgainstRegex(previousElem.innerText, regexExp); + } + let closestElem = closestElementPreceding(element, "label"); + return closestElem + ? checkValueAgainstRegex(closestElem.innerText, regexExp) + : false; + } + function getElementDescendants(element, selector) { + const selectorToDescendants = setDefault( + descendantsCache, + element, + () => new Map() + ); + + return setDefault(selectorToDescendants, selector, () => + Array.from(element.querySelectorAll(selector)) + ); + } + + function clearCache() { + descendantsCache = new WeakMap(); + surroundingNodesCache = new WeakMap(); + } + function closestHeaderMatchesPredicate(element, predicate) { + return ( + elementHasPredicateMatchingHeader(element, predicate) || + closestHeaderAboveMatchesPredicate(element, predicate) + ); + } + function closestHeaderAboveMatchesPredicate(element, predicate) { + let closestHeader = closestElementPreceding(element, "h1,h2,h3,h4,h5,h6"); + + if (closestHeader !== null) { + if (predicate(closestHeader)) { + return true; + } + } + closestHeader = closestElementPreceding( + element, + "div[class*=heading],div[class*=header],div[class*=title],header" + ); + return closestHeader ? predicate(closestHeader) : false; + } + function closestElementPreceding(element, selector) { + return getSurroundingNodes(element, selector).precedingNode; + } + function closestElementFollowing(element, selector) { + return getSurroundingNodes(element, selector).followingNode; + } + function getSurroundingNodes(element, selector) { + const selectorToSurroundingNodes = setDefault( + surroundingNodesCache, + element, + () => new Map() + ); + + return setDefault(selectorToSurroundingNodes, selector, () => { + let elements = getElementDescendants(element.ownerDocument, selector); + let followingIndex = closestFollowingNodeIndex(elements, element); + let precedingIndex = followingIndex - 1; + let preceding = precedingIndex < 0 ? null : elements[precedingIndex]; + let following = + followingIndex == elements.length ? null : elements[followingIndex]; + return { precedingNode: preceding, followingNode: following }; + }); + } + function closestFollowingNodeIndex(elements, element) { + let low = 0; + let high = elements.length; + while (low < high) { + let i = (low + high) >>> 1; + if ( + element.compareDocumentPosition(elements[i]) & + Node.DOCUMENT_POSITION_PRECEDING + ) { + low = i + 1; + } else { + high = i; + } + } + return low; + } + + function checkValueAgainstAllRegex(value, regexExp = []) { + return regexExp.every(reg => checkValueAgainstRegex(value, reg)); + } + + function checkValueAgainstRegex(value, regexExp) { + return value ? regexExp.test(value) : false; + } + function atLeastOne(iter) { + return iter.length >= 1; + } + + /** + * CREATION OF RULESET + */ + const rules = ruleset( + [ + rule( + DEVELOPMENT ? dom("form").when(isVisible) : element("form"), + type("form").note(clearCache) + ), + // Check form attributes + rule(type("form"), score(formAttributesMatchRegisterRegex), { + name: "formAttributesMatchRegisterRegex", + }), + rule(type("form"), score(formAttributesMatchLoginRegex), { + name: "formAttributesMatchLoginRegex", + }), + rule(type("form"), score(formAttributesMatchSubscriptionRegex), { + name: "formAttributesMatchSubscriptionRegex", + }), + rule(type("form"), score(formAttributesMatchLoginAndRegisterRegex), { + name: "formAttributesMatchLoginAndRegisterRegex", + }), + // Check autocomplete attributes + rule(type("form"), score(formHasAcCurrentPassword), { + name: "formHasAcCurrentPassword", + }), + rule(type("form"), score(formHasAcNewPassword), { + name: "formHasAcNewPassword", + }), + // Check input fields + rule(type("form"), score(formHasEmailField), { + name: "formHasEmailField", + }), + rule(type("form"), score(formHasUsernameField), { + name: "formHasUsernameField", + }), + rule(type("form"), score(formHasPasswordField), { + name: "formHasPasswordField", + }), + rule(type("form"), score(formHasFirstOrLastNameField), { + name: "formHasFirstOrLastNameField", + }), + // Check buttons + rule(type("form"), score(formHasRegisterButton), { + name: "formHasRegisterButton", + }), + rule(type("form"), score(formHasLoginButton), { + name: "formHasLoginButton", + }), + rule(type("form"), score(formHasContinueButton), { + name: "formHasContinueButton", + }), + rule(type("form"), score(formHasSubscribeButton), { + name: "formHasSubscribeButton", + }), + // Check hyperlinks + rule(type("form"), score(formHasTermsAndConditionsHyperlink), { + name: "formHasTermsAndConditionsHyperlink", + }), + rule(type("form"), score(formHasPasswordForgottenHyperlink), { + name: "formHasPasswordForgottenHyperlink", + }), + rule(type("form"), score(formHasAlreadySignedUpHyperlink), { + name: "formHasAlreadySignedUpHyperlink", + }), + // Check labels + rule(type("form"), score(closestElementIsEmailLabelLike), { + name: "closestElementIsEmailLabelLike", + }), + // Check checkboxes + rule(type("form"), score(formHasRememberMeCheckbox), { + name: "formHasRememberMeCheckbox", + }), + rule(type("form"), score(formHasSubcriptionCheckbox), { + name: "formHasSubcriptionCheckbox", + }), + // Check header + rule(type("form"), score(closestHeaderMatchesRegisterRegex), { + name: "closestHeaderMatchesRegisterRegex", + }), + rule(type("form"), score(closestHeaderMatchesLoginRegex), { + name: "closestHeaderMatchesLoginRegex", + }), + rule(type("form"), score(closestHeaderMatchesSubscriptionRegex), { + name: "closestHeaderMatchesSubscriptionRegex", + }), + // Check doc title + rule(type("form"), score(docTitleMatchesRegisterRegex), { + name: "docTitleMatchesRegisterRegex", + }), + rule(type("form"), score(docTitleMatchesEditProfileRegex), { + name: "docTitleMatchesEditProfileRegex", + }), + rule(type("form"), out("form")), + ], + coeffs, + biases + ); + return rules; +} + +/** + * --- END OF CODE FROM TRAINING REPOSITORY --- + */ + +export const SignUpFormRuleset = { + type: "form", + rules: createRuleset([...coefficients.form], biases), +}; diff --git a/toolkit/components/satchel/components.conf b/toolkit/components/satchel/components.conf new file mode 100644 index 0000000000..d843b869d6 --- /dev/null +++ b/toolkit/components/satchel/components.conf @@ -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/. + +Classes = [ + { + 'cid': '{895db6c7-dbdf-40ea-9f64-b175033243dc}', + 'contract_ids': [ + '@mozilla.org/satchel/form-fill-controller;1', + '@mozilla.org/autocomplete/search;1?name=form-history', + ], + 'type': 'nsFormFillController', + 'constructor': 'nsFormFillController::GetSingleton', + 'headers': ['/toolkit/components/satchel/nsFormFillController.h'], + 'categories': {'app-startup': 'FormFillController'}, + }, + + { + 'cid': '{c11c21b2-71c9-4f87-a0f8-5e13f50495fd}', + 'contract_ids': ['@mozilla.org/satchel/form-autocomplete;1'], + 'esModule': 'resource://gre/modules/FormAutoComplete.sys.mjs', + 'constructor': 'FormAutoComplete', + }, + { + 'cid': '{3a0012eb-007f-4bb8-aa81-a07385f77a25}', + 'contract_ids': ['@mozilla.org/satchel/form-history-startup;1'], + 'esModule': 'resource://gre/modules/FormHistoryStartup.sys.mjs', + 'constructor': 'FormHistoryStartup', + 'categories': {'profile-after-change': 'formHistoryStartup'}, + }, +] diff --git a/toolkit/components/satchel/integrations/FirefoxRelay.sys.mjs b/toolkit/components/satchel/integrations/FirefoxRelay.sys.mjs new file mode 100644 index 0000000000..8f88373763 --- /dev/null +++ b/toolkit/components/satchel/integrations/FirefoxRelay.sys.mjs @@ -0,0 +1,647 @@ +/* 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 { FirefoxRelayTelemetry } from "resource://gre/modules/FirefoxRelayTelemetry.mjs"; +import { + LoginHelper, + OptInFeature, + ParentAutocompleteOption, +} from "resource://gre/modules/LoginHelper.sys.mjs"; +import { TelemetryUtils } from "resource://gre/modules/TelemetryUtils.sys.mjs"; +import { showConfirmation } from "resource://gre/modules/FillHelpers.sys.mjs"; + +const lazy = {}; + +// Static configuration +const gConfig = (function () { + const baseUrl = Services.prefs.getStringPref( + "signon.firefoxRelay.base_url", + undefined + ); + return { + scope: ["profile", "https://identity.mozilla.com/apps/relay"], + addressesUrl: baseUrl + `relayaddresses/`, + acceptTermsUrl: baseUrl + `terms-accepted-user/`, + profilesUrl: baseUrl + `profiles/`, + learnMoreURL: Services.urlFormatter.formatURLPref( + "signon.firefoxRelay.learn_more_url" + ), + manageURL: Services.urlFormatter.formatURLPref( + "signon.firefoxRelay.manage_url" + ), + relayFeaturePref: "signon.firefoxRelay.feature", + termsOfServiceUrl: Services.urlFormatter.formatURLPref( + "signon.firefoxRelay.terms_of_service_url" + ), + privacyPolicyUrl: Services.urlFormatter.formatURLPref( + "signon.firefoxRelay.privacy_policy_url" + ), + }; +})(); + +ChromeUtils.defineLazyGetter(lazy, "log", () => + LoginHelper.createLogger("FirefoxRelay") +); +ChromeUtils.defineLazyGetter(lazy, "fxAccounts", () => + ChromeUtils.importESModule( + "resource://gre/modules/FxAccounts.sys.mjs" + ).getFxAccountsSingleton() +); +ChromeUtils.defineLazyGetter(lazy, "strings", function () { + return new Localization([ + "branding/brand.ftl", + "browser/firefoxRelay.ftl", + "toolkit/branding/accounts.ftl", + "toolkit/branding/brandings.ftl", + ]); +}); + +if (Services.appinfo.processType !== Services.appinfo.PROCESS_TYPE_DEFAULT) { + throw new Error("FirefoxRelay.sys.mjs should only run in the parent process"); +} + +// Using 418 to avoid conflict with other standard http error code +const AUTH_TOKEN_ERROR_CODE = 418; + +let gFlowId; + +async function getRelayTokenAsync() { + try { + return await lazy.fxAccounts.getOAuthToken({ scope: gConfig.scope }); + } catch (e) { + console.error(`There was an error getting the user's token: ${e.message}`); + return undefined; + } +} + +async function hasFirefoxAccountAsync() { + if (!lazy.fxAccounts.constructor.config.isProductionConfig()) { + return false; + } + return lazy.fxAccounts.hasLocalSession(); +} + +async function fetchWithReauth( + browser, + createRequest, + canGetFreshOAuthToken = true +) { + const relayToken = await getRelayTokenAsync(); + if (!relayToken) { + if (browser) { + await showErrorAsync(browser, "firefox-relay-must-login-to-account"); + } + return undefined; + } + + const headers = new Headers({ + Authorization: `Bearer ${relayToken}`, + Accept: "application/json", + "Accept-Language": Services.locale.requestedLocales, + "Content-Type": "application/json", + }); + + const request = createRequest(headers); + const response = await fetch(request); + + if (canGetFreshOAuthToken && response.status == 401) { + await lazy.fxAccounts.removeCachedOAuthToken({ token: relayToken }); + return fetchWithReauth(browser, createRequest, false); + } + return response; +} + +async function getReusableMasksAsync(browser, _origin) { + const response = await fetchWithReauth( + browser, + headers => + new Request(gConfig.addressesUrl, { + method: "GET", + headers, + }) + ); + + if (!response) { + // fetchWithReauth only returns undefined if login / obtaining a token failed. + // Otherwise, it will return a response object. + return [undefined, AUTH_TOKEN_ERROR_CODE]; + } + + if (response.ok) { + return [await response.json(), response.status]; + } + + lazy.log.error( + `failed to find reusable Relay masks: ${response.status}:${response.statusText}` + ); + await showErrorAsync(browser, "firefox-relay-get-reusable-masks-failed", { + status: response.status, + }); + + return [undefined, response.status]; +} + +/** + * Show localized notification. + * + * @param {*} browser + * @param {*} messageId from browser/firefoxRelay.ftl + * @param {object} messageArgs + */ +async function showErrorAsync(browser, messageId, messageArgs) { + const { PopupNotifications } = browser.ownerGlobal.wrappedJSObject; + const [message] = await lazy.strings.formatValues([ + { id: messageId, args: messageArgs }, + ]); + PopupNotifications.show( + browser, + "relay-integration-error", + message, + "password-notification-icon", + null, + null, + { + autofocus: true, + removeOnDismissal: true, + popupIconURL: "chrome://browser/content/logos/relay.svg", + learnMoreURL: gConfig.learnMoreURL, + } + ); +} + +function customizeNotificationHeader(notification) { + if (!notification) { + return; + } + const document = notification.owner.panel.ownerDocument; + const description = document.querySelector( + `description[popupid=${notification.id}]` + ); + const headerTemplate = document.getElementById("firefox-relay-header"); + description.replaceChildren(headerTemplate.firstChild.cloneNode(true)); +} + +async function formatMessages(...ids) { + for (let i in ids) { + if (typeof ids[i] == "string") { + ids[i] = { id: ids[i] }; + } + } + + const messages = await lazy.strings.formatMessages(ids); + return messages.map(message => { + if (message.attributes) { + return message.attributes.reduce( + (result, { name, value }) => ({ ...result, [name]: value }), + {} + ); + } + return message.value; + }); +} + +async function showReusableMasksAsync(browser, origin, error) { + const [reusableMasks, status] = await getReusableMasksAsync(browser, origin); + if (!reusableMasks) { + FirefoxRelayTelemetry.recordRelayReusePanelEvent("shown", gFlowId, status); + return null; + } + + let fillUsername; + const fillUsernamePromise = new Promise(resolve => (fillUsername = resolve)); + const [getUnlimitedMasksStrings] = await formatMessages( + "firefox-relay-get-unlimited-masks" + ); + const getUnlimitedMasks = { + label: getUnlimitedMasksStrings.label, + accessKey: getUnlimitedMasksStrings.accesskey, + dismiss: true, + async callback() { + FirefoxRelayTelemetry.recordRelayReusePanelEvent( + "get_unlimited_masks", + gFlowId + ); + browser.ownerGlobal.openWebLinkIn(gConfig.manageURL, "tab"); + }, + }; + + let notification; + + function getReusableMasksList() { + return notification?.owner.panel.getElementsByClassName( + "reusable-relay-masks" + )[0]; + } + + function notificationShown() { + if (!notification) { + return; + } + + customizeNotificationHeader(notification); + + notification.owner.panel.getElementsByClassName( + "error-message" + )[0].textContent = error.detail || ""; + + // rebuild "reuse mask" buttons list + const list = getReusableMasksList(); + list.innerHTML = ""; + + const document = list.ownerDocument; + const fragment = document.createDocumentFragment(); + reusableMasks + .filter(mask => mask.enabled) + .forEach(mask => { + const button = document.createElement("button"); + + const maskFullAddress = document.createElement("span"); + maskFullAddress.textContent = mask.full_address; + button.appendChild(maskFullAddress); + + const maskDescription = document.createElement("span"); + maskDescription.textContent = + mask.description || mask.generated_for || mask.used_on; + button.appendChild(maskDescription); + + button.addEventListener( + "click", + () => { + notification.remove(); + lazy.log.info("Reusing Relay mask"); + fillUsername(mask.full_address); + showConfirmation( + browser, + "confirmation-hint-firefox-relay-mask-reused" + ); + FirefoxRelayTelemetry.recordRelayReusePanelEvent( + "reuse_mask", + gFlowId + ); + }, + { once: true } + ); + fragment.appendChild(button); + }); + list.appendChild(fragment); + } + + function notificationRemoved() { + const list = getReusableMasksList(); + list.innerHTML = ""; + } + + function onNotificationEvent(event) { + switch (event) { + case "removed": + notificationRemoved(); + break; + case "shown": + notificationShown(); + FirefoxRelayTelemetry.recordRelayReusePanelEvent("shown", gFlowId); + break; + } + } + + const { PopupNotifications } = browser.ownerGlobal.wrappedJSObject; + notification = PopupNotifications.show( + browser, + "relay-integration-reuse-masks", + "", // content is provided after popup shown + "password-notification-icon", + getUnlimitedMasks, + [], + { + autofocus: true, + removeOnDismissal: true, + eventCallback: onNotificationEvent, + } + ); + + return fillUsernamePromise; +} + +async function generateUsernameAsync(browser, origin) { + const body = JSON.stringify({ + enabled: true, + description: origin.substr(0, 64), + generated_for: origin.substr(0, 255), + used_on: origin, + }); + + const response = await fetchWithReauth( + browser, + headers => + new Request(gConfig.addressesUrl, { + method: "POST", + headers, + body, + }) + ); + + if (!response) { + FirefoxRelayTelemetry.recordRelayUsernameFilledEvent( + "shown", + gFlowId, + AUTH_TOKEN_ERROR_CODE + ); + return undefined; + } + + if (response.ok) { + lazy.log.info(`generated Relay mask`); + const result = await response.json(); + showConfirmation(browser, "confirmation-hint-firefox-relay-mask-created"); + return result.full_address; + } + + if (response.status == 403) { + const error = await response.json(); + if (error?.error_code == "free_tier_limit") { + FirefoxRelayTelemetry.recordRelayUsernameFilledEvent( + "shown", + gFlowId, + error?.error_code + ); + return showReusableMasksAsync(browser, origin, error); + } + } + + lazy.log.error( + `failed to generate Relay mask: ${response.status}:${response.statusText}` + ); + + await showErrorAsync(browser, "firefox-relay-mask-generation-failed", { + status: response.status, + }); + + FirefoxRelayTelemetry.recordRelayReusePanelEvent( + "shown", + gFlowId, + response.status + ); + + return undefined; +} + +function isSignup(scenarioName) { + return scenarioName == "SignUpFormScenario"; +} + +class RelayOffered { + async *autocompleteItemsAsync(_origin, scenarioName, hasInput) { + if ( + !hasInput && + isSignup(scenarioName) && + (await hasFirefoxAccountAsync()) && + !Services.prefs.prefIsLocked("signon.firefoxRelay.feature") + ) { + const [title, subtitle] = await formatMessages( + "firefox-relay-opt-in-title-1", + "firefox-relay-opt-in-subtitle-1" + ); + yield new ParentAutocompleteOption( + "chrome://browser/content/logos/relay.svg", + title, + subtitle, + "PasswordManager:offerRelayIntegration", + { + telemetry: { + flowId: gFlowId, + scenarioName, + }, + } + ); + FirefoxRelayTelemetry.recordRelayOfferedEvent( + "shown", + gFlowId, + scenarioName + ); + } + } + + async notifyServerTermsAcceptedAsync(browser) { + const response = await fetchWithReauth( + browser, + headers => + new Request(gConfig.acceptTermsUrl, { + method: "POST", + headers, + }) + ); + + if (!response?.ok) { + lazy.log.error( + `failed to notify server that terms are accepted : ${response?.status}:${response?.statusText}` + ); + + let error; + try { + error = await response?.json(); + } catch {} + await showErrorAsync(browser, "firefox-relay-mask-generation-failed", { + status: error?.detail || response.status, + }); + return false; + } + + return true; + } + + async offerRelayIntegration(feature, browser, origin) { + const fxaUser = await lazy.fxAccounts.getSignedInUser(); + + if (!fxaUser) { + return null; + } + const { PopupNotifications } = browser.ownerGlobal.wrappedJSObject; + let fillUsername; + const fillUsernamePromise = new Promise( + resolve => (fillUsername = resolve) + ); + const [enableStrings, disableStrings, postponeStrings] = + await formatMessages( + "firefox-relay-opt-in-confirmation-enable-button", + "firefox-relay-opt-in-confirmation-disable", + "firefox-relay-opt-in-confirmation-postpone" + ); + const enableIntegration = { + label: enableStrings.label, + accessKey: enableStrings.accesskey, + dismiss: true, + callback: async () => { + lazy.log.info("user opted in to Firefox Relay integration"); + // Capture the flowId here since async operations might take some time to resolve + // and by then gFlowId might have another value + const flowId = gFlowId; + if (await this.notifyServerTermsAcceptedAsync(browser)) { + feature.markAsEnabled(); + FirefoxRelayTelemetry.recordRelayOptInPanelEvent("enabled", flowId); + fillUsername(await generateUsernameAsync(browser, origin)); + } + }, + }; + const postpone = { + label: postponeStrings.label, + accessKey: postponeStrings.accesskey, + dismiss: true, + callback() { + lazy.log.info( + "user decided not to decide about Firefox Relay integration" + ); + feature.markAsOffered(); + FirefoxRelayTelemetry.recordRelayOptInPanelEvent("postponed", gFlowId); + }, + }; + const disableIntegration = { + label: disableStrings.label, + accessKey: disableStrings.accesskey, + dismiss: true, + callback() { + lazy.log.info("user opted out from Firefox Relay integration"); + feature.markAsDisabled(); + FirefoxRelayTelemetry.recordRelayOptInPanelEvent("disabled", gFlowId); + }, + }; + let notification; + feature.markAsOffered(); + notification = PopupNotifications.show( + browser, + "relay-integration-offer", + "", // content is provided after popup shown + "password-notification-icon", + enableIntegration, + [postpone, disableIntegration], + { + autofocus: true, + removeOnDismissal: true, + learnMoreURL: gConfig.learnMoreURL, + eventCallback: event => { + switch (event) { + case "shown": + customizeNotificationHeader(notification); + const document = notification.owner.panel.ownerDocument; + const tosLink = document.getElementById( + "firefox-relay-offer-tos-url" + ); + tosLink.href = gConfig.termsOfServiceUrl; + const privacyLink = document.getElementById( + "firefox-relay-offer-privacy-url" + ); + privacyLink.href = gConfig.privacyPolicyUrl; + const content = document.querySelector( + `popupnotification[id=${notification.id}-notification] popupnotificationcontent` + ); + const line3 = content.querySelector( + "[id=firefox-relay-offer-what-relay-provides]" + ); + document.l10n.setAttributes( + line3, + "firefox-relay-offer-what-relay-provides", + { + useremail: fxaUser.email, + } + ); + FirefoxRelayTelemetry.recordRelayOptInPanelEvent( + "shown", + gFlowId + ); + break; + } + }, + } + ); + getRelayTokenAsync(); + return fillUsernamePromise; + } +} + +class RelayEnabled { + async *autocompleteItemsAsync(origin, scenarioName, hasInput) { + if ( + !hasInput && + isSignup(scenarioName) && + (await hasFirefoxAccountAsync()) + ) { + const [title] = await formatMessages("firefox-relay-use-mask-title"); + yield new ParentAutocompleteOption( + "chrome://browser/content/logos/relay.svg", + title, + "", // when the user has opted-in, there is no subtitle content + "PasswordManager:generateRelayUsername", + { + telemetry: { + flowId: gFlowId, + }, + } + ); + FirefoxRelayTelemetry.recordRelayUsernameFilledEvent("shown", gFlowId); + } + } + + async generateUsername(browser, origin) { + return generateUsernameAsync(browser, origin); + } +} + +class RelayDisabled {} + +class RelayFeature extends OptInFeature { + constructor() { + super(RelayOffered, RelayEnabled, RelayDisabled, gConfig.relayFeaturePref); + Services.telemetry.setEventRecordingEnabled("relay_integration", true); + // Update the config when the signon.firefoxRelay.base_url pref is changed. + // This is added mainly for tests. + Services.prefs.addObserver( + "signon.firefoxRelay.base_url", + this.updateConfig + ); + } + + get learnMoreUrl() { + return gConfig.learnMoreURL; + } + + updateConfig() { + const newBaseUrl = Services.prefs.getStringPref( + "signon.firefoxRelay.base_url" + ); + gConfig.addressesUrl = newBaseUrl + `relayaddresses/`; + gConfig.profilesUrl = newBaseUrl + `profiles/`; + gConfig.acceptTermsUrl = newBaseUrl + `terms-accepted-user/`; + } + + async autocompleteItemsAsync({ origin, scenarioName, hasInput }) { + const result = []; + + // Generate a flowID to unique identify a series of user action. FlowId + // allows us to link users' interaction on different UI component (Ex. autocomplete, notification) + // We can use flowID to build the Funnel Diagram + // This value need to always be regenerated in the entry point of an user + // action so we overwrite the previous one. + gFlowId = TelemetryUtils.generateUUID(); + + if (this.implementation.autocompleteItemsAsync) { + for await (const item of this.implementation.autocompleteItemsAsync( + origin, + scenarioName, + hasInput + )) { + result.push(item); + } + } + + return result; + } + + async generateUsername(browser, origin) { + return this.implementation.generateUsername?.(browser, origin); + } + + async offerRelayIntegration(browser, origin) { + return this.implementation.offerRelayIntegration?.(this, browser, origin); + } +} + +export const FirefoxRelay = new RelayFeature(); diff --git a/toolkit/components/satchel/integrations/FirefoxRelayTelemetry.mjs b/toolkit/components/satchel/integrations/FirefoxRelayTelemetry.mjs new file mode 100644 index 0000000000..c03f48ba0f --- /dev/null +++ b/toolkit/components/satchel/integrations/FirefoxRelayTelemetry.mjs @@ -0,0 +1,73 @@ +/* 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/. */ + +export const FirefoxRelayTelemetry = { + recordRelayIntegrationTelemetryEvent( + eventObject, + eventMethod, + eventFlowId, + eventExtras + ) { + Services.telemetry.recordEvent( + "relay_integration", + eventMethod, + eventObject, + eventFlowId ?? "", + eventExtras ?? {} + ); + }, + + recordRelayPrefEvent(eventMethod, eventFlowId, eventExtras) { + this.recordRelayIntegrationTelemetryEvent( + "pref_change", + eventMethod, + eventFlowId, + eventExtras + ); + }, + + recordRelayOfferedEvent(eventMethod, eventFlowId, scenarioName) { + return this.recordRelayIntegrationTelemetryEvent( + "offer_relay", + eventMethod, + eventFlowId, + { + scenario: scenarioName, + } + ); + }, + + recordRelayUsernameFilledEvent(eventMethod, eventFlowId, errorCode = 0) { + return this.recordRelayIntegrationTelemetryEvent( + "fill_username", + eventMethod, + eventFlowId, + { + error_code: errorCode + "", + } + ); + }, + + recordRelayReusePanelEvent(eventMethod, eventFlowId, errorCode = 0) { + return this.recordRelayIntegrationTelemetryEvent( + "reuse_panel", + eventMethod, + eventFlowId, + { + error_code: errorCode + "", + } + ); + }, + + recordRelayOptInPanelEvent(eventMethod, eventFlowId, eventExtras) { + return this.recordRelayIntegrationTelemetryEvent( + "opt_in_panel", + eventMethod, + eventFlowId, + eventExtras + ); + }, +}; + +export default FirefoxRelayTelemetry; diff --git a/toolkit/components/satchel/integrations/FirefoxRelayUtils.sys.mjs b/toolkit/components/satchel/integrations/FirefoxRelayUtils.sys.mjs new file mode 100644 index 0000000000..0dfb2a969d --- /dev/null +++ b/toolkit/components/satchel/integrations/FirefoxRelayUtils.sys.mjs @@ -0,0 +1,23 @@ +/* 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 { LoginHelper } from "resource://gre/modules/LoginHelper.sys.mjs"; + +export const FirefoxRelayUtils = { + isRelayInterestedField(input) { + return ( + FirefoxRelayUtils.relayIsAvailableOrEnabled && + (LoginHelper.isInferredEmailField(input) || + LoginHelper.isInferredUsernameField(input)) + ); + }, + + relayIsAvailableOrEnabled() { + const value = Services.prefs.getStringPref( + "signon.firefoxRelay.feature", + undefined + ); + return ["available", "offered", "enabled"].includes(value); + }, +}; diff --git a/toolkit/components/satchel/integrations/WebAuthnFeature.sys.mjs b/toolkit/components/satchel/integrations/WebAuthnFeature.sys.mjs new file mode 100644 index 0000000000..b5805d5a93 --- /dev/null +++ b/toolkit/components/satchel/integrations/WebAuthnFeature.sys.mjs @@ -0,0 +1,129 @@ +/* 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 { + LoginHelper, + ParentAutocompleteOption, +} from "resource://gre/modules/LoginHelper.sys.mjs"; + +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; + +const lazy = {}; + +XPCOMUtils.defineLazyServiceGetter( + lazy, + "webauthnService", + "@mozilla.org/webauthn/service;1", + "nsIWebAuthnService" +); + +ChromeUtils.defineLazyGetter( + lazy, + "strings", + () => new Localization(["browser/webauthnDialog.ftl"]) +); +ChromeUtils.defineLazyGetter(lazy, "log", () => + LoginHelper.createLogger("WebAuthnFeature") +); + +if (Services.appinfo.processType !== Services.appinfo.PROCESS_TYPE_DEFAULT) { + throw new Error( + "PasskeySupport.sys.mjs should only run in the parent process" + ); +} + +class WebAuthnSupport { + async *#getAutocompleteItemsAsync(browsingContextId, formOrigin) { + let transactionId = lazy.webauthnService.hasPendingConditionalGet( + browsingContextId, + formOrigin + ); + if (transactionId == 0) { + // No pending transaction + return; + } + let credentials = lazy.webauthnService.getAutoFillEntries(transactionId); + + let labels = credentials.map(x => ({ + id: "webauthn-specific-passkey-label", + args: { domain: x.rpId }, + })); + if (!credentials.length) { + labels.push({ id: "webauthn-a-passkey-label" }); + } else { + labels.push({ id: "webauthn-another-passkey-label" }); + } + const formattedLabels = await lazy.strings.formatValues(labels); + for (let i = 0; i < credentials.length; i++) { + yield new ParentAutocompleteOption( + "chrome://browser/content/logos/passkey.svg", + credentials[i].userName, + formattedLabels[i], + "PasswordManager:promptForAuthenticator", + { + selection: { + transactionId, + credentialId: credentials[i].credentialId, + }, + } + ); + } + // `getAutoFillEntries` may not return all of the credentials on the device + // (in particular it will not include credentials with a protection policy + // that forbids silent discovery), so we include a catch-all entry in the + // list. If the user selects this entry, the WebAuthn transaction will + // proceed using the modal UI. + yield new ParentAutocompleteOption( + "chrome://browser/content/logos/passkey.svg", + formattedLabels[formattedLabels.length - 1], + "", + "PasswordManager:promptForAuthenticator", + { + selection: { + transactionId, + }, + } + ); + } + + /** + * + * @param {int} browsingContextId the browsing context ID associated with this request + * @param {string} formOrigin + * @param {string} scenarioName can be "SignUpFormScenario" or undefined + * @param {string} isWebAuthn indicates whether "webauthn" was included in the input's autocomplete value + * @returns {ParentAutocompleteOption} the optional WebAuthn autocomplete item + */ + async autocompleteItemsAsync( + browsingContextId, + formOrigin, + scenarioName, + isWebAuthn + ) { + const result = []; + if (scenarioName !== "SignUpFormScenario" || isWebAuthn) { + for await (const item of this.#getAutocompleteItemsAsync( + browsingContextId, + formOrigin + )) { + result.push(item); + } + } + return result; + } + + async promptForAuthenticator(browser, selection) { + lazy.log.info("Prompting to authenticate with relying party."); + if (selection.credentialId) { + lazy.webauthnService.selectAutoFillEntry( + selection.transactionId, + selection.credentialId + ); + } else { + lazy.webauthnService.resumeConditionalGet(selection.transactionId); + } + } +} + +export const WebAuthnFeature = new WebAuthnSupport(); diff --git a/toolkit/components/satchel/moz.build b/toolkit/components/satchel/moz.build new file mode 100644 index 0000000000..90dbd9ad2d --- /dev/null +++ b/toolkit/components/satchel/moz.build @@ -0,0 +1,57 @@ +# -*- 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/. + +with Files("**"): + BUG_COMPONENT = ("Toolkit", "Form Manager") + +MOCHITEST_MANIFESTS += ["test/mochitest.toml"] +XPCSHELL_TESTS_MANIFESTS += ["test/unit/xpcshell.toml"] +BROWSER_CHROME_MANIFESTS += ["test/browser/browser.toml"] + +XPIDL_SOURCES += [ + "nsIFormAutoComplete.idl", + "nsIFormFillController.idl", +] + +XPIDL_MODULE = "satchel" + +SOURCES += [ + "nsFormFillController.cpp", +] + +LOCAL_INCLUDES += [ + "../build", +] + +EXTRA_JS_MODULES += [ + "FillHelpers.sys.mjs", + "FormAutoComplete.sys.mjs", + "FormHistory.sys.mjs", + "FormHistoryStartup.sys.mjs", + "FormScenarios.sys.mjs", + "integrations/FirefoxRelay.sys.mjs", + "integrations/FirefoxRelayTelemetry.mjs", + "integrations/FirefoxRelayUtils.sys.mjs", + "integrations/WebAuthnFeature.sys.mjs", + "SignUpFormRuleset.sys.mjs", +] + +XPCOM_MANIFESTS += [ + "components.conf", +] + +TESTING_JS_MODULES += [ + "test/FormHistoryTestUtils.sys.mjs", +] + +include("/ipc/chromium/chromium-config.mozbuild") + +FINAL_TARGET_FILES.actors += [ + "FormHistoryChild.sys.mjs", + "FormHistoryParent.sys.mjs", +] + +FINAL_LIBRARY = "xul" diff --git a/toolkit/components/satchel/nsFormFillController.cpp b/toolkit/components/satchel/nsFormFillController.cpp new file mode 100644 index 0000000000..7872ab36c8 --- /dev/null +++ b/toolkit/components/satchel/nsFormFillController.cpp @@ -0,0 +1,1300 @@ +/* -*- Mode: C++; tab-width: 8; indent-tabs-mode: nil; c-basic-offset: 2 -*- */ +/* vim: set ts=8 sts=2 et sw=2 tw=80: */ +/* 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 "nsFormFillController.h" + +#include "mozilla/ClearOnShutdown.h" +#include "mozilla/ErrorResult.h" +#include "mozilla/EventListenerManager.h" +#include "mozilla/dom/Document.h" +#include "mozilla/dom/Element.h" +#include "mozilla/dom/Event.h" // for Event +#include "mozilla/dom/HTMLDataListElement.h" +#include "mozilla/dom/HTMLInputElement.h" +#include "mozilla/dom/KeyboardEvent.h" +#include "mozilla/dom/KeyboardEventBinding.h" +#include "mozilla/dom/MouseEvent.h" +#include "mozilla/dom/PageTransitionEvent.h" +#include "mozilla/Logging.h" +#include "mozilla/PresShell.h" +#include "mozilla/Services.h" +#include "mozilla/StaticPrefs_ui.h" +#include "nsCRT.h" +#include "nsIFormAutoComplete.h" +#include "nsString.h" +#include "nsPIDOMWindow.h" +#include "nsIAutoCompleteResult.h" +#include "nsIContent.h" +#include "nsInterfaceHashtable.h" +#include "nsContentUtils.h" +#include "nsGenericHTMLElement.h" +#include "nsILoadContext.h" +#include "nsIFrame.h" +#include "nsIScriptSecurityManager.h" +#include "nsFocusManager.h" +#include "nsQueryActor.h" +#include "nsQueryObject.h" +#include "nsServiceManagerUtils.h" +#include "xpcpublic.h" + +using namespace mozilla; +using namespace mozilla::dom; +using mozilla::ErrorResult; +using mozilla::LogLevel; + +static mozilla::LazyLogModule sLogger("satchel"); + +static nsIFormAutoComplete* GetFormAutoComplete() { + static nsCOMPtr sInstance; + static bool sInitialized = false; + if (!sInitialized) { + nsresult rv; + sInstance = do_GetService("@mozilla.org/satchel/form-autocomplete;1", &rv); + + if (NS_SUCCEEDED(rv)) { + ClearOnShutdown(&sInstance); + sInitialized = true; + } + } + return sInstance; +} + +NS_IMPL_CYCLE_COLLECTION(nsFormFillController, mController, mLoginManagerAC, + mFocusedPopup, mPopups, mLastListener, + mLastFormAutoComplete) + +NS_INTERFACE_MAP_BEGIN_CYCLE_COLLECTION(nsFormFillController) + NS_INTERFACE_MAP_ENTRY_AMBIGUOUS(nsISupports, nsIFormFillController) + NS_INTERFACE_MAP_ENTRY(nsIFormFillController) + NS_INTERFACE_MAP_ENTRY(nsIAutoCompleteInput) + NS_INTERFACE_MAP_ENTRY(nsIAutoCompleteSearch) + NS_INTERFACE_MAP_ENTRY(nsIFormAutoCompleteObserver) + NS_INTERFACE_MAP_ENTRY(nsIDOMEventListener) + NS_INTERFACE_MAP_ENTRY(nsIObserver) + NS_INTERFACE_MAP_ENTRY(nsIMutationObserver) +NS_INTERFACE_MAP_END + +NS_IMPL_CYCLE_COLLECTING_ADDREF(nsFormFillController) +NS_IMPL_CYCLE_COLLECTING_RELEASE(nsFormFillController) + +nsFormFillController::nsFormFillController() + : mFocusedInput(nullptr), + mListNode(nullptr), + // The amount of time a context menu event supresses showing a + // popup from a focus event in ms. This matches the threshold in + // toolkit/components/passwordmgr/LoginManagerChild.jsm. + mFocusAfterRightClickThreshold(400), + mTimeout(50), + mMinResultsForPopup(1), + mMaxRows(0), + mDisableAutoComplete(false), + mCompleteDefaultIndex(false), + mCompleteSelectedIndex(false), + mForceComplete(false), + mSuppressOnInput(false), + mPasswordPopupAutomaticallyOpened(false) { + mController = do_GetService("@mozilla.org/autocomplete/controller;1"); + MOZ_ASSERT(mController); + + nsCOMPtr obs = mozilla::services::GetObserverService(); + MOZ_ASSERT(obs); + + obs->AddObserver(this, "chrome-event-target-created", false); + obs->AddObserver(this, "autofill-fill-starting", false); + obs->AddObserver(this, "autofill-fill-complete", false); +} + +nsFormFillController::~nsFormFillController() { + if (mListNode) { + mListNode->RemoveMutationObserver(this); + mListNode = nullptr; + } + if (mFocusedInput) { + MaybeRemoveMutationObserver(mFocusedInput); + mFocusedInput = nullptr; + } + RemoveForDocument(nullptr); +} + +/* static */ +already_AddRefed nsFormFillController::GetSingleton() { + static RefPtr sSingleton; + if (!sSingleton) { + sSingleton = new nsFormFillController(); + ClearOnShutdown(&sSingleton); + } + return do_AddRef(sSingleton); +} + +//////////////////////////////////////////////////////////////////////// +//// nsIMutationObserver +// + +MOZ_CAN_RUN_SCRIPT_BOUNDARY +void nsFormFillController::AttributeChanged(mozilla::dom::Element* aElement, + int32_t aNameSpaceID, + nsAtom* aAttribute, + int32_t aModType, + const nsAttrValue* aOldValue) { + if ((aAttribute == nsGkAtoms::type || aAttribute == nsGkAtoms::readonly || + aAttribute == nsGkAtoms::autocomplete) && + aNameSpaceID == kNameSpaceID_None) { + RefPtr focusedInput(mFocusedInput); + // Reset the current state of the controller, unconditionally. + StopControllingInput(); + // Then restart based on the new values. We have to delay this + // to avoid ending up in an endless loop due to re-registering our + // mutation observer (which would notify us again for *this* event). + nsCOMPtr event = + mozilla::NewRunnableMethod>( + "nsFormFillController::MaybeStartControllingInput", this, + &nsFormFillController::MaybeStartControllingInput, focusedInput); + aElement->OwnerDoc()->Dispatch(event.forget()); + } + + if (mListNode && mListNode->Contains(aElement)) { + RevalidateDataList(); + } +} + +MOZ_CAN_RUN_SCRIPT_BOUNDARY +void nsFormFillController::ContentAppended(nsIContent* aChild) { + if (mListNode && mListNode->Contains(aChild->GetParent())) { + RevalidateDataList(); + } +} + +MOZ_CAN_RUN_SCRIPT_BOUNDARY +void nsFormFillController::ContentInserted(nsIContent* aChild) { + if (mListNode && mListNode->Contains(aChild->GetParent())) { + RevalidateDataList(); + } +} + +MOZ_CAN_RUN_SCRIPT_BOUNDARY +void nsFormFillController::ContentRemoved(nsIContent* aChild, + nsIContent* aPreviousSibling) { + if (mListNode && mListNode->Contains(aChild->GetParent())) { + RevalidateDataList(); + } +} + +void nsFormFillController::CharacterDataWillChange( + nsIContent* aContent, const CharacterDataChangeInfo&) {} + +void nsFormFillController::CharacterDataChanged( + nsIContent* aContent, const CharacterDataChangeInfo&) {} + +void nsFormFillController::AttributeWillChange(mozilla::dom::Element* aElement, + int32_t aNameSpaceID, + nsAtom* aAttribute, + int32_t aModType) {} + +void nsFormFillController::ParentChainChanged(nsIContent* aContent) {} + +void nsFormFillController::ARIAAttributeDefaultWillChange( + mozilla::dom::Element* aElement, nsAtom* aAttribute, int32_t aModType) {} + +void nsFormFillController::ARIAAttributeDefaultChanged( + mozilla::dom::Element* aElement, nsAtom* aAttribute, int32_t aModType) {} + +MOZ_CAN_RUN_SCRIPT_BOUNDARY +void nsFormFillController::NodeWillBeDestroyed(nsINode* aNode) { + MOZ_LOG(sLogger, LogLevel::Verbose, ("NodeWillBeDestroyed: %p", aNode)); + mPwmgrInputs.Remove(aNode); + mAutofillInputs.Remove(aNode); + MaybeRemoveMutationObserver(aNode); + if (aNode == mListNode) { + mListNode = nullptr; + RevalidateDataList(); + } else if (aNode == mFocusedInput) { + mFocusedInput = nullptr; + } +} + +void nsFormFillController::MaybeRemoveMutationObserver(nsINode* aNode) { + // Nodes being tracked in mPwmgrInputs will have their observers removed when + // they stop being tracked. + if (!mPwmgrInputs.Get(aNode) && !mAutofillInputs.Get(aNode)) { + aNode->RemoveMutationObserver(this); + } +} + +//////////////////////////////////////////////////////////////////////// +//// nsIFormFillController + +NS_IMETHODIMP +nsFormFillController::AttachPopupElementToDocument(Document* aDocument, + dom::Element* aPopupEl) { + if (!xpc::IsInAutomation()) { + return NS_ERROR_NOT_AVAILABLE; + } + + MOZ_LOG(sLogger, LogLevel::Debug, + ("AttachPopupElementToDocument for document %p with popup %p", + aDocument, aPopupEl)); + NS_ENSURE_TRUE(aDocument && aPopupEl, NS_ERROR_ILLEGAL_VALUE); + + nsCOMPtr popup = aPopupEl->AsAutoCompletePopup(); + NS_ENSURE_STATE(popup); + + mPopups.InsertOrUpdate(aDocument, popup); + return NS_OK; +} + +NS_IMETHODIMP +nsFormFillController::DetachFromDocument(Document* aDocument) { + if (!xpc::IsInAutomation()) { + return NS_ERROR_NOT_AVAILABLE; + } + mPopups.Remove(aDocument); + return NS_OK; +} + +NS_IMETHODIMP +nsFormFillController::MarkAsLoginManagerField(HTMLInputElement* aInput) { + /* + * The Login Manager can supply autocomplete results for username fields, + * when a user has multiple logins stored for a site. It uses this + * interface to indicate that the form manager shouldn't handle the + * autocomplete. The form manager also checks for this tag when saving + * form history (so it doesn't save usernames). + */ + NS_ENSURE_STATE(aInput); + + // If the field was already marked, we don't want to show the popup again. + if (mPwmgrInputs.Get(aInput)) { + return NS_OK; + } + + mPwmgrInputs.InsertOrUpdate(aInput, true); + aInput->AddMutationObserverUnlessExists(this); + + nsFocusManager* fm = nsFocusManager::GetFocusManager(); + if (fm) { + nsCOMPtr focusedContent = fm->GetFocusedElement(); + if (focusedContent == aInput) { + if (!mFocusedInput) { + MaybeStartControllingInput(aInput); + } else { + // If we change who is responsible for searching the autocomplete + // result, notify the controller that the previous result is not valid + // anymore. + nsCOMPtr controller = mController; + controller->ResetInternalState(); + } + } + } + + if (!mLoginManagerAC) { + mLoginManagerAC = + do_GetService("@mozilla.org/login-manager/autocompletesearch;1"); + } + + return NS_OK; +} + +MOZ_CAN_RUN_SCRIPT NS_IMETHODIMP nsFormFillController::IsLoginManagerField( + HTMLInputElement* aInput, bool* isLoginManagerField) { + *isLoginManagerField = mPwmgrInputs.Get(aInput); + return NS_OK; +} + +NS_IMETHODIMP +nsFormFillController::MarkAsAutofillField(HTMLInputElement* aInput) { + /* + * Support other components implementing form autofill and handle autocomplete + * for the field. + */ + NS_ENSURE_STATE(aInput); + + MOZ_LOG(sLogger, LogLevel::Verbose, + ("MarkAsAutofillField: aInput = %p", aInput)); + + if (mAutofillInputs.Get(aInput)) { + return NS_OK; + } + + mAutofillInputs.InsertOrUpdate(aInput, true); + aInput->AddMutationObserverUnlessExists(this); + + aInput->EnablePreview(); + + nsFocusManager* fm = nsFocusManager::GetFocusManager(); + if (fm) { + nsCOMPtr focusedContent = fm->GetFocusedElement(); + if (focusedContent == aInput) { + if (!mFocusedInput) { + MaybeStartControllingInput(aInput); + } else { + // See `MarkAsLoginManagerField` for why this is needed. + nsCOMPtr controller = mController; + controller->ResetInternalState(); + } + } + } + + return NS_OK; +} + +NS_IMETHODIMP +nsFormFillController::GetFocusedInput(HTMLInputElement** aInput) { + *aInput = mFocusedInput; + NS_IF_ADDREF(*aInput); + return NS_OK; +} + +//////////////////////////////////////////////////////////////////////// +//// nsIAutoCompleteInput + +NS_IMETHODIMP +nsFormFillController::GetPopup(nsIAutoCompletePopup** aPopup) { + *aPopup = mFocusedPopup; + NS_IF_ADDREF(*aPopup); + return NS_OK; +} + +NS_IMETHODIMP +nsFormFillController::GetPopupElement(Element** aPopup) { + return NS_ERROR_NOT_IMPLEMENTED; +} + +NS_IMETHODIMP +nsFormFillController::GetController(nsIAutoCompleteController** aController) { + *aController = mController; + NS_IF_ADDREF(*aController); + return NS_OK; +} + +NS_IMETHODIMP +nsFormFillController::GetPopupOpen(bool* aPopupOpen) { + if (mFocusedPopup) { + mFocusedPopup->GetPopupOpen(aPopupOpen); + } else { + *aPopupOpen = false; + } + return NS_OK; +} + +NS_IMETHODIMP +nsFormFillController::SetPopupOpen(bool aPopupOpen) { + if (mFocusedPopup) { + if (aPopupOpen) { + // make sure input field is visible before showing popup (bug 320938) + nsCOMPtr content = mFocusedInput; + NS_ENSURE_STATE(content); + nsCOMPtr docShell = GetDocShellForInput(mFocusedInput); + NS_ENSURE_STATE(docShell); + RefPtr presShell = docShell->GetPresShell(); + NS_ENSURE_STATE(presShell); + presShell->ScrollContentIntoView( + content, + ScrollAxis(WhereToScroll::Nearest, WhenToScroll::IfNotVisible), + ScrollAxis(WhereToScroll::Nearest, WhenToScroll::IfNotVisible), + ScrollFlags::ScrollOverflowHidden); + // mFocusedPopup can be destroyed after ScrollContentIntoView, see bug + // 420089 + if (mFocusedPopup) { + mFocusedPopup->OpenAutocompletePopup(this, mFocusedInput); + } + } else { + mFocusedPopup->ClosePopup(); + mPasswordPopupAutomaticallyOpened = false; + } + } + + return NS_OK; +} + +NS_IMETHODIMP +nsFormFillController::GetDisableAutoComplete(bool* aDisableAutoComplete) { + *aDisableAutoComplete = mDisableAutoComplete; + return NS_OK; +} + +NS_IMETHODIMP +nsFormFillController::SetDisableAutoComplete(bool aDisableAutoComplete) { + mDisableAutoComplete = aDisableAutoComplete; + return NS_OK; +} + +NS_IMETHODIMP +nsFormFillController::GetCompleteDefaultIndex(bool* aCompleteDefaultIndex) { + *aCompleteDefaultIndex = mCompleteDefaultIndex; + return NS_OK; +} + +NS_IMETHODIMP +nsFormFillController::SetCompleteDefaultIndex(bool aCompleteDefaultIndex) { + mCompleteDefaultIndex = aCompleteDefaultIndex; + return NS_OK; +} + +NS_IMETHODIMP +nsFormFillController::GetCompleteSelectedIndex(bool* aCompleteSelectedIndex) { + *aCompleteSelectedIndex = mCompleteSelectedIndex; + return NS_OK; +} + +NS_IMETHODIMP +nsFormFillController::SetCompleteSelectedIndex(bool aCompleteSelectedIndex) { + mCompleteSelectedIndex = aCompleteSelectedIndex; + return NS_OK; +} + +NS_IMETHODIMP +nsFormFillController::GetForceComplete(bool* aForceComplete) { + *aForceComplete = mForceComplete; + return NS_OK; +} + +NS_IMETHODIMP nsFormFillController::SetForceComplete(bool aForceComplete) { + mForceComplete = aForceComplete; + return NS_OK; +} + +NS_IMETHODIMP +nsFormFillController::GetMinResultsForPopup(uint32_t* aMinResultsForPopup) { + *aMinResultsForPopup = mMinResultsForPopup; + return NS_OK; +} + +NS_IMETHODIMP nsFormFillController::SetMinResultsForPopup( + uint32_t aMinResultsForPopup) { + mMinResultsForPopup = aMinResultsForPopup; + return NS_OK; +} + +NS_IMETHODIMP +nsFormFillController::GetMaxRows(uint32_t* aMaxRows) { + *aMaxRows = mMaxRows; + return NS_OK; +} + +NS_IMETHODIMP +nsFormFillController::SetMaxRows(uint32_t aMaxRows) { + mMaxRows = aMaxRows; + return NS_OK; +} + +NS_IMETHODIMP +nsFormFillController::GetTimeout(uint32_t* aTimeout) { + *aTimeout = mTimeout; + return NS_OK; +} + +NS_IMETHODIMP nsFormFillController::SetTimeout(uint32_t aTimeout) { + mTimeout = aTimeout; + return NS_OK; +} + +NS_IMETHODIMP +nsFormFillController::SetSearchParam(const nsAString& aSearchParam) { + return NS_ERROR_NOT_IMPLEMENTED; +} + +NS_IMETHODIMP +nsFormFillController::GetSearchParam(nsAString& aSearchParam) { + if (!mFocusedInput) { + NS_WARNING( + "mFocusedInput is null for some reason! avoiding a crash. should find " + "out why... - ben"); + return NS_ERROR_FAILURE; // XXX why? fix me. + } + + mFocusedInput->GetName(aSearchParam); + if (aSearchParam.IsEmpty()) { + mFocusedInput->GetId(aSearchParam); + } + + return NS_OK; +} + +NS_IMETHODIMP +nsFormFillController::GetSearchCount(uint32_t* aSearchCount) { + *aSearchCount = 1; + return NS_OK; +} + +NS_IMETHODIMP +nsFormFillController::GetSearchAt(uint32_t index, nsACString& _retval) { + if (mAutofillInputs.Get(mFocusedInput)) { + MOZ_LOG(sLogger, LogLevel::Debug, ("GetSearchAt: autofill-profiles field")); + nsCOMPtr profileSearch = do_GetService( + "@mozilla.org/autocomplete/search;1?name=autofill-profiles"); + if (profileSearch) { + _retval.AssignLiteral("autofill-profiles"); + return NS_OK; + } + } + + MOZ_LOG(sLogger, LogLevel::Debug, ("GetSearchAt: form-history field")); + _retval.AssignLiteral("form-history"); + return NS_OK; +} + +NS_IMETHODIMP +nsFormFillController::GetTextValue(nsAString& aTextValue) { + if (mFocusedInput) { + mFocusedInput->GetValue(aTextValue, CallerType::System); + } else { + aTextValue.Truncate(); + } + return NS_OK; +} + +NS_IMETHODIMP +nsFormFillController::SetTextValue(const nsAString& aTextValue) { + if (mFocusedInput) { + mSuppressOnInput = true; + mFocusedInput->SetUserInput(aTextValue, + *nsContentUtils::GetSystemPrincipal()); + mSuppressOnInput = false; + } + + return NS_OK; +} + +NS_IMETHODIMP +nsFormFillController::GetSelectionStart(int32_t* aSelectionStart) { + if (!mFocusedInput) { + return NS_ERROR_UNEXPECTED; + } + ErrorResult rv; + *aSelectionStart = mFocusedInput->GetSelectionStartIgnoringType(rv); + return rv.StealNSResult(); +} + +NS_IMETHODIMP +nsFormFillController::GetSelectionEnd(int32_t* aSelectionEnd) { + if (!mFocusedInput) { + return NS_ERROR_UNEXPECTED; + } + ErrorResult rv; + *aSelectionEnd = mFocusedInput->GetSelectionEndIgnoringType(rv); + return rv.StealNSResult(); +} + +MOZ_CAN_RUN_SCRIPT_BOUNDARY NS_IMETHODIMP +nsFormFillController::SelectTextRange(int32_t aStartIndex, int32_t aEndIndex) { + if (!mFocusedInput) { + return NS_ERROR_UNEXPECTED; + } + RefPtr focusedInput(mFocusedInput); + ErrorResult rv; + focusedInput->SetSelectionRange(aStartIndex, aEndIndex, Optional(), + rv); + return rv.StealNSResult(); +} + +NS_IMETHODIMP +nsFormFillController::OnSearchBegin() { return NS_OK; } + +NS_IMETHODIMP +nsFormFillController::OnSearchComplete() { return NS_OK; } + +NS_IMETHODIMP +nsFormFillController::OnTextEntered(Event* aEvent) { + NS_ENSURE_TRUE(mFocusedInput, NS_OK); + return NS_OK; +} + +NS_IMETHODIMP +nsFormFillController::OnTextReverted(bool* _retval) { + mPasswordPopupAutomaticallyOpened = false; + return NS_OK; +} + +NS_IMETHODIMP +nsFormFillController::GetConsumeRollupEvent(bool* aConsumeRollupEvent) { + *aConsumeRollupEvent = false; + return NS_OK; +} + +NS_IMETHODIMP +nsFormFillController::GetInPrivateContext(bool* aInPrivateContext) { + if (!mFocusedInput) { + *aInPrivateContext = false; + return NS_OK; + } + + RefPtr doc = mFocusedInput->OwnerDoc(); + nsCOMPtr loadContext = doc->GetLoadContext(); + *aInPrivateContext = loadContext && loadContext->UsePrivateBrowsing(); + return NS_OK; +} + +NS_IMETHODIMP +nsFormFillController::GetNoRollupOnCaretMove(bool* aNoRollupOnCaretMove) { + *aNoRollupOnCaretMove = false; + return NS_OK; +} + +NS_IMETHODIMP +nsFormFillController::GetNoRollupOnEmptySearch(bool* aNoRollupOnEmptySearch) { + if (mFocusedInput && (mPwmgrInputs.Get(mFocusedInput) || + mFocusedInput->HasBeenTypePassword())) { + // Don't close the login popup when the field is cleared (bug 1534896). + *aNoRollupOnEmptySearch = true; + } else { + *aNoRollupOnEmptySearch = false; + } + return NS_OK; +} + +NS_IMETHODIMP +nsFormFillController::GetUserContextId(uint32_t* aUserContextId) { + *aUserContextId = nsIScriptSecurityManager::DEFAULT_USER_CONTEXT_ID; + return NS_OK; +} + +NS_IMETHODIMP +nsFormFillController::GetInvalidatePreviousResult( + bool* aInvalidatePreviousResult) { + *aInvalidatePreviousResult = mInvalidatePreviousResult; + return NS_OK; +} + +//////////////////////////////////////////////////////////////////////// +//// nsIAutoCompleteSearch + +NS_IMETHODIMP +nsFormFillController::StartSearch(const nsAString& aSearchString, + const nsAString& aSearchParam, + nsIAutoCompleteResult* aPreviousResult, + nsIAutoCompleteObserver* aListener) { + MOZ_LOG(sLogger, LogLevel::Debug, ("StartSearch for %p", mFocusedInput)); + + nsresult rv; + + // If the login manager has indicated it's responsible for this field, let it + // handle the autocomplete. Otherwise, handle with form history. + // This method is sometimes called in unit tests and from XUL without a + // focused node. + if (mFocusedInput && (mPwmgrInputs.Get(mFocusedInput) || + mFocusedInput->HasBeenTypePassword())) { + MOZ_LOG(sLogger, LogLevel::Debug, ("StartSearch: login field")); + + // Handle the case where a password field is focused but + // MarkAsLoginManagerField wasn't called because password manager is + // disabled. + if (!mLoginManagerAC) { + mLoginManagerAC = + do_GetService("@mozilla.org/login-manager/autocompletesearch;1"); + } + + if (NS_WARN_IF(!mLoginManagerAC)) { + return NS_ERROR_FAILURE; + } + + // XXX aPreviousResult shouldn't ever be a historyResult type, since we're + // not letting satchel manage the field? + mLastListener = aListener; + rv = mLoginManagerAC->StartSearch(aSearchString, aPreviousResult, + mFocusedInput, this); + NS_ENSURE_SUCCESS(rv, rv); + } else { + MOZ_LOG(sLogger, LogLevel::Debug, ("StartSearch: non-login field")); + mLastListener = aListener; + + bool addDataList = IsTextControl(mFocusedInput); + if (addDataList) { + MaybeObserveDataListMutations(); + } + + auto formAutoComplete = GetFormAutoComplete(); + NS_ENSURE_TRUE(formAutoComplete, NS_ERROR_FAILURE); + + formAutoComplete->AutoCompleteSearchAsync(aSearchParam, aSearchString, + mFocusedInput, aPreviousResult, + addDataList, this); + mLastFormAutoComplete = formAutoComplete; + } + + return NS_OK; +} + +void nsFormFillController::MaybeObserveDataListMutations() { + // If an is focused, check if it has a list="" which can + // provide the list of suggestions. + + MOZ_ASSERT(!mPwmgrInputs.Get(mFocusedInput)); + + if (mFocusedInput) { + Element* list = mFocusedInput->GetList(); + + // Add a mutation observer to check for changes to the items in the + // and update the suggestions accordingly. + if (mListNode != list) { + if (mListNode) { + mListNode->RemoveMutationObserver(this); + mListNode = nullptr; + } + if (list) { + list->AddMutationObserverUnlessExists(this); + mListNode = list; + } + } + } +} + +void nsFormFillController::RevalidateDataList() { + if (!mLastListener) { + return; + } + + nsCOMPtr controller( + do_QueryInterface(mLastListener)); + if (!controller) { + return; + } + + // We cannot use previous result since any items in search target are updated. + mInvalidatePreviousResult = true; + controller->StartSearch(mLastSearchString); +} + +NS_IMETHODIMP +nsFormFillController::StopSearch() { + // Make sure to stop and clear this, otherwise the controller will prevent + // mLastFormAutoComplete from being deleted. + if (mLastFormAutoComplete) { + mLastFormAutoComplete->StopAutoCompleteSearch(); + mLastFormAutoComplete = nullptr; + } + + if (mLoginManagerAC) { + mLoginManagerAC->StopSearch(); + } + return NS_OK; +} + +//////////////////////////////////////////////////////////////////////// +//// nsIFormAutoCompleteObserver + +NS_IMETHODIMP +nsFormFillController::OnSearchCompletion(nsIAutoCompleteResult* aResult) { + nsAutoString searchString; + aResult->GetSearchString(searchString); + + mLastSearchString = searchString; + + if (mLastListener) { + nsCOMPtr lastListener = mLastListener; + lastListener->OnSearchResult(this, aResult); + } + + return NS_OK; +} + +//////////////////////////////////////////////////////////////////////// +//// nsIObserver + +NS_IMETHODIMP +nsFormFillController::Observe(nsISupports* aSubject, const char* aTopic, + const char16_t* aData) { + if (!nsCRT::strcmp(aTopic, "chrome-event-target-created")) { + if (RefPtr eventTarget = do_QueryObject(aSubject)) { + AttachListeners(eventTarget); + } + } else if (!nsCRT::strcmp(aTopic, "autofill-fill-starting")) { + mAutoCompleteActive = true; + } else if (!nsCRT::strcmp(aTopic, "autofill-fill-complete")) { + mAutoCompleteActive = false; + } + return NS_OK; +} + +//////////////////////////////////////////////////////////////////////// +//// nsIDOMEventListener + +NS_IMETHODIMP +nsFormFillController::HandleEvent(Event* aEvent) { + EventTarget* target = aEvent->GetOriginalTarget(); + NS_ENSURE_STATE(target); + + mInvalidatePreviousResult = false; + + nsIGlobalObject* global = target->GetOwnerGlobal(); + NS_ENSURE_STATE(global); + nsPIDOMWindowInner* inner = global->GetAsInnerWindow(); + NS_ENSURE_STATE(inner); + + if (!inner->GetBrowsingContext()->IsContent()) { + return NS_OK; + } + + if (aEvent->ShouldIgnoreChromeEventTargetListener()) { + return NS_OK; + } + + WidgetEvent* internalEvent = aEvent->WidgetEventPtr(); + NS_ENSURE_STATE(internalEvent); + + switch (internalEvent->mMessage) { + case eFocus: + return Focus(aEvent); + case eMouseDown: + return MouseDown(aEvent); + case eKeyDown: + return KeyDown(aEvent); + case eEditorInput: { + if (!(mAutoCompleteActive || mSuppressOnInput)) { + nsCOMPtr input = + do_QueryInterface(aEvent->GetComposedTarget()); + if (IsTextControl(input) && IsFocusedInputControlled()) { + nsCOMPtr controller = mController; + bool unused = false; + return controller->HandleText(&unused); + } + } + return NS_OK; + } + case eBlur: + if (mFocusedInput && !StaticPrefs::ui_popup_disable_autohide()) { + StopControllingInput(); + } + return NS_OK; + case eCompositionStart: + NS_ASSERTION(mController, "should have a controller!"); + if (IsFocusedInputControlled()) { + nsCOMPtr controller = mController; + controller->HandleStartComposition(); + } + return NS_OK; + case eCompositionEnd: + NS_ASSERTION(mController, "should have a controller!"); + if (IsFocusedInputControlled()) { + nsCOMPtr controller = mController; + controller->HandleEndComposition(); + } + return NS_OK; + case eContextMenu: + if (mFocusedPopup) { + mFocusedPopup->ClosePopup(); + } + return NS_OK; + case ePageHide: { + nsCOMPtr doc = do_QueryInterface(aEvent->GetTarget()); + if (!doc) { + return NS_OK; + } + + if (mFocusedInput && doc == mFocusedInput->OwnerDoc()) { + StopControllingInput(); + } + + // Only remove the observer notifications and marked autofill and password + // manager fields if the page isn't going to be persisted (i.e. it's being + // unloaded) so that appropriate autocomplete handling works with bfcache. + bool persisted = aEvent->AsPageTransitionEvent()->Persisted(); + if (!persisted) { + RemoveForDocument(doc); + } + } break; + default: + // Handling the default case to shut up stupid -Wswitch warnings. + // One day compilers will be smarter... + break; + } + + return NS_OK; +} + +void nsFormFillController::AttachListeners(EventTarget* aEventTarget) { + EventListenerManager* elm = aEventTarget->GetOrCreateListenerManager(); + NS_ENSURE_TRUE_VOID(elm); + + elm->AddEventListenerByType(this, u"focus"_ns, TrustedEventsAtCapture()); + elm->AddEventListenerByType(this, u"blur"_ns, TrustedEventsAtCapture()); + elm->AddEventListenerByType(this, u"pagehide"_ns, TrustedEventsAtCapture()); + elm->AddEventListenerByType(this, u"mousedown"_ns, TrustedEventsAtCapture()); + elm->AddEventListenerByType(this, u"input"_ns, TrustedEventsAtCapture()); + elm->AddEventListenerByType(this, u"keydown"_ns, TrustedEventsAtCapture()); + elm->AddEventListenerByType(this, u"keypress"_ns, + TrustedEventsAtSystemGroupCapture()); + elm->AddEventListenerByType(this, u"compositionstart"_ns, + TrustedEventsAtCapture()); + elm->AddEventListenerByType(this, u"compositionend"_ns, + TrustedEventsAtCapture()); + elm->AddEventListenerByType(this, u"contextmenu"_ns, + TrustedEventsAtCapture()); +} + +void nsFormFillController::RemoveForDocument(Document* aDoc) { + MOZ_LOG(sLogger, LogLevel::Verbose, ("RemoveForDocument: %p", aDoc)); + for (auto iter = mPwmgrInputs.Iter(); !iter.Done(); iter.Next()) { + const nsINode* key = iter.Key(); + if (key && (!aDoc || key->OwnerDoc() == aDoc)) { + // mFocusedInput's observer is tracked separately, so don't remove it + // here. + if (key != mFocusedInput) { + const_cast(key)->RemoveMutationObserver(this); + } + iter.Remove(); + } + } + + for (auto iter = mAutofillInputs.Iter(); !iter.Done(); iter.Next()) { + const nsINode* key = iter.Key(); + if (key && (!aDoc || key->OwnerDoc() == aDoc)) { + // mFocusedInput's observer is tracked separately, so don't remove it + // here. + if (key != mFocusedInput) { + const_cast(key)->RemoveMutationObserver(this); + } + iter.Remove(); + } + } +} + +bool nsFormFillController::IsTextControl(nsINode* aNode) { + nsCOMPtr formControl = do_QueryInterface(aNode); + return formControl && formControl->IsSingleLineTextControl(false); +} + +void nsFormFillController::MaybeStartControllingInput( + HTMLInputElement* aInput) { + MOZ_LOG(sLogger, LogLevel::Verbose, + ("MaybeStartControllingInput for %p", aInput)); + if (!aInput) { + return; + } + + bool hasList = !!aInput->GetList(); + + if (!IsTextControl(aInput)) { + // Even if this is not a text control yet, it can become one in the future + if (hasList) { + StartControllingInput(aInput); + } + return; + } + + bool autocomplete = nsContentUtils::IsAutocompleteEnabled(aInput); + + bool isPwmgrInput = false; + if (mPwmgrInputs.Get(aInput) || aInput->HasBeenTypePassword()) { + isPwmgrInput = true; + } + + bool isAutofillInput = false; + if (mAutofillInputs.Get(aInput)) { + isAutofillInput = true; + } + + if (isAutofillInput || isPwmgrInput || hasList || autocomplete) { + StartControllingInput(aInput); + } +} + +nsresult nsFormFillController::HandleFocus(HTMLInputElement* aInput) { + MaybeStartControllingInput(aInput); + + // Bail if we didn't start controlling the input. + if (!mFocusedInput) { + return NS_OK; + } + + // If this focus doesn't follow a right click within our specified + // threshold then show the autocomplete popup for all password fields. + // This is done to avoid showing both the context menu and the popup + // at the same time. + // We use a timestamp instead of a bool to avoid complexity when dealing with + // multiple input forms and the fact that a mousedown into an already focused + // field does not trigger another focus. + + if (!mFocusedInput->HasBeenTypePassword()) { + return NS_OK; + } + + // If we have not seen a right click yet, just show the popup. + if (mLastRightClickTimeStamp.IsNull()) { + mPasswordPopupAutomaticallyOpened = true; + ShowPopup(); + return NS_OK; + } + + uint64_t timeDiff = + (TimeStamp::Now() - mLastRightClickTimeStamp).ToMilliseconds(); + if (timeDiff > mFocusAfterRightClickThreshold) { + mPasswordPopupAutomaticallyOpened = true; + ShowPopup(); + } + + return NS_OK; +} + +nsresult nsFormFillController::Focus(Event* aEvent) { + nsCOMPtr input = do_QueryInterface(aEvent->GetComposedTarget()); + return HandleFocus(MOZ_KnownLive(HTMLInputElement::FromNodeOrNull(input))); +} + +nsresult nsFormFillController::KeyDown(Event* aEvent) { + NS_ASSERTION(mController, "should have a controller!"); + + mPasswordPopupAutomaticallyOpened = false; + + if (!IsFocusedInputControlled()) { + return NS_OK; + } + + RefPtr keyEvent = aEvent->AsKeyboardEvent(); + if (!keyEvent) { + return NS_ERROR_FAILURE; + } + + bool cancel = false; + bool unused = false; + + uint32_t k = keyEvent->KeyCode(); + switch (k) { + case KeyboardEvent_Binding::DOM_VK_RETURN: { + nsCOMPtr controller = mController; + controller->HandleEnter(false, aEvent, &cancel); + break; + } + case KeyboardEvent_Binding::DOM_VK_DELETE: +#ifndef XP_MACOSX + { + nsCOMPtr controller = mController; + controller->HandleDelete(&cancel); + break; + } + case KeyboardEvent_Binding::DOM_VK_BACK_SPACE: { + nsCOMPtr controller = mController; + controller->HandleText(&unused); + break; + } +#else + case KeyboardEvent_Binding::DOM_VK_BACK_SPACE: { + if (keyEvent->ShiftKey()) { + nsCOMPtr controller = mController; + controller->HandleDelete(&cancel); + } else { + nsCOMPtr controller = mController; + controller->HandleText(&unused); + } + break; + } +#endif + case KeyboardEvent_Binding::DOM_VK_PAGE_UP: + case KeyboardEvent_Binding::DOM_VK_PAGE_DOWN: { + if (keyEvent->CtrlKey() || keyEvent->AltKey() || keyEvent->MetaKey()) { + break; + } + } + [[fallthrough]]; + case KeyboardEvent_Binding::DOM_VK_UP: + case KeyboardEvent_Binding::DOM_VK_DOWN: + case KeyboardEvent_Binding::DOM_VK_LEFT: + case KeyboardEvent_Binding::DOM_VK_RIGHT: { + // Get the writing-mode of the relevant input element, + // so that we can remap arrow keys if necessary. + mozilla::WritingMode wm; + if (mFocusedInput) { + nsIFrame* frame = mFocusedInput->GetPrimaryFrame(); + if (frame) { + wm = frame->GetWritingMode(); + } + } + if (wm.IsVertical()) { + switch (k) { + case KeyboardEvent_Binding::DOM_VK_LEFT: + k = wm.IsVerticalLR() ? KeyboardEvent_Binding::DOM_VK_UP + : KeyboardEvent_Binding::DOM_VK_DOWN; + break; + case KeyboardEvent_Binding::DOM_VK_RIGHT: + k = wm.IsVerticalLR() ? KeyboardEvent_Binding::DOM_VK_DOWN + : KeyboardEvent_Binding::DOM_VK_UP; + break; + case KeyboardEvent_Binding::DOM_VK_UP: + k = KeyboardEvent_Binding::DOM_VK_LEFT; + break; + case KeyboardEvent_Binding::DOM_VK_DOWN: + k = KeyboardEvent_Binding::DOM_VK_RIGHT; + break; + } + } + nsCOMPtr controller = mController; + controller->HandleKeyNavigation(k, &cancel); + break; + } + case KeyboardEvent_Binding::DOM_VK_ESCAPE: { + nsCOMPtr controller = mController; + controller->HandleEscape(&cancel); + break; + } + case KeyboardEvent_Binding::DOM_VK_TAB: { + nsCOMPtr controller = mController; + controller->HandleTab(); + cancel = false; + break; + } + } + + if (cancel) { + aEvent->PreventDefault(); + // Don't let the page see the RETURN event when the popup is open + // (indicated by cancel=true) so sites don't manually submit forms + // (e.g. via submit.click()) without the autocompleted value being filled. + // Bug 286933 will fix this for other key events. + if (k == KeyboardEvent_Binding::DOM_VK_RETURN) { + aEvent->StopPropagation(); + } + } + + return NS_OK; +} + +nsresult nsFormFillController::MouseDown(Event* aEvent) { + MouseEvent* mouseEvent = aEvent->AsMouseEvent(); + if (!mouseEvent) { + return NS_ERROR_FAILURE; + } + + nsCOMPtr targetNode = do_QueryInterface(aEvent->GetComposedTarget()); + if (!HTMLInputElement::FromNodeOrNull(targetNode)) { + return NS_OK; + } + + int16_t button = mouseEvent->Button(); + + // In case of a right click we set a timestamp that + // will be checked in Focus() to avoid showing + // both contextmenu and popup at the same time. + if (button == 2) { + mLastRightClickTimeStamp = TimeStamp::Now(); + return NS_OK; + } + + if (button != 0) { + return NS_OK; + } + + return ShowPopup(); +} + +NS_IMETHODIMP +nsFormFillController::ShowPopup() { + bool isOpen = false; + GetPopupOpen(&isOpen); + if (isOpen) { + return SetPopupOpen(false); + } + + nsCOMPtr controller = mController; + + nsCOMPtr input; + controller->GetInput(getter_AddRefs(input)); + if (!input) { + return NS_OK; + } + + nsAutoString value; + input->GetTextValue(value); + if (value.Length() > 0) { + // Show the popup with a filtered result set + controller->SetSearchString(u""_ns); + bool unused = false; + controller->HandleText(&unused); + } else { + // Show the popup with the complete result set. Can't use HandleText() + // because it doesn't display the popup if the input is blank. + bool cancel = false; + controller->HandleKeyNavigation(KeyboardEvent_Binding::DOM_VK_DOWN, + &cancel); + } + + return NS_OK; +} + +NS_IMETHODIMP nsFormFillController::GetPasswordPopupAutomaticallyOpened( + bool* _retval) { + *_retval = mPasswordPopupAutomaticallyOpened; + return NS_OK; +} + +void nsFormFillController::StartControllingInput(HTMLInputElement* aInput) { + MOZ_LOG(sLogger, LogLevel::Verbose, ("StartControllingInput for %p", aInput)); + // Make sure we're not still attached to an input + StopControllingInput(); + + if (!mController || !aInput) { + return; + } + + nsCOMPtr popup = mPopups.Get(aInput->OwnerDoc()); + if (!popup) { + popup = do_QueryActor("AutoComplete", aInput->OwnerDoc()); + if (!popup) { + return; + } + } + + mFocusedPopup = popup; + + aInput->AddMutationObserverUnlessExists(this); + mFocusedInput = aInput; + + if (Element* list = mFocusedInput->GetList()) { + list->AddMutationObserverUnlessExists(this); + mListNode = list; + } + + if (!mFocusedInput->ReadOnly()) { + nsCOMPtr controller = mController; + controller->SetInput(this); + } +} + +bool nsFormFillController::IsFocusedInputControlled() const { + return mFocusedInput && mController && !mFocusedInput->ReadOnly(); +} + +void nsFormFillController::StopControllingInput() { + mPasswordPopupAutomaticallyOpened = false; + + if (mListNode) { + mListNode->RemoveMutationObserver(this); + mListNode = nullptr; + } + + if (nsCOMPtr controller = mController) { + // Reset the controller's input, but not if it has been switched + // to another input already, which might happen if the user switches + // focus by clicking another autocomplete textbox + nsCOMPtr input; + controller->GetInput(getter_AddRefs(input)); + if (input == this) { + MOZ_LOG(sLogger, LogLevel::Verbose, + ("StopControllingInput: Nulled controller input for %p", this)); + controller->SetInput(nullptr); + } + } + + MOZ_LOG(sLogger, LogLevel::Verbose, + ("StopControllingInput: Stopped controlling %p", mFocusedInput)); + if (mFocusedInput) { + MaybeRemoveMutationObserver(mFocusedInput); + mFocusedInput = nullptr; + } + + if (mFocusedPopup) { + mFocusedPopup->ClosePopup(); + } + mFocusedPopup = nullptr; +} + +nsIDocShell* nsFormFillController::GetDocShellForInput( + HTMLInputElement* aInput) { + NS_ENSURE_TRUE(aInput, nullptr); + + nsCOMPtr win = aInput->OwnerDoc()->GetWindow(); + NS_ENSURE_TRUE(win, nullptr); + + return win->GetDocShell(); +} diff --git a/toolkit/components/satchel/nsFormFillController.h b/toolkit/components/satchel/nsFormFillController.h new file mode 100644 index 0000000000..eef6addb7a --- /dev/null +++ b/toolkit/components/satchel/nsFormFillController.h @@ -0,0 +1,146 @@ +/* -*- 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 __nsFormFillController__ +#define __nsFormFillController__ + +#include "mozilla/TimeStamp.h" +#include "nsIFormFillController.h" +#include "nsIAutoCompleteInput.h" +#include "nsIAutoCompleteSearch.h" +#include "nsIAutoCompleteController.h" +#include "nsIAutoCompletePopup.h" +#include "nsIDOMEventListener.h" +#include "nsIFormAutoComplete.h" +#include "nsCOMPtr.h" +#include "nsStubMutationObserver.h" +#include "nsTHashMap.h" +#include "nsInterfaceHashtable.h" +#include "nsIDocShell.h" +#include "nsILoginAutoCompleteSearch.h" +#include "nsIMutationObserver.h" +#include "nsIObserver.h" +#include "nsCycleCollectionParticipant.h" + +class nsFormHistory; +class nsINode; + +namespace mozilla { +namespace dom { +class EventTarget; +class HTMLInputElement; +} // namespace dom +} // namespace mozilla + +class nsFormFillController final : public nsIFormFillController, + public nsIAutoCompleteInput, + public nsIAutoCompleteSearch, + public nsIFormAutoCompleteObserver, + public nsIDOMEventListener, + public nsIObserver, + public nsMultiMutationObserver { + public: + NS_DECL_CYCLE_COLLECTING_ISUPPORTS + NS_DECL_NSIFORMFILLCONTROLLER + NS_DECL_NSIAUTOCOMPLETESEARCH + NS_DECL_NSIAUTOCOMPLETEINPUT + NS_DECL_NSIFORMAUTOCOMPLETEOBSERVER + NS_DECL_NSIDOMEVENTLISTENER + NS_DECL_NSIOBSERVER + NS_DECL_NSIMUTATIONOBSERVER + + NS_DECL_CYCLE_COLLECTION_CLASS_AMBIGUOUS(nsFormFillController, + nsIFormFillController) + + MOZ_CAN_RUN_SCRIPT nsresult Focus(mozilla::dom::Event* aEvent); + MOZ_CAN_RUN_SCRIPT nsresult KeyDown(mozilla::dom::Event* aKeyEvent); + MOZ_CAN_RUN_SCRIPT nsresult MouseDown(mozilla::dom::Event* aMouseEvent); + + nsFormFillController(); + + static already_AddRefed GetSingleton(); + + protected: + MOZ_CAN_RUN_SCRIPT virtual ~nsFormFillController(); + + MOZ_CAN_RUN_SCRIPT + void StartControllingInput(mozilla::dom::HTMLInputElement* aInput); + MOZ_CAN_RUN_SCRIPT void StopControllingInput(); + + bool IsFocusedInputControlled() const; + + MOZ_CAN_RUN_SCRIPT + nsresult HandleFocus(mozilla::dom::HTMLInputElement* aInput); + + void AttachListeners(mozilla::dom::EventTarget* aEventTarget); + + /** + * Checks that aElement is a type of element we want to fill, then calls + * StartControllingInput on it. + */ + MOZ_CAN_RUN_SCRIPT + void MaybeStartControllingInput(mozilla::dom::HTMLInputElement* aElement); + + void MaybeObserveDataListMutations(); + + MOZ_CAN_RUN_SCRIPT void RevalidateDataList(); + bool RowMatch(nsFormHistory* aHistory, uint32_t aIndex, + const nsAString& aInputName, const nsAString& aInputValue); + + inline nsIDocShell* GetDocShellForInput( + mozilla::dom::HTMLInputElement* aInput); + + void MaybeRemoveMutationObserver(nsINode* aNode); + + void RemoveForDocument(mozilla::dom::Document* aDoc); + + bool IsTextControl(nsINode* aNode); + + MOZ_CAN_RUN_SCRIPT NS_IMETHODIMP isLoginManagerField( + mozilla::dom::HTMLInputElement* aInput, bool* isLoginManagerField); + + // members ////////////////////////////////////////// + + nsCOMPtr mController; + nsCOMPtr mLoginManagerAC; + mozilla::dom::HTMLInputElement* mFocusedInput; + + // mListNode is a element which, is set, has the form fill + // controller as a mutation observer for it. + nsINode* mListNode; + nsCOMPtr mFocusedPopup; + + // Only used by tests. + nsInterfaceHashtable, + nsIAutoCompletePopup> + mPopups; + + // The observer passed to StartSearch. It will be notified when the search + // is complete or the data from a datalist changes. + nsCOMPtr mLastListener; + + // This is cleared by StopSearch(). + nsCOMPtr mLastFormAutoComplete; + nsString mLastSearchString; + + nsTHashMap, bool> mPwmgrInputs; + nsTHashMap, bool> mAutofillInputs; + + uint16_t mFocusAfterRightClickThreshold; + uint32_t mTimeout; + uint32_t mMinResultsForPopup; + uint32_t mMaxRows; + mozilla::TimeStamp mLastRightClickTimeStamp; + bool mDisableAutoComplete; + bool mCompleteDefaultIndex; + bool mCompleteSelectedIndex; + bool mForceComplete; + bool mSuppressOnInput; + bool mPasswordPopupAutomaticallyOpened; + bool mAutoCompleteActive = false; + bool mInvalidatePreviousResult = false; +}; + +#endif // __nsFormFillController__ diff --git a/toolkit/components/satchel/nsIFormAutoComplete.idl b/toolkit/components/satchel/nsIFormAutoComplete.idl new file mode 100644 index 0000000000..cc40872dd3 --- /dev/null +++ b/toolkit/components/satchel/nsIFormAutoComplete.idl @@ -0,0 +1,44 @@ +/* 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 "nsISupports.idl" + +interface nsIAutoCompleteResult; +interface nsIFormAutoCompleteObserver; +interface nsIPropertyBag2; + +webidl HTMLInputElement; + +[scriptable, uuid(bfd9b82b-0ab3-4b6b-9e54-aa961ff4b732)] +interface nsIFormAutoComplete: nsISupports { + /** + * Generate results for a form input autocomplete menu asynchronously. + */ + void autoCompleteSearchAsync(in AString aInputName, + in AString aSearchString, + in HTMLInputElement aField, + in nsIAutoCompleteResult aPreviousResult, + in bool aAddDatalist, + in nsIFormAutoCompleteObserver aListener); + + /** + * If a search is in progress, stop it. Otherwise, do nothing. This is used + * to cancel an existing search, for example, in preparation for a new search. + */ + void stopAutoCompleteSearch(); +}; + +[scriptable, function, uuid(604419ab-55a0-4831-9eca-1b9e67cc4751)] +interface nsIFormAutoCompleteObserver : nsISupports +{ + /* + * Called when a search is complete and the results are ready even if the + * result set is empty. If the search is cancelled or a new search is + * started, this is not called. + * + * @param result - The search result object + */ + [can_run_script] void onSearchCompletion(in nsIAutoCompleteResult result); +}; diff --git a/toolkit/components/satchel/nsIFormFillController.idl b/toolkit/components/satchel/nsIFormFillController.idl new file mode 100644 index 0000000000..25bd2d6738 --- /dev/null +++ b/toolkit/components/satchel/nsIFormFillController.idl @@ -0,0 +1,69 @@ +/* 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 "nsISupports.idl" + +interface nsIAutoCompletePopup; + +webidl Document; +webidl Element; +webidl Event; +webidl HTMLInputElement; + +/* + * nsIFormFillController is an interface for controlling form fill behavior + * on HTML documents. Any number of docShells can be controller concurrently. + * While a docShell is attached, all HTML documents that are loaded within it + * will have a focus listener attached that will listen for when a text input + * is focused. When this happens, the input will be bound to the + * global nsIAutoCompleteController service. + */ + +[scriptable, uuid(07f0a0dc-f6e9-4cdd-a55f-56d770523a4c)] +interface nsIFormFillController : nsISupports +{ + /* + * The input element the form fill controller is currently bound to. + */ + readonly attribute HTMLInputElement focusedInput; + + /* + * Whether the autocomplete popup on a password field was automatically opened + * by the form fill controller (upon focus). + */ + readonly attribute boolean passwordPopupAutomaticallyOpened; + + // Only used by tests. + void attachPopupElementToDocument(in Document document, in Element popup); + void detachFromDocument(in Document document); + + /* + * Returns true if aInput is managed by the login manager. + * + * @param aInput - The HTML element to tag + */ + [can_run_script] boolean isLoginManagerField(in HTMLInputElement aInput); + + /* + * Mark the specified element as being managed by password manager. + * Autocomplete requests will be handed off to the password manager, and will + * not be stored in form history. + * + * @param aInput - The HTML element to tag + */ + [can_run_script] void markAsLoginManagerField(in HTMLInputElement aInput); + + /* + * Mark the specified element as being managed by a form autofill component. + * Autocomplete requests will be handed off to the autofill component. + * + * @param aInput - The HTML element to mark + */ + [can_run_script] void markAsAutofillField(in HTMLInputElement aInput); + + /* + * Open the autocomplete popup, if possible. + */ + [can_run_script] void showPopup(); +}; diff --git a/toolkit/components/satchel/test/FormHistoryTestUtils.sys.mjs b/toolkit/components/satchel/test/FormHistoryTestUtils.sys.mjs new file mode 100644 index 0000000000..f17b952b90 --- /dev/null +++ b/toolkit/components/satchel/test/FormHistoryTestUtils.sys.mjs @@ -0,0 +1,106 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + FormHistory: "resource://gre/modules/FormHistory.sys.mjs", +}); + +/** + * Provides a js-friendly promise-based API around FormHistory, and utils. + * + * Note: This is not a 100% complete implementation, it is intended for quick + * additions and check, thus further changes may be necessary for different + * use-cases. + */ +export var FormHistoryTestUtils = { + /** + * Adds values to form history. + * + * @param {string} fieldname The field name. + * @param {Array} additions Array of entries describing the values to add. + * Each entry can either be a string, or an object with the shape + * { value, source}. + * @returns {Promise} Resolved once the operation is complete. + */ + async add(fieldname, additions = []) { + // Additions are made one by one, so multiple identical entries are properly + // applied. + additions = additions.map(v => (typeof v == "string" ? { value: v } : v)); + for (let { value, source } of additions) { + await lazy.FormHistory.update( + Object.assign({ fieldname }, { op: "bump", value, source }) + ); + } + }, + + /** + * Counts values from form history. + * + * @param {string} fieldname The field name. + * @param {Array} filters Objects describing the search properties. + * @returns {number} The number of entries found. + */ + async count(fieldname, filters = {}) { + return lazy.FormHistory.count(Object.assign({ fieldname }, filters)); + }, + + /** + * Removes values from form history. + * If you want to remove all history, use clear() instead. + * + * @param {string} fieldname The field name. + * @param {Array} removals Array of entries describing the values to add. + * Each entry can either be a string, or an object with the shape + * { value, source}. If source is specified, only the source relation will + * be removed, while the global form history value persists. + * @returns {Promise} Resolved once the operation is complete. + */ + remove(fieldname, removals) { + let changes = removals.map(v => { + let criteria = typeof v == "string" ? { value: v } : v; + return Object.assign({ fieldname, op: "remove" }, criteria); + }); + return lazy.FormHistory.update(changes); + }, + + /** + * Removes all values from form history. + * If you want to remove individual values, use remove() instead. + * + * @param {string} fieldname The field name whose history should be cleared. + * Can be omitted to clear all form history. + * @returns {Promise} Resolved once the operation is complete. + */ + clear(fieldname) { + let baseChange = fieldname ? { fieldname } : {}; + return lazy.FormHistory.update(Object.assign(baseChange, { op: "remove" })); + }, + + /** + * Searches form history. + * + * @param {string} fieldname The field name. + * @param {Array} filters Objects describing the search properties. + * @returns {Promise} Resolves an array of found form history entries. + */ + search(fieldname, filters = {}) { + return lazy.FormHistory.search(null, Object.assign({ fieldname }, filters)); + }, + + /** + * Gets autocomplete results from form history. + * + * @param {string} searchString The search string. + * @param {string} fieldname The field name. + * @param {Array} filters Objects describing the search properties. + * @returns {Promise} Resolves an array of found form history entries. + */ + autocomplete(searchString, fieldname, filters = {}) { + return lazy.FormHistory.getAutoCompleteResults( + searchString, + Object.assign({ fieldname }, filters) + ); + }, +}; diff --git a/toolkit/components/satchel/test/browser/browser.toml b/toolkit/components/satchel/test/browser/browser.toml new file mode 100644 index 0000000000..7207c27f34 --- /dev/null +++ b/toolkit/components/satchel/test/browser/browser.toml @@ -0,0 +1,10 @@ +[DEFAULT] +support-files = ["!/toolkit/components/satchel/test/subtst_privbrowsing.html"] + +["browser_close_tab.js"] + +["browser_popup_mouseover.js"] +skip-if = ["verify"] + +["browser_privbrowsing_perwindowpb.js"] +skip-if = ["verify"] diff --git a/toolkit/components/satchel/test/browser/browser_close_tab.js b/toolkit/components/satchel/test/browser/browser_close_tab.js new file mode 100644 index 0000000000..37962d37d8 --- /dev/null +++ b/toolkit/components/satchel/test/browser/browser_close_tab.js @@ -0,0 +1,46 @@ +/* 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 { FormHistory } = ChromeUtils.importESModule( + "resource://gre/modules/FormHistory.sys.mjs" +); + +add_task(async function test() { + const url = `data:text/html,`; + + // Open a dummy tab. + await BrowserTestUtils.withNewTab( + { gBrowser, url }, + async function (browser) {} + ); + + await BrowserTestUtils.withNewTab( + { gBrowser, url }, + async function (browser) { + const { autoCompletePopup } = browser; + const mockHistory = [{ op: "add", fieldname: "field1", value: "value1" }]; + + await FormHistory.update([{ op: "remove" }, ...mockHistory]); + await SpecialPowers.spawn(browser, [], async function () { + const input = content.document.querySelector("input"); + + input.focus(); + }); + + // show popup + await BrowserTestUtils.synthesizeKey("VK_DOWN", {}, browser); + await TestUtils.waitForCondition(() => { + return autoCompletePopup.popupOpen; + }); + + gBrowser.removeCurrentTab(); + + await TestUtils.waitForCondition(() => { + return !autoCompletePopup.popupOpen; + }); + + Assert.ok(!autoCompletePopup.popupOpen, "Ensure the popup is closed."); + } + ); +}); diff --git a/toolkit/components/satchel/test/browser/browser_popup_mouseover.js b/toolkit/components/satchel/test/browser/browser_popup_mouseover.js new file mode 100644 index 0000000000..2b293ab983 --- /dev/null +++ b/toolkit/components/satchel/test/browser/browser_popup_mouseover.js @@ -0,0 +1,81 @@ +/* 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 { FormHistory } = ChromeUtils.importESModule( + "resource://gre/modules/FormHistory.sys.mjs" +); + +add_task(async function test() { + const url = `data:text/html,`; + await BrowserTestUtils.withNewTab( + { gBrowser, url }, + async function (browser) { + const { + autoCompletePopup, + autoCompletePopup: { richlistbox: itemsBox }, + } = browser; + const mockHistory = [ + { op: "add", fieldname: "field1", value: "value1" }, + { op: "add", fieldname: "field1", value: "value2" }, + { op: "add", fieldname: "field1", value: "value3" }, + { op: "add", fieldname: "field1", value: "value4" }, + ]; + + await FormHistory.update([{ op: "remove" }, ...mockHistory]); + await SpecialPowers.spawn(browser, [], async function () { + const input = content.document.querySelector("input"); + + input.focus(); + }); + + // show popup + await BrowserTestUtils.synthesizeKey("VK_DOWN", {}, browser); + await BrowserTestUtils.waitForCondition(() => { + return autoCompletePopup.popupOpen; + }); + const listItemElems = itemsBox.querySelectorAll( + ".autocomplete-richlistitem" + ); + Assert.equal( + listItemElems.length, + mockHistory.length, + "ensure result length" + ); + Assert.equal( + autoCompletePopup.mousedOverIndex, + -1, + "mousedOverIndex should be -1" + ); + + // navigate to the first item + await BrowserTestUtils.synthesizeKey("VK_DOWN", {}, browser); + Assert.equal( + autoCompletePopup.selectedIndex, + 0, + "selectedIndex should be 0" + ); + + // mouseover the second item + EventUtils.synthesizeMouseAtCenter(listItemElems[1], { + type: "mouseover", + }); + await BrowserTestUtils.waitForCondition(() => { + return (autoCompletePopup.mousedOverIndex = 1); + }); + Assert.ok(true, "mousedOverIndex changed"); + Assert.equal( + autoCompletePopup.selectedIndex, + 0, + "selectedIndex should not be changed by mouseover" + ); + + // close popup + await SpecialPowers.spawn(browser, [], async function () { + const input = content.document.querySelector("input"); + + input.blur(); + }); + } + ); +}); diff --git a/toolkit/components/satchel/test/browser/browser_privbrowsing_perwindowpb.js b/toolkit/components/satchel/test/browser/browser_privbrowsing_perwindowpb.js new file mode 100644 index 0000000000..3abc6ebe54 --- /dev/null +++ b/toolkit/components/satchel/test/browser/browser_privbrowsing_perwindowpb.js @@ -0,0 +1,49 @@ +/* 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/. */ + +var { FormHistory } = ChromeUtils.importESModule( + "resource://gre/modules/FormHistory.sys.mjs" +); + +/** Test for Bug 472396 */ +add_task(async function test() { + // initialization + let windowsToClose = []; + let testURI = + "http://example.com/tests/toolkit/components/satchel/test/subtst_privbrowsing.html"; + + async function doTest(aShouldValueExist, aWindow) { + let browser = aWindow.gBrowser.selectedBrowser; + BrowserTestUtils.startLoadingURIString(browser, testURI); + await BrowserTestUtils.browserLoaded(browser); + + // Wait for the page to reload itself. + await BrowserTestUtils.browserLoaded(browser); + + let count = await FormHistory.count({ fieldname: "field", value: "value" }); + + if (aShouldValueExist) { + Assert.equal(count, 1, "In non-PB mode, we add a single entry"); + } else { + Assert.equal(count, 0, "In PB mode, we don't add any entries"); + } + } + + function testOnWindow(aOptions, aCallback) { + return BrowserTestUtils.openNewBrowserWindow(aOptions).then(win => { + windowsToClose.push(win); + return win; + }); + } + + await testOnWindow({ private: true }).then(aWin => doTest(false, aWin)); + + // Test when not on private mode after visiting a site on private + // mode. The form history should not exist. + await testOnWindow({}).then(aWin => doTest(true, aWin)); + + await Promise.all( + windowsToClose.map(win => BrowserTestUtils.closeWindow(win)) + ); +}); diff --git a/toolkit/components/satchel/test/mochitest.toml b/toolkit/components/satchel/test/mochitest.toml new file mode 100644 index 0000000000..beff4bc7d7 --- /dev/null +++ b/toolkit/components/satchel/test/mochitest.toml @@ -0,0 +1,49 @@ +[DEFAULT] +skip-if = ["os == 'android'"] +support-files = [ + "satchel_common.js", + "subtst_form_submission_1.html", + "subtst_privbrowsing.html", + "parent_utils.js", +] + +["test_bug_511615.html"] + +["test_bug_787624.html"] + +["test_capture_limit.html"] + +["test_datalist_attribute_change.html"] + +["test_datalist_dynamic.html"] + +["test_datalist_readonly_change.html"] + +["test_datalist_shadow_dom.html"] + +["test_datalist_with_caching.html"] + +["test_form_autocomplete.html"] + +["test_form_autocomplete_validation_at_input_event.html"] + +["test_form_autocomplete_with_list.html"] +skip-if = [ + "http3", + "http2", +] + +["test_form_submission.html"] + +["test_history_datalist_duplicates.html"] + +["test_input_valid_state_with_autocomplete.html"] + +["test_password_autocomplete.html"] +scheme = "https" + +["test_popup_direction.html"] + +["test_popup_enter_event.html"] + +["test_submit_on_keydown_enter.html"] diff --git a/toolkit/components/satchel/test/parent_utils.js b/toolkit/components/satchel/test/parent_utils.js new file mode 100644 index 0000000000..a46db9a63c --- /dev/null +++ b/toolkit/components/satchel/test/parent_utils.js @@ -0,0 +1,194 @@ +/* eslint-env mozilla/chrome-script */ + +const { FormHistory } = ChromeUtils.importESModule( + "resource://gre/modules/FormHistory.sys.mjs" +); +const { ContentTaskUtils } = ChromeUtils.importESModule( + "resource://testing-common/ContentTaskUtils.sys.mjs" +); +const { TestUtils } = ChromeUtils.importESModule( + "resource://testing-common/TestUtils.sys.mjs" +); + +var gAutocompletePopup = + Services.ww.activeWindow.document.getElementById("PopupAutoComplete"); +assert.ok(gAutocompletePopup, "Got autocomplete popup"); + +var ParentUtils = { + getMenuEntries() { + let entries = []; + let numRows = gAutocompletePopup.view.matchCount; + for (let i = 0; i < numRows; i++) { + entries.push(gAutocompletePopup.view.getValueAt(i)); + } + return entries; + }, + + cleanUpFormHistory() { + return FormHistory.update({ op: "remove" }); + }, + + updateFormHistory(changes) { + FormHistory.update(changes).then( + () => { + sendAsyncMessage("formHistoryUpdated", { ok: true }); + }, + error => { + sendAsyncMessage("formHistoryUpdated", { ok: false }); + assert.ok(false, error); + } + ); + }, + + popupshownListener() { + let results = this.getMenuEntries(); + sendAsyncMessage("onpopupshown", { results }); + }, + + countEntries(name, value) { + let obj = {}; + if (name) { + obj.fieldname = name; + } + if (value) { + obj.value = value; + } + + FormHistory.count(obj).then( + count => { + sendAsyncMessage("entriesCounted", { ok: true, count }); + }, + error => { + assert.ok(false, error); + sendAsyncMessage("entriesCounted", { ok: false }); + } + ); + }, + + async checkRowCount(expectedCount, expectedFirstValue = null) { + await ContentTaskUtils.waitForCondition(() => { + // This may be called before gAutocompletePopup has initialised + // which causes it to throw + try { + return ( + gAutocompletePopup.view.matchCount === expectedCount && + (!expectedFirstValue || + expectedCount <= 1 || + gAutocompletePopup.view.getValueAt(0) === expectedFirstValue) + ); + } catch (e) { + return false; + } + }, `Waiting for row count change to ${expectedCount}, first value: ${expectedFirstValue}.`); + return this.getMenuEntries(); + }, + + async checkSelectedIndex(expectedIndex) { + await ContentTaskUtils.waitForCondition( + () => + gAutocompletePopup.popupOpen && + gAutocompletePopup.selectedIndex === expectedIndex, + "Checking selected index" + ); + }, + + // Tests using this function need to flip pref for exceptional use of + // `new Function` / `eval()`. + // See test_autofill_and_ordinal_forms.html for example. + testMenuEntry(index, statement) { + ContentTaskUtils.waitForCondition(() => { + let el = gAutocompletePopup.richlistbox.getItemAtIndex(index); + let testFunc = new Services.ww.activeWindow.Function( + "el", + `return ${statement}` + ); + return gAutocompletePopup.popupOpen && el && testFunc(el); + }, "Testing menu entry").then(() => { + sendAsyncMessage("menuEntryTested"); + }); + }, + + getPopupState() { + function reply() { + sendAsyncMessage("gotPopupState", { + open: gAutocompletePopup.popupOpen, + selectedIndex: gAutocompletePopup.selectedIndex, + direction: gAutocompletePopup.style.direction, + }); + } + // If the popup state is stable, we can reply immediately. However, if + // it's showing or hiding, we should wait its finish and then, send the + // reply. + if ( + gAutocompletePopup.state == "open" || + gAutocompletePopup.state == "closed" + ) { + reply(); + return; + } + const stablerState = + gAutocompletePopup.state == "showing" ? "open" : "closed"; + TestUtils.waitForCondition( + () => gAutocompletePopup.state == stablerState, + `Waiting for autocomplete popup getting "${stablerState}" state` + ).then(reply); + }, + + observe(_subject, topic, data) { + // This function can be called after SimpleTest.finish(). + // Do not write assertions here, they will lead to intermittent failures. + sendAsyncMessage("satchel-storage-changed", { subject: null, topic, data }); + }, + + async cleanup() { + gAutocompletePopup.removeEventListener( + "popupshown", + this._popupshownListener + ); + await this.cleanUpFormHistory(); + }, +}; + +ParentUtils._popupshownListener = + ParentUtils.popupshownListener.bind(ParentUtils); +gAutocompletePopup.addEventListener( + "popupshown", + ParentUtils._popupshownListener +); +ParentUtils.cleanUpFormHistory(); + +addMessageListener("updateFormHistory", msg => { + ParentUtils.updateFormHistory(msg.changes); +}); + +addMessageListener("countEntries", ({ name, value }) => { + ParentUtils.countEntries(name, value); +}); + +addMessageListener( + "waitForMenuChange", + ({ expectedCount, expectedFirstValue }) => + ParentUtils.checkRowCount(expectedCount, expectedFirstValue) +); + +addMessageListener("waitForSelectedIndex", ({ expectedIndex }) => + ParentUtils.checkSelectedIndex(expectedIndex) +); +addMessageListener("waitForMenuEntryTest", ({ index, statement }) => { + ParentUtils.testMenuEntry(index, statement); +}); + +addMessageListener("getPopupState", () => { + ParentUtils.getPopupState(); +}); + +addMessageListener("addObserver", () => { + Services.obs.addObserver(ParentUtils, "satchel-storage-changed"); +}); +addMessageListener("removeObserver", () => { + Services.obs.removeObserver(ParentUtils, "satchel-storage-changed"); +}); + +addMessageListener("cleanup", async () => { + await ParentUtils.cleanup(); +}); diff --git a/toolkit/components/satchel/test/satchel_common.js b/toolkit/components/satchel/test/satchel_common.js new file mode 100644 index 0000000000..4e3436aba7 --- /dev/null +++ b/toolkit/components/satchel/test/satchel_common.js @@ -0,0 +1,316 @@ +/* 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/. */ + +var gPopupShownExpected = false; +var gPopupShownListener; +var gLastAutoCompleteResults; +var gChromeScript; + +const TelemetryFilterPropsAC = Object.freeze({ + category: "form_autocomplete", + method: "show", + object: "logins", +}); + +/* + * Returns the element with the specified |name| attribute. + */ +function getFormElementByName(formNum, name) { + const formElement = document.querySelector( + `#form${formNum} [name="${name}"]` + ); + + if (!formElement) { + ok(false, `getFormElementByName: Couldn't find specified CSS selector.`); + return null; + } + + return formElement; +} + +function registerPopupShownListener(listener) { + if (gPopupShownListener) { + ok(false, "got too many popupshownlisteners"); + return; + } + gPopupShownListener = listener; +} + +function getMenuEntries() { + if (!gLastAutoCompleteResults) { + throw new Error("no autocomplete results"); + } + + let results = gLastAutoCompleteResults; + gLastAutoCompleteResults = null; + return results; +} + +class StorageEventsObserver { + promisesToResolve = []; + + constructor() { + gChromeScript.sendAsyncMessage("addObserver"); + gChromeScript.addMessageListener( + "satchel-storage-changed", + this.observe.bind(this) + ); + } + + async cleanup() { + await gChromeScript.sendQuery("removeObserver"); + } + + observe({ subject, topic, data }) { + this.promisesToResolve.shift()?.({ subject, topic, data }); + } + + promiseNextStorageEvent() { + return new Promise(resolve => this.promisesToResolve.push(resolve)); + } +} + +function getFormSubmitButton(formNum) { + let form = $("form" + formNum); // by id, not name + ok(form != null, "getting form " + formNum); + + // we can't just call form.submit(), because that doesn't seem to + // invoke the form onsubmit handler. + let button = form.firstChild; + while (button && button.type != "submit") { + button = button.nextSibling; + } + ok(button != null, "getting form submit button"); + + return button; +} + +// Count the number of entries with the given name and value, and call then(number) +// when done. If name or value is null, then the value of that field does not matter. +function countEntries(name, value, then = null) { + return new Promise(resolve => { + gChromeScript.sendAsyncMessage("countEntries", { name, value }); + gChromeScript.addMessageListener("entriesCounted", function counted(data) { + gChromeScript.removeMessageListener("entriesCounted", counted); + if (!data.ok) { + ok(false, "Error occurred counting form history"); + SimpleTest.finish(); + return; + } + + if (then) { + then(data.count); + } + resolve(data.count); + }); + }); +} + +// Wrapper around FormHistory.update which handles errors. Calls then() when done. +function updateFormHistory(changes, then = null) { + return new Promise(resolve => { + gChromeScript.sendAsyncMessage("updateFormHistory", { changes }); + gChromeScript.addMessageListener( + "formHistoryUpdated", + function updated({ ok }) { + gChromeScript.removeMessageListener("formHistoryUpdated", updated); + if (!ok) { + ok(false, "Error occurred updating form history"); + SimpleTest.finish(); + return; + } + + if (then) { + then(); + } + resolve(); + } + ); + }); +} + +async function notifyMenuChanged(expectedCount, expectedFirstValue) { + gLastAutoCompleteResults = await gChromeScript.sendQuery( + "waitForMenuChange", + { expectedCount, expectedFirstValue } + ); + return gLastAutoCompleteResults; +} + +function notifySelectedIndex(expectedIndex) { + return gChromeScript.sendQuery("waitForSelectedIndex", { expectedIndex }); +} + +function testMenuEntry(index, statement) { + return new Promise(resolve => { + gChromeScript.sendAsyncMessage("waitForMenuEntryTest", { + index, + statement, + }); + gChromeScript.addMessageListener("menuEntryTested", function changed() { + gChromeScript.removeMessageListener("menuEntryTested", changed); + resolve(); + }); + }); +} + +function getPopupState(then = null) { + return new Promise(resolve => { + gChromeScript.sendAsyncMessage("getPopupState"); + gChromeScript.addMessageListener("gotPopupState", function listener(state) { + gChromeScript.removeMessageListener("gotPopupState", listener); + if (then) { + then(state); + } + resolve(state); + }); + }); +} + +function listenForUnexpectedPopupShown() { + gPopupShownListener = function onPopupShown() { + if (!gPopupShownExpected) { + ok(false, "Unexpected autocomplete popupshown event"); + } + }; +} + +async function popupBy(triggerFn) { + gPopupShownExpected = true; + const promise = new Promise(resolve => { + gPopupShownListener = ({ results }) => { + gPopupShownExpected = false; + resolve(results); + }; + }); + if (triggerFn) { + triggerFn(); + } + return promise; +} + +async function noPopupBy(triggerFn) { + gPopupShownExpected = false; + listenForUnexpectedPopupShown(); + SimpleTest.requestFlakyTimeout( + "Giving a chance for an unexpected popupshown to occur" + ); + if (triggerFn) { + await triggerFn(); + } + await new Promise(resolve => setTimeout(resolve, 500)); +} + +async function popupByArrowDown() { + return popupBy(() => { + synthesizeKey("KEY_Escape"); // in case popup is already open + synthesizeKey("KEY_ArrowDown"); + }); +} + +async function noPopupByArrowDown() { + await noPopupBy(() => { + synthesizeKey("KEY_Escape"); // in case popup is already open + synthesizeKey("KEY_ArrowDown"); + }); +} + +function checkACTelemetryEvent(actualEvent, input, augmentedExtra) { + ok( + parseInt(actualEvent[4], 10) > 0, + "elapsed time is a positive integer after converting from a string" + ); + let expectedExtra = { + acFieldName: SpecialPowers.wrap(input).getAutocompleteInfo().fieldName, + typeWasPassword: SpecialPowers.wrap(input).hasBeenTypePassword ? "1" : "0", + fieldType: input.type, + stringLength: input.value.length + "", + ...augmentedExtra, + }; + isDeeply(actualEvent[5], expectedExtra, "Check event extra object"); +} + +let gStorageEventsObserver; + +function promiseNextStorageEvent() { + return gStorageEventsObserver.promiseNextStorageEvent(); +} + +function satchelCommonSetup() { + let chromeURL = SimpleTest.getTestFileURL("parent_utils.js"); + gChromeScript = SpecialPowers.loadChromeScript(chromeURL); + gChromeScript.addMessageListener("onpopupshown", ({ results }) => { + gLastAutoCompleteResults = results; + if (gPopupShownListener) { + gPopupShownListener({ results }); + } + }); + + gStorageEventsObserver = new StorageEventsObserver(); + + SimpleTest.registerCleanupFunction(async () => { + await gStorageEventsObserver.cleanup(); + await gChromeScript.sendQuery("cleanup"); + gChromeScript.destroy(); + }); +} + +function add_named_task(name, fn) { + return add_task( + { + [name]() { + return fn(); + }, + }[name] + ); +} + +function preventSubmitOnForms() { + for (const form of document.querySelectorAll("form")) { + form.onsubmit = e => e.preventDefault(); + } +} + +/** + * Press requested keys and assert input's value + * + * @param {HTMLInputElement} input + * @param {string | Array} keys + * @param {string} expectedValue + */ +function assertValueAfterKeys(input, keys, expectedValue) { + if (!Array.isArray(keys)) { + keys = [keys]; + } + for (const key of keys) { + synthesizeKey(key); + } + + is(input.value, expectedValue, "input value"); +} + +function assertAutocompleteItems(...expectedValues) { + const actualValues = getMenuEntries(); + isDeeply(actualValues, expectedValues, "expected autocomplete list"); +} + +function deleteSelectedAutocompleteItem() { + synthesizeKey("KEY_Delete", { shiftKey: true }); +} + +async function openPopupOn( + inputOrSelector, + { inputValue = "", expectPopup = true } = {} +) { + const input = + typeof inputOrSelector == "string" + ? document.querySelector(inputOrSelector) + : inputOrSelector; + input.value = inputValue; + input.focus(); + const items = await (expectPopup ? popupByArrowDown() : noPopupByArrowDown()); + return { input, items }; +} + +satchelCommonSetup(); diff --git a/toolkit/components/satchel/test/subtst_form_submission_1.html b/toolkit/components/satchel/test/subtst_form_submission_1.html new file mode 100644 index 0000000000..c1b902d6bc --- /dev/null +++ b/toolkit/components/satchel/test/subtst_form_submission_1.html @@ -0,0 +1,15 @@ + + + + + + + + +
+ + +
+ + + diff --git a/toolkit/components/satchel/test/subtst_privbrowsing.html b/toolkit/components/satchel/test/subtst_privbrowsing.html new file mode 100644 index 0000000000..a61da1a714 --- /dev/null +++ b/toolkit/components/satchel/test/subtst_privbrowsing.html @@ -0,0 +1,23 @@ + + + + Subtest for bug 472396 + + + +

Subtest for bug 472396

+
+ +
+ + diff --git a/toolkit/components/satchel/test/test_bug_511615.html b/toolkit/components/satchel/test/test_bug_511615.html new file mode 100644 index 0000000000..2d67c5468b --- /dev/null +++ b/toolkit/components/satchel/test/test_bug_511615.html @@ -0,0 +1,179 @@ + + + + Test for Form History Autocomplete Untrusted Events: Bug 511615 + + + + + + +Test for Form History Autocomplete Untrusted Events: Bug 511615 +

+ + +
+ +
+ + +
+
+ + + + diff --git a/toolkit/components/satchel/test/test_bug_787624.html b/toolkit/components/satchel/test/test_bug_787624.html new file mode 100644 index 0000000000..13af53a477 --- /dev/null +++ b/toolkit/components/satchel/test/test_bug_787624.html @@ -0,0 +1,73 @@ + + + + Test for Layout of Form History Autocomplete: Bug 787624 + + + + + + + +Form History Layout test: form field autocomplete: Bug 787624 +

+ + +
+ +
+
+
+ + +
+
+
+
+ + + + diff --git a/toolkit/components/satchel/test/test_capture_limit.html b/toolkit/components/satchel/test/test_capture_limit.html new file mode 100644 index 0000000000..8591544016 --- /dev/null +++ b/toolkit/components/satchel/test/test_capture_limit.html @@ -0,0 +1,61 @@ + + + + Form History capture no more than 100 changes + + + + + +

+ + + + diff --git a/toolkit/components/satchel/test/test_datalist_attribute_change.html b/toolkit/components/satchel/test/test_datalist_attribute_change.html new file mode 100644 index 0000000000..f9c774f8d0 --- /dev/null +++ b/toolkit/components/satchel/test/test_datalist_attribute_change.html @@ -0,0 +1,53 @@ + + + + Test for Form History / Attribute change with datalist entries: Bug 1767250 + + + + + + +
+ +
+ + + +
+ +
+ + + + diff --git a/toolkit/components/satchel/test/test_datalist_dynamic.html b/toolkit/components/satchel/test/test_datalist_dynamic.html new file mode 100644 index 0000000000..89452236ec --- /dev/null +++ b/toolkit/components/satchel/test/test_datalist_dynamic.html @@ -0,0 +1,82 @@ + + +Dynamic change datalist + + + + + + + + + diff --git a/toolkit/components/satchel/test/test_datalist_readonly_change.html b/toolkit/components/satchel/test/test_datalist_readonly_change.html new file mode 100644 index 0000000000..986ef0cb7e --- /dev/null +++ b/toolkit/components/satchel/test/test_datalist_readonly_change.html @@ -0,0 +1,41 @@ + + + + + Dynamic change to readonly doesn't prevent datalist to keep working + + + + + + + + + + + + + + + + + diff --git a/toolkit/components/satchel/test/test_datalist_shadow_dom.html b/toolkit/components/satchel/test/test_datalist_shadow_dom.html new file mode 100644 index 0000000000..e2baeccb34 --- /dev/null +++ b/toolkit/components/satchel/test/test_datalist_shadow_dom.html @@ -0,0 +1,55 @@ + + + + Test for datalist in Shadow DOM + + + + + + +

+
+
+
+ + + + diff --git a/toolkit/components/satchel/test/test_datalist_with_caching.html b/toolkit/components/satchel/test/test_datalist_with_caching.html new file mode 100644 index 0000000000..9366d69ff6 --- /dev/null +++ b/toolkit/components/satchel/test/test_datalist_with_caching.html @@ -0,0 +1,64 @@ + + + + Test for Form History Autocomplete + + + + + + +Form History test: form field autocomplete +

+ + +
+ + +
+ + +
+ + + + + + +
+ + + + diff --git a/toolkit/components/satchel/test/test_form_autocomplete.html b/toolkit/components/satchel/test/test_form_autocomplete.html new file mode 100644 index 0000000000..ee0b2d5e14 --- /dev/null +++ b/toolkit/components/satchel/test/test_form_autocomplete.html @@ -0,0 +1,698 @@ + + + + Test for Form History Autocomplete + + + + + + + +Form History test: form field autocomplete +

+ + +
+ + +
+ + +
+ + +
+ + +
+ +
+ Space to force a scroll on form3 focus +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ +
+ + + + diff --git a/toolkit/components/satchel/test/test_form_autocomplete_validation_at_input_event.html b/toolkit/components/satchel/test/test_form_autocomplete_validation_at_input_event.html new file mode 100644 index 0000000000..7df6e97f27 --- /dev/null +++ b/toolkit/components/satchel/test/test_form_autocomplete_validation_at_input_event.html @@ -0,0 +1,83 @@ + + + + Test for validation has been done before "input" event + + + + + + + +

+ +
+
+ +
+
+ + + + diff --git a/toolkit/components/satchel/test/test_form_autocomplete_with_list.html b/toolkit/components/satchel/test/test_form_autocomplete_with_list.html new file mode 100644 index 0000000000..b903af5246 --- /dev/null +++ b/toolkit/components/satchel/test/test_form_autocomplete_with_list.html @@ -0,0 +1,402 @@ + + + + Test for Form History Autocomplete + + + + + + +Form History test: form field autocomplete +

+ + +
+ + + + + + + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ +
+ + + + + + + + + + + + + + + + + + + + + +
+
+ + + + diff --git a/toolkit/components/satchel/test/test_form_submission.html b/toolkit/components/satchel/test/test_form_submission.html new file mode 100644 index 0000000000..d1c0542609 --- /dev/null +++ b/toolkit/components/satchel/test/test_form_submission.html @@ -0,0 +1,598 @@ + + + + Satchel Test for Form Submisstion + + + + + +

+ + + + + diff --git a/toolkit/components/satchel/test/test_history_datalist_duplicates.html b/toolkit/components/satchel/test/test_history_datalist_duplicates.html new file mode 100644 index 0000000000..395405396a --- /dev/null +++ b/toolkit/components/satchel/test/test_history_datalist_duplicates.html @@ -0,0 +1,55 @@ + + + + Test for Form History / DataList Duplicate Autocomplete Entries: Bug 1263588 + + + + + + +
+ + +
+ + + + +
+ +
+ + + + diff --git a/toolkit/components/satchel/test/test_input_valid_state_with_autocomplete.html b/toolkit/components/satchel/test/test_input_valid_state_with_autocomplete.html new file mode 100644 index 0000000000..196ca59765 --- /dev/null +++ b/toolkit/components/satchel/test/test_input_valid_state_with_autocomplete.html @@ -0,0 +1,125 @@ + + + + + Test for valid state with autocomplete + + + + + + + +

+ +
+
+ + +
+
+ + + diff --git a/toolkit/components/satchel/test/test_password_autocomplete.html b/toolkit/components/satchel/test/test_password_autocomplete.html new file mode 100644 index 0000000000..d24f556d9e --- /dev/null +++ b/toolkit/components/satchel/test/test_password_autocomplete.html @@ -0,0 +1,89 @@ + + + + Test for form history on type=password + + + + + + + Test for form history on type=password + (based on test_bug_511615.html) +

+ + +
+ + + + + +
+ + + +
+ +
+ + +
+
+ + + + diff --git a/toolkit/components/satchel/test/test_popup_direction.html b/toolkit/components/satchel/test/test_popup_direction.html new file mode 100644 index 0000000000..6b2fa010d6 --- /dev/null +++ b/toolkit/components/satchel/test/test_popup_direction.html @@ -0,0 +1,44 @@ + + + + Test for Popup Direction + + + + + + +Test for Popup Direction +

+ + +
+ +
+ + +
+
+ + + + diff --git a/toolkit/components/satchel/test/test_popup_enter_event.html b/toolkit/components/satchel/test/test_popup_enter_event.html new file mode 100644 index 0000000000..6150a8a57a --- /dev/null +++ b/toolkit/components/satchel/test/test_popup_enter_event.html @@ -0,0 +1,70 @@ + + + + + Test for events while the form history popup is open + + + + + + +Form History test: Test for events while the form history popup is open +

+ +
+
+ + +
+
+ + + + diff --git a/toolkit/components/satchel/test/test_submit_on_keydown_enter.html b/toolkit/components/satchel/test/test_submit_on_keydown_enter.html new file mode 100644 index 0000000000..f5879a2b5e --- /dev/null +++ b/toolkit/components/satchel/test/test_submit_on_keydown_enter.html @@ -0,0 +1,108 @@ + + + + + Test for events while the form history popup is open + + + + + + +Form History test: Test for keydown handler submitting the form +

+ +
+
+ + +
+
+ + + + diff --git a/toolkit/components/satchel/test/unit/asyncformhistory_expire.sqlite b/toolkit/components/satchel/test/unit/asyncformhistory_expire.sqlite new file mode 100644 index 0000000000..07b43c2096 Binary files /dev/null and b/toolkit/components/satchel/test/unit/asyncformhistory_expire.sqlite differ diff --git a/toolkit/components/satchel/test/unit/formhistory_1000.sqlite b/toolkit/components/satchel/test/unit/formhistory_1000.sqlite new file mode 100644 index 0000000000..5eeab074fd Binary files /dev/null and b/toolkit/components/satchel/test/unit/formhistory_1000.sqlite differ diff --git a/toolkit/components/satchel/test/unit/formhistory_CORRUPT.sqlite b/toolkit/components/satchel/test/unit/formhistory_CORRUPT.sqlite new file mode 100644 index 0000000000..5f7498bfc2 --- /dev/null +++ b/toolkit/components/satchel/test/unit/formhistory_CORRUPT.sqlite @@ -0,0 +1 @@ +BACON diff --git a/toolkit/components/satchel/test/unit/formhistory_apitest.sqlite b/toolkit/components/satchel/test/unit/formhistory_apitest.sqlite new file mode 100644 index 0000000000..00daf03c27 Binary files /dev/null and b/toolkit/components/satchel/test/unit/formhistory_apitest.sqlite differ diff --git a/toolkit/components/satchel/test/unit/formhistory_autocomplete.sqlite b/toolkit/components/satchel/test/unit/formhistory_autocomplete.sqlite new file mode 100644 index 0000000000..724cff73f6 Binary files /dev/null and b/toolkit/components/satchel/test/unit/formhistory_autocomplete.sqlite differ diff --git a/toolkit/components/satchel/test/unit/formhistory_v3.sqlite b/toolkit/components/satchel/test/unit/formhistory_v3.sqlite new file mode 100644 index 0000000000..e0e8fe2468 Binary files /dev/null and b/toolkit/components/satchel/test/unit/formhistory_v3.sqlite differ diff --git a/toolkit/components/satchel/test/unit/formhistory_v3v4.sqlite b/toolkit/components/satchel/test/unit/formhistory_v3v4.sqlite new file mode 100644 index 0000000000..8eab177e97 Binary files /dev/null and b/toolkit/components/satchel/test/unit/formhistory_v3v4.sqlite differ diff --git a/toolkit/components/satchel/test/unit/formhistory_v999a.sqlite b/toolkit/components/satchel/test/unit/formhistory_v999a.sqlite new file mode 100644 index 0000000000..216bce4a34 Binary files /dev/null and b/toolkit/components/satchel/test/unit/formhistory_v999a.sqlite differ diff --git a/toolkit/components/satchel/test/unit/formhistory_v999b.sqlite b/toolkit/components/satchel/test/unit/formhistory_v999b.sqlite new file mode 100644 index 0000000000..fe400d04a1 Binary files /dev/null and b/toolkit/components/satchel/test/unit/formhistory_v999b.sqlite differ diff --git a/toolkit/components/satchel/test/unit/head_satchel.js b/toolkit/components/satchel/test/unit/head_satchel.js new file mode 100644 index 0000000000..3ff06b89fe --- /dev/null +++ b/toolkit/components/satchel/test/unit/head_satchel.js @@ -0,0 +1,188 @@ +/* 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 CURRENT_SCHEMA = 5; +const PR_HOURS = 60 * 60 * 1000000; + +var { XPCOMUtils } = ChromeUtils.importESModule( + "resource://gre/modules/XPCOMUtils.sys.mjs" +); + +ChromeUtils.defineESModuleGetters(this, { + FormHistory: "resource://gre/modules/FormHistory.sys.mjs", + FormHistoryTestUtils: + "resource://testing-common/FormHistoryTestUtils.sys.mjs", + Sqlite: "resource://gre/modules/Sqlite.sys.mjs", +}); + +do_get_profile(); + +// Send the profile-after-change notification to the form history component to ensure +// that it has been initialized. +var formHistoryStartup = Cc[ + "@mozilla.org/satchel/form-history-startup;1" +].getService(Ci.nsIObserver); +formHistoryStartup.observe(null, "profile-after-change", null); + +async function getDBVersion(dbfile) { + let dbConnection = await Sqlite.openConnection({ path: dbfile.path }); + let version = await dbConnection.getSchemaVersion(); + await dbConnection.close(); + + return version; +} + +async function getDBSchemaVersion(path) { + let db = await Sqlite.openConnection({ path }); + try { + return await db.getSchemaVersion(); + } finally { + await db.close(); + } +} + +function getFormHistoryDBVersion() { + let profileDir = do_get_profile(); + // Cleanup from any previous tests or failures. + let dbFile = profileDir.clone(); + dbFile.append("formhistory.sqlite"); + return getDBVersion(dbFile); +} + +const isGUID = /[A-Za-z0-9\+\/]{16}/; + +// Find form history entries. +function searchEntries(terms, params, iter) { + FormHistory.search(terms, params).then( + results => iter.next(results), + error => do_throw("Error occurred searching form history: " + error) + ); +} + +// Count the number of entries with the given name and value, and call then(number) +// when done. If name or value is null, then the value of that field does not matter. +function countEntries(name, value, then) { + let obj = {}; + if (name !== null) { + obj.fieldname = name; + } + if (value !== null) { + obj.value = value; + } + + FormHistory.count(obj).then( + count => { + then(count); + }, + error => { + do_throw("Error occurred searching form history: " + error); + } + ); +} + +// Perform a single form history update and call then() when done. +function updateEntry(op, name, value, then) { + let obj = { op }; + if (name !== null) { + obj.fieldname = name; + } + if (value !== null) { + obj.value = value; + } + updateFormHistory(obj, then); +} + +// Add a single form history entry with the current time and call then() when done. +function addEntry(name, value, then) { + let now = Date.now() * 1000; + updateFormHistory( + { + op: "add", + fieldname: name, + value, + timesUsed: 1, + firstUsed: now, + lastUsed: now, + }, + then + ); +} + +function promiseCountEntries(name, value, checkFn = () => {}) { + return new Promise(resolve => { + countEntries(name, value, function (result) { + checkFn(result); + resolve(result); + }); + }); +} + +function promiseUpdateEntry(op, name, value) { + return new Promise(res => { + updateEntry(op, name, value, res); + }); +} + +function promiseAddEntry(name, value) { + return new Promise(res => { + addEntry(name, value, res); + }); +} + +// Wrapper around FormHistory.update which handles errors. Calls then() when done. +function updateFormHistory(changes, then) { + FormHistory.update(changes).then(then, error => { + do_throw("Error occurred updating form history: " + error); + }); +} + +function promiseUpdate(change) { + return FormHistory.update(change); +} + +/** + * Logs info to the console in the standard way (includes the filename). + * + * @param {string} aMessage + * The message to log to the console. + */ +function do_log_info(aMessage) { + print("TEST-INFO | " + _TEST_FILE + " | " + aMessage); +} + +/** + * Copies a test file into the profile folder. + * + * @param {string} aFilename + * The name of the file to copy. + * @param {string} aDestFilename + * The name of the file to copy. + * @param {object} [options] + * @param {object} [options.overwriteExisting] + * Whether to overwrite an existing file. + * @returns {string} path to the copied file. + */ +async function copyToProfile( + aFilename, + aDestFilename, + { overwriteExisting = false } = {} +) { + let curDir = Services.dirsvc.get("CurWorkD", Ci.nsIFile).path; + let srcPath = PathUtils.join(curDir, aFilename); + Assert.ok(await IOUtils.exists(srcPath), "Database file found"); + + // Ensure that our file doesn't exist already. + let destPath = PathUtils.join(PathUtils.profileDir, aDestFilename); + let exists = await IOUtils.exists(destPath); + if (exists) { + if (overwriteExisting) { + await IOUtils.remove(destPath); + } else { + throw new Error("The file should not exist"); + } + } + await IOUtils.copy(srcPath, destPath); + info(`Copied ${aFilename} to ${destPath}`); + return destPath; +} diff --git a/toolkit/components/satchel/test/unit/test_async_expire.js b/toolkit/components/satchel/test/unit/test_async_expire.js new file mode 100644 index 0000000000..b6f25b81dd --- /dev/null +++ b/toolkit/components/satchel/test/unit/test_async_expire.js @@ -0,0 +1,134 @@ +/* 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/. */ + +ChromeUtils.defineESModuleGetters(this, { + TestUtils: "resource://testing-common/TestUtils.sys.mjs", +}); + +function promiseExpiration() { + let promise = TestUtils.topicObserved( + "satchel-storage-changed", + (subject, data) => { + return data == "formhistory-expireoldentries"; + } + ); + + // We can't easily fake a "daily idle" event, so for testing purposes form + // history listens for another notification to trigger an immediate + // expiration. + Services.obs.notifyObservers(null, "formhistory-expire-now"); + + return promise; +} + +add_task(async function () { + // ===== test init ===== + let testfile = do_get_file("asyncformhistory_expire.sqlite"); + let profileDir = do_get_profile(); + + // Cleanup from any previous tests or failures. + let dbFile = profileDir.clone(); + dbFile.append("formhistory.sqlite"); + if (dbFile.exists()) { + dbFile.remove(false); + } + + testfile.copyTo(profileDir, "formhistory.sqlite"); + Assert.ok(dbFile.exists()); + + // We're going to clear this at the end, so it better have the default value now. + Assert.ok(!Services.prefs.prefHasUserValue("browser.formfill.expire_days")); + + // Sanity check initial state + Assert.equal(508, await promiseCountEntries(null, null)); + Assert.ok((await promiseCountEntries("name-A", "value-A")) > 0); // lastUsed == distant past + Assert.ok((await promiseCountEntries("name-B", "value-B")) > 0); // lastUsed == distant future + + Assert.equal(CURRENT_SCHEMA, await getDBVersion(dbFile)); + + // Add a new entry + Assert.equal(0, await promiseCountEntries("name-C", "value-C")); + await promiseAddEntry("name-C", "value-C"); + Assert.equal(1, await promiseCountEntries("name-C", "value-C")); + + // Update some existing entries to have ages relative to when the test runs. + let now = 1000 * Date.now(); + let updateLastUsed = (results, age) => { + let lastUsed = now - age * 24 * PR_HOURS; + + let changes = []; + for (let result of results) { + changes.push({ op: "update", lastUsed, guid: result.guid }); + } + + return changes; + }; + + let results = await FormHistory.search(["guid"], { lastUsed: 181 }); + await promiseUpdate(updateLastUsed(results, 181)); + + results = await FormHistory.search(["guid"], { lastUsed: 179 }); + await promiseUpdate(updateLastUsed(results, 179)); + + results = await FormHistory.search(["guid"], { lastUsed: 31 }); + await promiseUpdate(updateLastUsed(results, 31)); + + results = await FormHistory.search(["guid"], { lastUsed: 29 }); + await promiseUpdate(updateLastUsed(results, 29)); + + results = await FormHistory.search(["guid"], { lastUsed: 9999 }); + await promiseUpdate(updateLastUsed(results, 11)); + + results = await FormHistory.search(["guid"], { lastUsed: 9 }); + await promiseUpdate(updateLastUsed(results, 9)); + + Assert.ok((await promiseCountEntries("name-A", "value-A")) > 0); + Assert.ok((await promiseCountEntries("181DaysOld", "foo")) > 0); + Assert.ok((await promiseCountEntries("179DaysOld", "foo")) > 0); + Assert.equal(509, await promiseCountEntries(null, null)); + + // 2 entries are expected to expire. + await promiseExpiration(); + + Assert.equal(0, await promiseCountEntries("name-A", "value-A")); + Assert.equal(0, await promiseCountEntries("181DaysOld", "foo")); + Assert.ok((await promiseCountEntries("179DaysOld", "foo")) > 0); + Assert.equal(507, await promiseCountEntries(null, null)); + + // And again. No change expected. + await promiseExpiration(); + + Assert.equal(507, await promiseCountEntries(null, null)); + + // Set formfill pref to 30 days. + Services.prefs.setIntPref("browser.formfill.expire_days", 30); + + Assert.ok((await promiseCountEntries("179DaysOld", "foo")) > 0); + Assert.ok((await promiseCountEntries("bar", "31days")) > 0); + Assert.ok((await promiseCountEntries("bar", "29days")) > 0); + Assert.equal(507, await promiseCountEntries(null, null)); + + await promiseExpiration(); + + Assert.equal(0, await promiseCountEntries("179DaysOld", "foo")); + Assert.equal(0, await promiseCountEntries("bar", "31days")); + Assert.ok((await promiseCountEntries("bar", "29days")) > 0); + Assert.equal(505, await promiseCountEntries(null, null)); + + // Set override pref to 10 days and expire. This expires a large batch of + // entries, and should trigger a VACCUM to reduce file size. + Services.prefs.setIntPref("browser.formfill.expire_days", 10); + + Assert.ok((await promiseCountEntries("bar", "29days")) > 0); + Assert.ok((await promiseCountEntries("9DaysOld", "foo")) > 0); + Assert.equal(505, await promiseCountEntries(null, null)); + + await promiseExpiration(); + + Assert.equal(0, await promiseCountEntries("bar", "29days")); + Assert.ok((await promiseCountEntries("9DaysOld", "foo")) > 0); + Assert.ok((await promiseCountEntries("name-B", "value-B")) > 0); + Assert.ok((await promiseCountEntries("name-C", "value-C")) > 0); + Assert.equal(3, await promiseCountEntries(null, null)); +}); diff --git a/toolkit/components/satchel/test/unit/test_autocomplete.js b/toolkit/components/satchel/test/unit/test_autocomplete.js new file mode 100644 index 0000000000..13f66eb0f2 --- /dev/null +++ b/toolkit/components/satchel/test/unit/test_autocomplete.js @@ -0,0 +1,388 @@ +/* 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"; + +var fac; + +var numRecords, timeGroupingSize, now; + +const DEFAULT_EXPIRE_DAYS = 180; + +function padLeft(number, length) { + let str = number + ""; + while (str.length < length) { + str = "0" + str; + } + return str; +} + +function getFormExpiryDays() { + if (Services.prefs.prefHasUserValue("browser.formfill.expire_days")) { + return Services.prefs.getIntPref("browser.formfill.expire_days"); + } + return DEFAULT_EXPIRE_DAYS; +} + +function run_test() { + // ===== test init ===== + let testfile = do_get_file("formhistory_autocomplete.sqlite"); + let profileDir = Services.dirsvc.get("ProfD", Ci.nsIFile); + + // Cleanup from any previous tests or failures. + let destFile = profileDir.clone(); + destFile.append("formhistory.sqlite"); + if (destFile.exists()) { + destFile.remove(false); + } + + testfile.copyTo(profileDir, "formhistory.sqlite"); + + fac = Cc["@mozilla.org/satchel/form-autocomplete;1"].getService( + Ci.nsIFormAutoComplete + ); + + timeGroupingSize = + Services.prefs.getIntPref("browser.formfill.timeGroupingSize") * + 1000 * + 1000; + + run_next_test(); +} + +add_test(function test0() { + let maxTimeGroupings = Services.prefs.getIntPref( + "browser.formfill.maxTimeGroupings" + ); + let bucketSize = Services.prefs.getIntPref("browser.formfill.bucketSize"); + + // ===== Tests with constant timesUsed and varying lastUsed date ===== + // insert 2 records per bucket to check alphabetical sort within + now = 1000 * Date.now(); + numRecords = Math.ceil(maxTimeGroupings / bucketSize) * 2; + + let changes = []; + for (let i = 0; i < numRecords; i += 2) { + let useDate = now - (i / 2) * bucketSize * timeGroupingSize; + + changes.push({ + op: "add", + fieldname: "field1", + value: "value" + padLeft(numRecords - 1 - i, 2), + timesUsed: 1, + firstUsed: useDate, + lastUsed: useDate, + }); + changes.push({ + op: "add", + fieldname: "field1", + value: "value" + padLeft(numRecords - 2 - i, 2), + timesUsed: 1, + firstUsed: useDate, + lastUsed: useDate, + }); + } + + updateFormHistory(changes, run_next_test); +}); + +add_test(function test1() { + do_log_info("Check initial state is as expected"); + + countEntries(null, null, function () { + countEntries("field1", null, function (count) { + Assert.ok(count > 0); + run_next_test(); + }); + }); +}); + +add_test(function test2() { + do_log_info("Check search contains all entries"); + + fac.autoCompleteSearchAsync("field1", "", null, null, false, { + onSearchCompletion(aResults) { + Assert.equal(numRecords, aResults.matchCount); + run_next_test(); + }, + }); +}); + +add_test(function test3() { + do_log_info("Check search result ordering with empty search term"); + + let lastFound = numRecords; + fac.autoCompleteSearchAsync("field1", "", null, null, false, { + onSearchCompletion(aResults) { + for (let i = 0; i < numRecords; i += 2) { + Assert.equal( + parseInt(aResults.getValueAt(i + 1).substr(5), 10), + --lastFound + ); + Assert.equal( + parseInt(aResults.getValueAt(i).substr(5), 10), + --lastFound + ); + } + run_next_test(); + }, + }); +}); + +add_test(function test4() { + do_log_info('Check search result ordering with "v"'); + + let lastFound = numRecords; + fac.autoCompleteSearchAsync("field1", "v", null, null, false, { + onSearchCompletion(aResults) { + for (let i = 0; i < numRecords; i += 2) { + Assert.equal( + parseInt(aResults.getValueAt(i + 1).substr(5), 10), + --lastFound + ); + Assert.equal( + parseInt(aResults.getValueAt(i).substr(5), 10), + --lastFound + ); + } + run_next_test(); + }, + }); +}); + +const timesUsedSamples = 20; + +add_test(function test5() { + do_log_info("Begin tests with constant use dates and varying timesUsed"); + + let changes = []; + for (let i = 0; i < timesUsedSamples; i++) { + let timesUsed = timesUsedSamples - i; + let change = { + op: "add", + fieldname: "field2", + value: "value" + (timesUsedSamples - 1 - i), + timesUsed: timesUsed * timeGroupingSize, + firstUsed: now, + lastUsed: now, + }; + changes.push(change); + } + updateFormHistory(changes, run_next_test); +}); + +add_test(function test6() { + do_log_info("Check search result ordering with empty search term"); + + let lastFound = timesUsedSamples; + fac.autoCompleteSearchAsync("field2", "", null, null, false, { + onSearchCompletion(aResults) { + for (let i = 0; i < timesUsedSamples; i++) { + Assert.equal( + parseInt(aResults.getValueAt(i).substr(5), 10), + --lastFound + ); + } + run_next_test(); + }, + }); +}); + +add_test(function test7() { + do_log_info('Check search result ordering with "v"'); + + let lastFound = timesUsedSamples; + fac.autoCompleteSearchAsync("field2", "v", null, null, false, { + onSearchCompletion(aResults) { + for (let i = 0; i < timesUsedSamples; i++) { + Assert.equal( + parseInt(aResults.getValueAt(i).substr(5), 10), + --lastFound + ); + } + run_next_test(); + }, + }); +}); + +add_test(function test8() { + do_log_info( + 'Check that "senior citizen" entries get a bonus (browser.formfill.agedBonus)' + ); + + let agedDate = + 1000 * (Date.now() - getFormExpiryDays() * 24 * 60 * 60 * 1000); + + let changes = []; + changes.push({ + op: "add", + fieldname: "field3", + value: "old but not senior", + timesUsed: 100, + firstUsed: agedDate + 60 * 1000 * 1000, + lastUsed: now, + }); + changes.push({ + op: "add", + fieldname: "field3", + value: "senior citizen", + timesUsed: 100, + firstUsed: agedDate - 60 * 1000 * 1000, + lastUsed: now, + }); + updateFormHistory(changes, run_next_test); +}); + +add_test(function test9() { + fac.autoCompleteSearchAsync("field3", "", null, null, false, { + onSearchCompletion(aResults) { + Assert.equal(aResults.getValueAt(0), "senior citizen"); + Assert.equal(aResults.getValueAt(1), "old but not senior"); + run_next_test(); + }, + }); +}); + +add_test(function test10() { + do_log_info("Check entries that are really old or in the future"); + + let changes = []; + changes.push({ + op: "add", + fieldname: "field4", + value: "date of 0", + timesUsed: 1, + firstUsed: 0, + lastUsed: 0, + }); + changes.push({ + op: "add", + fieldname: "field4", + value: "in the future 1", + timesUsed: 1, + firstUsed: 0, + lastUsed: now * 2, + }); + changes.push({ + op: "add", + fieldname: "field4", + value: "in the future 2", + timesUsed: 1, + firstUsed: now * 2, + lastUsed: now * 2, + }); + updateFormHistory(changes, run_next_test); +}); + +add_test(function test11() { + fac.autoCompleteSearchAsync("field4", "", null, null, false, { + onSearchCompletion(aResults) { + Assert.equal(aResults.matchCount, 3); + run_next_test(); + }, + }); +}); + +var syncValues = ["sync1", "sync1a", "sync2", "sync3"]; + +add_test(function test12() { + do_log_info("Check old synchronous api"); + + let changes = []; + for (let value of syncValues) { + changes.push({ op: "add", fieldname: "field5", value }); + } + updateFormHistory(changes, run_next_test); +}); + +add_test(function test_token_limit_DB() { + function test_token_limit_previousResult(previousResult) { + do_log_info( + "Check that the number of tokens used in a search is not capped to " + + "MAX_SEARCH_TOKENS when using a previousResult" + ); + // This provide more accuracy since performance is less of an issue. + // Search for a string where the first 10 tokens match the previous value but the 11th does not + // when re-using a previous result. + fac.autoCompleteSearchAsync( + "field_token_cap", + "a b c d e f g h i j .", + null, + previousResult, + false, + { + onSearchCompletion(aResults) { + Assert.equal( + aResults.matchCount, + 0, + "All search tokens should be used with previous results" + ); + run_next_test(); + }, + } + ); + } + + do_log_info( + "Check that the number of tokens used in a search is capped to MAX_SEARCH_TOKENS " + + "for performance when querying the DB" + ); + let changes = []; + changes.push({ + op: "add", + fieldname: "field_token_cap", + // value with 15 unique tokens + value: "a b c d e f g h i j k l m n o", + timesUsed: 1, + firstUsed: 0, + lastUsed: 0, + }); + updateFormHistory(changes, () => { + // Search for a string where the first 10 tokens match the value above but the 11th does not + // (which would prevent the result from being returned if the 11th term was used). + fac.autoCompleteSearchAsync( + "field_token_cap", + "a b c d e f g h i j .", + null, + null, + false, + { + onSearchCompletion(aResults) { + Assert.equal( + aResults.matchCount, + 1, + "Only the first MAX_SEARCH_TOKENS tokens " + + "should be used for DB queries" + ); + test_token_limit_previousResult(aResults); + }, + } + ); + }); +}); + +add_test(async function can_search_escape_marker() { + await promiseUpdate({ + op: "add", + fieldname: "field1", + value: "/* Further reading */ test", + timesUsed: 1, + firstUsed: now, + lastUsed: now, + }); + + fac.autoCompleteSearchAsync( + "field1", + "/* Further reading */ t", + null, + null, + false, + { + onSearchCompletion(aResults) { + Assert.equal(1, aResults.matchCount); + run_next_test(); + }, + } + ); +}); diff --git a/toolkit/components/satchel/test/unit/test_db_access_denied.js b/toolkit/components/satchel/test/unit/test_db_access_denied.js new file mode 100644 index 0000000000..99e038ea43 --- /dev/null +++ b/toolkit/components/satchel/test/unit/test_db_access_denied.js @@ -0,0 +1,52 @@ +/* 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/. */ + +var bakFile; +var dbFile; + +function run_test() { + let testfile = do_get_file("formhistory_apitest.sqlite"); + let profileDir = Services.dirsvc.get("ProfD", Ci.nsIFile); + + // Cleanup from any previous tests or failures. + let destFile = profileDir.clone(); + destFile.append("formhistory.sqlite"); + if (destFile.exists()) { + destFile.remove(false); + } + + bakFile = profileDir.clone(); + bakFile.append("formhistory.sqlite.corrupt"); + if (bakFile.exists()) { + bakFile.remove(false); + } + + dbFile = profileDir.clone(); + dbFile.append("formhistory.sqlite"); + + testfile.copyTo(profileDir, "formhistory.sqlite"); + + run_next_test(); +} + +add_test(async function initialize_database_in_readonly_results_in_db_reset() { + // original permissions are 440, now set to not readable... + dbFile.permissions = 0; + + // ...and reset them later for the next connection setup retry, which happens + // after 3 retries (10 + 20 + 40) ms + do_timeout(70, () => (dbFile.permissions = 440)); + + // this establishes a connection, the first one will fail but after a few + // retries we will have sufficiant permissions + const numEntriesAfter = await FormHistory.count({}); + + // original fixture data present + Assert.equal(9, numEntriesAfter); + + // No backup has been created + Assert.ok(!bakFile.exists(), "backup file does not exist"); + + run_next_test(); +}); diff --git a/toolkit/components/satchel/test/unit/test_db_corrupt.js b/toolkit/components/satchel/test/unit/test_db_corrupt.js new file mode 100644 index 0000000000..b53b5cd6d0 --- /dev/null +++ b/toolkit/components/satchel/test/unit/test_db_corrupt.js @@ -0,0 +1,80 @@ +/* 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/. */ + +var bakFile; + +function run_test() { + // ===== test init ===== + let testfile = do_get_file("formhistory_CORRUPT.sqlite"); + let profileDir = Services.dirsvc.get("ProfD", Ci.nsIFile); + + // Cleanup from any previous tests or failures. + let destFile = profileDir.clone(); + destFile.append("formhistory.sqlite"); + if (destFile.exists()) { + destFile.remove(false); + } + + bakFile = profileDir.clone(); + bakFile.append("formhistory.sqlite.corrupt"); + if (bakFile.exists()) { + bakFile.remove(false); + } + + testfile.copyTo(profileDir, "formhistory.sqlite"); + run_next_test(); +} + +add_test(function test_corruptFormHistoryDB_lazyCorruptInit1() { + do_log_info("ensure FormHistory backs up a corrupt DB on initialization."); + + // DB init is done lazily so the DB shouldn't be created yet. + Assert.ok(!bakFile.exists()); + // Doing any request to the DB should create it. + countEntries(null, null, run_next_test); +}); + +add_test(function test_corruptFormHistoryDB_lazyCorruptInit2() { + Assert.ok(bakFile.exists()); + bakFile.remove(false); + run_next_test(); +}); + +add_test(function test_corruptFormHistoryDB_emptyInit() { + do_log_info( + "test that FormHistory initializes an empty DB in place of corrupt DB." + ); + + (async function () { + let count = await FormHistory.count({}); + Assert.equal(count, 0); + count = await FormHistory.count({ fieldname: "name-A", value: "value-A" }); + Assert.equal(count, 0); + run_next_test(); + })().catch(error => { + do_throw("DB initialized after reading a corrupt DB file is not empty."); + }); +}); + +add_test(function test_corruptFormHistoryDB_addEntry() { + do_log_info("test adding an entry to the empty DB."); + + updateEntry("add", "name-A", "value-A", function () { + countEntries("name-A", "value-A", function (count) { + Assert.ok(count == 1); + run_next_test(); + }); + }); +}); + +add_test(function test_corruptFormHistoryDB_removeEntry() { + do_log_info("test removing an entry to the empty DB."); + + updateEntry("remove", "name-A", "value-A", function () { + countEntries("name-A", "value-A", function (count) { + Assert.ok(count == 0); + run_next_test(); + }); + }); +}); diff --git a/toolkit/components/satchel/test/unit/test_db_update_v4.js b/toolkit/components/satchel/test/unit/test_db_update_v4.js new file mode 100644 index 0000000000..8c39dd7788 --- /dev/null +++ b/toolkit/components/satchel/test/unit/test_db_update_v4.js @@ -0,0 +1,59 @@ +/* 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 { Sqlite } = ChromeUtils.importESModule( + "resource://gre/modules/Sqlite.sys.mjs" +); + +add_task(async function () { + let testnum = 0; + + try { + // ===== test init ===== + let testfile = do_get_file("formhistory_v3.sqlite"); + let profileDir = Services.dirsvc.get("ProfD", Ci.nsIFile); + + // Cleanup from any previous tests or failures. + let destFile = profileDir.clone(); + destFile.append("formhistory.sqlite"); + if (destFile.exists()) { + destFile.remove(false); + } + + testfile.copyTo(profileDir, "formhistory.sqlite"); + Assert.equal(3, await getDBVersion(testfile)); + + Assert.ok(destFile.exists()); + + // ===== 1 ===== + testnum++; + + destFile = profileDir.clone(); + destFile.append("formhistory.sqlite"); + let dbConnection = await Sqlite.openConnection({ + path: destFile.path, + sharedMemoryCache: false, + }); + + // Do something that will cause FormHistory to access and upgrade the + // database + await FormHistory.count({}); + + // check for upgraded schema. + Assert.equal(CURRENT_SCHEMA, await getDBVersion(destFile)); + + // Check that the index was added + Assert.ok(dbConnection.tableExists("moz_deleted_formhistory")); + dbConnection.close(); + + // check for upgraded schema. + Assert.equal(CURRENT_SCHEMA, await getDBVersion(destFile)); + + // check that an entry still exists + let num = await promiseCountEntries("name-A", "value-A"); + Assert.ok(num > 0); + } catch (e) { + throw new Error(`FAILED in test #${testnum} -- ${e}`); + } +}); diff --git a/toolkit/components/satchel/test/unit/test_db_update_v4b.js b/toolkit/components/satchel/test/unit/test_db_update_v4b.js new file mode 100644 index 0000000000..d3319d2956 --- /dev/null +++ b/toolkit/components/satchel/test/unit/test_db_update_v4b.js @@ -0,0 +1,49 @@ +/* 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/. */ + +add_task(async function () { + let testnum = 0; + + try { + // ===== test init ===== + let testfile = do_get_file("formhistory_v3v4.sqlite"); + let profileDir = Services.dirsvc.get("ProfD", Ci.nsIFile); + + // Cleanup from any previous tests or failures. + let destFile = profileDir.clone(); + destFile.append("formhistory.sqlite"); + if (destFile.exists()) { + destFile.remove(false); + } + + testfile.copyTo(profileDir, "formhistory.sqlite"); + Assert.equal(3, await getDBVersion(testfile)); + + // ===== 1 ===== + testnum++; + + destFile = profileDir.clone(); + destFile.append("formhistory.sqlite"); + let dbConnection = await Sqlite.openConnection({ + path: destFile.path, + sharedMemoryCache: false, + }); + + // Do something that will cause FormHistory to access and upgrade the + // database + await FormHistory.count({}); + + // check for upgraded schema. + Assert.equal(CURRENT_SCHEMA, await getDBVersion(destFile)); + + // Check that the index was added + Assert.ok(dbConnection.tableExists("moz_deleted_formhistory")); + dbConnection.close(); + + // check that an entry still exists + Assert.ok((await promiseCountEntries("name-A", "value-A")) > 0); + } catch (e) { + throw new Error(`FAILED in test #${testnum} -- ${e}`); + } +}); diff --git a/toolkit/components/satchel/test/unit/test_db_update_v5.js b/toolkit/components/satchel/test/unit/test_db_update_v5.js new file mode 100644 index 0000000000..f546d4a0f5 --- /dev/null +++ b/toolkit/components/satchel/test/unit/test_db_update_v5.js @@ -0,0 +1,29 @@ +/* Any copyright is dedicated to the Public Domain. + * https://creativecommons.org/publicdomain/zero/1.0/ */ + +add_task(async function () { + let destPath = await copyToProfile( + "formhistory_v3.sqlite", + "formhistory.sqlite" + ); + Assert.equal(3, await getDBSchemaVersion(destPath)); + + // Do something that will cause FormHistory to access and upgrade the + // database + await FormHistory.count({}); + + // check for upgraded schema. + Assert.equal(CURRENT_SCHEMA, await getDBSchemaVersion(destPath)); + + // Check that the source tables were added. + let db = await Sqlite.openConnection({ path: destPath }); + try { + Assert.ok(db.tableExists("moz_sources")); + Assert.ok(db.tableExists("moz_sources_to_history")); + } finally { + await db.close(); + } + // check that an entry still exists + let num = await promiseCountEntries("name-A", "value-A"); + Assert.ok(num > 0); +}); diff --git a/toolkit/components/satchel/test/unit/test_db_update_v999a.js b/toolkit/components/satchel/test/unit/test_db_update_v999a.js new file mode 100644 index 0000000000..f48c43c5cc --- /dev/null +++ b/toolkit/components/satchel/test/unit/test_db_update_v999a.js @@ -0,0 +1,56 @@ +/* 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 test uses a formhistory.sqlite with schema version set to 999 (a + * future version). This exercies the code that allows using a future schema + * version as long as the expected columns are present. + * + * Part A tests this when the columns do match, so the DB is used. + * Part B tests this when the columns do *not* match, so the DB is reset. + */ + +add_task(async function () { + let testnum = 0; + + try { + // ===== test init ===== + let testfile = do_get_file("formhistory_v999a.sqlite"); + let profileDir = Services.dirsvc.get("ProfD", Ci.nsIFile); + + // Cleanup from any previous tests or failures. + let destFile = profileDir.clone(); + destFile.append("formhistory.sqlite"); + if (destFile.exists()) { + destFile.remove(false); + } + + testfile.copyTo(profileDir, "formhistory.sqlite"); + Assert.equal(999, await getDBVersion(testfile)); + + // ===== 1 ===== + testnum++; + // Check for expected contents. + Assert.ok((await promiseCountEntries(null, null)) > 0); + Assert.equal(1, await promiseCountEntries("name-A", "value-A")); + Assert.equal(1, await promiseCountEntries("name-B", "value-B")); + Assert.equal(1, await promiseCountEntries("name-C", "value-C1")); + Assert.equal(1, await promiseCountEntries("name-C", "value-C2")); + Assert.equal(1, await promiseCountEntries("name-E", "value-E")); + + // check for downgraded schema. + Assert.equal(CURRENT_SCHEMA, await getDBVersion(destFile)); + + // ===== 2 ===== + testnum++; + // Exercise adding and removing a name/value pair + Assert.equal(0, await promiseCountEntries("name-D", "value-D")); + await promiseUpdateEntry("add", "name-D", "value-D"); + Assert.equal(1, await promiseCountEntries("name-D", "value-D")); + await promiseUpdateEntry("remove", "name-D", "value-D"); + Assert.equal(0, await promiseCountEntries("name-D", "value-D")); + } catch (e) { + throw new Error(`FAILED in test #${testnum} -- ${e}`); + } +}); diff --git a/toolkit/components/satchel/test/unit/test_db_update_v999b.js b/toolkit/components/satchel/test/unit/test_db_update_v999b.js new file mode 100644 index 0000000000..3424411343 --- /dev/null +++ b/toolkit/components/satchel/test/unit/test_db_update_v999b.js @@ -0,0 +1,74 @@ +/* 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 test uses a formhistory.sqlite with schema version set to 999 (a + * future version). This exercies the code that allows using a future schema + * version as long as the expected columns are present. + * + * Part A tests this when the columns do match, so the DB is used. + * Part B tests this when the columns do *not* match, so the DB is reset. + */ + +add_task(async function () { + let testnum = 0; + + try { + // ===== test init ===== + let testfile = do_get_file("formhistory_v999b.sqlite"); + let profileDir = Services.dirsvc.get("ProfD", Ci.nsIFile); + + // Cleanup from any previous tests or failures. + let destFile = profileDir.clone(); + destFile.append("formhistory.sqlite"); + if (destFile.exists()) { + destFile.remove(false); + } + + let bakFile = profileDir.clone(); + bakFile.append("formhistory.sqlite.corrupt"); + if (bakFile.exists()) { + bakFile.remove(false); + } + + testfile.copyTo(profileDir, "formhistory.sqlite"); + Assert.equal(999, await getDBVersion(destFile)); + + // ===== 1 ===== + testnum++; + + // Open the DB, ensure that a backup of the corrupt DB is made. + // DB init is done lazily so the DB shouldn't be created yet. + Assert.ok(!bakFile.exists()); + // Doing any request to the DB should create it. + await promiseCountEntries("", ""); + + Assert.ok(bakFile.exists()); + bakFile.remove(false); + + // ===== 2 ===== + testnum++; + // File should be empty + Assert.ok(!(await promiseCountEntries(null, null))); + Assert.equal(0, await promiseCountEntries("name-A", "value-A")); + // check for current schema. + Assert.equal(CURRENT_SCHEMA, await getDBVersion(destFile)); + + // ===== 3 ===== + testnum++; + // Try adding an entry + await promiseUpdateEntry("add", "name-A", "value-A"); + Assert.equal(1, await promiseCountEntries(null, null)); + Assert.equal(1, await promiseCountEntries("name-A", "value-A")); + + // ===== 4 ===== + testnum++; + // Try removing an entry + await promiseUpdateEntry("remove", "name-A", "value-A"); + Assert.equal(0, await promiseCountEntries(null, null)); + Assert.equal(0, await promiseCountEntries("name-A", "value-A")); + } catch (e) { + throw new Error(`FAILED in test #${testnum} -- ${e}`); + } +}); diff --git a/toolkit/components/satchel/test/unit/test_history_api.js b/toolkit/components/satchel/test/unit/test_history_api.js new file mode 100644 index 0000000000..91da24696c --- /dev/null +++ b/toolkit/components/satchel/test/unit/test_history_api.js @@ -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/. */ + +var testnum = 0; +var dbConnection; // used for deleted table tests + +async function countDeletedEntries(expected) { + let stmt = "SELECT COUNT(*) AS numEntries FROM moz_deleted_formhistory"; + try { + let requiredRow = await dbConnection.executeCached(stmt); + Assert.equal(expected, requiredRow[0].getResultByName("numEntries")); + } catch (error) { + do_throw("Error occurred counting deleted entries: " + error); + } +} + +async function checkTimeDeleted(guid, checkFunction) { + let stmt = + "SELECT timeDeleted FROM moz_deleted_formhistory WHERE guid = :guid"; + let params = { guid }; + + try { + let requiredRow = await dbConnection.executeCached(stmt, params); + checkFunction(requiredRow[0].getResultByName("timeDeleted")); + } catch (error) { + do_throw("Error occurred getting deleted entries: " + error); + } +} + +function promiseUpdateEntry(op, name, value) { + let change = { op }; + if (name !== null) { + change.fieldname = name; + } + if (value !== null) { + change.value = value; + } + return promiseUpdate(change); +} + +add_task(async function () { + let oldSupportsDeletedTable = FormHistory._supportsDeletedTable; + FormHistory._supportsDeletedTable = true; + + try { + // ===== test init ===== + let testfile = do_get_file("formhistory_apitest.sqlite"); + let profileDir = Services.dirsvc.get("ProfD", Ci.nsIFile); + + // Cleanup from any previous tests or failures. + let destFile = profileDir.clone(); + destFile.append("formhistory.sqlite"); + if (destFile.exists()) { + destFile.remove(false); + } + + testfile.copyTo(profileDir, "formhistory.sqlite"); + + function checkExists(num) { + Assert.ok(num > 0); + } + function checkNotExists(num) { + Assert.ok(num == 0); + } + + // ===== 1 ===== + // Check initial state is as expected + testnum++; + await promiseCountEntries("name-A", null, checkExists); + await promiseCountEntries("name-B", null, checkExists); + await promiseCountEntries("name-C", null, checkExists); + await promiseCountEntries("name-D", null, checkExists); + await promiseCountEntries("name-A", "value-A", checkExists); + await promiseCountEntries("name-B", "value-B1", checkExists); + await promiseCountEntries("name-B", "value-B2", checkExists); + await promiseCountEntries("name-C", "value-C", checkExists); + await promiseCountEntries("name-D", "value-D", checkExists); + // time-A/B/C/D checked below. + + // Delete anything from the deleted table + let dbFile = Services.dirsvc.get("ProfD", Ci.nsIFile).clone(); + dbFile.append("formhistory.sqlite"); + + dbConnection = await Sqlite.openConnection({ + path: dbFile.path, + sharedMemoryCache: false, + }); + + let stmt = "DELETE FROM moz_deleted_formhistory"; + try { + await dbConnection.executeCached(stmt); + } catch (error) { + do_throw("Error occurred counting deleted all entries: " + error); + } + + // ===== 2 ===== + // Test looking for nonexistent / bogus data. + testnum++; + await promiseCountEntries("blah", null, checkNotExists); + await promiseCountEntries("", null, checkNotExists); + await promiseCountEntries("name-A", "blah", checkNotExists); + await promiseCountEntries("name-A", "", checkNotExists); + await promiseCountEntries("name-A", null, checkExists); + await promiseCountEntries("blah", "value-A", checkNotExists); + await promiseCountEntries("", "value-A", checkNotExists); + await promiseCountEntries(null, "value-A", checkExists); + + // Cannot use promiseCountEntries when name and value are null + // because it treats null values as not set + // and here a search should be done explicity for null. + let count = await FormHistory.count({ fieldname: null, value: null }); + checkNotExists(count); + + // ===== 3 ===== + // Test removeEntriesForName with a single matching value + testnum++; + await promiseUpdateEntry("remove", "name-A", null); + + await promiseCountEntries("name-A", "value-A", checkNotExists); + await promiseCountEntries("name-B", "value-B1", checkExists); + await promiseCountEntries("name-B", "value-B2", checkExists); + await promiseCountEntries("name-C", "value-C", checkExists); + await promiseCountEntries("name-D", "value-D", checkExists); + await countDeletedEntries(1); + + // ===== 4 ===== + // Test removeEntriesForName with multiple matching values + testnum++; + await promiseUpdateEntry("remove", "name-B", null); + + await promiseCountEntries("name-A", "value-A", checkNotExists); + await promiseCountEntries("name-B", "value-B1", checkNotExists); + await promiseCountEntries("name-B", "value-B2", checkNotExists); + await promiseCountEntries("name-C", "value-C", checkExists); + await promiseCountEntries("name-D", "value-D", checkExists); + await countDeletedEntries(3); + + // ===== 5 ===== + // Test removing by time range (single entry, not surrounding entries) + testnum++; + await promiseCountEntries("time-A", null, checkExists); // firstUsed=1000, lastUsed=1000 + await promiseCountEntries("time-B", null, checkExists); // firstUsed=1000, lastUsed=1099 + await promiseCountEntries("time-C", null, checkExists); // firstUsed=1099, lastUsed=1099 + await promiseCountEntries("time-D", null, checkExists); // firstUsed=2001, lastUsed=2001 + await promiseUpdate({ + op: "remove", + firstUsedStart: 1050, + firstUsedEnd: 2000, + }); + + await promiseCountEntries("time-A", null, checkExists); + await promiseCountEntries("time-B", null, checkExists); + await promiseCountEntries("time-C", null, checkNotExists); + await promiseCountEntries("time-D", null, checkExists); + await countDeletedEntries(4); + + // ===== 6 ===== + // Test removing by time range (multiple entries) + testnum++; + await promiseUpdate({ + op: "remove", + firstUsedStart: 1000, + firstUsedEnd: 2000, + }); + + await promiseCountEntries("time-A", null, checkNotExists); + await promiseCountEntries("time-B", null, checkNotExists); + await promiseCountEntries("time-C", null, checkNotExists); + await promiseCountEntries("time-D", null, checkExists); + await countDeletedEntries(6); + + // ===== 7 ===== + // test removeAllEntries + testnum++; + await promiseUpdateEntry("remove", null, null); + + await promiseCountEntries("name-C", null, checkNotExists); + await promiseCountEntries("name-D", null, checkNotExists); + await promiseCountEntries("name-C", "value-C", checkNotExists); + await promiseCountEntries("name-D", "value-D", checkNotExists); + + await promiseCountEntries(null, null, checkNotExists); + await countDeletedEntries(6); + + // ===== 8 ===== + // Add a single entry back + testnum++; + await promiseUpdateEntry("add", "newname-A", "newvalue-A"); + await promiseCountEntries("newname-A", "newvalue-A", checkExists); + + // ===== 9 ===== + // Remove the single entry + testnum++; + await promiseUpdateEntry("remove", "newname-A", "newvalue-A"); + await promiseCountEntries("newname-A", "newvalue-A", checkNotExists); + + // ===== 10 ===== + // Add a single entry + testnum++; + await promiseUpdateEntry("add", "field1", "value1"); + await promiseCountEntries("field1", "value1", checkExists); + + let processFirstResult = function processResults(results) { + // Only handle the first result + if (results.length) { + let result = results[0]; + return [ + result.timesUsed, + result.firstUsed, + result.lastUsed, + result.guid, + ]; + } + return undefined; + }; + + let results = await FormHistory.search( + ["timesUsed", "firstUsed", "lastUsed"], + { fieldname: "field1", value: "value1" } + ); + let [timesUsed, firstUsed, lastUsed] = processFirstResult(results); + Assert.equal(1, timesUsed); + Assert.ok(firstUsed > 0); + Assert.ok(lastUsed > 0); + await promiseCountEntries(null, null, num => Assert.equal(num, 1)); + + // ===== 11 ===== + // Add another single entry + testnum++; + await promiseUpdateEntry("add", "field1", "value1b"); + await promiseCountEntries("field1", "value1", checkExists); + await promiseCountEntries("field1", "value1b", checkExists); + await promiseCountEntries(null, null, num => Assert.equal(num, 2)); + + // ===== 12 ===== + // Update a single entry + testnum++; + + results = await FormHistory.search(["guid"], { + fieldname: "field1", + value: "value1", + }); + let guid = processFirstResult(results)[3]; + + await promiseUpdate({ op: "update", guid, value: "modifiedValue" }); + await promiseCountEntries("field1", "modifiedValue", checkExists); + await promiseCountEntries("field1", "value1", checkNotExists); + await promiseCountEntries("field1", "value1b", checkExists); + await promiseCountEntries(null, null, num => Assert.equal(num, 2)); + + // ===== 13 ===== + // Add a single entry with times + testnum++; + await promiseUpdate({ + op: "add", + fieldname: "field2", + value: "value2", + timesUsed: 20, + firstUsed: 100, + lastUsed: 500, + }); + + results = await FormHistory.search(["timesUsed", "firstUsed", "lastUsed"], { + fieldname: "field2", + value: "value2", + }); + [timesUsed, firstUsed, lastUsed] = processFirstResult(results); + + Assert.equal(20, timesUsed); + Assert.equal(100, firstUsed); + Assert.equal(500, lastUsed); + await promiseCountEntries(null, null, num => Assert.equal(num, 3)); + + // ===== 14 ===== + // Bump an entry, which updates its lastUsed field + testnum++; + await promiseUpdate({ + op: "bump", + fieldname: "field2", + value: "value2", + timesUsed: 20, + firstUsed: 100, + lastUsed: 500, + }); + results = await FormHistory.search(["timesUsed", "firstUsed", "lastUsed"], { + fieldname: "field2", + value: "value2", + }); + [timesUsed, firstUsed, lastUsed] = processFirstResult(results); + Assert.equal(21, timesUsed); + Assert.equal(100, firstUsed); + Assert.ok(lastUsed > 500); + await promiseCountEntries(null, null, num => Assert.equal(num, 3)); + + // ===== 15 ===== + // Bump an entry that does not exist + testnum++; + await promiseUpdate({ + op: "bump", + fieldname: "field3", + value: "value3", + timesUsed: 10, + firstUsed: 50, + lastUsed: 400, + }); + results = await FormHistory.search(["timesUsed", "firstUsed", "lastUsed"], { + fieldname: "field3", + value: "value3", + }); + [timesUsed, firstUsed, lastUsed] = processFirstResult(results); + Assert.equal(10, timesUsed); + Assert.equal(50, firstUsed); + Assert.equal(400, lastUsed); + await promiseCountEntries(null, null, num => Assert.equal(num, 4)); + + // ===== 16 ===== + // Bump an entry with a guid + testnum++; + results = await FormHistory.search(["guid"], { + fieldname: "field3", + value: "value3", + }); + guid = processFirstResult(results)[3]; + await promiseUpdate({ + op: "bump", + guid, + timesUsed: 20, + firstUsed: 55, + lastUsed: 400, + }); + results = await FormHistory.search(["timesUsed", "firstUsed", "lastUsed"], { + fieldname: "field3", + value: "value3", + }); + [timesUsed, firstUsed, lastUsed] = processFirstResult(results); + Assert.equal(11, timesUsed); + Assert.equal(50, firstUsed); + Assert.ok(lastUsed > 400); + await promiseCountEntries(null, null, num => Assert.equal(num, 4)); + + // ===== 17 ===== + // Remove an entry + testnum++; + await countDeletedEntries(7); + + results = await FormHistory.search(["guid"], { + fieldname: "field1", + value: "value1b", + }); + guid = processFirstResult(results)[3]; + + await promiseUpdate({ op: "remove", guid }); + await promiseCountEntries("field1", "modifiedValue", checkExists); + await promiseCountEntries("field1", "value1b", checkNotExists); + await promiseCountEntries(null, null, num => Assert.equal(num, 3)); + + await countDeletedEntries(8); + await checkTimeDeleted(guid, timeDeleted => Assert.ok(timeDeleted > 10000)); + + // ===== 18 ===== + // Add yet another single entry + testnum++; + await promiseUpdate({ + op: "add", + fieldname: "field4", + value: "value4", + timesUsed: 5, + firstUsed: 230, + lastUsed: 600, + }); + await promiseCountEntries(null, null, num => Assert.equal(num, 4)); + + // ===== 19 ===== + // Remove an entry by time + testnum++; + await promiseUpdate({ + op: "remove", + firstUsedStart: 60, + firstUsedEnd: 250, + }); + await promiseCountEntries("field1", "modifiedValue", checkExists); + await promiseCountEntries("field2", "value2", checkNotExists); + await promiseCountEntries("field3", "value3", checkExists); + await promiseCountEntries("field4", "value4", checkNotExists); + await promiseCountEntries(null, null, num => Assert.equal(num, 2)); + await countDeletedEntries(10); + + // ===== 20 ===== + // Bump multiple existing entries at once + testnum++; + + await promiseUpdate([ + { + op: "add", + fieldname: "field5", + value: "value5", + timesUsed: 5, + firstUsed: 230, + lastUsed: 600, + }, + { + op: "add", + fieldname: "field6", + value: "value6", + timesUsed: 12, + firstUsed: 430, + lastUsed: 700, + }, + ]); + await promiseCountEntries(null, null, num => Assert.equal(num, 4)); + + await promiseUpdate([ + { op: "bump", fieldname: "field5", value: "value5" }, + { op: "bump", fieldname: "field6", value: "value6" }, + ]); + results = await FormHistory.search( + ["fieldname", "timesUsed", "firstUsed", "lastUsed"], + {} + ); + + Assert.equal(6, results[2].timesUsed); + Assert.equal(13, results[3].timesUsed); + Assert.equal(230, results[2].firstUsed); + Assert.equal(430, results[3].firstUsed); + Assert.ok(results[2].lastUsed > 600); + Assert.ok(results[3].lastUsed > 700); + + await promiseCountEntries(null, null, num => Assert.equal(num, 4)); + + // ===== 21 ===== + // Check update fails if form history is disabled and the operation is not a + // pure removal. + testnum++; + Services.prefs.setBoolPref("browser.formfill.enable", false); + + // Cannot use arrow functions, see bug 1237961. + await Assert.rejects( + promiseUpdate({ op: "bump", fieldname: "field5", value: "value5" }), + /Form history is disabled, only remove operations are allowed/, + "bumping when form history is disabled should fail" + ); + await Assert.rejects( + promiseUpdate({ op: "add", fieldname: "field5", value: "value5" }), + /Form history is disabled, only remove operations are allowed/, + "Adding when form history is disabled should fail" + ); + await Assert.rejects( + promiseUpdate([ + { op: "update", fieldname: "field5", value: "value5" }, + { op: "remove", fieldname: "field5", value: "value5" }, + ]), + /Form history is disabled, only remove operations are allowed/, + "mixed operations when form history is disabled should fail" + ); + await Assert.rejects( + promiseUpdate([ + null, + undefined, + "", + 1, + {}, + { op: "remove", fieldname: "field5", value: "value5" }, + ]), + /Form history is disabled, only remove operations are allowed/, + "Invalid entries when form history is disabled should fail" + ); + + // Remove should work though. + await promiseUpdate([ + { op: "remove", fieldname: "field5", value: null }, + { op: "remove", fieldname: null, value: null }, + ]); + Services.prefs.clearUserPref("browser.formfill.enable"); + } catch (e) { + throw new Error(`FAILED in test #${testnum} -- ${e}`); + } finally { + FormHistory._supportsDeletedTable = oldSupportsDeletedTable; + await dbConnection.close(do_test_finished); + } +}); + +function run_test() { + return run_next_test(); +} diff --git a/toolkit/components/satchel/test/unit/test_history_sources.js b/toolkit/components/satchel/test/unit/test_history_sources.js new file mode 100644 index 0000000000..a598a457be --- /dev/null +++ b/toolkit/components/satchel/test/unit/test_history_sources.js @@ -0,0 +1,88 @@ +/* Any copyright is dedicated to the Public Domain. + * https://creativecommons.org/publicdomain/zero/1.0/ */ + +// Tests source usage in the form history API. + +add_task(async function () { + // Shorthands to improve test readability. + const count = FormHistoryTestUtils.count.bind(FormHistoryTestUtils); + async function search(fieldname, filters) { + let results1 = (await FormHistoryTestUtils.search(fieldname, filters)).map( + f => f.value + ); + // Check autocomplete returns the same value. + let results2 = ( + await FormHistoryTestUtils.autocomplete("va", fieldname, filters) + ).map(f => f.text); + Assert.deepEqual(results1, results2); + return results1; + } + + info("Sanity checks"); + Assert.equal(await count("field-A"), 0); + Assert.equal(await count("field-B"), 0); + await FormHistoryTestUtils.add("field-A", [{ value: "value-A" }]); + Assert.equal(await FormHistoryTestUtils.count("field-A"), 1); + Assert.deepEqual(await search("field-A"), ["value-A"]); + await FormHistoryTestUtils.remove("field-A", [{ value: "value-A" }]); + Assert.equal(await count("field-A"), 0); + + info("Test source for field-A"); + await FormHistoryTestUtils.add("field-A", [ + { value: "value-A", source: "test" }, + ]); + Assert.equal(await count("field-A"), 1); + Assert.deepEqual(await search("field-A"), ["value-A"]); + Assert.equal(await count("field-A", { source: "test" }), 1); + Assert.deepEqual(await search("field-A", { source: "test" }), ["value-A"]); + Assert.equal(await count("field-A", { source: "test2" }), 0); + Assert.deepEqual(await search("field-A", { source: "test2" }), []); + + info("Test source for field-B"); + await FormHistoryTestUtils.add("field-B", [ + { value: "value-B", source: "test" }, + ]); + Assert.equal(await count("field-B", { source: "test" }), 1); + Assert.equal(await count("field-B", { source: "test2" }), 0); + + info("Remove source"); + await FormHistoryTestUtils.add("field-B", [ + { value: "value-B", source: "test2" }, + ]); + Assert.equal(await count("field-B", { source: "test2" }), 1); + Assert.deepEqual(await search("field-B", { source: "test2" }), ["value-B"]); + await FormHistoryTestUtils.remove("field-B", [{ source: "test2" }]); + Assert.equal(await count("field-B", { source: "test2" }), 0); + Assert.deepEqual(await search("field-B", { source: "test2" }), []); + Assert.equal(await count("field-A"), 1); + Assert.deepEqual(await search("field-A"), ["value-A"]); + Assert.deepEqual(await search("field-A", { source: "test" }), ["value-A"]); + info("The other source should be untouched"); + Assert.equal(await count("field-B", { source: "test" }), 1); + Assert.deepEqual(await search("field-B", { source: "test" }), ["value-B"]); + Assert.equal(await count("field-B"), 1); + Assert.deepEqual(await search("field-B"), ["value-B"]); + + info("Clear field-A"); + await FormHistoryTestUtils.clear("field-A"); + Assert.equal(await count("field-A", { source: "test" }), 0); + Assert.equal(await count("field-A", { source: "test2" }), 0); + Assert.equal(await count("field-A"), 0); + Assert.equal(await count("field-B", { source: "test" }), 1); + Assert.equal(await count("field-B", { source: "test2" }), 0); + Assert.equal(await count("field-B"), 1); + + info("Clear All"); + await FormHistoryTestUtils.clear(); + Assert.equal(await count("field-B", { source: "test" }), 0); + Assert.equal(await count("field-B"), 0); + Assert.deepEqual(await search("field-A"), []); + Assert.deepEqual(await search("field-A", { source: "test" }), []); + Assert.deepEqual(await search("field-B"), []); + Assert.deepEqual(await search("field-B", { source: "test" }), []); + + info("Check there's no orphan sources"); + let db = await FormHistory.db; + let rows = await db.execute(`SELECT count(*) FROM moz_sources`); + Assert.equal(rows[0].getResultByIndex(0), 0, "There should be no orphans"); +}); diff --git a/toolkit/components/satchel/test/unit/test_notify.js b/toolkit/components/satchel/test/unit/test_notify.js new file mode 100644 index 0000000000..94be56f3f5 --- /dev/null +++ b/toolkit/components/satchel/test/unit/test_notify.js @@ -0,0 +1,171 @@ +/* + * Test suite for satchel notifications + * + * Tests notifications dispatched when modifying form history. + * + */ + +ChromeUtils.defineESModuleGetters(this, { + Preferences: "resource://gre/modules/Preferences.sys.mjs", + setTimeout: "resource://gre/modules/Timer.sys.mjs", +}); + +const TestObserver = { + observed: [], + QueryInterface: ChromeUtils.generateQI([ + "nsIObserver", + "nsISupportsWeakReference", + ]), + observe(subject, topic, data) { + if (subject instanceof Ci.nsISupportsString) { + subject = subject.toString(); + } + this.observed.push({ subject, topic, data }); + }, + reset() { + this.observed = []; + }, +}; + +const entry1 = ["entry1", "value1"]; +const entry2 = ["entry2", "value2"]; +const entry3 = ["entry3", "value3"]; + +add_setup(async () => { + await promiseUpdateEntry("remove", null, null); + const count = await promiseCountEntries(null, null); + Assert.ok(!count, "Checking initial DB is empty"); + + // Add the observer + Services.obs.addObserver(TestObserver, "satchel-storage-changed"); +}); + +add_task(async function addAndUpdateEntry() { + // Add + await promiseUpdateEntry("add", entry1[0], entry1[1]); + Assert.equal(TestObserver.observed.length, 1); + let { subject, data } = TestObserver.observed[0]; + Assert.equal(data, "formhistory-add"); + Assert.ok(isGUID.test(subject)); + + let count = await promiseCountEntries(entry1[0], entry1[1]); + Assert.equal(count, 1); + + // Update + TestObserver.reset(); + + await promiseUpdateEntry("update", entry1[0], entry1[1]); + Assert.equal(TestObserver.observed.length, 1); + ({ subject, data } = TestObserver.observed[0]); + Assert.equal(data, "formhistory-update"); + Assert.ok(isGUID.test(subject)); + + count = await promiseCountEntries(entry1[0], entry1[1]); + Assert.equal(count, 1); + + // Clean-up + await promiseUpdateEntry("remove", null, null); +}); + +add_task(async function removeEntry() { + TestObserver.reset(); + await promiseUpdateEntry("add", entry1[0], entry1[1]); + const guid = TestObserver.observed[0].subject; + TestObserver.reset(); + + await FormHistory.update({ + op: "remove", + fieldname: entry1[0], + value: entry1[1], + guid, + }); + Assert.equal(TestObserver.observed.length, 1); + const { subject, data } = TestObserver.observed[0]; + Assert.equal(data, "formhistory-remove"); + Assert.ok(isGUID.test(subject)); + + const count = await promiseCountEntries(entry1[0], entry1[1]); + Assert.equal(count, 0, "doesn't exist after remove"); +}); + +add_task(async function removeAllEntries() { + await promiseAddEntry(entry1[0], entry1[1]); + await promiseAddEntry(entry2[0], entry2[1]); + await promiseAddEntry(entry3[0], entry3[1]); + TestObserver.reset(); + + await promiseUpdateEntry("remove", null, null); + Assert.equal(TestObserver.observed.length, 3); + for (const notification of TestObserver.observed) { + const { subject, data } = notification; + Assert.equal(data, "formhistory-remove"); + Assert.ok(isGUID.test(subject)); + } + + const count = await promiseCountEntries(null, null); + Assert.equal(count, 0); +}); + +add_task(async function removeEntriesForName() { + await promiseAddEntry(entry1[0], entry1[1]); + await promiseAddEntry(entry2[0], entry2[1]); + await promiseAddEntry(entry3[0], entry3[1]); + TestObserver.reset(); + + await promiseUpdateEntry("remove", entry2[0], null); + Assert.equal(TestObserver.observed.length, 1); + const { subject, data } = TestObserver.observed[0]; + Assert.equal(data, "formhistory-remove"); + Assert.ok(isGUID.test(subject)); + + let count = await promiseCountEntries(entry2[0], entry2[1]); + Assert.equal(count, 0); + + count = await promiseCountEntries(null, null); + Assert.equal(count, 2, "the other entries are still there"); + + // Clean-up + await promiseUpdateEntry("remove", null, null); +}); + +add_task(async function removeEntriesByTimeframe() { + let timerPrecision = Preferences.get("privacy.reduceTimerPrecision"); + Preferences.set("privacy.reduceTimerPrecision", false); + + registerCleanupFunction(function () { + Preferences.set("privacy.reduceTimerPrecision", timerPrecision); + }); + + await promiseAddEntry(entry1[0], entry1[1]); + await promiseAddEntry(entry2[0], entry2[1]); + + const cutoffDate = Date.now(); + // eslint-disable-next-line mozilla/no-arbitrary-setTimeout + await new Promise(res => setTimeout(res, 10)); + + await promiseAddEntry(entry3[0], entry3[1]); + TestObserver.reset(); + + await FormHistory.update({ + op: "remove", + firstUsedStart: 10, + firstUsedEnd: cutoffDate * 1000, + }); + Assert.equal(TestObserver.observed.length, 2); + for (const notification of TestObserver.observed) { + const { subject, data } = notification; + Assert.equal(data, "formhistory-remove"); + Assert.ok(isGUID.test(subject)); + } + + const count = await promiseCountEntries(null, null); + Assert.equal(count, 1, "entry2 should still be there"); + + // Clean-up + await promiseUpdateEntry("remove", null, null); +}); + +add_task(async function teardown() { + await promiseUpdateEntry("remove", null, null); + Services.obs.removeObserver(TestObserver, "satchel-storage-changed"); +}); diff --git a/toolkit/components/satchel/test/unit/test_previous_result.js b/toolkit/components/satchel/test/unit/test_previous_result.js new file mode 100644 index 0000000000..a782832db7 --- /dev/null +++ b/toolkit/components/satchel/test/unit/test_previous_result.js @@ -0,0 +1,25 @@ +/* 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/. */ + +var aaaListener = { + onSearchResult(search, result) { + Assert.equal(result.searchString, "aaa"); + do_test_finished(); + }, +}; + +var aaListener = { + onSearchResult(search, result) { + Assert.equal(result.searchString, "aa"); + search.startSearch("aaa", "", result, aaaListener); + }, +}; + +function run_test() { + do_test_pending(); + let search = Cc[ + "@mozilla.org/autocomplete/search;1?name=form-history" + ].getService(Ci.nsIAutoCompleteSearch); + search.startSearch("aa", "", null, aaListener); +} diff --git a/toolkit/components/satchel/test/unit/xpcshell.toml b/toolkit/components/satchel/test/unit/xpcshell.toml new file mode 100644 index 0000000000..68a379e74d --- /dev/null +++ b/toolkit/components/satchel/test/unit/xpcshell.toml @@ -0,0 +1,43 @@ +[DEFAULT] +head = "head_satchel.js" +tags = "condprof" +skip-if = ["os == 'android'"] +support-files = [ + "asyncformhistory_expire.sqlite", + "formhistory_1000.sqlite", + "formhistory_CORRUPT.sqlite", + "formhistory_apitest.sqlite", + "formhistory_autocomplete.sqlite", + "formhistory_v3.sqlite", + "formhistory_v3v4.sqlite", + "formhistory_v999a.sqlite", + "formhistory_v999b.sqlite", +] + +["test_async_expire.js"] + +["test_autocomplete.js"] + +["test_db_access_denied.js"] +skip-if = ["os != 'linux'"] # simulates insufficiant file permissions + +["test_db_corrupt.js"] + +["test_db_update_v4.js"] + +["test_db_update_v4b.js"] + +["test_db_update_v5.js"] +skip-if = ["condprof"] # Bug 1769154 - not supported + +["test_db_update_v999a.js"] + +["test_db_update_v999b.js"] + +["test_history_api.js"] + +["test_history_sources.js"] + +["test_notify.js"] + +["test_previous_result.js"] diff --git a/toolkit/components/satchel/towel b/toolkit/components/satchel/towel new file mode 100644 index 0000000000..c26c7a8b28 --- /dev/null +++ b/toolkit/components/satchel/towel @@ -0,0 +1,5 @@ +"Any man who can hitch the length and breadth of the galaxy, rough it, +slum it, struggle against terrible odds, win through, and still knows +where his towel is is clearly a man to be reckoned with." + + - Douglas Adams -- cgit v1.2.3